509 lines
23 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"
"fmt"
"log/slog"
"strings"
"time"
_ "time/tzdata"
"unicode/utf8"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
authdomain "hyapp/services/user-service/internal/domain/auth"
invitedomain "hyapp/services/user-service/internal/domain/invite"
userdomain "hyapp/services/user-service/internal/domain/user"
)
const (
// 以下长度必须和 services/user-service/deploy/mysql/initdb/001_user_service.sql 的 users 表 VARCHAR 保持一致。
maxRegistrationUsername = userdomain.ProfileUsernameMaxRunes
maxRegistrationGender = 32
maxRegistrationCountry = 64
maxRegistrationInviteCode = 64
maxRegistrationIP = 64
maxRegistrationUserAgent = 512
maxRegistrationCountryByIP = 64
maxRegistrationDeviceID = 128
maxRegistrationDevice = 128
maxRegistrationOSVersion = 64
maxRegistrationAvatar = userdomain.ProfileAvatarMaxRunes
maxRegistrationAppVersion = 64
maxRegistrationBuildNumber = 64
maxRegistrationSource = 64
maxRegistrationInstallChannel = 64
maxRegistrationCampaign = 128
maxRegistrationPlatform = 16
maxRegistrationLanguage = 32
maxRegistrationTimezone = 64
)
// LoginThirdParty 校验三方凭证,存在则登录,不存在则创建用户和三方绑定。
func (s *Service) LoginThirdParty(ctx context.Context, provider string, credential string, registration authdomain.ThirdPartyRegistration, meta Meta) (authdomain.Token, bool, error) {
meta.AppCode = appcode.Normalize(meta.AppCode)
registration.AppCode = meta.AppCode
provider = strings.ToLower(strings.TrimSpace(provider))
credential = strings.TrimSpace(credential)
registration = normalizeThirdPartyRegistration(registration, meta)
meta.Platform = registration.Platform
meta.Language = registration.Language
meta.Timezone = registration.Timezone
if provider == "" || credential == "" {
// provider/credential 是认证材料,缺失时属于请求参数错误,不进入 provider verifier。
return authdomain.Token{}, false, xerr.New(xerr.InvalidArgument, "provider and credential are required")
}
if registration.DeviceID == "" || registration.Platform == "" {
// device_id 绑定 refresh sessionplatform 是注册来源必填快照;两者缺失都不能创建用户主记录。
return authdomain.Token{}, false, xerr.New(xerr.InvalidArgument, "device_id and platform are required")
}
if err := validateThirdPartyRegistration(registration); err != nil {
return authdomain.Token{}, false, err
}
if s.authRepository == nil {
return authdomain.Token{}, false, xerr.New(xerr.Unavailable, "auth repository is not configured")
}
// provider credential 只在 user-service 内校验gateway 不接触 provider secret。
profile, err := s.thirdPartyVerifier.Verify(ctx, provider, credential)
if err != nil {
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.AuthFailed))
return authdomain.Token{}, false, authFailed()
}
// 已存在 provider + subject 绑定时走登录路径,否则走注册事务。
identity, err := s.authRepository.FindThirdPartyIdentity(ctx, provider, profile.ProviderSubject)
if err == nil {
return s.loginExistingThirdParty(ctx, identity, registration, meta)
}
if !xerr.IsCode(err, xerr.NotFound) {
// 非 not found 的存储错误不能退化为注册。
logx.Error(logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", meta.AppCode)), "third_party_lookup_failed", err, slog.String("component", "user_auth"), slog.String("provider", provider))
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, false, err
}
if err := s.ensureDeviceCanRegisterThirdParty(ctx, registration, meta, provider); err != nil {
return authdomain.Token{}, false, err
}
registration, err = s.resolveRegistrationCountry(ctx, registration)
if err != nil {
return authdomain.Token{}, false, err
}
return s.createThirdPartyUser(ctx, provider, profile, registration, meta)
}
func (s *Service) ensureDeviceCanRegisterThirdParty(ctx context.Context, registration authdomain.ThirdPartyRegistration, meta Meta, provider string) error {
// 设备号限制只约束“创建新账号”路径;已绑定三方身份登录不经过这里,避免把正常跨设备登录误拦。
userID, err := s.authRepository.FindUserIDByRegisterDeviceID(ctx, registration.AppCode, registration.DeviceID)
if xerr.IsCode(err, xerr.NotFound) {
return nil
}
if err != nil {
// 设备查重失败不能退化为注册,否则并发和依赖异常都会突破一机一号约束。
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
return err
}
// 已有任意用户占用该注册设备时拒绝创建新账号;不返回 user_id避免客户端枚举设备归属。
s.audit(ctx, meta, userID, loginThird, provider, resultFailed, string(xerr.DeviceAlreadyRegistered))
return xerr.New(xerr.DeviceAlreadyRegistered, "device already registered")
}
func (s *Service) loginExistingThirdParty(ctx context.Context, identity authdomain.ThirdPartyIdentity, registration authdomain.ThirdPartyRegistration, meta Meta) (authdomain.Token, bool, error) {
// 已绑定三方用户登录时仍要重新读取用户快照,确保禁用状态和靓号过期生效。
user, err := s.freshUser(ctx, identity.UserID, s.now().UnixMilli(), meta.RequestID)
if err != nil {
s.audit(ctx, meta, identity.UserID, loginThird, identity.Provider, resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, false, err
}
if !user.CanLogin() {
// 禁用用户不能通过三方登录绕过状态限制。
s.audit(ctx, meta, user.UserID, loginThird, identity.Provider, resultFailed, string(xerr.UserDisabled))
return authdomain.Token{}, false, xerr.New(xerr.UserDisabled, "user is disabled")
}
// 每次三方登录创建新的 refresh session。
session, refreshToken, err := s.newSession(meta.AppCode, user.UserID, registration.DeviceID)
if err != nil {
return authdomain.Token{}, false, err
}
if err := s.authRepository.CreateSession(ctx, session); err != nil {
return authdomain.Token{}, false, err
}
s.audit(ctx, meta, user.UserID, loginThird, identity.Provider, resultSuccess, "")
// isNewUser=false 表示本次只创建 session没有创建用户。
token, err := s.issueToken(user, session.SessionID, refreshToken)
if err != nil {
return authdomain.Token{}, false, err
}
s.enqueueLoginIPRiskJob(ctx, meta, token, normalizeThirdPartyChannel(identity.Provider), registration.Platform, registration.Language, registration.Timezone)
return token, false, nil
}
func (s *Service) createThirdPartyUser(ctx context.Context, provider string, profile authdomain.ThirdPartyProfile, registration authdomain.ThirdPartyRegistration, meta Meta) (authdomain.Token, bool, error) {
var lastErr error
for attempt := 0; attempt < s.allocateMaxAttempts; attempt++ {
// 三方首次登录要同时生成 user_id 和默认短号,短号冲突时有限重试。
user, displayIdentity := s.newUserWithIdentity(attempt)
user = applyThirdPartyRegistration(user, registration)
displayUserID, err := s.authRepository.AllocateDisplayUserID(ctx, registration.AppCode)
if err != nil {
return authdomain.Token{}, false, err
}
user.DefaultDisplayUserID = displayUserID
user.CurrentDisplayUserID = displayUserID
displayIdentity.DisplayUserID = displayUserID
displayIdentity.DefaultDisplayUserID = displayUserID
displayIdentity.AppCode = registration.AppCode
if !userdomain.ValidDisplayUserID(displayIdentity.DisplayUserID) {
// 非法候选直接跳过,避免进入 repository 唯一性判断。
continue
}
session, refreshToken, err := s.newSession(registration.AppCode, user.UserID, registration.DeviceID)
if err != nil {
return authdomain.Token{}, false, err
}
thirdParty := authdomain.ThirdPartyIdentity{
AppCode: registration.AppCode,
UserID: user.UserID,
Provider: provider,
ProviderSubject: profile.ProviderSubject,
ProviderUnionID: profile.ProviderUnionID,
CreatedAtMs: user.CreatedAtMs,
UpdatedAtMs: user.UpdatedAtMs,
}
inviteBind := invitedomain.BindCommand{
AppCode: registration.AppCode,
InvitedUserID: user.UserID,
InvitedRegionID: registration.RegionID,
InviteCode: registration.InviteCode,
Source: "third_party_registration",
RequestID: meta.RequestID,
BoundAtMs: user.CreatedAtMs,
}
err = s.authRepository.CreateThirdPartyUser(ctx, user, displayIdentity, thirdParty, session, inviteBind)
if err == nil {
// repository 事务成功后,用户、默认短号、三方绑定和首个 session 同时存在。
s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "")
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken)
if tokenErr != nil {
return authdomain.Token{}, false, tokenErr
}
s.importIMAccountAfterCommit(ctx, user, meta)
s.enqueueLoginIPRiskJob(ctx, meta, token, normalizeThirdPartyChannel(provider), registration.Platform, registration.Language, registration.Timezone)
s.issueRegistrationRewardAfterCommit(ctx, registration.AppCode, user.UserID, meta)
s.issueRegistrationLevelBadgesAfterCommit(ctx, registration.AppCode, user.UserID, meta)
return token, true, nil
}
if xerr.IsCode(err, xerr.DisplayUserIDExists) {
// 默认短号冲突可重试user_id 和 display_user_id 都会重新生成。
lastErr = err
continue
}
if xerr.IsCode(err, xerr.Conflict) {
// 并发注册同一 provider subject 时,尝试转为已有三方登录。
identity, findErr := s.authRepository.FindThirdPartyIdentity(ctx, provider, profile.ProviderSubject)
if findErr == nil {
return s.loginExistingThirdParty(ctx, identity, registration, meta)
}
}
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
logx.Error(logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", meta.AppCode)), "third_party_register_failed", err, slog.String("component", "user_auth"), slog.String("provider", provider))
return authdomain.Token{}, false, err
}
if lastErr != nil {
// 明确记录是短号分配耗尽,而不是 provider credential 失败。
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.DisplayUserIDAllocateFailed))
}
return authdomain.Token{}, false, xerr.New(xerr.DisplayUserIDAllocateFailed, "display_user_id allocation failed")
}
func (s *Service) issueRegistrationRewardAfterCommit(ctx context.Context, appCode string, userID int64, meta Meta) {
if s.registrationRewardIssuer == nil {
return
}
commandID := fmt.Sprintf("registration_reward:%s:%d", appcode.Normalize(appCode), userID)
if err := s.registrationRewardIssuer.IssueRegistrationReward(ctx, RegistrationRewardCommand{
AppCode: appcode.Normalize(appCode),
RequestID: meta.RequestID,
UserID: userID,
CommandID: commandID,
}); err != nil {
// 注册主事务已经提交,奖励是后置权益发放;失败只能记录并依赖同 command_id 的人工或重试修复,不能让用户注册回滚。
logx.Warn(logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", appcode.Normalize(appCode))), "registration_reward_issue_failed",
slog.String("component", "user_auth"),
slog.Int64("user_id", userID),
slog.String("command_id", commandID),
slog.String("error", err.Error()),
)
}
}
func (s *Service) issueRegistrationLevelBadgesAfterCommit(ctx context.Context, appCode string, userID int64, meta Meta) {
if s.registrationLevelBadgeIssuer == nil {
return
}
commandID := fmt.Sprintf("registration_level_badges:%s:%d", appcode.Normalize(appCode), userID)
if err := s.registrationLevelBadgeIssuer.IssueRegistrationLevelBadges(ctx, RegistrationLevelBadgeCommand{
AppCode: appcode.Normalize(appCode),
RequestID: meta.RequestID,
UserID: userID,
CommandID: commandID,
}); err != nil {
// 等级徽章是注册后置权益;用户注册事务已经提交,只能依赖同 command_id 重试或补发修复。
logx.Warn(logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", appcode.Normalize(appCode))), "registration_level_badges_issue_failed",
slog.String("component", "user_auth"),
slog.Int64("user_id", userID),
slog.String("command_id", commandID),
slog.String("error", err.Error()),
)
}
}
func normalizeThirdPartyRegistration(registration authdomain.ThirdPartyRegistration, meta Meta) authdomain.ThirdPartyRegistration {
// 注册资料来自公网入口,进入领域前统一裁剪空白,避免数据库里出现不可见差异。
registration.Username = strings.TrimSpace(registration.Username)
registration.Gender = strings.TrimSpace(registration.Gender)
registration.Country = strings.ToUpper(strings.TrimSpace(registration.Country))
registration.InviteCode = invitedomain.NormalizeCode(registration.InviteCode)
// 注册 IP 和 UA 只能来自 gateway 观察到的请求上下文,不能信任公网 JSON body。
registration.IP = strings.TrimSpace(meta.ClientIP)
registration.UserAgent = strings.TrimSpace(meta.UserAgent)
registration.CountryByIP = normalizeCountryByIP(meta.CountryByIP)
registration.DeviceID = strings.TrimSpace(registration.DeviceID)
registration.Device = strings.TrimSpace(registration.Device)
registration.OSVersion = strings.TrimSpace(registration.OSVersion)
registration.Avatar = strings.TrimSpace(registration.Avatar)
registration.BirthDate = strings.TrimSpace(registration.BirthDate)
registration.AppVersion = strings.TrimSpace(registration.AppVersion)
registration.BuildNumber = strings.TrimSpace(registration.BuildNumber)
registration.Source = strings.TrimSpace(registration.Source)
registration.InstallChannel = strings.TrimSpace(registration.InstallChannel)
registration.Campaign = strings.TrimSpace(registration.Campaign)
registration.Platform = strings.ToLower(strings.TrimSpace(registration.Platform))
registration.Language = strings.TrimSpace(registration.Language)
registration.Timezone = strings.TrimSpace(registration.Timezone)
return registration
}
func validateThirdPartyRegistration(registration authdomain.ThirdPartyRegistration) error {
if err := validateRegistrationLengths(registration); err != nil {
return err
}
if registration.Country != "" && !userdomain.ValidCountryCode(registration.Country) {
// 用户选择国家只接受 2 到 3 位大写英文国家码,是否可选由 countries 表决定。
return xerr.New(xerr.InvalidArgument, "country must use 2 to 3 uppercase letters")
}
if registration.InviteCode != "" && !invitedomain.ValidCode(registration.InviteCode) {
// 邀请码错误统一返回 INVALID_INVITE_CODE避免客户端通过错误细节枚举邀请码。
return xerr.New(xerr.InvalidInviteCode, "invalid invite code")
}
if registration.BirthDate != "" {
if !userdomain.ValidProfileBirthDate(registration.BirthDate) {
// 生日只保存日期,不接受带时区或非零填充格式,避免跨端解析不一致。
return xerr.New(xerr.InvalidArgument, "birth must use yyyy-mm-dd")
}
}
if registration.Avatar != "" && !userdomain.ValidProfileAvatarURL(registration.Avatar) {
// avatar 进入可展示资料,不接受相对地址或非 HTTP(S) scheme。
return xerr.New(xerr.InvalidArgument, "avatar must be an absolute http or https URL")
}
if registration.Platform != "android" && registration.Platform != "ios" {
// 平台值进入统计和渠道分析,先收敛到明确枚举。
return xerr.New(xerr.InvalidArgument, "platform must be android or ios")
}
if registration.Language != "" && !validLanguageTag(registration.Language) {
// language 使用轻量 BCP 47 子集,覆盖 en-US、zh-Hant-CN 等常见客户端语言。
return xerr.New(xerr.InvalidArgument, "language must use a BCP 47 language tag")
}
if registration.Timezone != "" {
if _, err := time.LoadLocation(registration.Timezone); err != nil {
// timezone 使用 IANA 名称time/tzdata 保证 Alpine 镜像内也能稳定校验。
return xerr.New(xerr.InvalidArgument, "timezone must use an IANA timezone name")
}
}
return nil
}
func validateThirdPartyRegistrationRequiredForCreate(registration authdomain.ThirdPartyRegistration) error {
if registration.Username == "" || registration.Country == "" || registration.Gender == "" || registration.Avatar == "" {
// 注册即落用户主数据;最小公开资料不完整时拒绝创建,避免后台出现空昵称、空头像或 GLOBAL 国家用户。
return xerr.New(xerr.InvalidArgument, "username, country, gender and avatar are required")
}
return nil
}
func validateRegistrationLengths(registration authdomain.ThirdPartyRegistration) error {
checks := []struct {
name string
value string
max int
}{
{name: "username", value: registration.Username, max: maxRegistrationUsername},
{name: "gender", value: registration.Gender, max: maxRegistrationGender},
{name: "country", value: registration.Country, max: maxRegistrationCountry},
{name: "invite_code", value: registration.InviteCode, max: maxRegistrationInviteCode},
{name: "register_ip", value: registration.IP, max: maxRegistrationIP},
{name: "register_user_agent", value: registration.UserAgent, max: maxRegistrationUserAgent},
{name: "country_by_ip", value: registration.CountryByIP, max: maxRegistrationCountryByIP},
{name: "device_id", value: registration.DeviceID, max: maxRegistrationDeviceID},
{name: "device", value: registration.Device, max: maxRegistrationDevice},
{name: "os_version", value: registration.OSVersion, max: maxRegistrationOSVersion},
{name: "avatar", value: registration.Avatar, max: maxRegistrationAvatar},
{name: "app_version", value: registration.AppVersion, max: maxRegistrationAppVersion},
{name: "build_number", value: registration.BuildNumber, max: maxRegistrationBuildNumber},
{name: "source", value: registration.Source, max: maxRegistrationSource},
{name: "install_channel", value: registration.InstallChannel, max: maxRegistrationInstallChannel},
{name: "campaign", value: registration.Campaign, max: maxRegistrationCampaign},
{name: "platform", value: registration.Platform, max: maxRegistrationPlatform},
{name: "language", value: registration.Language, max: maxRegistrationLanguage},
{name: "timezone", value: registration.Timezone, max: maxRegistrationTimezone},
}
for _, check := range checks {
if utf8.RuneCountInString(check.value) > check.max {
// MySQL VARCHAR 以字符数限制,使用 RuneCount 避免中文昵称被按 UTF-8 字节数误拒。
return xerr.New(xerr.InvalidArgument, check.name+" is too long")
}
}
return nil
}
func (s *Service) resolveRegistrationCountry(ctx context.Context, registration authdomain.ThirdPartyRegistration) (authdomain.ThirdPartyRegistration, error) {
if registration.Country == "" && registration.CountryByIP == "" {
// 没有国家输入时不要求国家主数据依赖,允许极简三方登录测试和匿名地域注册。
return registration, nil
}
if s.countryRegionRepository == nil {
// 只要需要写 country 或 country_by_ip就必须经过国家主数据校验。
return registration, xerr.New(xerr.Unavailable, "country repository is not configured")
}
if registration.Country != "" {
country, ok, err := s.countryRegionRepository.ResolveEnabledCountryByCode(ctx, registration.Country)
if err != nil {
return registration, err
}
if !ok {
return registration, xerr.New(xerr.InvalidArgument, "country is not supported")
}
registration.Country = country.CountryCode
region, ok, err := s.countryRegionRepository.ResolveActiveRegionByCountry(ctx, country.CountryCode)
if err != nil {
return registration, err
}
if ok {
// region_id 是服务端按国家映射计算出的快照,客户端不能直接提交。
registration.RegionID = region.RegionID
}
}
if registration.CountryByIP != "" {
country, ok, err := s.countryRegionRepository.ResolveEnabledCountryByCode(ctx, registration.CountryByIP)
if err != nil {
return registration, err
}
if ok {
// country_by_ip 只保留有效国家快照,不参与区域归属计算。
registration.CountryByIP = country.CountryCode
} else {
registration.CountryByIP = ""
}
}
return registration, nil
}
func normalizeCountryByIP(country string) string {
// 边缘层常用 XX 表示未知国家;未知值不写入用户主表,避免误当真实归属。
country = strings.ToUpper(strings.TrimSpace(country))
if country == "XX" || country == "UNKNOWN" || !userdomain.ValidCountryCode(country) {
return ""
}
return country
}
func validLanguageTag(language string) bool {
parts := strings.Split(language, "-")
if len(parts) == 0 || len(parts[0]) < 2 || len(parts[0]) > 8 {
return false
}
for index, part := range parts {
if part == "" || len(part) > 8 {
return false
}
for _, char := range part {
if !((char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || (index > 0 && char >= '0' && char <= '9')) {
return false
}
}
}
return true
}
func applyThirdPartyRegistration(user userdomain.User, registration authdomain.ThirdPartyRegistration) userdomain.User {
// 用户资料和注册来源跟随 users 主记录一次性落库,三方 credential 不会进入用户表。
user.Username = registration.Username
user.AppCode = registration.AppCode
user.Gender = registration.Gender
user.Country = registration.Country
user.RegionID = registration.RegionID
user.InviteCode = registration.InviteCode
user.RegisterIP = registration.IP
user.RegisterUserAgent = registration.UserAgent
user.CountryByIP = registration.CountryByIP
user.RegisterDeviceID = registration.DeviceID
user.RegisterDevice = registration.Device
user.RegisterOSVersion = registration.OSVersion
user.Avatar = registration.Avatar
user.BirthDate = registration.BirthDate
user.RegisterAppVersion = registration.AppVersion
user.RegisterBuildNumber = registration.BuildNumber
user.RegisterSource = registration.Source
user.RegisterInstallChannel = registration.InstallChannel
user.RegisterCampaign = registration.Campaign
user.RegisterPlatform = registration.Platform
user.Language = registration.Language
user.Timezone = registration.Timezone
return user
}
func (s *Service) newUserWithIdentity(attempt int) (userdomain.User, userdomain.Identity) {
// user 和 identity 必须成对创建,避免出现没有默认短号的用户。
userID := s.idGenerator.NewInt64()
nowMs := s.now().UnixMilli()
displayUserID := s.displayUserIDAllocator.NextDisplayUserID(userID, attempt)
return userdomain.User{
UserID: userID,
DefaultDisplayUserID: displayUserID,
CurrentDisplayUserID: displayUserID,
CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
ProfileCompleted: false,
ProfileCompletedAtMs: 0,
OnboardingStatus: userdomain.OnboardingStatusProfileRequired,
Status: userdomain.StatusActive,
CreatedAtMs: nowMs,
UpdatedAtMs: nowMs,
}, userdomain.Identity{
UserID: userID,
DisplayUserID: displayUserID,
DefaultDisplayUserID: displayUserID,
DisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
Status: userdomain.DisplayUserIDStatusActive,
}
}