登录子系统: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:
+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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user