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

450 lines
20 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 实现 user-service 的认证、三方登录、密码登录和 refresh session 用例。
package auth
import (
"context"
"time"
"hyapp/pkg/idgen"
authdomain "hyapp/services/user-service/internal/domain/auth"
invitedomain "hyapp/services/user-service/internal/domain/invite"
userdomain "hyapp/services/user-service/internal/domain/user"
userservice "hyapp/services/user-service/internal/service/user"
)
const (
// hashAlgBcrypt 是当前密码 hash 算法标识。
hashAlgBcrypt = "bcrypt"
// tokenType 是对外 token 类型。
tokenType = "Bearer"
// loginPassword 是密码登录审计类型。
loginPassword = "password"
// loginThird 是三方登录审计类型。
loginThird = "third_party"
// loginRefresh 是 refresh token 轮换审计类型。
loginRefresh = "refresh"
// loginAccessResign 是不轮换 refresh token 的 access token 重签审计类型。
loginAccessResign = "access_resign"
// loginSetPass 是设置密码审计类型。
loginSetPass = "set_password"
// resultSuccess 是审计成功结果。
resultSuccess = "success"
// resultFailed 是审计失败结果。
resultFailed = "failed"
// revokeReasonRefreshRotated 表示 refresh token 已被成功轮换,旧 access token 必须立刻失效。
revokeReasonRefreshRotated = "REFRESH_ROTATED"
// revokeReasonUserLogout 表示用户主动登出,旧 access token 必须立刻失效。
revokeReasonUserLogout = "USER_LOGOUT"
)
// AuthRepository 是认证用例的持久化边界。
// 跨表写入仍按用例保留事务入口,例如三方首次登录要一次性创建用户、默认短号、三方身份和 session。
type AuthRepository interface {
// AllocateDisplayUserID 分配当前 App 下的随机外观默认短号;最终唯一性由创建事务里的展示号事实表兜底。
AllocateDisplayUserID(ctx context.Context, appCode string) (string, error)
// SetPassword 为已有用户首次写入密码身份。
SetPassword(ctx context.Context, account authdomain.PasswordAccount) error
// CreatePasswordUser 原子创建用户、默认短号、密码身份和可选首个 session全站机器人会传空 session。
CreatePasswordUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, account authdomain.PasswordAccount, session authdomain.Session, invite invitedomain.BindCommand, maxAccountsPerDevice int32) error
// FindPasswordByDisplayUserID 通过当前 active 展示号或 held 默认短号登录别名读取密码身份。
FindPasswordByDisplayUserID(ctx context.Context, displayUserID string, nowMs int64) (authdomain.PasswordAccount, error)
// CreateSession 创建新的 refresh session。
CreateSession(ctx context.Context, session authdomain.Session) error
// FindActiveSessionByRefreshHash 通过 refresh token hash 查找未过期未吊销 session。
FindActiveSessionByRefreshHash(ctx context.Context, refreshTokenHash string, nowMs int64) (authdomain.Session, error)
// FindActiveSessionByID 通过 session_id 查找未过期未吊销 session用于只重签 access token 的链路。
FindActiveSessionByID(ctx context.Context, sessionID string, nowMs int64) (authdomain.Session, error)
// ReplaceSession 原子吊销旧 session 并创建新 session。
ReplaceSession(ctx context.Context, oldSessionID string, newSession authdomain.Session, revokedAtMs int64) error
// RevokeSession 按 session_id 或 refresh token hash 吊销 session。
RevokeSession(ctx context.Context, sessionID string, refreshTokenHash string, revokedAtMs int64) (bool, error)
// RevokeSessionWithReason 按 session_id 幂等吊销 session并持久化撤销来源。
RevokeSessionWithReason(ctx context.Context, sessionID string, revokedAtMs int64, reason string, requestID string, revokedBy string) (bool, error)
// RevokeUserDeviceSessionsForRisk 按风险源 session 定位同一设备后续 session吊销 active session并返回需要写入 denylist 的 session_id。
RevokeUserDeviceSessionsForRisk(ctx context.Context, sourceSessionID string, userID int64, revokedAtMs int64, reason string, requestID string, revokedBy string) ([]string, error)
// UpdateSessionHeartbeat 刷新当前 App 登录会话心跳,必须校验 session 仍属于当前用户且未过期未吊销。
UpdateSessionHeartbeat(ctx context.Context, sessionID string, userID int64, heartbeatAtMs int64, requestID string) (authdomain.Session, error)
// FindThirdPartyIdentity 查找 provider + subject 的绑定。
FindThirdPartyIdentity(ctx context.Context, provider string, providerSubject string) (authdomain.ThirdPartyIdentity, error)
// CountUsersByRegisterDeviceID 统计当前 App 下已使用同一注册设备号的账号数量。
CountUsersByRegisterDeviceID(ctx context.Context, appCode string, deviceID string) (int64, error)
// ListDisplayUserIDsByRegisterDeviceID 按原注册顺序列出同设备已注册账号短号,用于上限判断和失败提示。
ListDisplayUserIDsByRegisterDeviceID(ctx context.Context, appCode string, deviceID string) ([]string, error)
// CreateThirdPartyUser 原子创建用户、默认短号、三方身份和首个 session。
CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, invite invitedomain.BindCommand, maxAccountsPerDevice int32) error
// CompleteThirdPartyRegistration 原子补齐旧三方 pending 用户资料并创建首个注册完成 session。
CompleteThirdPartyRegistration(ctx context.Context, command userdomain.CompleteOnboardingCommand, session authdomain.Session) (userdomain.User, error)
// GetRegisterRiskConfig 读取当前 App 的注册风控配置。
GetRegisterRiskConfig(ctx context.Context, appCode string) (authdomain.RegisterRiskConfig, error)
// UpsertRegisterRiskConfig 保存当前 App 的注册风控配置。
UpsertRegisterRiskConfig(ctx context.Context, config authdomain.RegisterRiskConfig) (authdomain.RegisterRiskConfig, error)
// RecordLoginAudit 记录登录审计,主链路不会因为审计失败而失败。
RecordLoginAudit(ctx context.Context, audit authdomain.LoginAudit) error
// CreateLoginIPRiskJob 创建登录后异步 IP 风控任务,失败只影响复核能力。
CreateLoginIPRiskJob(ctx context.Context, job authdomain.LoginIPRiskJob) error
// ClaimLoginIPRiskJobs 按 DB 租约认领待处理风控任务。
ClaimLoginIPRiskJobs(ctx context.Context, workerID string, nowMs int64, lockTTL time.Duration, batchSize int) ([]authdomain.LoginIPRiskJob, error)
// CompleteLoginIPRiskJob 写入任务终态。
CompleteLoginIPRiskJob(ctx context.Context, job authdomain.LoginIPRiskJob) error
// CreateLoginIPRiskDecision 持久化 IP 风控决策审计。
CreateLoginIPRiskDecision(ctx context.Context, decision authdomain.LoginIPRiskDecision) error
// ListEnabledLoginRiskCountryBlocks 返回当前 App 的动态屏蔽国家词表。
ListEnabledLoginRiskCountryBlocks(ctx context.Context) ([]authdomain.LoginRiskCountryBlock, error)
// ListEnabledLoginRiskIPWhitelist 返回当前 App 的登录地区屏蔽 IP 白名单。
ListEnabledLoginRiskIPWhitelist(ctx context.Context) ([]authdomain.LoginRiskIPWhitelist, error)
// ListEnabledLoginRiskUserWhitelist 返回当前 App 的登录地区屏蔽用户白名单。
ListEnabledLoginRiskUserWhitelist(ctx context.Context) ([]authdomain.LoginRiskUserWhitelist, error)
// MarkLoginAuditBlocked 把登录成功后被异步风控撤销的审计行更新成阻断态。
MarkLoginAuditBlocked(ctx context.Context, requestID string, userID int64, countryCode string, blockReason string) error
}
// UserRepository 为 auth service 提供登录签发前的用户快照读取。
// Auth service 不关心用户创建和短号修改细节,只需要判断用户是否可登录以及签发当前展示号。
type UserRepository interface {
// GetUser 返回登录签发前需要的用户快照。
GetUser(ctx context.Context, userID int64) (userdomain.User, error)
}
// IdentityRepository 为 auth service 提供靓号懒过期能力。
// 过期恢复仍由 identity repository 的事务完成,避免 auth service 手动改 users 和短号表。
type IdentityRepository interface {
// ExpirePrettyDisplayUserID 原子恢复过期靓号,避免 token 签发过期展示号。
ExpirePrettyDisplayUserID(ctx context.Context, command userdomain.ExpirePrettyDisplayUserIDCommand) (userdomain.Identity, error)
}
// ThirdPartyVerifier 把 provider credential 校验成稳定 provider_subject。
// gateway 不接触 provider secret三方校验必须停留在 user-service。
type ThirdPartyVerifier interface {
// Verify 校验 provider credential并返回 provider 内稳定身份。
Verify(ctx context.Context, provider string, credential string) (authdomain.ThirdPartyProfile, error)
}
// RegistrationRewardCommand 是三方首次注册成功后发往 activity-service 的幂等奖励命令。
type RegistrationRewardCommand struct {
AppCode string
RequestID string
UserID int64
CommandID string
}
// RegistrationRewardIssuer 隔离 auth service 和 activity protobuf避免认证领域直接依赖外部 RPC 细节。
type RegistrationRewardIssuer interface {
IssueRegistrationReward(ctx context.Context, command RegistrationRewardCommand) error
}
// RegistrationLevelBadgeCommand 是三方首次注册成功后发往 activity-service 的等级徽章发放命令。
type RegistrationLevelBadgeCommand struct {
AppCode string
RequestID string
UserID int64
CommandID string
}
// RegistrationLevelBadgeIssuer 隔离注册等级徽章发放 RPC失败不能回滚用户注册主事务。
type RegistrationLevelBadgeIssuer interface {
IssueRegistrationLevelBadges(ctx context.Context, command RegistrationLevelBadgeCommand) error
}
// IMAccountImporter 把 user-service 用户导入腾讯 IM 账号体系。
type IMAccountImporter interface {
ImportAccount(ctx context.Context, userID int64, nickname string, faceURL string) error
}
// Config 保存认证底座需要的稳定参数。
type Config struct {
// Issuer 写入 access token 的 iss claim。
Issuer string
// AccessTokenTTLSec 是 App 登录 access token 有效期。
AccessTokenTTLSec int64
// RefreshTokenTTLSec 是服务端 refresh session 有效期。
RefreshTokenTTLSec int64
// SigningAlg 当前只支持 HS256。
SigningAlg string
// SigningSecret 是 HS256 签名密钥。
SigningSecret string
}
// LoginRiskPolicy 保存 IP 风控 worker 的判定和缓存策略。
type LoginRiskPolicy struct {
Enabled bool
UnsupportedCountries []string
BlockedTTL time.Duration
AllowedTTL time.Duration
UnknownTTL time.Duration
ProviderTimeout time.Duration
ProviderNames []string
DenylistTTL time.Duration
}
// IPDecisionCache 是 Redis 风控快查缓存和 session denylist 的最小接口。
type IPDecisionCache interface {
GetIPDecision(ctx context.Context, appCode string, clientIPHash string) (IPDecision, bool, error)
SetIPDecision(ctx context.Context, appCode string, clientIPHash string, decision IPDecision, ttl time.Duration) error
SetRevokedSession(ctx context.Context, appCode string, sessionID string, reason string, ttl time.Duration) error
GetIPWhitelist(ctx context.Context, appCode string) ([]string, bool, error)
SetIPWhitelist(ctx context.Context, appCode string, ips []string, ttl time.Duration) error
GetUserWhitelist(ctx context.Context, appCode string) ([]int64, bool, error)
SetUserWhitelist(ctx context.Context, appCode string, userIDs []int64, ttl time.Duration) error
}
// RegisterRiskConfigCache 是注册风控配置的 Redis 快取边界。
// 该缓存只做读放大优化MySQL 仍是配置的持久事实来源。
type RegisterRiskConfigCache interface {
GetRegisterRiskConfig(ctx context.Context, appCode string) (authdomain.RegisterRiskConfig, bool, error)
SetRegisterRiskConfig(ctx context.Context, config authdomain.RegisterRiskConfig, ttl time.Duration) error
}
// IPDecision 是 Redis 中 IP 决策 JSON 的领域投影。
type IPDecision struct {
Decision string `json:"decision"`
Country string `json:"country"`
Source string `json:"source"`
CheckedAtMs int64 `json:"checked_at_ms"`
ExpiresAtMs int64 `json:"expires_at_ms"`
}
// IPGeoProvider 表达一个按优先级调用的 IP Geo 来源。
type IPGeoProvider interface {
Name() string
ResolveCountry(ctx context.Context, job authdomain.LoginIPRiskJob) (IPGeoResult, error)
}
// IPGeoResult 是 provider 返回的标准化结果。
type IPGeoResult struct {
CountryCode string
Confidence int
}
// Service 承载登录注册用例。
type Service struct {
// cfg 保存 token 签发配置。
cfg Config
// authRepository 负责密码身份、session、三方绑定和登录审计。
authRepository AuthRepository
// userRepository 提供登录签发前的用户主状态读取。
userRepository UserRepository
// identityRepository 提供靓号懒过期恢复事务。
identityRepository IdentityRepository
// countryRegionRepository 提供三方首次注册时的国家主数据校验和区域解析。
countryRegionRepository userservice.CountryRegionRepository
// idGenerator 生成三方首次登录时的新 user_id。
idGenerator userservice.IDGenerator
// displayUserIDAllocator 生成三方首次登录时的默认短号候选。
displayUserIDAllocator userservice.DisplayUserIDAllocator
// thirdPartyVerifier 把 provider credential 校验成稳定 subject。
thirdPartyVerifier ThirdPartyVerifier
// now 是服务时钟,测试可注入固定时间。
now func() time.Time
// allocateMaxAttempts 是默认短号冲突重试上限。
allocateMaxAttempts int
// loginRiskPolicy 控制登录 IP 风控 worker 的判定和 TTL。
loginRiskPolicy LoginRiskPolicy
// ipDecisionCache 读写 Redis IP 决策和 session denylistnil 时按 fail-open 处理。
ipDecisionCache IPDecisionCache
// registerRiskConfigCache 缓存注册风控配置,后台修改后会立即刷新同一个 Redis key。
registerRiskConfigCache RegisterRiskConfigCache
// ipGeoProviders 按顺序提供 IP 国家解析能力。
ipGeoProviders []IPGeoProvider
// registrationRewardIssuer 在注册事务提交后异步式触发奖励;失败只记录日志,不回滚用户注册。
registrationRewardIssuer RegistrationRewardIssuer
// registrationLevelBadgeIssuer 在注册事务提交后补发三条等级轨道的 1 级徽章。
registrationLevelBadgeIssuer RegistrationLevelBadgeIssuer
// imAccountImporter 在用户创建成功后补齐腾讯 IM 账号UserSig 入口仍会兜底补偿历史账号。
imAccountImporter IMAccountImporter
}
// Option 调整 auth service 的依赖和策略。
type Option func(*Service)
// New 创建认证服务。
func New(cfg Config, options ...Option) *Service {
// 默认依赖只保证可构造,生产必须通过 app 层注入 repository 和真实配置。
svc := &Service{
cfg: cfg,
idGenerator: idgen.NewInt64Generator(0),
displayUserIDAllocator: userservice.NumericDisplayUserIDAllocator{},
thirdPartyVerifier: NewStaticThirdPartyVerifier([]string{"wechat"}),
now: time.Now,
allocateMaxAttempts: 8,
loginRiskPolicy: DefaultLoginRiskPolicy(),
}
for _, option := range options {
// option 只替换依赖和策略,不执行 IO。
option(svc)
}
return svc
}
func DefaultLoginRiskPolicy() LoginRiskPolicy {
return LoginRiskPolicy{
Enabled: true,
UnsupportedCountries: []string{"CN"},
BlockedTTL: 30 * 24 * time.Hour,
AllowedTTL: 7 * 24 * time.Hour,
UnknownTTL: 30 * time.Minute,
ProviderTimeout: 800 * time.Millisecond,
DenylistTTL: 7*24*time.Hour + time.Minute,
}
}
func WithLoginRiskPolicy(policy LoginRiskPolicy) Option {
return func(s *Service) {
defaults := DefaultLoginRiskPolicy()
if policy.BlockedTTL <= 0 {
policy.BlockedTTL = defaults.BlockedTTL
}
if policy.AllowedTTL <= 0 {
policy.AllowedTTL = defaults.AllowedTTL
}
if policy.UnknownTTL <= 0 {
policy.UnknownTTL = defaults.UnknownTTL
}
if policy.ProviderTimeout <= 0 {
policy.ProviderTimeout = defaults.ProviderTimeout
}
if policy.DenylistTTL <= 0 {
policy.DenylistTTL = defaults.DenylistTTL
if s.cfg.AccessTokenTTLSec > 0 {
policy.DenylistTTL = time.Duration(s.cfg.AccessTokenTTLSec+60) * time.Second
}
}
s.loginRiskPolicy = policy
}
}
func WithIPDecisionCache(cache IPDecisionCache) Option {
return func(s *Service) {
s.ipDecisionCache = cache
}
}
func WithRegisterRiskConfigCache(cache RegisterRiskConfigCache) Option {
return func(s *Service) {
s.registerRiskConfigCache = cache
}
}
func WithIPGeoProviders(providers []IPGeoProvider) Option {
return func(s *Service) {
s.ipGeoProviders = providers
}
}
// WithRegistrationRewardIssuer 注入注册奖励发放 clientnil 表示本环境不启用该集成。
func WithRegistrationRewardIssuer(issuer RegistrationRewardIssuer) Option {
return func(s *Service) {
if issuer != nil {
s.registrationRewardIssuer = issuer
}
}
}
// WithRegistrationLevelBadgeIssuer 注入注册等级徽章发放 clientnil 表示本环境不启用该集成。
func WithRegistrationLevelBadgeIssuer(issuer RegistrationLevelBadgeIssuer) Option {
return func(s *Service) {
if issuer != nil {
s.registrationLevelBadgeIssuer = issuer
}
}
}
// WithIMAccountImporter 注入腾讯 IM 账号导入边界nil 表示当前环境不触发真实 IM 副作用。
func WithIMAccountImporter(importer IMAccountImporter) Option {
return func(s *Service) {
if importer != nil {
s.imAccountImporter = importer
}
}
}
// WithAuthRepository 注入纯认证持久化实现。
func WithAuthRepository(repository AuthRepository) Option {
return func(s *Service) {
if repository != nil {
// nil 不覆盖现有配置,避免测试误传导致 service 不可用。
s.authRepository = repository
}
}
}
// WithUserRepository 注入用户快照读取实现。
func WithUserRepository(repository UserRepository) Option {
return func(s *Service) {
if repository != nil {
// auth service 只读取用户快照,不直接创建或修改用户资料。
s.userRepository = repository
}
}
}
// WithIdentityRepository 注入短号懒过期实现。
func WithIdentityRepository(repository IdentityRepository) Option {
return func(s *Service) {
if repository != nil {
// identityRepository 只负责过期靓号恢复事务。
s.identityRepository = repository
}
}
}
// WithCountryRegionRepository 注入国家和区域解析 repository。
func WithCountryRegionRepository(repository userservice.CountryRegionRepository) Option {
return func(s *Service) {
if repository != nil {
s.countryRegionRepository = repository
}
}
}
// WithIDGenerator 注入 user_id 发号器。
func WithIDGenerator(generator userservice.IDGenerator) Option {
return func(s *Service) {
if generator != nil {
// 三方首次登录需要生成新用户 ID。
s.idGenerator = generator
}
}
}
// WithDisplayUserIDAllocator 注入短号候选生成器。
func WithDisplayUserIDAllocator(allocator userservice.DisplayUserIDAllocator) Option {
return func(s *Service) {
if allocator != nil {
// allocator 只生成候选,唯一性由 repository 事务保证。
s.displayUserIDAllocator = allocator
}
}
}
// WithThirdPartyVerifier 注入三方 provider 校验器。
func WithThirdPartyVerifier(verifier ThirdPartyVerifier) Option {
return func(s *Service) {
if verifier != nil {
// verifier 可替换为真实三方 SDK 实现。
s.thirdPartyVerifier = verifier
}
}
}
// WithClock 注入时间函数,保证 session 轮换测试稳定。
func WithClock(now func() time.Time) Option {
return func(s *Service) {
if now != nil {
// 固定时钟用于 session 过期和 refresh 轮换测试。
s.now = now
}
}
}
// WithDisplayUserIDAllocateMaxAttempts 配置默认短号分配重试次数。
func WithDisplayUserIDAllocateMaxAttempts(maxAttempts int) Option {
return func(s *Service) {
if maxAttempts > 0 {
// 只接受正数,避免三方注册永远无法分配默认短号。
s.allocateMaxAttempts = maxAttempts
}
}
}