@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
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
|
package/dist/commands/auth.js
CHANGED
|
@@ -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:
|
|
52
|
-
//
|
|
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);
|
package/dist/commands/method.js
CHANGED
|
@@ -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",
|
|
@@ -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 = [
|
|
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
|
-
|
|
320
|
+
...mapping,
|
|
310
321
|
`}`,
|
|
311
322
|
"",
|
|
312
323
|
].join("\n"));
|
package/package.json
CHANGED
|
@@ -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"},
|
package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs
CHANGED
|
@@ -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
|
|
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
|
|
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"`
|