auth.go

v1.3.3
Doc Versions Source
1
package admin
2
3
import (
4
	"crypto/subtle"
5
	"net/http"
6
	"strings"
7
8
	"go.bigb.es/curator/internal/oidcauth"
9
)
10
11
// AuthMiddleware protects admin routes with admin token and/or OIDC.
12
// Supports Bearer token in Authorization header, cookie-based sessions,
13
// and OIDC session cookies.
14
func AuthMiddleware(adminToken string, oidcProvider *oidcauth.Provider, next http.Handler) http.Handler {
15
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16
		if adminToken == "" && oidcProvider == nil {
17
			http.Error(w, "admin access not configured", http.StatusForbidden)
18
			return
19
		}
20
21
		// Check Authorization: Bearer header (admin token).
22
		if adminToken != "" {
23
			if auth := r.Header.Get("Authorization"); auth != "" {
24
				if token, ok := strings.CutPrefix(auth, "Bearer "); ok {
25
					if subtle.ConstantTimeCompare([]byte(token), []byte(adminToken)) == 1 {
26
						next.ServeHTTP(w, r)
27
						return
28
					}
29
				}
30
			}
31
		}
32
33
		// Check curator_admin cookie (admin token session).
34
		if adminToken != "" {
35
			if cookie, err := r.Cookie("curator_admin"); err == nil {
36
				if subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(adminToken)) == 1 {
37
					next.ServeHTTP(w, r)
38
					return
39
				}
40
			}
41
		}
42
43
		// Check curator_session cookie (OIDC session).
44
		if oidcProvider != nil {
45
			if cookie, err := r.Cookie("curator_session"); err == nil {
46
				if email := oidcProvider.ValidateSession(cookie.Value); email != "" {
47
					next.ServeHTTP(w, r)
48
					return
49
				}
50
			}
51
		}
52
53
		// Handle login page.
54
		if r.URL.Path == "/-/admin/login" {
55
			// If only OIDC is configured (no admin_token), redirect straight to OIDC.
56
			if adminToken == "" && oidcProvider != nil {
57
				http.Redirect(w, r, "/-/oidc/login", http.StatusSeeOther)
58
				return
59
			}
60
61
			if adminToken != "" && r.Method == http.MethodPost {
62
				token := r.FormValue("token")
63
				if subtle.ConstantTimeCompare([]byte(token), []byte(adminToken)) == 1 {
64
					http.SetCookie(w, &http.Cookie{
65
						Name:     "curator_admin",
66
						Value:    adminToken,
67
						Path:     "/-/",
68
						HttpOnly: true,
69
						SameSite: http.SameSiteStrictMode,
70
					})
71
					http.Redirect(w, r, "/-/admin/", http.StatusSeeOther)
72
					return
73
				}
74
				w.WriteHeader(http.StatusUnauthorized)
75
				renderLogin(w, "Invalid token", oidcProvider != nil)
76
				return
77
			}
78
79
			renderLogin(w, "", oidcProvider != nil)
80
			return
81
		}
82
83
		// Redirect to login for browser requests.
84
		if strings.HasPrefix(r.URL.Path, "/-/admin/") {
85
			// If only OIDC configured, go straight to OIDC.
86
			if adminToken == "" && oidcProvider != nil {
87
				http.Redirect(w, r, "/-/oidc/login", http.StatusSeeOther)
88
				return
89
			}
90
			http.Redirect(w, r, "/-/admin/login", http.StatusSeeOther)
91
			return
92
		}
93
94
		// API requests get 401.
95
		w.Header().Set("WWW-Authenticate", `Bearer realm="curator admin"`)
96
		http.Error(w, "unauthorized", http.StatusUnauthorized)
97
	})
98
}
99
100
func renderLogin(w http.ResponseWriter, errMsg string, hasOIDC bool) {
101
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
102
	page := `<!DOCTYPE html>
103
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
104
<title>Curator Admin Login</title>
105
<style>
106
:root{--bg:#fff;--text:#202124;--border:#dadce0;--accent:#1a73e8;--err:#d93025}
107
@media(prefers-color-scheme:dark){:root{--bg:#1e1e1e;--text:#d4d4d4;--border:#404040;--accent:#6cb6ff;--err:#f28b82}}
108
*{box-sizing:border-box;margin:0;padding:0}
109
body{font-family:system-ui,sans-serif;background:var(--bg);color:var(--text);display:flex;justify-content:center;align-items:center;min-height:100vh}
110
.login{border:1px solid var(--border);border-radius:8px;padding:32px;max-width:360px;width:100%}
111
h1{font-size:20px;margin-bottom:16px}
112
input{display:block;width:100%;padding:10px 12px;border:1px solid var(--border);border-radius:4px;font-size:14px;margin-bottom:16px;background:var(--bg);color:var(--text)}
113
button{background:var(--accent);color:#fff;border:none;padding:10px 24px;border-radius:4px;font-size:14px;cursor:pointer;width:100%}
114
button:hover{opacity:0.9}
115
.err{color:var(--err);font-size:13px;margin-bottom:12px}
116
.divider{text-align:center;color:var(--border);margin:16px 0;font-size:13px}
117
.sso-btn{background:transparent;color:var(--accent);border:1px solid var(--accent);margin-top:0}
118
.sso-btn:hover{background:var(--accent);color:#fff}
119
</style></head><body>
120
<div class="login"><h1>Curator Admin</h1>`
121
122
	if errMsg != "" {
123
		page += `<p class="err">` + errMsg + `</p>`
124
	}
125
126
	page += `<form method="POST" action="/-/admin/login">
127
<input type="password" name="token" placeholder="Admin token" autofocus required>
128
<button type="submit">Login</button>
129
</form>`
130
131
	if hasOIDC {
132
		page += `<div class="divider">— or —</div>
133
<a href="/-/oidc/login"><button type="button" class="sso-btn">Login with SSO</button></a>`
134
	}
135
136
	page += `</div></body></html>`
137
	w.Write([]byte(page))
138
}
139

Source Files