018a77b13a
接续 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
455 lines
16 KiB
Go
455 lines
16 KiB
Go
package jwtauth
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"crypto/subtle"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"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"
|
||
)
|
||
|
||
// 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 {
|
||
cfg *config.Config
|
||
store Store
|
||
jwks *jwksCache
|
||
hsKey []byte
|
||
sessionTokenKey []byte
|
||
}
|
||
|
||
func New(cfg *config.Config, store Store) *Authenticator {
|
||
a := &Authenticator{
|
||
cfg: cfg,
|
||
store: store,
|
||
}
|
||
if cfg.HS256Secret != "" {
|
||
a.hsKey = DeriveHS256Key(cfg.HS256Secret)
|
||
}
|
||
// Self-signed session tokens (scan-login) are keyed off SessionSecret; nil
|
||
// when unset (dev) disables verifySelfToken, matching the minting side.
|
||
a.sessionTokenKey = DeriveSessionTokenKey(cfg.SessionSecret)
|
||
if cfg.AuthMode == "prod" && cfg.OIDCJWKSURL != "" {
|
||
a.jwks = newJWKSCache(cfg.OIDCJWKSURL, 10*time.Minute)
|
||
}
|
||
return a
|
||
}
|
||
|
||
func (a *Authenticator) Middleware(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
token, ok := bearerToken(r)
|
||
if !ok {
|
||
unauthorized(w, "missing bearer token")
|
||
return
|
||
}
|
||
|
||
claims, err := a.verify(r.Context(), token, r)
|
||
if err != nil {
|
||
slog.Warn("auth failed", "err", err, "path", r.URL.Path)
|
||
unauthorized(w, "invalid token")
|
||
return
|
||
}
|
||
|
||
deviceName := SanitizeDeviceName(r.Header.Get("X-Device-Name"))
|
||
deviceType := normalizeDeviceType(r.Header.Get("X-Device-Type"))
|
||
if claims.Scoped() {
|
||
// The backend authoritatively knows a scoped token is the iOS
|
||
// Shortcut, so it labels the device "shortcut" regardless of any
|
||
// header. ("ios" stays reserved for a real native client.)
|
||
deviceType = "shortcut"
|
||
}
|
||
|
||
// Register a device only when the client names itself (X-Device-Name).
|
||
// A nameless request — e.g. a polling shortcut hitting /api/clipboard/version
|
||
// without the header — must NOT be registered: the old random UA fallback
|
||
// minted a fresh "Unknown Device" row on every request and flooded the list.
|
||
if deviceName != "" {
|
||
if err := a.store.UpsertDevice(r.Context(), db.UpsertDeviceParams{
|
||
UserID: claims.UserID,
|
||
Name: deviceName,
|
||
Type: deviceType,
|
||
LastSeen: time.Now().Unix(),
|
||
}); err != nil {
|
||
// non-fatal: log and continue so transient DB errors don't 401 users
|
||
slog.Error("device upsert failed",
|
||
"err", err, "user", claims.UserID, "device", deviceName)
|
||
}
|
||
}
|
||
|
||
ctx := context.WithValue(r.Context(), claimsCtxKey, claims)
|
||
ctx = context.WithValue(ctx, deviceCtxKey, deviceName)
|
||
ctx = context.WithValue(ctx, deviceTypeCtxKey, deviceType)
|
||
next.ServeHTTP(w, r.WithContext(ctx))
|
||
})
|
||
}
|
||
|
||
func (a *Authenticator) verify(ctx context.Context, token string, r *http.Request) (*Claims, error) {
|
||
if a.cfg.AuthMode == "dev" {
|
||
return a.verifyDev(token, r)
|
||
}
|
||
// 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(ctx, token); err == nil {
|
||
return c, nil
|
||
}
|
||
if c, err := a.verifyHS256(ctx, token); err == nil {
|
||
return c, nil
|
||
}
|
||
return a.verifyRS256(ctx, token)
|
||
}
|
||
|
||
// verifySelfToken validates a cdrop self-signed session access token (AUTH.md
|
||
// §3.1): HS256 over DeriveSessionTokenKey, carrying typ=session and a full/guest
|
||
// 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")
|
||
}
|
||
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var std jwt.Claims
|
||
custom := map[string]any{}
|
||
if err := parsed.Claims(a.sessionTokenKey, &std, &custom); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := std.ValidateWithLeeway(jwt.Expected{Time: time.Now()}, 30*time.Second); err != nil {
|
||
return nil, err
|
||
}
|
||
if typ, _ := custom["typ"].(string); typ != "session" {
|
||
return nil, errors.New("not a session token")
|
||
}
|
||
if std.Subject == "" {
|
||
return nil, errors.New("session token missing subject")
|
||
}
|
||
scope, _ := custom["scope"].(string)
|
||
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
|
||
}
|
||
|
||
func (a *Authenticator) verifyDev(token string, r *http.Request) (*Claims, error) {
|
||
if subtle.ConstantTimeCompare([]byte(token), []byte(a.cfg.DevToken)) != 1 {
|
||
return nil, errors.New("invalid dev token")
|
||
}
|
||
userID := r.Header.Get("X-Dev-User")
|
||
if userID == "" {
|
||
userID = "dev-user"
|
||
}
|
||
return &Claims{UserID: userID, Groups: []string{"dev"}}, nil
|
||
}
|
||
|
||
func (a *Authenticator) verifyHS256(ctx context.Context, token string) (*Claims, error) {
|
||
if len(a.hsKey) == 0 {
|
||
return nil, errors.New("HS256 secret not configured")
|
||
}
|
||
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var std jwt.Claims
|
||
custom := map[string]any{}
|
||
if err := parsed.Claims(a.hsKey, &std, &custom); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := std.ValidateWithLeeway(jwt.Expected{Time: time.Now()}, 30*time.Second); err != nil {
|
||
return nil, err
|
||
}
|
||
claims, err := claimsFromJWT(std, custom)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// HS256 is only ever used to mint scoped shortcut tokens, and those ALWAYS
|
||
// carry a jti. A validly-signed HS256 token without one must be rejected
|
||
// outright: otherwise it would fall through here as a full, unscoped account
|
||
// session with a self-declared subject — far beyond the clipboard-only
|
||
// surface HS256 is meant for. Requiring the jti keeps a leaked HS256 secret's
|
||
// blast radius pinned to the shortcut scope (clipboard, and nothing else).
|
||
if std.ID == "" {
|
||
return nil, errors.New("HS256 token missing jti")
|
||
}
|
||
|
||
// The signature alone is not enough — the token must still be present and not
|
||
// revoked in the store, so a leaked or retired token can be killed
|
||
// server-side. The stored row is also the authoritative source of scopes
|
||
// (never trust scopes off the wire).
|
||
row, err := a.store.GetShortcutToken(ctx, std.ID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("shortcut token lookup: %w", err)
|
||
}
|
||
if row.Revoked != 0 {
|
||
return nil, errors.New("shortcut token revoked")
|
||
}
|
||
if row.UserID != claims.UserID {
|
||
return nil, errors.New("shortcut token subject mismatch")
|
||
}
|
||
claims.JTI = std.ID
|
||
claims.Scopes = strings.Fields(row.Scopes)
|
||
a.touchTokenAsync(std.ID)
|
||
return claims, nil
|
||
}
|
||
|
||
// touchTokenAsync records a shortcut token's last-use time off the request path
|
||
// — it's audit metadata, so a slow or failing write must never delay or fail
|
||
// the request it belongs to.
|
||
func (a *Authenticator) touchTokenAsync(jti string) {
|
||
now := time.Now().Unix()
|
||
go func() {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
if err := a.store.TouchShortcutTokenUsed(ctx, db.TouchShortcutTokenUsedParams{
|
||
LastUsedAt: &now,
|
||
Jti: jti,
|
||
}); err != nil {
|
||
slog.Warn("touch shortcut token failed", "err", err, "jti", jti)
|
||
}
|
||
}()
|
||
}
|
||
|
||
func (a *Authenticator) verifyRS256(ctx context.Context, token string) (*Claims, error) {
|
||
if a.jwks == nil {
|
||
return nil, errors.New("JWKS not configured")
|
||
}
|
||
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.RS256})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(parsed.Headers) == 0 {
|
||
return nil, errors.New("missing token headers")
|
||
}
|
||
kid := parsed.Headers[0].KeyID
|
||
key, err := a.jwks.GetKey(ctx, kid)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var std jwt.Claims
|
||
custom := map[string]any{}
|
||
if err := parsed.Claims(key, &std, &custom); err != nil {
|
||
return nil, err
|
||
}
|
||
expected := jwt.Expected{Time: time.Now()}
|
||
if a.cfg.OIDCIssuer != "" {
|
||
expected.Issuer = a.cfg.OIDCIssuer
|
||
}
|
||
if a.cfg.OIDCAudience != "" {
|
||
expected.AnyAudience = parseAudiences(a.cfg.OIDCAudience)
|
||
}
|
||
if err := std.ValidateWithLeeway(expected, 30*time.Second); err != nil {
|
||
return nil, err
|
||
}
|
||
return claimsFromJWT(std, custom)
|
||
}
|
||
|
||
// 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, 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")
|
||
}
|
||
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.RS256})
|
||
if err != nil {
|
||
return "", 0, err
|
||
}
|
||
if len(parsed.Headers) == 0 {
|
||
return "", 0, errors.New("missing token headers")
|
||
}
|
||
key, err := a.jwks.GetKey(ctx, parsed.Headers[0].KeyID)
|
||
if err != nil {
|
||
return "", 0, err
|
||
}
|
||
var std jwt.Claims
|
||
custom := map[string]any{}
|
||
if err := parsed.Claims(key, &std, &custom); err != nil {
|
||
return "", 0, err
|
||
}
|
||
expected := jwt.Expected{Time: time.Now()}
|
||
if a.cfg.OIDCIssuer != "" {
|
||
expected.Issuer = a.cfg.OIDCIssuer
|
||
}
|
||
if a.cfg.OIDCAudience != "" {
|
||
expected.AnyAudience = parseAudiences(a.cfg.OIDCAudience)
|
||
}
|
||
if err := std.ValidateWithLeeway(expected, 30*time.Second); err != nil {
|
||
return "", 0, err
|
||
}
|
||
if std.Subject == "" {
|
||
return "", 0, errors.New("missing subject 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
|
||
}
|
||
|
||
// numericClaim coerces a JSON number claim (float64 from stdlib unmarshal, or
|
||
// json.Number / int64) to int64.
|
||
func numericClaim(v any) (int64, bool) {
|
||
switch n := v.(type) {
|
||
case float64:
|
||
return int64(n), true
|
||
case int64:
|
||
return n, true
|
||
case json.Number:
|
||
if i, err := n.Int64(); err == nil {
|
||
return i, true
|
||
}
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
// parseAudiences splits a comma-separated OIDCAudience config into a jwt.Audience
|
||
// set. Multiple values let one backend accept tokens minted for several OAuth
|
||
// clients (the web app and the desktop client carry different `aud`); go-jose's
|
||
// AnyAudience passes when the token's audience matches any one entry. Whitespace
|
||
// around entries is trimmed and empties dropped, so a plain single value behaves
|
||
// exactly as before.
|
||
func parseAudiences(raw string) jwt.Audience {
|
||
parts := strings.Split(raw, ",")
|
||
out := make(jwt.Audience, 0, len(parts))
|
||
for _, p := range parts {
|
||
if s := strings.TrimSpace(p); s != "" {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func claimsFromJWT(std jwt.Claims, custom map[string]any) (*Claims, error) {
|
||
if std.Subject == "" {
|
||
return nil, errors.New("missing subject claim")
|
||
}
|
||
c := &Claims{UserID: std.Subject}
|
||
if g, ok := custom["groups"].([]any); ok {
|
||
for _, item := range g {
|
||
if s, ok := item.(string); ok {
|
||
c.Groups = append(c.Groups, s)
|
||
}
|
||
}
|
||
}
|
||
return c, nil
|
||
}
|
||
|
||
func bearerToken(r *http.Request) (string, bool) {
|
||
h := r.Header.Get("Authorization")
|
||
const prefix = "Bearer "
|
||
if !strings.HasPrefix(h, prefix) {
|
||
return "", false
|
||
}
|
||
tok := strings.TrimPrefix(h, prefix)
|
||
if tok == "" {
|
||
return "", false
|
||
}
|
||
return tok, true
|
||
}
|
||
|
||
func unauthorized(w http.ResponseWriter, reason string) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.Header().Set("WWW-Authenticate", `Bearer realm="cdrop"`)
|
||
w.WriteHeader(http.StatusUnauthorized)
|
||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||
"error": "unauthorized",
|
||
"reason": reason,
|
||
})
|
||
}
|
||
|
||
// DeriveHS256Key turns a configured secret of any length into a fixed 32-byte
|
||
// HMAC key. HS256 requires >= 32 bytes; hashing guarantees that (and keeps the
|
||
// minting and verifying sides in lockstep) so a short CDROP_HS256_SECRET can't
|
||
// make shortcut-token signing fail. Both signing (httpapi) and verifying use it.
|
||
func DeriveHS256Key(secret string) []byte {
|
||
sum := sha256.Sum256([]byte(secret))
|
||
return sum[:]
|
||
}
|
||
|
||
// DeriveSessionTokenKey derives the HMAC key for cdrop's self-signed session
|
||
// access tokens from CDROP_SESSION_SECRET. Domain-separated from both the at-rest
|
||
// refresh-token AES key (plain sha256(secret), in httpapi) and the shortcut-token
|
||
// key (DeriveHS256Key) so one secret yields three independent keys — a session
|
||
// token can never validate as a shortcut token, or vice versa. Empty secret →
|
||
// nil, which disables both minting and verifying (dev / unconfigured prod).
|
||
func DeriveSessionTokenKey(secret string) []byte {
|
||
if secret == "" {
|
||
return nil
|
||
}
|
||
sum := sha256.Sum256([]byte("cdrop-session-jwt\x00" + secret))
|
||
return sum[:]
|
||
}
|
||
|
||
// SanitizeDeviceName enforces the global ASCII-only device-name policy. Device
|
||
// names ride in the X-Device-Name HTTP header, which can't carry non-ASCII
|
||
// reliably (and browser fetch rejects such header values outright), so the name
|
||
// is restricted to printable ASCII everywhere. Here we keep only printable ASCII
|
||
// (0x20–0x7E), trim, and cap the length as a server-side backstop; clients also
|
||
// validate the name up front for a clear error. Empty after sanitising → the
|
||
// caller falls back to a UA-derived default.
|
||
func SanitizeDeviceName(raw string) string {
|
||
var b strings.Builder
|
||
for _, r := range raw {
|
||
if r >= 0x20 && r <= 0x7E {
|
||
b.WriteRune(r)
|
||
}
|
||
}
|
||
name := strings.TrimSpace(b.String())
|
||
if len(name) > 64 {
|
||
name = strings.TrimSpace(name[:64])
|
||
}
|
||
return name
|
||
}
|
||
|
||
// normalizeDeviceType whitelists the client-declared X-Device-Type so a device
|
||
// row only ever carries a known kind; anything unrecognised (incl. empty) falls
|
||
// back to "browser", the default web client. Desktop clients send macos/windows;
|
||
// "ios" is reserved for a future native iOS client (the Shortcut never sends it).
|
||
func normalizeDeviceType(raw string) string {
|
||
switch t := strings.ToLower(strings.TrimSpace(raw)); t {
|
||
case "macos", "windows", "linux", "ios", "browser":
|
||
return t
|
||
default:
|
||
return "browser"
|
||
}
|
||
}
|