Skip to content

Commit f78d44b

Browse files
authored
Merge pull request #24 from DEEIX-AI/email_relate
feat: add emailVerifiedField to identity provider model and update related logic
2 parents 85e2477 + 73a0939 commit f78d44b

23 files changed

Lines changed: 431 additions & 79 deletions

File tree

backend/internal/application/auth/provider.go

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ type IdentityProviderView struct {
5656
DefaultRole string
5757
SubjectField string
5858
EmailField string
59+
EmailVerifiedField string
5960
NameField string
6061
AvatarField string
6162
CreatedAt time.Time
@@ -101,6 +102,7 @@ type UpsertIdentityProviderInput struct {
101102
DefaultRole string
102103
SubjectField string
103104
EmailField string
105+
EmailVerifiedField string
104106
NameField string
105107
AvatarField string
106108
}
@@ -350,10 +352,13 @@ func (s *Service) CompleteProviderLogin(
350352
if subject == "" {
351353
return nil, fmt.Errorf("provider subject is missing")
352354
}
353-
email := claimString(profile, provider.EmailField)
355+
email, err := normalizeProviderEmail(claimString(profile, provider.EmailField))
356+
if err != nil {
357+
return nil, err
358+
}
354359
displayName := firstNonEmpty(claimString(profile, provider.NameField), email, subject)
355360
avatarURL := claimString(profile, provider.AvatarField)
356-
emailVerified := claimBool(profile, "email_verified", "verified_email")
361+
emailVerified := resolveProviderEmailVerified(profile, *provider)
357362

358363
userItem, err := s.resolveProviderUser(ctx, *provider, subject, email, displayName, avatarURL, emailVerified, string(profileJSON), verifiedState.Intent)
359364
if err != nil {
@@ -460,10 +465,12 @@ func (s *Service) CompleteProviderBind(
460465
if subject == "" {
461466
return nil, fmt.Errorf("provider subject is missing")
462467
}
463-
email := claimString(profile, provider.EmailField)
464-
normalizedEmail := strings.TrimSpace(email)
468+
normalizedEmail, err := normalizeProviderEmail(claimString(profile, provider.EmailField))
469+
if err != nil {
470+
return nil, err
471+
}
465472
providerDisplayName := firstNonEmpty(claimString(profile, provider.NameField), normalizedEmail, subject)
466-
emailVerified := claimBool(profile, "email_verified", "verified_email")
473+
emailVerified := resolveProviderEmailVerified(profile, *provider)
467474
now := time.Now()
468475

469476
existingIdentity, err := s.repo.GetUserIdentityByProviderSubject(ctx, provider.ID, subject)
@@ -606,6 +613,7 @@ func (s *Service) normalizeProviderInput(input UpsertIdentityProviderInput, curr
606613
DefaultRole: defaultRole,
607614
SubjectField: firstNonEmpty(strings.TrimSpace(input.SubjectField), "sub"),
608615
EmailField: firstNonEmpty(strings.TrimSpace(input.EmailField), "email"),
616+
EmailVerifiedField: firstNonEmpty(strings.TrimSpace(input.EmailVerifiedField), "email_verified"),
609617
NameField: firstNonEmpty(strings.TrimSpace(input.NameField), "name"),
610618
AvatarField: firstNonEmpty(strings.TrimSpace(input.AvatarField), "picture"),
611619
SortOrder: 100,
@@ -672,6 +680,7 @@ func toProviderView(item domainuser.IdentityProvider, includeSensitive bool) Ide
672680
DefaultRole: item.DefaultRole,
673681
SubjectField: item.SubjectField,
674682
EmailField: item.EmailField,
683+
EmailVerifiedField: item.EmailVerifiedField,
675684
NameField: item.NameField,
676685
AvatarField: item.AvatarField,
677686
CreatedAt: item.CreatedAt,
@@ -701,6 +710,7 @@ func providerUpdateInput(provider *domainuser.IdentityProvider) repository.Updat
701710
DefaultRole: &provider.DefaultRole,
702711
SubjectField: &provider.SubjectField,
703712
EmailField: &provider.EmailField,
713+
EmailVerifiedField: &provider.EmailVerifiedField,
704714
NameField: &provider.NameField,
705715
AvatarField: &provider.AvatarField,
706716
}
@@ -1100,10 +1110,13 @@ func (s *Service) resolveProviderUser(ctx context.Context, provider domainuser.I
11001110

11011111
cfg := s.cfg.Snapshot()
11021112
now := time.Now()
1103-
normalizedEmail := strings.TrimSpace(email)
1113+
normalizedEmail, err := normalizeProviderEmail(email)
1114+
if err != nil {
1115+
return nil, err
1116+
}
11041117
if cfg.AutoLinkVerifiedEmail && emailVerified && normalizedEmail != "" {
11051118
existingUser, findErr := s.repo.GetByEmail(ctx, normalizedEmail)
1106-
if findErr == nil && existingUser.EmailVerifiedAt != nil {
1119+
if findErr == nil {
11071120
if err = ensureProviderLoginUserActive(existingUser); err != nil {
11081121
return nil, err
11091122
}
@@ -1112,9 +1125,6 @@ func (s *Service) resolveProviderUser(ctx context.Context, provider domainuser.I
11121125
}
11131126
return existingUser, nil
11141127
}
1115-
if findErr == nil {
1116-
return nil, fmt.Errorf("email already exists; bind the provider before login")
1117-
}
11181128
if !errors.Is(findErr, repository.ErrNotFound) {
11191129
return nil, findErr
11201130
}
@@ -1357,6 +1367,44 @@ func claimString(profile map[string]interface{}, field string) string {
13571367
return conv.GetStringFromAny(value)
13581368
}
13591369

1370+
func normalizeProviderEmail(raw string) (string, error) {
1371+
trimmed := strings.TrimSpace(raw)
1372+
if trimmed == "" {
1373+
return "", nil
1374+
}
1375+
normalized, err := normalizeRegistrationEmail(trimmed)
1376+
if err != nil {
1377+
return "", fmt.Errorf("provider email is invalid")
1378+
}
1379+
return normalized, nil
1380+
}
1381+
1382+
func resolveProviderEmailVerified(profile map[string]interface{}, provider domainuser.IdentityProvider) bool {
1383+
fields := make([]string, 0, 3)
1384+
if strings.TrimSpace(provider.EmailVerifiedField) != "" {
1385+
fields = append(fields, provider.EmailVerifiedField)
1386+
}
1387+
fields = append(fields, "email_verified", "verified_email")
1388+
return claimBool(profile, uniqueClaimFields(fields)...)
1389+
}
1390+
1391+
func uniqueClaimFields(fields []string) []string {
1392+
seen := make(map[string]struct{}, len(fields))
1393+
results := make([]string, 0, len(fields))
1394+
for _, field := range fields {
1395+
normalized := strings.TrimSpace(field)
1396+
if normalized == "" {
1397+
continue
1398+
}
1399+
if _, ok := seen[normalized]; ok {
1400+
continue
1401+
}
1402+
seen[normalized] = struct{}{}
1403+
results = append(results, normalized)
1404+
}
1405+
return results
1406+
}
1407+
13601408
func claimBool(profile map[string]interface{}, fields ...string) bool {
13611409
for _, field := range fields {
13621410
value, ok := claimValue(profile, field)

backend/internal/application/auth/provider_test.go

Lines changed: 128 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import (
1111

1212
domainuser "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/user"
1313
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config"
14+
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/pkg/secretbox"
1415
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository"
16+
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/shared/requestmeta"
1517
)
1618

1719
func boolPtr(value bool) *bool {
@@ -135,13 +137,11 @@ func TestResolveProviderUserLoginRequiresRegistrationEnabledForNewAccount(t *tes
135137
}
136138
}
137139

138-
func TestResolveProviderUserAutoLinksVerifiedEmailBeforeProvisioning(t *testing.T) {
139-
now := time.Now()
140+
func TestResolveProviderUserAutoLinksVerifiedProviderEmailBeforeProvisioning(t *testing.T) {
140141
existing := &domainuser.User{
141-
ID: 42,
142-
Email: "verified@example.com",
143-
EmailVerifiedAt: &now,
144-
Status: domainuser.StatusActive,
142+
ID: 42,
143+
Email: "verified@example.com",
144+
Status: domainuser.StatusActive,
145145
}
146146
repo := &providerLoginRepo{usersByEmail: map[string]*domainuser.User{existing.Email: existing}}
147147
service := NewService(config.Config{JWTSecret: "test-secret", AutoLinkVerifiedEmail: true}, repo, nil)
@@ -170,6 +170,124 @@ func TestResolveProviderUserAutoLinksVerifiedEmailBeforeProvisioning(t *testing.
170170
}
171171
}
172172

173+
func TestResolveProviderUserNormalizesProviderEmailBeforeAutoLink(t *testing.T) {
174+
existing := &domainuser.User{
175+
ID: 42,
176+
Email: "verified@example.com",
177+
Status: domainuser.StatusActive,
178+
}
179+
repo := &providerLoginRepo{usersByEmail: map[string]*domainuser.User{existing.Email: existing}}
180+
service := NewService(config.Config{JWTSecret: "test-secret", AutoLinkVerifiedEmail: true}, repo, nil)
181+
provider := domainuser.IdentityProvider{
182+
ID: 10,
183+
Type: domainuser.IdentityProviderTypeOIDC,
184+
Name: "Acme SSO",
185+
Slug: "acme",
186+
LoginEnabled: true,
187+
RegistrationEnabled: false,
188+
DefaultRole: domainuser.RoleUser,
189+
}
190+
191+
userItem, err := service.resolveProviderUser(context.Background(), provider, "sub-1", "Verified@Example.com", "Verified User", "", true, `{"sub":"sub-1"}`, providerIntentLogin)
192+
if err != nil {
193+
t.Fatalf("expected normalized provider email to auto-link, got %v", err)
194+
}
195+
if userItem.ID != existing.ID {
196+
t.Fatalf("expected existing user %d, got %d", existing.ID, userItem.ID)
197+
}
198+
if len(repo.identities) != 1 || repo.identities[0].Email != existing.Email {
199+
t.Fatalf("expected normalized linked identity email, got %#v", repo.identities)
200+
}
201+
}
202+
203+
func TestResolveProviderEmailVerifiedUsesConfiguredField(t *testing.T) {
204+
provider := domainuser.IdentityProvider{EmailVerifiedField: "verified"}
205+
profile := map[string]interface{}{
206+
"email": "verified@example.com",
207+
"verified": true,
208+
}
209+
210+
if !resolveProviderEmailVerified(profile, provider) {
211+
t.Fatalf("expected configured email verified field to be recognized")
212+
}
213+
}
214+
215+
func TestCompleteProviderBindAllowsSameAccountWithoutProviderEmailVerification(t *testing.T) {
216+
dataKey := "test-data-key"
217+
clientSecret, err := secretbox.EncryptString(dataKey, "client-secret")
218+
if err != nil {
219+
t.Fatalf("encrypt client secret: %v", err)
220+
}
221+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
222+
w.Header().Set("Content-Type", "application/json")
223+
switch r.URL.Path {
224+
case "/token":
225+
_, _ = w.Write([]byte(`{"access_token":"access-token","token_type":"Bearer"}`))
226+
case "/userinfo":
227+
if r.Header.Get("Authorization") != "Bearer access-token" {
228+
t.Fatalf("unexpected authorization header %q", r.Header.Get("Authorization"))
229+
}
230+
_, _ = w.Write([]byte(`{"sub":"sub-1","email":"user@example.com","name":"Provider User"}`))
231+
default:
232+
http.NotFound(w, r)
233+
}
234+
}))
235+
defer server.Close()
236+
237+
provider := &domainuser.IdentityProvider{
238+
ID: 10,
239+
Type: domainuser.IdentityProviderTypeOAuth2,
240+
Name: "Acme SSO",
241+
Slug: "acme",
242+
LoginEnabled: true,
243+
RegistrationEnabled: false,
244+
ClientID: "client",
245+
ClientSecret: clientSecret,
246+
AuthURL: server.URL + "/auth",
247+
TokenURL: server.URL + "/token",
248+
UserInfoURL: server.URL + "/userinfo",
249+
SubjectField: "sub",
250+
EmailField: "email",
251+
EmailVerifiedField: "email_verified",
252+
NameField: "name",
253+
AvatarField: "picture",
254+
}
255+
repo := &providerLoginRepo{
256+
providersBySlug: map[string]*domainuser.IdentityProvider{"acme": provider},
257+
usersByEmail: map[string]*domainuser.User{
258+
"user@example.com": {ID: 42, Email: "user@example.com", Status: domainuser.StatusActive},
259+
},
260+
}
261+
service := NewService(config.Config{
262+
JWTSecret: "test-secret",
263+
DataEncryptionKey: dataKey,
264+
ThirdPartyLoginEnabled: true,
265+
}, repo, nil)
266+
redirectURI := "http://localhost/auth/callback?provider=acme"
267+
codeVerifier := strings.Repeat("a", 43)
268+
state, err := service.signProviderState(providerOAuthState{
269+
Provider: "acme",
270+
RedirectURI: redirectURI,
271+
Intent: providerIntentBind,
272+
CodeChallenge: providerCodeChallenge(codeVerifier),
273+
ExpiresAt: time.Now().Add(time.Minute).Unix(),
274+
})
275+
if err != nil {
276+
t.Fatalf("sign provider state: %v", err)
277+
}
278+
279+
identity, err := service.CompleteProviderBind(context.Background(), 42, "acme", "code", state, redirectURI, codeVerifier, "request-id", requestmeta.SessionAuditContext{})
280+
if err != nil {
281+
t.Fatalf("expected manual bind to succeed without provider email verification claim, got %v", err)
282+
}
283+
if identity.EmailVerified {
284+
t.Fatalf("expected linked identity to remain unverified")
285+
}
286+
if len(repo.identities) != 1 || repo.identities[0].UserID != 42 || repo.identities[0].EmailVerified {
287+
t.Fatalf("expected identity linked to current user without verified email, got %#v", repo.identities)
288+
}
289+
}
290+
173291
func TestResolveProviderUserRejectsInactiveBoundUserWithoutUpdatingIdentity(t *testing.T) {
174292
repo := &providerLoginRepo{
175293
usersByID: map[uint]*domainuser.User{
@@ -549,6 +667,10 @@ func (r *providerLoginRepo) UpdateUserIdentityLogin(ctx context.Context, identit
549667
return nil
550668
}
551669

670+
func (r *providerLoginRepo) RecordAuthEvent(ctx context.Context, userID uint, requestID string, eventType string, result string, reason string, clientIP string, userAgent string, detailJSON string) error {
671+
return nil
672+
}
673+
552674
func (r *providerLoginRepo) DeleteAccountHard(ctx context.Context, userID uint) error {
553675
r.deletedUserID = userID
554676
return nil

backend/internal/domain/user/types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ type IdentityProvider struct {
216216
DefaultRole string
217217
SubjectField string
218218
EmailField string
219+
EmailVerifiedField string
219220
NameField string
220221
AvatarField string
221222
SortOrder int

backend/internal/infra/persistence/models/user.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ type AuthIdentityProvider struct {
192192
DefaultRole string `gorm:"size:32;not null;default:'user';comment:自动创建用户默认角色"`
193193
SubjectField string `gorm:"size:64;not null;default:'sub';comment:用户唯一ID字段"`
194194
EmailField string `gorm:"size:64;not null;default:'email';comment:邮箱字段"`
195+
EmailVerifiedField string `gorm:"size:64;not null;default:'email_verified';comment:邮箱验证状态字段"`
195196
NameField string `gorm:"size:64;not null;default:'name';comment:昵称字段"`
196197
AvatarField string `gorm:"size:64;not null;default:'picture';comment:头像字段"`
197198
SortOrder int `gorm:"not null;default:100;index:idx_identity_providers_sort_order;comment:展示顺序"`

backend/internal/infra/persistence/postgres/user/repository.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,6 +1219,9 @@ func identityProviderUpdates(input repository.UpdateIdentityProviderInput) map[s
12191219
if input.EmailField != nil {
12201220
updates["email_field"] = *input.EmailField
12211221
}
1222+
if input.EmailVerifiedField != nil {
1223+
updates["email_verified_field"] = *input.EmailVerifiedField
1224+
}
12221225
if input.NameField != nil {
12231226
updates["name_field"] = *input.NameField
12241227
}
@@ -1855,6 +1858,7 @@ func toDomainIdentityProvider(item model.AuthIdentityProvider) *domainuser.Ident
18551858
DefaultRole: item.DefaultRole,
18561859
SubjectField: item.SubjectField,
18571860
EmailField: item.EmailField,
1861+
EmailVerifiedField: item.EmailVerifiedField,
18581862
NameField: item.NameField,
18591863
AvatarField: item.AvatarField,
18601864
SortOrder: item.SortOrder,
@@ -1893,6 +1897,7 @@ func toModelIdentityProvider(item *domainuser.IdentityProvider) *model.AuthIdent
18931897
DefaultRole: item.DefaultRole,
18941898
SubjectField: item.SubjectField,
18951899
EmailField: item.EmailField,
1900+
EmailVerifiedField: item.EmailVerifiedField,
18961901
NameField: item.NameField,
18971902
AvatarField: item.AvatarField,
18981903
SortOrder: item.SortOrder,

backend/internal/repository/user.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ type UpdateIdentityProviderInput struct {
8282
DefaultRole *string
8383
SubjectField *string
8484
EmailField *string
85+
EmailVerifiedField *string
8586
NameField *string
8687
AvatarField *string
8788
}
@@ -107,6 +108,7 @@ func (input UpdateIdentityProviderInput) IsZero() bool {
107108
input.DefaultRole == nil &&
108109
input.SubjectField == nil &&
109110
input.EmailField == nil &&
111+
input.EmailVerifiedField == nil &&
110112
input.NameField == nil &&
111113
input.AvatarField == nil
112114
}

backend/internal/transport/http/auth/dto.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ type IdentityProviderResponse struct {
146146
DefaultRole string `json:"defaultRole"`
147147
SubjectField string `json:"subjectField"`
148148
EmailField string `json:"emailField"`
149+
EmailVerifiedField string `json:"emailVerifiedField"`
149150
NameField string `json:"nameField"`
150151
AvatarField string `json:"avatarField"`
151152
CreatedAt time.Time `json:"createdAt"`
@@ -214,6 +215,7 @@ type UpsertIdentityProviderRequest struct {
214215
DefaultRole string `json:"defaultRole" binding:"omitempty,oneof=user admin superadmin"`
215216
SubjectField string `json:"subjectField" binding:"omitempty,max=64"`
216217
EmailField string `json:"emailField" binding:"omitempty,max=64"`
218+
EmailVerifiedField string `json:"emailVerifiedField" binding:"omitempty,max=64"`
217219
NameField string `json:"nameField" binding:"omitempty,max=64"`
218220
AvatarField string `json:"avatarField" binding:"omitempty,max=64"`
219221
}
@@ -602,6 +604,7 @@ func toIdentityProviderResponse(item appauth.IdentityProviderView) IdentityProvi
602604
DefaultRole: item.DefaultRole,
603605
SubjectField: item.SubjectField,
604606
EmailField: item.EmailField,
607+
EmailVerifiedField: item.EmailVerifiedField,
605608
NameField: item.NameField,
606609
AvatarField: item.AvatarField,
607610
CreatedAt: item.CreatedAt,
@@ -630,6 +633,7 @@ func toUpsertIdentityProviderInput(req UpsertIdentityProviderRequest, actorRole
630633
DefaultRole: req.DefaultRole,
631634
SubjectField: req.SubjectField,
632635
EmailField: req.EmailField,
636+
EmailVerifiedField: req.EmailVerifiedField,
633637
NameField: req.NameField,
634638
AvatarField: req.AvatarField,
635639
}

0 commit comments

Comments
 (0)