package httpapi import ( "context" "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "io" "log/slog" "net/http" "net/url" "time" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/jwtauth" ) // Browser "passwordless re-login" (web only — desktop persists its own // refresh_token in the OS keyring and never touches these endpoints). // // The durable credential (Casdoor's ~15 KB refresh_token) stays server-side in // web_sessions, encrypted at rest. The browser only ever holds an opaque cookie // token whose SHA-256 is the row's primary key — so a leaked DB yields neither a // usable cookie nor a decryptable token. The cookie is HttpOnly (XSS can't read // it), Secure (HTTPS only), SameSite=Lax + Origin-checked (CSRF), and Path-scoped // to /api/auth so it rides only these four endpoints, not every API call. const ( sessionCookieName = "cdrop_session" sessionCookiePath = "/api/auth" // webSessionTTL is the sliding inactivity window: each refresh pushes // expires_at this far forward. The effective cap is min(this, the IdP's own // refresh_token validity) — once Casdoor retires the refresh_token, refresh // 401s and the user re-logs in regardless. webSessionTTL = 7 * 24 * time.Hour // refreshLockStripes bounds the per-session refresh lock set (see Server). refreshLockStripes = 256 ) // lockRefresh serialises refreshes of one session id, returning the unlock fn. // Callers must re-read the session row after acquiring it: a concurrent refresh // may have already rotated the refresh_token. func (s *Server) lockRefresh(id string) func() { idx := stripeIndex(id) s.refreshLocks[idx].Lock() return func() { s.refreshLocks[idx].Unlock() } } // stripeIndex folds a session id into a stripe with FNV-1a — bounded, no cleanup. func stripeIndex(id string) int { var h uint32 = 2166136261 for i := 0; i < len(id); i++ { h = (h ^ uint32(id[i])) * 16777619 } return int(h % refreshLockStripes) } // deriveSessionKey turns a config secret of any length into a 32-byte AES key // (mirrors jwtauth.DeriveHS256Key). Empty secret → nil (feature disabled). func deriveSessionKey(secret string) []byte { if secret == "" { return nil } sum := sha256.Sum256([]byte(secret)) return sum[:] } // deriveSiteOrigin extracts scheme://host from the configured redirect_uri to // give the CSRF Origin check a fixed expected value. Empty (dev) disables it. func deriveSiteOrigin(redirectURI string) string { if redirectURI == "" { return "" } u, err := url.Parse(redirectURI) if err != nil || u.Scheme == "" || u.Host == "" { return "" } return u.Scheme + "://" + u.Host } // newSessionToken mints a fresh opaque cookie token plus its storage id. raw // goes in Set-Cookie; id (hex SHA-256 of raw) is the DB key, so the stored row // never contains a usable cookie value. func newSessionToken() (raw, id string, err error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", "", err } raw = base64.RawURLEncoding.EncodeToString(b) return raw, sessionID(raw), nil } func sessionID(raw string) string { sum := sha256.Sum256([]byte(raw)) return hex.EncodeToString(sum[:]) } // encryptRefresh seals a refresh_token with AES-256-GCM. Output is // base64(nonce || ciphertext+tag); the key is s.sessionKey (env-derived). func (s *Server) encryptRefresh(plain string) (string, error) { if len(s.sessionKey) == 0 { return "", errors.New("session key not configured") } gcm, err := newGCM(s.sessionKey) if err != nil { return "", err } nonce := make([]byte, gcm.NonceSize()) if _, err := rand.Read(nonce); err != nil { return "", err } ct := gcm.Seal(nonce, nonce, []byte(plain), nil) return base64.StdEncoding.EncodeToString(ct), nil } func (s *Server) decryptRefresh(enc string) (string, error) { if len(s.sessionKey) == 0 { return "", errors.New("session key not configured") } raw, err := base64.StdEncoding.DecodeString(enc) if err != nil { return "", err } gcm, err := newGCM(s.sessionKey) if err != nil { return "", err } if len(raw) < gcm.NonceSize() { return "", errors.New("ciphertext too short") } nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():] plain, err := gcm.Open(nil, nonce, ct, nil) if err != nil { return "", err } return string(plain), nil } func newGCM(key []byte) (cipher.AEAD, error) { block, err := aes.NewCipher(key) if err != nil { return nil, err } return cipher.NewGCM(block) } func setSessionCookie(w http.ResponseWriter, raw string) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: raw, Path: sessionCookiePath, MaxAge: int(webSessionTTL / time.Second), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) } func clearSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: sessionCookiePath, MaxAge: -1, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) } // sameOrigin is belt-and-suspenders CSRF defence atop SameSite=Lax: when the // browser sends an Origin header (always, on fetch POST) it must match the // deployment's own origin. Absent Origin (non-browser clients) is allowed, as is // an unconfigured site origin (dev). func (s *Server) sameOrigin(r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" || s.siteOrigin == "" { return true } return origin == s.siteOrigin } // handleAuthLogout destroys the server-side session and clears the cookie. // Cookie-authenticated (no bearer): the cookie is the only thing that proves // which session to drop. func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" { if err := s.queries.DeleteWebSession(r.Context(), sessionID(c.Value)); err != nil { slog.Warn("web session delete failed", "err", err) } } clearSessionCookie(w) w.WriteHeader(http.StatusNoContent) } type sessionDeviceReq struct { DeviceName string `json:"device_name"` } // handleAuthDevice persists this browser's device name into its session row so // it survives PWA storage eviction: on the next boot, /auth/refresh hands the // name back and the client re-hydrates selfDeviceName without a trip to /setup. func (s *Server) handleAuthDevice(w http.ResponseWriter, r *http.Request) { if !s.sameOrigin(r) { writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad origin"}) return } c, err := r.Cookie(sessionCookieName) if err != nil || c.Value == "" { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "no session"}) return } var req sessionDeviceReq if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } name := jwtauth.SanitizeDeviceName(req.DeviceName) if name == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "empty device name"}) return } if err := s.queries.SetWebSessionDevice(r.Context(), db.SetWebSessionDeviceParams{ DeviceName: name, ID: sessionID(c.Value), }); err != nil { slog.Error("set web session device failed", "err", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "persist failed"}) return } w.WriteHeader(http.StatusNoContent) } // RunWebSessionReaper periodically reclaims expired web_sessions rows. Lazy // deletion on access covers the hot path; this sweeps sessions that simply go // idle and are never touched again. func RunWebSessionReaper(ctx context.Context, q *db.Queries) { ticker := time.NewTicker(1 * time.Hour) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: now := time.Now().Unix() n, err := q.DeleteExpiredWebSessions(ctx, now) if err != nil { slog.Warn("web session reaper failed", "err", err) } else if n > 0 { slog.Info("web sessions reaped", "count", n) } // Scan-login requests are short-lived (default 120s); sweep the // stragglers here too. Expired rows are already rejected at use time. if n, err := q.DeleteExpiredLoginRequests(ctx, now); err != nil { slog.Warn("login request reaper failed", "err", err) } else if n > 0 { slog.Info("login requests reaped", "count", n) } } } }