Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 58 additions & 10 deletions backend/internal/application/auth/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type IdentityProviderView struct {
DefaultRole string
SubjectField string
EmailField string
EmailVerifiedField string
NameField string
AvatarField string
CreatedAt time.Time
Expand Down Expand Up @@ -101,6 +102,7 @@ type UpsertIdentityProviderInput struct {
DefaultRole string
SubjectField string
EmailField string
EmailVerifiedField string
NameField string
AvatarField string
}
Expand Down Expand Up @@ -350,10 +352,13 @@ func (s *Service) CompleteProviderLogin(
if subject == "" {
return nil, fmt.Errorf("provider subject is missing")
}
email := claimString(profile, provider.EmailField)
email, err := normalizeProviderEmail(claimString(profile, provider.EmailField))
if err != nil {
return nil, err
}
displayName := firstNonEmpty(claimString(profile, provider.NameField), email, subject)
avatarURL := claimString(profile, provider.AvatarField)
emailVerified := claimBool(profile, "email_verified", "verified_email")
emailVerified := resolveProviderEmailVerified(profile, *provider)

userItem, err := s.resolveProviderUser(ctx, *provider, subject, email, displayName, avatarURL, emailVerified, string(profileJSON), verifiedState.Intent)
if err != nil {
Expand Down Expand Up @@ -460,10 +465,12 @@ func (s *Service) CompleteProviderBind(
if subject == "" {
return nil, fmt.Errorf("provider subject is missing")
}
email := claimString(profile, provider.EmailField)
normalizedEmail := strings.TrimSpace(email)
normalizedEmail, err := normalizeProviderEmail(claimString(profile, provider.EmailField))
if err != nil {
return nil, err
}
providerDisplayName := firstNonEmpty(claimString(profile, provider.NameField), normalizedEmail, subject)
emailVerified := claimBool(profile, "email_verified", "verified_email")
emailVerified := resolveProviderEmailVerified(profile, *provider)
now := time.Now()

existingIdentity, err := s.repo.GetUserIdentityByProviderSubject(ctx, provider.ID, subject)
Expand Down Expand Up @@ -606,6 +613,7 @@ func (s *Service) normalizeProviderInput(input UpsertIdentityProviderInput, curr
DefaultRole: defaultRole,
SubjectField: firstNonEmpty(strings.TrimSpace(input.SubjectField), "sub"),
EmailField: firstNonEmpty(strings.TrimSpace(input.EmailField), "email"),
EmailVerifiedField: firstNonEmpty(strings.TrimSpace(input.EmailVerifiedField), "email_verified"),
NameField: firstNonEmpty(strings.TrimSpace(input.NameField), "name"),
AvatarField: firstNonEmpty(strings.TrimSpace(input.AvatarField), "picture"),
SortOrder: 100,
Expand Down Expand Up @@ -672,6 +680,7 @@ func toProviderView(item domainuser.IdentityProvider, includeSensitive bool) Ide
DefaultRole: item.DefaultRole,
SubjectField: item.SubjectField,
EmailField: item.EmailField,
EmailVerifiedField: item.EmailVerifiedField,
NameField: item.NameField,
AvatarField: item.AvatarField,
CreatedAt: item.CreatedAt,
Expand Down Expand Up @@ -701,6 +710,7 @@ func providerUpdateInput(provider *domainuser.IdentityProvider) repository.Updat
DefaultRole: &provider.DefaultRole,
SubjectField: &provider.SubjectField,
EmailField: &provider.EmailField,
EmailVerifiedField: &provider.EmailVerifiedField,
NameField: &provider.NameField,
AvatarField: &provider.AvatarField,
}
Expand Down Expand Up @@ -1100,10 +1110,13 @@ func (s *Service) resolveProviderUser(ctx context.Context, provider domainuser.I

cfg := s.cfg.Snapshot()
now := time.Now()
normalizedEmail := strings.TrimSpace(email)
normalizedEmail, err := normalizeProviderEmail(email)
if err != nil {
return nil, err
}
if cfg.AutoLinkVerifiedEmail && emailVerified && normalizedEmail != "" {
existingUser, findErr := s.repo.GetByEmail(ctx, normalizedEmail)
if findErr == nil && existingUser.EmailVerifiedAt != nil {
if findErr == nil {
if err = ensureProviderLoginUserActive(existingUser); err != nil {
return nil, err
}
Expand All @@ -1112,9 +1125,6 @@ func (s *Service) resolveProviderUser(ctx context.Context, provider domainuser.I
}
return existingUser, nil
}
if findErr == nil {
return nil, fmt.Errorf("email already exists; bind the provider before login")
}
if !errors.Is(findErr, repository.ErrNotFound) {
return nil, findErr
}
Expand Down Expand Up @@ -1357,6 +1367,44 @@ func claimString(profile map[string]interface{}, field string) string {
return conv.GetStringFromAny(value)
}

func normalizeProviderEmail(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", nil
}
normalized, err := normalizeRegistrationEmail(trimmed)
if err != nil {
return "", fmt.Errorf("provider email is invalid")
}
return normalized, nil
}

func resolveProviderEmailVerified(profile map[string]interface{}, provider domainuser.IdentityProvider) bool {
fields := make([]string, 0, 3)
if strings.TrimSpace(provider.EmailVerifiedField) != "" {
fields = append(fields, provider.EmailVerifiedField)
}
fields = append(fields, "email_verified", "verified_email")
return claimBool(profile, uniqueClaimFields(fields)...)
}

func uniqueClaimFields(fields []string) []string {
seen := make(map[string]struct{}, len(fields))
results := make([]string, 0, len(fields))
for _, field := range fields {
normalized := strings.TrimSpace(field)
if normalized == "" {
continue
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
results = append(results, normalized)
}
return results
}

func claimBool(profile map[string]interface{}, fields ...string) bool {
for _, field := range fields {
value, ok := claimValue(profile, field)
Expand Down
134 changes: 128 additions & 6 deletions backend/internal/application/auth/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import (

domainuser "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/user"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/pkg/secretbox"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/shared/requestmeta"
)

func boolPtr(value bool) *bool {
Expand Down Expand Up @@ -135,13 +137,11 @@ func TestResolveProviderUserLoginRequiresRegistrationEnabledForNewAccount(t *tes
}
}

func TestResolveProviderUserAutoLinksVerifiedEmailBeforeProvisioning(t *testing.T) {
now := time.Now()
func TestResolveProviderUserAutoLinksVerifiedProviderEmailBeforeProvisioning(t *testing.T) {
existing := &domainuser.User{
ID: 42,
Email: "verified@example.com",
EmailVerifiedAt: &now,
Status: domainuser.StatusActive,
ID: 42,
Email: "verified@example.com",
Status: domainuser.StatusActive,
}
repo := &providerLoginRepo{usersByEmail: map[string]*domainuser.User{existing.Email: existing}}
service := NewService(config.Config{JWTSecret: "test-secret", AutoLinkVerifiedEmail: true}, repo, nil)
Expand Down Expand Up @@ -170,6 +170,124 @@ func TestResolveProviderUserAutoLinksVerifiedEmailBeforeProvisioning(t *testing.
}
}

func TestResolveProviderUserNormalizesProviderEmailBeforeAutoLink(t *testing.T) {
existing := &domainuser.User{
ID: 42,
Email: "verified@example.com",
Status: domainuser.StatusActive,
}
repo := &providerLoginRepo{usersByEmail: map[string]*domainuser.User{existing.Email: existing}}
service := NewService(config.Config{JWTSecret: "test-secret", AutoLinkVerifiedEmail: true}, repo, nil)
provider := domainuser.IdentityProvider{
ID: 10,
Type: domainuser.IdentityProviderTypeOIDC,
Name: "Acme SSO",
Slug: "acme",
LoginEnabled: true,
RegistrationEnabled: false,
DefaultRole: domainuser.RoleUser,
}

userItem, err := service.resolveProviderUser(context.Background(), provider, "sub-1", "Verified@Example.com", "Verified User", "", true, `{"sub":"sub-1"}`, providerIntentLogin)
if err != nil {
t.Fatalf("expected normalized provider email to auto-link, got %v", err)
}
if userItem.ID != existing.ID {
t.Fatalf("expected existing user %d, got %d", existing.ID, userItem.ID)
}
if len(repo.identities) != 1 || repo.identities[0].Email != existing.Email {
t.Fatalf("expected normalized linked identity email, got %#v", repo.identities)
}
}

func TestResolveProviderEmailVerifiedUsesConfiguredField(t *testing.T) {
provider := domainuser.IdentityProvider{EmailVerifiedField: "verified"}
profile := map[string]interface{}{
"email": "verified@example.com",
"verified": true,
}

if !resolveProviderEmailVerified(profile, provider) {
t.Fatalf("expected configured email verified field to be recognized")
}
}

func TestCompleteProviderBindAllowsSameAccountWithoutProviderEmailVerification(t *testing.T) {
dataKey := "test-data-key"
clientSecret, err := secretbox.EncryptString(dataKey, "client-secret")
if err != nil {
t.Fatalf("encrypt client secret: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/token":
_, _ = w.Write([]byte(`{"access_token":"access-token","token_type":"Bearer"}`))
case "/userinfo":
if r.Header.Get("Authorization") != "Bearer access-token" {
t.Fatalf("unexpected authorization header %q", r.Header.Get("Authorization"))
}
_, _ = w.Write([]byte(`{"sub":"sub-1","email":"user@example.com","name":"Provider User"}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()

provider := &domainuser.IdentityProvider{
ID: 10,
Type: domainuser.IdentityProviderTypeOAuth2,
Name: "Acme SSO",
Slug: "acme",
LoginEnabled: true,
RegistrationEnabled: false,
ClientID: "client",
ClientSecret: clientSecret,
AuthURL: server.URL + "/auth",
TokenURL: server.URL + "/token",
UserInfoURL: server.URL + "/userinfo",
SubjectField: "sub",
EmailField: "email",
EmailVerifiedField: "email_verified",
NameField: "name",
AvatarField: "picture",
}
repo := &providerLoginRepo{
providersBySlug: map[string]*domainuser.IdentityProvider{"acme": provider},
usersByEmail: map[string]*domainuser.User{
"user@example.com": {ID: 42, Email: "user@example.com", Status: domainuser.StatusActive},
},
}
service := NewService(config.Config{
JWTSecret: "test-secret",
DataEncryptionKey: dataKey,
ThirdPartyLoginEnabled: true,
}, repo, nil)
redirectURI := "http://localhost/auth/callback?provider=acme"
codeVerifier := strings.Repeat("a", 43)
state, err := service.signProviderState(providerOAuthState{
Provider: "acme",
RedirectURI: redirectURI,
Intent: providerIntentBind,
CodeChallenge: providerCodeChallenge(codeVerifier),
ExpiresAt: time.Now().Add(time.Minute).Unix(),
})
if err != nil {
t.Fatalf("sign provider state: %v", err)
}

identity, err := service.CompleteProviderBind(context.Background(), 42, "acme", "code", state, redirectURI, codeVerifier, "request-id", requestmeta.SessionAuditContext{})
if err != nil {
t.Fatalf("expected manual bind to succeed without provider email verification claim, got %v", err)
}
if identity.EmailVerified {
t.Fatalf("expected linked identity to remain unverified")
}
if len(repo.identities) != 1 || repo.identities[0].UserID != 42 || repo.identities[0].EmailVerified {
t.Fatalf("expected identity linked to current user without verified email, got %#v", repo.identities)
}
}

func TestResolveProviderUserRejectsInactiveBoundUserWithoutUpdatingIdentity(t *testing.T) {
repo := &providerLoginRepo{
usersByID: map[uint]*domainuser.User{
Expand Down Expand Up @@ -549,6 +667,10 @@ func (r *providerLoginRepo) UpdateUserIdentityLogin(ctx context.Context, identit
return nil
}

func (r *providerLoginRepo) RecordAuthEvent(ctx context.Context, userID uint, requestID string, eventType string, result string, reason string, clientIP string, userAgent string, detailJSON string) error {
return nil
}

func (r *providerLoginRepo) DeleteAccountHard(ctx context.Context, userID uint) error {
r.deletedUserID = userID
return nil
Expand Down
1 change: 1 addition & 0 deletions backend/internal/domain/user/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ type IdentityProvider struct {
DefaultRole string
SubjectField string
EmailField string
EmailVerifiedField string
NameField string
AvatarField string
SortOrder int
Expand Down
1 change: 1 addition & 0 deletions backend/internal/infra/persistence/models/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ type AuthIdentityProvider struct {
DefaultRole string `gorm:"size:32;not null;default:'user';comment:自动创建用户默认角色"`
SubjectField string `gorm:"size:64;not null;default:'sub';comment:用户唯一ID字段"`
EmailField string `gorm:"size:64;not null;default:'email';comment:邮箱字段"`
EmailVerifiedField string `gorm:"size:64;not null;default:'email_verified';comment:邮箱验证状态字段"`
NameField string `gorm:"size:64;not null;default:'name';comment:昵称字段"`
AvatarField string `gorm:"size:64;not null;default:'picture';comment:头像字段"`
SortOrder int `gorm:"not null;default:100;index:idx_identity_providers_sort_order;comment:展示顺序"`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1219,6 +1219,9 @@ func identityProviderUpdates(input repository.UpdateIdentityProviderInput) map[s
if input.EmailField != nil {
updates["email_field"] = *input.EmailField
}
if input.EmailVerifiedField != nil {
updates["email_verified_field"] = *input.EmailVerifiedField
}
if input.NameField != nil {
updates["name_field"] = *input.NameField
}
Expand Down Expand Up @@ -1855,6 +1858,7 @@ func toDomainIdentityProvider(item model.AuthIdentityProvider) *domainuser.Ident
DefaultRole: item.DefaultRole,
SubjectField: item.SubjectField,
EmailField: item.EmailField,
EmailVerifiedField: item.EmailVerifiedField,
NameField: item.NameField,
AvatarField: item.AvatarField,
SortOrder: item.SortOrder,
Expand Down Expand Up @@ -1893,6 +1897,7 @@ func toModelIdentityProvider(item *domainuser.IdentityProvider) *model.AuthIdent
DefaultRole: item.DefaultRole,
SubjectField: item.SubjectField,
EmailField: item.EmailField,
EmailVerifiedField: item.EmailVerifiedField,
NameField: item.NameField,
AvatarField: item.AvatarField,
SortOrder: item.SortOrder,
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/repository/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ type UpdateIdentityProviderInput struct {
DefaultRole *string
SubjectField *string
EmailField *string
EmailVerifiedField *string
NameField *string
AvatarField *string
}
Expand All @@ -107,6 +108,7 @@ func (input UpdateIdentityProviderInput) IsZero() bool {
input.DefaultRole == nil &&
input.SubjectField == nil &&
input.EmailField == nil &&
input.EmailVerifiedField == nil &&
input.NameField == nil &&
input.AvatarField == nil
}
Expand Down
4 changes: 4 additions & 0 deletions backend/internal/transport/http/auth/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ type IdentityProviderResponse struct {
DefaultRole string `json:"defaultRole"`
SubjectField string `json:"subjectField"`
EmailField string `json:"emailField"`
EmailVerifiedField string `json:"emailVerifiedField"`
NameField string `json:"nameField"`
AvatarField string `json:"avatarField"`
CreatedAt time.Time `json:"createdAt"`
Expand Down Expand Up @@ -214,6 +215,7 @@ type UpsertIdentityProviderRequest struct {
DefaultRole string `json:"defaultRole" binding:"omitempty,oneof=user admin superadmin"`
SubjectField string `json:"subjectField" binding:"omitempty,max=64"`
EmailField string `json:"emailField" binding:"omitempty,max=64"`
EmailVerifiedField string `json:"emailVerifiedField" binding:"omitempty,max=64"`
NameField string `json:"nameField" binding:"omitempty,max=64"`
AvatarField string `json:"avatarField" binding:"omitempty,max=64"`
}
Expand Down Expand Up @@ -602,6 +604,7 @@ func toIdentityProviderResponse(item appauth.IdentityProviderView) IdentityProvi
DefaultRole: item.DefaultRole,
SubjectField: item.SubjectField,
EmailField: item.EmailField,
EmailVerifiedField: item.EmailVerifiedField,
NameField: item.NameField,
AvatarField: item.AvatarField,
CreatedAt: item.CreatedAt,
Expand Down Expand Up @@ -630,6 +633,7 @@ func toUpsertIdentityProviderInput(req UpsertIdentityProviderRequest, actorRole
DefaultRole: req.DefaultRole,
SubjectField: req.SubjectField,
EmailField: req.EmailField,
EmailVerifiedField: req.EmailVerifiedField,
NameField: req.NameField,
AvatarField: req.AvatarField,
}
Expand Down
Loading
Loading