登录子系统: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:
2026-06-22 11:55:02 +08:00
parent 90a3790a98
commit 018a77b13a
45 changed files with 2235 additions and 299 deletions
+63 -21
View File
@@ -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 == "" {
+8
View File
@@ -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-up403 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
View File
@@ -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
View File
@@ -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)
}
}
+16
View File
@@ -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,
})
}
+218
View File
@@ -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)
}