90a3790a98
身份仍源自 OAuth provider(user_id = OIDC sub),cdrop 在其上自维护一薄层: 自签会话令牌 + accounts 表,并新增扫码快速登录。实施蓝本见 AUTH.md。 后端 · 自签会话原语 - web_sessions 加 kind/scope/granted_by 列(bootstrap 幂等迁移);既有 oidc 会话行为不变,新增 self(滑动续期)/ guest(受限·不续)两类 - cdrop 自签 HS256 会话 access token,密钥派生自 SESSION_SECRET,与落盘 AES、 shortcut HS256 三密钥域隔离;jwtauth.verifySelfToken 无状态校验、靠 typ 区分 - requireFullSession 守卫:guest 会话不得改账号 / 再批准设备 / 签长效 token - /auth/refresh 按 kind 分流:self/guest 纯自签、不触 IdP 后端 · 扫码登录(internal/httpapi/qr.go) - qr/start・status・request・approve・deny 五端点 + login_requests 表 + reaper - 三密钥分离:QR 仅含批准信息,会话只投递给持私有 poll_secret 的原设备 (偷拍 QR 者无 poll_secret 领不到会话、未登录批不了准) - 会话在新设备侧领取(cookie 不经手机)、单次消费、短 TTL 后端 · 薄账户层与 2FA step-up - accounts 表(键 sub,不含任何凭证):exchange/refresh upsert match_key / 显示名 / 头像 / roles,供显示与未来管理员开启迁移标记时跨源关联 - step-up(默认关):开启后 qr/approve 要求新鲜 prompt=login 授权码,后端就地 换 id_token、JWKS 验签 + auth_time 窗口 + sub 匹配,provider 2FA 于此往返强制 前端(web/) - 显码页 /link/new + 批准页 /link + net/qr.ts,对齐 Theme B、复用 AuthShell 与聚珍排版管线、零新全局样式 - step-up 再认证流:批准前跑 prompt=login PKCE,回调分叉(不消费一次性 code、 独立 state key)后带 step_up_code/verifier 调 approve - 三语 i18n qr.*;新增 qrcode 依赖 修复 · clipboard sweeper(早已提交的损坏) - ClearExpiredClipboards 因 clipboard.sql 全角注释触发 sqlc 1.31 多字节偏移 bug,生成 SQL 被截断为「UPDATE clipboard_state SET content =」,sweeper 运行 期报「incomplete input」、过期剪贴板内容从未清除(短 TTL 暴露保护失效) - 注释改纯 ASCII 并加 bug 警告,重生成得完整 SQL;prod 已验证 sweeper 由 ERROR 转为正常清理(cleared count=1) 构建 - .dockerignore:排除本地 node_modules 等,避免宿主原生二进制污染镜像内 vite 构建 - Dockerfile.base:GOPROXY 改为可经 --build-arg 覆盖(默认仍官方代理,受限网络 构建时传区域镜像即可,仓库不固化区域值) 文档 - 新增 AUTH.md(账户与登录实施蓝本);README 特性;.env.example / compose.snippet 增配置项(QR / 自签会话 TTL / step-up / match_claim) 测试 - 自签 token 密钥域隔离、扫码端到端(领取 / 单次 / poll_secret 校验 / deny / step-up 门)、extractIdentity、web_sessions 升级迁移
269 lines
8.2 KiB
Go
269 lines
8.2 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|