@nakedev/go-scaffold 0.5.3 → 0.5.5

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.
Files changed (47) hide show
  1. package/README.md +4 -0
  2. package/dist/commands/auth.js +19 -12
  3. package/dist/commands/method.js +2 -0
  4. package/dist/templates/rbac-manifest.js +1 -0
  5. package/dist/utils/auth-patcher.js +10 -1
  6. package/dist/utils/hexagonal-method-patcher.js +12 -1
  7. package/package.json +1 -1
  8. package/templates/add/auth/docs/schemas.yaml.hbs +10 -3
  9. package/templates/add/auth/docs/users-me-identity-local-link.yaml.hbs +16 -0
  10. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +7 -3
  11. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +2 -0
  12. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_identity.go.hbs +27 -0
  13. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +1 -0
  14. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +1 -1
  15. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +40 -17
  16. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +231 -74
  17. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository_test.go.hbs +11 -8
  18. package/templates/add/auth/internal/app/user/application/errors.go.hbs +4 -0
  19. package/templates/add/auth/internal/app/user/application/external_login.go.hbs +5 -5
  20. package/templates/add/auth/internal/app/user/application/identities.go.hbs +40 -2
  21. package/templates/add/auth/internal/app/user/application/identities_test.go.hbs +33 -0
  22. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +3 -0
  23. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +1 -0
  24. package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +2 -0
  25. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +3 -0
  26. package/templates/add/auth/internal/app/user/application/service.go.hbs +12 -0
  27. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +3 -3
  28. package/templates/add/auth/internal/app/user/application/user_query.go.hbs +3 -0
  29. package/templates/add/auth/internal/app/user/domain/entity.go.hbs +1 -0
  30. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +1 -0
  31. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +1 -1
  32. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +1 -0
  33. package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +7 -1
  34. package/templates/add/auth/migrations/create_external_identities.down.sql.hbs +1 -0
  35. package/templates/add/auth/migrations/create_external_identities.up.sql.hbs +12 -0
  36. package/templates/add/auth/migrations/create_password_credentials.down.sql.hbs +1 -0
  37. package/templates/add/auth/migrations/create_password_credentials.up.sql.hbs +11 -0
  38. package/templates/add/auth/migrations/create_user_emails.down.sql.hbs +1 -0
  39. package/templates/add/auth/migrations/create_user_emails.up.sql.hbs +14 -0
  40. package/templates/add/auth/migrations/create_users.up.sql.hbs +0 -11
  41. package/templates/add/rbac/internal/app/role/adapters/inbound/http/dto.go.hbs +52 -0
  42. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +12 -11
  43. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +10 -10
  44. package/templates/create/base/AGENTS.md.hbs +7 -1
  45. package/templates/create/base/README.md.hbs +3 -1
  46. package/templates/add/auth/migrations/create_identities.down.sql.hbs +0 -1
  47. package/templates/add/auth/migrations/create_identities.up.sql.hbs +0 -15
package/README.md CHANGED
@@ -478,6 +478,10 @@ Auth adds:
478
478
  - JWT access tokens and refresh-token rotation with reuse detection
479
479
  - registration, login, logout, refresh, password reset, and email verification
480
480
  - generic provider OAuth routes, with Google as the first adapter
481
+ - account identity storage split into `users`, `user_emails`,
482
+ `password_credentials`, and `external_identities`; a Google-only user can
483
+ add a password through `POST /users/me/identities/local` without creating a
484
+ second account
481
485
  - failed-login lockout and user-session management
482
486
  - MFA endpoints and configuration hooks
483
487
  - internal/app/user, auth middleware, cmd/seed, migrations, and OpenAPI
@@ -35,6 +35,7 @@ const AUTH_OPENAPI_PATHS = [
35
35
  { urlPath: "/auth/{provider}/exchange", file: "./auth/provider-exchange.yaml" },
36
36
  { urlPath: "/users/me", file: "./auth/users-me.yaml" },
37
37
  { urlPath: "/users/me/identities", file: "./auth/users-me-identities.yaml" },
38
+ { urlPath: "/users/me/identities/local", file: "./auth/users-me-identity-local-link.yaml" },
38
39
  { urlPath: "/users/me/identities/{provider}/link", file: "./auth/users-me-identity-link.yaml" },
39
40
  { urlPath: "/users/me/identities/{provider}/link/exchange", file: "./auth/users-me-identity-link-exchange.yaml" },
40
41
  { urlPath: "/users/me/identities/{provider}", file: "./auth/users-me-identity.yaml" },
@@ -48,8 +49,8 @@ const AUTH_OPENAPI_PATHS = [
48
49
  { urlPath: "/users/me/mfa/disable", file: "./auth/users-me-mfa-disable.yaml" },
49
50
  { urlPath: "/auth/mfa/verify", file: "./auth/mfa-verify.yaml" },
50
51
  ];
51
- // addAuth scaffolds email/password authentication: a users+identities model
52
- // pair, JWT access tokens, a selectable refresh token store with
52
+ // addAuth scaffolds email/password authentication: an account plus separate
53
+ // email, password-credential, and external-identity records, JWT access tokens, a selectable refresh token store with
53
54
  // rotation + reuse detection, register/login/refresh/logout/me, and
54
55
  // per-user session listing/revocation. No RBAC
55
56
  // (no roles/permissions) — that's a separate opt-in on top of this, since
@@ -90,20 +91,26 @@ async function addAuth(store = "postgres", projectDir = process.cwd(), browserTo
90
91
  { template: "add/auth/migrations/create_users.up.sql.hbs", output: path_1.default.join("migrations", `${usersVersion}_create_users.up.sql`) },
91
92
  { template: "add/auth/migrations/create_users.down.sql.hbs", output: path_1.default.join("migrations", `${usersVersion}_create_users.down.sql`) },
92
93
  ], {});
94
+ const emailVersion = (0, migrations_1.newMigrationVersion)(migrationsDir);
95
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, [
96
+ { template: "add/auth/migrations/create_user_emails.up.sql.hbs", output: path_1.default.join("migrations", `${emailVersion}_create_user_emails.up.sql`) },
97
+ { template: "add/auth/migrations/create_user_emails.down.sql.hbs", output: path_1.default.join("migrations", `${emailVersion}_create_user_emails.down.sql`) },
98
+ ], {});
99
+ const passwordVersion = (0, migrations_1.newMigrationVersion)(migrationsDir);
100
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, [
101
+ { template: "add/auth/migrations/create_password_credentials.up.sql.hbs", output: path_1.default.join("migrations", `${passwordVersion}_create_password_credentials.up.sql`) },
102
+ { template: "add/auth/migrations/create_password_credentials.down.sql.hbs", output: path_1.default.join("migrations", `${passwordVersion}_create_password_credentials.down.sql`) },
103
+ ], {});
104
+ const externalIdentityVersion = (0, migrations_1.newMigrationVersion)(migrationsDir);
105
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, [
106
+ { template: "add/auth/migrations/create_external_identities.up.sql.hbs", output: path_1.default.join("migrations", `${externalIdentityVersion}_create_external_identities.up.sql`) },
107
+ { template: "add/auth/migrations/create_external_identities.down.sql.hbs", output: path_1.default.join("migrations", `${externalIdentityVersion}_create_external_identities.down.sql`) },
108
+ ], {});
93
109
  const mfaVersion = (0, migrations_1.newMigrationVersion)(migrationsDir);
94
110
  await (0, template_renderer_1.applyTemplateEntries)(projectDir, [
95
111
  { template: "add/auth/migrations/create_mfa.up.sql.hbs", output: path_1.default.join("migrations", `${mfaVersion}_create_mfa.up.sql`) },
96
112
  { template: "add/auth/migrations/create_mfa.down.sql.hbs", output: path_1.default.join("migrations", `${mfaVersion}_create_mfa.down.sql`) },
97
113
  ], {});
98
- // identities references users(id) — must apply strictly after it. A
99
- // second newMigrationVersion() call, scanning the dir again now that the
100
- // users pair is already written, guarantees a later (or same-second,
101
- // bumped) timestamp rather than assuming +1 by hand.
102
- const identitiesVersion = (0, migrations_1.newMigrationVersion)(migrationsDir);
103
- await (0, template_renderer_1.applyTemplateEntries)(projectDir, [
104
- { template: "add/auth/migrations/create_identities.up.sql.hbs", output: path_1.default.join("migrations", `${identitiesVersion}_create_identities.up.sql`) },
105
- { template: "add/auth/migrations/create_identities.down.sql.hbs", output: path_1.default.join("migrations", `${identitiesVersion}_create_identities.down.sql`) },
106
- ], {});
107
114
  // the failed-attempt counter is not a store choice — lockout has to survive a
108
115
  // deploy and mean the same thing on every replica whichever store holds tokens
109
116
  const throttleVersion = (0, migrations_1.newMigrationVersion)(migrationsDir);
@@ -185,7 +192,7 @@ async function addAuth(store = "postgres", projectDir = process.cwd(), browserTo
185
192
  console.log("registered POST /auth/{register,login,refresh,logout,forgot-password,reset-password,verify-email}, " +
186
193
  "GET /auth/{provider}/login, POST /auth/{provider}/exchange, GET /users/me, and " +
187
194
  "POST /users/me/{resend-verification,logout-all,mfa/setup,mfa/confirm,mfa/disable}, " +
188
- "GET /users/me/{identities,sessions,mfa}, POST /users/me/identities/{provider}/{link,link/exchange}, " +
195
+ "GET /users/me/{identities,sessions,mfa}, POST /users/me/identities/local, POST /users/me/identities/{provider}/{link,link/exchange}, " +
189
196
  "DELETE /users/me/identities/{provider}, DELETE /users/me/sessions/{id}, " +
190
197
  "POST /auth/mfa/verify in cmd/api/wiring.go" +
191
198
  docsMessage);
@@ -84,6 +84,8 @@ async function generateHexagonalMethod(config, naming, methodNameArg, opts, proj
84
84
  ? {
85
85
  repositoryModelType: "User",
86
86
  repositoryToDomain: "toDomainUser",
87
+ repositoryToDomainCall: "r.toDomainUser(ctx, &row)",
88
+ repositoryToDomainCallReturnsError: true,
87
89
  repositoryErrorMapper: "persistenceError",
88
90
  repositoryStubReceiver: "f *fakeRepo",
89
91
  handlerErrorMapper: "toHTTPError",
@@ -14,6 +14,7 @@ exports.RBAC_FILES = [
14
14
  { template: "add/rbac/internal/app/role/application/errors.go.hbs", output: "internal/app/role/application/errors.go" },
15
15
  { template: "add/rbac/internal/app/role/application/service.go.hbs", output: "internal/app/role/application/service.go" },
16
16
  { template: "add/rbac/internal/app/role/application/service_test.go.hbs", output: "internal/app/role/application/service_test.go" },
17
+ { template: "add/rbac/internal/app/role/adapters/inbound/http/dto.go.hbs", output: "internal/app/role/adapters/inbound/http/dto.go" },
17
18
  { template: "add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs", output: "internal/app/role/adapters/inbound/http/handler.go" },
18
19
  { template: "add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs", output: "internal/app/role/adapters/inbound/http/handler_test.go" },
19
20
  { template: "add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs", output: "internal/app/role/adapters/outbound/postgres/model.go" },
@@ -122,6 +122,9 @@ function patchMainGoForAuth(mainGoPath, w) {
122
122
  'if cfg.IsProd() && cfg.JWTSecret == "dev-secret-change-me" {',
123
123
  '\treturn errors.New("JWT_SECRET is still the dev default — set a real secret before deploying with APP_ENV=production")',
124
124
  "}",
125
+ 'if cfg.IsProd() && len([]byte(cfg.JWTSecret)) < 32 {',
126
+ '\treturn errors.New("JWT_SECRET must be at least 32 bytes before deploying with APP_ENV=production")',
127
+ "}",
125
128
  ].join("\n");
126
129
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_CHECKS_MARKER, checkBlock, "JWT_SECRET is still the dev default");
127
130
  // Without SMTP the mail client logs the message instead of sending it — a
@@ -164,7 +167,13 @@ function patchMainGoForAuth(mainGoPath, w) {
164
167
  "}",
165
168
  ].join("\n");
166
169
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SCHEMA_MARKER, schemaBlock, "CREATE SCHEMA IF NOT EXISTS user_svc");
167
- const migrateLines = ["&usermodel.User{},", "&usermodel.Identity{},", "&usermodel.LoginThrottle{},"];
170
+ const migrateLines = [
171
+ "&usermodel.User{},",
172
+ "&usermodel.UserEmail{},",
173
+ "&usermodel.PasswordCredential{},",
174
+ "&usermodel.ExternalIdentity{},",
175
+ "&usermodel.LoginThrottle{},",
176
+ ];
168
177
  // Recovery tokens are always durable in Postgres, even when refresh tokens
169
178
  // live in Redis, so reset/verification can share the user transaction.
170
179
  migrateLines.push("&usermodel.AuthToken{},");
@@ -299,14 +299,25 @@ function patchHexagonalMethod(paths, naming, method, opts, goModule) {
299
299
  const column = (0, naming_1.toDbName)(opts.field);
300
300
  const modelType = paths.repositoryModelType ?? `${naming.pascalName}Model`;
301
301
  const toDomain = paths.repositoryToDomain ?? "toDomain";
302
+ const toDomainCall = paths.repositoryToDomainCall ?? `${toDomain}(&row)`;
303
+ const toDomainCallReturnsError = paths.repositoryToDomainCallReturnsError ?? false;
302
304
  const errorMapper = paths.repositoryErrorMapper ?? "mapDatabaseError";
305
+ const mapping = toDomainCallReturnsError
306
+ ? [
307
+ `\tm, err := ${toDomainCall}`,
308
+ `\tif err != nil {`,
309
+ `\t\treturn nil, ${errorMapper}(err)`,
310
+ `\t}`,
311
+ `\treturn m, nil`,
312
+ ]
313
+ : [`\treturn ${toDomainCall}, nil`];
303
314
  adapter = insert(adapter, REPOSITORY_METHODS_MARKER, [
304
315
  `func (r *Repository) FindBy${fieldPascal}(ctx context.Context, ${fieldParam} string) (*domain.${naming.pascalName}, error) {`,
305
316
  `\tvar row ${modelType}`,
306
317
  `\tif err := tx.From(ctx, r.db).WithContext(ctx).First(&row, "${column} = ?", ${fieldParam}).Error; err != nil {`,
307
318
  `\t\treturn nil, ${errorMapper}(err)`,
308
319
  `\t}`,
309
- `\treturn ${toDomain}(&row), nil`,
320
+ ...mapping,
310
321
  `}`,
311
322
  "",
312
323
  ].join("\n"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nakedev/go-scaffold",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard",
5
5
  "repository": {
6
6
  "type": "git",
@@ -3,7 +3,7 @@ RegisterInput:
3
3
  required: [email, password, name]
4
4
  properties:
5
5
  email: { type: string, format: email }
6
- password: { type: string, format: password, minLength: 8 }
6
+ password: { type: string, format: password, minLength: 8, maxLength: 72 }
7
7
  name: { type: string }
8
8
 
9
9
  LoginInput:
@@ -11,7 +11,7 @@ LoginInput:
11
11
  required: [email, password]
12
12
  properties:
13
13
  email: { type: string, format: email }
14
- password: { type: string, format: password }
14
+ password: { type: string, format: password, maxLength: 72 }
15
15
 
16
16
  ForgotPasswordInput:
17
17
  type: object
@@ -24,7 +24,7 @@ ResetPasswordInput:
24
24
  required: [token, new_password]
25
25
  properties:
26
26
  token: { type: string }
27
- new_password: { type: string, format: password, minLength: 8 }
27
+ new_password: { type: string, format: password, minLength: 8, maxLength: 72 }
28
28
 
29
29
  VerifyEmailInput:
30
30
  type: object
@@ -168,3 +168,10 @@ IdentityLinkStartResponse:
168
168
  required: [authorization_url]
169
169
  properties:
170
170
  authorization_url: { type: string, format: uri }
171
+
172
+ LocalIdentityLinkInput:
173
+ type: object
174
+ required: [password]
175
+ additionalProperties: false
176
+ properties:
177
+ password: { type: string, format: password, minLength: 8, maxLength: 72 }
@@ -0,0 +1,16 @@
1
+ post:
2
+ summary: Add an email/password login to the current user
3
+ description: Creates a password credential for the authenticated account. It does not create another account, even when the account originally signed in with Google.
4
+ operationId: linkMyLocalIdentity
5
+ tags: [users]
6
+ security: [{ bearerAuth: [] }]
7
+ requestBody:
8
+ required: true
9
+ content:
10
+ application/json:
11
+ schema: { $ref: './schemas.yaml#/LocalIdentityLinkInput' }
12
+ responses:
13
+ "204": { description: email/password login linked }
14
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
15
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
16
+ "409": { description: email/password login is already linked }
@@ -13,13 +13,13 @@ import (
13
13
  // transport-neutral inputs from handler mapping code.
14
14
  type registerInput struct {
15
15
  Email string `json:"email" binding:"required,email"`
16
- Password string `json:"password" binding:"required,min=8"`
16
+ Password string `json:"password" binding:"required,min=8,max=72"`
17
17
  Name string `json:"name" binding:"required"`
18
18
  }
19
19
 
20
20
  type loginInput struct {
21
21
  Email string `json:"email" binding:"required,email"`
22
- Password string `json:"password" binding:"required"`
22
+ Password string `json:"password" binding:"required,max=72"`
23
23
  }
24
24
 
25
25
  type loginExchangeInput struct {
@@ -62,7 +62,7 @@ type forgotPasswordInput struct {
62
62
 
63
63
  type resetPasswordInput struct {
64
64
  Token string `json:"token" binding:"required"`
65
- NewPassword string `json:"new_password" binding:"required,min=8"`
65
+ NewPassword string `json:"new_password" binding:"required,min=8,max=72"`
66
66
  }
67
67
 
68
68
  type verifyEmailInput struct {
@@ -128,6 +128,10 @@ type identityLinkExchangeInput struct {
128
128
  CodeVerifier string `json:"code_verifier" binding:"required"`
129
129
  }
130
130
 
131
+ type localIdentityLinkInput struct {
132
+ Password string `json:"password" binding:"required,min=8,max=72"`
133
+ }
134
+
131
135
  type identityLinkStartResponse struct {
132
136
  AuthorizationURL string `json:"authorization_url"`
133
137
  }
@@ -215,6 +215,8 @@ func toHTTPError(err error) error {
215
215
  status = http.StatusNotFound
216
216
  case "AUTH_INVALID_CREDENTIALS", "AUTH_INVALID_TOKEN", "AUTH_MFA_INVALID":
217
217
  status = http.StatusUnauthorized
218
+ case "AUTH_INVALID_PASSWORD":
219
+ status = http.StatusBadRequest
218
220
  case "AUTH_MFA_UNAVAILABLE":
219
221
  status = http.StatusServiceUnavailable
220
222
  case "AUTH_TOO_MANY_ATTEMPTS":
@@ -14,11 +14,29 @@ import (
14
14
  // route file while the handler remains in the same inbound adapter package.
15
15
  func (h *Handler) registerIdentityRoutes(usersGroup gin.IRouter, linkLimiter gin.HandlerFunc) {
16
16
  usersGroup.GET("/me/identities", h.listIdentities)
17
+ usersGroup.POST("/me/identities/local", linkLimiter, h.linkLocalIdentity)
17
18
  usersGroup.POST("/me/identities/:provider/link", linkLimiter, h.beginIdentityLink)
18
19
  usersGroup.POST("/me/identities/:provider/link/exchange", linkLimiter, h.exchangeIdentityLink)
19
20
  usersGroup.DELETE("/me/identities/:provider", h.unlinkIdentity)
20
21
  }
21
22
 
23
+ func (h *Handler) linkLocalIdentity(c *gin.Context) {
24
+ setNoStoreHeaders(c)
25
+ if !h.requireBrowserOrigin(c) {
26
+ return
27
+ }
28
+ var in localIdentityLinkInput
29
+ if err := c.ShouldBindJSON(&in); err != nil {
30
+ c.Error(httpx.BindErr(err))
31
+ return
32
+ }
33
+ if err := h.svc.LinkLocalIdentity(c.Request.Context(), currentUserID(c), in.Password); err != nil {
34
+ c.Error(toHTTPError(err))
35
+ return
36
+ }
37
+ c.Status(http.StatusNoContent)
38
+ }
39
+
22
40
  func (h *Handler) listIdentities(c *gin.Context) {
23
41
  setNoStoreHeaders(c)
24
42
  items, err := h.svc.ListIdentities(c.Request.Context(), currentUserID(c))
@@ -35,6 +53,9 @@ func (h *Handler) listIdentities(c *gin.Context) {
35
53
 
36
54
  func (h *Handler) beginIdentityLink(c *gin.Context) {
37
55
  setNoStoreHeaders(c)
56
+ if !h.requireBrowserOrigin(c) {
57
+ return
58
+ }
38
59
  var in identityLinkStartInput
39
60
  if err := c.ShouldBindJSON(&in); err != nil {
40
61
  c.Error(httpx.BindErr(err))
@@ -50,6 +71,9 @@ func (h *Handler) beginIdentityLink(c *gin.Context) {
50
71
 
51
72
  func (h *Handler) exchangeIdentityLink(c *gin.Context) {
52
73
  setNoStoreHeaders(c)
74
+ if !h.requireBrowserOrigin(c) {
75
+ return
76
+ }
53
77
  var in identityLinkExchangeInput
54
78
  if err := c.ShouldBindJSON(&in); err != nil {
55
79
  c.Error(h.oauthAppError(c.Param("provider"), userapp.NewOAuthError(userapp.OAuthFailed, err)))
@@ -72,6 +96,9 @@ func (h *Handler) identityLinkError(provider string, err error) error {
72
96
  }
73
97
 
74
98
  func (h *Handler) unlinkIdentity(c *gin.Context) {
99
+ if !h.requireBrowserOrigin(c) {
100
+ return
101
+ }
75
102
  if err := h.svc.UnlinkIdentity(c.Request.Context(), currentUserID(c), c.Param("provider")); err != nil {
76
103
  c.Error(toHTTPError(err))
77
104
  return
@@ -158,6 +158,7 @@ func TestHandler_SessionRoutesAreProtected(t *testing.T) {
158
158
  router := newOAuthTestRouter(t)
159
159
  for _, route := range []struct{ method, path string }{
160
160
  {http.MethodGet, "/users/me/identities"},
161
+ {http.MethodPost, "/users/me/identities/local"},
161
162
  {http.MethodPost, "/users/me/identities/fake/link"},
162
163
  {http.MethodPost, "/users/me/identities/fake/link/exchange"},
163
164
  {http.MethodDelete, "/users/me/identities/fake"},
@@ -53,7 +53,7 @@ func TestPostgresMFAStore_ConsumesChallengesAndRecoveryCodesOnce(t *testing.T) {
53
53
  store := NewPostgresMFAStore(db)
54
54
  ctx := context.Background()
55
55
  userID := uuid.New()
56
- if err := db.Create(&User{ID: userID, Email: "mfa-store-" + uuid.NewString() + "@example.com"}).Error; err != nil {
56
+ if err := db.Create(&User{ID: userID}).Error; err != nil {
57
57
  t.Fatalf("create test user: %v", err)
58
58
  }
59
59
  t.Cleanup(func() { _ = db.Delete(&User{}, "id = ?", userID).Error })
@@ -9,29 +9,52 @@ import (
9
9
  )
10
10
 
11
11
  type User struct {
12
- ID uuid.UUID `gorm:"type:uuid;primaryKey"`
13
- Email string `gorm:"uniqueIndex:idx_users_email;not null"`
14
- Name string
15
- AvatarURL string
16
- EmailVerified bool
17
- Role string `gorm:"type:varchar(20);not null;default:'staff'"`
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 Identity struct {
25
- ID uuid.UUID `gorm:"type:uuid;primaryKey"`
26
- UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_identities_user_provider,priority:1"`
27
- Provider string `gorm:"type:varchar(20);not null;uniqueIndex:idx_identities_user_provider,priority:2;uniqueIndex:idx_identities_provider_uid,priority:1"`
28
- PasswordHash *string
29
- ProviderUID *string `gorm:"column:provider_uid;uniqueIndex:idx_identities_provider_uid,priority:2"`
30
- CreatedAt time.Time
31
- UpdatedAt time.Time
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 (Identity) TableName() string { return "user_svc.identities" }
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"`