@nakedev/go-scaffold 0.1.2 → 0.1.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 +93 -14
- package/dist/commands/auth.js +129 -0
- package/dist/commands/create.js +3 -2
- package/dist/commands/generate.js +61 -2
- package/dist/commands/method.js +66 -15
- package/dist/commands/migration.js +34 -0
- package/dist/commands/rbac.js +103 -0
- package/dist/commands/remove.js +36 -20
- package/dist/commands/worker.js +75 -0
- package/dist/index.js +71 -7
- package/dist/prompts/create-wizard.js +6 -1
- package/dist/prompts/generate-wizard.js +8 -0
- package/dist/templates/auth-manifest.js +19 -0
- package/dist/templates/create-manifest.js +25 -0
- package/dist/templates/module-manifest.js +2 -0
- package/dist/templates/rbac-manifest.js +17 -0
- package/dist/templates/worker-manifest.js +12 -0
- package/dist/utils/auth-patcher.js +96 -0
- package/dist/utils/gocheck.js +65 -0
- package/dist/utils/main-patcher.js +8 -1
- package/dist/utils/method-patcher.js +80 -16
- package/dist/utils/migrations.js +30 -8
- package/dist/utils/module-location.js +22 -0
- package/dist/utils/naming.js +75 -12
- package/dist/utils/openapi-patcher.js +35 -1
- package/dist/utils/platform-patcher.js +59 -0
- package/dist/utils/rbac-patcher.js +277 -0
- package/dist/utils/smoke-run.js +31 -0
- package/dist/utils/version.js +24 -0
- package/package.json +15 -6
- package/scripts/smoke-test.mjs +2058 -0
- package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
- package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
- package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
- package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
- package/templates/add/auth/docs/login.yaml.hbs +19 -0
- package/templates/add/auth/docs/logout.yaml.hbs +8 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
- package/templates/add/auth/docs/register.yaml.hbs +19 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
- package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
- package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
- package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
- package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
- package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
- package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
- package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
- package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
- package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
- package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
- package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
- package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
- package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
- package/templates/add/rbac/docs/role.yaml.hbs +17 -0
- package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
- package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
- package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
- package/templates/add/rbac/docs/user.yaml.hbs +16 -0
- package/templates/add/rbac/docs/users.yaml.hbs +23 -0
- package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
- package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
- package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
- package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
- package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
- package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
- package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
- package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
- package/templates/create/base/.env.example.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +19 -4
- package/templates/create/base/.gitignore.hbs +2 -0
- package/templates/create/base/AGENTS.md.hbs +10 -12
- package/templates/create/base/Makefile.hbs +39 -8
- package/templates/create/base/README.md.hbs +52 -12
- package/templates/create/base/cmd/api/main.go.hbs +26 -1
- package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
- package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
- package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
- package/templates/create/base/migrations/embed.go.hbs +15 -0
- package/templates/create/features/docs/architecture.md.hbs +22 -0
- package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
- package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
- package/templates/create/features/docs/openapi.yaml.hbs +17 -5
- package/templates/create/features/docs/patterns.md.hbs +9 -6
- package/templates/create/features/docs/techstack.md.hbs +3 -0
- package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
- package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
- package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
- package/templates/generate/module/docs/item.yaml.hbs +3 -3
- package/templates/generate/module/handler.go.hbs +37 -6
- package/templates/generate/module/handler_test.go.hbs +113 -51
- package/templates/generate/module/migration.down.sql.hbs +1 -1
- package/templates/generate/module/migration.up.sql.hbs +1 -1
- package/templates/generate/module/minimal/handler.go.hbs +28 -4
- package/templates/generate/module/minimal/handler_test.go.hbs +5 -65
- package/templates/generate/module/minimal/service_test.go.hbs +39 -16
- package/templates/generate/module/model/model.go.hbs +7 -0
- package/templates/generate/module/permission.down.sql.hbs +5 -0
- package/templates/generate/module/permission.up.sql.hbs +4 -0
- package/templates/generate/module/repository_test.go.hbs +79 -0
- package/templates/generate/module/service.go.hbs +6 -4
- package/templates/generate/module/service_test.go.hbs +68 -17
- package/tests/integration/default-module.test.mjs +46 -0
- package/tests/integration/generator-naming.test.mjs +81 -0
- package/tests/integration/generator-unit-test-seams.test.mjs +91 -0
- package/tests/integration/legacy-method-compat.test.mjs +222 -0
- package/tests/integration/remove-module.test.mjs +58 -0
- package/tests/unit/naming.test.mjs +94 -0
- package/tests/unit/smoke-isolation.test.mjs +35 -0
- package/dist/utils/module-paths.js +0 -33
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"flag"
|
|
6
|
+
"log/slog"
|
|
7
|
+
"os"
|
|
8
|
+
|
|
9
|
+
"{{goModule}}/internal/app/user"
|
|
10
|
+
"{{goModule}}/internal/platform/database"
|
|
11
|
+
"{{goModule}}/internal/shared/config"
|
|
12
|
+
// go-scaffold:seed-imports
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// cmd/seed is a one-off operational task, like `migrate` — not part of the
|
|
16
|
+
// API server's runtime, so it's a separate binary rather than a flag on
|
|
17
|
+
// cmd/api. tokens/mailer are nil: EnsureUser never touches them (no login
|
|
18
|
+
// or email flow runs during seeding).
|
|
19
|
+
func main() {
|
|
20
|
+
withFixtures := flag.Bool("fixtures", false, "also seed sample dev users — do not use against a real environment")
|
|
21
|
+
flag.Parse()
|
|
22
|
+
|
|
23
|
+
cfg := config.Load()
|
|
24
|
+
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
|
25
|
+
|
|
26
|
+
db, err := database.Open(cfg)
|
|
27
|
+
if err != nil {
|
|
28
|
+
logger.Error("open db", "error", err)
|
|
29
|
+
os.Exit(1)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// go-scaffold:seed-services
|
|
33
|
+
svc := user.NewService(
|
|
34
|
+
user.NewRepository(db), nil, nil, cfg,
|
|
35
|
+
// go-scaffold:seed-user-service-args
|
|
36
|
+
)
|
|
37
|
+
ctx := context.Background()
|
|
38
|
+
|
|
39
|
+
if email := os.Getenv("SEED_ADMIN_EMAIL"); email != "" {
|
|
40
|
+
password := os.Getenv("SEED_ADMIN_PASSWORD")
|
|
41
|
+
if password == "" {
|
|
42
|
+
logger.Error("SEED_ADMIN_PASSWORD is required when SEED_ADMIN_EMAIL is set")
|
|
43
|
+
os.Exit(1)
|
|
44
|
+
}
|
|
45
|
+
name := os.Getenv("SEED_ADMIN_NAME")
|
|
46
|
+
if name == "" {
|
|
47
|
+
name = "Admin"
|
|
48
|
+
}
|
|
49
|
+
u, err := svc.EnsureUser(ctx, email, password, name)
|
|
50
|
+
if err != nil {
|
|
51
|
+
logger.Error("seed admin user", "error", err)
|
|
52
|
+
os.Exit(1)
|
|
53
|
+
}
|
|
54
|
+
logger.Info("admin user ready", "email", u.Email, "id", u.ID)
|
|
55
|
+
// go-scaffold:seed-admin-role
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if *withFixtures {
|
|
59
|
+
seedFixtures(ctx, svc, logger)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func seedFixtures(ctx context.Context, svc *user.Service, logger *slog.Logger) {
|
|
64
|
+
fixtures := []struct{ email, name string }{
|
|
65
|
+
{email: "dev.one@example.com", name: "Dev One"},
|
|
66
|
+
{email: "dev.two@example.com", name: "Dev Two"},
|
|
67
|
+
}
|
|
68
|
+
for _, f := range fixtures {
|
|
69
|
+
u, err := svc.EnsureUser(ctx, f.email, "password123", f.name)
|
|
70
|
+
if err != nil {
|
|
71
|
+
logger.Error("seed fixture user", "email", f.email, "error", err)
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
logger.Info("fixture user ready", "email", u.Email, "id", u.ID)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
post:
|
|
2
|
+
summary: Request a password reset link
|
|
3
|
+
description: Always returns the same message whether or not the email exists, to avoid leaking which emails are registered.
|
|
4
|
+
operationId: forgotPassword
|
|
5
|
+
tags: [auth]
|
|
6
|
+
security: []
|
|
7
|
+
requestBody:
|
|
8
|
+
required: true
|
|
9
|
+
content:
|
|
10
|
+
application/json:
|
|
11
|
+
schema: { $ref: './schemas.yaml#/ForgotPasswordInput' }
|
|
12
|
+
responses:
|
|
13
|
+
"200":
|
|
14
|
+
description: ok
|
|
15
|
+
content:
|
|
16
|
+
application/json:
|
|
17
|
+
schema: { $ref: './schemas.yaml#/MessageResponse' }
|
|
18
|
+
"400": { $ref: '../common/responses.yaml#/ValidationError' }
|
|
19
|
+
"429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
get:
|
|
2
|
+
summary: Google OAuth callback
|
|
3
|
+
description: Redirect target Google sends the browser back to after consent — not called directly by API clients.
|
|
4
|
+
operationId: googleCallback
|
|
5
|
+
tags: [auth]
|
|
6
|
+
security: []
|
|
7
|
+
parameters:
|
|
8
|
+
- name: code
|
|
9
|
+
in: query
|
|
10
|
+
required: true
|
|
11
|
+
schema: { type: string }
|
|
12
|
+
- name: state
|
|
13
|
+
in: query
|
|
14
|
+
required: true
|
|
15
|
+
schema: { type: string }
|
|
16
|
+
responses:
|
|
17
|
+
"200":
|
|
18
|
+
description: ok, refresh_token set as an httpOnly cookie
|
|
19
|
+
content:
|
|
20
|
+
application/json:
|
|
21
|
+
schema: { $ref: './schemas.yaml#/AuthResponse' }
|
|
22
|
+
"401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
post:
|
|
2
|
+
summary: Log in with email and password
|
|
3
|
+
operationId: loginUser
|
|
4
|
+
tags: [auth]
|
|
5
|
+
security: []
|
|
6
|
+
requestBody:
|
|
7
|
+
required: true
|
|
8
|
+
content:
|
|
9
|
+
application/json:
|
|
10
|
+
schema: { $ref: './schemas.yaml#/LoginInput' }
|
|
11
|
+
responses:
|
|
12
|
+
"200":
|
|
13
|
+
description: ok, refresh_token set as an httpOnly cookie
|
|
14
|
+
content:
|
|
15
|
+
application/json:
|
|
16
|
+
schema: { $ref: './schemas.yaml#/AuthResponse' }
|
|
17
|
+
"400": { $ref: '../common/responses.yaml#/ValidationError' }
|
|
18
|
+
"401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
|
|
19
|
+
"429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
post:
|
|
2
|
+
summary: Log out the current session
|
|
3
|
+
description: Revokes the refresh_token cookie's session and clears the cookie. Always succeeds, even with no cookie.
|
|
4
|
+
operationId: logoutUser
|
|
5
|
+
tags: [auth]
|
|
6
|
+
security: []
|
|
7
|
+
responses:
|
|
8
|
+
"204": { description: logged out }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
post:
|
|
2
|
+
summary: Rotate the refresh token cookie for a new access token
|
|
3
|
+
description: >-
|
|
4
|
+
Reads refresh_token from the httpOnly cookie (no request body). Reusing an
|
|
5
|
+
already-rotated-out token revokes the whole session family.
|
|
6
|
+
operationId: refreshToken
|
|
7
|
+
tags: [auth]
|
|
8
|
+
security: []
|
|
9
|
+
responses:
|
|
10
|
+
"200":
|
|
11
|
+
description: ok, a new refresh_token cookie is set
|
|
12
|
+
content:
|
|
13
|
+
application/json:
|
|
14
|
+
schema: { $ref: './schemas.yaml#/AuthResponse' }
|
|
15
|
+
"401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
post:
|
|
2
|
+
summary: Register a new account
|
|
3
|
+
operationId: registerUser
|
|
4
|
+
tags: [auth]
|
|
5
|
+
security: []
|
|
6
|
+
requestBody:
|
|
7
|
+
required: true
|
|
8
|
+
content:
|
|
9
|
+
application/json:
|
|
10
|
+
schema: { $ref: './schemas.yaml#/RegisterInput' }
|
|
11
|
+
responses:
|
|
12
|
+
"201":
|
|
13
|
+
description: created, refresh_token set as an httpOnly cookie
|
|
14
|
+
content:
|
|
15
|
+
application/json:
|
|
16
|
+
schema: { $ref: './schemas.yaml#/AuthResponse' }
|
|
17
|
+
"400": { $ref: '../common/responses.yaml#/ValidationError' }
|
|
18
|
+
"409": { $ref: '../common/responses.yaml#/ConflictError' }
|
|
19
|
+
"429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
post:
|
|
2
|
+
summary: Reset a password using a reset token
|
|
3
|
+
description: The token is one-time use (consumed on success) and also revokes every existing refresh token for the account.
|
|
4
|
+
operationId: resetPassword
|
|
5
|
+
tags: [auth]
|
|
6
|
+
security: []
|
|
7
|
+
requestBody:
|
|
8
|
+
required: true
|
|
9
|
+
content:
|
|
10
|
+
application/json:
|
|
11
|
+
schema: { $ref: './schemas.yaml#/ResetPasswordInput' }
|
|
12
|
+
responses:
|
|
13
|
+
"204": { description: password reset }
|
|
14
|
+
"400": { $ref: '../common/responses.yaml#/ValidationError' }
|
|
15
|
+
"401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
|
|
16
|
+
"429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
RegisterInput:
|
|
2
|
+
type: object
|
|
3
|
+
required: [email, password, name]
|
|
4
|
+
properties:
|
|
5
|
+
email: { type: string, format: email }
|
|
6
|
+
password: { type: string, format: password, minLength: 8 }
|
|
7
|
+
name: { type: string }
|
|
8
|
+
|
|
9
|
+
LoginInput:
|
|
10
|
+
type: object
|
|
11
|
+
required: [email, password]
|
|
12
|
+
properties:
|
|
13
|
+
email: { type: string, format: email }
|
|
14
|
+
password: { type: string, format: password }
|
|
15
|
+
|
|
16
|
+
ForgotPasswordInput:
|
|
17
|
+
type: object
|
|
18
|
+
required: [email]
|
|
19
|
+
properties:
|
|
20
|
+
email: { type: string, format: email }
|
|
21
|
+
|
|
22
|
+
ResetPasswordInput:
|
|
23
|
+
type: object
|
|
24
|
+
required: [token, new_password]
|
|
25
|
+
properties:
|
|
26
|
+
token: { type: string }
|
|
27
|
+
new_password: { type: string, format: password, minLength: 8 }
|
|
28
|
+
|
|
29
|
+
VerifyEmailInput:
|
|
30
|
+
type: object
|
|
31
|
+
required: [token]
|
|
32
|
+
properties:
|
|
33
|
+
token: { type: string }
|
|
34
|
+
|
|
35
|
+
# access_token in the body; refresh_token is set as an httpOnly cookie, never
|
|
36
|
+
# echoed back — see toCookieResponse in internal/app/user/dto.go.
|
|
37
|
+
AuthResponse:
|
|
38
|
+
type: object
|
|
39
|
+
properties:
|
|
40
|
+
access_token: { type: string }
|
|
41
|
+
token_type: { type: string, example: Bearer }
|
|
42
|
+
expires_in: { type: integer, description: seconds until access_token expires }
|
|
43
|
+
|
|
44
|
+
MessageResponse:
|
|
45
|
+
type: object
|
|
46
|
+
properties:
|
|
47
|
+
message: { type: string }
|
|
48
|
+
|
|
49
|
+
MeResponse:
|
|
50
|
+
type: object
|
|
51
|
+
properties:
|
|
52
|
+
id: { type: string, format: uuid }
|
|
53
|
+
email: { type: string, format: email }
|
|
54
|
+
name: { type: string }
|
|
55
|
+
avatar_url: { type: string }
|
|
56
|
+
email_verified: { type: boolean }
|
|
57
|
+
# go-scaffold:me-response-fields
|
|
58
|
+
created_at: { type: string, format: date-time }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
post:
|
|
2
|
+
summary: Log out every session for the current user
|
|
3
|
+
description: Revokes every refresh token for the caller, not just the current one, and clears this request's cookie too.
|
|
4
|
+
operationId: logoutAllSessions
|
|
5
|
+
tags: [users]
|
|
6
|
+
security: [{ bearerAuth: [] }]
|
|
7
|
+
responses:
|
|
8
|
+
"204": { description: all sessions logged out }
|
|
9
|
+
"401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
post:
|
|
2
|
+
summary: Resend the email verification link
|
|
3
|
+
operationId: resendVerification
|
|
4
|
+
tags: [users]
|
|
5
|
+
security: [{ bearerAuth: [] }]
|
|
6
|
+
responses:
|
|
7
|
+
"204": { description: verification email sent }
|
|
8
|
+
"401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
|
|
9
|
+
"409": { $ref: '../common/responses.yaml#/ConflictError' }
|
|
10
|
+
"429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
get:
|
|
2
|
+
summary: Get the current user
|
|
3
|
+
operationId: getMe
|
|
4
|
+
tags: [users]
|
|
5
|
+
security: [{ bearerAuth: [] }]
|
|
6
|
+
responses:
|
|
7
|
+
"200":
|
|
8
|
+
description: ok
|
|
9
|
+
content:
|
|
10
|
+
application/json:
|
|
11
|
+
schema: { $ref: './schemas.yaml#/MeResponse' }
|
|
12
|
+
"401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
post:
|
|
2
|
+
summary: Verify an email address using a verification token
|
|
3
|
+
description: The token is one-time use (GETDEL), reusing an already-consumed token fails.
|
|
4
|
+
operationId: verifyEmail
|
|
5
|
+
tags: [auth]
|
|
6
|
+
security: []
|
|
7
|
+
requestBody:
|
|
8
|
+
required: true
|
|
9
|
+
content:
|
|
10
|
+
application/json:
|
|
11
|
+
schema: { $ref: './schemas.yaml#/VerifyEmailInput' }
|
|
12
|
+
responses:
|
|
13
|
+
"204": { description: email verified }
|
|
14
|
+
"400": { $ref: '../common/responses.yaml#/ValidationError' }
|
|
15
|
+
"401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
|
|
16
|
+
"429": { $ref: '../common/responses.yaml#/TooManyRequestsError' }
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
package user
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"time"
|
|
5
|
+
|
|
6
|
+
"{{goModule}}/internal/app/user/model"
|
|
7
|
+
|
|
8
|
+
"github.com/google/uuid"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
type registerInput struct {
|
|
12
|
+
Email string `json:"email" binding:"required,email"`
|
|
13
|
+
Password string `json:"password" binding:"required,min=8"`
|
|
14
|
+
Name string `json:"name" binding:"required"`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type loginInput struct {
|
|
18
|
+
Email string `json:"email" binding:"required,email"`
|
|
19
|
+
Password string `json:"password" binding:"required"`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type forgotPasswordInput struct {
|
|
23
|
+
Email string `json:"email" binding:"required,email"`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type resetPasswordInput struct {
|
|
27
|
+
Token string `json:"token" binding:"required"`
|
|
28
|
+
NewPassword string `json:"new_password" binding:"required,min=8"`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type verifyEmailInput struct {
|
|
32
|
+
Token string `json:"token" binding:"required"`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// authResponse is what the service returns internally (both tokens); the
|
|
36
|
+
// handler sends the refresh token as an httpOnly cookie instead of echoing
|
|
37
|
+
// it in the body, so meResponse never carries it — see toCookieResponse.
|
|
38
|
+
type authResponse struct {
|
|
39
|
+
AccessToken string `json:"access_token"`
|
|
40
|
+
RefreshToken string `json:"-"`
|
|
41
|
+
TokenType string `json:"token_type"`
|
|
42
|
+
ExpiresIn int `json:"expires_in"`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type authCookieResponse struct {
|
|
46
|
+
AccessToken string `json:"access_token"`
|
|
47
|
+
TokenType string `json:"token_type"`
|
|
48
|
+
ExpiresIn int `json:"expires_in"`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func toCookieResponse(a *authResponse) authCookieResponse {
|
|
52
|
+
return authCookieResponse{AccessToken: a.AccessToken, TokenType: a.TokenType, ExpiresIn: a.ExpiresIn}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type meResponse struct {
|
|
56
|
+
ID uuid.UUID `json:"id"`
|
|
57
|
+
Email string `json:"email"`
|
|
58
|
+
Name string `json:"name"`
|
|
59
|
+
AvatarURL string `json:"avatar_url"`
|
|
60
|
+
EmailVerified bool `json:"email_verified"`
|
|
61
|
+
// go-scaffold:user-me-fields
|
|
62
|
+
CreatedAt time.Time `json:"created_at"`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func toMeResponse(u *model.User) meResponse {
|
|
66
|
+
return meResponse{
|
|
67
|
+
ID: u.ID,
|
|
68
|
+
Email: u.Email,
|
|
69
|
+
Name: u.Name,
|
|
70
|
+
AvatarURL: u.AvatarURL,
|
|
71
|
+
EmailVerified: u.EmailVerified,
|
|
72
|
+
CreatedAt: u.CreatedAt,
|
|
73
|
+
// go-scaffold:user-me-values
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// go-scaffold:user-dto
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package user
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
|
|
6
|
+
"{{goModule}}/internal/shared/apperror"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// functions, not vars: the error middleware writes RequestID onto the
|
|
10
|
+
// returned pointer directly — a shared instance would race across
|
|
11
|
+
// concurrent requests.
|
|
12
|
+
|
|
13
|
+
func errNotFound() *apperror.AppError {
|
|
14
|
+
return apperror.New(http.StatusNotFound, "USER_NOT_FOUND", "user not found")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func errEmailTaken() *apperror.AppError {
|
|
18
|
+
return apperror.New(http.StatusConflict, "USER_EMAIL_TAKEN", "email already registered")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// errInvalidCredentials is returned for both "no such user" and "wrong
|
|
22
|
+
// password" — a single generic message so a login attempt can't be used to
|
|
23
|
+
// enumerate which emails have accounts.
|
|
24
|
+
func errInvalidCredentials() *apperror.AppError {
|
|
25
|
+
return apperror.New(http.StatusUnauthorized, "AUTH_INVALID_CREDENTIALS", "invalid email or password")
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func errInvalidToken() *apperror.AppError {
|
|
29
|
+
return apperror.New(http.StatusUnauthorized, "AUTH_INVALID_TOKEN", "invalid or expired refresh token")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func errAlreadyVerified() *apperror.AppError {
|
|
33
|
+
return apperror.New(http.StatusConflict, "AUTH_ALREADY_VERIFIED", "email is already verified")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// go-scaffold:user-errors
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
package user
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
"time"
|
|
6
|
+
|
|
7
|
+
"{{goModule}}/internal/shared/httpx"
|
|
8
|
+
"{{goModule}}/internal/shared/middleware"
|
|
9
|
+
// go-scaffold:user-handler-imports
|
|
10
|
+
|
|
11
|
+
"github.com/gin-gonic/gin"
|
|
12
|
+
"github.com/google/uuid"
|
|
13
|
+
"github.com/redis/go-redis/v9"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
const refreshCookieName = "refresh_token"
|
|
17
|
+
|
|
18
|
+
// go-scaffold:user-handler-consts
|
|
19
|
+
|
|
20
|
+
type Handler struct {
|
|
21
|
+
svc *Service
|
|
22
|
+
jwtSecret string
|
|
23
|
+
refreshTTL time.Duration
|
|
24
|
+
cookieSecure bool
|
|
25
|
+
rdb *redis.Client
|
|
26
|
+
// go-scaffold:user-handler-fields
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func NewHandler(
|
|
30
|
+
svc *Service,
|
|
31
|
+
jwtSecret string,
|
|
32
|
+
refreshTTL time.Duration,
|
|
33
|
+
cookieSecure bool,
|
|
34
|
+
rdb *redis.Client,
|
|
35
|
+
// go-scaffold:user-handler-params
|
|
36
|
+
) *Handler {
|
|
37
|
+
return &Handler{
|
|
38
|
+
svc: svc,
|
|
39
|
+
jwtSecret: jwtSecret,
|
|
40
|
+
refreshTTL: refreshTTL,
|
|
41
|
+
cookieSecure: cookieSecure,
|
|
42
|
+
rdb: rdb,
|
|
43
|
+
// go-scaffold:user-handler-init
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Register wires both a public /auth group (register/login/refresh/logout)
|
|
48
|
+
// and a /users group gated by RequireAuth — public vs protected is decided
|
|
49
|
+
// here, per domain, not centrally in main.go.
|
|
50
|
+
func (h *Handler) Register(rg gin.IRouter) {
|
|
51
|
+
// per-IP, per-route budgets — separate names so a burst on one endpoint
|
|
52
|
+
// doesn't spend another's budget. refresh/logout/google aren't limited:
|
|
53
|
+
// refresh/logout are gated by possessing a valid cookie already, and the
|
|
54
|
+
// Google flow's abuse surface lives on Google's side, not ours.
|
|
55
|
+
loginLimit := middleware.RateLimit(h.rdb, "login", 10, time.Minute)
|
|
56
|
+
registerLimit := middleware.RateLimit(h.rdb, "register", 5, time.Minute)
|
|
57
|
+
forgotPasswordLimit := middleware.RateLimit(h.rdb, "forgot-password", 5, time.Minute)
|
|
58
|
+
resetPasswordLimit := middleware.RateLimit(h.rdb, "reset-password", 10, time.Minute)
|
|
59
|
+
verifyEmailLimit := middleware.RateLimit(h.rdb, "verify-email", 10, time.Minute)
|
|
60
|
+
resendVerificationLimit := middleware.RateLimit(h.rdb, "resend-verification", 5, time.Minute)
|
|
61
|
+
|
|
62
|
+
authGroup := rg.Group("/auth")
|
|
63
|
+
authGroup.POST("/register", registerLimit, h.register)
|
|
64
|
+
authGroup.POST("/login", loginLimit, h.login)
|
|
65
|
+
authGroup.POST("/refresh", h.refresh)
|
|
66
|
+
authGroup.POST("/logout", h.logout)
|
|
67
|
+
authGroup.POST("/forgot-password", forgotPasswordLimit, h.forgotPassword)
|
|
68
|
+
authGroup.POST("/reset-password", resetPasswordLimit, h.resetPassword)
|
|
69
|
+
authGroup.POST("/verify-email", verifyEmailLimit, h.verifyEmail)
|
|
70
|
+
authGroup.GET("/google/login", h.googleLogin)
|
|
71
|
+
authGroup.GET("/google/callback", h.googleCallback)
|
|
72
|
+
|
|
73
|
+
usersGroup := rg.Group("/users", middleware.RequireAuth(h.jwtSecret))
|
|
74
|
+
usersGroup.GET("/me", h.me)
|
|
75
|
+
usersGroup.POST("/me/resend-verification", resendVerificationLimit, h.resendVerification)
|
|
76
|
+
usersGroup.POST("/me/logout-all", h.logoutAll)
|
|
77
|
+
// go-scaffold:user-routes
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func (h *Handler) register(c *gin.Context) {
|
|
81
|
+
var in registerInput
|
|
82
|
+
if err := c.ShouldBindJSON(&in); err != nil {
|
|
83
|
+
c.Error(httpx.BindErr(err))
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
auth, err := h.svc.Register(c.Request.Context(), in)
|
|
87
|
+
if err != nil {
|
|
88
|
+
c.Error(err)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
h.setRefreshCookie(c, auth.RefreshToken)
|
|
92
|
+
c.JSON(http.StatusCreated, toCookieResponse(auth))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func (h *Handler) login(c *gin.Context) {
|
|
96
|
+
var in loginInput
|
|
97
|
+
if err := c.ShouldBindJSON(&in); err != nil {
|
|
98
|
+
c.Error(httpx.BindErr(err))
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
auth, err := h.svc.Login(c.Request.Context(), in)
|
|
102
|
+
if err != nil {
|
|
103
|
+
c.Error(err)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
h.setRefreshCookie(c, auth.RefreshToken)
|
|
107
|
+
c.JSON(http.StatusOK, toCookieResponse(auth))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func (h *Handler) refresh(c *gin.Context) {
|
|
111
|
+
raw, err := c.Cookie(refreshCookieName)
|
|
112
|
+
if err != nil || raw == "" {
|
|
113
|
+
c.Error(errInvalidToken())
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
auth, err := h.svc.Refresh(c.Request.Context(), raw)
|
|
117
|
+
if err != nil {
|
|
118
|
+
h.clearRefreshCookie(c)
|
|
119
|
+
c.Error(err)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
h.setRefreshCookie(c, auth.RefreshToken)
|
|
123
|
+
c.JSON(http.StatusOK, toCookieResponse(auth))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
func (h *Handler) logout(c *gin.Context) {
|
|
127
|
+
raw, _ := c.Cookie(refreshCookieName)
|
|
128
|
+
_ = h.svc.Logout(c.Request.Context(), raw)
|
|
129
|
+
h.clearRefreshCookie(c)
|
|
130
|
+
c.Status(http.StatusNoContent)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func (h *Handler) forgotPassword(c *gin.Context) {
|
|
134
|
+
var in forgotPasswordInput
|
|
135
|
+
if err := c.ShouldBindJSON(&in); err != nil {
|
|
136
|
+
c.Error(httpx.BindErr(err))
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
if err := h.svc.ForgotPassword(c.Request.Context(), in.Email); err != nil {
|
|
140
|
+
c.Error(err)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
// always the same response, whether or not the email exists — see
|
|
144
|
+
// Service.ForgotPassword for why.
|
|
145
|
+
c.JSON(http.StatusOK, gin.H{"message": "if that email exists, a reset link has been sent"})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func (h *Handler) resetPassword(c *gin.Context) {
|
|
149
|
+
var in resetPasswordInput
|
|
150
|
+
if err := c.ShouldBindJSON(&in); err != nil {
|
|
151
|
+
c.Error(httpx.BindErr(err))
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
if err := h.svc.ResetPassword(c.Request.Context(), in.Token, in.NewPassword); err != nil {
|
|
155
|
+
c.Error(err)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
c.Status(http.StatusNoContent)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func (h *Handler) verifyEmail(c *gin.Context) {
|
|
162
|
+
var in verifyEmailInput
|
|
163
|
+
if err := c.ShouldBindJSON(&in); err != nil {
|
|
164
|
+
c.Error(httpx.BindErr(err))
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
if err := h.svc.VerifyEmail(c.Request.Context(), in.Token); err != nil {
|
|
168
|
+
c.Error(err)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
c.Status(http.StatusNoContent)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
func (h *Handler) resendVerification(c *gin.Context) {
|
|
175
|
+
userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
|
|
176
|
+
if err := h.svc.ResendVerificationEmail(c.Request.Context(), userID); err != nil {
|
|
177
|
+
c.Error(err)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
c.Status(http.StatusNoContent)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// logoutAll ends every session for the caller, not just the one making the
|
|
184
|
+
// request — also clears this request's own cookie, since that session is
|
|
185
|
+
// dead too now.
|
|
186
|
+
func (h *Handler) logoutAll(c *gin.Context) {
|
|
187
|
+
userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
|
|
188
|
+
if err := h.svc.LogoutAll(c.Request.Context(), userID); err != nil {
|
|
189
|
+
c.Error(err)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
h.clearRefreshCookie(c)
|
|
193
|
+
c.Status(http.StatusNoContent)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
func (h *Handler) googleLogin(c *gin.Context) {
|
|
197
|
+
url, err := h.svc.GoogleLoginURL()
|
|
198
|
+
if err != nil {
|
|
199
|
+
c.Error(err)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
c.Redirect(http.StatusFound, url)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
func (h *Handler) googleCallback(c *gin.Context) {
|
|
206
|
+
auth, err := h.svc.GoogleCallback(c.Request.Context(), c.Query("code"), c.Query("state"))
|
|
207
|
+
if err != nil {
|
|
208
|
+
c.Error(err)
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
h.setRefreshCookie(c, auth.RefreshToken)
|
|
212
|
+
c.JSON(http.StatusOK, toCookieResponse(auth))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
func (h *Handler) me(c *gin.Context) {
|
|
216
|
+
userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
|
|
217
|
+
u, err := h.svc.Get(c.Request.Context(), userID)
|
|
218
|
+
if err != nil {
|
|
219
|
+
c.Error(err)
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
c.JSON(http.StatusOK, toMeResponse(u))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// go-scaffold:user-handler-funcs
|
|
226
|
+
|
|
227
|
+
func (h *Handler) setRefreshCookie(c *gin.Context, token string) {
|
|
228
|
+
c.SetSameSite(http.SameSiteStrictMode)
|
|
229
|
+
c.SetCookie(refreshCookieName, token, int(h.refreshTTL.Seconds()), "/", "", h.cookieSecure, true)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
func (h *Handler) clearRefreshCookie(c *gin.Context) {
|
|
233
|
+
c.SetSameSite(http.SameSiteStrictMode)
|
|
234
|
+
c.SetCookie(refreshCookieName, "", -1, "/", "", h.cookieSecure, true)
|
|
235
|
+
}
|