@nakedev/go-scaffold 0.5.3 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/commands/auth.js +19 -12
- package/dist/commands/method.js +2 -0
- package/dist/utils/auth-patcher.js +10 -1
- package/dist/utils/hexagonal-method-patcher.js +12 -1
- package/package.json +1 -1
- package/templates/add/auth/docs/schemas.yaml.hbs +10 -3
- package/templates/add/auth/docs/users-me-identity-local-link.yaml.hbs +16 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +7 -3
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_identity.go.hbs +27 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +1 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +1 -1
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +40 -17
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +231 -74
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository_test.go.hbs +11 -8
- package/templates/add/auth/internal/app/user/application/errors.go.hbs +4 -0
- package/templates/add/auth/internal/app/user/application/external_login.go.hbs +5 -5
- package/templates/add/auth/internal/app/user/application/identities.go.hbs +40 -2
- package/templates/add/auth/internal/app/user/application/identities_test.go.hbs +33 -0
- package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +1 -0
- package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/application/service.go.hbs +12 -0
- package/templates/add/auth/internal/app/user/application/service_test.go.hbs +3 -3
- package/templates/add/auth/internal/app/user/application/user_query.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/domain/entity.go.hbs +1 -0
- package/templates/add/auth/internal/app/user/domain/errors.go.hbs +1 -0
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +1 -1
- package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +1 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +7 -1
- package/templates/add/auth/migrations/create_external_identities.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_external_identities.up.sql.hbs +12 -0
- package/templates/add/auth/migrations/create_password_credentials.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_password_credentials.up.sql.hbs +11 -0
- package/templates/add/auth/migrations/create_user_emails.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_user_emails.up.sql.hbs +14 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +0 -11
- package/templates/create/base/AGENTS.md.hbs +7 -1
- package/templates/create/base/README.md.hbs +3 -1
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +0 -1
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +0 -15
|
@@ -3,6 +3,8 @@ package postgres
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
5
|
"errors"
|
|
6
|
+
"sort"
|
|
7
|
+
"strings"
|
|
6
8
|
"time"
|
|
7
9
|
|
|
8
10
|
"{{goModule}}/internal/app/user/domain"
|
|
@@ -26,24 +28,52 @@ func NewRepository(db *gorm.DB) *Repository {
|
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
func (r *Repository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
db := tx.From(ctx, r.db).WithContext(ctx)
|
|
32
|
+
var emailRow UserEmail
|
|
33
|
+
if err := db.Where("email_normalized = ?", normalizeEmail(email)).First(&emailRow).Error; err != nil {
|
|
31
34
|
return nil, persistenceError(err)
|
|
32
35
|
}
|
|
33
|
-
return
|
|
36
|
+
return findUserWithEmail(db, emailRow.UserID, &emailRow)
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (*domain.User, error) {
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
db := tx.From(ctx, r.db).WithContext(ctx)
|
|
41
|
+
var emailRow UserEmail
|
|
42
|
+
if err := db.Where("user_id = ? AND is_primary = true", id).First(&emailRow).Error; err != nil {
|
|
39
43
|
return nil, persistenceError(err)
|
|
40
44
|
}
|
|
41
|
-
return
|
|
45
|
+
return findUserWithEmail(db, id, &emailRow)
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
func (r *Repository) UpdateUser(ctx context.Context, user *domain.User) error {
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
if user == nil || strings.TrimSpace(user.Email) == "" {
|
|
50
|
+
return errors.New("user email is required")
|
|
51
|
+
}
|
|
52
|
+
return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
|
|
53
|
+
row := fromDomainUser(user)
|
|
54
|
+
if err := db.Save(&row).Error; err != nil {
|
|
55
|
+
return err
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
var emailRow UserEmail
|
|
59
|
+
err := db.Where("user_id = ? AND is_primary = true", user.ID).First(&emailRow).Error
|
|
60
|
+
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
61
|
+
emailRow = UserEmail{ID: uuid.New(), UserID: user.ID, IsPrimary: true}
|
|
62
|
+
} else if err != nil {
|
|
63
|
+
return err
|
|
64
|
+
}
|
|
65
|
+
emailRow.Email = strings.TrimSpace(user.Email)
|
|
66
|
+
emailRow.EmailNormalized = normalizeEmail(user.Email)
|
|
67
|
+
if user.EmailVerified {
|
|
68
|
+
if emailRow.VerifiedAt == nil {
|
|
69
|
+
now := time.Now()
|
|
70
|
+
emailRow.VerifiedAt = &now
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
emailRow.VerifiedAt = nil
|
|
74
|
+
}
|
|
75
|
+
return db.Save(&emailRow).Error
|
|
76
|
+
}))
|
|
47
77
|
}
|
|
48
78
|
|
|
49
79
|
func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]domain.User, error) {
|
|
@@ -53,81 +83,137 @@ func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]domain.U
|
|
|
53
83
|
if err != nil {
|
|
54
84
|
return nil, persistenceError(err)
|
|
55
85
|
}
|
|
86
|
+
ids := make([]uuid.UUID, len(rows))
|
|
87
|
+
for i := range rows {
|
|
88
|
+
ids[i] = rows[i].ID
|
|
89
|
+
}
|
|
90
|
+
var emailRows []UserEmail
|
|
91
|
+
if len(ids) > 0 {
|
|
92
|
+
if err := tx.From(ctx, r.db).WithContext(ctx).
|
|
93
|
+
Where("user_id IN ? AND is_primary = true", ids).Find(&emailRows).Error; err != nil {
|
|
94
|
+
return nil, persistenceError(err)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
emails := make(map[uuid.UUID]*UserEmail, len(emailRows))
|
|
98
|
+
for i := range emailRows {
|
|
99
|
+
emails[emailRows[i].UserID] = &emailRows[i]
|
|
100
|
+
}
|
|
56
101
|
items := make([]domain.User, len(rows))
|
|
57
102
|
for i := range rows {
|
|
58
|
-
items[i] = *
|
|
103
|
+
items[i] = *toDomainUserWithEmail(&rows[i], emails[rows[i].ID])
|
|
59
104
|
}
|
|
60
105
|
return items, nil
|
|
61
106
|
}
|
|
62
107
|
|
|
63
108
|
func (r *Repository) FindIdentity(ctx context.Context, userID uuid.UUID, provider domain.Provider) (*domain.Identity, error) {
|
|
64
|
-
|
|
65
|
-
if
|
|
66
|
-
|
|
109
|
+
db := tx.From(ctx, r.db).WithContext(ctx)
|
|
110
|
+
if provider == domain.ProviderLocal {
|
|
111
|
+
var row PasswordCredential
|
|
112
|
+
if err := db.Where("user_id = ?", userID).First(&row).Error; err != nil {
|
|
113
|
+
return nil, persistenceError(err)
|
|
114
|
+
}
|
|
115
|
+
return toDomainPasswordIdentity(&row), nil
|
|
116
|
+
}
|
|
117
|
+
var row ExternalIdentity
|
|
118
|
+
if err := db.Where("user_id = ? AND provider = ?", userID, string(provider)).First(&row).Error; err != nil {
|
|
67
119
|
return nil, persistenceError(err)
|
|
68
120
|
}
|
|
69
|
-
return
|
|
121
|
+
return toDomainExternalIdentity(&row), nil
|
|
70
122
|
}
|
|
71
123
|
|
|
72
124
|
func (r *Repository) ListIdentities(ctx context.Context, userID uuid.UUID) ([]domain.Identity, error) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
Order("created_at ASC, id ASC").Find(&rows).Error; err != nil {
|
|
125
|
+
db := tx.From(ctx, r.db).WithContext(ctx)
|
|
126
|
+
var passwordRows []PasswordCredential
|
|
127
|
+
if err := db.Where("user_id = ?", userID).Find(&passwordRows).Error; err != nil {
|
|
77
128
|
return nil, persistenceError(err)
|
|
78
129
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
130
|
+
var externalRows []ExternalIdentity
|
|
131
|
+
if err := db.Where("user_id = ?", userID).Find(&externalRows).Error; err != nil {
|
|
132
|
+
return nil, persistenceError(err)
|
|
133
|
+
}
|
|
134
|
+
items := make([]domain.Identity, 0, len(passwordRows)+len(externalRows))
|
|
135
|
+
for i := range passwordRows {
|
|
136
|
+
items = append(items, *toDomainPasswordIdentity(&passwordRows[i]))
|
|
82
137
|
}
|
|
138
|
+
for i := range externalRows {
|
|
139
|
+
items = append(items, *toDomainExternalIdentity(&externalRows[i]))
|
|
140
|
+
}
|
|
141
|
+
sort.Slice(items, func(i, j int) bool {
|
|
142
|
+
if items[i].CreatedAt.Equal(items[j].CreatedAt) {
|
|
143
|
+
return items[i].ID.String() < items[j].ID.String()
|
|
144
|
+
}
|
|
145
|
+
return items[i].CreatedAt.Before(items[j].CreatedAt)
|
|
146
|
+
})
|
|
83
147
|
return items, nil
|
|
84
148
|
}
|
|
85
149
|
|
|
86
|
-
func (r *Repository) FindIdentityByProviderUID(ctx context.Context,
|
|
87
|
-
var row
|
|
150
|
+
func (r *Repository) FindIdentityByProviderUID(ctx context.Context, issuer, subject string) (*domain.Identity, error) {
|
|
151
|
+
var row ExternalIdentity
|
|
88
152
|
if err := tx.From(ctx, r.db).WithContext(ctx).
|
|
89
|
-
First(&row, "
|
|
153
|
+
First(&row, "issuer = ? AND subject = ?", issuer, subject).Error; err != nil {
|
|
90
154
|
return nil, persistenceError(err)
|
|
91
155
|
}
|
|
92
|
-
return
|
|
156
|
+
return toDomainExternalIdentity(&row), nil
|
|
93
157
|
}
|
|
94
158
|
|
|
95
159
|
// CreateIdentity links a new login method onto an existing user.
|
|
96
160
|
func (r *Repository) CreateIdentity(ctx context.Context, identity *domain.Identity) error {
|
|
97
|
-
|
|
98
|
-
return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Create(&row).Error)
|
|
161
|
+
return persistenceError(createIdentity(tx.From(ctx, r.db).WithContext(ctx), identity))
|
|
99
162
|
}
|
|
100
163
|
|
|
101
164
|
func (r *Repository) UpdateIdentity(ctx context.Context, identity *domain.Identity) error {
|
|
102
|
-
|
|
103
|
-
|
|
165
|
+
if identity == nil {
|
|
166
|
+
return errors.New("identity is required")
|
|
167
|
+
}
|
|
168
|
+
db := tx.From(ctx, r.db).WithContext(ctx)
|
|
169
|
+
if identity.Provider == domain.ProviderLocal {
|
|
170
|
+
if identity.PasswordHash == nil {
|
|
171
|
+
return errors.New("local identity password is required")
|
|
172
|
+
}
|
|
173
|
+
row := PasswordCredential{ID: identity.ID, UserID: identity.UserID, PasswordHash: *identity.PasswordHash, HashAlgorithm: "bcrypt", PasswordChangedAt: time.Now(), CreatedAt: identity.CreatedAt, UpdatedAt: identity.UpdatedAt}
|
|
174
|
+
return persistenceError(db.Save(&row).Error)
|
|
175
|
+
}
|
|
176
|
+
row, err := externalIdentityFromDomain(identity)
|
|
177
|
+
if err != nil {
|
|
178
|
+
return err
|
|
179
|
+
}
|
|
180
|
+
return persistenceError(db.Save(&row).Error)
|
|
104
181
|
}
|
|
105
182
|
|
|
106
183
|
// DeleteIdentity locks the user's identity rows before checking the count so
|
|
107
184
|
// two concurrent unlink requests cannot both remove the last login method.
|
|
108
185
|
func (r *Repository) DeleteIdentity(ctx context.Context, userID uuid.UUID, provider domain.Provider) error {
|
|
109
186
|
return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
|
|
110
|
-
var
|
|
111
|
-
if err := db.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
112
|
-
Where("user_id = ?", userID).
|
|
113
|
-
Order("created_at ASC, id ASC").Find(&rows).Error; err != nil {
|
|
187
|
+
var user User
|
|
188
|
+
if err := db.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id = ?", userID).Error; err != nil {
|
|
114
189
|
return err
|
|
115
190
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if rows[i].Provider == string(provider) {
|
|
120
|
-
target = &rows[i]
|
|
121
|
-
break
|
|
122
|
-
}
|
|
191
|
+
var passwordCount, externalCount int64
|
|
192
|
+
if err := db.Model(&PasswordCredential{}).Where("user_id = ?", userID).Count(&passwordCount).Error; err != nil {
|
|
193
|
+
return err
|
|
123
194
|
}
|
|
124
|
-
if
|
|
195
|
+
if err := db.Model(&ExternalIdentity{}).Where("user_id = ?", userID).Count(&externalCount).Error; err != nil {
|
|
196
|
+
return err
|
|
197
|
+
}
|
|
198
|
+
total := passwordCount + externalCount
|
|
199
|
+
if total == 0 {
|
|
125
200
|
return domain.ErrNotFound
|
|
126
201
|
}
|
|
127
|
-
if
|
|
202
|
+
if total <= 1 {
|
|
128
203
|
return domain.ErrLastIdentity
|
|
129
204
|
}
|
|
130
|
-
|
|
205
|
+
if provider == domain.ProviderLocal {
|
|
206
|
+
result := db.Where("user_id = ?", userID).Delete(&PasswordCredential{})
|
|
207
|
+
if result.Error == nil && result.RowsAffected == 0 {
|
|
208
|
+
return domain.ErrNotFound
|
|
209
|
+
}
|
|
210
|
+
return result.Error
|
|
211
|
+
}
|
|
212
|
+
result := db.Where("user_id = ? AND provider = ?", userID, string(provider)).Delete(&ExternalIdentity{})
|
|
213
|
+
if result.Error == nil && result.RowsAffected == 0 {
|
|
214
|
+
return domain.ErrNotFound
|
|
215
|
+
}
|
|
216
|
+
return result.Error
|
|
131
217
|
}))
|
|
132
218
|
}
|
|
133
219
|
|
|
@@ -135,13 +221,23 @@ func (r *Repository) DeleteIdentity(ctx context.Context, userID uuid.UUID, provi
|
|
|
135
221
|
// one transaction. A user without an identity cannot authenticate.
|
|
136
222
|
func (r *Repository) CreateUserWithIdentity(ctx context.Context, user *domain.User, identity *domain.Identity) error {
|
|
137
223
|
userRow := fromDomainUser(user)
|
|
138
|
-
identityRow := fromDomainIdentity(identity)
|
|
139
224
|
return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
|
|
140
225
|
if err := db.Create(&userRow).Error; err != nil {
|
|
141
226
|
return persistenceError(err)
|
|
142
227
|
}
|
|
143
|
-
|
|
144
|
-
|
|
228
|
+
verifiedAt := (*time.Time)(nil)
|
|
229
|
+
if user.EmailVerified {
|
|
230
|
+
now := time.Now()
|
|
231
|
+
verifiedAt = &now
|
|
232
|
+
}
|
|
233
|
+
if err := db.Create(&UserEmail{
|
|
234
|
+
ID: uuid.New(), UserID: userRow.ID, Email: strings.TrimSpace(user.Email),
|
|
235
|
+
EmailNormalized: normalizeEmail(user.Email), IsPrimary: true, VerifiedAt: verifiedAt,
|
|
236
|
+
}).Error; err != nil {
|
|
237
|
+
return persistenceError(err)
|
|
238
|
+
}
|
|
239
|
+
identity.UserID = userRow.ID
|
|
240
|
+
return persistenceError(createIdentity(db, identity))
|
|
145
241
|
}))
|
|
146
242
|
}
|
|
147
243
|
|
|
@@ -194,16 +290,48 @@ func persistenceError(err error) error {
|
|
|
194
290
|
return err
|
|
195
291
|
}
|
|
196
292
|
|
|
197
|
-
func
|
|
293
|
+
func findUserWithEmail(db *gorm.DB, userID uuid.UUID, emailRow *UserEmail) (*domain.User, error) {
|
|
294
|
+
var row User
|
|
295
|
+
if err := db.First(&row, "id = ?", userID).Error; err != nil {
|
|
296
|
+
return nil, persistenceError(err)
|
|
297
|
+
}
|
|
298
|
+
return toDomainUserWithEmail(&row, emailRow), nil
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
func normalizeEmail(email string) string {
|
|
302
|
+
return strings.ToLower(strings.TrimSpace(email))
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// toDomainUser is the one-row mapping hook used by generic generated method
|
|
306
|
+
// extensions. It hydrates the primary email because auth stores addresses in
|
|
307
|
+
// user_emails rather than on the profile row.
|
|
308
|
+
//nolint:unused // generate method uses this seam when a custom user lookup is added.
|
|
309
|
+
func (r *Repository) toDomainUser(ctx context.Context, row *User) (*domain.User, error) {
|
|
310
|
+
var emailRow UserEmail
|
|
311
|
+
if err := tx.From(ctx, r.db).WithContext(ctx).
|
|
312
|
+
Where("user_id = ? AND is_primary = true", row.ID).
|
|
313
|
+
First(&emailRow).Error; err != nil {
|
|
314
|
+
return nil, persistenceError(err)
|
|
315
|
+
}
|
|
316
|
+
return toDomainUserWithEmail(row, &emailRow), nil
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
func toDomainUserWithEmail(row *User, emailRow *UserEmail) *domain.User {
|
|
198
320
|
if row == nil {
|
|
199
321
|
return nil
|
|
200
322
|
}
|
|
323
|
+
var email string
|
|
324
|
+
var verified bool
|
|
325
|
+
if emailRow != nil {
|
|
326
|
+
email = emailRow.Email
|
|
327
|
+
verified = emailRow.VerifiedAt != nil
|
|
328
|
+
}
|
|
201
329
|
return &domain.User{
|
|
202
330
|
ID: row.ID,
|
|
203
|
-
Email:
|
|
331
|
+
Email: email,
|
|
204
332
|
Name: row.Name,
|
|
205
333
|
AvatarURL: row.AvatarURL,
|
|
206
|
-
EmailVerified:
|
|
334
|
+
EmailVerified: verified,
|
|
207
335
|
Role: row.Role,
|
|
208
336
|
CreatedAt: row.CreatedAt,
|
|
209
337
|
UpdatedAt: row.UpdatedAt,
|
|
@@ -212,42 +340,71 @@ func toDomainUser(row *User) *domain.User {
|
|
|
212
340
|
|
|
213
341
|
func fromDomainUser(user *domain.User) User {
|
|
214
342
|
return User{
|
|
215
|
-
ID:
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
EmailVerified: user.EmailVerified,
|
|
220
|
-
Role: user.Role,
|
|
343
|
+
ID: user.ID,
|
|
344
|
+
Name: user.Name,
|
|
345
|
+
AvatarURL: user.AvatarURL,
|
|
346
|
+
Role: user.Role,
|
|
221
347
|
CreatedAt: user.CreatedAt,
|
|
222
348
|
UpdatedAt: user.UpdatedAt,
|
|
223
349
|
}
|
|
224
350
|
}
|
|
225
351
|
|
|
226
|
-
func
|
|
352
|
+
func toDomainPasswordIdentity(row *PasswordCredential) *domain.Identity {
|
|
227
353
|
if row == nil {
|
|
228
354
|
return nil
|
|
229
355
|
}
|
|
356
|
+
hash := row.PasswordHash
|
|
230
357
|
return &domain.Identity{
|
|
231
|
-
ID:
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return Identity{
|
|
243
|
-
ID:
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
358
|
+
ID: row.ID, UserID: row.UserID, Provider: domain.ProviderLocal,
|
|
359
|
+
PasswordHash: &hash, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
func toDomainExternalIdentity(row *ExternalIdentity) *domain.Identity {
|
|
365
|
+
if row == nil {
|
|
366
|
+
return nil
|
|
367
|
+
}
|
|
368
|
+
subject := row.Subject
|
|
369
|
+
return &domain.Identity{
|
|
370
|
+
ID: row.ID, UserID: row.UserID, Provider: domain.Provider(row.Provider),
|
|
371
|
+
Issuer: row.Issuer, ProviderUID: &subject, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
func createIdentity(db *gorm.DB, identity *domain.Identity) error {
|
|
376
|
+
if identity == nil || identity.UserID == uuid.Nil || identity.ID == uuid.Nil {
|
|
377
|
+
return errors.New("identity id and user id are required")
|
|
378
|
+
}
|
|
379
|
+
if identity.Provider == domain.ProviderLocal {
|
|
380
|
+
if identity.PasswordHash == nil {
|
|
381
|
+
return errors.New("local identity password is required")
|
|
382
|
+
}
|
|
383
|
+
return db.Create(&PasswordCredential{
|
|
384
|
+
ID: identity.ID, UserID: identity.UserID, PasswordHash: *identity.PasswordHash,
|
|
385
|
+
HashAlgorithm: "bcrypt", PasswordChangedAt: time.Now(),
|
|
386
|
+
CreatedAt: identity.CreatedAt, UpdatedAt: identity.UpdatedAt,
|
|
387
|
+
}).Error
|
|
388
|
+
}
|
|
389
|
+
row, err := externalIdentityFromDomain(identity)
|
|
390
|
+
if err != nil {
|
|
391
|
+
return err
|
|
392
|
+
}
|
|
393
|
+
return db.Create(&row).Error
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
func externalIdentityFromDomain(identity *domain.Identity) (ExternalIdentity, error) {
|
|
397
|
+
if identity == nil || identity.ID == uuid.Nil || identity.UserID == uuid.Nil {
|
|
398
|
+
return ExternalIdentity{}, errors.New("identity id and user id are required")
|
|
399
|
+
}
|
|
400
|
+
if strings.TrimSpace(identity.Issuer) == "" || identity.ProviderUID == nil || strings.TrimSpace(*identity.ProviderUID) == "" {
|
|
401
|
+
return ExternalIdentity{}, errors.New("external identity issuer and subject are required")
|
|
250
402
|
}
|
|
403
|
+
return ExternalIdentity{
|
|
404
|
+
ID: identity.ID, UserID: identity.UserID, Provider: string(identity.Provider),
|
|
405
|
+
Issuer: identity.Issuer, Subject: *identity.ProviderUID,
|
|
406
|
+
CreatedAt: identity.CreatedAt, UpdatedAt: identity.UpdatedAt,
|
|
407
|
+
}, nil
|
|
251
408
|
}
|
|
252
409
|
|
|
253
410
|
// go-scaffold:repository-methods
|
package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository_test.go.hbs
CHANGED
|
@@ -60,31 +60,34 @@ func repositoryDBForTest(t *testing.T) *gorm.DB {
|
|
|
60
60
|
return tx
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
func stringPtr(value string) *string { return &value }
|
|
64
|
+
|
|
63
65
|
// CreateUserWithIdentity's whole reason to be a transaction: a user that
|
|
64
66
|
// exists with no way to log in is unreachable. If the identity insert fails,
|
|
65
67
|
// the user it was meant to arrive with must vanish too — a function-backed
|
|
66
68
|
// stub can't prove this, since it never runs a real insert against a real
|
|
67
69
|
// constraint. This forces exactly that: the user insert succeeds (it's a
|
|
68
|
-
// brand new row), but the identity insert collides with an existing
|
|
69
|
-
// identity's
|
|
70
|
+
// brand new row), but the identity insert collides with an existing external
|
|
71
|
+
// identity's issuer/subject.
|
|
70
72
|
func TestRepository_CreateUserWithIdentity_RollsBackBothOnIdentityConflict(t *testing.T) {
|
|
71
73
|
repo := NewRepository(repositoryDBForTest(t))
|
|
72
74
|
ctx := context.Background()
|
|
73
75
|
|
|
74
|
-
|
|
76
|
+
issuer := "https://accounts.google.com"
|
|
77
|
+
providerUID := "conflicting-provider-subject"
|
|
75
78
|
existing := &domain.User{ID: uuid.New(), Email: "first@example.com"}
|
|
76
79
|
if err := repo.CreateUserWithIdentity(ctx, existing, &domain.Identity{
|
|
77
|
-
ID: uuid.New(), Provider: domain.ProviderGoogle, ProviderUID: &providerUID,
|
|
80
|
+
ID: uuid.New(), Provider: domain.ProviderGoogle, Issuer: issuer, ProviderUID: &providerUID,
|
|
78
81
|
}); err != nil {
|
|
79
82
|
t.Fatalf("seed existing user+identity: %v", err)
|
|
80
83
|
}
|
|
81
84
|
|
|
82
85
|
blocked := &domain.User{ID: uuid.New(), Email: "second@example.com"}
|
|
83
86
|
err := repo.CreateUserWithIdentity(ctx, blocked, &domain.Identity{
|
|
84
|
-
ID: uuid.New(), Provider: domain.ProviderGoogle, ProviderUID: &providerUID,
|
|
87
|
+
ID: uuid.New(), Provider: domain.ProviderGoogle, Issuer: issuer, ProviderUID: &providerUID,
|
|
85
88
|
})
|
|
86
89
|
if !errors.Is(err, domain.ErrConflict) {
|
|
87
|
-
t.Fatalf("want a mapped user conflict from the conflicting
|
|
90
|
+
t.Fatalf("want a mapped user conflict from the conflicting issuer/subject, got %v", err)
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
if _, findErr := repo.FindByID(ctx, blocked.ID); !errors.Is(findErr, domain.ErrNotFound) {
|
|
@@ -103,13 +106,13 @@ func TestRepository_CreateUserWithIdentity_DuplicateEmailIsDetectable(t *testing
|
|
|
103
106
|
|
|
104
107
|
email := "dup@example.com"
|
|
105
108
|
if err := repo.CreateUserWithIdentity(ctx, &domain.User{ID: uuid.New(), Email: email}, &domain.Identity{
|
|
106
|
-
ID: uuid.New(), Provider: domain.ProviderLocal,
|
|
109
|
+
ID: uuid.New(), Provider: domain.ProviderLocal, PasswordHash: stringPtr("hash-one"),
|
|
107
110
|
}); err != nil {
|
|
108
111
|
t.Fatalf("seed first user: %v", err)
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
err := repo.CreateUserWithIdentity(ctx, &domain.User{ID: uuid.New(), Email: email}, &domain.Identity{
|
|
112
|
-
ID: uuid.New(), Provider: domain.ProviderLocal,
|
|
115
|
+
ID: uuid.New(), Provider: domain.ProviderLocal, PasswordHash: stringPtr("hash-two"),
|
|
113
116
|
})
|
|
114
117
|
if !errors.Is(err, domain.ErrConflict) {
|
|
115
118
|
t.Fatalf("want a mapped user conflict for the reused email, got %v", err)
|
|
@@ -25,6 +25,10 @@ func errInvalidCredentials() error {
|
|
|
25
25
|
return domain.Rule("AUTH_INVALID_CREDENTIALS", "invalid email or password", domain.ErrInvalidCredential)
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
func errInvalidPassword() error {
|
|
29
|
+
return domain.Rule("AUTH_INVALID_PASSWORD", "password must be between 8 and 72 bytes", domain.ErrInvalidPassword)
|
|
30
|
+
}
|
|
31
|
+
|
|
28
32
|
func errInvalidToken() error {
|
|
29
33
|
return domain.Rule("AUTH_INVALID_TOKEN", "invalid or expired token", domain.ErrInvalidToken)
|
|
30
34
|
}
|
|
@@ -183,15 +183,15 @@ func mapProviderError(err error) error {
|
|
|
183
183
|
// provider subject wins; only a verified external email may link to an
|
|
184
184
|
// existing account.
|
|
185
185
|
func (s *Service) findOrCreateExternalUser(ctx context.Context, info ExternalIdentity) (*domain.User, error) {
|
|
186
|
-
if strings.TrimSpace(info.Provider) == "" || strings.TrimSpace(info.Subject) == "" || strings.TrimSpace(info.Email) == "" {
|
|
187
|
-
return nil, fmt.Errorf("external identity is missing provider, subject, or email")
|
|
186
|
+
if strings.TrimSpace(info.Provider) == "" || strings.TrimSpace(info.Issuer) == "" || strings.TrimSpace(info.Subject) == "" || strings.TrimSpace(info.Email) == "" {
|
|
187
|
+
return nil, fmt.Errorf("external identity is missing provider, issuer, subject, or email")
|
|
188
188
|
}
|
|
189
189
|
if len(info.Provider) > 20 {
|
|
190
190
|
return nil, fmt.Errorf("external identity provider name is too long")
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
provider := domain.Provider(info.Provider)
|
|
194
|
-
if ident, err := s.repo.FindIdentityByProviderUID(ctx,
|
|
194
|
+
if ident, err := s.repo.FindIdentityByProviderUID(ctx, info.Issuer, info.Subject); err == nil {
|
|
195
195
|
user, findErr := s.repo.FindByID(ctx, ident.UserID)
|
|
196
196
|
if findErr != nil {
|
|
197
197
|
return nil, fmt.Errorf("find user for existing identity: %w", findErr)
|
|
@@ -205,7 +205,7 @@ func (s *Service) findOrCreateExternalUser(ctx context.Context, info ExternalIde
|
|
|
205
205
|
email := normalizeEmail(info.Email)
|
|
206
206
|
if info.EmailVerified {
|
|
207
207
|
if u, err := s.repo.FindByEmail(ctx, email); err == nil {
|
|
208
|
-
ident := &domain.Identity{ID: id.New(), UserID: u.ID, Provider: provider, ProviderUID: &providerUID}
|
|
208
|
+
ident := &domain.Identity{ID: id.New(), UserID: u.ID, Provider: provider, Issuer: info.Issuer, ProviderUID: &providerUID}
|
|
209
209
|
if err := s.repo.CreateIdentity(ctx, ident); err != nil {
|
|
210
210
|
return nil, fmt.Errorf("link external identity: %w", err)
|
|
211
211
|
}
|
|
@@ -216,7 +216,7 @@ func (s *Service) findOrCreateExternalUser(ctx context.Context, info ExternalIde
|
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
u := &domain.User{ID: id.New(), Email: email, Name: info.Name, AvatarURL: info.AvatarURL, EmailVerified: info.EmailVerified, Role: domain.DefaultRole}
|
|
219
|
-
ident := &domain.Identity{ID: id.New(), Provider: provider, ProviderUID: &providerUID}
|
|
219
|
+
ident := &domain.Identity{ID: id.New(), Provider: provider, Issuer: info.Issuer, ProviderUID: &providerUID}
|
|
220
220
|
if err := s.repo.CreateUserWithIdentity(ctx, u, ident); err != nil {
|
|
221
221
|
if errors.Is(err, domain.ErrConflict) {
|
|
222
222
|
return nil, errEmailTaken()
|
|
@@ -24,6 +24,41 @@ func (s *Service) ListIdentities(ctx context.Context, userID uuid.UUID) ([]Ident
|
|
|
24
24
|
return out, nil
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// LinkLocalIdentity adds a password credential to an already authenticated
|
|
28
|
+
// account. This is the supported Google/OIDC -> email/password path: it
|
|
29
|
+
// creates a credential for the existing user instead of creating a second
|
|
30
|
+
// account keyed by the same email address.
|
|
31
|
+
func (s *Service) LinkLocalIdentity(ctx context.Context, userID uuid.UUID, password string) error {
|
|
32
|
+
if userID == uuid.Nil || strings.TrimSpace(password) == "" {
|
|
33
|
+
return fmt.Errorf("authenticated user and password are required")
|
|
34
|
+
}
|
|
35
|
+
if err := validatePassword(password); err != nil {
|
|
36
|
+
return err
|
|
37
|
+
}
|
|
38
|
+
if _, err := s.repo.FindByID(ctx, userID); err != nil {
|
|
39
|
+
return wrapFindErr(err)
|
|
40
|
+
}
|
|
41
|
+
if _, err := s.repo.FindIdentity(ctx, userID, domain.ProviderLocal); err == nil {
|
|
42
|
+
return errIdentityAlreadyLinked()
|
|
43
|
+
} else if !errors.Is(err, domain.ErrNotFound) {
|
|
44
|
+
return fmt.Errorf("check local identity: %w", err)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
hash, err := s.passwords.Hash(password)
|
|
48
|
+
if err != nil {
|
|
49
|
+
return fmt.Errorf("hash password: %w", err)
|
|
50
|
+
}
|
|
51
|
+
hashStr := string(hash)
|
|
52
|
+
identity := &domain.Identity{ID: uuid.New(), UserID: userID, Provider: domain.ProviderLocal, PasswordHash: &hashStr}
|
|
53
|
+
if err := s.repo.CreateIdentity(ctx, identity); err != nil {
|
|
54
|
+
if errors.Is(err, domain.ErrConflict) {
|
|
55
|
+
return errIdentityAlreadyLinked()
|
|
56
|
+
}
|
|
57
|
+
return fmt.Errorf("link local identity: %w", err)
|
|
58
|
+
}
|
|
59
|
+
return nil
|
|
60
|
+
}
|
|
61
|
+
|
|
27
62
|
func (s *Service) ExchangeIdentityLink(ctx context.Context, userID uuid.UUID, providerName string, in LoginExchangeInput) (*IdentityResponse, error) {
|
|
28
63
|
if userID == uuid.Nil {
|
|
29
64
|
return nil, NewOAuthError(OAuthStateInvalid, fmt.Errorf("authenticated user is required to link an identity"))
|
|
@@ -45,7 +80,10 @@ func (s *Service) linkExternalIdentity(ctx context.Context, userID uuid.UUID, in
|
|
|
45
80
|
// Replaying the same provider account from the same user is idempotent.
|
|
46
81
|
// A provider subject already owned by another user is a conflict and must
|
|
47
82
|
// never be silently moved between accounts.
|
|
48
|
-
if
|
|
83
|
+
if strings.TrimSpace(info.Issuer) == "" {
|
|
84
|
+
return nil, NewOAuthError(OAuthFailed, fmt.Errorf("external identity issuer is missing"))
|
|
85
|
+
}
|
|
86
|
+
if existing, err := s.repo.FindIdentityByProviderUID(ctx, info.Issuer, info.Subject); err == nil {
|
|
49
87
|
if existing.UserID != userID {
|
|
50
88
|
return nil, errIdentityConflict()
|
|
51
89
|
}
|
|
@@ -65,7 +103,7 @@ func (s *Service) linkExternalIdentity(ctx context.Context, userID uuid.UUID, in
|
|
|
65
103
|
|
|
66
104
|
providerUID := info.Subject
|
|
67
105
|
identity := &domain.Identity{
|
|
68
|
-
ID: uuid.New(), UserID: userID, Provider: provider, ProviderUID: &providerUID,
|
|
106
|
+
ID: uuid.New(), UserID: userID, Provider: provider, Issuer: info.Issuer, ProviderUID: &providerUID,
|
|
69
107
|
}
|
|
70
108
|
if err := s.repo.CreateIdentity(ctx, identity); err != nil {
|
|
71
109
|
if errors.Is(err, domain.ErrConflict) {
|
|
@@ -28,6 +28,39 @@ func TestService_IdentityLinkBindsOAuthTransactionToCaller(t *testing.T) {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
func TestService_LinkLocalIdentityAddsPasswordToExistingAccount(t *testing.T) {
|
|
32
|
+
userID := uuid.New()
|
|
33
|
+
repo := &fakeRepo{user: &domain.User{ID: userID, Email: "google@example.com"}}
|
|
34
|
+
svc := newTestService(repo, newFakeTokenStore())
|
|
35
|
+
|
|
36
|
+
if err := svc.LinkLocalIdentity(context.Background(), userID, "new-password"); err != nil {
|
|
37
|
+
t.Fatalf("link local identity: %v", err)
|
|
38
|
+
}
|
|
39
|
+
identity, err := repo.FindIdentity(context.Background(), userID, domain.ProviderLocal)
|
|
40
|
+
if err != nil {
|
|
41
|
+
t.Fatalf("find linked local identity: %v", err)
|
|
42
|
+
}
|
|
43
|
+
if identity.PasswordHash == nil || *identity.PasswordHash != "hashed:new-password" {
|
|
44
|
+
t.Fatalf("password was not hashed through the password port: %+v", identity)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func TestService_LinkLocalIdentityRejectsSecondPasswordCredential(t *testing.T) {
|
|
49
|
+
userID := uuid.New()
|
|
50
|
+
hash := "already-hashed"
|
|
51
|
+
repo := &fakeRepo{user: &domain.User{ID: userID, Email: "user@example.com"}}
|
|
52
|
+
repo.identities = append(repo.identities, domain.Identity{
|
|
53
|
+
ID: uuid.New(), UserID: userID, Provider: domain.ProviderLocal, PasswordHash: &hash,
|
|
54
|
+
})
|
|
55
|
+
svc := newTestService(repo, newFakeTokenStore())
|
|
56
|
+
|
|
57
|
+
err := svc.LinkLocalIdentity(context.Background(), userID, "another-password")
|
|
58
|
+
var ruleErr *domain.RuleError
|
|
59
|
+
if !errors.As(err, &ruleErr) || ruleErr.Code != "AUTH_IDENTITY_ALREADY_LINKED" {
|
|
60
|
+
t.Fatalf("link duplicate local identity error = %v, want AUTH_IDENTITY_ALREADY_LINKED", err)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
31
64
|
func TestService_IdentityLinkIsIdempotentForSameProviderSubject(t *testing.T) {
|
|
32
65
|
provider, svc := validFakeProvider(t)
|
|
33
66
|
userID := uuid.New()
|
|
@@ -38,6 +38,9 @@ func (s *Service) throttled(ctx context.Context, key string) bool {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
func (s *Service) Register(ctx context.Context, in RegisterInput) (*AuthResult, error) {
|
|
41
|
+
if err := validatePassword(in.Password); err != nil {
|
|
42
|
+
return nil, err
|
|
43
|
+
}
|
|
41
44
|
hash, err := s.passwords.Hash(in.Password)
|
|
42
45
|
if err != nil {
|
|
43
46
|
return nil, fmt.Errorf("hash password: %w", err)
|
|
@@ -90,6 +90,7 @@ func validFakeProvider(t *testing.T) (*fakeLoginProvider, *Service) {
|
|
|
90
90
|
}
|
|
91
91
|
return ExternalIdentity{
|
|
92
92
|
Provider: "fake",
|
|
93
|
+
Issuer: "https://provider.example.test",
|
|
93
94
|
Subject: "subject-1",
|
|
94
95
|
Email: "user@example.com",
|
|
95
96
|
EmailVerified: true,
|
|
@@ -165,6 +166,7 @@ func TestService_ExchangeLoginRejectsIdentityFromAnotherProvider(t *testing.T) {
|
|
|
165
166
|
provider.completeFn = func(LoginCompleteInput) (ExternalIdentity, error) {
|
|
166
167
|
return ExternalIdentity{
|
|
167
168
|
Provider: "another-provider",
|
|
169
|
+
Issuer: "https://provider.example.test",
|
|
168
170
|
Subject: "subject-1",
|
|
169
171
|
Email: "user@example.com",
|
|
170
172
|
EmailVerified: true,
|