@nakedev/go-scaffold 0.5.2 → 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 +25 -13
- package/dist/commands/method.js +2 -0
- package/dist/templates/auth-manifest.js +3 -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 +45 -3
- package/templates/add/auth/docs/users-me-identities.yaml.hbs +13 -0
- package/templates/add/auth/docs/users-me-identity-link-exchange.yaml.hbs +26 -0
- package/templates/add/auth/docs/users-me-identity-link.yaml.hbs +25 -0
- package/templates/add/auth/docs/users-me-identity-local-link.yaml.hbs +16 -0
- package/templates/add/auth/docs/users-me-identity.yaml.hbs +15 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +45 -3
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +7 -1
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_identity.go.hbs +107 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +21 -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 +253 -53
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository_test.go.hbs +11 -8
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +4 -2
- package/templates/add/auth/internal/app/user/application/dto.go.hbs +14 -0
- package/templates/add/auth/internal/app/user/application/errors.go.hbs +20 -0
- package/templates/add/auth/internal/app/user/application/external_login.go.hbs +47 -18
- package/templates/add/auth/internal/app/user/application/identities.go.hbs +135 -0
- package/templates/add/auth/internal/app/user/application/identities_test.go.hbs +102 -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 +16 -0
- package/templates/add/auth/internal/app/user/application/service_test.go.hbs +75 -4
- 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 +4 -1
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +4 -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/.claude/skills/go-scaffold/SKILL.md.hbs +9 -0
- package/templates/create/base/AGENTS.md.hbs +14 -0
- package/templates/create/base/README.md.hbs +7 -0
- package/templates/create/features/docs/architecture.md.hbs +5 -5
- package/templates/create/features/docs/techstack.md.hbs +1 -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
|
@@ -9,29 +9,52 @@ import (
|
|
|
9
9
|
)
|
|
10
10
|
|
|
11
11
|
type User struct {
|
|
12
|
-
ID
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
CreatedAt time.Time
|
|
19
|
-
UpdatedAt time.Time
|
|
12
|
+
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
|
13
|
+
Name string
|
|
14
|
+
AvatarURL string
|
|
15
|
+
Role string `gorm:"type:varchar(20);not null;default:'staff'"`
|
|
16
|
+
CreatedAt time.Time
|
|
17
|
+
UpdatedAt time.Time
|
|
20
18
|
}
|
|
21
19
|
|
|
22
20
|
func (User) TableName() string { return "user_svc.users" }
|
|
23
21
|
|
|
24
|
-
type
|
|
25
|
-
ID
|
|
26
|
-
UserID
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
type UserEmail struct {
|
|
23
|
+
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
|
24
|
+
UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_user_emails_user;uniqueIndex:idx_user_emails_primary,where:is_primary"`
|
|
25
|
+
Email string `gorm:"type:varchar(255);not null"`
|
|
26
|
+
EmailNormalized string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_emails_normalized"`
|
|
27
|
+
IsPrimary bool `gorm:"not null;default:true"`
|
|
28
|
+
VerifiedAt *time.Time
|
|
29
|
+
CreatedAt time.Time
|
|
30
|
+
UpdatedAt time.Time
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func (UserEmail) TableName() string { return "user_svc.user_emails" }
|
|
34
|
+
|
|
35
|
+
type PasswordCredential struct {
|
|
36
|
+
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
|
37
|
+
UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_password_credentials_user"`
|
|
38
|
+
PasswordHash string `gorm:"column:password_hash;type:text;not null"`
|
|
39
|
+
HashAlgorithm string `gorm:"type:varchar(32);not null;default:'bcrypt'"`
|
|
40
|
+
PasswordChangedAt time.Time `gorm:"not null"`
|
|
41
|
+
CreatedAt time.Time
|
|
42
|
+
UpdatedAt time.Time
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func (PasswordCredential) TableName() string { return "user_svc.password_credentials" }
|
|
46
|
+
|
|
47
|
+
type ExternalIdentity struct {
|
|
48
|
+
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
|
49
|
+
UserID uuid.UUID `gorm:"type:uuid;not null"`
|
|
50
|
+
Provider string `gorm:"type:varchar(50);not null;uniqueIndex:idx_external_identities_user_provider,priority:2"`
|
|
51
|
+
Issuer string `gorm:"type:text;not null;uniqueIndex:idx_external_identities_issuer_subject,priority:1"`
|
|
52
|
+
Subject string `gorm:"type:text;not null;uniqueIndex:idx_external_identities_issuer_subject,priority:2"`
|
|
53
|
+
CreatedAt time.Time
|
|
54
|
+
UpdatedAt time.Time
|
|
32
55
|
}
|
|
33
56
|
|
|
34
|
-
func (
|
|
57
|
+
func (ExternalIdentity) TableName() string { return "user_svc.external_identities" }
|
|
35
58
|
|
|
36
59
|
type AuthToken struct {
|
|
37
60
|
TokenHash string `gorm:"primaryKey;type:text"`
|
|
@@ -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"
|
|
@@ -12,6 +14,7 @@ import (
|
|
|
12
14
|
|
|
13
15
|
"github.com/google/uuid"
|
|
14
16
|
"gorm.io/gorm"
|
|
17
|
+
"gorm.io/gorm/clause"
|
|
15
18
|
)
|
|
16
19
|
|
|
17
20
|
type Repository struct {
|
|
@@ -25,24 +28,52 @@ func NewRepository(db *gorm.DB) *Repository {
|
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
func (r *Repository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
|
|
28
|
-
|
|
29
|
-
|
|
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 {
|
|
30
34
|
return nil, persistenceError(err)
|
|
31
35
|
}
|
|
32
|
-
return
|
|
36
|
+
return findUserWithEmail(db, emailRow.UserID, &emailRow)
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (*domain.User, error) {
|
|
36
|
-
|
|
37
|
-
|
|
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 {
|
|
38
43
|
return nil, persistenceError(err)
|
|
39
44
|
}
|
|
40
|
-
return
|
|
45
|
+
return findUserWithEmail(db, id, &emailRow)
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
func (r *Repository) UpdateUser(ctx context.Context, user *domain.User) error {
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
}))
|
|
46
77
|
}
|
|
47
78
|
|
|
48
79
|
func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]domain.User, error) {
|
|
@@ -52,53 +83,161 @@ func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]domain.U
|
|
|
52
83
|
if err != nil {
|
|
53
84
|
return nil, persistenceError(err)
|
|
54
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
|
+
}
|
|
55
101
|
items := make([]domain.User, len(rows))
|
|
56
102
|
for i := range rows {
|
|
57
|
-
items[i] = *
|
|
103
|
+
items[i] = *toDomainUserWithEmail(&rows[i], emails[rows[i].ID])
|
|
58
104
|
}
|
|
59
105
|
return items, nil
|
|
60
106
|
}
|
|
61
107
|
|
|
62
108
|
func (r *Repository) FindIdentity(ctx context.Context, userID uuid.UUID, provider domain.Provider) (*domain.Identity, error) {
|
|
63
|
-
|
|
64
|
-
if
|
|
65
|
-
|
|
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 {
|
|
66
119
|
return nil, persistenceError(err)
|
|
67
120
|
}
|
|
68
|
-
return
|
|
121
|
+
return toDomainExternalIdentity(&row), nil
|
|
69
122
|
}
|
|
70
123
|
|
|
71
|
-
func (r *Repository)
|
|
72
|
-
|
|
124
|
+
func (r *Repository) ListIdentities(ctx context.Context, userID uuid.UUID) ([]domain.Identity, error) {
|
|
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 {
|
|
128
|
+
return nil, persistenceError(err)
|
|
129
|
+
}
|
|
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]))
|
|
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
|
+
})
|
|
147
|
+
return items, nil
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func (r *Repository) FindIdentityByProviderUID(ctx context.Context, issuer, subject string) (*domain.Identity, error) {
|
|
151
|
+
var row ExternalIdentity
|
|
73
152
|
if err := tx.From(ctx, r.db).WithContext(ctx).
|
|
74
|
-
First(&row, "
|
|
153
|
+
First(&row, "issuer = ? AND subject = ?", issuer, subject).Error; err != nil {
|
|
75
154
|
return nil, persistenceError(err)
|
|
76
155
|
}
|
|
77
|
-
return
|
|
156
|
+
return toDomainExternalIdentity(&row), nil
|
|
78
157
|
}
|
|
79
158
|
|
|
80
159
|
// CreateIdentity links a new login method onto an existing user.
|
|
81
160
|
func (r *Repository) CreateIdentity(ctx context.Context, identity *domain.Identity) error {
|
|
82
|
-
|
|
83
|
-
return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Create(&row).Error)
|
|
161
|
+
return persistenceError(createIdentity(tx.From(ctx, r.db).WithContext(ctx), identity))
|
|
84
162
|
}
|
|
85
163
|
|
|
86
164
|
func (r *Repository) UpdateIdentity(ctx context.Context, identity *domain.Identity) error {
|
|
87
|
-
|
|
88
|
-
|
|
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)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// DeleteIdentity locks the user's identity rows before checking the count so
|
|
184
|
+
// two concurrent unlink requests cannot both remove the last login method.
|
|
185
|
+
func (r *Repository) DeleteIdentity(ctx context.Context, userID uuid.UUID, provider domain.Provider) error {
|
|
186
|
+
return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
|
|
187
|
+
var user User
|
|
188
|
+
if err := db.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id = ?", userID).Error; err != nil {
|
|
189
|
+
return err
|
|
190
|
+
}
|
|
191
|
+
var passwordCount, externalCount int64
|
|
192
|
+
if err := db.Model(&PasswordCredential{}).Where("user_id = ?", userID).Count(&passwordCount).Error; err != nil {
|
|
193
|
+
return err
|
|
194
|
+
}
|
|
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 {
|
|
200
|
+
return domain.ErrNotFound
|
|
201
|
+
}
|
|
202
|
+
if total <= 1 {
|
|
203
|
+
return domain.ErrLastIdentity
|
|
204
|
+
}
|
|
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
|
|
217
|
+
}))
|
|
89
218
|
}
|
|
90
219
|
|
|
91
220
|
// CreateUserWithIdentity inserts the profile and its first login method in
|
|
92
221
|
// one transaction. A user without an identity cannot authenticate.
|
|
93
222
|
func (r *Repository) CreateUserWithIdentity(ctx context.Context, user *domain.User, identity *domain.Identity) error {
|
|
94
223
|
userRow := fromDomainUser(user)
|
|
95
|
-
identityRow := fromDomainIdentity(identity)
|
|
96
224
|
return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
|
|
97
225
|
if err := db.Create(&userRow).Error; err != nil {
|
|
98
226
|
return persistenceError(err)
|
|
99
227
|
}
|
|
100
|
-
|
|
101
|
-
|
|
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))
|
|
102
241
|
}))
|
|
103
242
|
}
|
|
104
243
|
|
|
@@ -151,16 +290,48 @@ func persistenceError(err error) error {
|
|
|
151
290
|
return err
|
|
152
291
|
}
|
|
153
292
|
|
|
154
|
-
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 {
|
|
155
320
|
if row == nil {
|
|
156
321
|
return nil
|
|
157
322
|
}
|
|
323
|
+
var email string
|
|
324
|
+
var verified bool
|
|
325
|
+
if emailRow != nil {
|
|
326
|
+
email = emailRow.Email
|
|
327
|
+
verified = emailRow.VerifiedAt != nil
|
|
328
|
+
}
|
|
158
329
|
return &domain.User{
|
|
159
330
|
ID: row.ID,
|
|
160
|
-
Email:
|
|
331
|
+
Email: email,
|
|
161
332
|
Name: row.Name,
|
|
162
333
|
AvatarURL: row.AvatarURL,
|
|
163
|
-
EmailVerified:
|
|
334
|
+
EmailVerified: verified,
|
|
164
335
|
Role: row.Role,
|
|
165
336
|
CreatedAt: row.CreatedAt,
|
|
166
337
|
UpdatedAt: row.UpdatedAt,
|
|
@@ -169,42 +340,71 @@ func toDomainUser(row *User) *domain.User {
|
|
|
169
340
|
|
|
170
341
|
func fromDomainUser(user *domain.User) User {
|
|
171
342
|
return User{
|
|
172
|
-
ID:
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
EmailVerified: user.EmailVerified,
|
|
177
|
-
Role: user.Role,
|
|
343
|
+
ID: user.ID,
|
|
344
|
+
Name: user.Name,
|
|
345
|
+
AvatarURL: user.AvatarURL,
|
|
346
|
+
Role: user.Role,
|
|
178
347
|
CreatedAt: user.CreatedAt,
|
|
179
348
|
UpdatedAt: user.UpdatedAt,
|
|
180
349
|
}
|
|
181
350
|
}
|
|
182
351
|
|
|
183
|
-
func
|
|
352
|
+
func toDomainPasswordIdentity(row *PasswordCredential) *domain.Identity {
|
|
184
353
|
if row == nil {
|
|
185
354
|
return nil
|
|
186
355
|
}
|
|
356
|
+
hash := row.PasswordHash
|
|
187
357
|
return &domain.Identity{
|
|
188
|
-
ID:
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
return Identity{
|
|
200
|
-
ID:
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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")
|
|
207
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
|
|
208
408
|
}
|
|
209
409
|
|
|
210
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)
|
package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs
CHANGED
|
@@ -176,6 +176,7 @@ func (s *PgTokenStore) SetLoginTransaction(ctx context.Context, stateHash string
|
|
|
176
176
|
}
|
|
177
177
|
row := AuthToken{
|
|
178
178
|
TokenHash: stateHash,
|
|
179
|
+
UserID: transaction.UserID,
|
|
179
180
|
Kind: kindOAuthState,
|
|
180
181
|
ExpiresAt: transaction.ExpiresAt,
|
|
181
182
|
Provider: transaction.Provider,
|
|
@@ -185,7 +186,7 @@ func (s *PgTokenStore) SetLoginTransaction(ctx context.Context, stateHash string
|
|
|
185
186
|
return tx.From(ctx, s.db).WithContext(ctx).
|
|
186
187
|
Where("token_hash = ?", stateHash).
|
|
187
188
|
Assign(map[string]any{
|
|
188
|
-
"user_id":
|
|
189
|
+
"user_id": transaction.UserID,
|
|
189
190
|
"kind": kindOAuthState,
|
|
190
191
|
"expires_at": transaction.ExpiresAt,
|
|
191
192
|
"provider": transaction.Provider,
|
|
@@ -199,8 +200,9 @@ func (s *PgTokenStore) ConsumeLoginTransaction(ctx context.Context, stateHash st
|
|
|
199
200
|
err := tx.From(ctx, s.db).WithContext(ctx).Raw(
|
|
200
201
|
`DELETE FROM user_svc.auth_tokens
|
|
201
202
|
WHERE token_hash = ? AND kind = ? AND expires_at > now()
|
|
202
|
-
RETURNING provider, code_challenge, nonce, expires_at`,
|
|
203
|
+
RETURNING user_id, provider, code_challenge, nonce, expires_at`,
|
|
203
204
|
stateHash, kindOAuthState).Row().Scan(
|
|
205
|
+
&transaction.UserID,
|
|
204
206
|
&transaction.Provider,
|
|
205
207
|
&transaction.CodeChallenge,
|
|
206
208
|
&transaction.Nonce,
|
|
@@ -43,6 +43,20 @@ type Session struct {
|
|
|
43
43
|
Current bool
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// IdentityResponse is the safe public view of a login identity. Provider
|
|
47
|
+
// subjects and password hashes never cross the application/HTTP boundary.
|
|
48
|
+
type IdentityResponse struct {
|
|
49
|
+
ID uuid.UUID
|
|
50
|
+
Provider string
|
|
51
|
+
CreatedAt time.Time
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func ToIdentityResponse(identity domain.Identity) IdentityResponse {
|
|
55
|
+
return IdentityResponse{
|
|
56
|
+
ID: identity.ID, Provider: string(identity.Provider), CreatedAt: identity.CreatedAt,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
46
60
|
type AuthResponse struct {
|
|
47
61
|
AccessToken string
|
|
48
62
|
RefreshToken string
|
|
@@ -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
|
}
|
|
@@ -60,3 +64,19 @@ func errMFAConfig() error {
|
|
|
60
64
|
func errUnknownRole() error {
|
|
61
65
|
return domain.Rule("USER_UNKNOWN_ROLE", "unknown role code", domain.ErrUnknownRole)
|
|
62
66
|
}
|
|
67
|
+
|
|
68
|
+
func errIdentityAlreadyLinked() error {
|
|
69
|
+
return domain.Rule("AUTH_IDENTITY_ALREADY_LINKED", "this login method is already linked", domain.ErrIdentityAlreadyLinked)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func errIdentityConflict() error {
|
|
73
|
+
return domain.Rule("AUTH_IDENTITY_CONFLICT", "this provider account is linked to another user", domain.ErrConflict)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func errIdentityNotFound() error {
|
|
77
|
+
return domain.Rule("AUTH_IDENTITY_NOT_FOUND", "login method not found", domain.ErrNotFound)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func errLastIdentity() error {
|
|
81
|
+
return domain.Rule("AUTH_LAST_IDENTITY", "you cannot remove your last login method", domain.ErrLastIdentity)
|
|
82
|
+
}
|