| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450 |
- package auth
- import (
- "context"
- "database/sql"
- "errors"
- "fmt"
- "strings"
- "time"
- )
- // 登录 / TOTP / 密码相关的业务逻辑。
- //
- // 依据 docs/backend/auth.md:
- // - 第二节:两步登录流程;
- // - 第三节:会话,7 天,硬删除;
- // - 3.1:pending_logins,5 分钟;trust_device 拼在临时 token 明文末尾,不落库;
- // - 第四节:授信设备,30 天,免 TOTP 不免密;
- // - 第八节:bcrypt、TOTP secret 明文存储、改密后的级联。
- // 业务错误类型,由 handler 层映射成 api.md 1.4 的 HTTP 状态码。
- var (
- // ErrInvalidCredentials 用户名或密码错误(401)。message 统一,防枚举(api.md 2.1)。
- ErrInvalidCredentials = errors.New("用户名或密码错误")
- // ErrPendingExpired 临时 token 无效或已过期(401)。
- ErrPendingExpired = errors.New("登录已超时,请重新登录")
- // ErrBadTotp 动态码错误(401)。
- ErrBadTotp = errors.New("动态码错误")
- // ErrTotpAlreadyEnabled TOTP 已启用(409)。
- ErrTotpAlreadyEnabled = errors.New("已启用 TOTP")
- // ErrTotpNotEnabled 尚未启用 TOTP(422)。
- ErrTotpNotEnabled = errors.New("尚未启用 TOTP")
- // ErrSessionNotFound 会话不存在或不属于当前用户(404)。
- ErrSessionNotFound = errors.New("会话不存在")
- // ErrCannotRevokeCurrentSession 不能撤销当前会话(422,api.md 3.2)。
- ErrCannotRevokeCurrentSession = errors.New("请使用登出接口撤销当前会话")
- )
- // User 是 users 表在鉴权层的完整视图(auth.md 第九节)。
- type User struct {
- ID string
- Username string
- PasswordHash string
- TotpSecret string
- TotpEnabled bool
- }
- // LoginResult 是 POST /api/auth/login 的领域结果(api.md 2.1)。
- type LoginResult struct {
- User UserBrief
- TotpRequired bool
- // TempToken 仅在 TotpRequired 时非空。
- TempToken string
- // SessionToken / DeviceToken 是下发 cookie 的明文,仅在不需 TOTP 时非空。
- SessionToken string
- DeviceToken string
- // DeviceName 是新建授信设备时使用的展示名,供写 trusted_device_added 审计。
- DeviceName string
- // AuditVia 用于写 login_success 审计(audit.md 第三节)。
- // 不需要 TOTP 时也要写,值为 password+totp 或 password+trusted_device。
- AuditVia string
- }
- // UserBrief 是响应里的 {id, username}(api.md 2.1)。
- type UserBrief struct {
- ID string `json:"id"`
- Username string `json:"username"`
- }
- // Service 承载认证业务逻辑,不依赖 gin。
- type Service struct {
- db *sql.DB
- now func() time.Time
- }
- // NewService 构造 Service。
- func NewService(db *sql.DB) *Service {
- return &Service{db: db, now: time.Now}
- }
- // Login 执行第二步之前的第一步:校验账密,再决定是否直接建会话。
- //
- // 流程见 auth.md 2.1:
- // 1. 账密错误 → ErrInvalidCredentials;
- // 2. 未启用 TOTP → 直接建会话;
- // 3. 启用 TOTP 但授信设备有效 → 跳过 TOTP,直接建会话;
- // 4. 否则 → 建 pending_logins 并返回临时 token。
- func (s *Service) Login(ctx context.Context, username, password string, trustDevice bool, trustedDeviceCookie, userAgent string) (LoginResult, error) {
- user, err := s.findUserByUsername(ctx, username)
- if errors.Is(err, ErrNotFound) {
- return LoginResult{}, ErrInvalidCredentials
- }
- if err != nil {
- return LoginResult{}, err
- }
- if !passwordMatches(user.PasswordHash, password) {
- return LoginResult{}, ErrInvalidCredentials
- }
- now := s.now()
- // 未启用 TOTP:第一步即完成登录
- if !user.TotpEnabled {
- res, err := s.issueSession(ctx, user, trustDevice, userAgent, now)
- if err != nil {
- return LoginResult{}, err
- }
- res.AuditVia = auditViaTotp
- return res, nil
- }
- // 启用 TOTP:先看授信设备是否有效。授信设备不免密,只免 TOTP(auth.md 2.3)
- if trustedDeviceCookie != "" {
- ok, err := s.trustedDeviceValid(ctx, user.ID, trustedDeviceCookie, now)
- if err != nil {
- return LoginResult{}, err
- }
- if ok {
- res, err := s.issueSession(ctx, user, trustDevice, userAgent, now)
- if err != nil {
- return LoginResult{}, err
- }
- res.AuditVia = auditViaTrustedDevice
- return res, nil
- }
- }
- // 需要 TOTP:建 pending_logins,trust_device 编进临时 token 明文
- tempToken, err := newPendingToken(trustDevice)
- if err != nil {
- return LoginResult{}, err
- }
- hashedToken, _ := splitPendingToken(tempToken)
- // 顺手清理该用户已过期的待验证记录(auth.md 3.1 规则 3 允许)
- if _, err := s.db.ExecContext(ctx,
- `DELETE FROM pending_logins WHERE user_id = $1 AND expires_at <= now()`, user.ID); err != nil {
- return LoginResult{}, fmt.Errorf("清理过期待验证登录失败: %w", err)
- }
- if _, err := s.db.ExecContext(ctx,
- `INSERT INTO pending_logins (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
- user.ID, HashToken(hashedToken), now.Add(PendingLoginTTL)); err != nil {
- return LoginResult{}, fmt.Errorf("创建待验证登录失败: %w", err)
- }
- return LoginResult{
- User: UserBrief{ID: user.ID, Username: user.Username},
- TotpRequired: true,
- TempToken: tempToken,
- }, nil
- }
- // LoginTotp 执行第二步:校验临时 token 与动态码(api.md 2.2 / auth.md 2.2)。
- func (s *Service) LoginTotp(ctx context.Context, tempToken, code, userAgent string) (LoginResult, error) {
- // auth.md 3.1 规则 1:先剥离 trust_device 后缀,再哈希查库
- base, trustDevice := splitPendingToken(tempToken)
- now := s.now()
- var userID string
- err := s.db.QueryRowContext(ctx,
- `SELECT user_id FROM pending_logins WHERE token_hash = $1 AND expires_at > now()`,
- HashToken(base),
- ).Scan(&userID)
- if errors.Is(err, sql.ErrNoRows) {
- return LoginResult{}, ErrPendingExpired
- }
- if err != nil {
- return LoginResult{}, fmt.Errorf("查询待验证登录失败: %w", err)
- }
- user, err := s.findUserByID(ctx, userID)
- if err != nil {
- return LoginResult{}, err
- }
- if !TotpValid(user.TotpSecret, code, now) {
- // auth.md 3.1 规则 5:不做失败计数,记录保留到过期
- return LoginResult{}, ErrBadTotp
- }
- if _, err := s.db.ExecContext(ctx,
- `DELETE FROM pending_logins WHERE token_hash = $1`, HashToken(base)); err != nil {
- return LoginResult{}, fmt.Errorf("删除待验证登录失败: %w", err)
- }
- res, err := s.issueSessionWithFlags(ctx, user, trustDevice, userAgent, true, now)
- if err != nil {
- return LoginResult{}, err
- }
- res.AuditVia = auditViaTotp
- return res, nil
- }
- // Logout 删除当前会话;Token 调用无实际操作(api.md 2.3)。
- func (s *Service) Logout(ctx context.Context, sessionID string) error {
- if sessionID == "" {
- return nil
- }
- const q = `DELETE FROM sessions WHERE id = $1`
- if _, err := s.db.ExecContext(ctx, q, sessionID); err != nil {
- return fmt.Errorf("删除会话失败: %w", err)
- }
- return nil
- }
- // SetupTotp 生成新的 TOTP secret 与 otpauth URI,但**不落库**(api.md 2.4)。
- func (s *Service) SetupTotp(userID, issuer string) (secret, uri string, err error) {
- user, err := s.findUserByID(context.Background(), userID)
- if err != nil {
- return "", "", err
- }
- if user.TotpEnabled {
- return "", "", ErrTotpAlreadyEnabled
- }
- secret, uri, err = NewTotpSecret(issuer, user.Username)
- if err != nil {
- return "", "", err
- }
- return secret, uri, nil
- }
- // VerifyTotp 校验并落库 secret,置 totp_enabled = true(api.md 2.5)。
- func (s *Service) VerifyTotp(ctx context.Context, userID, secret, code string) error {
- user, err := s.findUserByID(ctx, userID)
- if err != nil {
- return err
- }
- if user.TotpEnabled {
- return ErrTotpAlreadyEnabled
- }
- if !TotpValid(secret, code, s.now()) {
- return ErrBadTotp
- }
- const q = `UPDATE users SET totp_secret = $2, totp_enabled = true, updated_at = now() WHERE id = $1`
- if _, err := s.db.ExecContext(ctx, q, userID, secret); err != nil {
- return fmt.Errorf("保存 TOTP secret 失败: %w", err)
- }
- return nil
- }
- // DisableTotp 关闭 TOTP 并删除该用户全部授信设备(api.md 2.6 / auth.md)。
- func (s *Service) DisableTotp(ctx context.Context, userID, code string) error {
- user, err := s.findUserByID(ctx, userID)
- if err != nil {
- return err
- }
- if !user.TotpEnabled {
- return ErrTotpNotEnabled
- }
- if !TotpValid(user.TotpSecret, code, s.now()) {
- return ErrBadTotp
- }
- tx, err := s.db.BeginTx(ctx, nil)
- if err != nil {
- return fmt.Errorf("开启事务失败: %w", err)
- }
- defer tx.Rollback()
- if _, err := tx.ExecContext(ctx,
- `UPDATE users SET totp_secret = NULL, totp_enabled = false, updated_at = now() WHERE id = $1`,
- userID); err != nil {
- return fmt.Errorf("关闭 TOTP 失败: %w", err)
- }
- // 授信设备的存在意义是免 TOTP,关闭后必须一并删除(api.md 2.6 备注)
- if _, err := tx.ExecContext(ctx,
- `DELETE FROM trusted_devices WHERE user_id = $1`, userID); err != nil {
- return fmt.Errorf("删除授信设备失败: %w", err)
- }
- return tx.Commit()
- }
- // ChangePassword 修改密码:同事务内删全部会话与授信设备,不删 Token(auth.md 8.3)。
- func (s *Service) ChangePassword(ctx context.Context, userID, oldPassword, newPassword, totpCode string) error {
- user, err := s.findUserByID(ctx, userID)
- if err != nil {
- return err
- }
- if !passwordMatches(user.PasswordHash, oldPassword) {
- return ErrInvalidCredentials
- }
- if user.TotpEnabled && !TotpValid(user.TotpSecret, totpCode, s.now()) {
- return ErrBadTotp
- }
- if err := ValidatePassword(newPassword, oldPassword); err != nil {
- return err
- }
- hash, err := HashPassword(newPassword)
- if err != nil {
- return err
- }
- tx, err := s.db.BeginTx(ctx, nil)
- if err != nil {
- return fmt.Errorf("开启事务失败: %w", err)
- }
- defer tx.Rollback()
- if _, err := tx.ExecContext(ctx,
- `UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1`,
- userID, hash); err != nil {
- return fmt.Errorf("更新密码失败: %w", err)
- }
- if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = $1`, userID); err != nil {
- return fmt.Errorf("删除会话失败: %w", err)
- }
- if _, err := tx.ExecContext(ctx, `DELETE FROM trusted_devices WHERE user_id = $1`, userID); err != nil {
- return fmt.Errorf("删除授信设备失败: %w", err)
- }
- // 刻意不删 tokens(auth.md 8.3 第 3 条)
- return tx.Commit()
- }
- // issueSessionWithFlags 与 issueSession 相同,额外指定会话是否带 TOTP 标记。
- //
- // login_success 的 via 决定标记:
- // - password+totp → 会话通过过 TOTP
- // - password+trusted_device → 授信设备只免 TOTP,会话本身未通过过 TOTP
- // - 未启用 TOTP → 未通过过 TOTP
- func (s *Service) issueSessionWithFlags(ctx context.Context, user User, trustDevice bool, userAgent string, totpVerified bool, now time.Time) (LoginResult, error) {
- sessionToken, err := newSessionTokenFor(totpVerified)
- if err != nil {
- return LoginResult{}, err
- }
- tx, err := s.db.BeginTx(ctx, nil)
- if err != nil {
- return LoginResult{}, fmt.Errorf("开启事务失败: %w", err)
- }
- defer tx.Rollback()
- base, _ := splitSessionToken(sessionToken)
- if _, err := tx.ExecContext(ctx,
- `INSERT INTO sessions (user_id, token_hash, expires_at, last_used_at, user_agent)
- VALUES ($1, $2, $3, $4, $5)`,
- user.ID, HashToken(base), now.Add(SessionTTL), now, userAgent); err != nil {
- return LoginResult{}, fmt.Errorf("创建会话失败: %w", err)
- }
- res := LoginResult{
- User: UserBrief{ID: user.ID, Username: user.Username},
- TotpRequired: false,
- SessionToken: sessionToken,
- }
- if trustDevice {
- deviceToken, err := newDeviceToken()
- if err != nil {
- return LoginResult{}, err
- }
- name := deviceNameFromUA(userAgent)
- if _, err := tx.ExecContext(ctx,
- `INSERT INTO trusted_devices (user_id, token_hash, device_name, expires_at, last_used_at)
- VALUES ($1, $2, $3, $4, $5)`,
- user.ID, HashToken(deviceToken), name, now.Add(TrustedDeviceTTL), now); err != nil {
- return LoginResult{}, fmt.Errorf("创建授信设备失败: %w", err)
- }
- res.DeviceToken = deviceToken
- res.DeviceName = name
- }
- if err := tx.Commit(); err != nil {
- return LoginResult{}, fmt.Errorf("提交事务失败: %w", err)
- }
- return res, nil
- }
- // issueSession 创建会话,供未启用 TOTP / 授信设备路径复用(未通过 TOTP)。
- func (s *Service) issueSession(ctx context.Context, user User, trustDevice bool, userAgent string, now time.Time) (LoginResult, error) {
- return s.issueSessionWithFlags(ctx, user, trustDevice, userAgent, false, now)
- }
- // trustedDeviceValid 校验授信设备:未过期、哈希匹配、属于该用户(auth.md 2.1)。
- // 匹配成功会刷新 last_used_at。
- func (s *Service) trustedDeviceValid(ctx context.Context, userID, cookieValue string, now time.Time) (bool, error) {
- const q = `SELECT id FROM trusted_devices WHERE user_id = $1 AND token_hash = $2 AND expires_at > now()`
- var id string
- err := s.db.QueryRowContext(ctx, q, userID, HashToken(cookieValue)).Scan(&id)
- if errors.Is(err, sql.ErrNoRows) {
- return false, nil
- }
- if err != nil {
- return false, fmt.Errorf("查询授信设备失败: %w", err)
- }
- if _, err := s.db.ExecContext(ctx,
- `UPDATE trusted_devices SET last_used_at = $2 WHERE id = $1`, id, now); err != nil {
- return false, fmt.Errorf("更新授信设备时间失败: %w", err)
- }
- return true, nil
- }
- func (s *Service) findUserByUsername(ctx context.Context, username string) (User, error) {
- // username 不区分大小写(auth.md 第九节规则 3)
- const q = `SELECT id, username, password_hash, coalesce(totp_secret, ''), totp_enabled
- FROM users WHERE lower(username) = lower($1)`
- return s.scanUser(s.db.QueryRowContext(ctx, q, strings.TrimSpace(username)))
- }
- func (s *Service) findUserByID(ctx context.Context, id string) (User, error) {
- const q = `SELECT id, username, password_hash, coalesce(totp_secret, ''), totp_enabled
- FROM users WHERE id = $1`
- return s.scanUser(s.db.QueryRowContext(ctx, q, id))
- }
- func (s *Service) scanUser(row *sql.Row) (User, error) {
- var u User
- err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.TotpSecret, &u.TotpEnabled)
- if errors.Is(err, sql.ErrNoRows) {
- return User{}, ErrNotFound
- }
- if err != nil {
- return User{}, fmt.Errorf("查询用户失败: %w", err)
- }
- return u, nil
- }
- // pendingUsername 用 pending_logins 的哈希反查用户名,供失败审计使用。
- func (s *Service) pendingUsername(ctx context.Context, tokenHash string) (string, error) {
- const q = `
- SELECT u.username
- FROM pending_logins p
- JOIN users u ON u.id = p.user_id
- WHERE p.token_hash = $1`
- var name string
- if err := s.db.QueryRowContext(ctx, q, tokenHash).Scan(&name); err != nil {
- return "", fmt.Errorf("查询待验证登录用户名失败: %w", err)
- }
- return name, nil
- }
- // deviceNameFromUA 从 UA 里取一个粗略可读的设备名,作为 trusted_devices.device_name 的默认值。
- // 用户可在设置页后续改名;这里只避免留空串(auth.md 第四节字段默认 ”)。
- func deviceNameFromUA(ua string) string {
- if ua == "" {
- return ""
- }
- for _, sep := range []string{" ", "/"} {
- if i := strings.Index(ua, sep); i > 0 {
- return ua[:i]
- }
- }
- return ua
- }
|