更新三方登录和 admin token刷新问题

This commit is contained in:
ZuoZuo 2026-05-31 21:45:47 +08:00
parent ce3ffe666d
commit fc082d2c00
10 changed files with 249 additions and 56 deletions

View File

@ -18,7 +18,7 @@ migrations:
dir: "/opt/hyapp/admin-server/migrations" dir: "/opt/hyapp/admin-server/migrations"
allow_checksum_repair: false allow_checksum_repair: false
jwt_secret: "REPLACE_WITH_AT_LEAST_32_RANDOM_CHARS" jwt_secret: "REPLACE_WITH_AT_LEAST_32_RANDOM_CHARS"
access_token_ttl: "30m" access_token_ttl: "12h"
refresh_token_ttl: "168h" refresh_token_ttl: "168h"
cors_allowed_origins: cors_allowed_origins:
- "http://43.128.56.40" - "http://43.128.56.40"

View File

@ -18,7 +18,7 @@ migrations:
# 本地开发阶段允许历史 migration 文件被修改后修复 checksumstaging/prod 必须关闭。 # 本地开发阶段允许历史 migration 文件被修改后修复 checksumstaging/prod 必须关闭。
allow_checksum_repair: true allow_checksum_repair: true
jwt_secret: "dev-shared-secret" jwt_secret: "dev-shared-secret"
access_token_ttl: "30m" access_token_ttl: "12h"
refresh_token_ttl: "168h" refresh_token_ttl: "168h"
cors_allowed_origins: cors_allowed_origins:
- "http://localhost:7001" - "http://localhost:7001"

View File

@ -129,7 +129,7 @@ func Default() Config {
AllowChecksumRepair: true, AllowChecksumRepair: true,
}, },
JWTSecret: "dev-shared-secret", JWTSecret: "dev-shared-secret",
AccessTokenTTL: 30 * time.Minute, AccessTokenTTL: 12 * time.Hour,
RefreshTokenTTL: 7 * 24 * time.Hour, RefreshTokenTTL: 7 * 24 * time.Hour,
CORSAllowedOrigins: []string{ CORSAllowedOrigins: []string{
"http://localhost:7001", "http://localhost:7001",

View File

@ -21,7 +21,7 @@ func TestLoadConfigYAML(t *testing.T) {
if cfg.UserMySQLDSN != "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC" { if cfg.UserMySQLDSN != "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC" {
t.Fatalf("UserMySQLDSN = %q", cfg.UserMySQLDSN) t.Fatalf("UserMySQLDSN = %q", cfg.UserMySQLDSN)
} }
if cfg.AccessTokenTTL != 30*time.Minute { if cfg.AccessTokenTTL != 12*time.Hour {
t.Fatalf("AccessTokenTTL = %s", cfg.AccessTokenTTL) t.Fatalf("AccessTokenTTL = %s", cfg.AccessTokenTTL)
} }
if cfg.RefreshTokenTTL != 7*24*time.Hour { if cfg.RefreshTokenTTL != 7*24*time.Hour {

View File

@ -65,12 +65,16 @@ func (h *Handler) Refresh(c *gin.Context) {
return return
} }
result, err := h.service.Refresh(token) result, err := h.service.Refresh(token, LoginInput{
IP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
})
if err != nil { if err != nil {
response.Unauthorized(c, "刷新会话已失效") response.Unauthorized(c, "刷新会话已失效")
return return
} }
h.setRefreshCookie(c, result.RefreshToken, int(h.cfg.RefreshTokenTTL.Seconds()))
response.OK(c, gin.H{ response.OK(c, gin.H{
"accessToken": result.AccessToken, "accessToken": result.AccessToken,
"expiresAtMs": result.ExpiresAtMS, "expiresAtMs": result.ExpiresAtMS,

View File

@ -14,11 +14,23 @@ import (
) )
type AuthFlowService struct { type AuthFlowService struct {
store *repository.Store store authStore
auth *authcore.AuthService auth *authcore.AuthService
cfg config.Config cfg config.Config
} }
type authStore interface {
FindUserByUsername(username string) (*model.User, error)
FindUserByID(id uint) (*model.User, error)
CreateRefreshToken(token model.RefreshToken) error
RotateRefreshToken(oldTokenID uint, token model.RefreshToken) error
FindRefreshToken(hash string) (*model.RefreshToken, error)
RevokeRefreshToken(hash string) error
TouchUserLogin(id uint) error
CreateLoginLog(log model.LoginLog) error
ResetUserPassword(id uint, password string) error
}
type LoginInput struct { type LoginInput struct {
Username string Username string
Password string Password string
@ -34,7 +46,7 @@ type LoginResult struct {
Permissions []string Permissions []string
} }
func NewService(store *repository.Store, auth *authcore.AuthService, cfg config.Config) *AuthFlowService { func NewService(store authStore, auth *authcore.AuthService, cfg config.Config) *AuthFlowService {
return &AuthFlowService{store: store, auth: auth, cfg: cfg} return &AuthFlowService{store: store, auth: auth, cfg: cfg}
} }
@ -54,17 +66,11 @@ func (s *AuthFlowService) Login(input LoginInput) (LoginResult, error) {
if err != nil { if err != nil {
return LoginResult{}, err return LoginResult{}, err
} }
refreshToken, refreshHash, err := authcore.NewRefreshToken() refreshToken, refreshRecord, err := s.newRefreshToken(user.ID, input.IP, input.UserAgent)
if err != nil { if err != nil {
return LoginResult{}, err return LoginResult{}, err
} }
if err := s.store.CreateRefreshToken(model.RefreshToken{ if err := s.store.CreateRefreshToken(refreshRecord); err != nil {
UserID: user.ID,
TokenHash: refreshHash,
UserAgent: input.UserAgent,
IP: input.IP,
ExpiresAtMS: time.Now().UTC().Add(s.cfg.RefreshTokenTTL).UnixMilli(),
}); err != nil {
return LoginResult{}, err return LoginResult{}, err
} }
_ = s.store.TouchUserLogin(user.ID) _ = s.store.TouchUserLogin(user.ID)
@ -79,7 +85,7 @@ func (s *AuthFlowService) Logout(refreshToken string) error {
return s.store.RevokeRefreshToken(authcore.HashToken(refreshToken)) return s.store.RevokeRefreshToken(authcore.HashToken(refreshToken))
} }
func (s *AuthFlowService) Refresh(refreshToken string) (LoginResult, error) { func (s *AuthFlowService) Refresh(refreshToken string, input LoginInput) (LoginResult, error) {
if refreshToken == "" { if refreshToken == "" {
return LoginResult{}, errors.New("刷新会话不存在") return LoginResult{}, errors.New("刷新会话不存在")
} }
@ -96,7 +102,28 @@ func (s *AuthFlowService) Refresh(refreshToken string) (LoginResult, error) {
if err != nil { if err != nil {
return LoginResult{}, err return LoginResult{}, err
} }
return LoginResult{AccessToken: accessToken, ExpiresAtMS: expiresAt.UnixMilli(), User: *user, Permissions: permissions}, nil newRefreshToken, refreshRecord, err := s.newRefreshToken(user.ID, input.IP, input.UserAgent)
if err != nil {
return LoginResult{}, err
}
if err := s.store.RotateRefreshToken(stored.ID, refreshRecord); err != nil {
return LoginResult{}, err
}
return LoginResult{AccessToken: accessToken, RefreshToken: newRefreshToken, ExpiresAtMS: expiresAt.UnixMilli(), User: *user, Permissions: permissions}, nil
}
func (s *AuthFlowService) newRefreshToken(userID uint, ip string, userAgent string) (string, model.RefreshToken, error) {
refreshToken, refreshHash, err := authcore.NewRefreshToken()
if err != nil {
return "", model.RefreshToken{}, err
}
return refreshToken, model.RefreshToken{
UserID: userID,
TokenHash: refreshHash,
UserAgent: userAgent,
IP: ip,
ExpiresAtMS: time.Now().UTC().Add(s.cfg.RefreshTokenTTL).UnixMilli(),
}, nil
} }
func (s *AuthFlowService) Me(actor shared.Actor) (*model.User, []string, error) { func (s *AuthFlowService) Me(actor shared.Actor) (*model.User, []string, error) {

View File

@ -0,0 +1,162 @@
package auth
import (
"errors"
"testing"
"time"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/model"
authcore "hyapp-admin-server/internal/service"
)
type fakeAuthStore struct {
user model.User
tokens map[string]model.RefreshToken
nextTokenID uint
}
func newFakeAuthStore(t *testing.T) *fakeAuthStore {
t.Helper()
passwordHash, err := authcore.HashPassword("secret")
if err != nil {
t.Fatalf("hash password: %v", err)
}
return &fakeAuthStore{
user: model.User{
ID: 7,
Username: "admin",
Name: "Admin",
PasswordHash: passwordHash,
Status: model.UserStatusActive,
Roles: []model.Role{{
Permissions: []model.Permission{{Code: "user:view"}},
}},
},
tokens: make(map[string]model.RefreshToken),
}
}
func (s *fakeAuthStore) FindUserByUsername(username string) (*model.User, error) {
if username != s.user.Username {
return nil, errors.New("not found")
}
user := s.user
return &user, nil
}
func (s *fakeAuthStore) FindUserByID(id uint) (*model.User, error) {
if id != s.user.ID {
return nil, errors.New("not found")
}
user := s.user
return &user, nil
}
func (s *fakeAuthStore) CreateRefreshToken(token model.RefreshToken) error {
if token.ID == 0 {
s.nextTokenID++
token.ID = s.nextTokenID
}
s.tokens[token.TokenHash] = token
return nil
}
func (s *fakeAuthStore) RotateRefreshToken(oldTokenID uint, token model.RefreshToken) error {
if err := s.CreateRefreshToken(token); err != nil {
return err
}
nowMS := time.Now().UTC().UnixMilli()
for hash, existing := range s.tokens {
if existing.ID == oldTokenID && existing.RevokedAtMS == nil {
existing.RevokedAtMS = &nowMS
s.tokens[hash] = existing
return nil
}
}
return errors.New("old token not found")
}
func (s *fakeAuthStore) FindRefreshToken(hash string) (*model.RefreshToken, error) {
token, ok := s.tokens[hash]
if !ok || token.RevokedAtMS != nil || token.ExpiresAtMS <= time.Now().UTC().UnixMilli() {
return nil, errors.New("not found")
}
return &token, nil
}
func (s *fakeAuthStore) RevokeRefreshToken(hash string) error {
token, ok := s.tokens[hash]
if !ok {
return nil
}
nowMS := time.Now().UTC().UnixMilli()
token.RevokedAtMS = &nowMS
s.tokens[hash] = token
return nil
}
func (s *fakeAuthStore) TouchUserLogin(uint) error {
return nil
}
func (s *fakeAuthStore) CreateLoginLog(model.LoginLog) error {
return nil
}
func (s *fakeAuthStore) ResetUserPassword(id uint, password string) error {
if id != s.user.ID {
return errors.New("not found")
}
passwordHash, err := authcore.HashPassword(password)
if err != nil {
return err
}
s.user.PasswordHash = passwordHash
return nil
}
func (s *fakeAuthStore) activeRefreshTokenCount() int {
count := 0
nowMS := time.Now().UTC().UnixMilli()
for _, token := range s.tokens {
if token.RevokedAtMS == nil && token.ExpiresAtMS > nowMS {
count++
}
}
return count
}
func TestRefreshRotatesOnlyCurrentSessionAndKeepsMultiLogin(t *testing.T) {
store := newFakeAuthStore(t)
service := NewService(store, authcore.NewAuthService("test-secret", time.Hour), config.Config{RefreshTokenTTL: 7 * 24 * time.Hour})
first, err := service.Login(LoginInput{Username: "admin", Password: "secret", IP: "127.0.0.1", UserAgent: "browser-a"})
if err != nil {
t.Fatalf("first login: %v", err)
}
second, err := service.Login(LoginInput{Username: "admin", Password: "secret", IP: "127.0.0.2", UserAgent: "browser-b"})
if err != nil {
t.Fatalf("second login: %v", err)
}
if store.activeRefreshTokenCount() != 2 {
t.Fatalf("expected two active sessions after multi-login, got %d", store.activeRefreshTokenCount())
}
refreshedFirst, err := service.Refresh(first.RefreshToken, LoginInput{IP: "127.0.0.1", UserAgent: "browser-a"})
if err != nil {
t.Fatalf("refresh first session: %v", err)
}
if refreshedFirst.RefreshToken == "" || refreshedFirst.RefreshToken == first.RefreshToken {
t.Fatalf("refresh must rotate current refresh token")
}
if _, err := service.Refresh(first.RefreshToken, LoginInput{IP: "127.0.0.1", UserAgent: "browser-a"}); err == nil {
t.Fatalf("old refresh token must be revoked after rotation")
}
if store.activeRefreshTokenCount() != 2 {
t.Fatalf("rotation should keep one session per login point, got %d", store.activeRefreshTokenCount())
}
if _, err := service.Refresh(second.RefreshToken, LoginInput{IP: "127.0.0.2", UserAgent: "browser-b"}); err != nil {
t.Fatalf("second session should remain refreshable: %v", err)
}
}

View File

@ -3,6 +3,8 @@ package repository
import ( import (
"hyapp-admin-server/internal/model" "hyapp-admin-server/internal/model"
"time" "time"
"gorm.io/gorm"
) )
func (s *Store) FindUserByUsername(username string) (*model.User, error) { func (s *Store) FindUserByUsername(username string) (*model.User, error) {
@ -53,6 +55,18 @@ func (s *Store) CreateRefreshToken(token model.RefreshToken) error {
return s.db.Create(&token).Error return s.db.Create(&token).Error
} }
func (s *Store) RotateRefreshToken(oldTokenID uint, token model.RefreshToken) error {
nowMS := time.Now().UTC().UnixMilli()
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&token).Error; err != nil {
return err
}
return tx.Model(&model.RefreshToken{}).
Where("id = ? AND revoked_at_ms IS NULL", oldTokenID).
Update("revoked_at_ms", &nowMS).Error
})
}
func (s *Store) FindRefreshToken(hash string) (*model.RefreshToken, error) { func (s *Store) FindRefreshToken(hash string) (*model.RefreshToken, error) {
var token model.RefreshToken var token model.RefreshToken
err := s.db.Where("token_hash = ? AND revoked_at_ms IS NULL AND expires_at_ms > ?", hash, time.Now().UTC().UnixMilli()).First(&token).Error err := s.db.Where("token_hash = ? AND revoked_at_ms IS NULL AND expires_at_ms > ?", hash, time.Now().UTC().UnixMilli()).First(&token).Error

View File

@ -126,7 +126,7 @@ func newAuthService(repository *mysqltest.Repository, now *time.Time, ids []int6
} }
func thirdPartyRegistration(platform string) authdomain.ThirdPartyRegistration { func thirdPartyRegistration(platform string) authdomain.ThirdPartyRegistration {
// 三方登录是注册入口,测试默认提供完整最小资料,避免生成后台不可运营的空资料用户 // 部分测试需要覆盖注册快照字段,因此默认提供完整资料;完成态仍必须由 onboarding 流程推进
return authdomain.ThirdPartyRegistration{ return authdomain.ThirdPartyRegistration{
Username: "hy", Username: "hy",
Avatar: "https://cdn.example/avatar.png", Avatar: "https://cdn.example/avatar.png",
@ -192,8 +192,8 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
if !isNewUser || thirdToken.UserID != 900001 || thirdToken.DisplayUserID != "163000" || thirdToken.RefreshToken == "" || thirdToken.AccessToken == "" { if !isNewUser || thirdToken.UserID != 900001 || thirdToken.DisplayUserID != "163000" || thirdToken.RefreshToken == "" || thirdToken.AccessToken == "" {
t.Fatalf("unexpected third-party token: token=%+v isNew=%v", thirdToken, isNewUser) t.Fatalf("unexpected third-party token: token=%+v isNew=%v", thirdToken, isNewUser)
} }
if !thirdToken.ProfileCompleted || thirdToken.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) { if thirdToken.ProfileCompleted || thirdToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
t.Fatalf("new third-party user must be completed: %+v", thirdToken) t.Fatalf("new third-party user must require onboarding: %+v", thirdToken)
} }
if _, err := svc.LoginPassword(ctx, "163000", "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) { if _, err := svc.LoginPassword(ctx, "163000", "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) {
@ -293,8 +293,8 @@ func TestQuickCreateAccountCreatesCompletedPasswordLogin(t *testing.T) {
} }
} }
func TestRefreshTokenCarriesCompletedOnboardingStatus(t *testing.T) { func TestRefreshTokenCarriesProfileRequiredOnboardingStatus(t *testing.T) {
// gateway profile gate 依赖 access token 快照;注册资料齐全的新用户必须直接带出完成态。 // gateway profile gate 依赖 access token 快照;未完成 onboarding 的新用户刷新后仍必须保持资料待补态。
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG") seedCountry(t, repository, "SG")
@ -305,8 +305,8 @@ func TestRefreshTokenCarriesCompletedOnboardingStatus(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err) t.Fatalf("LoginThirdParty failed: %v", err)
} }
if !token.ProfileCompleted || token.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) { if token.ProfileCompleted || token.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
t.Fatalf("new token should be completed: %+v", token) t.Fatalf("new token should require onboarding: %+v", token)
} }
now = time.UnixMilli(3000) now = time.UnixMilli(3000)
@ -314,8 +314,8 @@ func TestRefreshTokenCarriesCompletedOnboardingStatus(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("RefreshToken failed: %v", err) t.Fatalf("RefreshToken failed: %v", err)
} }
if !refreshed.ProfileCompleted || refreshed.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) { if refreshed.ProfileCompleted || refreshed.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
t.Fatalf("refreshed token must carry completed onboarding: %+v", refreshed) t.Fatalf("refreshed token must carry profile-required onboarding: %+v", refreshed)
} }
} }
@ -446,7 +446,7 @@ func TestLoginIPRiskWorkerUsesDynamicCountryBlocks(t *testing.T) {
} }
} }
func TestIssueAccessTokenForSessionCarriesCompletedOnboardingStatusWithoutRotatingRefresh(t *testing.T) { func TestIssueAccessTokenForSessionCarriesProfileRequiredOnboardingStatusWithoutRotatingRefresh(t *testing.T) {
// 重新签发 access token 只能复用已有 sessionrefresh token 原文不可从数据库反查,也不应被静默轮换。 // 重新签发 access token 只能复用已有 sessionrefresh token 原文不可从数据库反查,也不应被静默轮换。
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
@ -464,8 +464,8 @@ func TestIssueAccessTokenForSessionCarriesCompletedOnboardingStatusWithoutRotati
if err != nil { if err != nil {
t.Fatalf("IssueAccessTokenForSession failed: %v", err) t.Fatalf("IssueAccessTokenForSession failed: %v", err)
} }
if !reissued.ProfileCompleted || reissued.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) || reissued.AccessToken == "" { if reissued.ProfileCompleted || reissued.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) || reissued.AccessToken == "" {
t.Fatalf("reissued token must carry completed onboarding: %+v", reissued) t.Fatalf("reissued token must carry profile-required onboarding: %+v", reissued)
} }
if reissued.SessionID != token.SessionID || reissued.RefreshToken != "" { if reissued.SessionID != token.SessionID || reissued.RefreshToken != "" {
t.Fatalf("access resign must reuse session and not mint refresh token: %+v", reissued) t.Fatalf("access resign must reuse session and not mint refresh token: %+v", reissued)
@ -517,8 +517,8 @@ func TestThirdPartyLoginOrRegister(t *testing.T) {
if !isNewUser || firstToken.UserID != 900002 || firstToken.DisplayUserID != "163000" { if !isNewUser || firstToken.UserID != 900002 || firstToken.DisplayUserID != "163000" {
t.Fatalf("unexpected first third-party result: token=%+v isNew=%v", firstToken, isNewUser) t.Fatalf("unexpected first third-party result: token=%+v isNew=%v", firstToken, isNewUser)
} }
if !firstToken.ProfileCompleted || firstToken.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) { if firstToken.ProfileCompleted || firstToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
t.Fatalf("first third-party registration must complete profile: %+v", firstToken) t.Fatalf("first third-party registration must require onboarding: %+v", firstToken)
} }
secondToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", authdomain.ThirdPartyRegistration{DeviceID: "dev-ios", Platform: "ios"}, authservice.Meta{RequestID: "req-third-second"}) secondToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", authdomain.ThirdPartyRegistration{DeviceID: "dev-ios", Platform: "ios"}, authservice.Meta{RequestID: "req-third-second"})
@ -535,29 +535,22 @@ func TestThirdPartyLoginOrRegister(t *testing.T) {
} }
} }
func TestThirdPartyRegisterRequiresProfileFields(t *testing.T) { func TestThirdPartyRegisterAllowsMinimalProfileFields(t *testing.T) {
// 三方首次注册必须一次性写齐后台列表和业务准入依赖的最小资料,不能再落 profile_required 空用户 // 三方首次登录只需要认证材料、设备和平台;公开资料由后续 onboarding 原子提交
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000) now := time.UnixMilli(1000)
svc := newAuthService(repository, &now, []int64{900012, 900013, 900014, 900015}, []string{"100012", "100013", "100014", "100015"}) svc := newAuthService(repository, &now, []int64{900012}, []string{"100012"})
base := thirdPartyRegistration("ios") token, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-minimal", authdomain.ThirdPartyRegistration{
cases := []struct { DeviceID: "dev-ios",
name string Platform: "ios",
registration authdomain.ThirdPartyRegistration }, authservice.Meta{})
}{ if err != nil {
{name: "username", registration: authdomain.ThirdPartyRegistration{Avatar: base.Avatar, Gender: base.Gender, Country: base.Country, DeviceID: base.DeviceID, Platform: base.Platform}}, t.Fatalf("LoginThirdParty with minimal registration failed: %v", err)
{name: "country", registration: authdomain.ThirdPartyRegistration{Username: base.Username, Avatar: base.Avatar, Gender: base.Gender, DeviceID: base.DeviceID, Platform: base.Platform}},
{name: "gender", registration: authdomain.ThirdPartyRegistration{Username: base.Username, Avatar: base.Avatar, Country: base.Country, DeviceID: base.DeviceID, Platform: base.Platform}},
{name: "avatar", registration: authdomain.ThirdPartyRegistration{Username: base.Username, Gender: base.Gender, Country: base.Country, DeviceID: base.DeviceID, Platform: base.Platform}},
} }
for _, tc := range cases { if !isNewUser || token.ProfileCompleted || token.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
_, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-missing-"+tc.name, tc.registration, authservice.Meta{}) t.Fatalf("minimal third-party registration must create profile-required user: token=%+v isNew=%v", token, isNewUser)
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("expected INVALID_ARGUMENT for missing %s, got %v", tc.name, err)
}
} }
} }

View File

@ -83,10 +83,6 @@ func (s *Service) LoginThirdParty(ctx context.Context, provider string, credenti
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err))) s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, false, err return authdomain.Token{}, false, err
} }
if err := validateThirdPartyRegistrationRequiredForCreate(registration); err != nil {
// 只有首次创建用户才要求注册资料完整;已有三方绑定登录不能被请求体资料缺失阻断。
return authdomain.Token{}, false, err
}
registration, err = s.resolveRegistrationCountry(ctx, registration) registration, err = s.resolveRegistrationCountry(ctx, registration)
if err != nil { if err != nil {
return authdomain.Token{}, false, err return authdomain.Token{}, false, err
@ -133,9 +129,6 @@ func (s *Service) createThirdPartyUser(ctx context.Context, provider string, pro
// 三方首次登录要同时生成 user_id 和默认短号,短号冲突时有限重试。 // 三方首次登录要同时生成 user_id 和默认短号,短号冲突时有限重试。
user, displayIdentity := s.newUserWithIdentity(attempt) user, displayIdentity := s.newUserWithIdentity(attempt)
user = applyThirdPartyRegistration(user, registration) user = applyThirdPartyRegistration(user, registration)
user.ProfileCompleted = true
user.ProfileCompletedAtMs = user.CreatedAtMs
user.OnboardingStatus = userdomain.OnboardingStatusCompleted
displayUserID, err := s.authRepository.AllocateDisplayUserID(ctx, registration.AppCode) displayUserID, err := s.authRepository.AllocateDisplayUserID(ctx, registration.AppCode)
if err != nil { if err != nil {
return authdomain.Token{}, false, err return authdomain.Token{}, false, err