package pub import ( "context" "crypto/subtle" "errors" "fmt" "perms-system-server/internal/consts" "perms-system-server/internal/loaders" authHelper "perms-system-server/internal/logic/auth" "perms-system-server/internal/model/user" "perms-system-server/internal/svc" "github.com/zeromicro/go-zero/core/limit" "golang.org/x/crypto/bcrypt" ) // dummyBcryptHash 用于对不存在的用户名执行等时 bcrypt 比对,防止基于响应时间的用户名枚举 var dummyBcryptHash, _ = bcrypt.GenerateFromPassword([]byte("dummy-anti-timing"), bcrypt.DefaultCost) type LoginResult struct { UserDetails *loaders.UserDetails AccessToken string RefreshToken string } type LoginError struct { Code int Message string } func (e *LoginError) Error() string { return e.Message } func checkUsernameLimit(svcCtx *svc.ServiceContext, clientIP, username string) bool { if svcCtx.UsernameLoginLimit == nil { return false } key := fmt.Sprintf("%s:%s", clientIP, username) code, _ := svcCtx.UsernameLoginLimit.Take(key) return code == limit.OverQuota } func ValidateProductLogin(ctx context.Context, svcCtx *svc.ServiceContext, username, password, productCode, clientIP string) (*LoginResult, error) { if checkUsernameLimit(svcCtx, clientIP, username) { return nil, &LoginError{Code: 429, Message: "该账号登录尝试过于频繁,请5分钟后再试"} } u, lookupErr := svcCtx.SysUserModel.FindOneByUsername(ctx, username) var userHash []byte if lookupErr != nil { if !errors.Is(lookupErr, user.ErrNotFound) { return nil, lookupErr } userHash = dummyBcryptHash } else { userHash = []byte(u.Password) } // 无条件执行一次 bcrypt:让"账号不存在 / 冻结 / 密码错"三条路径在耗时上完全等长, // 消除基于响应时间的账号存在性 / 冻结状态 oracle(见审计 H-2)。 bcryptErr := bcrypt.CompareHashAndPassword(userHash, []byte(password)) if lookupErr != nil || bcryptErr != nil { return nil, &LoginError{Code: 401, Message: "用户名或密码错误"} } // 密码正确之后再披露账号语义状态:此时攻击者已经猜中密码,再隐藏"冻结/超管"已无意义。 if u.Status != consts.StatusEnabled { return nil, &LoginError{Code: 403, Message: "账号已被冻结"} } if u.IsSuperAdmin == consts.IsSuperAdminYes { return nil, &LoginError{Code: 403, Message: "超级管理员不允许通过产品端登录,请使用管理后台"} } product, err := svcCtx.SysProductModel.FindOneByCode(ctx, productCode) if err != nil { return nil, &LoginError{Code: 400, Message: "产品不存在"} } if product.Status != consts.StatusEnabled { return nil, &LoginError{Code: 403, Message: "该产品已被禁用"} } // 审计 M-R10-5:Load 内部的 loadMembership 已经做了完全等价的判断—— // FindOneByProductCodeUserId 未命中 / member.Status != StatusEnabled 都会把 ud.MemberType 置空。 // 删除此处重复的 FindOneByProductCodeUserId,把登录路径由 2 次 sys_product_member 查询降到 1 次。 // 错误文案合并为"您不是该产品的有效成员",与 jwtauthMiddleware 的同类分支口径一致。 ud, err := svcCtx.UserDetailsLoader.Load(ctx, u.Id, productCode) if err != nil { return nil, &LoginError{Code: 503, Message: "服务暂时不可用,请稍后重试"} } if !ud.IsSuperAdmin && ud.MemberType == "" { return nil, &LoginError{Code: 403, Message: "您不是该产品的有效成员"} } // 审计 H-R18-2:与 jwtauthMiddleware 的 DeptStatus 拦截对齐——如果登录时不挡, // 冻结部门成员仍能拿到一对 token,只是每次业务请求被 middleware 返 403,既产生 // 误导性的"登录成功"UX,又让 SOC 无法通过登录审计看到访问尝试。 if !ud.IsSuperAdmin && ud.DeptId > 0 && ud.DeptStatus != consts.StatusEnabled { return nil, &LoginError{Code: 403, Message: "所在部门已被冻结"} } accessToken, err := authHelper.GenerateAccessToken( svcCtx.Config.Auth.AccessSecret, svcCtx.Config.Auth.AccessExpire, ud.UserId, ud.Username, ud.ProductCode, ud.MemberType, ud.TokenVersion, ) if err != nil { return nil, err } refreshToken, err := authHelper.GenerateRefreshToken( svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire, ud.UserId, ud.ProductCode, ud.TokenVersion, ) if err != nil { return nil, err } return &LoginResult{ UserDetails: ud, AccessToken: accessToken, RefreshToken: refreshToken, }, nil } // ValidateAdminLogin 管理后台登录核心认证:校验 managementKey、频率限制、用户名密码、超管身份, // 生成并返回令牌对。captcha / cap.js 校验由调用方在进入本函数前完成。 func ValidateAdminLogin(ctx context.Context, svcCtx *svc.ServiceContext, username, password, managementKey, clientIP string) (*LoginResult, error) { if subtle.ConstantTimeCompare([]byte(managementKey), []byte(svcCtx.Config.Auth.ManagementKey)) != 1 { return nil, &LoginError{Code: 401, Message: "managementKey无效"} } if svcCtx.UsernameLoginLimit != nil { if clientIP == "" { clientIP = "unknown" } key := fmt.Sprintf("admin:%s:%s", clientIP, username) code, _ := svcCtx.UsernameLoginLimit.Take(key) if code == limit.OverQuota { return nil, &LoginError{Code: 429, Message: "登录尝试过于频繁,请5分钟后再试"} } } u, err := svcCtx.SysUserModel.FindOneByUsername(ctx, username) if err != nil { if errors.Is(err, user.ErrNotFound) { bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(password)) return nil, &LoginError{Code: 401, Message: "用户名或密码错误"} } return nil, err } // 审计 L-N3:IsSuperAdmin 判断前置到真 bcrypt 之前,防止分支耗时差泄露账号存在性; // 仍走一次 dummyBcryptHash 把时序抹平。 if u.IsSuperAdmin != consts.IsSuperAdminYes { bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(password)) return nil, &LoginError{Code: 401, Message: "用户名或密码错误"} } if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)); err != nil { return nil, &LoginError{Code: 401, Message: "用户名或密码错误"} } if u.Status != consts.StatusEnabled { return nil, &LoginError{Code: 401, Message: "用户名或密码错误"} } ud, err := svcCtx.UserDetailsLoader.Load(ctx, u.Id, "") if err != nil { return nil, &LoginError{Code: 503, Message: "服务暂时不可用,请稍后重试"} } accessToken, err := authHelper.GenerateAccessToken( svcCtx.Config.Auth.AccessSecret, svcCtx.Config.Auth.AccessExpire, ud.UserId, ud.Username, ud.ProductCode, ud.MemberType, ud.TokenVersion, ) if err != nil { return nil, err } refreshToken, err := authHelper.GenerateRefreshToken( svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire, ud.UserId, ud.ProductCode, ud.TokenVersion, ) if err != nil { return nil, err } return &LoginResult{ UserDetails: ud, AccessToken: accessToken, RefreshToken: refreshToken, }, nil }