handler.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. // Package handler 身份域 HTTP 接入层(/auth/* 端点,架构规格 §3.2 三层之"Handler")。
  2. //
  3. // 分层职责:
  4. // - 本包(Handler):把请求体绑定/校验(validator)→ 调 service.AuthService(纯业务)→
  5. // 把 *AppError 映射为统一响应信封(response.Fail / OK)。**不写业务逻辑、不直接碰 DB/Redis**。
  6. // - Service(identity/service):两步登录 / me / refresh / logout 编排(见其包注释)。
  7. // - 验签中间件(identity/handler 的 auth.go,T4-5):受保护路由前置,验签 + 作用域注入;
  8. // 本包 Me/Logout 从 c.Locals 读取中间件写入的已验签 claims(见 ClaimsCtxKey)。
  9. //
  10. // 端点清单(路由挂载在 T4-6 main 完成;此处只定义方法):
  11. // - POST /auth/login 两步登录①:验账密 → 一次性票据 + 用户 + 作用域列表(**公开**,无鉴权)
  12. // - POST /auth/issue 两步登录②:消费票据 + 选作用域 → access+refresh(**公开**,凭票据)
  13. // - POST /auth/refresh refresh 轮换:旧 refresh 换新对(**公开**,凭 refresh)
  14. // - GET /auth/wechat/qrcode、GET /auth/wechat/status PC 微信扫码(state 状态机 + 复用两步②票据)
  15. // - GET /auth/wechat/authorize 移动端 App 内微信 webview 授权(snsapi_userinfo,整页跳)
  16. // - GET /auth/wechat/callback 微信重定向落点(code 换 openid,两 scope 共用)
  17. // - GET /auth/me 登录态自查(**受保护**,需 Auth 中间件)
  18. // - POST /auth/logout 登出(**受保护**,恒 200 幂等;撤销尽力而为)
  19. //
  20. // 契约要点:
  21. // - HTTP 恒 200,真实状态在 body code(铁律 6;见 infra/response)。
  22. // - 校验失败由 validator 内部写 code=2001 并返回 false,Handler `return nil` 终止(铁律 7)。
  23. // - logout 对客户端**恒 200**(本地态必清):撤销失败只记日志,不阻断(对齐前端 doLogout 幂等语义)。
  24. package handler
  25. import (
  26. "encoding/json"
  27. "time"
  28. "github.com/gofiber/fiber/v2"
  29. "go.uber.org/zap"
  30. "yuansiwei/unified-backend/internal/identity/model"
  31. "yuansiwei/unified-backend/internal/identity/service"
  32. "yuansiwei/unified-backend/internal/identity/token"
  33. apperrors "yuansiwei/unified-backend/internal/infra/errors"
  34. "yuansiwei/unified-backend/internal/infra/response"
  35. "yuansiwei/unified-backend/internal/infra/validator"
  36. )
  37. // ClaimsCtxKey 已验签 claims 在 fiber.Ctx 局部值(c.Locals)中的键。
  38. //
  39. // 由 T4-5 的 Auth 验签中间件在验签成功后写入(*token.Claims);本包 Me/Logout
  40. // 经 claimsFromCtx 读回。**中间件写入与 Handler 读取两端用同一常量**,避免字符串散落
  41. // (对齐 infra/scope 的 CtxKey 纪律)。受保护路由若无此值 = 中间件链配置错误,按未登录拒绝。
  42. const ClaimsCtxKey = "authClaims"
  43. // Handler 身份域 HTTP 处理器(启动装配一次,全进程共享)。
  44. //
  45. // 字段(装配入口 New 注入):
  46. // - svc:身份域业务服务(两步登录 / me / refresh / logout 编排,T4-3)——**必填**。
  47. // - wechat:微信扫码登录服务(二维码/状态轮询/回调编排)——可选(nil 时 /auth/wechat/* 端点
  48. // 返回 4004"微信扫码未配置";main 仅在 WeChatCfg 配齐 appid+回调地址时注入)。
  49. // - log:结构化日志器(logout 撤销失败的告警出口;nil 时静默不记,不阻断)。
  50. type Handler struct {
  51. svc *service.AuthService
  52. wechat *service.WechatService
  53. log *zap.Logger
  54. }
  55. // Config Handler 装配参数(T4-6 main 按此构造)。
  56. type Config struct {
  57. // Svc 身份域业务服务(必填)。
  58. Svc *service.AuthService
  59. // Wechat 微信扫码登录服务(可选;nil 时微信端点返回 4004 未配置)。
  60. Wechat *service.WechatService
  61. // Log 结构化日志器(可选;nil 时登出撤销失败不记日志,仍恒 200)。
  62. Log *zap.Logger
  63. }
  64. // New 构造身份域 HTTP 处理器。
  65. //
  66. // 入参:cfg Config:见 Config 字段说明。
  67. // 出参:
  68. // - *Handler:就绪处理器。
  69. // - error:必填依赖缺失(Svc 为 nil,fail-fast——无业务服务不允许挂载端点)。
  70. func New(cfg Config) (*Handler, error) {
  71. if cfg.Svc == nil {
  72. return nil, apperrors.New(apperrors.CodeInternal, "handler: AuthService 未注入")
  73. }
  74. return &Handler{svc: cfg.Svc, wechat: cfg.Wechat, log: cfg.Log}, nil
  75. }
  76. // ---- 请求体 DTO(入参;camelCase 与前端契约严格对齐,见 README 命名规范)----
  77. // LoginReq 两步登录① 请求体(账号密码登录,账号 = 用户名或手机号,二选一)。
  78. //
  79. // 字段:
  80. // - Username:用户名(与 Phone 二选一;归一/校验在 Service,此处仅校验长度上限)。
  81. // - Phone:手机号(与 Username 二选一;11 位,Service 层 isValidPhone 做权威位数校验,此处 max=11 兜底)。
  82. // - Password:明文密码(6~128 位;**仅内存调用栈,禁止进日志/响应**)。
  83. //
  84. // 校验:Username 与 Phone 至少一个非空(required_without 交叉约束);两者都给时 Handler 优先走 phone。
  85. type LoginReq struct {
  86. Username string `json:"username" validate:"required_without=Phone,max=64"`
  87. Phone string `json:"phone" validate:"required_without=Username,max=11"`
  88. Password string `json:"password" validate:"required,min=6,max=128"`
  89. }
  90. // IssueReq 两步登录② 请求体(选定作用域后换取 token)。
  91. //
  92. // 字段:
  93. // - LoginTicket:① 返回的一次性票据(32 字符 hex;已消费/过期/不存在 → 拒绝)。
  94. // - InstitutionID:选定机构(scope 根;0=平台级超管,故不标 required)。
  95. // - CampusID:选定校区(0=机构级/不限,故不标 required)。
  96. type IssueReq struct {
  97. LoginTicket string `json:"loginTicket" validate:"required,len=32"`
  98. InstitutionID int64 `json:"institutionId"`
  99. CampusID int64 `json:"campusId"`
  100. }
  101. // RefreshReq refresh 轮换请求体。
  102. type RefreshReq struct {
  103. // RefreshToken:旧 refresh 不透明串(64 字符 hex = 32 字节随机,Issue 返回的原样值;
  104. // 见 token.Issue——hex.EncodeToString(32 字节) 恒 64 位)。
  105. RefreshToken string `json:"refreshToken" validate:"required,len=64"`
  106. }
  107. // LogoutReq 登出请求体(refresh 可空——仅凭 access jti 也能拉黑当前 access)。
  108. type LogoutReq struct {
  109. // RefreshToken:当前 refresh 串(删其白名单键;空则跳过,仅吊销 access)。
  110. RefreshToken string `json:"refreshToken"`
  111. }
  112. // ---- 端点 ----
  113. // Login 两步登录**第一步**(POST /auth/login,公开)。
  114. //
  115. // 账号 = 用户名或手机号二选一(LoginReq 用 required_without 交叉约束);两者都给时优先 phone。
  116. // phone 走 model.IdentityTypePhone(Service 层 isValidPhone 权威校验 + 唯一键 (phone, 号, "") 命中),
  117. // username 走 model.IdentityTypeUsername——两者共用同一套两步①②协议 + 统一 RS256 token。
  118. //
  119. // 出参(成功):LoginStep1 → {loginTicket, user, memberships[]}(**不签 token**)。
  120. // 出参(失败):code 1001(统一文案防枚举)/ 2001(参数,含手机号位数不合法)/ 5002(DB·票据写失败)。
  121. func (h *Handler) Login(c *fiber.Ctx) error {
  122. var req LoginReq
  123. if !validator.Validate(c, &req) { // 校验失败:body code=2001 已写出
  124. return nil
  125. }
  126. identityType, identifier := model.IdentityTypeUsername, req.Username
  127. if req.Phone != "" { // phone 优先(与 username 同传时按 phone 走)
  128. identityType, identifier = model.IdentityTypePhone, req.Phone
  129. }
  130. res, err := h.svc.Login(c.Context(), identityType, identifier, req.Password)
  131. if err != nil {
  132. return response.Fail(c, err)
  133. }
  134. return response.OK(c, res)
  135. }
  136. // Issue 两步登录**第二步**(POST /auth/issue,公开,凭一次性票据)。
  137. //
  138. // 出参(成功):TokenPair → {accessToken, refreshToken, expiresAt, user, roles, institutionId, campusId}。
  139. // 出参(失败):code 1001(票据失效)/ 1003(作用域越权/归属失效)/ 5002(签发写白名单失败)。
  140. func (h *Handler) Issue(c *fiber.Ctx) error {
  141. var req IssueReq
  142. if !validator.Validate(c, &req) {
  143. return nil
  144. }
  145. res, err := h.svc.Issue(c.Context(), req.LoginTicket, req.InstitutionID, req.CampusID)
  146. if err != nil {
  147. return response.Fail(c, err)
  148. }
  149. return response.OK(c, res)
  150. }
  151. // Me 登录态自查(GET /auth/me,**受保护**——需 T4-5 Auth 中间件前置验签并写入 claims)。
  152. //
  153. // 出参(成功):MeResult → {user 字段 + roles, institutionId, campusId, memberships[]}(菜单刷新恢复)。
  154. // 出参(失败):code 1001(中间件未注入 claims = 未登录)/ 3001(用户不存在)/ 5002(DB 故障)。
  155. func (h *Handler) Me(c *fiber.Ctx) error {
  156. claims := claimsFromCtx(c)
  157. if claims == nil { // 公开路由误配或中间件未执行:按未登录拒绝
  158. return response.Fail(c, apperrors.ErrUnauth)
  159. }
  160. res, err := h.svc.Me(c.Context(), *claims)
  161. if err != nil {
  162. return response.Fail(c, err)
  163. }
  164. return response.OK(c, res)
  165. }
  166. // Refresh refresh 轮换(POST /auth/refresh,公开,凭旧 refresh)。
  167. //
  168. // 出参(成功):TokenPair(新 access + 新 refresh;旧 refresh 立即失效)。
  169. // 出参(失败):code 1002(refresh 失效,前端跳重新登录)/ 5002(轮换写白名单/回读用户 DB 故障)。
  170. func (h *Handler) Refresh(c *fiber.Ctx) error {
  171. var req RefreshReq
  172. if !validator.Validate(c, &req) {
  173. return nil
  174. }
  175. res, err := h.svc.Refresh(c.Context(), req.RefreshToken)
  176. if err != nil {
  177. return response.Fail(c, err)
  178. }
  179. return response.OK(c, res)
  180. }
  181. // Logout 登出(POST /auth/logout,**受保护**,对客户端**恒 200** 幂等)。
  182. //
  183. // 语义:撤销"尽力而为"——删 refresh 白名单 + 拉黑当前 access jti;任一步失败仅记告警日志,
  184. // 仍返回成功(前端本地态必清;access 30min 自然过期 + 黑名单兜底)。
  185. //
  186. // 出参(恒成功):code 200,data 省略(无数据体)。
  187. func (h *Handler) Logout(c *fiber.Ctx) error {
  188. var req LogoutReq
  189. // 尽力解析(refresh 可空;body 非 JSON 也不阻断——登出幂等,本地态必清)
  190. _ = json.Unmarshal(c.Body(), &req)
  191. claims := claimsFromCtx(c)
  192. if claims == nil {
  193. // 中间件未注入 claims(理论不该发生——logout 在受保护路由):仍恒 200,仅记日志
  194. if h.log != nil {
  195. h.log.Warn("logout: 无已验签 claims(中间件未注入),仅清本地态")
  196. }
  197. return response.OK(c, nil)
  198. }
  199. remaining := time.Duration(0)
  200. if claims.ExpiresAt != nil {
  201. remaining = time.Until(claims.ExpiresAt.Time) // 黑名单键 TTL = access 剩余有效期
  202. }
  203. if err := h.svc.Logout(c.Context(), req.RefreshToken, claims.ID, remaining); err != nil {
  204. // 撤销失败不阻断登出(对齐前端 doLogout 幂等语义):记告警供排障
  205. if h.log != nil {
  206. h.log.Warn("logout 撤销失败(已忽略,前端本地态必清)",
  207. zap.String("jti", claims.ID), zap.Error(err))
  208. }
  209. }
  210. return response.OK(c, nil)
  211. }
  212. // WechatQrcode 微信扫码登录——获取二维码(GET /auth/wechat/qrcode,**公开**)。
  213. //
  214. // 出参(成功):QRCodeResult → {state, qrUrl, campusId, expiresIn}(前端渲染二维码 + 携 state 轮询)。
  215. // 出参(失败):code 4004(微信未配置——wechat 服务未注入 / appid·回调地址缺失)/ 5002(Redis 写 state 失败)。
  216. func (h *Handler) WechatQrcode(c *fiber.Ctx) error {
  217. if h.wechat == nil {
  218. return response.Fail(c, apperrors.New(apperrors.CodeThirdNotConfigured, "微信扫码登录未配置"))
  219. }
  220. res, err := h.wechat.Qrcode(c.Context())
  221. if err != nil {
  222. return response.Fail(c, err)
  223. }
  224. return response.OK(c, res)
  225. }
  226. // WechatStatus 微信扫码登录——轮询扫码状态(GET /auth/wechat/status?state=xxx,**公开**)。
  227. //
  228. // 出参(成功):StatusResult → {status, campusId, loginTicket?, userId?, expired?}。
  229. // 出参(失败):code 4004(微信未配置)/ 5002(Redis 读 state 失败,fail-closed)。
  230. func (h *Handler) WechatStatus(c *fiber.Ctx) error {
  231. if h.wechat == nil {
  232. return response.Fail(c, apperrors.New(apperrors.CodeThirdNotConfigured, "微信扫码登录未配置"))
  233. }
  234. state := c.Query("state")
  235. if state == "" {
  236. return response.Fail(c, apperrors.ErrBadParam)
  237. }
  238. res, err := h.wechat.Status(c.Context(), state)
  239. if err != nil {
  240. return response.Fail(c, err)
  241. }
  242. return response.OK(c, res)
  243. }
  244. // WechatAuthorize 移动端 App 内微信授权——生成 webview 授权 URL(GET /auth/wechat/authorize,**公开**)。
  245. //
  246. // 与 WechatQrcode(PC 扫码)共用同一 state 状态机 / 同一 /auth/wechat/callback 换 openid /
  247. // 同一套 ② /auth/issue 签 token;差异仅在前端拿到的 URL 形态:本端点返 **snsapi_userinfo** 的
  248. // connect/oauth2/authorize(移动端 H5 跑微信内置浏览器,整页跳转),而 qrcode 返 snsapi_login 的
  249. // connect/qrconnect(PC 渲染二维码,无 #wechat_redirect 尾段差异见 wechat.BuildAuthorizeURL)。
  250. //
  251. // 出参(成功):AuthorizeResult → {state, authorizeUrl, campusId, expiresIn}(前端整页跳 authorizeUrl)。
  252. // 出参(失败):code 4004(微信未配置——wechat 服务未注入 / appid·回调地址缺失)/ 5002(Redis 写 state 失败)。
  253. func (h *Handler) WechatAuthorize(c *fiber.Ctx) error {
  254. if h.wechat == nil {
  255. return response.Fail(c, apperrors.New(apperrors.CodeThirdNotConfigured, "微信授权登录未配置"))
  256. }
  257. res, err := h.wechat.Authorize(c.Context())
  258. if err != nil {
  259. return response.Fail(c, err)
  260. }
  261. return response.OK(c, res)
  262. }
  263. // WechatCallback 微信扫码登录——微信重定向落点(GET /auth/wechat/callback?code=&state=,**公开**)。
  264. //
  265. // 行为:code 换 openid → 查 t_identity(wechat) → 已绑定发 loginTicket 写回 state=success;
  266. // 未绑定写回 unbound;第三方失败写回 fail。前端拿到 success 的 loginTicket 后走**同一套** ② /auth/issue。
  267. //
  268. // 出参(成功):StatusResult → {status, campusId, loginTicket?, userId?}。
  269. // 出参(失败):code 4004(微信未配置)/ 1001(state 失效/已终态,防重放)/ 2001(缺 code/state)/
  270. // 4003(微信换 openid 失败)/ 5002(Redis/DB 故障)。
  271. func (h *Handler) WechatCallback(c *fiber.Ctx) error {
  272. if h.wechat == nil {
  273. return response.Fail(c, apperrors.New(apperrors.CodeThirdNotConfigured, "微信扫码登录未配置"))
  274. }
  275. res, err := h.wechat.Callback(c.Context(), c.Query("code"), c.Query("state"))
  276. if err != nil {
  277. return response.Fail(c, err)
  278. }
  279. return response.OK(c, res)
  280. }
  281. // claimsFromCtx 从 fiber.Ctx 局部值读取已验签 claims(T4-5 中间件写入 *token.Claims)。
  282. //
  283. // 出参:*token.Claims;未写入或类型不符时返回 nil(调用方按未登录拒绝)。
  284. func claimsFromCtx(c *fiber.Ctx) *token.Claims {
  285. if c == nil {
  286. return nil
  287. }
  288. if cl, ok := c.Locals(ClaimsCtxKey).(*token.Claims); ok {
  289. return cl
  290. }
  291. return nil
  292. }