增加接口判断

This commit is contained in:
zhx 2026-06-26 10:51:43 +08:00
parent 5890e3321a
commit bea9dcf1dd
12 changed files with 809 additions and 393 deletions

File diff suppressed because it is too large Load Diff

View File

@ -62,6 +62,17 @@ message RegisterThirdPartyRequest {
string timezone = 20; string timezone = 20;
} }
// CheckThirdPartyRegisteredRequest
message CheckThirdPartyRegisteredRequest {
RequestMeta meta = 1;
string provider = 2;
string credential = 3;
}
message CheckThirdPartyRegisteredResponse {
bool registered = 1;
}
// AuthResponse // AuthResponse
message AuthResponse { message AuthResponse {
AuthToken token = 1; AuthToken token = 1;
@ -208,6 +219,7 @@ service AuthService {
rpc LoginPassword(LoginPasswordRequest) returns (AuthResponse); rpc LoginPassword(LoginPasswordRequest) returns (AuthResponse);
rpc LoginThirdParty(LoginThirdPartyRequest) returns (AuthResponse); rpc LoginThirdParty(LoginThirdPartyRequest) returns (AuthResponse);
rpc RegisterThirdParty(RegisterThirdPartyRequest) returns (AuthResponse); rpc RegisterThirdParty(RegisterThirdPartyRequest) returns (AuthResponse);
rpc CheckThirdPartyRegistered(CheckThirdPartyRegisteredRequest) returns (CheckThirdPartyRegisteredResponse);
rpc SetPassword(SetPasswordRequest) returns (SetPasswordResponse); rpc SetPassword(SetPasswordRequest) returns (SetPasswordResponse);
rpc QuickCreateAccount(QuickCreateAccountRequest) returns (QuickCreateAccountResponse); rpc QuickCreateAccount(QuickCreateAccountRequest) returns (QuickCreateAccountResponse);
rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse); rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse);

View File

@ -22,6 +22,7 @@ const (
AuthService_LoginPassword_FullMethodName = "/hyapp.user.v1.AuthService/LoginPassword" AuthService_LoginPassword_FullMethodName = "/hyapp.user.v1.AuthService/LoginPassword"
AuthService_LoginThirdParty_FullMethodName = "/hyapp.user.v1.AuthService/LoginThirdParty" AuthService_LoginThirdParty_FullMethodName = "/hyapp.user.v1.AuthService/LoginThirdParty"
AuthService_RegisterThirdParty_FullMethodName = "/hyapp.user.v1.AuthService/RegisterThirdParty" AuthService_RegisterThirdParty_FullMethodName = "/hyapp.user.v1.AuthService/RegisterThirdParty"
AuthService_CheckThirdPartyRegistered_FullMethodName = "/hyapp.user.v1.AuthService/CheckThirdPartyRegistered"
AuthService_SetPassword_FullMethodName = "/hyapp.user.v1.AuthService/SetPassword" AuthService_SetPassword_FullMethodName = "/hyapp.user.v1.AuthService/SetPassword"
AuthService_QuickCreateAccount_FullMethodName = "/hyapp.user.v1.AuthService/QuickCreateAccount" AuthService_QuickCreateAccount_FullMethodName = "/hyapp.user.v1.AuthService/QuickCreateAccount"
AuthService_RefreshToken_FullMethodName = "/hyapp.user.v1.AuthService/RefreshToken" AuthService_RefreshToken_FullMethodName = "/hyapp.user.v1.AuthService/RefreshToken"
@ -42,6 +43,7 @@ type AuthServiceClient interface {
LoginPassword(ctx context.Context, in *LoginPasswordRequest, opts ...grpc.CallOption) (*AuthResponse, error) LoginPassword(ctx context.Context, in *LoginPasswordRequest, opts ...grpc.CallOption) (*AuthResponse, error)
LoginThirdParty(ctx context.Context, in *LoginThirdPartyRequest, opts ...grpc.CallOption) (*AuthResponse, error) LoginThirdParty(ctx context.Context, in *LoginThirdPartyRequest, opts ...grpc.CallOption) (*AuthResponse, error)
RegisterThirdParty(ctx context.Context, in *RegisterThirdPartyRequest, opts ...grpc.CallOption) (*AuthResponse, error) RegisterThirdParty(ctx context.Context, in *RegisterThirdPartyRequest, opts ...grpc.CallOption) (*AuthResponse, error)
CheckThirdPartyRegistered(ctx context.Context, in *CheckThirdPartyRegisteredRequest, opts ...grpc.CallOption) (*CheckThirdPartyRegisteredResponse, error)
SetPassword(ctx context.Context, in *SetPasswordRequest, opts ...grpc.CallOption) (*SetPasswordResponse, error) SetPassword(ctx context.Context, in *SetPasswordRequest, opts ...grpc.CallOption) (*SetPasswordResponse, error)
QuickCreateAccount(ctx context.Context, in *QuickCreateAccountRequest, opts ...grpc.CallOption) (*QuickCreateAccountResponse, error) QuickCreateAccount(ctx context.Context, in *QuickCreateAccountRequest, opts ...grpc.CallOption) (*QuickCreateAccountResponse, error)
RefreshToken(ctx context.Context, in *RefreshTokenRequest, opts ...grpc.CallOption) (*RefreshTokenResponse, error) RefreshToken(ctx context.Context, in *RefreshTokenRequest, opts ...grpc.CallOption) (*RefreshTokenResponse, error)
@ -91,6 +93,16 @@ func (c *authServiceClient) RegisterThirdParty(ctx context.Context, in *Register
return out, nil return out, nil
} }
func (c *authServiceClient) CheckThirdPartyRegistered(ctx context.Context, in *CheckThirdPartyRegisteredRequest, opts ...grpc.CallOption) (*CheckThirdPartyRegisteredResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CheckThirdPartyRegisteredResponse)
err := c.cc.Invoke(ctx, AuthService_CheckThirdPartyRegistered_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *authServiceClient) SetPassword(ctx context.Context, in *SetPasswordRequest, opts ...grpc.CallOption) (*SetPasswordResponse, error) { func (c *authServiceClient) SetPassword(ctx context.Context, in *SetPasswordRequest, opts ...grpc.CallOption) (*SetPasswordResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SetPasswordResponse) out := new(SetPasswordResponse)
@ -190,6 +202,7 @@ type AuthServiceServer interface {
LoginPassword(context.Context, *LoginPasswordRequest) (*AuthResponse, error) LoginPassword(context.Context, *LoginPasswordRequest) (*AuthResponse, error)
LoginThirdParty(context.Context, *LoginThirdPartyRequest) (*AuthResponse, error) LoginThirdParty(context.Context, *LoginThirdPartyRequest) (*AuthResponse, error)
RegisterThirdParty(context.Context, *RegisterThirdPartyRequest) (*AuthResponse, error) RegisterThirdParty(context.Context, *RegisterThirdPartyRequest) (*AuthResponse, error)
CheckThirdPartyRegistered(context.Context, *CheckThirdPartyRegisteredRequest) (*CheckThirdPartyRegisteredResponse, error)
SetPassword(context.Context, *SetPasswordRequest) (*SetPasswordResponse, error) SetPassword(context.Context, *SetPasswordRequest) (*SetPasswordResponse, error)
QuickCreateAccount(context.Context, *QuickCreateAccountRequest) (*QuickCreateAccountResponse, error) QuickCreateAccount(context.Context, *QuickCreateAccountRequest) (*QuickCreateAccountResponse, error)
RefreshToken(context.Context, *RefreshTokenRequest) (*RefreshTokenResponse, error) RefreshToken(context.Context, *RefreshTokenRequest) (*RefreshTokenResponse, error)
@ -218,6 +231,9 @@ func (UnimplementedAuthServiceServer) LoginThirdParty(context.Context, *LoginThi
func (UnimplementedAuthServiceServer) RegisterThirdParty(context.Context, *RegisterThirdPartyRequest) (*AuthResponse, error) { func (UnimplementedAuthServiceServer) RegisterThirdParty(context.Context, *RegisterThirdPartyRequest) (*AuthResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RegisterThirdParty not implemented") return nil, status.Errorf(codes.Unimplemented, "method RegisterThirdParty not implemented")
} }
func (UnimplementedAuthServiceServer) CheckThirdPartyRegistered(context.Context, *CheckThirdPartyRegisteredRequest) (*CheckThirdPartyRegisteredResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CheckThirdPartyRegistered not implemented")
}
func (UnimplementedAuthServiceServer) SetPassword(context.Context, *SetPasswordRequest) (*SetPasswordResponse, error) { func (UnimplementedAuthServiceServer) SetPassword(context.Context, *SetPasswordRequest) (*SetPasswordResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetPassword not implemented") return nil, status.Errorf(codes.Unimplemented, "method SetPassword not implemented")
} }
@ -320,6 +336,24 @@ func _AuthService_RegisterThirdParty_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _AuthService_CheckThirdPartyRegistered_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CheckThirdPartyRegisteredRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthServiceServer).CheckThirdPartyRegistered(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthService_CheckThirdPartyRegistered_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthServiceServer).CheckThirdPartyRegistered(ctx, req.(*CheckThirdPartyRegisteredRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AuthService_SetPassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { func _AuthService_SetPassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetPasswordRequest) in := new(SetPasswordRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@ -501,6 +535,10 @@ var AuthService_ServiceDesc = grpc.ServiceDesc{
MethodName: "RegisterThirdParty", MethodName: "RegisterThirdParty",
Handler: _AuthService_RegisterThirdParty_Handler, Handler: _AuthService_RegisterThirdParty_Handler,
}, },
{
MethodName: "CheckThirdPartyRegistered",
Handler: _AuthService_CheckThirdPartyRegistered_Handler,
},
{ {
MethodName: "SetPassword", MethodName: "SetPassword",
Handler: _AuthService_SetPassword_Handler, Handler: _AuthService_SetPassword_Handler,

View File

@ -6,7 +6,7 @@ Google 登录后Flutter 只暂存 Firebase ID token 和 Google 基本信息
资料页允许 pending third-party auth 进入,不要求 `_authService.isSignedIn`。用户点击完成后Flutter 调注册接口;后端校验 Firebase ID token、创建或补齐用户资料、返回 access/refresh token。Flutter 收到 token 后再写入现有 `AppAuthSession` 资料页允许 pending third-party auth 进入,不要求 `_authService.isSignedIn`。用户点击完成后Flutter 调注册接口;后端校验 Firebase ID token、创建或补齐用户资料、返回 access/refresh token。Flutter 收到 token 后再写入现有 `AppAuthSession`
旧接口 `POST /api/v1/auth/third-party/login` 仍保留兼容,不用于新的 Google 完整注册流程 旧接口 `POST /api/v1/auth/third-party/login` 仍保留兼容。新 Google 流程只能在检查接口返回 `registered=true` 后调用它拿 session不要在 `registered=false` 时调用它,避免旧接口创建 pending 用户
## 调用流程 ## 调用流程
@ -16,10 +16,64 @@ Google 登录后Flutter 只暂存 Firebase ID token 和 Google 基本信息
- `credential=<firebase_id_token>` - `credential=<firebase_id_token>`
- Google 返回的昵称、头像等基础信息 - Google 返回的昵称、头像等基础信息
- `device_id``platform``language``timezone` - `device_id``platform``language``timezone`
3. 进入资料页,用户补齐 `username``avatar``gender``country``invite_code` 3. 调注册检查接口。
4. 如果用户选择本地头像,先调用注册头像上传接口拿 URL。 4. 如果 `registered=true`,调用旧三方登录接口拿 session写入 `AppAuthSession` 后进首页。
5. 调 `POST /api/v1/auth/third-party/register` 5. 如果 `registered=false`,进入资料页,用户补齐 `username``avatar``gender``country``invite_code`
6. 成功后把返回的 token 写入 `AppAuthSession` 6. 如果用户选择本地头像,先调用注册头像上传接口拿 URL。
7. 调 `POST /api/v1/auth/third-party/register`
8. 成功后把返回的 token 写入 `AppAuthSession`
## 注册检查
地址:
```http
POST /api/v1/auth/third-party/registered/check
```
Headers
| Header | 必填 | 说明 |
| --- | --- | --- |
| `Content-Type` | 是 | `application/json` |
| `X-App-Code` | 否 | App 编码,默认 `lalu` |
| `X-App-Package` | 否 | 包名;后端可按包名解析 app_code。 |
参数:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `provider` | string | 是 | 固定传 `firebase`。 |
| `credential` | string | 是 | Firebase ID token。 |
请求示例:
```json
{
"provider": "firebase",
"credential": "firebase_id_token"
}
```
返回值:
```json
{
"code": 0,
"message": "ok",
"request_id": "req_xxx",
"data": {
"registered": true
}
}
```
Flutter 处理:
| `registered` | 处理 |
| --- | --- |
| `true` | 用户已完成注册;再调用 `POST /api/v1/auth/third-party/login` 获取 session。 |
| `false` | 用户未完成注册,或者只有旧流程留下的 pending 用户;进入资料页,最后调用注册接口。 |
## 注册头像上传 ## 注册头像上传

View File

@ -13,6 +13,7 @@ type UserAuthClient interface {
LoginPassword(ctx context.Context, req *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error) LoginPassword(ctx context.Context, req *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error)
LoginThirdParty(ctx context.Context, req *userv1.LoginThirdPartyRequest) (*userv1.AuthResponse, error) LoginThirdParty(ctx context.Context, req *userv1.LoginThirdPartyRequest) (*userv1.AuthResponse, error)
RegisterThirdParty(ctx context.Context, req *userv1.RegisterThirdPartyRequest) (*userv1.AuthResponse, error) RegisterThirdParty(ctx context.Context, req *userv1.RegisterThirdPartyRequest) (*userv1.AuthResponse, error)
CheckThirdPartyRegistered(ctx context.Context, req *userv1.CheckThirdPartyRegisteredRequest) (*userv1.CheckThirdPartyRegisteredResponse, error)
SetPassword(ctx context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error) SetPassword(ctx context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error)
QuickCreateAccount(ctx context.Context, req *userv1.QuickCreateAccountRequest) (*userv1.QuickCreateAccountResponse, error) QuickCreateAccount(ctx context.Context, req *userv1.QuickCreateAccountRequest) (*userv1.QuickCreateAccountResponse, error)
RefreshToken(ctx context.Context, req *userv1.RefreshTokenRequest) (*userv1.RefreshTokenResponse, error) RefreshToken(ctx context.Context, req *userv1.RefreshTokenRequest) (*userv1.RefreshTokenResponse, error)
@ -260,6 +261,10 @@ func (c *grpcUserAuthClient) RegisterThirdParty(ctx context.Context, req *userv1
return c.client.RegisterThirdParty(ctx, req) return c.client.RegisterThirdParty(ctx, req)
} }
func (c *grpcUserAuthClient) CheckThirdPartyRegistered(ctx context.Context, req *userv1.CheckThirdPartyRegisteredRequest) (*userv1.CheckThirdPartyRegisteredResponse, error) {
return c.client.CheckThirdPartyRegistered(ctx, req)
}
func (c *grpcUserAuthClient) SetPassword(ctx context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error) { func (c *grpcUserAuthClient) SetPassword(ctx context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error) {
return c.client.SetPassword(ctx, req) return c.client.SetPassword(ctx, req)
} }

View File

@ -364,6 +364,39 @@ func (h *Handler) registerThirdParty(writer http.ResponseWriter, request *http.R
httpkit.WriteOK(writer, request, authData(resp.GetToken(), &isNewUser)) httpkit.WriteOK(writer, request, authData(resp.GetToken(), &isNewUser))
} }
// checkThirdPartyRegistered 只校验三方授权是否已绑定完整注册用户,不创建用户和 session。
func (h *Handler) checkThirdPartyRegistered(writer http.ResponseWriter, request *http.Request) {
var body struct {
Provider string `json:"provider"`
Credential string `json:"credential"`
}
if !decode(writer, request, &body) {
return
}
if !h.allowPublicAuthRequest(writer, request, authRateInput{
operation: authOperationThirdParty,
provider: body.Provider,
}) {
return
}
if h.userClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.userClient.CheckThirdPartyRegistered(request.Context(), &userv1.CheckThirdPartyRegisteredRequest{
Meta: authRequestMeta(request, ""),
Provider: body.Provider,
Credential: body.Credential,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]bool{"registered": resp.GetRegistered()})
}
// setPassword 处理已登录用户首次设置密码。 // setPassword 处理已登录用户首次设置密码。
func (h *Handler) setPassword(writer http.ResponseWriter, request *http.Request) { func (h *Handler) setPassword(writer http.ResponseWriter, request *http.Request) {
var body struct { var body struct {

View File

@ -29,13 +29,14 @@ type Config struct {
} }
type AuthHandlers struct { type AuthHandlers struct {
LoginPassword http.HandlerFunc LoginPassword http.HandlerFunc
SetPassword http.HandlerFunc SetPassword http.HandlerFunc
QuickCreateAccount http.HandlerFunc QuickCreateAccount http.HandlerFunc
LoginThirdParty http.HandlerFunc LoginThirdParty http.HandlerFunc
RegisterThirdParty http.HandlerFunc RegisterThirdParty http.HandlerFunc
RefreshToken http.HandlerFunc CheckThirdPartyRegistered http.HandlerFunc
Logout http.HandlerFunc RefreshToken http.HandlerFunc
Logout http.HandlerFunc
} }
type AppHandlers struct { type AppHandlers struct {
@ -372,6 +373,7 @@ func (r routes) registerAuthRoutes() {
r.public("/auth/account/quick-create", http.MethodPost, h.QuickCreateAccount) r.public("/auth/account/quick-create", http.MethodPost, h.QuickCreateAccount)
r.public("/auth/third-party/login", "", h.LoginThirdParty) r.public("/auth/third-party/login", "", h.LoginThirdParty)
r.public("/auth/third-party/register", http.MethodPost, h.RegisterThirdParty) r.public("/auth/third-party/register", http.MethodPost, h.RegisterThirdParty)
r.public("/auth/third-party/registered/check", http.MethodPost, h.CheckThirdPartyRegistered)
r.public("/auth/token/refresh", "", h.RefreshToken) r.public("/auth/token/refresh", "", h.RefreshToken)
r.public("/auth/logout", "", h.Logout) r.public("/auth/logout", "", h.Logout)
} }

View File

@ -289,6 +289,7 @@ func (f *fakeRoomClient) UnfollowRoom(_ context.Context, req *roomv1.UnfollowRoo
type fakeUserAuthClient struct { type fakeUserAuthClient struct {
lastLoginThirdParty *userv1.LoginThirdPartyRequest lastLoginThirdParty *userv1.LoginThirdPartyRequest
lastRegisterThird *userv1.RegisterThirdPartyRequest lastRegisterThird *userv1.RegisterThirdPartyRequest
lastCheckThird *userv1.CheckThirdPartyRegisteredRequest
lastLoginPassword *userv1.LoginPasswordRequest lastLoginPassword *userv1.LoginPasswordRequest
lastSetPassword *userv1.SetPasswordRequest lastSetPassword *userv1.SetPasswordRequest
lastQuickCreate *userv1.QuickCreateAccountRequest lastQuickCreate *userv1.QuickCreateAccountRequest
@ -305,6 +306,7 @@ type fakeUserAuthClient struct {
whitelisted bool whitelisted bool
loginErr error loginErr error
loginThirdResp *userv1.AuthResponse loginThirdResp *userv1.AuthResponse
checkRegistered bool
} }
type fakeUserProfileClient struct { type fakeUserProfileClient struct {
@ -712,6 +714,14 @@ func (f *fakeUserAuthClient) RegisterThirdParty(_ context.Context, req *userv1.R
}, nil }, nil
} }
func (f *fakeUserAuthClient) CheckThirdPartyRegistered(_ context.Context, req *userv1.CheckThirdPartyRegisteredRequest) (*userv1.CheckThirdPartyRegisteredResponse, error) {
f.lastCheckThird = req
if f.loginErr != nil {
return nil, f.loginErr
}
return &userv1.CheckThirdPartyRegisteredResponse{Registered: f.checkRegistered}, nil
}
func (f *fakeUserAuthClient) SetPassword(_ context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error) { func (f *fakeUserAuthClient) SetPassword(_ context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error) {
f.lastSetPassword = req f.lastSetPassword = req
return &userv1.SetPasswordResponse{PasswordSet: true}, nil return &userv1.SetPasswordResponse{PasswordSet: true}, nil
@ -5535,6 +5545,41 @@ func TestThirdPartyRegisterUsesPublicEnvelopeAndReturnsCompletedToken(t *testing
} }
} }
func TestCheckThirdPartyRegisteredOnlyReturnsRegisteredFlag(t *testing.T) {
userClient := &fakeUserAuthClient{checkRegistered: true}
router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
body := []byte(`{"provider":"firebase","credential":"firebase-id-token-1"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/registered/check", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-auth-check")
request.Header.Set("X-Forwarded-For", "203.0.113.10")
request.Header.Set("User-Agent", "hyapp-test/1.0")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != httpkit.CodeOK || !ok || data["registered"] != true {
t.Fatalf("unexpected registered check response: %+v", response)
}
req := userClient.lastCheckThird
if req == nil {
t.Fatal("CheckThirdPartyRegistered request was not sent")
}
if req.GetProvider() != "firebase" || req.GetCredential() != "firebase-id-token-1" {
t.Fatalf("provider credential mismatch: %+v", req)
}
if userClient.lastLoginThirdParty != nil || userClient.lastRegisterThird != nil {
t.Fatalf("check endpoint must not login or register: login=%+v register=%+v", userClient.lastLoginThirdParty, userClient.lastRegisterThird)
}
}
func TestAuthClientIPPrefersForwardedPublicIPOverProxyRealIP(t *testing.T) { func TestAuthClientIPPrefersForwardedPublicIPOverProxyRealIP(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", nil) request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", nil)
request.RemoteAddr = "10.0.0.9:54123" request.RemoteAddr = "10.0.0.9:54123"

View File

@ -165,13 +165,14 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
return h.profileAPIHandler(jwtVerifier, handler) return h.profileAPIHandler(jwtVerifier, handler)
}, },
Auth: httproutes.AuthHandlers{ Auth: httproutes.AuthHandlers{
LoginPassword: h.loginPassword, LoginPassword: h.loginPassword,
SetPassword: h.setPassword, SetPassword: h.setPassword,
QuickCreateAccount: h.quickCreateAccount, QuickCreateAccount: h.quickCreateAccount,
LoginThirdParty: h.loginThirdParty, LoginThirdParty: h.loginThirdParty,
RegisterThirdParty: h.registerThirdParty, RegisterThirdParty: h.registerThirdParty,
RefreshToken: h.refreshToken, CheckThirdPartyRegistered: h.checkThirdPartyRegistered,
Logout: h.logout, RefreshToken: h.refreshToken,
Logout: h.logout,
}, },
App: httproutes.AppHandlers{ App: httproutes.AppHandlers{
ListRegistrationCountries: userAPI.ListRegistrationCountries, ListRegistrationCountries: userAPI.ListRegistrationCountries,

View File

@ -1085,6 +1085,51 @@ func TestRegisterThirdPartyCompletesExistingPendingUser(t *testing.T) {
assertUserRegisteredOutboxFact(t, repository, pendingToken.UserID, country.CountryID, region.RegionID, now.UnixMilli()) assertUserRegisteredOutboxFact(t, repository, pendingToken.UserID, country.CountryID, region.RegionID, now.UnixMilli())
} }
func TestCheckThirdPartyRegisteredReturnsOnlyCompletedRegistration(t *testing.T) {
// 检查接口只服务前端路由判断,不创建用户、不创建 sessionpending 用户仍然要进资料页。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000)
svc := newAuthService(repository, &now, []int64{900033}, []string{"100033"})
registered, err := svc.CheckThirdPartyRegistered(ctx, "wechat", "openid-check", authservice.Meta{RequestID: "req-check-empty"})
if err != nil {
t.Fatalf("CheckThirdPartyRegistered empty failed: %v", err)
}
if registered {
t.Fatalf("unbound third-party subject must not be registered")
}
pendingToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-check", authdomain.ThirdPartyRegistration{DeviceID: "dev-ios", Platform: "ios"}, authservice.Meta{RequestID: "req-check-pending"})
if err != nil {
t.Fatalf("seed pending LoginThirdParty failed: %v", err)
}
if !isNewUser || pendingToken.ProfileCompleted {
t.Fatalf("seed login must create pending user: token=%+v isNew=%v", pendingToken, isNewUser)
}
registered, err = svc.CheckThirdPartyRegistered(ctx, "wechat", "openid-check", authservice.Meta{RequestID: "req-check-pending"})
if err != nil {
t.Fatalf("CheckThirdPartyRegistered pending failed: %v", err)
}
if registered {
t.Fatalf("pending third-party subject must not be treated as registered")
}
register := thirdPartyRegistration("ios")
register.DeviceID = "dev-ios-register"
if _, _, err := svc.RegisterThirdParty(ctx, "wechat", "openid-check", register, authservice.Meta{RequestID: "req-check-register"}); err != nil {
t.Fatalf("RegisterThirdParty completion failed: %v", err)
}
registered, err = svc.CheckThirdPartyRegistered(ctx, "wechat", "openid-check", authservice.Meta{RequestID: "req-check-completed"})
if err != nil {
t.Fatalf("CheckThirdPartyRegistered completed failed: %v", err)
}
if !registered {
t.Fatalf("completed third-party subject must be registered")
}
}
func TestThirdPartyRegisterRejectsReusedDeviceID(t *testing.T) { func TestThirdPartyRegisterRejectsReusedDeviceID(t *testing.T) {
// 设备号只限制新账号注册;同一个 provider subject 的再次登录仍然可以创建新 session。 // 设备号只限制新账号注册;同一个 provider subject 的再次登录仍然可以创建新 session。
ctx := context.Background() ctx := context.Background()

View File

@ -158,6 +158,44 @@ func (s *Service) RegisterThirdParty(ctx context.Context, provider string, crede
return s.createRegisteredThirdPartyUser(ctx, provider, profile, registration, meta, maxAccountsPerDevice) return s.createRegisteredThirdPartyUser(ctx, provider, profile, registration, meta, maxAccountsPerDevice)
} }
// CheckThirdPartyRegistered 只校验 provider credential并判断当前 App 下是否已有完整注册用户。
// 它不创建用户、不创建 session专门给 Flutter 决定“进首页登录链路”还是“进资料页注册链路”。
func (s *Service) CheckThirdPartyRegistered(ctx context.Context, provider string, credential string, meta Meta) (bool, error) {
meta.AppCode = appcode.Normalize(meta.AppCode)
provider = strings.ToLower(strings.TrimSpace(provider))
credential = strings.TrimSpace(credential)
if provider == "" || credential == "" {
// 缺认证材料是客户端参数错误;不能返回 registered=false 掩盖真实调用错误。
return false, xerr.New(xerr.InvalidArgument, "provider and credential are required")
}
if s.authRepository == nil {
return false, xerr.New(xerr.Unavailable, "auth repository is not configured")
}
profile, err := s.thirdPartyVerifier.Verify(ctx, provider, credential)
if err != nil {
return false, authFailed()
}
identity, err := s.authRepository.FindThirdPartyIdentity(ctx, provider, profile.ProviderSubject)
if xerr.IsCode(err, xerr.NotFound) {
// 未绑定表示从注册视角还不能进首页;客户端应继续资料页和注册接口。
return false, nil
}
if err != nil {
return false, err
}
user, err := s.freshUser(ctx, identity.UserID, s.now().UnixMilli(), meta.RequestID)
if xerr.IsCode(err, xerr.NotFound) {
// 极少数脏数据只有三方绑定没有用户;按未完成注册处理,避免前端误进首页。
return false, nil
}
if err != nil {
return false, err
}
return user.ProfileCompleted, nil
}
func (s *Service) ensureDeviceCanRegisterThirdParty(ctx context.Context, registration authdomain.ThirdPartyRegistration, meta Meta, provider string) (int32, error) { func (s *Service) ensureDeviceCanRegisterThirdParty(ctx context.Context, registration authdomain.ThirdPartyRegistration, meta Meta, provider string) (int32, error) {
// 设备号限制只约束“创建新账号”路径;已绑定三方身份登录不经过这里,避免把正常跨设备登录误拦。 // 设备号限制只约束“创建新账号”路径;已绑定三方身份登录不经过这里,避免把正常跨设备登录误拦。
config, err := s.GetRegisterRiskConfig(ctx) config, err := s.GetRegisterRiskConfig(ctx)

View File

@ -171,6 +171,16 @@ func (s *Server) RegisterThirdParty(ctx context.Context, req *userv1.RegisterThi
return &userv1.AuthResponse{Token: toProtoToken(token), IsNewUser: isNewUser, ProfileCompleted: token.ProfileCompleted, OnboardingStatus: token.OnboardingStatus}, nil return &userv1.AuthResponse{Token: toProtoToken(token), IsNewUser: isNewUser, ProfileCompleted: token.ProfileCompleted, OnboardingStatus: token.OnboardingStatus}, nil
} }
// CheckThirdPartyRegistered 只检查 Firebase/三方身份是否已完成注册,不创建用户或 session。
func (s *Server) CheckThirdPartyRegistered(ctx context.Context, req *userv1.CheckThirdPartyRegisteredRequest) (*userv1.CheckThirdPartyRegisteredResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
registered, err := s.authSvc.CheckThirdPartyRegistered(ctx, req.GetProvider(), req.GetCredential(), authMeta(req.GetMeta()))
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.CheckThirdPartyRegisteredResponse{Registered: registered}, nil
}
// SetPassword 为已登录用户首次写入密码身份。 // SetPassword 为已登录用户首次写入密码身份。
func (s *Server) SetPassword(ctx context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error) { func (s *Server) SetPassword(ctx context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta()) ctx = contextWithApp(ctx, req.GetMeta())