更新三方登录和 admin token刷新问题
This commit is contained in:
parent
ce3ffe666d
commit
fc082d2c00
@ -18,7 +18,7 @@ migrations:
|
||||
dir: "/opt/hyapp/admin-server/migrations"
|
||||
allow_checksum_repair: false
|
||||
jwt_secret: "REPLACE_WITH_AT_LEAST_32_RANDOM_CHARS"
|
||||
access_token_ttl: "30m"
|
||||
access_token_ttl: "12h"
|
||||
refresh_token_ttl: "168h"
|
||||
cors_allowed_origins:
|
||||
- "http://43.128.56.40"
|
||||
|
||||
@ -18,7 +18,7 @@ migrations:
|
||||
# 本地开发阶段允许历史 migration 文件被修改后修复 checksum;staging/prod 必须关闭。
|
||||
allow_checksum_repair: true
|
||||
jwt_secret: "dev-shared-secret"
|
||||
access_token_ttl: "30m"
|
||||
access_token_ttl: "12h"
|
||||
refresh_token_ttl: "168h"
|
||||
cors_allowed_origins:
|
||||
- "http://localhost:7001"
|
||||
|
||||
@ -129,7 +129,7 @@ func Default() Config {
|
||||
AllowChecksumRepair: true,
|
||||
},
|
||||
JWTSecret: "dev-shared-secret",
|
||||
AccessTokenTTL: 30 * time.Minute,
|
||||
AccessTokenTTL: 12 * time.Hour,
|
||||
RefreshTokenTTL: 7 * 24 * time.Hour,
|
||||
CORSAllowedOrigins: []string{
|
||||
"http://localhost:7001",
|
||||
|
||||
@ -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" {
|
||||
t.Fatalf("UserMySQLDSN = %q", cfg.UserMySQLDSN)
|
||||
}
|
||||
if cfg.AccessTokenTTL != 30*time.Minute {
|
||||
if cfg.AccessTokenTTL != 12*time.Hour {
|
||||
t.Fatalf("AccessTokenTTL = %s", cfg.AccessTokenTTL)
|
||||
}
|
||||
if cfg.RefreshTokenTTL != 7*24*time.Hour {
|
||||
|
||||
@ -65,12 +65,16 @@ func (h *Handler) Refresh(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.service.Refresh(token)
|
||||
result, err := h.service.Refresh(token, LoginInput{
|
||||
IP: c.ClientIP(),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
})
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "刷新会话已失效")
|
||||
return
|
||||
}
|
||||
|
||||
h.setRefreshCookie(c, result.RefreshToken, int(h.cfg.RefreshTokenTTL.Seconds()))
|
||||
response.OK(c, gin.H{
|
||||
"accessToken": result.AccessToken,
|
||||
"expiresAtMs": result.ExpiresAtMS,
|
||||
|
||||
@ -14,11 +14,23 @@ import (
|
||||
)
|
||||
|
||||
type AuthFlowService struct {
|
||||
store *repository.Store
|
||||
store authStore
|
||||
auth *authcore.AuthService
|
||||
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 {
|
||||
Username string
|
||||
Password string
|
||||
@ -34,7 +46,7 @@ type LoginResult struct {
|
||||
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}
|
||||
}
|
||||
|
||||
@ -54,17 +66,11 @@ func (s *AuthFlowService) Login(input LoginInput) (LoginResult, error) {
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
refreshToken, refreshHash, err := authcore.NewRefreshToken()
|
||||
refreshToken, refreshRecord, err := s.newRefreshToken(user.ID, input.IP, input.UserAgent)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
if err := s.store.CreateRefreshToken(model.RefreshToken{
|
||||
UserID: user.ID,
|
||||
TokenHash: refreshHash,
|
||||
UserAgent: input.UserAgent,
|
||||
IP: input.IP,
|
||||
ExpiresAtMS: time.Now().UTC().Add(s.cfg.RefreshTokenTTL).UnixMilli(),
|
||||
}); err != nil {
|
||||
if err := s.store.CreateRefreshToken(refreshRecord); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
_ = s.store.TouchUserLogin(user.ID)
|
||||
@ -79,7 +85,7 @@ func (s *AuthFlowService) Logout(refreshToken string) error {
|
||||
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 == "" {
|
||||
return LoginResult{}, errors.New("刷新会话不存在")
|
||||
}
|
||||
@ -96,7 +102,28 @@ func (s *AuthFlowService) Refresh(refreshToken string) (LoginResult, error) {
|
||||
if err != nil {
|
||||
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) {
|
||||
|
||||
162
server/admin/internal/modules/auth/service_test.go
Normal file
162
server/admin/internal/modules/auth/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,8 @@ package repository
|
||||
import (
|
||||
"hyapp-admin-server/internal/model"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
|
||||
@ -126,7 +126,7 @@ func newAuthService(repository *mysqltest.Repository, now *time.Time, ids []int6
|
||||
}
|
||||
|
||||
func thirdPartyRegistration(platform string) authdomain.ThirdPartyRegistration {
|
||||
// 三方登录是注册入口,测试默认提供完整最小资料,避免生成后台不可运营的空资料用户。
|
||||
// 部分测试需要覆盖注册快照字段,因此默认提供完整资料;完成态仍必须由 onboarding 流程推进。
|
||||
return authdomain.ThirdPartyRegistration{
|
||||
Username: "hy",
|
||||
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 == "" {
|
||||
t.Fatalf("unexpected third-party token: token=%+v isNew=%v", thirdToken, isNewUser)
|
||||
}
|
||||
if !thirdToken.ProfileCompleted || thirdToken.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) {
|
||||
t.Fatalf("new third-party user must be completed: %+v", thirdToken)
|
||||
if thirdToken.ProfileCompleted || thirdToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
|
||||
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) {
|
||||
@ -293,8 +293,8 @@ func TestQuickCreateAccountCreatesCompletedPasswordLogin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokenCarriesCompletedOnboardingStatus(t *testing.T) {
|
||||
// gateway profile gate 依赖 access token 快照;注册资料齐全的新用户必须直接带出完成态。
|
||||
func TestRefreshTokenCarriesProfileRequiredOnboardingStatus(t *testing.T) {
|
||||
// gateway profile gate 依赖 access token 快照;未完成 onboarding 的新用户刷新后仍必须保持资料待补态。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "SG")
|
||||
@ -305,8 +305,8 @@ func TestRefreshTokenCarriesCompletedOnboardingStatus(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoginThirdParty failed: %v", err)
|
||||
}
|
||||
if !token.ProfileCompleted || token.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) {
|
||||
t.Fatalf("new token should be completed: %+v", token)
|
||||
if token.ProfileCompleted || token.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
|
||||
t.Fatalf("new token should require onboarding: %+v", token)
|
||||
}
|
||||
|
||||
now = time.UnixMilli(3000)
|
||||
@ -314,8 +314,8 @@ func TestRefreshTokenCarriesCompletedOnboardingStatus(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshToken failed: %v", err)
|
||||
}
|
||||
if !refreshed.ProfileCompleted || refreshed.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) {
|
||||
t.Fatalf("refreshed token must carry completed onboarding: %+v", refreshed)
|
||||
if refreshed.ProfileCompleted || refreshed.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
|
||||
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 只能复用已有 session;refresh token 原文不可从数据库反查,也不应被静默轮换。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
@ -464,8 +464,8 @@ func TestIssueAccessTokenForSessionCarriesCompletedOnboardingStatusWithoutRotati
|
||||
if err != nil {
|
||||
t.Fatalf("IssueAccessTokenForSession failed: %v", err)
|
||||
}
|
||||
if !reissued.ProfileCompleted || reissued.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) || reissued.AccessToken == "" {
|
||||
t.Fatalf("reissued token must carry completed onboarding: %+v", reissued)
|
||||
if reissued.ProfileCompleted || reissued.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) || reissued.AccessToken == "" {
|
||||
t.Fatalf("reissued token must carry profile-required onboarding: %+v", reissued)
|
||||
}
|
||||
if reissued.SessionID != token.SessionID || reissued.RefreshToken != "" {
|
||||
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" {
|
||||
t.Fatalf("unexpected first third-party result: token=%+v isNew=%v", firstToken, isNewUser)
|
||||
}
|
||||
if !firstToken.ProfileCompleted || firstToken.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) {
|
||||
t.Fatalf("first third-party registration must complete profile: %+v", firstToken)
|
||||
if firstToken.ProfileCompleted || firstToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
|
||||
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"})
|
||||
@ -535,29 +535,22 @@ func TestThirdPartyLoginOrRegister(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestThirdPartyRegisterRequiresProfileFields(t *testing.T) {
|
||||
// 三方首次注册必须一次性写齐后台列表和业务准入依赖的最小资料,不能再落 profile_required 空用户。
|
||||
func TestThirdPartyRegisterAllowsMinimalProfileFields(t *testing.T) {
|
||||
// 三方首次登录只需要认证材料、设备和平台;公开资料由后续 onboarding 原子提交。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "SG")
|
||||
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")
|
||||
cases := []struct {
|
||||
name string
|
||||
registration authdomain.ThirdPartyRegistration
|
||||
}{
|
||||
{name: "username", registration: authdomain.ThirdPartyRegistration{Avatar: base.Avatar, Gender: base.Gender, Country: base.Country, DeviceID: base.DeviceID, Platform: base.Platform}},
|
||||
{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}},
|
||||
token, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-minimal", authdomain.ThirdPartyRegistration{
|
||||
DeviceID: "dev-ios",
|
||||
Platform: "ios",
|
||||
}, authservice.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("LoginThirdParty with minimal registration failed: %v", err)
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-missing-"+tc.name, tc.registration, authservice.Meta{})
|
||||
if !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||
t.Fatalf("expected INVALID_ARGUMENT for missing %s, got %v", tc.name, err)
|
||||
}
|
||||
if !isNewUser || token.ProfileCompleted || token.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
|
||||
t.Fatalf("minimal third-party registration must create profile-required user: token=%+v isNew=%v", token, isNewUser)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)))
|
||||
return authdomain.Token{}, false, err
|
||||
}
|
||||
if err := validateThirdPartyRegistrationRequiredForCreate(registration); err != nil {
|
||||
// 只有首次创建用户才要求注册资料完整;已有三方绑定登录不能被请求体资料缺失阻断。
|
||||
return authdomain.Token{}, false, err
|
||||
}
|
||||
registration, err = s.resolveRegistrationCountry(ctx, registration)
|
||||
if err != nil {
|
||||
return authdomain.Token{}, false, err
|
||||
@ -133,9 +129,6 @@ func (s *Service) createThirdPartyUser(ctx context.Context, provider string, pro
|
||||
// 三方首次登录要同时生成 user_id 和默认短号,短号冲突时有限重试。
|
||||
user, displayIdentity := s.newUserWithIdentity(attempt)
|
||||
user = applyThirdPartyRegistration(user, registration)
|
||||
user.ProfileCompleted = true
|
||||
user.ProfileCompletedAtMs = user.CreatedAtMs
|
||||
user.OnboardingStatus = userdomain.OnboardingStatusCompleted
|
||||
displayUserID, err := s.authRepository.AllocateDisplayUserID(ctx, registration.AppCode)
|
||||
if err != nil {
|
||||
return authdomain.Token{}, false, err
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user