diff --git a/docs/mobile-api/deployment.md b/docs/mobile-api/deployment.md new file mode 100644 index 0000000..171ed71 --- /dev/null +++ b/docs/mobile-api/deployment.md @@ -0,0 +1,91 @@ +# MeBox Mobile API 接入与部署说明 + +更新日期:2026-10-17 +适用版本:后端 `feature/android-mobile-api` 分支;App `0.1.0`(versionCode 1)。 + +## 1. 路径与版本 + +- 安卓契约挂载在 `/api/v1`,与既有 `/api`、Emby/Jellyfin 入口并行。 +- 健康检查:`GET /api/health`。 +- 实例身份:`GET /api/v1/server-info`(匿名,`Cache-Control: no-store`)。 +- 契约字段与错误码见同目录 `openapi.yaml`。 + +`server-info.capabilities` 必须反映真实运行能力。当前分支只有 +`device_sessions=true`;播放会话、阅读 v1 进度、批量正文与移动端转码仍为 +`false`,App 不得据此进入对应流程。 + +## 2. 连接与传输安全 + +- 公网必须使用 HTTPS;App 默认使用系统信任链,不提供全局跳过证书校验。 +- 局域网 HTTP 需要用户在 App 内明确确认风险;App 只对当前配置的服务器来源 + 使用明文,不会把 MeBox `Authorization` 发往其它主机。 +- 首版只支持根路径部署。`https://host/mebox` 这类子路径会被 App 明确拒绝, + 不会静默截断为根路径。 +- 反代需要保留 `X-Request-ID`(可选);服务端会回传同值或新生成值,用于 + 日志对账。 + +## 3. 反向代理检查项 + +以下项用于媒体阶段(M3/M4)回归,当前登录闭环不依赖它们,但仍建议提前配置: + +| 项目 | 要求 | +| --- | --- | +| Range / 206 | 透传客户端 `Range`,不要缓存完整响应后改写状态码 | +| 流式响应 | 关闭代理缓冲(如 nginx `proxy_buffering off`) | +| HLS | `.m3u8` 与分片路径不要被改写或强制压缩 | +| WebSocket | 升级头透传,读超时大于心跳间隔 | +| 超时 | 登录/普通 API 30 秒内完成;转码起播超时单独配置 | + +目前只验证了普通 JSON 端点;上表其余项尚未在本分支验收。 + +## 4. 认证与会话 + +- `POST /api/v1/auth/login` 创建 `auth_sessions` 记录,访问令牌 JWT 增加 `sid`。 +- 旧 `/api` 中间件同样校验 `sid`:设备被踢出或退出后,未过期的 JWT 也会立即 + 返回 401,不能借旧入口绕过。 +- 没有 `sid` 的历史令牌继续按原逻辑访问旧 API;v1 认证端点拒绝这类令牌, + App 需要重新登录。 +- `POST /api/v1/auth/logout` 幂等;会话撤销会同时撤销其名下刷新令牌。 +- 设备管理中的踢出操作会同步撤销该设备认证会话。 + +### 刷新轮换边界 + +- 同一个刷新令牌的并发请求共享一次轮换;60 秒宽限期内重复提交返回同一结果。 +- 轮换结果仍保存在进程内。若新令牌已签发、旧令牌已撤销,但客户端没收到响应 + 且服务器随后重启,旧令牌可能无法恢复;此时 App 应要求重新登录。首版接受 + 该边界,未实现持久化幂等轮换。 +- 会话被撤销时,刷新返回 `401 SESSION_REVOKED`,优先于宽限期复用。 + +## 5. 错误契约 + +统一响应: + +```json +{ + "code": "SESSION_REVOKED", + "message": "auth session revoked or expired", + "request_id": "8a3bade4-cbb3-485b-8e34-2471e4940fc3", + "retryable": false +} +``` + +- 401 由 `code` 区分 `TOKEN_EXPIRED`、`SESSION_REVOKED`、`INVALID_CREDENTIALS`。 +- 403 表示权限或账号状态,不触发刷新。 +- 429 带 `Retry-After`,客户端必须等待。 +- 5xx / `SERVICE_UNAVAILABLE` / `retryable=true` 表示可安全重试。 + +## 6. 数据迁移与回滚 + +- 新表 `auth_sessions` 与 `refresh_tokens.auth_session_id` 由现有 AutoMigrate + 创建,属于增量变更,不清库。 +- SQLite 已验证;PostgreSQL 迁移需要在有实例的环境中单独执行 + `go test ./internal/database ./internal/repository -count=1` 后才能宣称通过。 +- 回滚到旧版本不会删除新表;旧程序忽略新增字段。回滚前应先确认不需要再 + 依赖 `sid` 即时撤销能力。 + +## 7. 已知限制 + +- 单实例部署;未实现多副本共享会话。 +- 外部播放令牌尚未继承 `sid`(属于 M3 播放会话范围),当前不能按设备即时撤销。 +- 反代子路径、自签名证书信任、多服务器配置均未支持。 +- App 目前只开放连接、登录、会话恢复与退出;媒体与阅读入口未实现。 diff --git a/docs/mobile-api/openapi.yaml b/docs/mobile-api/openapi.yaml new file mode 100644 index 0000000..0889838 --- /dev/null +++ b/docs/mobile-api/openapi.yaml @@ -0,0 +1,389 @@ +openapi: 3.0.3 +info: + title: MeBox Mobile API + version: 1.0.0 + description: | + MeBox 安卓客户端的稳定接入契约。所有路径以 `/api` 为前缀挂载, + 本文档描述 `/api/v1` 部分;既有 `/api` 与 Emby/Jellyfin 入口保持兼容。 + + 约定: + - JSON 字段使用 snake_case; + - 时间点为 RFC 3339 UTC 字符串,播放位置与时长使用整数毫秒; + - 集合为空时返回 `[]`,未知字段客户端必须忽略; + - 错误响应统一为 `code`、`message`、`request_id`、`retryable`; + - 能力开关只有在接口实现且当前可运行时才为 true。 +servers: + - url: /api +security: + - bearerAuth: [] +tags: + - name: server + - name: auth +paths: + /v1/server-info: + get: + tags: [server] + summary: 匿名读取实例身份与移动端能力 + security: [] + responses: + '200': + description: 实例可连接 + headers: + Cache-Control: + schema: + type: string + description: 固定为 no-store + content: + application/json: + schema: + $ref: '#/components/schemas/ServerInfo' + '503': + description: 暂时无法读取实例身份 + content: + application/json: + schema: + $ref: '#/components/schemas/MobileError' + /v1/auth/login: + post: + tags: [auth] + summary: 账号密码登录并创建认证会话 + security: [] + parameters: + - $ref: '#/components/parameters/RequestId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '200': + description: 登录成功 + content: + application/json: + schema: + $ref: '#/components/schemas/AuthResponse' + '400': + $ref: '#/components/responses/InvalidArgument' + '401': + $ref: '#/components/responses/InvalidCredentials' + '403': + description: 账号被禁用或已到期 + content: + application/json: + schema: + $ref: '#/components/schemas/MobileError' + '429': + $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/ServiceUnavailable' + /v1/auth/refresh: + post: + tags: [auth] + summary: 使用刷新令牌轮换令牌对 + security: [] + description: | + 刷新令牌必须由 v1 登录签发并关联认证会话。同一 refresh token 的 + 并发提交只会真正轮换一次;轮换后 60 秒内重复提交返回同一次结果。 + 会话被撤销后刷新返回 SESSION_REVOKED。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RefreshRequest' + responses: + '200': + description: 刷新成功,新令牌继续归属同一 auth_session_id + content: + application/json: + schema: + $ref: '#/components/schemas/AuthResponse' + '400': + $ref: '#/components/responses/InvalidArgument' + '401': + description: 刷新令牌无效、过期或会话已撤销 + content: + application/json: + schema: + $ref: '#/components/schemas/MobileError' + '503': + $ref: '#/components/responses/ServiceUnavailable' + /v1/auth/session: + get: + tags: [auth] + summary: 读取当前用户与认证会话 + responses: + '200': + description: 会话有效 + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + '401': + $ref: '#/components/responses/SessionInvalid' + '503': + $ref: '#/components/responses/ServiceUnavailable' + /v1/auth/logout: + post: + tags: [auth] + summary: 撤销当前认证会话 + description: 幂等;重复退出同样返回 200。 + responses: + '200': + description: 已退出(本机与远端) + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ok + '401': + $ref: '#/components/responses/SessionInvalid' + '503': + $ref: '#/components/responses/ServiceUnavailable' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + parameters: + RequestId: + name: X-Request-ID + in: header + required: false + description: | + 可选的诊断 ID(8—128 位 ASCII 字母、数字或 `-_.:`)。服务器会在 + 响应头与错误体回传同一个值;非法值会被替换为新 UUID。该值不参与鉴权。 + schema: + type: string + responses: + InvalidArgument: + description: 请求字段缺失或格式错误 + content: + application/json: + schema: + $ref: '#/components/schemas/MobileError' + InvalidCredentials: + description: 用户名或密码错误 + content: + application/json: + schema: + $ref: '#/components/schemas/MobileError' + SessionInvalid: + description: 访问令牌无效、过期或认证会话已撤销 + content: + application/json: + schema: + $ref: '#/components/schemas/MobileError' + RateLimited: + description: 请求过于频繁 + headers: + Retry-After: + schema: + type: integer + description: 建议等待秒数 + content: + application/json: + schema: + $ref: '#/components/schemas/MobileError' + ServiceUnavailable: + description: 暂时性服务/存储错误,可安全重试 + content: + application/json: + schema: + $ref: '#/components/schemas/MobileError' + schemas: + ServerInfo: + type: object + required: + - name + - server_id + - server_version + - api_version + - min_supported_app_version_code + - capabilities + properties: + name: + type: string + example: MeBox + server_id: + type: string + format: uuid + description: 实例持久身份;备份克隆成独立实例时必须显式重置。 + server_version: + type: string + example: MeBox-v0.1.16 + api_version: + type: string + example: v1 + min_supported_app_version_code: + type: integer + minimum: 1 + capabilities: + $ref: '#/components/schemas/ServerCapabilities' + ServerCapabilities: + type: object + required: + - device_sessions + - playback_sessions + - reader_progress_v1 + - reader_content_batch + - local_transcode + properties: + device_sessions: + type: boolean + description: M1 起为 true + playback_sessions: + type: boolean + description: 播放会话接口未实现前必须为 false + reader_progress_v1: + type: boolean + description: v1 阅读锚点/版本冲突未实现前必须为 false + reader_content_batch: + type: boolean + description: v1 批量正文与资源引用未实现前必须为 false + local_transcode: + type: boolean + description: 移动端可用的本地转码会话未实现前必须为 false + MobileUser: + type: object + required: [id, username, role, tier, is_active, force_password_reset] + properties: + id: + type: string + username: + type: string + nickname: + type: string + role: + type: string + enum: [admin, user] + tier: + type: string + example: free + is_active: + type: boolean + force_password_reset: + type: boolean + expires_at: + type: string + format: date-time + nullable: true + TokenPair: + type: object + required: [access_token, refresh_token, expires_in, token_type] + properties: + access_token: + type: string + refresh_token: + type: string + expires_in: + type: integer + description: 访问令牌有效秒数 + token_type: + type: string + example: Bearer + LoginRequest: + type: object + required: [username, password, device_id] + properties: + username: + type: string + password: + type: string + device_id: + type: string + maxLength: 128 + description: App 安装随机生成,不使用 IMEI/MAC 等硬件标识。 + device_name: + type: string + maxLength: 128 + client_name: + type: string + maxLength: 64 + example: MeBox Android + client_version: + type: string + maxLength: 64 + RefreshRequest: + type: object + required: [refresh_token] + properties: + refresh_token: + type: string + AuthResponse: + type: object + required: [auth_session_id, user, tokens] + properties: + auth_session_id: + type: string + format: uuid + user: + $ref: '#/components/schemas/MobileUser' + tokens: + $ref: '#/components/schemas/TokenPair' + SessionDevice: + type: object + properties: + device_id: + type: string + device_name: + type: string + client_name: + type: string + client_version: + type: string + last_used_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + SessionResponse: + type: object + required: [auth_session_id, user] + properties: + auth_session_id: + type: string + format: uuid + user: + $ref: '#/components/schemas/MobileUser' + session: + $ref: '#/components/schemas/SessionDevice' + MobileError: + type: object + required: [code, message, request_id, retryable] + properties: + code: + type: string + enum: + - INVALID_ARGUMENT + - INVALID_CREDENTIALS + - TOKEN_EXPIRED + - SESSION_REVOKED + - ACCOUNT_DISABLED + - ACCOUNT_EXPIRED + - PERMISSION_DENIED + - PROFILE_PIN_REQUIRED + - RATE_LIMITED + - UPSTREAM_UNAVAILABLE + - TRANSCODE_BUSY + - PLAYBACK_SESSION_NOT_FOUND + - RESOURCE_EXPIRED + - PROGRESS_CONFLICT + - SERVICE_UNAVAILABLE + message: + type: string + request_id: + type: string + retryable: + type: boolean + retry_after_sec: + type: integer + minimum: 0 + details: + description: 可选的稳定结构化细节,未定义时客户端必须忽略。 diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 46a2397..069ec85 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -46,6 +46,8 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C registerPublicAuthRoutes(api, svc, log) + registerMobileRoutes(api, svc, log) + registerAuthenticatedRoutes(api, cfg, svc) registerAdminRoutes(api, cfg, svc) diff --git a/internal/handler/mobile_auth.go b/internal/handler/mobile_auth.go new file mode 100644 index 0000000..346ce31 --- /dev/null +++ b/internal/handler/mobile_auth.go @@ -0,0 +1,395 @@ +// Package handler — 移动端 /api/v1 认证端点。 +package handler + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" +) + +// mobileUserPayload 是 v1 返回的用户摘要。这里显式挑选字段,避免把仅服务端 +// 使用的偏好和内部状态带进新契约。 +type mobileUserPayload struct { + ID string `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname,omitempty"` + Role string `json:"role"` + Tier string `json:"tier"` + IsActive bool `json:"is_active"` + ForcePasswordReset bool `json:"force_password_reset"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +func toMobileUserPayload(u *model.User) *mobileUserPayload { + if u == nil { + return nil + } + return &mobileUserPayload{ + ID: u.ID, + Username: u.Username, + Nickname: u.Nickname, + Role: u.Role, + Tier: u.Tier, + IsActive: u.IsActive, + ForcePasswordReset: u.ForcePasswordReset, + ExpiresAt: u.ExpiredAt, + } +} + +type mobileSessionPayload struct { + AuthSessionID string `json:"auth_session_id"` + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name,omitempty"` + ClientName string `json:"client_name,omitempty"` + ClientVersion string `json:"client_version,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt time.Time `json:"last_used_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +func toMobileSessionPayload(s *model.AuthSession) *mobileSessionPayload { + if s == nil { + return nil + } + return &mobileSessionPayload{ + AuthSessionID: s.ID, + DeviceID: s.DeviceID, + DeviceName: s.DeviceName, + ClientName: s.ClientName, + ClientVersion: s.ClientVersion, + CreatedAt: s.CreatedAt, + LastUsedAt: s.LastUsedAt, + ExpiresAt: s.ExpiresAt, + } +} + +type mobileLoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + DeviceID string `json:"device_id" binding:"required"` + DeviceName string `json:"device_name"` + ClientName string `json:"client_name"` + ClientVersion string `json:"client_version"` +} + +type mobileRefreshRequest struct { + RefreshToken string `json:"refresh_token" binding:"required"` +} + +// mobileAuthRequired 是 v1 专用认证中间件:要求有效 JWT + 有效设备会话, +// 并统一输出 v1 错误契约。 +func mobileAuthRequired(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + raw := middleware.ExtractToken(c) + if raw == "" { + writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials, + "access token required", false, 0) + return + } + claims, err := svc.Token.ValidateAccessToken(raw) + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired, + "access token expired", false, 0) + return + } + writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials, + "invalid access token", false, 0) + return + } + if strings.TrimSpace(claims.Purpose) != "" { + writeMobileError(c, http.StatusForbidden, mobileCodePermissionDenied, + "token scope denied for this endpoint", false, 0) + return + } + sessionID := strings.TrimSpace(claims.Sid) + if sessionID == "" { + // v1 客户端必须使用登录时创建的会话;旧网页令牌不进入移动端契约。 + writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials, + "auth session required; sign in again", false, 0) + return + } + session, err := svc.AuthSessions.Validate(c.Request.Context(), sessionID, claims.UserID) + if err != nil { + if service.IsTransientDatabaseLock(err) { + writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable, + "auth session check temporarily unavailable", true, 2) + return + } + writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked, + "auth session revoked or expired", false, 0) + return + } + c.Set(middleware.CtxUserID, claims.UserID) + c.Set(middleware.CtxUserRole, claims.Role) + c.Set(middleware.CtxUserTier, claims.Tier) + c.Set(middleware.CtxAuthSessionID, sessionID) + c.Set(mobileCtxAuthSession, session) + // 每个请求写一次 last_used_at 会在 SQLite 上造成不必要的写锁竞争; + // 一分钟内的重复请求不更新,足以支撑设备列表的“最近活动”。 + if time.Since(session.LastUsedAt) > time.Minute { + svc.AuthSessions.Touch(c.Request.Context(), sessionID) + } + c.Next() + } +} + +// mobileLogoutAuthRequired 只校验 JWT 与 sid,不要求会话仍然有效。 +// 退出必须幂等:同一个令牌重复调用 logout 也要返回成功,而不是在第二次 +// 被会话校验拦成 401。 +func mobileLogoutAuthRequired(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + raw := middleware.ExtractToken(c) + if raw == "" { + writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials, + "access token required", false, 0) + return + } + claims, err := svc.Token.ValidateAccessToken(raw) + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired, + "access token expired", false, 0) + return + } + writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials, + "invalid access token", false, 0) + return + } + sessionID := strings.TrimSpace(claims.Sid) + if sessionID == "" { + writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials, + "auth session required; sign in again", false, 0) + return + } + c.Set(middleware.CtxUserID, claims.UserID) + c.Set(middleware.CtxAuthSessionID, sessionID) + c.Next() + } +} + +func mobileLoginHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + var req mobileLoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument, + "username, password and device_id are required", false, 0) + return + } + req.Username = strings.TrimSpace(req.Username) + req.DeviceID = strings.TrimSpace(req.DeviceID) + if req.Username == "" || req.DeviceID == "" || len(req.DeviceID) > 128 { + writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument, + "invalid username or device_id", false, 0) + return + } + ctx := c.Request.Context() + user, err := svc.Auth.Authenticate(ctx, req.Username, req.Password) + if err != nil { + writeMobileAuthError(c, log, err) + return + } + + deviceName := strings.TrimSpace(req.DeviceName) + clientName := strings.TrimSpace(req.ClientName) + if clientName == "" { + clientName = "MeBox Android" + } + session, err := svc.AuthSessions.Create(ctx, service.CreateAuthSessionInput{ + UserID: user.ID, + DeviceID: req.DeviceID, + DeviceName: deviceName, + ClientName: clientName, + ClientVersion: req.ClientVersion, + }) + if err != nil { + if isTransientServiceError(err) { + writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable, + "cannot create auth session right now", true, 2) + return + } + writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument, + "invalid device information", false, 0) + return + } + + tokens, err := svc.Token.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID) + if err != nil { + // 令牌签发失败时回收会话,避免留下永远无法使用的会话记录。 + _ = svc.AuthSessions.Revoke(ctx, session.ID) + writeMobileAuthError(c, log, err) + return + } + if svc.Device != nil { + svc.Device.RecordLogin(ctx, user.ID, session.DeviceID, deviceName, clientName, c.ClientIP()) + } + if svc.Audit != nil { + svc.Audit.RecordBestEffort(user.ID, "auth.mobile.login", user.Username, c.ClientIP(), "") + } + c.JSON(http.StatusOK, gin.H{ + "auth_session_id": session.ID, + "user": toMobileUserPayload(user), + "tokens": tokens, + }) + } +} + +func mobileRefreshHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + var req mobileRefreshRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument, + "refresh_token required", false, 0) + return + } + ctx := c.Request.Context() + rt, err := svc.Repo.RefreshToken.FindByHash(ctx, repository.HashToken(req.RefreshToken)) + if err != nil { + if isTransientServiceError(err) { + writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable, + "token store temporarily unavailable", true, 2) + return + } + writeMobileAuthError(c, log, err) + return + } + // 只有 v1 登录签发的刷新令牌才带会话;旧网页令牌必须重新登录。 + // 注意这里不能在 rt.Revoked 时提前返回:轮换宽限期内的幂等复用 + // 由 TokenService 判定。 + if rt == nil || strings.TrimSpace(rt.AuthSessionID) == "" { + writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials, + "refresh token is not bound to a device session; sign in again", false, 0) + return + } + if _, err := svc.AuthSessions.Validate(ctx, rt.AuthSessionID, rt.UserID); err != nil { + if isTransientServiceError(err) { + writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable, + "auth session check temporarily unavailable", true, 2) + return + } + writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked, + "auth session revoked or expired", false, 0) + return + } + + tokens, err := svc.Auth.RefreshTokens(ctx, req.RefreshToken) + if err != nil { + writeMobileAuthError(c, log, err) + return + } + user, err := svc.Repo.User.FindByID(ctx, rt.UserID) + if err != nil { + writeMobileAuthError(c, log, err) + return + } + if user == nil { + writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials, + "user not found", false, 0) + return + } + c.JSON(http.StatusOK, gin.H{ + "auth_session_id": rt.AuthSessionID, + "user": toMobileUserPayload(user), + "tokens": tokens, + }) + } +} + +func mobileSessionHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + ctx := c.Request.Context() + userID := c.GetString(middleware.CtxUserID) + user, err := svc.Repo.User.FindByID(ctx, userID) + if err != nil { + writeMobileAuthError(c, svc.Log, err) + return + } + if user == nil { + writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials, + "user not found", false, 0) + return + } + session, _ := c.Get(mobileCtxAuthSession) + sessionModel, _ := session.(*model.AuthSession) + c.JSON(http.StatusOK, gin.H{ + "auth_session_id": c.GetString(middleware.CtxAuthSessionID), + "user": toMobileUserPayload(user), + "session": toMobileSessionPayload(sessionModel), + }) + } +} + +func mobileLogoutHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + sessionID := c.GetString(middleware.CtxAuthSessionID) + if err := svc.AuthSessions.Revoke(c.Request.Context(), sessionID); err != nil { + if isTransientServiceError(err) { + writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable, + "cannot revoke session right now", true, 2) + return + } + writeMobileError(c, http.StatusInternalServerError, mobileCodeServiceUnavailable, + "logout failed", false, 0) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + } +} + +// writeMobileAuthError 把 service 层错误映射为稳定错误码。 +// 临时性存储/基础设施错误必须返回可重试的 503,不能伪装成凭证失效。 +func writeMobileAuthError(c *gin.Context, log *zap.Logger, err error) { + switch { + case errors.Is(err, service.ErrInvalidCredentials), errors.Is(err, service.ErrInvalidRefreshToken): + writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials, + "invalid username, password or refresh token", false, 0) + case errors.Is(err, service.ErrTokenExpired): + writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired, + "refresh token expired", false, 0) + case errors.Is(err, service.ErrTokenRevoked), + errors.Is(err, service.ErrAuthSessionRevoked), + errors.Is(err, service.ErrAuthSessionExpired), + errors.Is(err, service.ErrAuthSessionRequired): + writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked, + "auth session revoked or expired", false, 0) + case errors.Is(err, service.ErrUserInactive): + writeMobileError(c, http.StatusForbidden, mobileCodeAccountDisabled, + "account is disabled", false, 0) + case errors.Is(err, service.ErrUserExpired): + writeMobileError(c, http.StatusForbidden, mobileCodeAccountExpired, + "account has expired", false, 0) + case isTransientServiceError(err): + writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable, + "service temporarily unavailable, please retry", true, 2) + default: + if log != nil { + log.Warn("mobile auth request failed", zap.Error(err)) + } + writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable, + "service temporarily unavailable, please retry", true, 2) + } +} + +func isTransientServiceError(err error) bool { + if err == nil { + return false + } + if service.IsTransientDatabaseLock(err) { + return true + } + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return true + } + return false +} diff --git a/internal/handler/mobile_auth_test.go b/internal/handler/mobile_auth_test.go new file mode 100644 index 0000000..f349731 --- /dev/null +++ b/internal/handler/mobile_auth_test.go @@ -0,0 +1,434 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" +) + +const mobileTestSecret = "mobile-test-secret" + +func newMobileTestEnv(t *testing.T) (*gin.Engine, *service.Container) { + t.Helper() + gin.SetMode(gin.TestMode) + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + // :memory: 只有单连接能看到同一份数据;handler 测试足够,且避免 + // shared cache 名字在并行测试间串库。 + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { _ = sqlDB.Close() }) + + if err := db.AutoMigrate( + &model.User{}, &model.RefreshToken{}, &model.AuthSession{}, &model.Setting{}, + &model.UserDevice{}, &model.UserPermission{}, + ); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + cfg := &config.Config{Secrets: config.SecretsConfig{JWTSecret: mobileTestSecret}} + svc := service.NewWithVersion(cfg, zap.NewNop(), repos, "MeBox-test") + + router := gin.New() + Register(router, cfg, zap.NewNop(), svc) + return router, svc +} + +func seedMobileUser(t *testing.T, svc *service.Container, username, password string) *model.User { + t.Helper() + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + u := &model.User{Username: username, PasswordHash: string(hash), Role: "user", Tier: "free", IsActive: true} + if err := svc.Repo.User.Create(t.Context(), u); err != nil { + t.Fatal(err) + } + return u +} + +func mobileJSONRequest(t *testing.T, router *gin.Engine, method, path, token string, body any, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + var reader *bytes.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + reader = bytes.NewReader(raw) + } else { + reader = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, path, reader) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + for k, v := range headers { + req.Header.Set(k, v) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w +} + +func decodeMobileJSON(t *testing.T, w *httptest.ResponseRecorder, out any) { + t.Helper() + if err := json.Unmarshal(w.Body.Bytes(), out); err != nil { + t.Fatalf("decode body %q: %v", w.Body.String(), err) + } +} + +func TestMobileServerInfoAnonymousAndCacheable(t *testing.T) { + router, _ := newMobileTestEnv(t) + w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/server-info", "", nil, nil) + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) + } + if got := w.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("Cache-Control = %q, want no-store", got) + } + var info service.ServerInfo + decodeMobileJSON(t, w, &info) + if info.ServerID == "" || info.ServerVersion != "MeBox-test" || info.APIVersion != "v1" { + t.Fatalf("unexpected server info: %+v", info) + } + if !info.Capabilities.DeviceSessions { + t.Fatalf("device_sessions capability should be true: %+v", info.Capabilities) + } + if info.Capabilities.PlaybackSessions || info.Capabilities.ReaderProgressV1 { + t.Fatalf("unimplemented capabilities must stay false: %+v", info.Capabilities) + } + if info.MinSupportedAppVersionCode < 1 { + t.Fatalf("min_supported_app_version_code = %d", info.MinSupportedAppVersionCode) + } + + // 匿名访问不应要求 Authorization,也不应记录 cookie。 + if len(w.Result().Cookies()) != 0 { + t.Fatalf("server-info should not set cookies") + } +} + +func TestMobileAuthLifecycle(t *testing.T) { + router, svc := newMobileTestEnv(t) + user := seedMobileUser(t, svc, "alice", "secret-123") + + // 1. 错误密码:401 + 稳定错误码 + request_id。 + w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{ + "username": "alice", "password": "wrong", "device_id": "device-1", + }, map[string]string{"X-Request-ID": "mobile-test-0001"}) + if w.Code != http.StatusUnauthorized { + t.Fatalf("wrong password status = %d body=%s", w.Code, w.Body.String()) + } + var authErr mobileErrorBody + decodeMobileJSON(t, w, &authErr) + if authErr.Code != mobileCodeInvalidCredentials || authErr.RequestID != "mobile-test-0001" { + t.Fatalf("unexpected auth error: %+v", authErr) + } + if authErr.Retryable { + t.Fatalf("invalid credentials must not be retryable: %+v", authErr) + } + + // 2. 缺少 device_id:400 INVALID_ARGUMENT。 + w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{ + "username": "alice", "password": "secret-123", + }, nil) + if w.Code != http.StatusBadRequest { + t.Fatalf("missing device id status = %d body=%s", w.Code, w.Body.String()) + } + decodeMobileJSON(t, w, &authErr) + if authErr.Code != mobileCodeInvalidArgument { + t.Fatalf("code = %q, want %q", authErr.Code, mobileCodeInvalidArgument) + } + + // 3. 正常登录。 + w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{ + "username": "alice", "password": "secret-123", "device_id": "device-1", + "device_name": "Pixel 9", "client_name": "MeBox Android", "client_version": "1.0.0", + }, nil) + if w.Code != http.StatusOK { + t.Fatalf("login status = %d body=%s", w.Code, w.Body.String()) + } + var loginResp struct { + AuthSessionID string `json:"auth_session_id"` + User mobileUserPayload `json:"user"` + Tokens service.TokenPair `json:"tokens"` + } + decodeMobileJSON(t, w, &loginResp) + if loginResp.AuthSessionID == "" || loginResp.Tokens.AccessToken == "" || loginResp.Tokens.RefreshToken == "" { + t.Fatalf("incomplete login response: %+v", loginResp) + } + if loginResp.User.ID != user.ID || loginResp.User.Username != "alice" { + t.Fatalf("unexpected user payload: %+v", loginResp.User) + } + claims, err := svc.Token.ValidateAccessToken(loginResp.Tokens.AccessToken) + if err != nil { + t.Fatalf("validate access token: %v", err) + } + if claims.Sid != loginResp.AuthSessionID { + t.Fatalf("token sid = %q, want %q", claims.Sid, loginResp.AuthSessionID) + } + rt, err := svc.Repo.RefreshToken.FindByHash(t.Context(), repository.HashToken(loginResp.Tokens.RefreshToken)) + if err != nil || rt == nil || rt.AuthSessionID != loginResp.AuthSessionID { + t.Fatalf("refresh token not bound to session: %+v err=%v", rt, err) + } + + // 4. 会话读取。 + w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", loginResp.Tokens.AccessToken, nil, nil) + if w.Code != http.StatusOK { + t.Fatalf("session status = %d body=%s", w.Code, w.Body.String()) + } + var sessionResp struct { + AuthSessionID string `json:"auth_session_id"` + User mobileUserPayload `json:"user"` + Session *mobileSessionPayload `json:"session"` + } + decodeMobileJSON(t, w, &sessionResp) + if sessionResp.AuthSessionID != loginResp.AuthSessionID || sessionResp.Session == nil { + t.Fatalf("unexpected session response: %+v", sessionResp) + } + if sessionResp.Session.DeviceID != "device-1" || sessionResp.Session.DeviceName != "Pixel 9" { + t.Fatalf("unexpected session payload: %+v", sessionResp.Session) + } + + // 5. 刷新:新令牌继续归属同一会话,旧刷新令牌被撤销。 + w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{ + "refresh_token": loginResp.Tokens.RefreshToken, + }, nil) + if w.Code != http.StatusOK { + t.Fatalf("refresh status = %d body=%s", w.Code, w.Body.String()) + } + var refreshResp struct { + AuthSessionID string `json:"auth_session_id"` + Tokens service.TokenPair `json:"tokens"` + } + decodeMobileJSON(t, w, &refreshResp) + if refreshResp.AuthSessionID != loginResp.AuthSessionID || refreshResp.Tokens.AccessToken == "" { + t.Fatalf("unexpected refresh response: %+v", refreshResp) + } + if refreshResp.Tokens.RefreshToken == loginResp.Tokens.RefreshToken { + t.Fatal("refresh token should rotate") + } + oldRT, err := svc.Repo.RefreshToken.FindByHash(t.Context(), repository.HashToken(loginResp.Tokens.RefreshToken)) + if err != nil || oldRT == nil || !oldRT.Revoked { + t.Fatalf("old refresh token should be revoked: %+v err=%v", oldRT, err) + } + + // 6. 用刷新后的访问令牌访问旧 API,确认 sid 会话校验在旧入口同样生效。 + w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", refreshResp.Tokens.AccessToken, nil, nil) + if w.Code != http.StatusOK { + t.Fatalf("old api with fresh token status = %d body=%s", w.Code, w.Body.String()) + } + + // 7. 退出:成功且可重复。 + for i := 0; i < 2; i++ { + w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/logout", refreshResp.Tokens.AccessToken, nil, nil) + if w.Code != http.StatusOK { + t.Fatalf("logout #%d status = %d body=%s", i+1, w.Code, w.Body.String()) + } + } + + // 8. 会话撤销后:v1 会话接口 401 SESSION_REVOKED。 + w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", refreshResp.Tokens.AccessToken, nil, nil) + if w.Code != http.StatusUnauthorized { + t.Fatalf("session after logout status = %d body=%s", w.Code, w.Body.String()) + } + decodeMobileJSON(t, w, &authErr) + if authErr.Code != mobileCodeSessionRevoked { + t.Fatalf("session after logout code = %q, want %q", authErr.Code, mobileCodeSessionRevoked) + } + + // 9. 被踢后的未过期 JWT 不能绕过旧 API。 + w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", refreshResp.Tokens.AccessToken, nil, nil) + if w.Code != http.StatusUnauthorized { + t.Fatalf("old api after logout status = %d body=%s", w.Code, w.Body.String()) + } + + // 10. 刷新令牌随会话一起撤销。 + w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{ + "refresh_token": refreshResp.Tokens.RefreshToken, + }, nil) + if w.Code != http.StatusUnauthorized { + t.Fatalf("refresh after logout status = %d body=%s", w.Code, w.Body.String()) + } + decodeMobileJSON(t, w, &authErr) + if authErr.Code != mobileCodeSessionRevoked { + t.Fatalf("refresh after logout code = %q, want %q", authErr.Code, mobileCodeSessionRevoked) + } +} + +func TestMobileV1RejectsLegacyTokenWithoutSid(t *testing.T) { + router, svc := newMobileTestEnv(t) + user := seedMobileUser(t, svc, "legacy", "secret-123") + pair, err := svc.Token.IssuePair(t.Context(), user.ID, user.Role, user.Tier) + if err != nil { + t.Fatal(err) + } + if claims, err := svc.Token.ValidateAccessToken(pair.AccessToken); err != nil || claims.Sid != "" { + t.Fatalf("legacy token unexpectedly has sid: %+v err=%v", claims, err) + } + + w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", pair.AccessToken, nil, nil) + if w.Code != http.StatusUnauthorized { + t.Fatalf("legacy token status = %d body=%s", w.Code, w.Body.String()) + } + var body mobileErrorBody + decodeMobileJSON(t, w, &body) + if body.Code != mobileCodeInvalidCredentials { + t.Fatalf("legacy token code = %q, want %q", body.Code, mobileCodeInvalidCredentials) + } + + // 旧令牌仍可访问旧 API(兼容路径不能被新协议破坏)。 + w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", pair.AccessToken, nil, nil) + if w.Code != http.StatusOK { + t.Fatalf("legacy token on old api status = %d body=%s", w.Code, w.Body.String()) + } +} + +func TestMobileRefreshRejectsUnboundLegacyToken(t *testing.T) { + router, svc := newMobileTestEnv(t) + user := seedMobileUser(t, svc, "bob", "secret-123") + pair, err := svc.Token.IssuePair(t.Context(), user.ID, user.Role, user.Tier) + if err != nil { + t.Fatal(err) + } + w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{ + "refresh_token": pair.RefreshToken, + }, nil) + if w.Code != http.StatusUnauthorized { + t.Fatalf("legacy refresh status = %d body=%s", w.Code, w.Body.String()) + } + var body mobileErrorBody + decodeMobileJSON(t, w, &body) + if body.Code != mobileCodeInvalidCredentials { + t.Fatalf("legacy refresh code = %q, want %q", body.Code, mobileCodeInvalidCredentials) + } +} + +func TestMobileDeviceKickRevokesSession(t *testing.T) { + router, svc := newMobileTestEnv(t) + user := seedMobileUser(t, svc, "carol", "secret-123") + + w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{ + "username": "carol", "password": "secret-123", "device_id": "device-kick", + "device_name": "Kick Me", "client_name": "MeBox Android", + }, nil) + if w.Code != http.StatusOK { + t.Fatalf("login status = %d body=%s", w.Code, w.Body.String()) + } + var loginResp struct { + AuthSessionID string `json:"auth_session_id"` + Tokens service.TokenPair `json:"tokens"` + } + decodeMobileJSON(t, w, &loginResp) + + if err := svc.Device.KickDevice(t.Context(), user.ID, "device-kick"); err != nil { + t.Fatalf("kick device: %v", err) + } + session, err := svc.Repo.AuthSession.FindByID(t.Context(), loginResp.AuthSessionID) + if err != nil || session == nil || !session.Revoked { + t.Fatalf("session should be revoked after device kick: %+v err=%v", session, err) + } + w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", loginResp.Tokens.AccessToken, nil, nil) + if w.Code != http.StatusUnauthorized { + t.Fatalf("session after kick status = %d body=%s", w.Code, w.Body.String()) + } +} + +func TestMobileKickOneDeviceKeepsOtherSessions(t *testing.T) { + router, svc := newMobileTestEnv(t) + seedMobileUser(t, svc, "erin", "secret-123") + + login := func(deviceID string) string { + w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{ + "username": "erin", "password": "secret-123", "device_id": deviceID, + "device_name": deviceID, "client_name": "MeBox Android", + }, nil) + if w.Code != http.StatusOK { + t.Fatalf("login %s status = %d body=%s", deviceID, w.Code, w.Body.String()) + } + var resp struct { + Tokens service.TokenPair `json:"tokens"` + } + decodeMobileJSON(t, w, &resp) + return resp.Tokens.AccessToken + } + + tokenA := login("device-a") + tokenB := login("device-b") + + if err := svc.Device.KickDevice(t.Context(), svcUserID(t, svc, "erin"), "device-a"); err != nil { + t.Fatalf("kick device a: %v", err) + } + + w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", tokenA, nil, nil) + if w.Code != http.StatusUnauthorized { + t.Fatalf("kicked session status = %d body=%s", w.Code, w.Body.String()) + } + w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", tokenB, nil, nil) + if w.Code != http.StatusOK { + t.Fatalf("other device status = %d body=%s", w.Code, w.Body.String()) + } +} + +func svcUserID(t *testing.T, svc *service.Container, username string) string { + t.Helper() + u, err := svc.Repo.User.FindByUsername(t.Context(), username) + if err != nil || u == nil { + t.Fatalf("find user %s: %v", username, err) + } + return u.ID +} + +func TestMobileAuthDefaultsAndSessionExpiryShape(t *testing.T) { + router, svc := newMobileTestEnv(t) + seedMobileUser(t, svc, "dave", "secret-123") + + w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{ + "username": "dave", "password": "secret-123", "device_id": "device-default", + }, nil) + if w.Code != http.StatusOK { + t.Fatalf("login status = %d body=%s", w.Code, w.Body.String()) + } + var loginResp struct { + AuthSessionID string `json:"auth_session_id"` + Tokens service.TokenPair `json:"tokens"` + } + decodeMobileJSON(t, w, &loginResp) + session, err := svc.Repo.AuthSession.FindByID(t.Context(), loginResp.AuthSessionID) + if err != nil || session == nil { + t.Fatalf("load session: %v", err) + } + if session.ClientName != "MeBox Android" { + t.Fatalf("client_name default = %q", session.ClientName) + } + if session.ExpiresAt.Before(time.Now()) { + t.Fatalf("session expiry in the past: %v", session.ExpiresAt) + } + if loginResp.Tokens.TokenType != "Bearer" || loginResp.Tokens.ExpiresIn <= 0 { + t.Fatalf("unexpected token metadata: %+v", loginResp.Tokens) + } +} diff --git a/internal/handler/mobile_errors.go b/internal/handler/mobile_errors.go new file mode 100644 index 0000000..6558f83 --- /dev/null +++ b/internal/handler/mobile_errors.go @@ -0,0 +1,129 @@ +package handler + +import ( + "net/http" + "strconv" + "strings" + "unicode" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/truewhile/MeBox/internal/middleware" +) + +// 移动端 v1 错误码。新增错误码必须同步 OpenAPI 与 App 的 ApiErrorMapper。 +const ( + mobileCodeInvalidArgument = "INVALID_ARGUMENT" + mobileCodeInvalidCredentials = "INVALID_CREDENTIALS" + mobileCodeTokenExpired = "TOKEN_EXPIRED" + mobileCodeSessionRevoked = "SESSION_REVOKED" + mobileCodeAccountDisabled = "ACCOUNT_DISABLED" + mobileCodeAccountExpired = "ACCOUNT_EXPIRED" + mobileCodePermissionDenied = "PERMISSION_DENIED" + mobileCodeProfilePINRequired = "PROFILE_PIN_REQUIRED" + mobileCodeRateLimited = "RATE_LIMITED" + mobileCodeUpstreamUnavailable = "UPSTREAM_UNAVAILABLE" + mobileCodeTranscodeBusy = "TRANSCODE_BUSY" + mobileCodePlaybackSessionAbsent = "PLAYBACK_SESSION_NOT_FOUND" + mobileCodeResourceExpired = "RESOURCE_EXPIRED" + mobileCodeProgressConflict = "PROGRESS_CONFLICT" + mobileCodeServiceUnavailable = "SERVICE_UNAVAILABLE" +) + +const ( + mobileCtxRequestID = "mobile_request_id" + mobileCtxAuthSession = "mobile_auth_session" + mobileRequestIDHeader = "X-Request-ID" +) + +// mobileErrorBody 是 v1 统一错误响应。字段直接位于顶层,便于客户端按 +// code 分流,不需要再判断后端历史包装层。 +type mobileErrorBody struct { + Code string `json:"code"` + Message string `json:"message"` + RequestID string `json:"request_id"` + Retryable bool `json:"retryable"` + RetryAfterSec int `json:"retry_after_sec,omitempty"` + Details any `json:"details,omitempty"` +} + +// mobileRequestIDMiddleware 为每个 v1 请求绑定可追踪的请求 ID。 +// 合法客户端值会沿用(便于 App 与服务端日志对齐),非法或过长则由服务器 +// 重新生成;该值仅用于诊断,绝不参与鉴权。 +func mobileRequestIDMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + id := strings.TrimSpace(c.GetHeader(mobileRequestIDHeader)) + if !validRequestID(id) { + id = uuid.NewString() + } + c.Set(mobileCtxRequestID, id) + c.Header(mobileRequestIDHeader, id) + c.Next() + } +} + +func validRequestID(id string) bool { + if len(id) < 8 || len(id) > 128 { + return false + } + for _, r := range id { + if r > unicode.MaxASCII { + return false + } + if unicode.IsLetter(r) || unicode.IsDigit(r) { + continue + } + switch r { + case '-', '_', '.', ':': + continue + default: + return false + } + } + return true +} + +func currentMobileRequestID(c *gin.Context) string { + if c == nil { + return "" + } + if v, ok := c.Get(mobileCtxRequestID); ok { + if s, ok := v.(string); ok && s != "" { + return s + } + } + return "" +} + +// writeMobileError 输出统一错误体。request_id 缺失时现场生成,保证所有 +// v1 错误都可用于对账,而不是只在注册了中间件的路由上才有。 +func writeMobileError(c *gin.Context, status int, code, message string, retryable bool, retryAfterSec int) { + requestID := currentMobileRequestID(c) + if requestID == "" { + requestID = uuid.NewString() + c.Header(mobileRequestIDHeader, requestID) + } + if retryAfterSec > 0 { + c.Header("Retry-After", strconv.Itoa(retryAfterSec)) + } + c.AbortWithStatusJSON(status, mobileErrorBody{ + Code: code, + Message: message, + RequestID: requestID, + Retryable: retryable, + RetryAfterSec: retryAfterSec, + }) +} + +// mobileRateLimit 与旧端点共用 RateLimiter,但返回 v1 错误契约和 Retry-After。 +func mobileRateLimit(limiter *middleware.RateLimiter) gin.HandlerFunc { + return func(c *gin.Context) { + if !limiter.Allow(c.ClientIP()) { + writeMobileError(c, http.StatusTooManyRequests, mobileCodeRateLimited, + "too many requests, please try again later", true, 60) + return + } + c.Next() + } +} diff --git a/internal/handler/mobile_routes.go b/internal/handler/mobile_routes.go new file mode 100644 index 0000000..a04de50 --- /dev/null +++ b/internal/handler/mobile_routes.go @@ -0,0 +1,81 @@ +package handler + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/service" +) + +// registerMobileRoutes 挂载 /api/v1 移动端稳定契约。 +// +// 既有 /api 与 Emby 入口保持不变;v1 只做适配和会话语义补强,不复制媒体 +// 或书源业务逻辑。 +func registerMobileRoutes(api *gin.RouterGroup, svc *service.Container, log *zap.Logger) { + if svc == nil || svc.ServerIdentity == nil || svc.AuthSessions == nil { + // 最小容器(例如旧测试桩)没有身份/会话服务时,不暴露不可用的 v1。 + return + } + wireAuthSessionValidator(svc) + + v1 := api.Group("/v1") + v1.Use(mobileRequestIDMiddleware()) + + v1.GET("/server-info", mobileServerInfoHandler(svc, log)) + + authLimiter := middleware.NewRateLimiter(30, time.Minute) + v1.POST("/auth/login", mobileRateLimit(authLimiter), mobileLoginHandler(svc, log)) + v1.POST("/auth/refresh", mobileRefreshHandler(svc, log)) + + authed := v1.Group("/auth") + { + authed.GET("/session", mobileAuthRequired(svc), mobileSessionHandler(svc)) + authed.POST("/logout", mobileLogoutAuthRequired(svc), mobileLogoutHandler(svc)) + } +} + +// wireAuthSessionValidator 让旧 /api 中间件也能校验新令牌的 sid。 +// service 层错误在这里映射为 middleware 的两个哨兵,避免包依赖反转。 +func wireAuthSessionValidator(svc *service.Container) { + middleware.SetSessionValidator(func(ctx context.Context, sessionID, userID string) error { + if svc == nil || svc.AuthSessions == nil { + return nil + } + _, err := svc.AuthSessions.Validate(ctx, sessionID, userID) + if err == nil { + return nil + } + if errors.Is(err, service.ErrAuthSessionRevoked) || + errors.Is(err, service.ErrAuthSessionExpired) || + errors.Is(err, service.ErrAuthSessionRequired) { + return middleware.ErrSessionRevoked + } + return fmt.Errorf("%w: %v", middleware.ErrSessionUnavailable, err) + }) +} + +func mobileServerInfoHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + info, err := svc.ServerIdentity.Info(ctx, service.CurrentServerCapabilities(svc)) + if err != nil { + if log != nil { + log.Warn("mobile server info failed", zap.Error(err)) + } + writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable, + "server identity temporarily unavailable", true, 2) + return + } + // 身份信息随配置变化且不应被中间缓存:同一地址可能切换部署实例。 + c.Header("Cache-Control", "no-store") + c.JSON(http.StatusOK, info) + } +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 5370ab4..622a0f3 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -17,6 +17,8 @@ type Claims struct { Tier string `json:"tier,omitempty"` Purpose string `json:"purpose,omitempty"` MediaID string `json:"media_id,omitempty"` + // Sid 是设备认证会话 ID;旧令牌为空,继续走兼容路径。 + Sid string `json:"sid,omitempty"` jwt.RegisteredClaims } @@ -44,6 +46,19 @@ func AuthRequired(secret string) gin.HandlerFunc { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 40304, "message": "token scope denied"}) return } + // 新协议令牌携带 sid:旧 API 也必须检查会话是否已被撤销,否则 + // 设备被踢后仍能用未过期的 JWT 访问 /api/*。 + if sid := strings.TrimSpace(claims.Sid); sid != "" { + if err := ValidateAuthSession(c.Request.Context(), sid, claims.UserID); err != nil { + if errors.Is(err, ErrSessionUnavailable) { + c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"code": 50303, "message": "auth session unavailable"}) + return + } + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40104, "message": "auth session revoked"}) + return + } + c.Set(CtxAuthSessionID, sid) + } syncAccessTokenCookie(c, raw, claims) c.Set(CtxUserID, claims.UserID) c.Set(CtxUserRole, claims.Role) @@ -151,3 +166,8 @@ func extractToken(c *gin.Context) string { } return "" } + +// ExtractToken 暴露令牌提取逻辑给需要自定义错误契约的移动端中间件。 +func ExtractToken(c *gin.Context) string { + return extractToken(c) +} diff --git a/internal/middleware/auth_session.go b/internal/middleware/auth_session.go new file mode 100644 index 0000000..a6cf5a4 --- /dev/null +++ b/internal/middleware/auth_session.go @@ -0,0 +1,48 @@ +package middleware + +import ( + "context" + "errors" + "strings" + "sync" +) + +// 会话校验错误。service 层把自己的错误映射到这两个哨兵,避免 middleware +// 依赖 service 包(auth 服务已经依赖 middleware 的上下文键)。 +var ( + // ErrSessionRevoked 表示会话已撤销、已过期或不存在:客户端应重新登录。 + ErrSessionRevoked = errors.New("auth session revoked") + // ErrSessionUnavailable 表示暂时无法完成会话校验:客户端应稍后重试。 + ErrSessionUnavailable = errors.New("auth session unavailable") +) + +// SessionValidator 校验认证会话是否仍属于该用户。 +type SessionValidator func(ctx context.Context, sessionID, userID string) error + +var ( + sessionValidatorMu sync.RWMutex + sessionValidator SessionValidator +) + +// SetSessionValidator 注册全局会话校验器;传 nil 表示关闭校验(旧装配路径)。 +func SetSessionValidator(fn SessionValidator) { + sessionValidatorMu.Lock() + defer sessionValidatorMu.Unlock() + sessionValidator = fn +} + +// ValidateAuthSession 使用已注册的校验器检查 sid。未注册校验器时放行, +// 以保持旧测试和嵌入式调用的兼容行为。 +func ValidateAuthSession(ctx context.Context, sessionID, userID string) error { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return nil + } + sessionValidatorMu.RLock() + fn := sessionValidator + sessionValidatorMu.RUnlock() + if fn == nil { + return nil + } + return fn(ctx, sessionID, userID) +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 970f4f2..42a41a6 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -9,6 +9,8 @@ const ( CtxUserTier = "ctx_user_tier" CtxTokenPurpose = "ctx_token_purpose" CtxTokenMediaID = "ctx_token_media_id" + // CtxAuthSessionID 保存已校验的设备认证会话 ID(新协议令牌才有)。 + CtxAuthSessionID = "ctx_auth_session_id" // AccessTokenCookieName carries the web access token for browser-managed // resource requests such as , which cannot attach Authorization. diff --git a/internal/model/auth_session.go b/internal/model/auth_session.go new file mode 100644 index 0000000..3009ceb --- /dev/null +++ b/internal/model/auth_session.go @@ -0,0 +1,49 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// AuthSession 是可撤销的设备认证会话。 +// +// 会话与刷新令牌的关系是 1:N:一次登录创建一个会话,之后每次轮换刷新 +// 令牌都继续归属同一会话。访问令牌通过 JWT 的 sid 字段引用会话,因此 +// 服务端可以在 JWT 尚未过期时立即撤销某台设备的访问能力。 +// +// 旧客户端没有会话关联(sid 为空),继续走既有兼容路径;本模型不强制 +// 把历史刷新令牌绑定到任何设备。 +type AuthSession struct { + ID string `gorm:"primaryKey;size:36" json:"id"` + UserID string `gorm:"index;size:36;not null" json:"user_id"` + DeviceID string `gorm:"index:idx_auth_session_device,priority:1;size:128;not null" json:"device_id"` + DeviceName string `gorm:"size:128" json:"device_name,omitempty"` + ClientName string `gorm:"size:64" json:"client_name,omitempty"` + ClientVersion string `gorm:"size:64" json:"client_version,omitempty"` + Revoked bool `gorm:"not null;default:false" json:"revoked"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` + LastUsedAt time.Time `json:"last_used_at"` + ExpiresAt time.Time `gorm:"index" json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// BeforeCreate 生成 UUID 主键。 +func (s *AuthSession) BeforeCreate(_ *gorm.DB) error { + if s.ID == "" { + s.ID = uuid.NewString() + } + return nil +} + +// IsExpired 判断会话是否超过最长有效期。 +func (s *AuthSession) IsExpired() bool { + return s != nil && !s.ExpiresAt.IsZero() && time.Now().After(s.ExpiresAt) +} + +// IsValid 判断会话当前是否可继续使用。 +func (s *AuthSession) IsValid() bool { + return s != nil && !s.Revoked && !s.IsExpired() +} diff --git a/internal/model/model.go b/internal/model/model.go index ac7a64e..83a7a3f 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -49,6 +49,7 @@ func AllModels() []interface{} { &APIConfig{}, &UserPermission{}, &RefreshToken{}, + &AuthSession{}, &PlayProfile{}, &RegistrationCode{}, &SignIn{}, diff --git a/internal/model/refresh_token.go b/internal/model/refresh_token.go index 98d82b3..77cdfcc 100644 --- a/internal/model/refresh_token.go +++ b/internal/model/refresh_token.go @@ -11,12 +11,14 @@ import ( // RefreshToken 用于双令牌认证机制中的刷新令牌。 // 存储时使用 SHA256 哈希,原始令牌不存储。 type RefreshToken struct { - ID string `gorm:"primaryKey;size:36" json:"id"` - UserID string `gorm:"index;size:36;not null" json:"user_id"` - TokenHash string `gorm:"uniqueIndex;size:128;not null" json:"-"` - ExpiresAt time.Time `gorm:"index" json:"expires_at"` - CreatedAt time.Time `json:"created_at"` - Revoked bool `gorm:"default:false" json:"revoked"` + ID string `gorm:"primaryKey;size:36" json:"id"` + UserID string `gorm:"index;size:36;not null" json:"user_id"` + TokenHash string `gorm:"uniqueIndex;size:128;not null" json:"-"` + // AuthSessionID 关联移动端设备会话。旧数据为空,继续按既有兼容路径使用。 + AuthSessionID string `gorm:"index;size:36" json:"auth_session_id,omitempty"` + ExpiresAt time.Time `gorm:"index" json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + Revoked bool `gorm:"default:false" json:"revoked"` } // BeforeCreate 生成 UUID。 diff --git a/internal/repository/auth_session_repository.go b/internal/repository/auth_session_repository.go new file mode 100644 index 0000000..f39a2b1 --- /dev/null +++ b/internal/repository/auth_session_repository.go @@ -0,0 +1,101 @@ +package repository + +import ( + "context" + "errors" + "time" + + "gorm.io/gorm" + + "github.com/truewhile/MeBox/internal/model" +) + +// AuthSessionRepository persists model.AuthSession records. +type AuthSessionRepository struct{ db *gorm.DB } + +// Create inserts a new authentication session. +func (r *AuthSessionRepository) Create(ctx context.Context, s *model.AuthSession) error { + return withSQLiteBusyRetry(ctx, func() error { + return r.db.WithContext(ctx).Create(s).Error + }) +} + +// FindByID returns the session or (nil, nil) when absent. +func (r *AuthSessionRepository) FindByID(ctx context.Context, id string) (*model.AuthSession, error) { + if id == "" { + return nil, nil + } + var s model.AuthSession + err := withSQLiteBusyRetry(ctx, func() error { + s = model.AuthSession{} + return r.db.WithContext(ctx).Where("id = ?", id).First(&s).Error + }) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &s, nil +} + +// TouchLastUsed 更新会话最近使用时间。 +func (r *AuthSessionRepository) TouchLastUsed(ctx context.Context, id string, at time.Time) error { + return withSQLiteBusyRetry(ctx, func() error { + return r.db.WithContext(ctx).Model(&model.AuthSession{}). + Where("id = ? AND revoked = ?", id, false). + Update("last_used_at", at).Error + }) +} + +// Revoke 撤销单个会话(幂等)。 +func (r *AuthSessionRepository) Revoke(ctx context.Context, id string, at time.Time) error { + return withSQLiteBusyRetry(ctx, func() error { + return r.db.WithContext(ctx).Model(&model.AuthSession{}). + Where("id = ? AND revoked = ?", id, false). + Updates(map[string]any{"revoked": true, "revoked_at": at}).Error + }) +} + +// RevokeByUser 撤销某用户的全部会话(幂等)。 +func (r *AuthSessionRepository) RevokeByUser(ctx context.Context, userID string, at time.Time) error { + return withSQLiteBusyRetry(ctx, func() error { + return r.db.WithContext(ctx).Model(&model.AuthSession{}). + Where("user_id = ? AND revoked = ?", userID, false). + Updates(map[string]any{"revoked": true, "revoked_at": at}).Error + }) +} + +// RevokeByDevice 撤销某用户某设备关联的全部会话(幂等)。 +func (r *AuthSessionRepository) RevokeByDevice(ctx context.Context, userID, deviceID string, at time.Time) error { + return withSQLiteBusyRetry(ctx, func() error { + return r.db.WithContext(ctx).Model(&model.AuthSession{}). + Where("user_id = ? AND device_id = ? AND revoked = ?", userID, deviceID, false). + Updates(map[string]any{"revoked": true, "revoked_at": at}).Error + }) +} + +// ListActiveByUser 返回某用户当前有效(未撤销、未过期)的会话。 +func (r *AuthSessionRepository) ListActiveByUser(ctx context.Context, userID string) ([]model.AuthSession, error) { + var rows []model.AuthSession + err := withSQLiteBusyRetry(ctx, func() error { + rows = nil + return r.db.WithContext(ctx). + Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()). + Order("last_used_at desc, created_at desc"). + Find(&rows).Error + }) + return rows, err +} + +// ListByDevice 返回某用户某设备的全部会话,用于踢出设备时逐个撤销。 +func (r *AuthSessionRepository) ListByDevice(ctx context.Context, userID, deviceID string) ([]model.AuthSession, error) { + var rows []model.AuthSession + err := withSQLiteBusyRetry(ctx, func() error { + rows = nil + return r.db.WithContext(ctx). + Where("user_id = ? AND device_id = ?", userID, deviceID). + Find(&rows).Error + }) + return rows, err +} diff --git a/internal/repository/refresh_token_repository.go b/internal/repository/refresh_token_repository.go index d0c7d89..b759d97 100644 --- a/internal/repository/refresh_token_repository.go +++ b/internal/repository/refresh_token_repository.go @@ -79,7 +79,7 @@ func (r *RefreshTokenRepository) DeleteExpired(ctx context.Context) error { }) } -// Revoke revokes a specific refresh token. +// Revoke 撤销某个刷新令牌。 func (r *RefreshTokenRepository) Revoke(ctx context.Context, hash string) error { return withSQLiteBusyRetry(ctx, func() error { return r.db.WithContext(ctx).Model(&model.RefreshToken{}). @@ -87,6 +87,18 @@ func (r *RefreshTokenRepository) Revoke(ctx context.Context, hash string) error }) } +// RevokeByAuthSessionID 撤销某个认证会话名下的全部刷新令牌。 +// 设备被踢或主动退出后,旧刷新令牌不能再换出新令牌。 +func (r *RefreshTokenRepository) RevokeByAuthSessionID(ctx context.Context, sessionID string) error { + if sessionID == "" { + return nil + } + return withSQLiteBusyRetry(ctx, func() error { + return r.db.WithContext(ctx).Model(&model.RefreshToken{}). + Where("auth_session_id = ?", sessionID).Update("revoked", true).Error + }) +} + // HashToken returns the SHA256 hash of a token. func HashToken(token string) string { h := sha256.Sum256([]byte(token)) diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 37551a9..666a4b5 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -23,6 +23,7 @@ type Container struct { Log *AccessLogRepository Permission *PermissionRepository RefreshToken *RefreshTokenRepository + AuthSession *AuthSessionRepository ApiConfig *ApiConfigRepository PlayProfile *PlayProfileRepository RegCode *RegistrationCodeRepository @@ -56,6 +57,7 @@ func New(db *gorm.DB) *Container { Log: &AccessLogRepository{db: db}, Permission: &PermissionRepository{db: db}, RefreshToken: &RefreshTokenRepository{db: db}, + AuthSession: &AuthSessionRepository{db: db}, ApiConfig: &ApiConfigRepository{db: db}, PlayProfile: &PlayProfileRepository{db: db}, RegCode: &RegistrationCodeRepository{db: db}, diff --git a/internal/service/auth.go b/internal/service/auth.go index 7ca85fb..a1ed5d2 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -34,6 +34,7 @@ type AuthService struct { repo *repository.Container tokenSvc *TokenService permissionSvc *PermissionService + sessionSvc *AuthSessionService tempPassMu sync.RWMutex tempPasswords map[string]temporaryPasswordEntry @@ -51,6 +52,11 @@ func NewAuthService(cfg *config.Config, log *zap.Logger, repo *repository.Contai } } +// SetAuthSessionService 注入设备会话服务;旧登出路径会同时撤销全部会话。 +func (s *AuthService) SetAuthSessionService(sessions *AuthSessionService) { + s.sessionSvc = sessions +} + // Common service-level errors. var ( ErrInvalidCredentials = errors.New("invalid username or password") @@ -162,6 +168,23 @@ type LoginResponse struct { // Login validates credentials and returns the user + a fresh JWT token pair. func (s *AuthService) Login(ctx context.Context, username, password string) (*LoginResponse, error) { + u, err := s.Authenticate(ctx, username, password) + if err != nil { + return nil, err + } + // 签发令牌对 + tokens, err := s.tokenSvc.IssuePairBestEffort(ctx, u.ID, u.Role, u.Tier) + if err != nil { + return nil, err + } + s.touchLoginBestEffort(u.ID) + return &LoginResponse{User: u, Tokens: tokens}, nil +} + +// Authenticate 只校验账号与密码,不签发任何令牌。 +// 移动端 v1 登录需要先确认账号有效,再创建设备会话并签发带 sid 的令牌; +// 分离这一步可以避免先签发一对无会话的令牌再作废。 +func (s *AuthService) Authenticate(ctx context.Context, username, password string) (*model.User, error) { u, err := s.repo.User.FindByUsername(ctx, username) if err != nil { return nil, err @@ -180,13 +203,7 @@ func (s *AuthService) Login(ctx context.Context, username, password string) (*Lo if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil { return nil, ErrInvalidCredentials } - // 签发令牌对 - tokens, err := s.tokenSvc.IssuePairBestEffort(ctx, u.ID, u.Role, u.Tier) - if err != nil { - return nil, err - } - s.touchLoginBestEffort(u.ID) - return &LoginResponse{User: u, Tokens: tokens}, nil + return u, nil } func (s *AuthService) touchLoginBestEffort(userID string) { diff --git a/internal/service/auth_session.go b/internal/service/auth_session.go new file mode 100644 index 0000000..f153db7 --- /dev/null +++ b/internal/service/auth_session.go @@ -0,0 +1,187 @@ +// Package service — 设备认证会话。 +package service + +import ( + "context" + "errors" + "strings" + "time" + "unicode" + + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" +) + +// 认证会话错误。middleware 与 handler 依据这些错误区分「重新登录」和 +// 「临时基础设施故障」。 +var ( + ErrAuthSessionRequired = errors.New("auth session required") + ErrAuthSessionRevoked = errors.New("auth session revoked") + ErrAuthSessionExpired = errors.New("auth session expired") +) + +// AuthSessionService 管理设备会话的创建、校验和撤销。 +type AuthSessionService struct { + log *zap.Logger + repo *repository.Container + now func() time.Time +} + +// NewAuthSessionService 创建会话服务。 +func NewAuthSessionService(log *zap.Logger, repo *repository.Container) *AuthSessionService { + return &AuthSessionService{log: log, repo: repo, now: time.Now} +} + +// CreateAuthSessionInput 是创建会话所需的最小设备信息。 +type CreateAuthSessionInput struct { + UserID string + DeviceID string + DeviceName string + ClientName string + ClientVersion string +} + +// Create 创建一次登录对应的认证会话。 +// +// 设备 ID 由 App 安装随机生成,不接收 IMEI/MAC 等硬件标识;这里只做长度 +// 与字符白名单校验,避免任意字符串进入索引和日志。 +func (s *AuthSessionService) Create(ctx context.Context, in CreateAuthSessionInput) (*model.AuthSession, error) { + userID := strings.TrimSpace(in.UserID) + deviceID := strings.TrimSpace(in.DeviceID) + if userID == "" || deviceID == "" { + return nil, errors.New("user id and device id required") + } + if len(deviceID) > 128 || !validDeviceID(deviceID) { + return nil, errors.New("invalid device id") + } + now := s.currentTime() + session := &model.AuthSession{ + UserID: userID, + DeviceID: deviceID, + DeviceName: truncateRunes(strings.TrimSpace(in.DeviceName), 128), + ClientName: truncateRunes(strings.TrimSpace(in.ClientName), 64), + ClientVersion: truncateRunes(strings.TrimSpace(in.ClientVersion), 64), + LastUsedAt: now, + ExpiresAt: now.Add(RefreshTokenDuration), + } + if err := s.repo.AuthSession.Create(ctx, session); err != nil { + return nil, err + } + return session, nil +} + +// Validate 校验会话仍可用于给定用户。 +// +// 未找到的会话按「已撤销」处理:App 收到 SESSION_REVOKED 后应重新登录, +// 而不是无限刷新。数据库错误保持原样返回,由上层映射成可重试的 503。 +func (s *AuthSessionService) Validate(ctx context.Context, sessionID, userID string) (*model.AuthSession, error) { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return nil, ErrAuthSessionRequired + } + session, err := s.repo.AuthSession.FindByID(ctx, sessionID) + if err != nil { + return nil, err + } + if session == nil || session.UserID != userID { + return nil, ErrAuthSessionRevoked + } + if session.Revoked { + return nil, ErrAuthSessionRevoked + } + if session.IsExpired() { + return nil, ErrAuthSessionExpired + } + return session, nil +} + +// Touch 更新会话最近使用时间,失败只记日志,不影响请求。 +func (s *AuthSessionService) Touch(ctx context.Context, sessionID string) { + if s == nil || sessionID == "" { + return + } + if err := s.repo.AuthSession.TouchLastUsed(ctx, sessionID, s.currentTime()); err != nil && s.log != nil { + s.log.Debug("touch auth session failed", zap.String("session_id", sessionID), zap.Error(err)) + } +} + +// Revoke 撤销单个会话及其名下刷新令牌(幂等)。 +func (s *AuthSessionService) Revoke(ctx context.Context, sessionID string) error { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return nil + } + if err := s.repo.AuthSession.Revoke(ctx, sessionID, s.currentTime()); err != nil { + return err + } + return s.repo.RefreshToken.RevokeByAuthSessionID(ctx, sessionID) +} + +// RevokeByUser 撤销某用户的全部会话(幂等)。 +func (s *AuthSessionService) RevokeByUser(ctx context.Context, userID string) error { + userID = strings.TrimSpace(userID) + if userID == "" { + return nil + } + sessions, err := s.repo.AuthSession.ListActiveByUser(ctx, userID) + if err != nil { + return err + } + for i := range sessions { + if err := s.Revoke(ctx, sessions[i].ID); err != nil { + return err + } + } + return nil +} + +// RevokeDevice 撤销某用户某设备的全部会话(幂等)。 +func (s *AuthSessionService) RevokeDevice(ctx context.Context, userID, deviceID string) error { + userID = strings.TrimSpace(userID) + deviceID = strings.TrimSpace(deviceID) + if userID == "" || deviceID == "" { + return nil + } + sessions, err := s.repo.AuthSession.ListByDevice(ctx, userID, deviceID) + if err != nil { + return err + } + for i := range sessions { + if sessions[i].Revoked { + continue + } + if err := s.Revoke(ctx, sessions[i].ID); err != nil { + return err + } + } + return nil +} + +func (s *AuthSessionService) currentTime() time.Time { + if s == nil || s.now == nil { + return time.Now() + } + return s.now() +} + +func validDeviceID(id string) bool { + for _, r := range id { + if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' || r == '.' || r == ':') { + return false + } + } + return true +} + +func truncateRunes(s string, max int) string { + if max <= 0 { + return "" + } + runes := []rune(s) + if len(runes) <= max { + return s + } + return string(runes[:max]) +} diff --git a/internal/service/auth_session_test.go b/internal/service/auth_session_test.go new file mode 100644 index 0000000..e4b7ee7 --- /dev/null +++ b/internal/service/auth_session_test.go @@ -0,0 +1,214 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" +) + +func newAuthSessionTestEnv(t *testing.T) (*AuthSessionService, *TokenService, *repository.Container) { + t.Helper() + db := newServiceTestDB(t, &model.AuthSession{}, &model.RefreshToken{}, &model.User{}, &model.Setting{}) + repos := repository.New(db) + sessions := NewAuthSessionService(zap.NewNop(), repos) + cfg := &config.Config{} + cfg.Secrets.JWTSecret = "test-secret" + tokens := NewTokenService(cfg, zap.NewNop(), repos) + tokens.SetSessionValidator(func(ctx context.Context, sessionID, userID string) error { + _, err := sessions.Validate(ctx, sessionID, userID) + return err + }) + return sessions, tokens, repos +} + +func seedSessionUser(t *testing.T, repos *repository.Container) *model.User { + t.Helper() + u := &model.User{Username: "mobile", PasswordHash: "x", Role: "user", Tier: "free", IsActive: true} + if err := repos.User.Create(t.Context(), u); err != nil { + t.Fatal(err) + } + return u +} + +func TestAuthSessionLifecycle(t *testing.T) { + sessions, tokens, repos := newAuthSessionTestEnv(t) + ctx := context.Background() + user := seedSessionUser(t, repos) + + session, err := sessions.Create(ctx, CreateAuthSessionInput{ + UserID: user.ID, + DeviceID: "device-abc", + DeviceName: "Pixel", + ClientName: "MeBox Android", + }) + if err != nil { + t.Fatalf("create session: %v", err) + } + if session.ID == "" || session.ExpiresAt.IsZero() { + t.Fatal("session missing id or expiry") + } + if _, err := sessions.Validate(ctx, session.ID, user.ID); err != nil { + t.Fatalf("validate active session: %v", err) + } + if _, err := sessions.Validate(ctx, session.ID, "other-user"); !errors.Is(err, ErrAuthSessionRevoked) { + t.Fatalf("cross-user validate err = %v, want ErrAuthSessionRevoked", err) + } + + pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID) + if err != nil { + t.Fatalf("issue pair: %v", err) + } + claims, err := tokens.ValidateAccessToken(pair.AccessToken) + if err != nil { + t.Fatalf("validate access token: %v", err) + } + if claims.Sid != session.ID { + t.Fatalf("access token sid = %q, want %q", claims.Sid, session.ID) + } + rt, err := repos.RefreshToken.FindByHash(ctx, repository.HashToken(pair.RefreshToken)) + if err != nil || rt == nil { + t.Fatalf("refresh token row = %v, err = %v", rt, err) + } + if rt.AuthSessionID != session.ID { + t.Fatalf("refresh token session = %q, want %q", rt.AuthSessionID, session.ID) + } + + if err := sessions.Revoke(ctx, session.ID); err != nil { + t.Fatalf("revoke session: %v", err) + } + if _, err := sessions.Validate(ctx, session.ID, user.ID); !errors.Is(err, ErrAuthSessionRevoked) { + t.Fatalf("validate revoked err = %v, want ErrAuthSessionRevoked", err) + } + rt, err = repos.RefreshToken.FindByHash(ctx, repository.HashToken(pair.RefreshToken)) + if err != nil || rt == nil { + t.Fatalf("reload refresh token: %v", err) + } + if !rt.Revoked { + t.Fatal("refresh token should be revoked with the session") + } + + // 幂等:重复撤销不报错。 + if err := sessions.Revoke(ctx, session.ID); err != nil { + t.Fatalf("second revoke: %v", err) + } +} + +func TestRefreshRejectsRevokedSession(t *testing.T) { + sessions, tokens, repos := newAuthSessionTestEnv(t) + ctx := context.Background() + user := seedSessionUser(t, repos) + + session, err := sessions.Create(ctx, CreateAuthSessionInput{UserID: user.ID, DeviceID: "device-1"}) + if err != nil { + t.Fatal(err) + } + pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID) + if err != nil { + t.Fatal(err) + } + if err := sessions.Revoke(ctx, session.ID); err != nil { + t.Fatal(err) + } + if _, err := tokens.Refresh(ctx, pair.RefreshToken); !errors.Is(err, ErrAuthSessionRevoked) { + t.Fatalf("refresh revoked session err = %v, want ErrAuthSessionRevoked", err) + } +} + +func TestRefreshKeepsSessionAssociation(t *testing.T) { + sessions, tokens, repos := newAuthSessionTestEnv(t) + ctx := context.Background() + user := seedSessionUser(t, repos) + + session, err := sessions.Create(ctx, CreateAuthSessionInput{UserID: user.ID, DeviceID: "device-2"}) + if err != nil { + t.Fatal(err) + } + pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID) + if err != nil { + t.Fatal(err) + } + rotated, err := tokens.Refresh(ctx, pair.RefreshToken) + if err != nil { + t.Fatalf("refresh: %v", err) + } + claims, err := tokens.ValidateAccessToken(rotated.AccessToken) + if err != nil { + t.Fatal(err) + } + if claims.Sid != session.ID { + t.Fatalf("rotated access sid = %q, want %q", claims.Sid, session.ID) + } + // 刷新路径使用 best-effort 落库;轮换结果可能稍后才写入。 + var rt *model.RefreshToken + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + rt, err = repos.RefreshToken.FindByHash(ctx, repository.HashToken(rotated.RefreshToken)) + if err == nil && rt != nil { + break + } + time.Sleep(20 * time.Millisecond) + } + if err != nil || rt == nil { + t.Fatalf("rotated refresh row: %v err=%v", rt, err) + } + if rt.AuthSessionID != session.ID { + t.Fatalf("rotated refresh session = %q, want %q", rt.AuthSessionID, session.ID) + } +} + +func TestServerIdentityPersistsAcrossInstances(t *testing.T) { + db := newServiceTestDB(t, &model.Setting{}) + repos := repository.New(db) + ctx := context.Background() + + first := NewServerIdentityService(repos, "MeBox-v1.2.3") + id1, err := first.ServerID(ctx) + if err != nil { + t.Fatal(err) + } + if id1 == "" { + t.Fatal("empty server id") + } + + // 独立服务实例(模拟重启)读到同一个持久身份。 + second := NewServerIdentityService(repos, "MeBox-v1.2.3") + id2, err := second.ServerID(ctx) + if err != nil { + t.Fatal(err) + } + if id2 != id1 { + t.Fatalf("server id changed across instances: %q != %q", id2, id1) + } + + info, err := second.Info(ctx, ServerCapabilities{DeviceSessions: true}) + if err != nil { + t.Fatal(err) + } + if info.ServerID != id1 || info.ServerVersion != "MeBox-v1.2.3" || info.APIVersion != "v1" { + t.Fatalf("unexpected info: %+v", info) + } + if !info.Capabilities.DeviceSessions || info.Capabilities.PlaybackSessions { + t.Fatalf("unexpected capabilities: %+v", info.Capabilities) + } +} + +func TestAuthSessionExpiry(t *testing.T) { + sessions, _, repos := newAuthSessionTestEnv(t) + user := seedSessionUser(t, repos) + sessions.now = func() time.Time { return time.Now().Add(-40 * 24 * time.Hour) } + session, err := sessions.Create(context.Background(), CreateAuthSessionInput{UserID: user.ID, DeviceID: "device-old"}) + if err != nil { + t.Fatal(err) + } + sessions.now = time.Now + if _, err := sessions.Validate(context.Background(), session.ID, user.ID); !errors.Is(err, ErrAuthSessionExpired) { + t.Fatalf("expired validate err = %v, want ErrAuthSessionExpired", err) + } +} diff --git a/internal/service/auth_tokens.go b/internal/service/auth_tokens.go index cb7cef9..7d6eb37 100644 --- a/internal/service/auth_tokens.go +++ b/internal/service/auth_tokens.go @@ -108,7 +108,13 @@ func (s *AuthService) RefreshTokens(ctx context.Context, refreshToken string) (* return s.tokenSvc.Refresh(ctx, refreshToken) } -// Logout 撤销用户的所有刷新令牌。 +// Logout 撤销用户的所有刷新令牌与认证会话。 func (s *AuthService) Logout(ctx context.Context, userID string) error { - return s.tokenSvc.RevokeAll(ctx, userID) + if err := s.tokenSvc.RevokeAll(ctx, userID); err != nil { + return err + } + if s.sessionSvc != nil { + return s.sessionSvc.RevokeByUser(ctx, userID) + } + return nil } diff --git a/internal/service/device_listing.go b/internal/service/device_listing.go index 061f935..57e71d3 100644 --- a/internal/service/device_listing.go +++ b/internal/service/device_listing.go @@ -7,6 +7,8 @@ import ( "strings" "time" + "go.uber.org/zap" + "github.com/truewhile/MeBox/internal/model" ) @@ -28,6 +30,14 @@ func (s *DeviceService) KickDevice(ctx context.Context, userID, deviceID string) if err != nil { return err } + // 立即撤销该设备的认证会话:只标记 UserDevice 不足以让已签发的 JWT + // 失效,新协议令牌必须同时被会话校验拒绝。 + if s.authSessions != nil { + if err := s.authSessions.RevokeDevice(ctx, userID, d.DeviceID); err != nil && s.log != nil { + s.log.Warn("revoke auth sessions for kicked device failed", + zap.String("user_id", userID), zap.String("device_id", d.DeviceID), zap.Error(err)) + } + } s.notify(ctx, userID, fmt.Sprintf("🔌 设备已下线:%s\n该终端需要重新登录后才能继续使用。", deviceLabel(d.DeviceName, d.Client))) return nil } @@ -37,6 +47,12 @@ func (s *DeviceService) KickAllDevices(ctx context.Context, userID string) error if err := s.repo.UserDevice.SetKickedByUser(ctx, userID, true); err != nil { return err } + if s.authSessions != nil { + if err := s.authSessions.RevokeByUser(ctx, userID); err != nil && s.log != nil { + s.log.Warn("revoke auth sessions for kicked user failed", + zap.String("user_id", userID), zap.Error(err)) + } + } s.notify(ctx, userID, "🔌 你名下的全部设备已下线,需要重新登录后才能继续使用。") return nil } diff --git a/internal/service/device_service.go b/internal/service/device_service.go index 07bfe8b..77e4b01 100644 --- a/internal/service/device_service.go +++ b/internal/service/device_service.go @@ -31,6 +31,8 @@ type DeviceService struct { log *zap.Logger repo *repository.Container sessions *SessionTrackerService + // authSessions 用于踢出设备时立即撤销其认证会话;nil 表示旧装配路径。 + authSessions *AuthSessionService // notifyUser sends a Telegram message to the local user (resolved to their // Telegram binding). Wired by the bot service; nil disables notifications. @@ -60,6 +62,11 @@ func (s *DeviceService) SetSessionTracker(tracker *SessionTrackerService) { s.sessions = tracker } +// SetAuthSessionService 注入认证会话服务,使踢出设备同时撤销其新协议令牌。 +func (s *DeviceService) SetAuthSessionService(sessions *AuthSessionService) { + s.authSessions = sessions +} + // fingerprint derives a stable terminal hash from the device name. Client/app // names are deliberately ignored so one phone/TV/PC using multiple apps is // still counted as one terminal device; Client remains a login channel label. diff --git a/internal/service/favorites_sync.go b/internal/service/favorites_sync.go index cc72d5d..4b6e446 100644 --- a/internal/service/favorites_sync.go +++ b/internal/service/favorites_sync.go @@ -35,16 +35,28 @@ func IsUserFavorite(ctx context.Context, repo *repository.Container, userID, med func setLocalFavorite(ctx context.Context, repo *repository.Container, userID, mediaID string, favorite bool) error { if favorite { var existing model.Favorite - err := repo.DB.WithContext(ctx). + // 收藏行的删除是软删除;重新收藏时必须连已软删除的行一起查出来, + // 否则会再次插入同一 (user_id, media_id),撞上唯一索引后稳定返回 + // 500,用户表现为“取消收藏后无法再收藏”。 + err := repo.DB.WithContext(ctx).Unscoped(). Where("user_id = ? AND media_id = ?", userID, mediaID). First(&existing).Error - if errors.Is(err, gorm.ErrRecordNotFound) { + switch { + case errors.Is(err, gorm.ErrRecordNotFound): return repo.DB.WithContext(ctx).Create(&model.Favorite{ UserID: userID, MediaID: mediaID, }).Error + case err != nil: + return err + case existing.DeletedAt.Valid: + return repo.DB.WithContext(ctx).Unscoped(). + Model(&model.Favorite{}). + Where("id = ?", existing.ID). + Update("deleted_at", nil).Error + default: + return nil } - return err } return repo.DB.WithContext(ctx). Where("user_id = ? AND media_id = ?", userID, mediaID). diff --git a/internal/service/favorites_sync_test.go b/internal/service/favorites_sync_test.go index 0c7e85c..64366c1 100644 --- a/internal/service/favorites_sync_test.go +++ b/internal/service/favorites_sync_test.go @@ -44,6 +44,19 @@ func TestSyncUserFavoriteWritesLocalForRemoteID(t *testing.T) { if favorite { t.Fatal("expected remote favourite to be removed locally") } + + // 软删除后重新收藏必须恢复原行:唯一索引 (user_id, media_id) 不允许 + // 再插入一条,旧的实现会在这里返回约束错误。 + if err := SyncUserFavorite(context.Background(), repos, nil, userID, remoteMediaID, true); err != nil { + t.Fatalf("SyncUserFavorite re-favorite: %v", err) + } + favorite, err = IsUserFavorite(context.Background(), repos, userID, remoteMediaID) + if err != nil { + t.Fatalf("IsUserFavorite after re-favorite: %v", err) + } + if !favorite { + t.Fatal("expected re-favourite to restore the soft-deleted row") + } } func TestFavoriteItemsIncludesRemoteFavourites(t *testing.T) { diff --git a/internal/service/server_identity.go b/internal/service/server_identity.go new file mode 100644 index 0000000..43e3d84 --- /dev/null +++ b/internal/service/server_identity.go @@ -0,0 +1,126 @@ +// Package service — 服务器身份与移动端能力声明。 +package service + +import ( + "context" + "errors" + "strings" + "sync" + + "github.com/google/uuid" + + "github.com/truewhile/MeBox/internal/repository" +) + +// 服务器身份与移动端契约常量。 +const ( + // ServerIDSettingKey 是持久化实例身份的设置键。普通重启、升级和同实例 + // 数据恢复都沿用同一个值;把备份克隆成独立服务时需要显式重置。 + ServerIDSettingKey = "mobile.server_id" + + // MobileAPIVersion 是 /api/v1 契约版本。 + MobileAPIVersion = "v1" + + // MinSupportedAppVersionCode 是首个移动端契约要求的最低 App versionCode。 + MinSupportedAppVersionCode = 1 + + // MobileServerName 是 server-info 暴露的实例名称。 + MobileServerName = "MeBox" +) + +// ServerCapabilities 是 server-info 的能力开关。 +// +// 每项都由「接口已实现且当前可运行」共同决定;仅注册了 handler 但功能 +// 不可用时必须返回 false,避免 App 依据虚假能力进入不可用流程。 +type ServerCapabilities struct { + DeviceSessions bool `json:"device_sessions"` + PlaybackSessions bool `json:"playback_sessions"` + ReaderProgressV1 bool `json:"reader_progress_v1"` + ReaderContentBatch bool `json:"reader_content_batch"` + LocalTranscode bool `json:"local_transcode"` +} + +// ServerInfo 是 GET /api/v1/server-info 的响应。 +type ServerInfo struct { + Name string `json:"name"` + ServerID string `json:"server_id"` + ServerVersion string `json:"server_version"` + APIVersion string `json:"api_version"` + MinSupportedAppVersionCode int `json:"min_supported_app_version_code"` + Capabilities ServerCapabilities `json:"capabilities"` +} + +// ServerIdentityService 负责生成并持久化实例身份。 +type ServerIdentityService struct { + repo *repository.Container + version string + + mu sync.Mutex + cached string +} + +// NewServerIdentityService 创建实例身份服务。 +func NewServerIdentityService(repo *repository.Container, version string) *ServerIdentityService { + return &ServerIdentityService{repo: repo, version: strings.TrimSpace(version)} +} + +// ServerID 返回持久化的实例 ID,首次调用时生成并写入设置表。 +func (s *ServerIdentityService) ServerID(ctx context.Context) (string, error) { + if s == nil || s.repo == nil || s.repo.Setting == nil { + return "", errors.New("server identity unavailable") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.cached != "" { + return s.cached, nil + } + stored, err := s.repo.Setting.Get(ctx, ServerIDSettingKey) + if err != nil { + return "", err + } + stored = strings.TrimSpace(stored) + if stored != "" { + s.cached = stored + return stored, nil + } + generated := uuid.NewString() + if err := s.repo.Setting.Set(ctx, ServerIDSettingKey, generated); err != nil { + return "", err + } + s.cached = generated + return generated, nil +} + +// Info 组装 server-info 响应。capabilities 由调用方按真实可用服务推导。 +func (s *ServerIdentityService) Info(ctx context.Context, capabilities ServerCapabilities) (ServerInfo, error) { + serverID, err := s.ServerID(ctx) + if err != nil { + return ServerInfo{}, err + } + version := strings.TrimSpace(s.version) + if version == "" { + version = "dev" + } + return ServerInfo{ + Name: MobileServerName, + ServerID: serverID, + ServerVersion: version, + APIVersion: MobileAPIVersion, + MinSupportedAppVersionCode: MinSupportedAppVersionCode, + Capabilities: capabilities, + }, nil +} + +// CurrentServerCapabilities 依据服务容器推导移动端能力。 +// +// M1 只启用了设备会话;播放会话、阅读 v1 进度与批量正文属于后续阶段, +// 在对应任务真正实现并通过契约测试前保持 false。 +func CurrentServerCapabilities(c *Container) ServerCapabilities { + return ServerCapabilities{ + DeviceSessions: c != nil && c.AuthSessions != nil, + PlaybackSessions: false, + ReaderProgressV1: false, + ReaderContentBatch: false, + LocalTranscode: false, + } +} diff --git a/internal/service/service.go b/internal/service/service.go index 500ac69..964380e 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -60,6 +60,8 @@ type Container struct { OrganizePipeline *OrganizePipelineService Douban *DoubanProvider Token *TokenService + AuthSessions *AuthSessionService + ServerIdentity *ServerIdentityService ApiConfig *ApiConfigService Device *DeviceService Telegram *TelegramService diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index f2c9b43..c7717fe 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -234,10 +234,20 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() { func (b *serviceContainerBuilder) initIdentityServices() { b.c.Token = NewTokenService(b.cfg, b.log, b.repos) + b.c.AuthSessions = NewAuthSessionService(b.log, b.repos) + b.c.ServerIdentity = NewServerIdentityService(b.repos, b.version) + // 刷新令牌轮换时必须校验所属会话仍然有效,否则被踢设备可以用旧刷新 + // 令牌换出新令牌。 + b.c.Token.SetSessionValidator(func(ctx context.Context, sessionID, userID string) error { + _, err := b.c.AuthSessions.Validate(ctx, sessionID, userID) + return err + }) b.c.Auth = NewAuthService(b.cfg, b.log, b.repos, b.c.Token, b.c.Permissions) + b.c.Auth.SetAuthSessionService(b.c.AuthSessions) b.c.Sessions = NewSessionTrackerService(b.log) b.c.Device = NewDeviceService(b.log, b.repos) b.c.Device.SetSessionTracker(b.c.Sessions) + b.c.Device.SetAuthSessionService(b.c.AuthSessions) // Telegram 通知:未启用时 Start 不会占用 goroutine,SendToUser 静默跳过。 b.c.Telegram = NewTelegramService(b.log, b.repos) b.c.Device.SetNotifier(b.c.Telegram.SendToUser) diff --git a/internal/service/token_svc.go b/internal/service/token_svc.go index faec6af..b887765 100644 --- a/internal/service/token_svc.go +++ b/internal/service/token_svc.go @@ -33,6 +33,8 @@ type Claims struct { Tier string `json:"tier,omitempty"` Purpose string `json:"purpose,omitempty"` MediaID string `json:"media_id,omitempty"` + // Sid 是设备认证会话 ID。旧令牌没有该字段,继续走兼容路径。 + Sid string `json:"sid,omitempty"` jwt.RegisteredClaims } @@ -60,6 +62,10 @@ type TokenService struct { // rotations 记录刚轮换过的 refresh token 及其新令牌对,供宽限期内 // 幂等复用(见 token_refresh_rotation.go)。 rotations map[string]rotatedRefreshToken + + // sessionValidator 校验刷新令牌所属认证会话是否仍有效;由服务构建器 + // 注入,nil 表示不做会话校验(测试或旧配置)。 + sessionValidator func(ctx context.Context, sessionID, userID string) error } // NewTokenService 创建令牌服务实例。 @@ -92,19 +98,35 @@ var ( // IssuePair 为用户签发新的令牌对。 func (s *TokenService) IssuePair(ctx context.Context, userID, role, tier string) (*TokenPair, error) { - return s.issuePair(ctx, userID, role, tier, false) + return s.issuePair(ctx, userID, role, tier, "", false) } // IssuePairBestEffort 为登录签发令牌。SQLite 被后台扫描长期写锁占用时, // 登录不能因为 refresh token 暂时无法落库而失败:先返回可用 access token, // 再在后台把 refresh token 补写进库。 func (s *TokenService) IssuePairBestEffort(ctx context.Context, userID, role, tier string) (*TokenPair, error) { - return s.issuePair(ctx, userID, role, tier, true) + return s.issuePair(ctx, userID, role, tier, "", true) } -func (s *TokenService) issuePair(ctx context.Context, userID, role, tier string, bestEffort bool) (*TokenPair, error) { +// IssuePairForSession 为指定认证会话签发令牌对,访问令牌携带 sid, +// 刷新令牌记录关联该会话。 +func (s *TokenService) IssuePairForSession(ctx context.Context, userID, role, tier, sessionID string) (*TokenPair, error) { + return s.issuePair(ctx, userID, role, tier, sessionID, false) +} + +// IssuePairBestEffortForSession 与 IssuePairBestEffort 相同,但绑定会话。 +func (s *TokenService) IssuePairBestEffortForSession(ctx context.Context, userID, role, tier, sessionID string) (*TokenPair, error) { + return s.issuePair(ctx, userID, role, tier, sessionID, true) +} + +// SetSessionValidator 注入认证会话校验器。 +func (s *TokenService) SetSessionValidator(fn func(ctx context.Context, sessionID, userID string) error) { + s.sessionValidator = fn +} + +func (s *TokenService) issuePair(ctx context.Context, userID, role, tier, sessionID string, bestEffort bool) (*TokenPair, error) { // 生成 Access Token - accessToken, err := s.issueAccessToken(userID, role, tier) + accessToken, err := s.issueAccessToken(userID, role, tier, sessionID) if err != nil { return nil, err } @@ -118,12 +140,13 @@ func (s *TokenService) issuePair(ctx context.Context, userID, role, tier string, // 存储 Refresh Token 哈希 tokenHash := repository.HashToken(refreshToken) rt := &model.RefreshToken{ - UserID: userID, - TokenHash: tokenHash, - ExpiresAt: time.Now().Add(RefreshTokenDuration), + UserID: userID, + TokenHash: tokenHash, + AuthSessionID: sessionID, + ExpiresAt: time.Now().Add(RefreshTokenDuration), } if bestEffort { - s.storeRefreshTokenBestEffort(userID, tokenHash, rt.ExpiresAt) + s.storeRefreshTokenBestEffort(userID, tokenHash, sessionID, rt.ExpiresAt) return &TokenPair{ AccessToken: accessToken, RefreshToken: refreshToken, @@ -162,11 +185,12 @@ func (s *TokenService) maxActiveRefreshTokens(ctx context.Context) int { } // issueAccessToken 签发 JWT Access Token(HS256,60分钟有效期)。 -func (s *TokenService) issueAccessToken(userID, role, tier string) (string, error) { +func (s *TokenService) issueAccessToken(userID, role, tier, sessionID string) (string, error) { claims := Claims{ UserID: userID, Role: role, Tier: tier, + Sid: sessionID, RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)), @@ -232,14 +256,28 @@ func (s *TokenService) refreshOnce(ctx context.Context, tokenHash string) (*Toke return nil, ErrInvalidRefreshToken } rt = &model.RefreshToken{ - UserID: pending.UserID, - TokenHash: tokenHash, - ExpiresAt: pending.ExpiresAt, + UserID: pending.UserID, + TokenHash: tokenHash, + AuthSessionID: pending.SessionID, + ExpiresAt: pending.ExpiresAt, + } + // 尚未落库的令牌同样受会话撤销约束。 + if rt.AuthSessionID != "" && s.sessionValidator != nil { + if err := s.sessionValidator(ctx, rt.AuthSessionID, rt.UserID); err != nil { + return nil, err + } } } // 检查是否已撤销 if rt.Revoked { + // 会话被撤销时必须在宽限期复用之前拦截:否则登出/被踢后的 60 秒 + // 内,旧刷新令牌仍能从内存复用表换回一对有效令牌。 + if rt.AuthSessionID != "" && s.sessionValidator != nil { + if err := s.sessionValidator(ctx, rt.AuthSessionID, rt.UserID); err != nil { + return nil, err + } + } // 刚被轮换过的 token 在宽限期内允许幂等复用,避免并发/重试的 // 客户端拿到 revoked 401 后清空整个会话。 if pair, ok := s.reusedRotation(tokenHash, rt.UserID); ok { @@ -268,14 +306,22 @@ func (s *TokenService) refreshOnce(ctx context.Context, tokenHash string) (*Toke return nil, ErrUserExpired } + // 会话被踢出/撤销后,旧刷新令牌不能再换出新会话。检查必须发生在撤销 + // 旧令牌之前,否则一次失败的重试会把仍然有效的令牌标记成已撤销。 + if rt.AuthSessionID != "" && s.sessionValidator != nil { + if err := s.sessionValidator(ctx, rt.AuthSessionID, user.ID); err != nil { + return nil, err + } + } + // 撤销旧的 Refresh Token(包括可能仍在后台补写队列里的副本)。 if err := s.repo.RefreshToken.Revoke(ctx, tokenHash); err != nil { s.log.Warn("failed to revoke old refresh token", zap.Error(err)) } s.untrackDelayedStore(rt.UserID, tokenHash) - // 签发新的令牌对 - pair, err := s.IssuePairBestEffort(ctx, user.ID, user.Role, user.Tier) + // 签发新的令牌对;轮换后的令牌继续归属同一个认证会话。 + pair, err := s.IssuePairBestEffortForSession(ctx, user.ID, user.Role, user.Tier, rt.AuthSessionID) if err != nil { return nil, err } diff --git a/internal/service/token_svc_pending.go b/internal/service/token_svc_pending.go index 662bb20..3d7df45 100644 --- a/internal/service/token_svc_pending.go +++ b/internal/service/token_svc_pending.go @@ -16,11 +16,12 @@ const loginRefreshTokenStoreTimeout = 750 * time.Millisecond type pendingRefreshToken struct { UserID string + SessionID string ExpiresAt time.Time } -func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash string, expiresAt time.Time) { - if !s.trackDelayedStore(userID, tokenHash, expiresAt) { +func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash, sessionID string, expiresAt time.Time) { + if !s.trackDelayedStore(userID, tokenHash, sessionID, expiresAt) { return } done := make(chan error, 1) @@ -29,16 +30,17 @@ func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash string, exp defer cancel() err := helper.Recover(s.log, "token.storeRefreshToken", func() error { return s.storeRefreshToken(ctx, &model.RefreshToken{ - UserID: userID, - TokenHash: tokenHash, - ExpiresAt: expiresAt, + UserID: userID, + TokenHash: tokenHash, + AuthSessionID: sessionID, + ExpiresAt: expiresAt, }) }) done <- err }() select { case err := <-done: - s.finishBestEffortRefreshTokenStore(userID, tokenHash, expiresAt, err) + s.finishBestEffortRefreshTokenStore(userID, tokenHash, sessionID, expiresAt, err) case <-time.After(loginRefreshTokenStoreTimeout): if s.log != nil { s.log.Warn("refresh token store delayed; login will continue", @@ -47,12 +49,12 @@ func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash string, exp } go func() { err := <-done - s.finishBestEffortRefreshTokenStore(userID, tokenHash, expiresAt, err) + s.finishBestEffortRefreshTokenStore(userID, tokenHash, sessionID, expiresAt, err) }() } } -func (s *TokenService) finishBestEffortRefreshTokenStore(userID, tokenHash string, expiresAt time.Time, err error) { +func (s *TokenService) finishBestEffortRefreshTokenStore(userID, tokenHash, sessionID string, expiresAt time.Time, err error) { if err == nil { s.untrackDelayedStore(userID, tokenHash) return @@ -63,7 +65,7 @@ func (s *TokenService) finishBestEffortRefreshTokenStore(userID, tokenHash strin zap.String("user_id", userID), zap.Error(err)) } - s.storeRefreshTokenEventually(userID, tokenHash, expiresAt) + s.storeRefreshTokenEventually(userID, tokenHash, sessionID, expiresAt) return } s.untrackDelayedStore(userID, tokenHash) @@ -72,22 +74,23 @@ func (s *TokenService) finishBestEffortRefreshTokenStore(userID, tokenHash strin } } -func (s *TokenService) storeRefreshTokenEventually(userID, tokenHash string, expiresAt time.Time) { +func (s *TokenService) storeRefreshTokenEventually(userID, tokenHash, sessionID string, expiresAt time.Time) { defer s.untrackDelayedStore(userID, tokenHash) delay := time.Second for attempt := 1; attempt <= 8; attempt++ { timer := time.NewTimer(delay) <-timer.C - // 令牌可能已在等待期间被轮换/登出(从 pending 表移除), - // 此时绝不能再写库,否则会复活一个已被替换的旧令牌。 + // ?????????????/???? pending ????? + // ????????????????????????? if _, stillPending := s.pendingDelayedStore(tokenHash); !stillPending { return } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) err := s.storeRefreshToken(ctx, &model.RefreshToken{ - UserID: userID, - TokenHash: tokenHash, - ExpiresAt: expiresAt, + UserID: userID, + TokenHash: tokenHash, + AuthSessionID: sessionID, + ExpiresAt: expiresAt, }) cancel() if err == nil { @@ -114,7 +117,7 @@ func (s *TokenService) storeRefreshTokenEventually(userID, tokenHash string, exp } } -func (s *TokenService) trackDelayedStore(userID, tokenHash string, expiresAt time.Time) bool { +func (s *TokenService) trackDelayedStore(userID, tokenHash, sessionID string, expiresAt time.Time) bool { if s == nil { return false } @@ -126,7 +129,7 @@ func (s *TokenService) trackDelayedStore(userID, tokenHash string, expiresAt tim if _, ok := s.delayedStores[tokenHash]; ok { return false } - s.delayedStores[tokenHash] = pendingRefreshToken{UserID: userID, ExpiresAt: expiresAt} + s.delayedStores[tokenHash] = pendingRefreshToken{UserID: userID, SessionID: sessionID, ExpiresAt: expiresAt} return true } @@ -139,7 +142,7 @@ func (s *TokenService) untrackDelayedStore(userID, tokenHash string) { s.delayedStoreMu.Unlock() } -// pendingDelayedStore 返回尚未落库的 refresh token 信息(如果存在)。 +// pendingDelayedStore ??????? refresh token ????????? func (s *TokenService) pendingDelayedStore(tokenHash string) (pendingRefreshToken, bool) { if s == nil { return pendingRefreshToken{}, false diff --git a/internal/service/token_svc_pending_test.go b/internal/service/token_svc_pending_test.go index 7e1719a..9abe0b8 100644 --- a/internal/service/token_svc_pending_test.go +++ b/internal/service/token_svc_pending_test.go @@ -34,7 +34,7 @@ func TestRefreshAcceptsPendingDelayedToken(t *testing.T) { refreshToken := "pending-token-value" hash := repository.HashToken(refreshToken) - if !svc.trackDelayedStore(u.ID, hash, time.Now().Add(time.Hour)) { + if !svc.trackDelayedStore(u.ID, hash, "", time.Now().Add(time.Hour)) { t.Fatal("trackDelayedStore returned false") } @@ -97,7 +97,7 @@ func TestRefreshRejectsExpiredPendingToken(t *testing.T) { refreshToken := "expired-pending" hash := repository.HashToken(refreshToken) - svc.trackDelayedStore("user-x", hash, time.Now().Add(-time.Minute)) + svc.trackDelayedStore("user-x", hash, "", time.Now().Add(-time.Minute)) if _, err := svc.Refresh(t.Context(), refreshToken); err == nil { t.Fatal("expired pending token should be rejected")