feat(oidc): 上游邮箱已验证时跳过 choice 页直接登录注册
当前 OIDC 首次登录无条件创建 choose_account_action_required 的 pending session,即使 force_email_on_third_party_signup 关闭,前端仍然会强制 弹出"创建账号 / 绑定已有账号"的二选一界面,并展示内部合成邮箱 (oidc-xxx@oidc-connect.invalid),用户体验差。 本次复用已存在的 LoginOrRegisterVerifiedEmailOAuth 路径(原本仅供 github/google 使用),在以下条件全部满足时跳过 choice 页,直接 信任上游身份完成注册/登录: - force_email_on_third_party_signup = false - 邀请码模式未启用 - 上游声明 email_verified = true 且 compat_email 非空 - 本地不存在同邮箱已有账号 失败时(如邮箱后缀不在白名单、注册关闭等)自动回退到现有 choice 流程,行为完全向后兼容。 测试覆盖: - TestTryOIDCVerifiedEmailFastPathCreatesUserAndIdentity - TestTryOIDCVerifiedEmailFastPathSkippedWhenInvitationCodeRequired - TestTryOIDCVerifiedEmailFastPathSkippedWhenForceEmailEnabled
This commit is contained in:
parent
bd3d4d9a24
commit
39fe7aa0eb
@ -454,6 +454,28 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fast-path: when the upstream supplies a verified email and the deployment
|
||||||
|
// does not require any extra confirmation (force_email_on_third_party_signup
|
||||||
|
// disabled + invitation code disabled) and no local account collides with the
|
||||||
|
// upstream email, trust the upstream identity and finish login without
|
||||||
|
// rendering the choice page. Any failure falls through to the regular choice
|
||||||
|
// flow below.
|
||||||
|
if compatEmailUser == nil &&
|
||||||
|
strings.TrimSpace(compatEmail) != "" &&
|
||||||
|
emailVerified != nil && *emailVerified {
|
||||||
|
if h.tryOIDCVerifiedEmailFastPath(
|
||||||
|
c,
|
||||||
|
frontendCallback,
|
||||||
|
redirectTo,
|
||||||
|
identityRef,
|
||||||
|
compatEmail,
|
||||||
|
username,
|
||||||
|
upstreamClaims,
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if h.isForceEmailOnThirdPartySignup(c.Request.Context()) {
|
if h.isForceEmailOnThirdPartySignup(c.Request.Context()) {
|
||||||
if err := h.createOIDCOAuthChoicePendingSession(
|
if err := h.createOIDCOAuthChoicePendingSession(
|
||||||
c,
|
c,
|
||||||
@ -1190,3 +1212,66 @@ func oidcClearCookie(c *gin.Context, name string, secure bool) {
|
|||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tryOIDCVerifiedEmailFastPath attempts to skip the choice/pending page when
|
||||||
|
// the upstream identity carries a verified email and the deployment does not
|
||||||
|
// require any additional confirmation. It mirrors the behaviour already used
|
||||||
|
// for verified github/google logins via LoginOrRegisterVerifiedEmailOAuth.
|
||||||
|
//
|
||||||
|
// Returns true when the flow has completed (token issued and browser
|
||||||
|
// redirected); a false return tells the caller to fall through to the regular
|
||||||
|
// choice flow.
|
||||||
|
func (h *AuthHandler) tryOIDCVerifiedEmailFastPath(
|
||||||
|
c *gin.Context,
|
||||||
|
frontendCallback string,
|
||||||
|
redirectTo string,
|
||||||
|
identity service.PendingAuthIdentityKey,
|
||||||
|
compatEmail string,
|
||||||
|
username string,
|
||||||
|
upstreamClaims map[string]any,
|
||||||
|
) bool {
|
||||||
|
if h == nil || h.authService == nil || h.settingSvc == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
if h.isForceEmailOnThirdPartySignup(ctx) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if h.settingSvc.IsInvitationCodeEnabled(ctx) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
upstreamMetadata := make(map[string]any, len(upstreamClaims))
|
||||||
|
for k, v := range upstreamClaims {
|
||||||
|
upstreamMetadata[k] = v
|
||||||
|
}
|
||||||
|
input := service.EmailOAuthIdentityInput{
|
||||||
|
ProviderType: "oidc",
|
||||||
|
ProviderKey: strings.TrimSpace(identity.ProviderKey),
|
||||||
|
ProviderSubject: strings.TrimSpace(identity.ProviderSubject),
|
||||||
|
Email: strings.TrimSpace(strings.ToLower(compatEmail)),
|
||||||
|
EmailVerified: true,
|
||||||
|
Username: strings.TrimSpace(username),
|
||||||
|
DisplayName: pendingSessionStringValue(upstreamClaims, "suggested_display_name"),
|
||||||
|
AvatarURL: pendingSessionStringValue(upstreamClaims, "suggested_avatar_url"),
|
||||||
|
UpstreamMetadata: upstreamMetadata,
|
||||||
|
}
|
||||||
|
tokenPair, user, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithInvitation(ctx, input, "", "")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[OIDC OAuth] verified-email fast path skipped: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := h.ensureBackendModeAllowsUser(ctx, user); err != nil {
|
||||||
|
log.Printf("[OIDC OAuth] verified-email fast path blocked by backend mode: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment := url.Values{}
|
||||||
|
fragment.Set("access_token", tokenPair.AccessToken)
|
||||||
|
fragment.Set("refresh_token", tokenPair.RefreshToken)
|
||||||
|
fragment.Set("expires_in", fmt.Sprintf("%d", tokenPair.ExpiresIn))
|
||||||
|
fragment.Set("token_type", "Bearer")
|
||||||
|
fragment.Set("redirect", redirectTo)
|
||||||
|
redirectWithFragment(c, frontendCallback, fragment)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@ -926,6 +926,123 @@ func TestCompleteOIDCOAuthRegistrationRejectsIdentityOwnershipConflictBeforeUser
|
|||||||
require.Nil(t, storedSession.ConsumedAt)
|
require.Nil(t, storedSession.ConsumedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTryOIDCVerifiedEmailFastPathCreatesUserAndIdentity(t *testing.T) {
|
||||||
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
||||||
|
t.Cleanup(func() { _ = client.Close() })
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback", nil)
|
||||||
|
|
||||||
|
identity := service.PendingAuthIdentityKey{
|
||||||
|
ProviderType: "oidc",
|
||||||
|
ProviderKey: "https://issuer.example.com",
|
||||||
|
ProviderSubject: "fast-path-subject",
|
||||||
|
}
|
||||||
|
completed := handler.tryOIDCVerifiedEmailFastPath(
|
||||||
|
c,
|
||||||
|
"/auth/oidc/callback",
|
||||||
|
"/dashboard",
|
||||||
|
identity,
|
||||||
|
"fastpath@example.com",
|
||||||
|
"fastpath_user",
|
||||||
|
map[string]any{
|
||||||
|
"suggested_display_name": "Fast Path",
|
||||||
|
"suggested_avatar_url": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
require.True(t, completed)
|
||||||
|
require.Equal(t, http.StatusFound, recorder.Code)
|
||||||
|
|
||||||
|
location := recorder.Header().Get("Location")
|
||||||
|
require.Contains(t, location, "/auth/oidc/callback")
|
||||||
|
require.Contains(t, location, "access_token=")
|
||||||
|
require.Contains(t, location, "refresh_token=")
|
||||||
|
require.Contains(t, location, "token_type=Bearer")
|
||||||
|
|
||||||
|
user, err := client.User.Query().Where(dbuser.EmailEQ("fastpath@example.com")).Only(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "fastpath_user", user.Username)
|
||||||
|
require.Equal(t, "oidc", user.SignupSource)
|
||||||
|
|
||||||
|
identityCount, err := client.AuthIdentity.Query().Where(
|
||||||
|
authidentity.ProviderTypeEQ("oidc"),
|
||||||
|
authidentity.ProviderKeyEQ("https://issuer.example.com"),
|
||||||
|
authidentity.ProviderSubjectEQ("fast-path-subject"),
|
||||||
|
authidentity.UserIDEQ(user.ID),
|
||||||
|
).Count(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, identityCount)
|
||||||
|
|
||||||
|
pendingCount, err := client.PendingAuthSession.Query().Count(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, pendingCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTryOIDCVerifiedEmailFastPathSkippedWhenInvitationCodeRequired(t *testing.T) {
|
||||||
|
handler, client := newOAuthPendingFlowTestHandler(t, true)
|
||||||
|
t.Cleanup(func() { _ = client.Close() })
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback", nil)
|
||||||
|
|
||||||
|
identity := service.PendingAuthIdentityKey{
|
||||||
|
ProviderType: "oidc",
|
||||||
|
ProviderKey: "https://issuer.example.com",
|
||||||
|
ProviderSubject: "fast-path-skipped-invitation",
|
||||||
|
}
|
||||||
|
completed := handler.tryOIDCVerifiedEmailFastPath(
|
||||||
|
c,
|
||||||
|
"/auth/oidc/callback",
|
||||||
|
"/dashboard",
|
||||||
|
identity,
|
||||||
|
"invite-only@example.com",
|
||||||
|
"invite_only_user",
|
||||||
|
map[string]any{},
|
||||||
|
)
|
||||||
|
require.False(t, completed)
|
||||||
|
require.NotEqual(t, http.StatusFound, recorder.Code)
|
||||||
|
|
||||||
|
userCount, err := client.User.Query().Where(dbuser.EmailEQ("invite-only@example.com")).Count(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, userCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTryOIDCVerifiedEmailFastPathSkippedWhenForceEmailEnabled(t *testing.T) {
|
||||||
|
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
|
||||||
|
settingValues: map[string]string{
|
||||||
|
service.SettingKeyForceEmailOnThirdPartySignup: "true",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { _ = client.Close() })
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback", nil)
|
||||||
|
|
||||||
|
identity := service.PendingAuthIdentityKey{
|
||||||
|
ProviderType: "oidc",
|
||||||
|
ProviderKey: "https://issuer.example.com",
|
||||||
|
ProviderSubject: "fast-path-skipped-force-email",
|
||||||
|
}
|
||||||
|
completed := handler.tryOIDCVerifiedEmailFastPath(
|
||||||
|
c,
|
||||||
|
"/auth/oidc/callback",
|
||||||
|
"/dashboard",
|
||||||
|
identity,
|
||||||
|
"force-email@example.com",
|
||||||
|
"force_email_user",
|
||||||
|
map[string]any{},
|
||||||
|
)
|
||||||
|
require.False(t, completed)
|
||||||
|
|
||||||
|
userCount, err := client.User.Query().Where(dbuser.EmailEQ("force-email@example.com")).Count(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, userCount)
|
||||||
|
}
|
||||||
|
|
||||||
type oidcProviderFixture struct {
|
type oidcProviderFixture struct {
|
||||||
Subject string
|
Subject string
|
||||||
PreferredUsername string
|
PreferredUsername string
|
||||||
|
|||||||
@ -49,7 +49,7 @@ func (s *AuthService) loginOrRegisterVerifiedEmailOAuth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
providerType := normalizeOAuthSignupSource(input.ProviderType)
|
providerType := normalizeOAuthSignupSource(input.ProviderType)
|
||||||
if providerType != "github" && providerType != "google" {
|
if providerType != "github" && providerType != "google" && providerType != "oidc" {
|
||||||
return nil, nil, infraerrors.BadRequest("OAUTH_PROVIDER_INVALID", "oauth provider is invalid")
|
return nil, nil, infraerrors.BadRequest("OAUTH_PROVIDER_INVALID", "oauth provider is invalid")
|
||||||
}
|
}
|
||||||
providerKey := strings.TrimSpace(input.ProviderKey)
|
providerKey := strings.TrimSpace(input.ProviderKey)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user