2026-05-16 08:12:42 +08:00

364 lines
14 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"
)
// AuthRepository 是认证用例的持久化边界。
// 跨表写入仍按用例保留事务入口,例如三方首次登录要一次性创建用户、默认短号、三方身份和 session。
type AuthRepository interface {
// AllocateDisplayUserID 分配当前 App 下的下一个默认短号。
AllocateDisplayUserID(ctx context.Context, appCode string) (string, error)
// SetPassword 为已有用户首次写入密码身份。
SetPassword(ctx context.Context, account authdomain.PasswordAccount) error
// FindPasswordByDisplayUserID 通过当前 active display_user_id 读取密码身份。
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)
// FindThirdPartyIdentity 查找 provider + subject 的绑定。
FindThirdPartyIdentity(ctx context.Context, provider string, providerSubject string) (authdomain.ThirdPartyIdentity, error)
// CreateThirdPartyUser 原子创建用户、默认短号、三方身份和首个 session。
CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, invite invitedomain.BindCommand) 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)
// 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
}
// Config 保存认证底座需要的稳定参数。
type Config struct {
// Issuer 写入 access token 的 iss claim。
Issuer string
// AccessTokenTTLSec 是短期 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
}
// 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
// ipGeoProviders 按顺序提供 IP 国家解析能力。
ipGeoProviders []IPGeoProvider
// registrationRewardIssuer 在注册事务提交后异步式触发奖励;失败只记录日志,不回滚用户注册。
registrationRewardIssuer RegistrationRewardIssuer
}
// 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: 31 * 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 = time.Duration(s.cfg.AccessTokenTTLSec+60) * time.Second
}
s.loginRiskPolicy = policy
}
}
func WithIPDecisionCache(cache IPDecisionCache) Option {
return func(s *Service) {
s.ipDecisionCache = 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
}
}
}
// 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
}
}
}