auth.go

v1.4.3
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
				oidcURL := "/-/oidc/login"
59
				if ret := r.URL.Query().Get("return"); ret != "" {
60
					oidcURL += "?return=" + template.URLQueryEscaper(ret)
61
				}
62
				http.Redirect(w, r, oidcURL, http.StatusSeeOther)
63
				return
64
			}
65
66
			returnURL := r.URL.Query().Get("return")
67
68
			if adminToken != "" && r.Method == http.MethodPost {
69
				token := r.FormValue("token")
70
				// Return URL from hidden form field (POST doesn't carry query params).
71
				if ret := r.FormValue("return"); ret != "" {
72
					returnURL = ret
73
				}
74
				if subtle.ConstantTimeCompare([]byte(token), []byte(adminToken)) == 1 {
75
					http.SetCookie(w, &http.Cookie{
76
						Name:     "curator_admin",
77
						Value:    adminToken,
78
						Path:     "/-/",
79
						HttpOnly: true,
80
						SameSite: http.SameSiteStrictMode,
81
					})
82
					http.SetCookie(w, &http.Cookie{
83
						Name:     "curator_auth",
84
						Value:    adminToken,
85
						Path:     "/",
86
						HttpOnly: true,
87
						SameSite: http.SameSiteStrictMode,
88
					})
89
					if returnURL == "" || !strings.HasPrefix(returnURL, "/") {
90
						returnURL = "/-/admin/"
91
					}
92
					http.Redirect(w, r, returnURL, http.StatusSeeOther)
93
					return
94
				}
95
				w.WriteHeader(http.StatusUnauthorized)
96
				renderLogin(w, "Invalid token", oidcProvider != nil, returnURL)
97
				return
98
			}
99
100
			renderLogin(w, "", oidcProvider != nil, returnURL)
101
			return
102
		}
103
104
		// Redirect to login for browser requests.
105
		if strings.HasPrefix(r.URL.Path, "/-/admin/") {
106
			// If only OIDC configured, go straight to OIDC.
107
			if adminToken == "" && oidcProvider != nil {
108
				http.Redirect(w, r, "/-/oidc/login", http.StatusSeeOther)
109
				return
110
			}
111
			http.Redirect(w, r, "/-/admin/login", http.StatusSeeOther)
112
			return
113
		}
114
115
		// API requests get 401.
116
		w.Header().Set("WWW-Authenticate", `Bearer realm="curator admin"`)
117
		http.Error(w, "unauthorized", http.StatusUnauthorized)
118
	})
119
}
120
121
func renderLogin(w http.ResponseWriter, errMsg string, hasOIDC bool, returnURL string) {
122
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
123
	page := `<!DOCTYPE html>
124
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
125
<title>Curator Login</title>
126
<style>
127
:root{--bg:#fff;--text:#202124;--border:#dadce0;--accent:#1a73e8;--err:#d93025}
128
@media(prefers-color-scheme:dark){:root{--bg:#1e1e1e;--text:#d4d4d4;--border:#404040;--accent:#6cb6ff;--err:#f28b82}}
129
*{box-sizing:border-box;margin:0;padding:0}
130
body{font-family:system-ui,sans-serif;background:var(--bg);color:var(--text);display:flex;justify-content:center;align-items:center;min-height:100vh}
131
.login{border:1px solid var(--border);border-radius:8px;padding:32px;max-width:360px;width:100%}
132
h1{font-size:20px;margin-bottom:16px}
133
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)}
134
button{background:var(--accent);color:#fff;border:none;padding:10px 24px;border-radius:4px;font-size:14px;cursor:pointer;width:100%}
135
button:hover{opacity:0.9}
136
.err{color:var(--err);font-size:13px;margin-bottom:12px}
137
.divider{text-align:center;color:var(--border);margin:16px 0;font-size:13px}
138
.sso-btn{background:transparent;color:var(--accent);border:1px solid var(--accent);margin-top:0}
139
.sso-btn:hover{background:var(--accent);color:#fff}
140
</style></head><body>
141
<div class="login"><h1>Curator</h1>`
142
143
	if errMsg != "" {
144
		page += `<p class="err">` + errMsg + `</p>`
145
	}
146
147
	page += `<form method="POST" action="/-/admin/login">
148
<input type="hidden" name="return" value="` + template.HTMLEscapeString(returnURL) + `">
149
<input type="password" name="token" placeholder="Token" autofocus required>
150
<button type="submit">Login</button>
151
</form>`
152
153
	if hasOIDC {
154
		oidcURL := "/-/oidc/login"
155
		if returnURL != "" {
156
			oidcURL += "?return=" + template.URLQueryEscaper(returnURL)
157
		}
158
		page += `<div class="divider">— or —</div>
159
<a href="` + oidcURL + `"><button type="button" class="sso-btn">Login with SSO</button></a>`
160
	}
161
162
	page += `</div></body></html>`
163
	w.Write([]byte(page))
164
}
165

Source Files