auth.go

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

Source Files