2026-07-06 11:10:19 +08:00

144 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package auth
import (
"context"
"strings"
"golang.org/x/crypto/bcrypt"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
authdomain "hyapp/services/user-service/internal/domain/auth"
userdomain "hyapp/services/user-service/internal/domain/user"
)
// LoginPassword 使用当前 display_user_id 和用户后续设置的密码创建新 session。
// 用户不存在、未设置密码和密码错误统一返回 AUTH_FAILED避免暴露可枚举身份状态。
func (s *Service) LoginPassword(ctx context.Context, displayUserID string, password string, deviceID string, meta Meta) (authdomain.Token, error) {
meta.AppCode = appcode.Normalize(meta.AppCode)
displayUserID = strings.TrimSpace(displayUserID)
if displayUserID == "" || password == "" || strings.TrimSpace(deviceID) == "" {
// device_id 是 refresh token 后续轮换绑定条件,登录时必须提供。
return authdomain.Token{}, xerr.New(xerr.InvalidArgument, "display_user_id, password and device_id are required")
}
if s.authRepository == nil {
// 没有认证 repository 时不能读取密码身份或创建 session。
return authdomain.Token{}, xerr.New(xerr.Unavailable, "auth repository is not configured")
}
if !userdomain.ValidResolvableDisplayUserID(displayUserID) {
// 密码登录接受当前 active 展示号,也接受被 active 靓号覆盖的 held 默认短号;非法字符和不存在账号统一映射 AUTH_FAILED避免账号枚举。
s.audit(ctx, meta, 0, loginPassword, "", resultFailed, string(xerr.AuthFailed))
return authdomain.Token{}, authFailed()
}
nowMs := s.now().UnixMilli()
// repository 会按输入展示号定位用户,再返回该用户当前 active 展示号held 默认短号只作为登录别名,不会覆盖 token 展示号。
accountRecord, err := s.authRepository.FindPasswordByDisplayUserID(ctx, displayUserID, nowMs)
if err != nil {
s.audit(ctx, meta, 0, loginPassword, "", resultFailed, string(xerr.AuthFailed))
return authdomain.Token{}, authFailed()
}
if err := verifyPassword(accountRecord.PasswordHash, password); err != nil {
// 密码错误不暴露为独立 reason和未设置密码保持同一认证失败语义。
s.audit(ctx, meta, accountRecord.UserID, loginPassword, "", resultFailed, string(xerr.AuthFailed))
return authdomain.Token{}, authFailed()
}
// 重新读取用户快照,保证禁用状态和过期靓号恢复都在签发前生效。
user, err := s.freshUser(ctx, accountRecord.UserID, nowMs, meta.RequestID)
if err != nil {
s.audit(ctx, meta, accountRecord.UserID, loginPassword, "", resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, err
}
if !user.CanLogin() {
// disabled/banned 用户不能通过已有密码继续拿新 token。
s.audit(ctx, meta, user.UserID, loginPassword, "", resultFailed, string(xerr.UserDisabled))
return authdomain.Token{}, xerr.New(xerr.UserDisabled, "user is disabled")
}
// 每次密码登录都会创建新的 refresh session。
session, refreshToken, err := s.newSession(meta.AppCode, user.UserID, deviceID)
if err != nil {
return authdomain.Token{}, err
}
if err := s.authRepository.CreateSession(ctx, session); err != nil {
// session 写入失败时不能返回 token避免客户端持有没有服务端状态的 refresh token。
s.audit(ctx, meta, user.UserID, loginPassword, "", resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, err
}
s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "")
token, err := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs)
if err != nil {
return authdomain.Token{}, err
}
s.enqueueLoginIPRiskJob(ctx, meta, token, "account", meta.Platform, meta.Language, meta.Timezone)
return token, nil
}
// SetPassword 为已经三方登录创建出的用户首次设置密码。
// 该入口必须由 gateway 先校验 access token 后传入 user_id不能再接受客户端自报用户。
func (s *Service) SetPassword(ctx context.Context, userID int64, password string, meta Meta) error {
if userID <= 0 || password == "" {
// user_id 必须来自 gateway 的 access token 校验结果,不能接受客户端自报身份。
return xerr.New(xerr.InvalidArgument, "user_id and password are required")
}
if s.authRepository == nil {
return xerr.New(xerr.Unavailable, "auth repository is not configured")
}
user, err := s.freshUser(ctx, userID, s.now().UnixMilli(), meta.RequestID)
if err != nil {
s.audit(ctx, meta, userID, loginSetPass, "", resultFailed, string(xerr.CodeOf(err)))
return err
}
if !user.CanLogin() {
// 禁用用户不能新增密码身份。
s.audit(ctx, meta, userID, loginSetPass, "", resultFailed, string(xerr.UserDisabled))
return xerr.New(xerr.UserDisabled, "user is disabled")
}
// 密码进入持久层前必须先 hash。
passwordHash, err := hashPassword(password)
if err != nil {
return err
}
nowMs := s.now().UnixMilli()
// repository 负责保证一个用户只能首次设置一次密码。
err = s.authRepository.SetPassword(ctx, authdomain.PasswordAccount{
AppCode: appcode.Normalize(meta.AppCode),
UserID: user.UserID,
DisplayUserID: user.CurrentDisplayUserID,
PasswordHash: passwordHash,
HashAlg: hashAlgBcrypt,
CreatedAtMs: nowMs,
UpdatedAtMs: nowMs,
})
if err != nil {
s.audit(ctx, meta, userID, loginSetPass, "", resultFailed, string(xerr.CodeOf(err)))
return err
}
s.audit(ctx, meta, userID, loginSetPass, "", resultSuccess, "")
return nil
}
func hashPassword(password string) (string, error) {
// bcrypt 默认 cost 作为 v1 密码 hash 策略,后续可通过 HashAlg 升级。
body, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(body), nil
}
func verifyPassword(passwordHash string, password string) error {
// bcrypt 比对失败统一映射 AUTH_FAILED。
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil {
return authFailed()
}
return nil
}