diff --git a/server/admin/configs/config.tencent.example.yaml b/server/admin/configs/config.tencent.example.yaml index cc6d73df..4acfddce 100644 --- a/server/admin/configs/config.tencent.example.yaml +++ b/server/admin/configs/config.tencent.example.yaml @@ -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" diff --git a/server/admin/configs/config.yaml b/server/admin/configs/config.yaml index f342f18a..26d9be1e 100644 --- a/server/admin/configs/config.yaml +++ b/server/admin/configs/config.yaml @@ -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" diff --git a/server/admin/internal/config/config.go b/server/admin/internal/config/config.go index fc41004d..5cc4a248 100644 --- a/server/admin/internal/config/config.go +++ b/server/admin/internal/config/config.go @@ -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", diff --git a/server/admin/internal/config/config_test.go b/server/admin/internal/config/config_test.go index deb90d30..cf9b85ba 100644 --- a/server/admin/internal/config/config_test.go +++ b/server/admin/internal/config/config_test.go @@ -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 { diff --git a/server/admin/internal/modules/auth/handler.go b/server/admin/internal/modules/auth/handler.go index 92721afa..c6d20fd3 100644 --- a/server/admin/internal/modules/auth/handler.go +++ b/server/admin/internal/modules/auth/handler.go @@ -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, diff --git a/server/admin/internal/modules/auth/service.go b/server/admin/internal/modules/auth/service.go index d6ed78c5..2da8cf8b 100644 --- a/server/admin/internal/modules/auth/service.go +++ b/server/admin/internal/modules/auth/service.go @@ -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) { diff --git a/server/admin/internal/modules/auth/service_test.go b/server/admin/internal/modules/auth/service_test.go new file mode 100644 index 00000000..278053a2 --- /dev/null +++ b/server/admin/internal/modules/auth/service_test.go @@ -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) + } +} diff --git a/server/admin/internal/repository/auth_repository.go b/server/admin/internal/repository/auth_repository.go index 574220c9..c3f388b0 100644 --- a/server/admin/internal/repository/auth_repository.go +++ b/server/admin/internal/repository/auth_repository.go @@ -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 diff --git a/services/user-service/internal/service/auth/service_test.go b/services/user-service/internal/service/auth/service_test.go index aec09df6..ed697bf4 100644 --- a/services/user-service/internal/service/auth/service_test.go +++ b/services/user-service/internal/service/auth/service_test.go @@ -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) } } diff --git a/services/user-service/internal/service/auth/third_party.go b/services/user-service/internal/service/auth/third_party.go index 417c704c..9aa39269 100644 --- a/services/user-service/internal/service/auth/third_party.go +++ b/services/user-service/internal/service/auth/third_party.go @@ -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