loginService.go 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. package pub
  2. import (
  3. "context"
  4. "crypto/subtle"
  5. "errors"
  6. "fmt"
  7. "perms-system-server/internal/consts"
  8. "perms-system-server/internal/loaders"
  9. authHelper "perms-system-server/internal/logic/auth"
  10. "perms-system-server/internal/model/user"
  11. "perms-system-server/internal/svc"
  12. "github.com/zeromicro/go-zero/core/limit"
  13. "golang.org/x/crypto/bcrypt"
  14. )
  15. // dummyBcryptHash 用于对不存在的用户名执行等时 bcrypt 比对,防止基于响应时间的用户名枚举
  16. var dummyBcryptHash, _ = bcrypt.GenerateFromPassword([]byte("dummy-anti-timing"), bcrypt.DefaultCost)
  17. type LoginResult struct {
  18. UserDetails *loaders.UserDetails
  19. AccessToken string
  20. RefreshToken string
  21. }
  22. type LoginError struct {
  23. Code int
  24. Message string
  25. }
  26. func (e *LoginError) Error() string {
  27. return e.Message
  28. }
  29. func checkUsernameLimit(svcCtx *svc.ServiceContext, clientIP, username string) bool {
  30. if svcCtx.UsernameLoginLimit == nil {
  31. return false
  32. }
  33. key := fmt.Sprintf("%s:%s", clientIP, username)
  34. code, _ := svcCtx.UsernameLoginLimit.Take(key)
  35. return code == limit.OverQuota
  36. }
  37. func ValidateProductLogin(ctx context.Context, svcCtx *svc.ServiceContext, username, password, productCode, clientIP string) (*LoginResult, error) {
  38. if checkUsernameLimit(svcCtx, clientIP, username) {
  39. return nil, &LoginError{Code: 429, Message: "该账号登录尝试过于频繁,请5分钟后再试"}
  40. }
  41. u, lookupErr := svcCtx.SysUserModel.FindOneByUsername(ctx, username)
  42. var userHash []byte
  43. if lookupErr != nil {
  44. if !errors.Is(lookupErr, user.ErrNotFound) {
  45. return nil, lookupErr
  46. }
  47. userHash = dummyBcryptHash
  48. } else {
  49. userHash = []byte(u.Password)
  50. }
  51. // 无条件执行一次 bcrypt:让"账号不存在 / 冻结 / 密码错"三条路径在耗时上完全等长,
  52. // 消除基于响应时间的账号存在性 / 冻结状态 oracle(见审计 H-2)。
  53. bcryptErr := bcrypt.CompareHashAndPassword(userHash, []byte(password))
  54. if lookupErr != nil || bcryptErr != nil {
  55. return nil, &LoginError{Code: 401, Message: "用户名或密码错误"}
  56. }
  57. // 密码正确之后再披露账号语义状态:此时攻击者已经猜中密码,再隐藏"冻结/超管"已无意义。
  58. if u.Status != consts.StatusEnabled {
  59. return nil, &LoginError{Code: 403, Message: "账号已被冻结"}
  60. }
  61. if u.IsSuperAdmin == consts.IsSuperAdminYes {
  62. return nil, &LoginError{Code: 403, Message: "超级管理员不允许通过产品端登录,请使用管理后台"}
  63. }
  64. product, err := svcCtx.SysProductModel.FindOneByCode(ctx, productCode)
  65. if err != nil {
  66. return nil, &LoginError{Code: 400, Message: "产品不存在"}
  67. }
  68. if product.Status != consts.StatusEnabled {
  69. return nil, &LoginError{Code: 403, Message: "该产品已被禁用"}
  70. }
  71. // 审计 M-R10-5:Load 内部的 loadMembership 已经做了完全等价的判断——
  72. // FindOneByProductCodeUserId 未命中 / member.Status != StatusEnabled 都会把 ud.MemberType 置空。
  73. // 删除此处重复的 FindOneByProductCodeUserId,把登录路径由 2 次 sys_product_member 查询降到 1 次。
  74. // 错误文案合并为"您不是该产品的有效成员",与 jwtauthMiddleware 的同类分支口径一致。
  75. ud, err := svcCtx.UserDetailsLoader.Load(ctx, u.Id, productCode)
  76. if err != nil {
  77. return nil, &LoginError{Code: 503, Message: "服务暂时不可用,请稍后重试"}
  78. }
  79. if !ud.IsSuperAdmin && ud.MemberType == "" {
  80. return nil, &LoginError{Code: 403, Message: "您不是该产品的有效成员"}
  81. }
  82. // 审计 H-R18-2:与 jwtauthMiddleware 的 DeptStatus 拦截对齐——如果登录时不挡,
  83. // 冻结部门成员仍能拿到一对 token,只是每次业务请求被 middleware 返 403,既产生
  84. // 误导性的"登录成功"UX,又让 SOC 无法通过登录审计看到访问尝试。
  85. if !ud.IsSuperAdmin && ud.DeptId > 0 && ud.DeptStatus != consts.StatusEnabled {
  86. return nil, &LoginError{Code: 403, Message: "所在部门已被冻结"}
  87. }
  88. accessToken, err := authHelper.GenerateAccessToken(
  89. svcCtx.Config.Auth.AccessSecret,
  90. svcCtx.Config.Auth.AccessExpire,
  91. ud.UserId, ud.Username, ud.ProductCode, ud.MemberType, ud.TokenVersion,
  92. )
  93. if err != nil {
  94. return nil, err
  95. }
  96. refreshToken, err := authHelper.GenerateRefreshToken(
  97. svcCtx.Config.Auth.RefreshSecret,
  98. svcCtx.Config.Auth.RefreshExpire,
  99. ud.UserId, ud.ProductCode, ud.TokenVersion,
  100. )
  101. if err != nil {
  102. return nil, err
  103. }
  104. return &LoginResult{
  105. UserDetails: ud,
  106. AccessToken: accessToken,
  107. RefreshToken: refreshToken,
  108. }, nil
  109. }
  110. // ValidateAdminLogin 管理后台登录核心认证:校验 managementKey、频率限制、用户名密码、超管身份,
  111. // 生成并返回令牌对。captcha / cap.js 校验由调用方在进入本函数前完成。
  112. func ValidateAdminLogin(ctx context.Context, svcCtx *svc.ServiceContext, username, password, managementKey, clientIP string) (*LoginResult, error) {
  113. if subtle.ConstantTimeCompare([]byte(managementKey), []byte(svcCtx.Config.Auth.ManagementKey)) != 1 {
  114. return nil, &LoginError{Code: 401, Message: "managementKey无效"}
  115. }
  116. if svcCtx.UsernameLoginLimit != nil {
  117. if clientIP == "" {
  118. clientIP = "unknown"
  119. }
  120. key := fmt.Sprintf("admin:%s:%s", clientIP, username)
  121. code, _ := svcCtx.UsernameLoginLimit.Take(key)
  122. if code == limit.OverQuota {
  123. return nil, &LoginError{Code: 429, Message: "登录尝试过于频繁,请5分钟后再试"}
  124. }
  125. }
  126. u, err := svcCtx.SysUserModel.FindOneByUsername(ctx, username)
  127. if err != nil {
  128. if errors.Is(err, user.ErrNotFound) {
  129. bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(password))
  130. return nil, &LoginError{Code: 401, Message: "用户名或密码错误"}
  131. }
  132. return nil, err
  133. }
  134. // 审计 L-N3:IsSuperAdmin 判断前置到真 bcrypt 之前,防止分支耗时差泄露账号存在性;
  135. // 仍走一次 dummyBcryptHash 把时序抹平。
  136. if u.IsSuperAdmin != consts.IsSuperAdminYes {
  137. bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(password))
  138. return nil, &LoginError{Code: 401, Message: "用户名或密码错误"}
  139. }
  140. if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)); err != nil {
  141. return nil, &LoginError{Code: 401, Message: "用户名或密码错误"}
  142. }
  143. if u.Status != consts.StatusEnabled {
  144. return nil, &LoginError{Code: 401, Message: "用户名或密码错误"}
  145. }
  146. ud, err := svcCtx.UserDetailsLoader.Load(ctx, u.Id, "")
  147. if err != nil {
  148. return nil, &LoginError{Code: 503, Message: "服务暂时不可用,请稍后重试"}
  149. }
  150. accessToken, err := authHelper.GenerateAccessToken(
  151. svcCtx.Config.Auth.AccessSecret,
  152. svcCtx.Config.Auth.AccessExpire,
  153. ud.UserId, ud.Username, ud.ProductCode, ud.MemberType, ud.TokenVersion,
  154. )
  155. if err != nil {
  156. return nil, err
  157. }
  158. refreshToken, err := authHelper.GenerateRefreshToken(
  159. svcCtx.Config.Auth.RefreshSecret,
  160. svcCtx.Config.Auth.RefreshExpire,
  161. ud.UserId, ud.ProductCode, ud.TokenVersion,
  162. )
  163. if err != nil {
  164. return nil, err
  165. }
  166. return &LoginResult{
  167. UserDetails: ud,
  168. AccessToken: accessToken,
  169. RefreshToken: refreshToken,
  170. }, nil
  171. }