登录子系统:OIDC 统一自签 token + 吊销即时生效 + 设备并入会话列表 + 应用内扫码 + 禁则修复
接续 90a3790(扫码 A0+A1+B + step-up 雏形)。蓝本见 AUTH.md。
OIDC 统一到 cdrop 自签 token
- /auth/exchange 先 JWKS 验 id_token 签名(自签 token 的唯一信任锚)再签 cdrop
自签 HS256 access token(绑 sid),不再把 IdP RS256 下发浏览器;验签失败仅告警
并回退 RS256(会话仍建、下次 refresh 自愈),登录不中断
- /auth/refresh oidc 路径仍打 IdP 轮换 refresh_token + 探测 IdP 侧吊销,但同样回
自签 token;createWebSession 改返回行 id
- verify 链保留 RS256 分支仅供桌面端 loopback(其即时吊销留后续)
吊销即时生效 + 被吊销设备自知回退
- verifySelfToken 每请求按 sid 查 web_sessions 行,行删即拒 → 吊销在被吊销设备
下一次请求即生效(不等 TTL),oidc / self / guest 一致
- 前端仅在「服务端以 401 确证会话失效」时 forceLogout 清登录态、回登录页;短期连接
失败(5xx / 网络)一律保留登录态(refreshTokens 改三态 refreshed/invalid/transient,
cookieRefresh 改 discriminated 结果)
- __root 反应式守卫:prod 下失登录态且非认证路由即跳登录页;新增 auth.sessionLost.* 三语
设备列表并入会话列表 + 孤儿自动清除
- GET /api/auth/sessions 统一 web_sessions 与原生客户端设备(桌面 / iOS,自 devices
登记表呈现,native=true,离线也列);同名不重复列出,排除 shortcut 与无会话 browser
- 吊销网页会话连带删其设备登记(无同名在用会话时);原生「登出」走
DELETE /api/devices/{name}(同要求 step-up)
- 新增 DeleteOrphanBrowserDevices(browser 型且无存活 web_session),接入设备清扫器(1h)
应用内扫码 + 聚珍崩溃 / CJK 禁则修复
- 登录页内置 getUserMedia + jsqr 扫码,不再外跳系统应用(避免开错浏览器)
- 修聚珍(cjk-autospace)MutationObserver 与 React 重渲染同子树冲突致的 insertBefore
崩溃:扫码 / 显码 / 批准三状态机页整页跳过聚珍(data-jz-skip)
- 修流动正文未跑 CJK 禁则(jinze 为段落级、须 opt-in):设置 / 快捷指令 / 桌面页一批
hint 补 data-jz-level="paragraph" + justify,短标签 / 状态仍留文本级
step-up 完善
- auth_time 改可选(Casdoor 不下发,原强制致死循环);stepped_up_at 按会话绑定、
StepUpMaxAgeSeconds 窗口内不复要求;新增 POST /api/auth/stepup
- 批准页 persist 编进 step-up returnTo 防整页跳转丢失;scope 随档绑定(信任=full /
仅此次=guest),默认偏安全的「仅此次」
文档与测试
- AUTH.md:折中架构、accounts 薄表、令牌架构、扫码流程、step-up、§4.4 统一会话列表
- 新增 Go 测试:OIDC 自签验证、verifySelfToken 吊销即拒、会话吊销连带删设备、
统一会话列表(含原生 / 不含 shortcut / 不重复)、孤儿清扫、删设备需 step-up
This commit is contained in:
@@ -74,6 +74,10 @@ func Bootstrap(ctx context.Context, d *sql.DB) error {
|
||||
"ALTER TABLE web_sessions ADD COLUMN granted_by TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return fmt.Errorf("migrate web_sessions.granted_by: %w", err)
|
||||
}
|
||||
if err := ensureColumn(ctx, tx, "web_sessions", "stepped_up_at",
|
||||
"ALTER TABLE web_sessions ADD COLUMN stepped_up_at INTEGER NOT NULL DEFAULT 0"); err != nil {
|
||||
return fmt.Errorf("migrate web_sessions.stepped_up_at: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit bootstrap: %w", err)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,29 @@ func (q *Queries) DeleteDevice(ctx context.Context, arg DeleteDeviceParams) (int
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const deleteOrphanBrowserDevices = `-- name: DeleteOrphanBrowserDevices :execrows
|
||||
DELETE FROM devices
|
||||
WHERE type = 'browser'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM web_sessions ws
|
||||
WHERE ws.user_id = devices.user_id
|
||||
AND ws.device_name = devices.name
|
||||
AND ws.expires_at > ?
|
||||
)
|
||||
`
|
||||
|
||||
// DeleteOrphanBrowserDevices removes browser device rows with no live web_session
|
||||
// (the session was revoked or expired), keeping the device list aligned with the
|
||||
// session list. The ? is the current epoch second. Native (macos/windows/linux/ios)
|
||||
// and shortcut devices are left alone: they legitimately keep no web_session.
|
||||
func (q *Queries) DeleteOrphanBrowserDevices(ctx context.Context, expiresAt int64) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, deleteOrphanBrowserDevices, expiresAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const deleteStaleDevices = `-- name: DeleteStaleDevices :exec
|
||||
DELETE FROM devices
|
||||
WHERE last_seen < ?
|
||||
|
||||
@@ -73,7 +73,10 @@ CREATE TABLE IF NOT EXISTS web_sessions (
|
||||
-- 但不能改账号、不能再批准别的设备、不能签发长效 token(路由层 requireFullSession 守门)。
|
||||
scope TEXT NOT NULL DEFAULT 'full',
|
||||
-- granted_by=扫码批准者的 session id,供审计与「吊销我批准过的借用设备」连带吊销。
|
||||
granted_by TEXT NOT NULL DEFAULT ''
|
||||
granted_by TEXT NOT NULL DEFAULT '',
|
||||
-- stepped_up_at=本会话最近一次通过 step-up 再认证的 Unix 秒(AUTH.md §6)。在
|
||||
-- StepUpMaxAgeSeconds 窗口内,敏感动作不再重复要求再认证(避免每次都弹再登录)。
|
||||
stepped_up_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions (expires_at);
|
||||
|
||||
@@ -101,4 +101,5 @@ type WebSession struct {
|
||||
Kind string `json:"kind"`
|
||||
Scope string `json:"scope"`
|
||||
GrantedBy string `json:"granted_by"`
|
||||
SteppedUpAt int64 `json:"stepped_up_at"`
|
||||
}
|
||||
|
||||
@@ -15,6 +15,20 @@ ORDER BY name;
|
||||
DELETE FROM devices
|
||||
WHERE last_seen < ?;
|
||||
|
||||
-- DeleteOrphanBrowserDevices removes browser device rows with no live web_session
|
||||
-- (the session was revoked or expired), keeping the device list aligned with the
|
||||
-- session list. The ? is the current epoch second. Native (macos/windows/linux/ios)
|
||||
-- and shortcut devices are left alone: they legitimately keep no web_session.
|
||||
-- name: DeleteOrphanBrowserDevices :execrows
|
||||
DELETE FROM devices
|
||||
WHERE type = 'browser'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM web_sessions ws
|
||||
WHERE ws.user_id = devices.user_id
|
||||
AND ws.device_name = devices.name
|
||||
AND ws.expires_at > ?
|
||||
);
|
||||
|
||||
-- name: DeleteDevice :execrows
|
||||
DELETE FROM devices
|
||||
WHERE user_id = ? AND name = ?;
|
||||
|
||||
@@ -9,10 +9,17 @@ INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, c
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?);
|
||||
|
||||
-- name: GetWebSession :one
|
||||
SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by
|
||||
SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by, stepped_up_at
|
||||
FROM web_sessions
|
||||
WHERE id = ?;
|
||||
|
||||
-- SetSessionSteppedUp stamps the moment this session last passed step-up re-auth;
|
||||
-- sensitive actions within the freshness window then skip re-auth (AUTH.md 6).
|
||||
-- name: SetSessionSteppedUp :exec
|
||||
UPDATE web_sessions
|
||||
SET stepped_up_at = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: RotateWebSession :exec
|
||||
UPDATE web_sessions
|
||||
SET refresh_token = ?, last_used_at = ?, expires_at = ?
|
||||
|
||||
@@ -126,7 +126,7 @@ func (q *Queries) DeleteWebSessionForUser(ctx context.Context, arg DeleteWebSess
|
||||
}
|
||||
|
||||
const getWebSession = `-- name: GetWebSession :one
|
||||
SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by
|
||||
SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by, stepped_up_at
|
||||
FROM web_sessions
|
||||
WHERE id = ?
|
||||
`
|
||||
@@ -146,6 +146,7 @@ func (q *Queries) GetWebSession(ctx context.Context, id string) (WebSession, err
|
||||
&i.Kind,
|
||||
&i.Scope,
|
||||
&i.GrantedBy,
|
||||
&i.SteppedUpAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -230,6 +231,24 @@ func (q *Queries) RotateWebSession(ctx context.Context, arg RotateWebSessionPara
|
||||
return err
|
||||
}
|
||||
|
||||
const setSessionSteppedUp = `-- name: SetSessionSteppedUp :exec
|
||||
UPDATE web_sessions
|
||||
SET stepped_up_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type SetSessionSteppedUpParams struct {
|
||||
SteppedUpAt int64 `json:"stepped_up_at"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// SetSessionSteppedUp stamps the moment this session last passed step-up re-auth;
|
||||
// sensitive actions within the freshness window then skip re-auth (AUTH.md 6).
|
||||
func (q *Queries) SetSessionSteppedUp(ctx context.Context, arg SetSessionSteppedUpParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setSessionSteppedUp, arg.SteppedUpAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const setWebSessionDevice = `-- name: SetWebSessionDevice :exec
|
||||
UPDATE web_sessions
|
||||
SET device_name = ?
|
||||
|
||||
+63
-21
@@ -19,7 +19,9 @@ import (
|
||||
|
||||
// PROJECT_BRIEF.md §2 keeps the browser path on standard OIDC PKCE. Backend
|
||||
// proxies the token endpoint so the browser doesn't need a CORS-friendly OIDC
|
||||
// provider; the access_token still flows back to the browser unchanged.
|
||||
// provider. The browser is then handed a cdrop self-signed access token (sid-bound
|
||||
// to its web_sessions row), not the IdP's RS256 token, so OIDC web sessions verify
|
||||
// statefully and revoke immediately — unified with scan-login (AUTH.md §5).
|
||||
|
||||
type authConfigResp struct {
|
||||
AuthMode string `json:"auth_mode"`
|
||||
@@ -120,33 +122,58 @@ func (s *Server) handleAuthExchange(w http.ResponseWriter, r *http.Request) {
|
||||
// Refresh the thin accounts row (display name / avatar / roles / match_key).
|
||||
s.upsertAccount(r.Context(), identity)
|
||||
|
||||
// Stash the refresh_token server-side and hand the browser an opaque,
|
||||
// HttpOnly cookie instead. Guarded so a deploy without the encryption key (or
|
||||
// an IdP that returned no refresh_token) still logs the user in — they just
|
||||
// don't get passwordless re-login and re-auth on the next access-token expiry.
|
||||
// By default the browser holds a cdrop self-signed access token (sid-bound),
|
||||
// not the IdP's RS256 token — so the OIDC web session is verified statefully on
|
||||
// every request (verifySelfToken → web_sessions lookup) and revoking the row
|
||||
// logs this device out on its very next call, exactly like scan-login. We mint
|
||||
// it only after stashing the refresh_token server-side (encrypted, behind an
|
||||
// HttpOnly cookie) so the session is durable. The IdP RS256 token stays the
|
||||
// fallback for degraded configs (no encryption key / no refresh_token / an
|
||||
// id_token that fails verification) — it is still independently verified per
|
||||
// request via JWKS (verifyRS256), and the next refresh upgrades the device to
|
||||
// the unified self-token model. (AUTH.md §5; desktop keeps its RS256 token.)
|
||||
accessToken, expiresIn := tr.AccessToken, tr.ExpiresIn
|
||||
if len(s.sessionKey) > 0 && tr.RefreshToken != "" {
|
||||
if err := s.createWebSession(r, w, user.ID, tr.RefreshToken); err != nil {
|
||||
slog.Error("create web session failed", "err", err)
|
||||
id, cerr := s.createWebSession(r, w, user.ID, tr.RefreshToken)
|
||||
if cerr != nil {
|
||||
slog.Error("create web session failed", "err", cerr)
|
||||
} else {
|
||||
// The id_token is the SOLE trust anchor for the minted self token (the
|
||||
// IdP RS256 token no longer reaches the browser to be re-verified), so
|
||||
// its signature MUST validate here and its subject must match. On
|
||||
// failure we keep the durable session but hand back the per-request-
|
||||
// verified RS256 token; the next refresh upgrades this device to the
|
||||
// self-token model. (Should not happen with a healthy IdP — warn ops.)
|
||||
sub, _, verr := s.auth.VerifyIDToken(r.Context(), tr.IDToken)
|
||||
if verr != nil || sub != user.ID {
|
||||
slog.Warn("oidc id_token verification failed; using RS256 fallback",
|
||||
"err", verr, "sub_match", sub == user.ID)
|
||||
} else if tok, ein, merr := s.mintSessionToken(user.ID, id, "full"); merr != nil {
|
||||
slog.Error("mint session token failed", "err", merr)
|
||||
} else {
|
||||
accessToken, expiresIn = tok, ein
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, exchangeResp{
|
||||
AccessToken: tr.AccessToken,
|
||||
ExpiresIn: tr.ExpiresIn,
|
||||
AccessToken: accessToken,
|
||||
ExpiresIn: expiresIn,
|
||||
User: user,
|
||||
})
|
||||
}
|
||||
|
||||
// createWebSession encrypts the refresh_token, persists a new session row, and
|
||||
// sets the session cookie on the response.
|
||||
func (s *Server) createWebSession(r *http.Request, w http.ResponseWriter, userID, refreshToken string) error {
|
||||
// sets the session cookie on the response. Returns the session row id so the
|
||||
// caller can bind a cdrop self-signed access token to it (sid).
|
||||
func (s *Server) createWebSession(r *http.Request, w http.ResponseWriter, userID, refreshToken string) (string, error) {
|
||||
raw, id, err := newSessionToken()
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
enc, err := s.encryptRefresh(refreshToken)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
now := time.Now()
|
||||
if err := s.queries.CreateWebSession(r.Context(), db.CreateWebSessionParams{
|
||||
@@ -159,10 +186,10 @@ func (s *Server) createWebSession(r *http.Request, w http.ResponseWriter, userID
|
||||
LastUsedAt: now.Unix(),
|
||||
ExpiresAt: now.Add(webSessionTTL).Unix(),
|
||||
}); err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
setSessionCookie(w, raw)
|
||||
return nil
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// refreshResp is what the browser sees: a fresh access_token plus the identity
|
||||
@@ -282,9 +309,23 @@ func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
setSessionCookie(w, c.Value)
|
||||
|
||||
// Hand back a cdrop self-signed access token (sid-bound), not the IdP RS256
|
||||
// token: the browser holds one uniform token type and revoking the session row
|
||||
// logs this device out on its next request. The IdP round-trip above still ran
|
||||
// — it rotated the refresh_token and surfaced IdP-side revocation (a rejected
|
||||
// refresh deletes the session above) — its access_token simply no longer ships.
|
||||
// Mint from the row's canonical user_id; degrade to the IdP token only if the
|
||||
// HS256 key is unconfigured. (AUTH.md §5.)
|
||||
accessToken, expiresIn := tr.AccessToken, tr.ExpiresIn
|
||||
if tok, ein, mErr := s.mintSessionToken(sess.UserID, id, "full"); mErr != nil {
|
||||
slog.Error("mint session token failed", "err", mErr)
|
||||
} else {
|
||||
accessToken, expiresIn = tok, ein
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, refreshResp{
|
||||
AccessToken: tr.AccessToken,
|
||||
ExpiresIn: tr.ExpiresIn,
|
||||
AccessToken: accessToken,
|
||||
ExpiresIn: expiresIn,
|
||||
User: user,
|
||||
DeviceName: sess.DeviceName,
|
||||
})
|
||||
@@ -369,10 +410,11 @@ type tokenIdentity struct {
|
||||
|
||||
// extractIdentity parses {sub, preferred_username | name | email, avatar | picture,
|
||||
// matchClaim, groups} from the id_token if available, else the access_token. The
|
||||
// signature is NOT verified here — for OIDC sessions the auth middleware verifies
|
||||
// the access_token on every API call via JWKS, so any tamper surfaces there. (Once
|
||||
// OIDC sessions migrate to cdrop self-signed access tokens, this becomes the only
|
||||
// trust point and MUST then verify the id_token signature — AUTH.md §5.)
|
||||
// signature is NOT verified here — callers verify separately around it: at login
|
||||
// (handleAuthExchange) the id_token signature is checked via VerifyIDToken before
|
||||
// the extracted subject is trusted to mint a self token; at refresh the IdP just
|
||||
// authenticated the refresh_token over a TLS back-channel, so the token it returns
|
||||
// is trusted by transport. Either way the extracted fields are sound. (AUTH.md §5.)
|
||||
func extractIdentity(idToken, accessToken, matchClaim string) (tokenIdentity, error) {
|
||||
pick := idToken
|
||||
if pick == "" {
|
||||
|
||||
@@ -44,7 +44,15 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
|
||||
// handleDeleteDevice 注销设备:删除 (user, name) 行 + Kick 在线 SSE + 立刻广播
|
||||
// 新的 presence。请求方若注销的是自己当前设备,前端需同步调用 logout,否则
|
||||
// 任何后续带 X-Device-Name 的认证请求都会被中间件再次 UPSERT 回来。
|
||||
//
|
||||
// 这是登录会话列表里「原生客户端(桌面 / iOS)」一栏的登出路径——它们用 IdP 令牌、
|
||||
// 不入 web_sessions,故按设备名吊销(AUTH.md §4)。注销设备属敏感动作,与吊销网页
|
||||
// 会话同口径要求最近一次 step-up(403 step_up_required,前端据此发起再认证)。
|
||||
func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"})
|
||||
return
|
||||
}
|
||||
claims, _ := jwtauth.ClaimsFromContext(r.Context())
|
||||
name := chi.URLParam(r, "name")
|
||||
if name == "" {
|
||||
|
||||
+67
-9
@@ -285,7 +285,9 @@ func (s *Server) handleQRRequest(w http.ResponseWriter, r *http.Request) {
|
||||
RequestIP: req.RequestIp,
|
||||
ExpiresAt: req.ExpiresAt,
|
||||
Status: req.Status,
|
||||
StepUp: s.cfg.StepUpEnabled,
|
||||
// Tell the approver UI to re-auth only if step-up is on AND this session
|
||||
// hasn't recently stepped up (within the window) — no repeated prompts (#3).
|
||||
StepUp: s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -309,10 +311,14 @@ func (s *Server) handleQRApprove(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Step-up: approving a new device into your account is sensitive. When enabled,
|
||||
// require a fresh prompt=login re-auth (the provider's 2FA, if configured, is
|
||||
// enforced during that exchange) within the freshness window (AUTH.md §6).
|
||||
if s.cfg.StepUpEnabled && !s.verifyStepUp(r, claims.UserID, body.StepUpCode, body.StepUpVerifier) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"})
|
||||
return
|
||||
// enforced during that exchange) — UNLESS this session already stepped up within
|
||||
// the freshness window, so the same session isn't re-prompted repeatedly (#3).
|
||||
if s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r) {
|
||||
if !s.verifyStepUp(r, claims.UserID, body.StepUpCode, body.StepUpVerifier) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"})
|
||||
return
|
||||
}
|
||||
s.recordStepUp(r)
|
||||
}
|
||||
|
||||
scope := "guest" // this milestone grants restricted guest sessions only
|
||||
@@ -367,6 +373,50 @@ func (s *Server) handleQRDeny(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// sessionFromCookie resolves the caller's web_session row from the cdrop_session
|
||||
// cookie. The scan-approval endpoints live under /api/auth/*, which the cookie's
|
||||
// Path covers, so both OIDC and self sessions can be located here.
|
||||
func (s *Server) sessionFromCookie(r *http.Request) (db.WebSession, bool) {
|
||||
c, err := r.Cookie(sessionCookieName)
|
||||
if err != nil || c.Value == "" {
|
||||
return db.WebSession{}, false
|
||||
}
|
||||
sess, err := s.queries.GetWebSession(r.Context(), sessionID(c.Value))
|
||||
if err != nil {
|
||||
return db.WebSession{}, false
|
||||
}
|
||||
return sess, true
|
||||
}
|
||||
|
||||
// sessionRecentlySteppedUp reports whether the caller's session passed step-up
|
||||
// within StepUpMaxAgeSeconds — so a sensitive action skips re-auth and the same
|
||||
// session isn't re-prompted repeatedly (#3, AUTH.md §6).
|
||||
func (s *Server) sessionRecentlySteppedUp(r *http.Request) bool {
|
||||
sess, ok := s.sessionFromCookie(r)
|
||||
if !ok || sess.SteppedUpAt == 0 {
|
||||
return false
|
||||
}
|
||||
window := int64(s.cfg.StepUpMaxAgeSeconds)
|
||||
if window <= 0 {
|
||||
window = 300
|
||||
}
|
||||
return time.Now().Unix()-sess.SteppedUpAt <= window
|
||||
}
|
||||
|
||||
// recordStepUp stamps the caller's session as freshly stepped-up.
|
||||
func (s *Server) recordStepUp(r *http.Request) {
|
||||
sess, ok := s.sessionFromCookie(r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.queries.SetSessionSteppedUp(r.Context(), db.SetSessionSteppedUpParams{
|
||||
SteppedUpAt: time.Now().Unix(),
|
||||
ID: sess.ID,
|
||||
}); err != nil {
|
||||
slog.Warn("record step-up failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// verifyStepUp exchanges a fresh prompt=login authorization code and confirms it
|
||||
// proves a recent interactive re-authentication by the same user: the id_token's
|
||||
// signature validates (JWKS), its sub matches the caller, and its auth_time is
|
||||
@@ -401,11 +451,19 @@ func (s *Server) verifyStepUp(r *http.Request, userID, code, verifier string) bo
|
||||
if sub != userID {
|
||||
return false
|
||||
}
|
||||
maxAge := int64(s.cfg.StepUpMaxAgeSeconds)
|
||||
if maxAge <= 0 {
|
||||
maxAge = 300
|
||||
// Enforce the freshness window only when the IdP supplied auth_time (Casdoor
|
||||
// omits it). When absent, the fresh single-use prompt=login code — exchanged
|
||||
// once, just now — is itself the bound on how recent the re-auth was.
|
||||
if authTime > 0 {
|
||||
maxAge := int64(s.cfg.StepUpMaxAgeSeconds)
|
||||
if maxAge <= 0 {
|
||||
maxAge = 300
|
||||
}
|
||||
if time.Now().Unix()-authTime > maxAge {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return time.Now().Unix()-authTime <= maxAge
|
||||
return true
|
||||
}
|
||||
|
||||
// lookupQRRequest fetches a request and verifies the approval_code in constant
|
||||
|
||||
+294
-1
@@ -12,11 +12,13 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
|
||||
"commilitia.net/cdrop/internal/config"
|
||||
"commilitia.net/cdrop/internal/db"
|
||||
"commilitia.net/cdrop/internal/hub"
|
||||
"commilitia.net/cdrop/internal/jwtauth"
|
||||
)
|
||||
|
||||
@@ -35,6 +37,7 @@ func newQRTestServer(t *testing.T) *Server {
|
||||
if err := db.Bootstrap(context.Background(), conn); err != nil {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
q := db.New(conn)
|
||||
return &Server{
|
||||
cfg: &config.Config{
|
||||
AuthMode: "prod", SessionSecret: qrTestSecret,
|
||||
@@ -42,7 +45,8 @@ func newQRTestServer(t *testing.T) *Server {
|
||||
QRGuestTTLSeconds: 3600, QRPersistTTLHours: 168,
|
||||
SessionTokenTTLSeconds: 900,
|
||||
},
|
||||
queries: db.New(conn),
|
||||
queries: q,
|
||||
hub: hub.New(q),
|
||||
sessionKey: deriveSessionKey(qrTestSecret),
|
||||
sessionTokenKey: jwtauth.DeriveSessionTokenKey(qrTestSecret),
|
||||
siteOrigin: "https://drop.example.net",
|
||||
@@ -222,6 +226,106 @@ func TestQRFlow_PendingLongPoll(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// noopAuthStore satisfies jwtauth.Store for middleware wiring in tests (no device
|
||||
// upsert happens without an X-Device-Name header).
|
||||
type noopAuthStore struct{}
|
||||
|
||||
func (noopAuthStore) UpsertDevice(context.Context, db.UpsertDeviceParams) error { return nil }
|
||||
func (noopAuthStore) GetShortcutToken(context.Context, string) (db.ShortcutToken, error) {
|
||||
return db.ShortcutToken{}, fmt.Errorf("none")
|
||||
}
|
||||
func (noopAuthStore) TouchShortcutTokenUsed(context.Context, db.TouchShortcutTokenUsedParams) error {
|
||||
return nil
|
||||
}
|
||||
func (noopAuthStore) GetWebSession(_ context.Context, id string) (db.WebSession, error) {
|
||||
return db.WebSession{ID: id}, nil // session always live in these tests
|
||||
}
|
||||
|
||||
// End-to-end through the REAL chain (auth.Middleware → requireFullSession): a
|
||||
// guest session token is rejected 403 on an account-management route, a full one
|
||||
// passes. This is the backend enforcement behind AUTH.md §3.2 (guest can't approve
|
||||
// devices / remove devices / mint tokens).
|
||||
func TestRequireFullSession_GuestBlockedFullPasses(t *testing.T) {
|
||||
secret := "test-session-secret-at-least-32-bytes-ok"
|
||||
srv := &Server{
|
||||
cfg: &config.Config{SessionTokenTTLSeconds: 900},
|
||||
sessionTokenKey: jwtauth.DeriveSessionTokenKey(secret),
|
||||
}
|
||||
guest, _, err := srv.mintSessionToken("u", "sid", "guest")
|
||||
if err != nil {
|
||||
t.Fatalf("mint guest: %v", err)
|
||||
}
|
||||
full, _, err := srv.mintSessionToken("u", "sid", "full")
|
||||
if err != nil {
|
||||
t.Fatalf("mint full: %v", err)
|
||||
}
|
||||
|
||||
a := jwtauth.New(&config.Config{AuthMode: "prod", SessionSecret: secret}, noopAuthStore{})
|
||||
handler := a.Middleware(requireFullSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})))
|
||||
probe := func(tok string) int {
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/approve", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+tok)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
return w.Code
|
||||
}
|
||||
if c := probe(guest); c != http.StatusForbidden {
|
||||
t.Errorf("guest token on requireFullSession route: got %d, want 403", c)
|
||||
}
|
||||
if c := probe(full); c != http.StatusOK {
|
||||
t.Errorf("full token on requireFullSession route: got %d, want 200", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Revoking a session is sensitive: with step-up enabled it is refused (403
|
||||
// step_up_required) until the caller's session has recently stepped up, then it
|
||||
// proceeds (here to 400 missing-id, i.e. past the gate). Step-up state is keyed on
|
||||
// the cookie session, so it is per-device (AUTH.md §1/§6).
|
||||
func TestSessionRevoke_RequiresStepUp(t *testing.T) {
|
||||
s := newQRTestServer(t)
|
||||
s.cfg.StepUpEnabled = true
|
||||
s.cfg.StepUpMaxAgeSeconds = 300
|
||||
|
||||
raw, id, err := newSessionToken()
|
||||
if err != nil {
|
||||
t.Fatalf("token: %v", err)
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
if err := s.queries.CreateSelfSession(context.Background(), db.CreateSelfSessionParams{
|
||||
ID: id, UserID: "u", DeviceName: "", UserAgent: "", Kind: "oidc", Scope: "full",
|
||||
GrantedBy: "", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600,
|
||||
}); err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
|
||||
revoke := func() int {
|
||||
r := httptest.NewRequest(http.MethodDelete, "/api/auth/sessions/x", nil)
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: raw})
|
||||
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(),
|
||||
&jwtauth.Claims{UserID: "u", SessionScope: "full"}))
|
||||
w := httptest.NewRecorder()
|
||||
s.handleSessionRevoke(w, r)
|
||||
return w.Code
|
||||
}
|
||||
|
||||
// Not stepped up → blocked before any deletion.
|
||||
if c := revoke(); c != http.StatusForbidden {
|
||||
t.Fatalf("revoke without step-up: got %d, want 403", c)
|
||||
}
|
||||
// Record step-up on this (cookie) session → gate now passes (400 = missing id,
|
||||
// reached past the step-up check).
|
||||
if err := s.queries.SetSessionSteppedUp(context.Background(), db.SetSessionSteppedUpParams{
|
||||
SteppedUpAt: now, ID: id,
|
||||
}); err != nil {
|
||||
t.Fatalf("set stepped up: %v", err)
|
||||
}
|
||||
if c := revoke(); c == http.StatusForbidden {
|
||||
t.Errorf("revoke after step-up still 403; gate did not honour stepped_up_at")
|
||||
}
|
||||
}
|
||||
|
||||
// selfTokenScope verifies a self-signed session token under the test's session
|
||||
// key (proving the signature) and returns its scope claim.
|
||||
func selfTokenScope(t *testing.T, token string) string {
|
||||
@@ -241,3 +345,192 @@ func selfTokenScope(t *testing.T, token string) string {
|
||||
scope, _ := custom["scope"].(string)
|
||||
return scope
|
||||
}
|
||||
|
||||
// revokeByID drives handleSessionRevoke with the chi {id} param and full claims,
|
||||
// step-up off (the gate is exercised separately).
|
||||
func revokeByID(s *Server, id, userID string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(http.MethodDelete, "/api/auth/sessions/"+id, nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", id)
|
||||
ctx := context.WithValue(r.Context(), chi.RouteCtxKey, rctx)
|
||||
ctx = jwtauth.ContextWithClaims(ctx, &jwtauth.Claims{UserID: userID, SessionScope: "full"})
|
||||
w := httptest.NewRecorder()
|
||||
s.handleSessionRevoke(w, r.WithContext(ctx))
|
||||
return w
|
||||
}
|
||||
|
||||
// Revoking a web session removes its device registration too, so a revoked device
|
||||
// disappears from the device list at once (the user can no longer remove devices
|
||||
// by hand). AUTH.md §4.
|
||||
func TestSessionRevoke_DeletesDevice(t *testing.T) {
|
||||
s := newQRTestServer(t) // step-up off by default
|
||||
ctx := context.Background()
|
||||
now := time.Now().Unix()
|
||||
|
||||
_, id, err := newSessionToken()
|
||||
if err != nil {
|
||||
t.Fatalf("token: %v", err)
|
||||
}
|
||||
if err := s.queries.CreateSelfSession(ctx, db.CreateSelfSessionParams{
|
||||
ID: id, UserID: "u", DeviceName: "Laptop", UserAgent: "", Kind: "oidc", Scope: "full",
|
||||
GrantedBy: "", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600,
|
||||
}); err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
if err := s.queries.UpsertDevice(ctx, db.UpsertDeviceParams{
|
||||
UserID: "u", Name: "Laptop", Type: "browser", LastSeen: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert device: %v", err)
|
||||
}
|
||||
|
||||
if w := revokeByID(s, id, "u"); w.Code != http.StatusNoContent {
|
||||
t.Fatalf("revoke: got %d %s, want 204", w.Code, w.Body.String())
|
||||
}
|
||||
if _, err := s.queries.GetWebSession(ctx, id); err == nil {
|
||||
t.Errorf("session still present after revoke")
|
||||
}
|
||||
devs, _ := s.queries.ListDevicesByUser(ctx, "u")
|
||||
for _, d := range devs {
|
||||
if d.Name == "Laptop" {
|
||||
t.Errorf("device 'Laptop' not deleted on session revoke")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The session list unifies web_sessions with native client devices (desktop / iOS)
|
||||
// that have no web_session, while never double-listing a device that already has a
|
||||
// session and excluding shortcut-token devices. AUTH.md §4.
|
||||
func TestSessionsList_IncludesNativeDevices(t *testing.T) {
|
||||
s := newQRTestServer(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().Unix()
|
||||
|
||||
_, id, err := newSessionToken()
|
||||
if err != nil {
|
||||
t.Fatalf("token: %v", err)
|
||||
}
|
||||
if err := s.queries.CreateSelfSession(ctx, db.CreateSelfSessionParams{
|
||||
ID: id, UserID: "u", DeviceName: "Web", UserAgent: "", Kind: "oidc", Scope: "full",
|
||||
GrantedBy: "", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600,
|
||||
}); err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
for _, d := range []struct{ name, typ string }{
|
||||
{"Web", "browser"}, // has a web_session — must not be double-listed
|
||||
{"Desk", "macos"}, // native, no session — must appear as native
|
||||
{"iShortcut", "shortcut"}, // scoped token — excluded from session list
|
||||
} {
|
||||
if err := s.queries.UpsertDevice(ctx, db.UpsertDeviceParams{
|
||||
UserID: "u", Name: d.name, Type: d.typ, LastSeen: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert device %s: %v", d.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/auth/sessions", nil)
|
||||
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(),
|
||||
&jwtauth.Claims{UserID: "u", SessionScope: "full"}))
|
||||
w := httptest.NewRecorder()
|
||||
s.handleSessionsList(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("sessions list: got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Sessions []sessionView `json:"sessions"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
var web, webNativeDup, desk, shortcut bool
|
||||
for _, v := range resp.Sessions {
|
||||
switch {
|
||||
case v.DeviceName == "Web" && !v.Native && v.Kind == "oidc":
|
||||
web = true
|
||||
case v.DeviceName == "Web" && v.Native:
|
||||
webNativeDup = true
|
||||
case v.DeviceName == "Desk" && v.Native && v.Kind == "macos":
|
||||
desk = true
|
||||
case v.DeviceName == "iShortcut":
|
||||
shortcut = true
|
||||
}
|
||||
}
|
||||
if !web {
|
||||
t.Errorf("web session for 'Web' missing")
|
||||
}
|
||||
if webNativeDup {
|
||||
t.Errorf("'Web' double-listed as a native device")
|
||||
}
|
||||
if !desk {
|
||||
t.Errorf("native device 'Desk' missing from session list")
|
||||
}
|
||||
if shortcut {
|
||||
t.Errorf("shortcut device leaked into session list")
|
||||
}
|
||||
}
|
||||
|
||||
// The sweeper drops browser devices whose web_session is gone (orphans) while
|
||||
// keeping browser devices that still have a live session and all native devices.
|
||||
func TestDeleteOrphanBrowserDevices(t *testing.T) {
|
||||
s := newQRTestServer(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().Unix()
|
||||
|
||||
// Browser "Kept" has a live session; browser "Orphan" has none; native "Desk"
|
||||
// (macos) legitimately has no session and must survive.
|
||||
_, id, err := newSessionToken()
|
||||
if err != nil {
|
||||
t.Fatalf("token: %v", err)
|
||||
}
|
||||
if err := s.queries.CreateSelfSession(ctx, db.CreateSelfSessionParams{
|
||||
ID: id, UserID: "u", DeviceName: "Kept", Kind: "oidc", Scope: "full",
|
||||
CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600,
|
||||
}); err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
for _, d := range []struct{ name, typ string }{
|
||||
{"Kept", "browser"}, {"Orphan", "browser"}, {"Desk", "macos"},
|
||||
} {
|
||||
if err := s.queries.UpsertDevice(ctx, db.UpsertDeviceParams{
|
||||
UserID: "u", Name: d.name, Type: d.typ, LastSeen: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert %s: %v", d.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.queries.DeleteOrphanBrowserDevices(ctx, now); err != nil {
|
||||
t.Fatalf("sweep: %v", err)
|
||||
}
|
||||
devs, _ := s.queries.ListDevicesByUser(ctx, "u")
|
||||
got := map[string]bool{}
|
||||
for _, d := range devs {
|
||||
got[d.Name] = true
|
||||
}
|
||||
if !got["Kept"] {
|
||||
t.Errorf("browser 'Kept' with a live session was wrongly swept")
|
||||
}
|
||||
if got["Orphan"] {
|
||||
t.Errorf("orphan browser 'Orphan' was not swept")
|
||||
}
|
||||
if !got["Desk"] {
|
||||
t.Errorf("native 'Desk' was wrongly swept (it keeps no web_session)")
|
||||
}
|
||||
}
|
||||
|
||||
// Removing a native device (the session list's logout path for desktop / iOS) is
|
||||
// sensitive and requires a recent step-up, mirroring web-session revocation.
|
||||
func TestDeleteDevice_RequiresStepUp(t *testing.T) {
|
||||
s := newQRTestServer(t)
|
||||
s.cfg.StepUpEnabled = true
|
||||
s.cfg.StepUpMaxAgeSeconds = 300
|
||||
|
||||
r := httptest.NewRequest(http.MethodDelete, "/api/devices/Desk", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("name", "Desk")
|
||||
ctx := context.WithValue(r.Context(), chi.RouteCtxKey, rctx)
|
||||
ctx = jwtauth.ContextWithClaims(ctx, &jwtauth.Claims{UserID: "u", SessionScope: "full"})
|
||||
w := httptest.NewRecorder()
|
||||
s.handleDeleteDevice(w, r.WithContext(ctx))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("delete device without step-up: got %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +176,13 @@ func (s *Server) routes() {
|
||||
r.Get("/auth/qr/request", s.handleQRRequest)
|
||||
r.Post("/auth/qr/approve", s.handleQRApprove)
|
||||
r.Post("/auth/qr/deny", s.handleQRDeny)
|
||||
// Session management: list logins with their permission level,
|
||||
// really revoke one (logout), and the standalone step-up the
|
||||
// revoke flow re-uses. All under /api/auth so the session cookie
|
||||
// (Path=/api/auth) is available for step-up state.
|
||||
r.Post("/auth/stepup", s.handleStepUp)
|
||||
r.Get("/auth/sessions", s.handleSessionsList)
|
||||
r.Delete("/auth/sessions/{id}", s.handleSessionRevoke)
|
||||
})
|
||||
|
||||
r.Route("/transfer", func(r chi.Router) {
|
||||
@@ -219,6 +226,10 @@ type meResp struct {
|
||||
UserID string `json:"user_id"`
|
||||
Groups []string `json:"groups"`
|
||||
DeviceName string `json:"device_name"`
|
||||
// Scope is the session's capability level: "guest" (scan-login borrow,
|
||||
// capability-limited) or "full" (normal login). The UI hides account-management
|
||||
// affordances on guest sessions (AUTH.md §3.2).
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -228,10 +239,15 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
device, _ := jwtauth.DeviceNameFromContext(r.Context())
|
||||
scope := "full"
|
||||
if claims.Guest() {
|
||||
scope = "guest"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, meResp{
|
||||
UserID: claims.UserID,
|
||||
Groups: claims.Groups,
|
||||
DeviceName: device,
|
||||
Scope: scope,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"commilitia.net/cdrop/internal/db"
|
||||
"commilitia.net/cdrop/internal/jwtauth"
|
||||
)
|
||||
|
||||
// Session management (AUTH.md §1, §6): list the caller's logins with their
|
||||
// capability level, and really revoke one (delete the row → its access token can
|
||||
// no longer refresh and dies within its short TTL). Revocation is a sensitive
|
||||
// action, so it requires a recent step-up. A standalone step-up endpoint lets the
|
||||
// UI establish that re-auth once (per session/device) and reuse it across actions
|
||||
// within the window — the same session isn't re-prompted repeatedly (#3).
|
||||
|
||||
type stepUpReq struct {
|
||||
Code string `json:"code"`
|
||||
Verifier string `json:"verifier"`
|
||||
}
|
||||
|
||||
// handleStepUp exchanges a fresh prompt=login code and, on success, stamps the
|
||||
// caller's session as stepped-up (per-session, cookie-keyed). Sensitive actions
|
||||
// within StepUpMaxAgeSeconds then skip re-auth. 403 on failure; 204 when step-up
|
||||
// is disabled (nothing to establish).
|
||||
func (s *Server) handleStepUp(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.cfg.StepUpEnabled {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
claims, _ := jwtauth.ClaimsFromContext(r.Context())
|
||||
var body stepUpReq
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
if !s.verifyStepUp(r, claims.UserID, body.Code, body.Verifier) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"})
|
||||
return
|
||||
}
|
||||
s.recordStepUp(r)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type sessionView struct {
|
||||
ID string `json:"id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
Kind string `json:"kind"` // oidc | self | guest | macos | windows | linux | ios
|
||||
Scope string `json:"scope"` // full | guest
|
||||
Current bool `json:"current"`
|
||||
Online bool `json:"online"` // device currently connected (SSE) — UI sorts online first
|
||||
// Native marks a device that authenticates with an IdP token and keeps no
|
||||
// web_session row (desktop / iOS). It is surfaced from the devices registry so
|
||||
// the session list covers every device; its "logout" routes to the device
|
||||
// endpoint rather than the web-session one.
|
||||
Native bool `json:"native"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
LastUsedAt int64 `json:"last_used_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
// isNativeDeviceType reports whether a device type is a real native client (one
|
||||
// that authenticates via the IdP and keeps no web_session). browser devices are
|
||||
// expected to carry a web_session; "shortcut" tokens have their own panel.
|
||||
func isNativeDeviceType(t string) bool {
|
||||
switch t {
|
||||
case "macos", "windows", "linux", "ios":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// handleSessionsList returns the caller's login sessions with their scope, so the
|
||||
// UI can show the permission level (完整 / 受限访客) and offer real logout. The list
|
||||
// is unified: web_sessions (browser / scan-login) PLUS native client devices
|
||||
// (desktop / iOS) that have no web_session of their own — so every logged-in
|
||||
// device appears in one place and orphaned device rows fall away (AUTH.md §4).
|
||||
func (s *Server) handleSessionsList(w http.ResponseWriter, r *http.Request) {
|
||||
claims, _ := jwtauth.ClaimsFromContext(r.Context())
|
||||
now := time.Now().Unix()
|
||||
|
||||
rows, err := s.queries.ListWebSessionsByUser(r.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
slog.Error("list sessions failed", "err", err, "user", claims.UserID)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
|
||||
return
|
||||
}
|
||||
current := ""
|
||||
if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" {
|
||||
current = sessionID(c.Value)
|
||||
}
|
||||
// Cookieless callers (native apps) can't match by cookie; fall back to their
|
||||
// own device name to mark the "current" row.
|
||||
callerDevice, _ := jwtauth.DeviceNameFromContext(r.Context())
|
||||
|
||||
out := make([]sessionView, 0, len(rows))
|
||||
named := make(map[string]struct{}, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.ExpiresAt < now {
|
||||
continue // hide expired sessions (reaped separately)
|
||||
}
|
||||
if row.DeviceName != "" {
|
||||
named[row.DeviceName] = struct{}{}
|
||||
}
|
||||
out = append(out, sessionView{
|
||||
ID: row.ID,
|
||||
DeviceName: row.DeviceName,
|
||||
Kind: row.Kind,
|
||||
Scope: row.Scope,
|
||||
Current: row.ID == current || (current == "" && row.DeviceName != "" && row.DeviceName == callerDevice),
|
||||
Online: row.DeviceName != "" && s.hub.Online(claims.UserID, row.DeviceName),
|
||||
CreatedAt: row.CreatedAt,
|
||||
LastUsedAt: row.LastUsedAt,
|
||||
ExpiresAt: row.ExpiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
// Native clients keep no web_session — surface them from the devices registry,
|
||||
// skipping any whose name already has a web_session (no double-listing).
|
||||
if devs, derr := s.queries.ListDevicesByUser(r.Context(), claims.UserID); derr == nil {
|
||||
for _, d := range devs {
|
||||
if !isNativeDeviceType(d.Type) {
|
||||
continue
|
||||
}
|
||||
if _, ok := named[d.Name]; ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, sessionView{
|
||||
ID: "device:" + d.Name,
|
||||
DeviceName: d.Name,
|
||||
Kind: d.Type,
|
||||
Scope: "full",
|
||||
Native: true,
|
||||
Current: current == "" && d.Name != "" && d.Name == callerDevice,
|
||||
Online: s.hub.Online(claims.UserID, d.Name),
|
||||
CreatedAt: d.LastSeen,
|
||||
LastUsedAt: d.LastSeen,
|
||||
ExpiresAt: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"sessions": out})
|
||||
}
|
||||
|
||||
// deviceNameHasLiveSession reports whether the user still has a non-expired
|
||||
// web_session bound to deviceName — used to avoid deleting a device that another
|
||||
// live session (e.g. a re-login that left the old row) still depends on.
|
||||
func (s *Server) deviceNameHasLiveSession(ctx context.Context, userID, deviceName string) bool {
|
||||
rows, err := s.queries.ListWebSessionsByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
for _, ws := range rows {
|
||||
if ws.DeviceName == deviceName && ws.ExpiresAt >= now {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleSessionRevoke deletes a session row (scoped to its owner), really
|
||||
// invalidating that login. Requires a recent step-up (403 step_up_required
|
||||
// otherwise). Kicks the device's live SSE so it drops immediately.
|
||||
func (s *Server) handleSessionRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"})
|
||||
return
|
||||
}
|
||||
claims, _ := jwtauth.ClaimsFromContext(r.Context())
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"})
|
||||
return
|
||||
}
|
||||
// Read the device name before deletion so we can kick its SSE.
|
||||
deviceName := ""
|
||||
if sess, err := s.queries.GetWebSession(r.Context(), id); err == nil && sess.UserID == claims.UserID {
|
||||
deviceName = sess.DeviceName
|
||||
}
|
||||
n, err := s.queries.DeleteWebSessionForUser(r.Context(), db.DeleteWebSessionForUserParams{
|
||||
ID: id,
|
||||
UserID: claims.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("revoke session failed", "err", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if deviceName != "" {
|
||||
// Drop the device registration too, so a revoked device disappears from the
|
||||
// device list at once (we no longer remove devices by hand) — unless another
|
||||
// live session still uses this name.
|
||||
if !s.deviceNameHasLiveSession(r.Context(), claims.UserID, deviceName) {
|
||||
if _, derr := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{
|
||||
UserID: claims.UserID,
|
||||
Name: deviceName,
|
||||
}); derr != nil {
|
||||
slog.Warn("delete device on session revoke failed", "err", derr)
|
||||
}
|
||||
}
|
||||
s.hub.Kick(claims.UserID, deviceName)
|
||||
s.hub.PublishPresence(r.Context(), claims.UserID)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -11,10 +11,14 @@ import (
|
||||
// DeviceSweepInterval is how often the device sweeper wakes up.
|
||||
const DeviceSweepInterval = 1 * time.Hour
|
||||
|
||||
// RunDeviceSweeper deletes devices whose last_seen is older than ttl.
|
||||
// Per user requirement: device registration must not outlive the longest
|
||||
// valid refresh_token (brief §2: 196h sliding window). Anything ≤ TTL is fine;
|
||||
// 0 disables the sweeper.
|
||||
// RunDeviceSweeper prunes the devices table on a timer so it stays aligned with
|
||||
// the session list (AUTH.md §4): it drops (1) devices whose last_seen is older
|
||||
// than ttl — registration must not outlive the longest valid refresh_token
|
||||
// (brief §2: 196h sliding window) — and (2) browser devices whose web_session is
|
||||
// gone (revoked or expired), which would otherwise linger until the stale cutoff
|
||||
// now that users can no longer remove devices by hand. Native and shortcut
|
||||
// devices keep no web_session and are pruned by the stale cutoff only. ttl ≤ 0
|
||||
// disables the sweeper.
|
||||
func RunDeviceSweeper(ctx context.Context, queries *db.Queries, ttl time.Duration) {
|
||||
if ttl <= 0 {
|
||||
slog.Info("device sweeper disabled (ttl <= 0)")
|
||||
@@ -29,10 +33,14 @@ func RunDeviceSweeper(ctx context.Context, queries *db.Queries, ttl time.Duratio
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
cutoff := time.Now().Add(-ttl).Unix()
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-ttl).Unix()
|
||||
if err := queries.DeleteStaleDevices(ctx, cutoff); err != nil {
|
||||
slog.Error("device sweeper failed", "err", err)
|
||||
}
|
||||
if _, err := queries.DeleteOrphanBrowserDevices(ctx, now.Unix()); err != nil {
|
||||
slog.Error("device sweeper: orphan browser cleanup failed", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,16 @@ import (
|
||||
"commilitia.net/cdrop/internal/db"
|
||||
)
|
||||
|
||||
// Store is the subset of *db.Queries the auth middleware uses: device upserts
|
||||
// plus the shortcut-token lookups needed to honour revocation. Declared as an
|
||||
// interface so tests can swap in a fake.
|
||||
// Store is the subset of *db.Queries the auth middleware uses: device upserts,
|
||||
// the shortcut-token lookups needed to honour revocation, and the web_session
|
||||
// lookup that makes a self-signed session token's revocation take effect at once
|
||||
// (a deleted row → the token is rejected on its next request, not after TTL).
|
||||
// Declared as an interface so tests can swap in a fake.
|
||||
type Store interface {
|
||||
UpsertDevice(ctx context.Context, arg db.UpsertDeviceParams) error
|
||||
GetShortcutToken(ctx context.Context, jti string) (db.ShortcutToken, error)
|
||||
TouchShortcutTokenUsed(ctx context.Context, arg db.TouchShortcutTokenUsedParams) error
|
||||
GetWebSession(ctx context.Context, id string) (db.WebSession, error)
|
||||
}
|
||||
|
||||
type Authenticator struct {
|
||||
@@ -108,7 +111,7 @@ func (a *Authenticator) verify(ctx context.Context, token string, r *http.Reques
|
||||
// cdrop self-signed session tokens first: distinct key + typ=session, so a
|
||||
// shortcut token (different key, requires jti) never validates here and a
|
||||
// session token never falls through to the shortcut path's DB lookup.
|
||||
if c, err := a.verifySelfToken(token); err == nil {
|
||||
if c, err := a.verifySelfToken(ctx, token); err == nil {
|
||||
return c, nil
|
||||
}
|
||||
if c, err := a.verifyHS256(ctx, token); err == nil {
|
||||
@@ -119,9 +122,13 @@ func (a *Authenticator) verify(ctx context.Context, token string, r *http.Reques
|
||||
|
||||
// verifySelfToken validates a cdrop self-signed session access token (AUTH.md
|
||||
// §3.1): HS256 over DeriveSessionTokenKey, carrying typ=session and a full/guest
|
||||
// scope. Stateless by design — no DB lookup, so it stays cheap on every request;
|
||||
// revocation rides the short TTL (the session row is re-checked at /auth/refresh).
|
||||
func (a *Authenticator) verifySelfToken(token string) (*Claims, error) {
|
||||
// scope. This is the unified browser token — scan-login (self/guest) AND OIDC web
|
||||
// logins both ride it now, so the browser holds one token type. After the cheap
|
||||
// signature/exp/scope checks it does ONE indexed lookup of the token's sid against
|
||||
// web_sessions: a deleted row means the session was revoked, and the token is
|
||||
// rejected on its very next request (immediate "log out this device"). The IdP
|
||||
// RS256 path (verifyRS256) remains only for the desktop client's loopback tokens.
|
||||
func (a *Authenticator) verifySelfToken(ctx context.Context, token string) (*Claims, error) {
|
||||
if len(a.sessionTokenKey) == 0 {
|
||||
return nil, errors.New("session token key not configured")
|
||||
}
|
||||
@@ -147,6 +154,16 @@ func (a *Authenticator) verifySelfToken(token string) (*Claims, error) {
|
||||
if scope != "full" && scope != "guest" {
|
||||
return nil, errors.New("session token invalid scope")
|
||||
}
|
||||
// Bind the token to its live web_session row (sid): once that row is revoked
|
||||
// (deleted), the token is rejected on its very next request — "log out this
|
||||
// device" takes effect immediately rather than after the access token's TTL.
|
||||
sid, _ := custom["sid"].(string)
|
||||
if sid == "" {
|
||||
return nil, errors.New("session token missing sid")
|
||||
}
|
||||
if _, err := a.store.GetWebSession(ctx, sid); err != nil {
|
||||
return nil, errors.New("session revoked")
|
||||
}
|
||||
return &Claims{UserID: std.Subject, SessionScope: scope}, nil
|
||||
}
|
||||
|
||||
@@ -265,8 +282,9 @@ func (a *Authenticator) verifyRS256(ctx context.Context, token string) (*Claims,
|
||||
|
||||
// VerifyIDToken validates an OIDC id_token (RS256 via JWKS, issuer + audience)
|
||||
// from a fresh prompt=login exchange and returns its subject and auth_time (the
|
||||
// epoch second of the actual end-user authentication). Used for step-up re-auth
|
||||
// (AUTH.md §6): the caller checks sub matches and auth_time is recent enough.
|
||||
// epoch second of the actual end-user authentication, or 0 when the IdP omits the
|
||||
// claim — Casdoor does). Used for step-up re-auth (AUTH.md §6): the caller checks
|
||||
// sub matches and, only when auth_time is present, that it is recent enough.
|
||||
func (a *Authenticator) VerifyIDToken(ctx context.Context, token string) (subject string, authTime int64, err error) {
|
||||
if a.jwks == nil {
|
||||
return "", 0, errors.New("JWKS not configured")
|
||||
@@ -300,10 +318,10 @@ func (a *Authenticator) VerifyIDToken(ctx context.Context, token string) (subjec
|
||||
if std.Subject == "" {
|
||||
return "", 0, errors.New("missing subject claim")
|
||||
}
|
||||
at, ok := numericClaim(custom["auth_time"])
|
||||
if !ok {
|
||||
return "", 0, errors.New("missing auth_time claim")
|
||||
}
|
||||
// auth_time is OPTIONAL: required by OIDC only when the IdP chooses to honour
|
||||
// max_age, and Casdoor omits it entirely. Absent → 0; the caller treats the
|
||||
// fresh single-use prompt=login code as the freshness bound instead.
|
||||
at, _ := numericClaim(custom["auth_time"])
|
||||
return std.Subject, at, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,10 @@ import (
|
||||
// shortcut-token lookups from an in-memory map (empty → "not found", so plain
|
||||
// device-only tests are unaffected).
|
||||
type fakeDeviceUpserter struct {
|
||||
calls []db.UpsertDeviceParams
|
||||
err error
|
||||
tokens map[string]db.ShortcutToken
|
||||
calls []db.UpsertDeviceParams
|
||||
err error
|
||||
tokens map[string]db.ShortcutToken
|
||||
revokedSIDs map[string]bool
|
||||
}
|
||||
|
||||
func (f *fakeDeviceUpserter) UpsertDevice(_ context.Context, arg db.UpsertDeviceParams) error {
|
||||
@@ -45,6 +46,15 @@ func (f *fakeDeviceUpserter) TouchShortcutTokenUsed(_ context.Context, _ db.Touc
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetWebSession backs the self-token revocation check. revokedSIDs lets a test
|
||||
// simulate a revoked session (deleted row); any other sid resolves to a live row.
|
||||
func (f *fakeDeviceUpserter) GetWebSession(_ context.Context, id string) (db.WebSession, error) {
|
||||
if f.revokedSIDs[id] {
|
||||
return db.WebSession{}, sql.ErrNoRows
|
||||
}
|
||||
return db.WebSession{ID: id, UserID: "user-x"}, nil
|
||||
}
|
||||
|
||||
// echo handler that responds with the claims+device discovered in context.
|
||||
func echoMe(w http.ResponseWriter, r *http.Request) {
|
||||
claims, _ := ClaimsFromContext(r.Context())
|
||||
@@ -489,6 +499,26 @@ func TestSanitizeDeviceName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A self-signed session token is bound to its web_session row: once the row is
|
||||
// revoked (deleted), the token is rejected on its next request even though it is
|
||||
// still cryptographically valid and unexpired — "log out this device" is immediate
|
||||
// (AUTH.md §1/§3.1).
|
||||
func TestSelfToken_RejectedAfterSessionRevoked(t *testing.T) {
|
||||
secret := "test-session-secret-at-least-32-bytes-long"
|
||||
exp := time.Now().Add(time.Hour)
|
||||
|
||||
live := New(&config.Config{AuthMode: "prod", SessionSecret: secret}, &fakeDeviceUpserter{})
|
||||
if c, _ := serveBearer(live, signSelfToken(t, secret, "user-x", "session", "full", exp)); c != http.StatusOK {
|
||||
t.Fatalf("token with a live session: got %d, want 200", c)
|
||||
}
|
||||
|
||||
revoked := New(&config.Config{AuthMode: "prod", SessionSecret: secret},
|
||||
&fakeDeviceUpserter{revokedSIDs: map[string]bool{"test-sid": true}})
|
||||
if c, _ := serveBearer(revoked, signSelfToken(t, secret, "user-x", "session", "full", exp)); c != http.StatusUnauthorized {
|
||||
t.Errorf("token whose session was revoked must be rejected, got %d", c)
|
||||
}
|
||||
}
|
||||
|
||||
// signSelfToken mints a cdrop self-signed session token the way httpapi.mintSessionToken
|
||||
// does: HS256 over DeriveSessionTokenKey, with typ + scope custom claims and no jti.
|
||||
func signSelfToken(t *testing.T, secret, sub, typ, scope string, exp time.Time) string {
|
||||
@@ -505,7 +535,7 @@ func signSelfToken(t *testing.T, secret, sub, typ, scope string, exp time.Time)
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Expiry: jwt.NewNumericDate(exp),
|
||||
}
|
||||
tok, err := jwt.Signed(sig).Claims(std).Claims(map[string]any{"typ": typ, "scope": scope}).Serialize()
|
||||
tok, err := jwt.Signed(sig).Claims(std).Claims(map[string]any{"typ": typ, "scope": scope, "sid": "test-sid"}).Serialize()
|
||||
if err != nil {
|
||||
t.Fatalf("serialize: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user