service.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. package auth
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "fmt"
  7. "strings"
  8. "time"
  9. )
  10. // 登录 / TOTP / 密码相关的业务逻辑。
  11. //
  12. // 依据 docs/backend/auth.md:
  13. // - 第二节:两步登录流程;
  14. // - 第三节:会话,7 天,硬删除;
  15. // - 3.1:pending_logins,5 分钟;trust_device 拼在临时 token 明文末尾,不落库;
  16. // - 第四节:授信设备,30 天,免 TOTP 不免密;
  17. // - 第八节:bcrypt、TOTP secret 明文存储、改密后的级联。
  18. // 业务错误类型,由 handler 层映射成 api.md 1.4 的 HTTP 状态码。
  19. var (
  20. // ErrInvalidCredentials 用户名或密码错误(401)。message 统一,防枚举(api.md 2.1)。
  21. ErrInvalidCredentials = errors.New("用户名或密码错误")
  22. // ErrPendingExpired 临时 token 无效或已过期(401)。
  23. ErrPendingExpired = errors.New("登录已超时,请重新登录")
  24. // ErrBadTotp 动态码错误(401)。
  25. ErrBadTotp = errors.New("动态码错误")
  26. // ErrTotpAlreadyEnabled TOTP 已启用(409)。
  27. ErrTotpAlreadyEnabled = errors.New("已启用 TOTP")
  28. // ErrTotpNotEnabled 尚未启用 TOTP(422)。
  29. ErrTotpNotEnabled = errors.New("尚未启用 TOTP")
  30. // ErrSessionNotFound 会话不存在或不属于当前用户(404)。
  31. ErrSessionNotFound = errors.New("会话不存在")
  32. // ErrCannotRevokeCurrentSession 不能撤销当前会话(422,api.md 3.2)。
  33. ErrCannotRevokeCurrentSession = errors.New("请使用登出接口撤销当前会话")
  34. )
  35. // User 是 users 表在鉴权层的完整视图(auth.md 第九节)。
  36. type User struct {
  37. ID string
  38. Username string
  39. PasswordHash string
  40. TotpSecret string
  41. TotpEnabled bool
  42. }
  43. // LoginResult 是 POST /api/auth/login 的领域结果(api.md 2.1)。
  44. type LoginResult struct {
  45. User UserBrief
  46. TotpRequired bool
  47. // TempToken 仅在 TotpRequired 时非空。
  48. TempToken string
  49. // SessionToken / DeviceToken 是下发 cookie 的明文,仅在不需 TOTP 时非空。
  50. SessionToken string
  51. DeviceToken string
  52. // DeviceName 是新建授信设备时使用的展示名,供写 trusted_device_added 审计。
  53. DeviceName string
  54. // AuditVia 用于写 login_success 审计(audit.md 第三节)。
  55. // 不需要 TOTP 时也要写,值为 password+totp 或 password+trusted_device。
  56. AuditVia string
  57. }
  58. // UserBrief 是响应里的 {id, username}(api.md 2.1)。
  59. type UserBrief struct {
  60. ID string `json:"id"`
  61. Username string `json:"username"`
  62. }
  63. // Service 承载认证业务逻辑,不依赖 gin。
  64. type Service struct {
  65. db *sql.DB
  66. now func() time.Time
  67. }
  68. // NewService 构造 Service。
  69. func NewService(db *sql.DB) *Service {
  70. return &Service{db: db, now: time.Now}
  71. }
  72. // Login 执行第二步之前的第一步:校验账密,再决定是否直接建会话。
  73. //
  74. // 流程见 auth.md 2.1:
  75. // 1. 账密错误 → ErrInvalidCredentials;
  76. // 2. 未启用 TOTP → 直接建会话;
  77. // 3. 启用 TOTP 但授信设备有效 → 跳过 TOTP,直接建会话;
  78. // 4. 否则 → 建 pending_logins 并返回临时 token。
  79. func (s *Service) Login(ctx context.Context, username, password string, trustDevice bool, trustedDeviceCookie, userAgent string) (LoginResult, error) {
  80. user, err := s.findUserByUsername(ctx, username)
  81. if errors.Is(err, ErrNotFound) {
  82. return LoginResult{}, ErrInvalidCredentials
  83. }
  84. if err != nil {
  85. return LoginResult{}, err
  86. }
  87. if !passwordMatches(user.PasswordHash, password) {
  88. return LoginResult{}, ErrInvalidCredentials
  89. }
  90. now := s.now()
  91. // 未启用 TOTP:第一步即完成登录
  92. if !user.TotpEnabled {
  93. res, err := s.issueSession(ctx, user, trustDevice, userAgent, now)
  94. if err != nil {
  95. return LoginResult{}, err
  96. }
  97. res.AuditVia = auditViaTotp
  98. return res, nil
  99. }
  100. // 启用 TOTP:先看授信设备是否有效。授信设备不免密,只免 TOTP(auth.md 2.3)
  101. if trustedDeviceCookie != "" {
  102. ok, err := s.trustedDeviceValid(ctx, user.ID, trustedDeviceCookie, now)
  103. if err != nil {
  104. return LoginResult{}, err
  105. }
  106. if ok {
  107. res, err := s.issueSession(ctx, user, trustDevice, userAgent, now)
  108. if err != nil {
  109. return LoginResult{}, err
  110. }
  111. res.AuditVia = auditViaTrustedDevice
  112. return res, nil
  113. }
  114. }
  115. // 需要 TOTP:建 pending_logins,trust_device 编进临时 token 明文
  116. tempToken, err := newPendingToken(trustDevice)
  117. if err != nil {
  118. return LoginResult{}, err
  119. }
  120. hashedToken, _ := splitPendingToken(tempToken)
  121. // 顺手清理该用户已过期的待验证记录(auth.md 3.1 规则 3 允许)
  122. if _, err := s.db.ExecContext(ctx,
  123. `DELETE FROM pending_logins WHERE user_id = $1 AND expires_at <= now()`, user.ID); err != nil {
  124. return LoginResult{}, fmt.Errorf("清理过期待验证登录失败: %w", err)
  125. }
  126. if _, err := s.db.ExecContext(ctx,
  127. `INSERT INTO pending_logins (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
  128. user.ID, HashToken(hashedToken), now.Add(PendingLoginTTL)); err != nil {
  129. return LoginResult{}, fmt.Errorf("创建待验证登录失败: %w", err)
  130. }
  131. return LoginResult{
  132. User: UserBrief{ID: user.ID, Username: user.Username},
  133. TotpRequired: true,
  134. TempToken: tempToken,
  135. }, nil
  136. }
  137. // LoginTotp 执行第二步:校验临时 token 与动态码(api.md 2.2 / auth.md 2.2)。
  138. func (s *Service) LoginTotp(ctx context.Context, tempToken, code, userAgent string) (LoginResult, error) {
  139. // auth.md 3.1 规则 1:先剥离 trust_device 后缀,再哈希查库
  140. base, trustDevice := splitPendingToken(tempToken)
  141. now := s.now()
  142. var userID string
  143. err := s.db.QueryRowContext(ctx,
  144. `SELECT user_id FROM pending_logins WHERE token_hash = $1 AND expires_at > now()`,
  145. HashToken(base),
  146. ).Scan(&userID)
  147. if errors.Is(err, sql.ErrNoRows) {
  148. return LoginResult{}, ErrPendingExpired
  149. }
  150. if err != nil {
  151. return LoginResult{}, fmt.Errorf("查询待验证登录失败: %w", err)
  152. }
  153. user, err := s.findUserByID(ctx, userID)
  154. if err != nil {
  155. return LoginResult{}, err
  156. }
  157. if !TotpValid(user.TotpSecret, code, now) {
  158. // auth.md 3.1 规则 5:不做失败计数,记录保留到过期
  159. return LoginResult{}, ErrBadTotp
  160. }
  161. if _, err := s.db.ExecContext(ctx,
  162. `DELETE FROM pending_logins WHERE token_hash = $1`, HashToken(base)); err != nil {
  163. return LoginResult{}, fmt.Errorf("删除待验证登录失败: %w", err)
  164. }
  165. res, err := s.issueSessionWithFlags(ctx, user, trustDevice, userAgent, true, now)
  166. if err != nil {
  167. return LoginResult{}, err
  168. }
  169. res.AuditVia = auditViaTotp
  170. return res, nil
  171. }
  172. // Logout 删除当前会话;Token 调用无实际操作(api.md 2.3)。
  173. func (s *Service) Logout(ctx context.Context, sessionID string) error {
  174. if sessionID == "" {
  175. return nil
  176. }
  177. const q = `DELETE FROM sessions WHERE id = $1`
  178. if _, err := s.db.ExecContext(ctx, q, sessionID); err != nil {
  179. return fmt.Errorf("删除会话失败: %w", err)
  180. }
  181. return nil
  182. }
  183. // SetupTotp 生成新的 TOTP secret 与 otpauth URI,但**不落库**(api.md 2.4)。
  184. func (s *Service) SetupTotp(userID, issuer string) (secret, uri string, err error) {
  185. user, err := s.findUserByID(context.Background(), userID)
  186. if err != nil {
  187. return "", "", err
  188. }
  189. if user.TotpEnabled {
  190. return "", "", ErrTotpAlreadyEnabled
  191. }
  192. secret, uri, err = NewTotpSecret(issuer, user.Username)
  193. if err != nil {
  194. return "", "", err
  195. }
  196. return secret, uri, nil
  197. }
  198. // VerifyTotp 校验并落库 secret,置 totp_enabled = true(api.md 2.5)。
  199. func (s *Service) VerifyTotp(ctx context.Context, userID, secret, code string) error {
  200. user, err := s.findUserByID(ctx, userID)
  201. if err != nil {
  202. return err
  203. }
  204. if user.TotpEnabled {
  205. return ErrTotpAlreadyEnabled
  206. }
  207. if !TotpValid(secret, code, s.now()) {
  208. return ErrBadTotp
  209. }
  210. const q = `UPDATE users SET totp_secret = $2, totp_enabled = true, updated_at = now() WHERE id = $1`
  211. if _, err := s.db.ExecContext(ctx, q, userID, secret); err != nil {
  212. return fmt.Errorf("保存 TOTP secret 失败: %w", err)
  213. }
  214. return nil
  215. }
  216. // DisableTotp 关闭 TOTP 并删除该用户全部授信设备(api.md 2.6 / auth.md)。
  217. func (s *Service) DisableTotp(ctx context.Context, userID, code string) error {
  218. user, err := s.findUserByID(ctx, userID)
  219. if err != nil {
  220. return err
  221. }
  222. if !user.TotpEnabled {
  223. return ErrTotpNotEnabled
  224. }
  225. if !TotpValid(user.TotpSecret, code, s.now()) {
  226. return ErrBadTotp
  227. }
  228. tx, err := s.db.BeginTx(ctx, nil)
  229. if err != nil {
  230. return fmt.Errorf("开启事务失败: %w", err)
  231. }
  232. defer tx.Rollback()
  233. if _, err := tx.ExecContext(ctx,
  234. `UPDATE users SET totp_secret = NULL, totp_enabled = false, updated_at = now() WHERE id = $1`,
  235. userID); err != nil {
  236. return fmt.Errorf("关闭 TOTP 失败: %w", err)
  237. }
  238. // 授信设备的存在意义是免 TOTP,关闭后必须一并删除(api.md 2.6 备注)
  239. if _, err := tx.ExecContext(ctx,
  240. `DELETE FROM trusted_devices WHERE user_id = $1`, userID); err != nil {
  241. return fmt.Errorf("删除授信设备失败: %w", err)
  242. }
  243. return tx.Commit()
  244. }
  245. // ChangePassword 修改密码:同事务内删全部会话与授信设备,不删 Token(auth.md 8.3)。
  246. func (s *Service) ChangePassword(ctx context.Context, userID, oldPassword, newPassword, totpCode string) error {
  247. user, err := s.findUserByID(ctx, userID)
  248. if err != nil {
  249. return err
  250. }
  251. if !passwordMatches(user.PasswordHash, oldPassword) {
  252. return ErrInvalidCredentials
  253. }
  254. if user.TotpEnabled && !TotpValid(user.TotpSecret, totpCode, s.now()) {
  255. return ErrBadTotp
  256. }
  257. if err := ValidatePassword(newPassword, oldPassword); err != nil {
  258. return err
  259. }
  260. hash, err := HashPassword(newPassword)
  261. if err != nil {
  262. return err
  263. }
  264. tx, err := s.db.BeginTx(ctx, nil)
  265. if err != nil {
  266. return fmt.Errorf("开启事务失败: %w", err)
  267. }
  268. defer tx.Rollback()
  269. if _, err := tx.ExecContext(ctx,
  270. `UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1`,
  271. userID, hash); err != nil {
  272. return fmt.Errorf("更新密码失败: %w", err)
  273. }
  274. if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = $1`, userID); err != nil {
  275. return fmt.Errorf("删除会话失败: %w", err)
  276. }
  277. if _, err := tx.ExecContext(ctx, `DELETE FROM trusted_devices WHERE user_id = $1`, userID); err != nil {
  278. return fmt.Errorf("删除授信设备失败: %w", err)
  279. }
  280. // 刻意不删 tokens(auth.md 8.3 第 3 条)
  281. return tx.Commit()
  282. }
  283. // issueSessionWithFlags 与 issueSession 相同,额外指定会话是否带 TOTP 标记。
  284. //
  285. // login_success 的 via 决定标记:
  286. // - password+totp → 会话通过过 TOTP
  287. // - password+trusted_device → 授信设备只免 TOTP,会话本身未通过过 TOTP
  288. // - 未启用 TOTP → 未通过过 TOTP
  289. func (s *Service) issueSessionWithFlags(ctx context.Context, user User, trustDevice bool, userAgent string, totpVerified bool, now time.Time) (LoginResult, error) {
  290. sessionToken, err := newSessionTokenFor(totpVerified)
  291. if err != nil {
  292. return LoginResult{}, err
  293. }
  294. tx, err := s.db.BeginTx(ctx, nil)
  295. if err != nil {
  296. return LoginResult{}, fmt.Errorf("开启事务失败: %w", err)
  297. }
  298. defer tx.Rollback()
  299. base, _ := splitSessionToken(sessionToken)
  300. if _, err := tx.ExecContext(ctx,
  301. `INSERT INTO sessions (user_id, token_hash, expires_at, last_used_at, user_agent)
  302. VALUES ($1, $2, $3, $4, $5)`,
  303. user.ID, HashToken(base), now.Add(SessionTTL), now, userAgent); err != nil {
  304. return LoginResult{}, fmt.Errorf("创建会话失败: %w", err)
  305. }
  306. res := LoginResult{
  307. User: UserBrief{ID: user.ID, Username: user.Username},
  308. TotpRequired: false,
  309. SessionToken: sessionToken,
  310. }
  311. if trustDevice {
  312. deviceToken, err := newDeviceToken()
  313. if err != nil {
  314. return LoginResult{}, err
  315. }
  316. name := deviceNameFromUA(userAgent)
  317. if _, err := tx.ExecContext(ctx,
  318. `INSERT INTO trusted_devices (user_id, token_hash, device_name, expires_at, last_used_at)
  319. VALUES ($1, $2, $3, $4, $5)`,
  320. user.ID, HashToken(deviceToken), name, now.Add(TrustedDeviceTTL), now); err != nil {
  321. return LoginResult{}, fmt.Errorf("创建授信设备失败: %w", err)
  322. }
  323. res.DeviceToken = deviceToken
  324. res.DeviceName = name
  325. }
  326. if err := tx.Commit(); err != nil {
  327. return LoginResult{}, fmt.Errorf("提交事务失败: %w", err)
  328. }
  329. return res, nil
  330. }
  331. // issueSession 创建会话,供未启用 TOTP / 授信设备路径复用(未通过 TOTP)。
  332. func (s *Service) issueSession(ctx context.Context, user User, trustDevice bool, userAgent string, now time.Time) (LoginResult, error) {
  333. return s.issueSessionWithFlags(ctx, user, trustDevice, userAgent, false, now)
  334. }
  335. // trustedDeviceValid 校验授信设备:未过期、哈希匹配、属于该用户(auth.md 2.1)。
  336. // 匹配成功会刷新 last_used_at。
  337. func (s *Service) trustedDeviceValid(ctx context.Context, userID, cookieValue string, now time.Time) (bool, error) {
  338. const q = `SELECT id FROM trusted_devices WHERE user_id = $1 AND token_hash = $2 AND expires_at > now()`
  339. var id string
  340. err := s.db.QueryRowContext(ctx, q, userID, HashToken(cookieValue)).Scan(&id)
  341. if errors.Is(err, sql.ErrNoRows) {
  342. return false, nil
  343. }
  344. if err != nil {
  345. return false, fmt.Errorf("查询授信设备失败: %w", err)
  346. }
  347. if _, err := s.db.ExecContext(ctx,
  348. `UPDATE trusted_devices SET last_used_at = $2 WHERE id = $1`, id, now); err != nil {
  349. return false, fmt.Errorf("更新授信设备时间失败: %w", err)
  350. }
  351. return true, nil
  352. }
  353. func (s *Service) findUserByUsername(ctx context.Context, username string) (User, error) {
  354. // username 不区分大小写(auth.md 第九节规则 3)
  355. const q = `SELECT id, username, password_hash, coalesce(totp_secret, ''), totp_enabled
  356. FROM users WHERE lower(username) = lower($1)`
  357. return s.scanUser(s.db.QueryRowContext(ctx, q, strings.TrimSpace(username)))
  358. }
  359. func (s *Service) findUserByID(ctx context.Context, id string) (User, error) {
  360. const q = `SELECT id, username, password_hash, coalesce(totp_secret, ''), totp_enabled
  361. FROM users WHERE id = $1`
  362. return s.scanUser(s.db.QueryRowContext(ctx, q, id))
  363. }
  364. func (s *Service) scanUser(row *sql.Row) (User, error) {
  365. var u User
  366. err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.TotpSecret, &u.TotpEnabled)
  367. if errors.Is(err, sql.ErrNoRows) {
  368. return User{}, ErrNotFound
  369. }
  370. if err != nil {
  371. return User{}, fmt.Errorf("查询用户失败: %w", err)
  372. }
  373. return u, nil
  374. }
  375. // pendingUsername 用 pending_logins 的哈希反查用户名,供失败审计使用。
  376. func (s *Service) pendingUsername(ctx context.Context, tokenHash string) (string, error) {
  377. const q = `
  378. SELECT u.username
  379. FROM pending_logins p
  380. JOIN users u ON u.id = p.user_id
  381. WHERE p.token_hash = $1`
  382. var name string
  383. if err := s.db.QueryRowContext(ctx, q, tokenHash).Scan(&name); err != nil {
  384. return "", fmt.Errorf("查询待验证登录用户名失败: %w", err)
  385. }
  386. return name, nil
  387. }
  388. // deviceNameFromUA 从 UA 里取一个粗略可读的设备名,作为 trusted_devices.device_name 的默认值。
  389. // 用户可在设置页后续改名;这里只避免留空串(auth.md 第四节字段默认 ”)。
  390. func deviceNameFromUA(ua string) string {
  391. if ua == "" {
  392. return ""
  393. }
  394. for _, sep := range []string{" ", "/"} {
  395. if i := strings.Index(ua, sep); i > 0 {
  396. return ua[:i]
  397. }
  398. }
  399. return ua
  400. }
苏ICP备2026082406号
苏公网安备 待审核