返回 DeepSeek-Reasonix
auth.go
根目录 / internal / serve / auth.go
1 package serve
2
3 import (
4 "crypto/hmac"
5 "crypto/pbkdf2"
6 "crypto/rand"
7 "crypto/sha256"
8 "crypto/subtle"
9 _ "embed"
10 "encoding/base64"
11 "encoding/hex"
12 "fmt"
13 "log/slog"
14 "net"
15 "net/http"
16 "net/url"
17 "strconv"
18 "strings"
19 "sync"
20 "time"
21
22 "golang.org/x/crypto/bcrypt"
23
24 "reasonix/internal/config"
25 )
26
27 //go:embed login.html
28 var loginHTML []byte
29
30 // authMode represents the authentication mode for the serve frontend.
31 type authMode int
32
33 const (
34 authInvalid authMode = iota // invalid config; deny all requests
35 authNone // no authentication (default, backward-compatible)
36 authToken // pre-shared token in URL or cookie
37 authPassword // login page with bcrypt password
38 )
39
40 const (
41 cookieToken = "reasonix_token" // holds the token for token mode
42 cookieSession = "reasonix_session" // holds the HMAC-signed session for password mode
43 cookieRedirect = "reasonix_redirect" // temporary: where to go after login
44 tokenByteLen = 32 // 256-bit random token
45 sessionDuration = 30 * 24 * time.Hour // how long a password session lasts
46 bcryptCost = 12 // bcrypt cost factor
47 pbkdf2Iter = 4096 // deterministic session-key derivation from password_hash
48 )
49
50 // NormalizeAuthMode normalizes and validates the serve auth mode.
51 func NormalizeAuthMode(mode string) (string, error) {
52 mode = strings.ToLower(strings.TrimSpace(mode))
53 if mode == "" {
54 mode = "none"
55 }
56 switch mode {
57 case "none", "token", "password":
58 return mode, nil
59 default:
60 return "", fmt.Errorf("auth mode must be none, token, or password, got %q", mode)
61 }
62 }
63
64 // rateLimit tracks login attempts per IP for brute-force protection.
65 type rateLimit struct {
66 mu sync.Mutex
67 attempts map[string]*rateWindow
68 }
69
70 type rateWindow struct {
71 count int
72 start time.Time
73 }
74
75 const (
76 rateLimitMax = 5
77 rateLimitWin = time.Minute
78 )
79
80 func newRateLimit() *rateLimit {
81 rl := &rateLimit{attempts: make(map[string]*rateWindow)}
82 go rl.cleanupLoop()
83 return rl
84 }
85
86 // cleanupLoop periodically purges expired rate-limit windows so the map does not
87 // grow without bound over the lifetime of a long-running server.
88 func (rl *rateLimit) cleanupLoop() {
89 ticker := time.NewTicker(2 * rateLimitWin)
90 defer ticker.Stop()
91 for range ticker.C {
92 rl.mu.Lock()
93 now := time.Now()
94 for ip, w := range rl.attempts {
95 if now.Sub(w.start) > rateLimitWin {
96 delete(rl.attempts, ip)
97 }
98 }
99 rl.mu.Unlock()
100 }
101 }
102
103 // allow reports whether the IP is allowed to attempt login. It also cleans up
104 // expired windows.
105 func (rl *rateLimit) allow(ip string) bool {
106 rl.mu.Lock()
107 defer rl.mu.Unlock()
108 now := time.Now()
109 w, ok := rl.attempts[ip]
110 if !ok || now.Sub(w.start) > rateLimitWin {
111 rl.attempts[ip] = &rateWindow{count: 1, start: now}
112 return true
113 }
114 w.count++
115 return w.count <= rateLimitMax
116 }
117
118 // authGate is the authentication middleware and its runtime state.
119 type authGate struct {
120 mode authMode
121 token string // pre-shared token (token mode)
122 passwordHash string // bcrypt hash for password verification (password mode)
123 sessKey []byte // HMAC key for session signing (password mode, generated at startup)
124 behindProxy bool // trust X-Forwarded-For / X-Forwarded-Proto headers
125 rateLimit *rateLimit // per-IP rate limiter for /login
126 }
127
128 // newAuthGate creates the auth middleware from the serve config. For token mode
129 // without a configured token, it generates a random one.
130 func newAuthGate(cfg config.ServeConfig) *authGate {
131 ag := &authGate{
132 rateLimit: newRateLimit(),
133 behindProxy: cfg.BehindProxy,
134 }
135 mode, err := NormalizeAuthMode(cfg.AuthMode)
136 if err != nil {
137 ag.mode = authInvalid
138 return ag
139 }
140 switch mode {
141 case "token":
142 ag.mode = authToken
143 ag.token = strings.TrimSpace(cfg.Token)
144 if ag.token == "" {
145 ag.token = generateToken()
146 }
147 case "password":
148 ag.mode = authPassword
149 ag.passwordHash = strings.TrimSpace(cfg.PasswordHash)
150 ag.sessKey = sessionKeyForPasswordHash(ag.passwordHash)
151 default:
152 ag.mode = authNone
153 }
154 return ag
155 }
156
157 // Token returns the shared token (empty if not in token mode).
158 func (ag *authGate) Token() string { return ag.token }
159
160 // Mode returns the auth mode name as a string.
161 func (ag *authGate) Mode() string {
162 switch ag.mode {
163 case authToken:
164 return "token"
165 case authPassword:
166 return "password"
167 case authInvalid:
168 return "invalid"
169 default:
170 return "none"
171 }
172 }
173
174 // HashPassword returns a bcrypt hash of the given password. Exported for use by
175 // the CLI `--hash-password` flag.
176 func HashPassword(password string) (string, error) {
177 b, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
178 if err != nil {
179 return "", err
180 }
181 return string(b), nil
182 }
183
184 func sessionKeyForPasswordHash(passwordHash string) []byte {
185 if passwordHash != "" {
186 key, err := pbkdf2.Key(sha256.New, passwordHash, []byte("reasonix serve session key"), pbkdf2Iter, 32)
187 if err != nil {
188 panic("serve/auth: pbkdf2 failed: " + err.Error())
189 }
190 return key
191 }
192 key := make([]byte, 32)
193 if _, err := rand.Read(key); err != nil {
194 // crypto/rand.Read cannot fail on modern systems; panic rather than
195 // fall back to a deterministic key that would weaken every session.
196 panic("serve/auth: crypto/rand.Read failed: " + err.Error())
197 }
198 return key
199 }
200
201 // middleware returns an http.Handler that wraps next with authentication checks.
202 // In password mode, /login is handled directly to bypass the CSRF content-type
203 // guard (the login form uses application/x-www-form-urlencoded, not JSON).
204 func (ag *authGate) middleware(next http.Handler) http.Handler {
205 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
206 if ag.mode == authInvalid {
207 ag.deny(w, r)
208 return
209 }
210 if ag.mode == authNone {
211 next.ServeHTTP(w, r)
212 return
213 }
214 // /login and /login/ are handled directly by the auth gate — they must
215 // not pass through the CSRF guard (which rejects non-JSON POSTs).
216 if r.URL.Path == "/login" || r.URL.Path == "/login/" {
217 ag.handleLogin(w, r)
218 return
219 }
220 if ag.mode == authToken {
221 ag.checkToken(w, r, next)
222 return
223 }
224 // password mode
225 ag.checkSession(w, r, next)
226 })
227 }
228
229 // checkToken validates the token from cookie or query parameter. If the query
230 // parameter is valid, it sets a cookie and redirects to strip the token from the
231 // URL (preventing it from leaking via browser history or referrer headers).
232 func (ag *authGate) checkToken(w http.ResponseWriter, r *http.Request, next http.Handler) {
233 // 1. Check cookie first (fast path).
234 if c, err := r.Cookie(cookieToken); err == nil && strings.TrimSpace(c.Value) != "" {
235 if subtle.ConstantTimeCompare([]byte(c.Value), []byte(ag.token)) == 1 {
236 next.ServeHTTP(w, r)
237 return
238 }
239 }
240
241 // 2. Check query parameter.
242 if q := r.URL.Query().Get("token"); q != "" {
243 if subtle.ConstantTimeCompare([]byte(q), []byte(ag.token)) == 1 {
244 // Set a persistent cookie so future requests (including SSE) are
245 // authenticated without the token in the URL.
246 ag.setAuthCookie(w, r, &http.Cookie{
247 Name: cookieToken,
248 Value: ag.token,
249 Path: "/",
250 HttpOnly: true,
251 SameSite: http.SameSiteLaxMode,
252 MaxAge: int(sessionDuration.Seconds()),
253 })
254 // Redirect to the same path without the token query parameter.
255 cleanURL := *r.URL
256 qry := cleanURL.Query()
257 qry.Del("token")
258 cleanURL.RawQuery = qry.Encode()
259 if cleanURL.RawQuery == "" {
260 cleanURL.RawQuery = ""
261 }
262 redirectToSafeTarget(w, r, cleanURL.RequestURI(), http.StatusFound)
263 return
264 }
265 }
266
267 // 3. Not authenticated.
268 ag.deny(w, r)
269 }
270
271 // checkSession validates the HMAC-signed session cookie for password mode.
272 // Unauthenticated browser requests are redirected to /login; API/SSE requests
273 // get a 401. The /login path is intercepted before this function by middleware.
274 func (ag *authGate) checkSession(w http.ResponseWriter, r *http.Request, next http.Handler) {
275 // Check session cookie.
276 if c, err := r.Cookie(cookieSession); err == nil {
277 if ag.verifySession(c.Value) {
278 next.ServeHTTP(w, r)
279 return
280 }
281 }
282
283 // Not authenticated.
284 if acceptsHTML(r) {
285 // Store the original path so we can redirect back after login.
286 dest := safeRedirectTarget(r.URL.RequestURI())
287 ag.setAuthCookie(w, r, &http.Cookie{
288 Name: cookieRedirect,
289 Value: dest,
290 Path: "/",
291 HttpOnly: true,
292 SameSite: http.SameSiteLaxMode,
293 MaxAge: 300, // 5 minutes
294 })
295 http.Redirect(w, r, "/login", http.StatusFound)
296 return
297 }
298
299 ag.deny(w, r)
300 }
301
302 // deny sends a 401 response. The message is intentionally generic to avoid
303 // leaking information about which auth mode is active.
304 func (ag *authGate) deny(w http.ResponseWriter, r *http.Request) {
305 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
306 w.WriteHeader(http.StatusUnauthorized)
307 _, _ = w.Write([]byte("Unauthorized\n"))
308 }
309
310 // handleLogin serves the login page (GET) or processes a login attempt (POST).
311 func (ag *authGate) handleLogin(w http.ResponseWriter, r *http.Request) {
312 switch r.Method {
313 case http.MethodGet:
314 ag.loginPage(w, r)
315 case http.MethodPost:
316 ag.loginSubmit(w, r)
317 default:
318 http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
319 }
320 }
321
322 // loginPage serves the embedded login HTML.
323 func (ag *authGate) loginPage(w http.ResponseWriter, r *http.Request) {
324 w.Header().Set("Content-Type", "text/html; charset=utf-8")
325 _, _ = w.Write(loginHTML)
326 }
327
328 // loginSubmit verifies the password and issues a session cookie.
329 func (ag *authGate) loginSubmit(w http.ResponseWriter, r *http.Request) {
330 // Rate limit.
331 ip := ag.clientIP(r)
332 if !ag.rateLimit.allow(ip) {
333 slog.Warn("serve/auth: rate-limited login attempt", "ip", ip)
334 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
335 w.WriteHeader(http.StatusTooManyRequests)
336 _, _ = w.Write([]byte("Too many attempts. Please wait a minute.\n"))
337 return
338 }
339
340 // Parse the password from the form.
341 if err := r.ParseForm(); err != nil {
342 http.Error(w, "Bad Request", http.StatusBadRequest)
343 return
344 }
345 password := r.FormValue("password")
346 if password == "" {
347 ag.loginPageWithError(w, "Password is required.")
348 return
349 }
350
351 // Verify against the stored bcrypt hash.
352 if ag.passwordHash == "" {
353 slog.Error("serve/auth: cannot verify password — no password_hash configured")
354 ag.loginPageWithError(w, "Server not configured for password authentication.")
355 return
356 }
357
358 // Verify against bcrypt hash.
359 if err := bcrypt.CompareHashAndPassword([]byte(ag.passwordHash), []byte(password)); err != nil {
360 ag.loginPageWithError(w, "Invalid password.")
361 return
362 }
363
364 // Create and sign a session.
365 session := ag.signSession()
366
367 // Clear the redirect cookie and set the session cookie.
368 ag.setAuthCookie(w, r, &http.Cookie{
369 Name: cookieRedirect,
370 Value: "",
371 Path: "/",
372 HttpOnly: true,
373 SameSite: http.SameSiteLaxMode,
374 MaxAge: -1,
375 })
376 ag.setAuthCookie(w, r, &http.Cookie{
377 Name: cookieSession,
378 Value: session,
379 Path: "/",
380 HttpOnly: true,
381 SameSite: http.SameSiteLaxMode,
382 MaxAge: int(sessionDuration.Seconds()),
383 })
384
385 // Redirect to the original destination, or /.
386 dest := "/"
387 if c, err := r.Cookie(cookieRedirect); err == nil && c.Value != "" {
388 dest = safeRedirectTarget(c.Value)
389 }
390 redirectToSafeTarget(w, r, dest, http.StatusFound)
391 }
392
393 func (ag *authGate) setAuthCookie(w http.ResponseWriter, r *http.Request, c *http.Cookie) {
394 c.Secure = ag.authCookieSecure(r)
395 // codeql[go/cookie-secure-not-set] Secure cookies are only sent back over HTTPS; plain-HTTP serve must keep token/password auth usable.
396 http.SetCookie(w, c)
397 }
398
399 func (ag *authGate) authCookieSecure(r *http.Request) bool {
400 return ag.isTLS(r)
401 }
402
403 func safeRedirectTarget(raw string) string {
404 raw = strings.TrimSpace(raw)
405 raw = strings.ReplaceAll(raw, "\\", "/")
406 if i := strings.IndexByte(raw, '#'); i >= 0 {
407 raw = raw[:i]
408 }
409 if raw == "" {
410 return "/"
411 }
412 if raw != "/" && (len(raw) <= 1 || raw[0] != '/' || raw[1] == '/' || raw[1] == '\\') {
413 return "/"
414 }
415 u, err := url.Parse(raw)
416 if err != nil || u == nil || u.IsAbs() || u.Hostname() != "" {
417 return "/"
418 }
419 path := strings.ReplaceAll(u.Path, "\\", "/")
420 if path == "" {
421 return "/"
422 }
423 if path != "/" && (len(path) <= 1 || path[0] != '/' || path[1] == '/' || path[1] == '\\') {
424 return "/"
425 }
426 return u.RequestURI()
427 }
428
429 func redirectToSafeTarget(w http.ResponseWriter, r *http.Request, raw string, status int) {
430 target := safeRedirectTarget(raw)
431 target = strings.ReplaceAll(target, "\\", "/")
432 u, err := url.Parse(target)
433 if err == nil && u != nil && !u.IsAbs() && u.Hostname() == "" {
434 redirect := u.RequestURI()
435 if redirect == "/" {
436 http.Redirect(w, r, "/", status)
437 return
438 }
439 if len(redirect) > 1 && redirect[0] == '/' && redirect[1] != '/' && redirect[1] != '\\' {
440 http.Redirect(w, r, redirect, status)
441 return
442 }
443 }
444 http.Redirect(w, r, "/", status)
445 }
446
447 // signSession creates a new HMAC-signed session token valid for sessionDuration.
448 // Format: base64url(expiry_base10|random_16_bytes).hex(hmac_sha256)
449 func (ag *authGate) signSession() string {
450 expiry := time.Now().Add(sessionDuration).Unix()
451 nonce := make([]byte, 16)
452 if _, err := rand.Read(nonce); err != nil {
453 // crypto/rand.Read cannot fail on modern systems; panic rather than
454 // fall back to an all-zero nonce. Forging a cookie still requires the
455 // PBKDF2-derived sessKey, so this is not an auth bypass, but a constant
456 // nonce weakens session token uniqueness/unpredictability and is the
457 // same anti-pattern generateToken/sessionKeyForPasswordHash panic on.
458 panic("serve/auth: crypto/rand.Read failed: " + err.Error())
459 }
460
461 payload := strconv.FormatInt(expiry, 10) + "|" + base64.RawURLEncoding.EncodeToString(nonce)
462 mac := hmac.New(sha256.New, ag.sessKey)
463 mac.Write([]byte(payload))
464 sig := hex.EncodeToString(mac.Sum(nil))
465
466 return payload + "." + sig
467 }
468
469 // verifySession checks that a session token is valid (HMAC matches and not expired).
470 func (ag *authGate) verifySession(token string) bool {
471 // Split payload.signature
472 dot := strings.LastIndexByte(token, '.')
473 if dot < 0 {
474 return false
475 }
476 payload, sigHex := token[:dot], token[dot+1:]
477
478 // Verify HMAC (constant-time via hmac.Equal; handles length mismatch
479 // internally so we don't leak timing information from a pre-check).
480 mac := hmac.New(sha256.New, ag.sessKey)
481 mac.Write([]byte(payload))
482 expected := mac.Sum(nil)
483 sig, err := hex.DecodeString(sigHex)
484 if err != nil {
485 return false
486 }
487 if !hmac.Equal(sig, expected) {
488 return false
489 }
490
491 // Check expiry (format: "unix_timestamp|base64nonce").
492 pipe := strings.IndexByte(payload, '|')
493 if pipe < 0 {
494 return false
495 }
496 expiry, err := strconv.ParseInt(payload[:pipe], 10, 64)
497 if err != nil {
498 return false
499 }
500 return time.Now().Unix() < expiry
501 }
502
503 // loginPageWithError renders the login page with an error message.
504 func (ag *authGate) loginPageWithError(w http.ResponseWriter, msg string) {
505 w.Header().Set("Content-Type", "text/html; charset=utf-8")
506 w.WriteHeader(http.StatusUnauthorized)
507 html := strings.Replace(string(loginHTML), "<!--ERROR-->",
508 `<div class="error">`+htmlEscape(msg)+`</div>`, 1)
509 _, _ = w.Write([]byte(html))
510 }
511
512 // htmlEscape does minimal escaping for display in an HTML context.
513 func htmlEscape(s string) string {
514 s = strings.ReplaceAll(s, "&", "&amp;")
515 s = strings.ReplaceAll(s, "<", "&lt;")
516 s = strings.ReplaceAll(s, ">", "&gt;")
517 s = strings.ReplaceAll(s, "\"", "&#34;")
518 s = strings.ReplaceAll(s, "'", "&#39;")
519 return s
520 }
521
522 // generateToken returns a cryptographically random URL-safe token.
523 func generateToken() string {
524 b := make([]byte, tokenByteLen)
525 if _, err := rand.Read(b); err != nil {
526 // crypto/rand.Read failure is fatal for token generation.
527 panic("serve/auth: crypto/rand.Read failed: " + err.Error())
528 }
529 return base64.RawURLEncoding.EncodeToString(b)
530 }
531
532 // acceptsHTML reports whether the request's Accept header prefers text/html.
533 func acceptsHTML(r *http.Request) bool {
534 for _, h := range strings.Fields(r.Header.Get("Accept")) {
535 if strings.HasPrefix(h, "text/html") {
536 return true
537 }
538 }
539 return false
540 }
541
542 // clientIP extracts the client IP from the request. When behindProxy is true,
543 // it trusts the leftmost entry in X-Forwarded-For (set by a trusted reverse
544 // proxy). Otherwise it uses RemoteAddr directly — X-Forwarded-For is ignored
545 // because an attacker can forge it.
546 func (ag *authGate) clientIP(r *http.Request) string {
547 if ag.behindProxy {
548 if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
549 if i := strings.IndexByte(fwd, ','); i >= 0 {
550 return strings.TrimSpace(fwd[:i])
551 }
552 return strings.TrimSpace(fwd)
553 }
554 }
555 // Strip port from RemoteAddr.
556 addr := r.RemoteAddr
557 if i := strings.LastIndexByte(addr, ':'); i >= 0 {
558 return addr[:i]
559 }
560 return addr
561 }
562
563 // isTLS reports whether the request arrived over TLS. It trusts
564 // X-Forwarded-Proto only when behindProxy is true.
565 func (ag *authGate) isTLS(r *http.Request) bool {
566 if r.TLS != nil {
567 return true
568 }
569 if ag.behindProxy {
570 return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
571 }
572 return false
573 }
574
575 func isLoopbackHost(hostport string) bool {
576 hostport = strings.TrimSpace(hostport)
577 if hostport == "" {
578 return false
579 }
580 host := hostport
581 if h, _, err := net.SplitHostPort(hostport); err == nil {
582 host = h
583 }
584 host = strings.Trim(host, "[]")
585 if strings.EqualFold(host, "localhost") {
586 return true
587 }
588 ip := net.ParseIP(host)
589 return ip != nil && ip.IsLoopback()
590 }
591
592 // PlainHTTPAuthWarning reports whether serve is exposing authenticated access
593 // over a non-loopback plain-HTTP listener. The listener may still be valid for a
594 // trusted LAN or reverse-proxy setup, but users should see the risk explicitly.
595 func PlainHTTPAuthWarning(cfg config.ServeConfig, addr string) string {
596 mode, err := NormalizeAuthMode(cfg.AuthMode)
597 if err != nil || mode == "none" || isLoopbackHost(addr) {
598 return ""
599 }
600 return "warning: authenticated serve is listening on non-loopback HTTP; use HTTPS via a trusted reverse proxy or bind to 127.0.0.1 for local-only access"
601 }
602
602 lines GO