auth.go

v1.0.0
Doc Versions Source
1
package admin
2
3
import (
4
	"crypto/subtle"
5
	"net/http"
6
	"strings"
7
)
8
9
// AuthMiddleware protects admin routes with the admin token.
10
// Supports Bearer token in Authorization header and cookie-based sessions.
11
func AuthMiddleware(adminToken string, next http.Handler) http.Handler {
12
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13
		if adminToken == "" {
14
			http.Error(w, "admin access not configured", http.StatusForbidden)
15
			return
16
		}
17
18
		// Check Authorization header.
19
		if auth := r.Header.Get("Authorization"); auth != "" {
20
			if token, ok := strings.CutPrefix(auth, "Bearer "); ok {
21
				if subtle.ConstantTimeCompare([]byte(token), []byte(adminToken)) == 1 {
22
					next.ServeHTTP(w, r)
23
					return
24
				}
25
			}
26
		}
27
28
		// Check cookie.
29
		if cookie, err := r.Cookie("curator_admin"); err == nil {
30
			if subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(adminToken)) == 1 {
31
				next.ServeHTTP(w, r)
32
				return
33
			}
34
		}
35
36
		// Check if this is the login page.
37
		if r.URL.Path == "/-/admin/login" {
38
			if r.Method == http.MethodPost {
39
				token := r.FormValue("token")
40
				if subtle.ConstantTimeCompare([]byte(token), []byte(adminToken)) == 1 {
41
					http.SetCookie(w, &http.Cookie{
42
						Name:     "curator_admin",
43
						Value:    adminToken,
44
						Path:     "/-/",
45
						HttpOnly: true,
46
						SameSite: http.SameSiteStrictMode,
47
					})
48
					http.Redirect(w, r, "/-/admin/", http.StatusSeeOther)
49
					return
50
				}
51
				// Wrong token, show login with error.
52
				w.WriteHeader(http.StatusUnauthorized)
53
				renderLogin(w, "Invalid token")
54
				return
55
			}
56
			renderLogin(w, "")
57
			return
58
		}
59
60
		// Redirect to login page for browser requests.
61
		if strings.HasPrefix(r.URL.Path, "/-/admin/") {
62
			http.Redirect(w, r, "/-/admin/login", http.StatusSeeOther)
63
			return
64
		}
65
66
		// API requests get 401.
67
		w.Header().Set("WWW-Authenticate", `Bearer realm="curator admin"`)
68
		http.Error(w, "unauthorized", http.StatusUnauthorized)
69
	})
70
}
71
72
func renderLogin(w http.ResponseWriter, errMsg string) {
73
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
74
	page := `<!DOCTYPE html>
75
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
76
<title>Curator Admin Login</title>
77
<style>
78
:root{--bg:#fff;--text:#202124;--border:#dadce0;--accent:#1a73e8;--err:#d93025}
79
@media(prefers-color-scheme:dark){:root{--bg:#1e1e1e;--text:#d4d4d4;--border:#404040;--accent:#6cb6ff;--err:#f28b82}}
80
*{box-sizing:border-box;margin:0;padding:0}
81
body{font-family:system-ui,sans-serif;background:var(--bg);color:var(--text);display:flex;justify-content:center;align-items:center;min-height:100vh}
82
.login{border:1px solid var(--border);border-radius:8px;padding:32px;max-width:360px;width:100%}
83
h1{font-size:20px;margin-bottom:16px}
84
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)}
85
button{background:var(--accent);color:#fff;border:none;padding:10px 24px;border-radius:4px;font-size:14px;cursor:pointer;width:100%}
86
button:hover{opacity:0.9}
87
.err{color:var(--err);font-size:13px;margin-bottom:12px}
88
</style></head><body>
89
<div class="login"><h1>Curator Admin</h1>`
90
91
	if errMsg != "" {
92
		page += `<p class="err">` + errMsg + `</p>`
93
	}
94
95
	page += `<form method="POST" action="/-/admin/login">
96
<input type="password" name="token" placeholder="Admin token" autofocus required>
97
<button type="submit">Login</button>
98
</form></div></body></html>`
99
100
	w.Write([]byte(page))
101
}
102

Source Files