@nakedev/go-scaffold 0.4.0 → 0.5.0
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 +598 -306
- package/dist/commands/auth.js +65 -23
- package/dist/commands/check.js +281 -0
- package/dist/commands/config.js +50 -0
- package/dist/commands/create.js +33 -2
- package/dist/commands/generate.js +29 -3
- package/dist/commands/method.js +74 -63
- package/dist/commands/migration.js +2 -2
- package/dist/commands/observability.js +4 -53
- package/dist/commands/rbac.js +21 -10
- package/dist/commands/undo.js +11 -3
- package/dist/commands/worker.js +15 -5
- package/dist/index.js +198 -59
- package/dist/prompts/auth-wizard.js +40 -6
- package/dist/prompts/create-wizard.js +42 -1
- package/dist/prompts/generate-wizard.js +89 -9
- package/dist/templates/auth-manifest.js +50 -19
- package/dist/templates/create-manifest.js +8 -0
- package/dist/templates/module-manifest.js +84 -26
- package/dist/templates/rbac-manifest.js +16 -11
- package/dist/templates/worker-manifest.js +4 -1
- package/dist/types.js +8 -0
- package/dist/utils/auth-patcher.js +124 -33
- package/dist/utils/config.js +167 -4
- package/dist/utils/docs-patcher.js +68 -0
- package/dist/utils/hexagonal-method-patcher.js +334 -0
- package/dist/utils/main-patcher.js +32 -30
- package/dist/utils/marker-patch.js +7 -1
- package/dist/utils/module-location.js +17 -11
- package/dist/utils/module-profile.js +32 -0
- package/dist/utils/platform-patcher.js +56 -7
- package/dist/utils/rbac-patcher.js +89 -210
- package/package.json +7 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +15 -3
- package/templates/add/auth/docs/login.yaml.hbs +11 -1
- package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
- package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
- package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
- package/templates/add/auth/docs/register.yaml.hbs +7 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
- package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
- package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
- package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
- package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
- package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/browser_policy.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +228 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +76 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +83 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +70 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_recovery.go.hbs +49 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +311 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/session_cookie.go.hbs +35 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +129 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +174 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
- package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +213 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +103 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_recovery.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +228 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +196 -0
- package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
- package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
- package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
- package/templates/add/auth/internal/app/user/application/external_login.go.hbs +198 -0
- package/templates/add/auth/internal/app/user/application/jwt.go.hbs +58 -0
- package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +96 -0
- package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +449 -0
- package/templates/add/auth/internal/app/user/application/mfa_service_test.go.hbs +200 -0
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
- package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +285 -0
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +82 -0
- package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +112 -0
- package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
- package/templates/add/auth/internal/app/user/application/service_test.go.hbs +891 -0
- package/templates/add/auth/internal/app/user/application/sessions.go.hbs +99 -0
- package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
- package/templates/add/auth/internal/app/user/application/user_query.go.hbs +65 -0
- package/templates/add/auth/internal/app/user/composition.go.hbs +168 -0
- package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
- package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -0
- package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
- package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +10 -5
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
- package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +4 -3
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
- package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
- package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
- package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
- package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
- package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
- package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
- package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
- package/templates/add/rbac/internal/app/role/composition.go.hbs +48 -0
- package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
- package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
- package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
- package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +358 -121
- package/templates/create/base/.env.example.hbs +0 -1
- package/templates/create/base/.golangci.yml.hbs +2 -2
- package/templates/create/base/AGENTS.md.hbs +279 -67
- package/templates/create/base/Makefile.hbs +2 -1
- package/templates/create/base/README.md.hbs +115 -32
- package/templates/create/base/cmd/api/wiring.go.hbs +13 -9
- package/templates/create/base/internal/composition/doc.go.hbs +7 -0
- package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
- package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
- package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
- package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
- package/templates/create/features/docs/architecture.md.hbs +92 -32
- package/templates/create/features/docs/patterns.md.hbs +137 -91
- package/templates/create/features/docs/techstack.md.hbs +18 -3
- package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
- package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
- package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
- package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
- package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
- package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
- package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
- package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
- package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
- package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
- package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
- package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
- package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
- package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
- package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
- package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
- package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
- package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
- package/templates/generate/module/migration.up.sql.hbs +1 -1
- package/dist/utils/method-patcher.js +0 -357
- package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
- package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
- package/templates/add/auth/internal/app/user/dto.go.hbs +0 -77
- package/templates/add/auth/internal/app/user/errors.go.hbs +0 -43
- package/templates/add/auth/internal/app/user/handler.go.hbs +0 -276
- package/templates/add/auth/internal/app/user/jwt.go.hbs +0 -108
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -39
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -31
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
- package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -30
- package/templates/add/auth/internal/app/user/repository.go.hbs +0 -137
- package/templates/add/auth/internal/app/user/service.go.hbs +0 -531
- package/templates/add/auth/internal/app/user/service_test.go.hbs +0 -316
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -30
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +0 -144
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +0 -147
- package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
- package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
- package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
- package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
- package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
- package/templates/generate/module/dto.go.hbs +0 -36
- package/templates/generate/module/errors.go.hbs +0 -33
- package/templates/generate/module/handler.go.hbs +0 -134
- package/templates/generate/module/handler_test.go.hbs +0 -174
- package/templates/generate/module/minimal/dto.go.hbs +0 -28
- package/templates/generate/module/minimal/handler.go.hbs +0 -48
- package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
- package/templates/generate/module/minimal/service.go.hbs +0 -45
- package/templates/generate/module/minimal/service_test.go.hbs +0 -77
- package/templates/generate/module/model/model.go.hbs +0 -36
- package/templates/generate/module/repository.go.hbs +0 -103
- package/templates/generate/module/service.go.hbs +0 -108
- package/templates/generate/module/service_test.go.hbs +0 -161
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
package application
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"time"
|
|
7
|
+
|
|
8
|
+
"{{goModule}}/internal/app/user/domain"
|
|
9
|
+
|
|
10
|
+
"github.com/google/uuid"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// Refresh rotates a refresh token. A replayed, already-used token revokes
|
|
14
|
+
// every session for that user; an intentionally logged-out token is rejected
|
|
15
|
+
// without triggering reuse detection.
|
|
16
|
+
func (s *Service) Refresh(ctx context.Context, rawRefreshToken string) (*AuthResponse, error) {
|
|
17
|
+
hash := hashToken(rawRefreshToken)
|
|
18
|
+
token, ok, err := s.refreshTokens.ConsumeRefreshToken(ctx, hash)
|
|
19
|
+
if err != nil {
|
|
20
|
+
return nil, fmt.Errorf("consume refresh token: %w", err)
|
|
21
|
+
}
|
|
22
|
+
if !ok {
|
|
23
|
+
reusedBy, used, reuseErr := s.refreshTokens.IsRefreshTokenUsed(ctx, hash)
|
|
24
|
+
if reuseErr != nil {
|
|
25
|
+
return nil, fmt.Errorf("check refresh token reuse: %w", reuseErr)
|
|
26
|
+
}
|
|
27
|
+
if used {
|
|
28
|
+
if revokeErr := s.refreshTokens.RevokeAllRefreshTokens(ctx, reusedBy); revokeErr != nil {
|
|
29
|
+
return nil, fmt.Errorf("revoke sessions after refresh token reuse: %w", revokeErr)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return nil, errInvalidToken()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
u, err := s.repo.FindByID(ctx, token.UserID)
|
|
36
|
+
if err != nil {
|
|
37
|
+
return nil, errInvalidToken()
|
|
38
|
+
}
|
|
39
|
+
return s.issueTokens(ctx, u, token.AbsoluteExpiresAt)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Logout is intentional, not reuse: a missing or already-gone token still
|
|
43
|
+
// succeeds and leaves no tombstone.
|
|
44
|
+
func (s *Service) Logout(ctx context.Context, rawRefreshToken string) error {
|
|
45
|
+
if rawRefreshToken == "" {
|
|
46
|
+
return nil
|
|
47
|
+
}
|
|
48
|
+
hash := hashToken(rawRefreshToken)
|
|
49
|
+
userID, ok, err := s.refreshTokens.GetRefreshToken(ctx, hash)
|
|
50
|
+
if err != nil || !ok {
|
|
51
|
+
return nil
|
|
52
|
+
}
|
|
53
|
+
return s.refreshTokens.DeleteRefreshToken(ctx, hash, userID)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// LogoutAll ends every refresh session for the authenticated user.
|
|
57
|
+
func (s *Service) LogoutAll(ctx context.Context, userID uuid.UUID) error {
|
|
58
|
+
return s.refreshTokens.RevokeAllRefreshTokens(ctx, userID)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func (s *Service) issueTokens(ctx context.Context, u *domain.User, absoluteExpiresAt ...time.Time) (*AuthResponse, error) {
|
|
62
|
+
access, err := s.issueAccessToken(
|
|
63
|
+
u.ID,
|
|
64
|
+
u.Role,
|
|
65
|
+
// go-scaffold:issue-access-token-args
|
|
66
|
+
)
|
|
67
|
+
if err != nil {
|
|
68
|
+
return nil, fmt.Errorf("issue access token: %w", err)
|
|
69
|
+
}
|
|
70
|
+
refresh, err := randomToken()
|
|
71
|
+
if err != nil {
|
|
72
|
+
return nil, fmt.Errorf("generate refresh token: %w", err)
|
|
73
|
+
}
|
|
74
|
+
clock := s.now
|
|
75
|
+
if clock == nil {
|
|
76
|
+
clock = time.Now
|
|
77
|
+
}
|
|
78
|
+
now := clock()
|
|
79
|
+
abs := now.Add(s.config.JWTRefreshMaxTTL)
|
|
80
|
+
if len(absoluteExpiresAt) > 0 && !absoluteExpiresAt[0].IsZero() {
|
|
81
|
+
abs = absoluteExpiresAt[0]
|
|
82
|
+
}
|
|
83
|
+
expiresAt := now.Add(s.config.JWTRefreshTTL)
|
|
84
|
+
if abs.Before(expiresAt) {
|
|
85
|
+
expiresAt = abs
|
|
86
|
+
}
|
|
87
|
+
if !expiresAt.After(now) || !abs.After(now) {
|
|
88
|
+
return nil, fmt.Errorf("refresh token lifetime is exhausted")
|
|
89
|
+
}
|
|
90
|
+
if err := s.refreshTokens.SetRefreshToken(ctx, hashToken(refresh), RefreshTokenRecord{UserID: u.ID, ExpiresAt: expiresAt, AbsoluteExpiresAt: abs}); err != nil {
|
|
91
|
+
return nil, fmt.Errorf("store refresh token: %w", err)
|
|
92
|
+
}
|
|
93
|
+
return &AuthResponse{
|
|
94
|
+
AccessToken: access,
|
|
95
|
+
RefreshToken: refresh,
|
|
96
|
+
TokenType: "Bearer",
|
|
97
|
+
ExpiresIn: int(s.config.JWTAccessTTL.Seconds()),
|
|
98
|
+
}, nil
|
|
99
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
package application
|
|
2
|
+
|
|
3
|
+
import "{{goModule}}/internal/app/user/ports"
|
|
4
|
+
|
|
5
|
+
// These aliases keep the use-case files readable while the actual outbound
|
|
6
|
+
// contracts remain owned by the user module's ports package.
|
|
7
|
+
type RefreshTokenStore = ports.RefreshTokenStore
|
|
8
|
+
type OAuthTransactionStore = ports.OAuthTransactionStore
|
|
9
|
+
type RecoveryTokenStore = ports.RecoveryTokenStore
|
|
10
|
+
type RefreshTokenRecord = ports.RefreshTokenRecord
|
|
11
|
+
type LoginTransaction = ports.LoginTransaction
|
|
12
|
+
type MFAEnrollment = ports.MFAEnrollment
|
|
13
|
+
type MFAChallenge = ports.MFAChallenge
|
|
14
|
+
type MFAStore = ports.MFAStore
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
package application
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
|
|
8
|
+
"{{goModule}}/internal/app/user/domain"
|
|
9
|
+
"{{goModule}}/internal/shared/id"
|
|
10
|
+
|
|
11
|
+
"github.com/google/uuid"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// wrapFindErr is shared by methods added through `generate method`. Keeping
|
|
15
|
+
// not-found translation in this query file means the small service facade
|
|
16
|
+
// does not need to import database-specific error packages just because a
|
|
17
|
+
// later method was generated.
|
|
18
|
+
func wrapFindErr(err error) error {
|
|
19
|
+
if errors.Is(err, domain.ErrNotFound) {
|
|
20
|
+
return errNotFound()
|
|
21
|
+
}
|
|
22
|
+
return fmt.Errorf("find user: %w", err)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func (s *Service) Get(ctx context.Context, userID uuid.UUID) (*domain.User, error) {
|
|
26
|
+
u, err := s.repo.FindByID(ctx, userID)
|
|
27
|
+
if err != nil {
|
|
28
|
+
return nil, wrapFindErr(err)
|
|
29
|
+
}
|
|
30
|
+
return u, nil
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// List is kept for the optional RBAC admin routes. It is not part of the
|
|
34
|
+
// public self-service auth surface until that feature is installed.
|
|
35
|
+
func (s *Service) List(ctx context.Context, limit, offset int) ([]domain.User, error) {
|
|
36
|
+
items, err := s.repo.FindAll(ctx, limit, offset)
|
|
37
|
+
if err != nil {
|
|
38
|
+
return nil, fmt.Errorf("list users: %w", err)
|
|
39
|
+
}
|
|
40
|
+
return items, nil
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// EnsureUser is idempotent and used only by cmd/seed. It never resets an
|
|
44
|
+
// existing user's password as a side effect of a later deployment.
|
|
45
|
+
func (s *Service) EnsureUser(ctx context.Context, email, password, name string) (*domain.User, error) {
|
|
46
|
+
email = normalizeEmail(email)
|
|
47
|
+
if u, err := s.repo.FindByEmail(ctx, email); err == nil {
|
|
48
|
+
return u, nil
|
|
49
|
+
} else if !errors.Is(err, domain.ErrNotFound) {
|
|
50
|
+
return nil, fmt.Errorf("find existing user: %w", err)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
hash, err := s.passwords.Hash(password)
|
|
54
|
+
if err != nil {
|
|
55
|
+
return nil, fmt.Errorf("hash password: %w", err)
|
|
56
|
+
}
|
|
57
|
+
hashStr := string(hash)
|
|
58
|
+
|
|
59
|
+
u := &domain.User{ID: id.New(), Email: email, Name: name, Role: domain.DefaultRole}
|
|
60
|
+
i := &domain.Identity{ID: id.New(), Provider: domain.ProviderLocal, PasswordHash: &hashStr}
|
|
61
|
+
if err := s.repo.CreateUserWithIdentity(ctx, u, i); err != nil {
|
|
62
|
+
return nil, fmt.Errorf("create user: %w", err)
|
|
63
|
+
}
|
|
64
|
+
return u, nil
|
|
65
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
package user
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"time"
|
|
6
|
+
|
|
7
|
+
httpadapter "{{goModule}}/internal/app/user/adapters/inbound/http"
|
|
8
|
+
userpassword "{{goModule}}/internal/app/user/adapters/outbound/password"
|
|
9
|
+
userpostgres "{{goModule}}/internal/app/user/adapters/outbound/postgres"
|
|
10
|
+
"{{goModule}}/internal/app/user/application"
|
|
11
|
+
"{{goModule}}/internal/app/user/ports"
|
|
12
|
+
googleprovider "{{goModule}}/internal/platform/authprovider/google"
|
|
13
|
+
"{{goModule}}/internal/platform/mail"
|
|
14
|
+
{{#if worker}}
|
|
15
|
+
"{{goModule}}/internal/platform/queue"
|
|
16
|
+
{{/if}}
|
|
17
|
+
"{{goModule}}/internal/shared/config"
|
|
18
|
+
"{{goModule}}/internal/shared/middleware"
|
|
19
|
+
|
|
20
|
+
{{#if redis}}
|
|
21
|
+
userredis "{{goModule}}/internal/app/user/adapters/outbound/redis"
|
|
22
|
+
"github.com/redis/go-redis/v9"
|
|
23
|
+
{{/if}}
|
|
24
|
+
"github.com/gin-gonic/gin"
|
|
25
|
+
"gorm.io/gorm"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
// NewHandlerFromDB is the user module's composition root. It assembles the
|
|
29
|
+
// application service and adapters here; cmd/api only supplies process-level
|
|
30
|
+
// infrastructure and registers the returned inbound handler.
|
|
31
|
+
func NewHandlerFromDB(
|
|
32
|
+
db *gorm.DB,
|
|
33
|
+
cfg config.Config,
|
|
34
|
+
{{#if redis}}
|
|
35
|
+
rdb *redis.Client,
|
|
36
|
+
{{/if}}
|
|
37
|
+
{{#if worker}}
|
|
38
|
+
q queue.Enqueuer,
|
|
39
|
+
{{/if}}
|
|
40
|
+
roleChecker RoleChecker,
|
|
41
|
+
authz Authorizer,
|
|
42
|
+
) *httpadapter.Handler {
|
|
43
|
+
{{#if redis}}
|
|
44
|
+
tokens := userredis.NewRedisTokenStore(rdb, db)
|
|
45
|
+
{{else}}
|
|
46
|
+
tokens := userpostgres.NewPgTokenStore(db)
|
|
47
|
+
{{/if}}
|
|
48
|
+
|
|
49
|
+
{{#if worker}}
|
|
50
|
+
mailer := mail.NewAsyncClient(q)
|
|
51
|
+
{{else}}
|
|
52
|
+
mailer := mail.NewSyncClient(mail.Open(cfg))
|
|
53
|
+
{{/if}}
|
|
54
|
+
|
|
55
|
+
providers := make([]application.LoginProvider, 0, 1)
|
|
56
|
+
if cfg.GoogleClientID != "" && cfg.GoogleClientSecret != "" && cfg.GoogleOAuthRedirectURI != "" {
|
|
57
|
+
providers = append(providers, googleprovider.New(googleprovider.Config{
|
|
58
|
+
ClientID: cfg.GoogleClientID,
|
|
59
|
+
ClientSecret: cfg.GoogleClientSecret,
|
|
60
|
+
RedirectURL: cfg.GoogleOAuthRedirectURI,
|
|
61
|
+
}))
|
|
62
|
+
}
|
|
63
|
+
providerRegistry := application.NewProviderRegistry(providers...)
|
|
64
|
+
if err := httpadapter.ValidateBrowserCookiePolicy(cfg.AuthBrowserTopology, cfg.CookieSameSite, cfg.CookieSecure, cfg.IsProd()); err != nil {
|
|
65
|
+
panic(err)
|
|
66
|
+
}
|
|
67
|
+
if cfg.JWTRefreshTTL <= 0 || cfg.JWTRefreshMaxTTL <= 0 {
|
|
68
|
+
panic(fmt.Errorf("JWT refresh token lifetimes must be positive"))
|
|
69
|
+
}
|
|
70
|
+
if cfg.OAuthStateTTL <= 0 {
|
|
71
|
+
panic(fmt.Errorf("OAUTH_STATE_TTL_MIN must be positive"))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
service := application.NewService(application.Dependencies{
|
|
75
|
+
Repository: userpostgres.NewRepository(db),
|
|
76
|
+
Passwords: userpassword.NewBCryptHasher(),
|
|
77
|
+
RefreshTokens: tokens,
|
|
78
|
+
OAuthTransactions: tokens,
|
|
79
|
+
RecoveryTokens: tokens,
|
|
80
|
+
MFA: userpostgres.NewPostgresMFAStore(db),
|
|
81
|
+
Mailer: mailer,
|
|
82
|
+
Providers: providerRegistry,
|
|
83
|
+
Roles: roleChecker,
|
|
84
|
+
Clock: time.Now,
|
|
85
|
+
}, application.AuthConfig{
|
|
86
|
+
JWTSecret: cfg.JWTSecret,
|
|
87
|
+
JWTAccessTTL: cfg.JWTAccessTTL,
|
|
88
|
+
JWTRefreshTTL: cfg.JWTRefreshTTL,
|
|
89
|
+
JWTRefreshMaxTTL: cfg.JWTRefreshMaxTTL,
|
|
90
|
+
OAuthStateTTL: cfg.OAuthStateTTL,
|
|
91
|
+
PasswordResetTTL: cfg.PasswordResetTTL,
|
|
92
|
+
PasswordResetURL: cfg.PasswordResetURL,
|
|
93
|
+
EmailVerifyTTL: cfg.EmailVerifyTTL,
|
|
94
|
+
EmailVerifyURL: cfg.EmailVerifyURL,
|
|
95
|
+
MFA: application.MFASettings{
|
|
96
|
+
Enabled: cfg.AuthMFAEnabled,
|
|
97
|
+
Issuer: cfg.MFAIssuer,
|
|
98
|
+
EncryptionKey: cfg.MFAEncryptionKey,
|
|
99
|
+
ChallengeTTL: cfg.MFAChallengeTTL,
|
|
100
|
+
TOTPWindow: cfg.MFATOTPWindow,
|
|
101
|
+
RecoveryCodeCount: cfg.MFARecoveryCodeCount,
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
{{#if redis}}
|
|
105
|
+
limiter := middleware.NewRedisLimiter(rdb)
|
|
106
|
+
{{else}}
|
|
107
|
+
limiter := middleware.NewMemoryLimiter()
|
|
108
|
+
{{/if}}
|
|
109
|
+
return httpadapter.NewHandlerWithOrigins(
|
|
110
|
+
service,
|
|
111
|
+
cfg.JWTSecret,
|
|
112
|
+
cfg.JWTRefreshTTL,
|
|
113
|
+
cfg.CookieSecure,
|
|
114
|
+
cfg.CookieSameSite,
|
|
115
|
+
cfg.CORSAllowedOrigins,
|
|
116
|
+
limiter,
|
|
117
|
+
authz,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Public aliases keep the composition surface small for cmd/seed and for
|
|
122
|
+
// optional RBAC wiring. Consumers do not need to know adapter package names.
|
|
123
|
+
type Dependencies = application.Dependencies
|
|
124
|
+
type AuthConfig = application.AuthConfig
|
|
125
|
+
type MFASettings = application.MFASettings
|
|
126
|
+
type ProviderRegistry = application.ProviderRegistry
|
|
127
|
+
type RoleChecker = ports.RoleChecker
|
|
128
|
+
type Service = application.Service
|
|
129
|
+
type ServicePort = application.ServicePort
|
|
130
|
+
type Repository = userpostgres.Repository
|
|
131
|
+
{{#unless redis}}
|
|
132
|
+
type PgTokenStore = userpostgres.PgTokenStore
|
|
133
|
+
{{/unless}}
|
|
134
|
+
type PostgresMFAStore = userpostgres.PostgresMFAStore
|
|
135
|
+
{{#if redis}}
|
|
136
|
+
type RedisTokenStore = userredis.RedisTokenStore
|
|
137
|
+
{{/if}}
|
|
138
|
+
|
|
139
|
+
type Authorizer interface {
|
|
140
|
+
Require(string) gin.HandlerFunc
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
func NewService(deps Dependencies, cfg AuthConfig) *Service {
|
|
144
|
+
return application.NewService(deps, cfg)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func ValidateMFASettings(settings MFASettings) error {
|
|
148
|
+
return application.ValidateMFASettings(settings)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
func NewRepository(db *gorm.DB) *Repository {
|
|
152
|
+
return userpostgres.NewRepository(db)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
{{#unless redis}}
|
|
156
|
+
func NewPgTokenStore(db *gorm.DB) *PgTokenStore {
|
|
157
|
+
return userpostgres.NewPgTokenStore(db)
|
|
158
|
+
}
|
|
159
|
+
{{/unless}}
|
|
160
|
+
|
|
161
|
+
func NewPostgresMFAStore(db *gorm.DB) *PostgresMFAStore {
|
|
162
|
+
return userpostgres.NewPostgresMFAStore(db)
|
|
163
|
+
}
|
|
164
|
+
{{#if redis}}
|
|
165
|
+
func NewRedisTokenStore(rdb *redis.Client, db *gorm.DB) *RedisTokenStore {
|
|
166
|
+
return userredis.NewRedisTokenStore(rdb, db)
|
|
167
|
+
}
|
|
168
|
+
{{/if}}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Package domain contains transport- and persistence-neutral user values.
|
|
2
|
+
package domain
|
|
3
|
+
|
|
4
|
+
import (
|
|
5
|
+
"time"
|
|
6
|
+
|
|
7
|
+
"github.com/google/uuid"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
type User struct {
|
|
11
|
+
ID uuid.UUID
|
|
12
|
+
Email string
|
|
13
|
+
Name string
|
|
14
|
+
AvatarURL string
|
|
15
|
+
EmailVerified bool
|
|
16
|
+
Role string
|
|
17
|
+
CreatedAt time.Time
|
|
18
|
+
UpdatedAt time.Time
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// DefaultRole is part of auth's persisted user contract. RBAC may add the
|
|
22
|
+
// role catalog later, but every newly issued access token must carry the same
|
|
23
|
+
// default that the users migration applies at the database boundary.
|
|
24
|
+
const DefaultRole = "staff"
|
|
25
|
+
|
|
26
|
+
type Provider string
|
|
27
|
+
|
|
28
|
+
const (
|
|
29
|
+
ProviderLocal Provider = "local"
|
|
30
|
+
ProviderGoogle Provider = "google"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
type Identity struct {
|
|
34
|
+
ID uuid.UUID
|
|
35
|
+
UserID uuid.UUID
|
|
36
|
+
Provider Provider
|
|
37
|
+
PasswordHash *string
|
|
38
|
+
ProviderUID *string
|
|
39
|
+
CreatedAt time.Time
|
|
40
|
+
UpdatedAt time.Time
|
|
41
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
package domain
|
|
2
|
+
|
|
3
|
+
import "errors"
|
|
4
|
+
|
|
5
|
+
var (
|
|
6
|
+
ErrNotFound = errors.New("user not found")
|
|
7
|
+
ErrConflict = errors.New("user conflict")
|
|
8
|
+
ErrInvalidCredential = errors.New("invalid credentials")
|
|
9
|
+
ErrInvalidToken = errors.New("invalid or expired token")
|
|
10
|
+
ErrEmailTaken = errors.New("email already registered")
|
|
11
|
+
ErrTooManyAttempts = errors.New("too many failed attempts")
|
|
12
|
+
ErrAlreadyVerified = errors.New("email already verified")
|
|
13
|
+
ErrMFAUnavailable = errors.New("MFA is unavailable")
|
|
14
|
+
ErrMFAInvalid = errors.New("invalid or expired MFA code")
|
|
15
|
+
ErrMFAAlreadyEnabled = errors.New("MFA is already enabled")
|
|
16
|
+
ErrMFANotEnrolled = errors.New("MFA is not enrolled")
|
|
17
|
+
ErrMFASetupRequired = errors.New("MFA setup is required")
|
|
18
|
+
ErrUnknownRole = errors.New("unknown role")
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
type RuleError struct {
|
|
22
|
+
Code string
|
|
23
|
+
Message string
|
|
24
|
+
Cause error
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func (e *RuleError) Error() string { return e.Message }
|
|
28
|
+
func (e *RuleError) Unwrap() error { return e.Cause }
|
|
29
|
+
|
|
30
|
+
func Rule(code, message string, cause error) error {
|
|
31
|
+
return &RuleError{Code: code, Message: message, Cause: cause}
|
|
32
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
package ports
|
|
2
|
+
|
|
3
|
+
// PasswordHasher is the application-owned outbound contract for password
|
|
4
|
+
// storage and verification. The algorithm and cost policy belong to an
|
|
5
|
+
// adapter, not to the auth use cases.
|
|
6
|
+
type PasswordHasher interface {
|
|
7
|
+
Hash(string) (string, error)
|
|
8
|
+
Compare(hash, password string) error
|
|
9
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Package ports contains user auth's application-owned outbound contracts.
|
|
2
|
+
package ports
|
|
3
|
+
|
|
4
|
+
import (
|
|
5
|
+
"context"
|
|
6
|
+
"time"
|
|
7
|
+
|
|
8
|
+
"{{goModule}}/internal/app/user/domain"
|
|
9
|
+
|
|
10
|
+
"github.com/google/uuid"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
type UserRepository interface {
|
|
14
|
+
FindByEmail(context.Context, string) (*domain.User, error)
|
|
15
|
+
FindByID(context.Context, uuid.UUID) (*domain.User, error)
|
|
16
|
+
UpdateUser(context.Context, *domain.User) error
|
|
17
|
+
FindAll(context.Context, int, int) ([]domain.User, error)
|
|
18
|
+
FindIdentity(context.Context, uuid.UUID, domain.Provider) (*domain.Identity, error)
|
|
19
|
+
FindIdentityByProviderUID(context.Context, domain.Provider, string) (*domain.Identity, error)
|
|
20
|
+
CreateUserWithIdentity(context.Context, *domain.User, *domain.Identity) error
|
|
21
|
+
CreateIdentity(context.Context, *domain.Identity) error
|
|
22
|
+
UpdateIdentity(context.Context, *domain.Identity) error
|
|
23
|
+
LoginLockedUntil(context.Context, string) (time.Time, error)
|
|
24
|
+
RecordLoginFailure(context.Context, string, int, time.Duration) error
|
|
25
|
+
ClearLoginFailures(context.Context, string) error
|
|
26
|
+
// go-scaffold:repository-interface
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type AuthMailer interface {
|
|
30
|
+
Send(context.Context, string, string, string) error
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type RoleChecker interface {
|
|
34
|
+
CodeExists(context.Context, string) (bool, error)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type RefreshTokenRecord struct {
|
|
38
|
+
UserID uuid.UUID `json:"user_id"`
|
|
39
|
+
ExpiresAt time.Time `json:"expires_at"`
|
|
40
|
+
AbsoluteExpiresAt time.Time `json:"absolute_expires_at"`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type RefreshTokenStore interface {
|
|
44
|
+
SetRefreshToken(context.Context, string, RefreshTokenRecord) error
|
|
45
|
+
GetRefreshToken(context.Context, string) (uuid.UUID, bool, error)
|
|
46
|
+
ConsumeRefreshToken(context.Context, string) (RefreshTokenRecord, bool, error)
|
|
47
|
+
DeleteRefreshToken(context.Context, string, uuid.UUID) error
|
|
48
|
+
RevokeAllRefreshTokens(context.Context, uuid.UUID) error
|
|
49
|
+
IsRefreshTokenUsed(context.Context, string) (uuid.UUID, bool, error)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type LoginTransaction struct {
|
|
53
|
+
Provider string `json:"provider"`
|
|
54
|
+
CodeChallenge string `json:"code_challenge"`
|
|
55
|
+
Nonce string `json:"nonce"`
|
|
56
|
+
ExpiresAt time.Time `json:"expires_at"`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type OAuthTransactionStore interface {
|
|
60
|
+
SetLoginTransaction(context.Context, string, LoginTransaction) error
|
|
61
|
+
ConsumeLoginTransaction(context.Context, string) (LoginTransaction, bool, error)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type RecoveryTokenStore interface {
|
|
65
|
+
WithTransaction(context.Context, func(context.Context) error) error
|
|
66
|
+
ConsumePasswordResetToken(context.Context, string) (uuid.UUID, bool, error)
|
|
67
|
+
ConsumeEmailVerifyToken(context.Context, string) (uuid.UUID, bool, error)
|
|
68
|
+
SetPasswordResetToken(context.Context, string, uuid.UUID, time.Duration) error
|
|
69
|
+
SetEmailVerifyToken(context.Context, string, uuid.UUID, time.Duration) error
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type MFAEnrollment struct {
|
|
73
|
+
EncryptedSecret string
|
|
74
|
+
Enabled bool
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type MFAChallenge struct {
|
|
78
|
+
UserID uuid.UUID
|
|
79
|
+
ExpiresAt time.Time
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type MFAStore interface {
|
|
83
|
+
GetEnrollment(context.Context, uuid.UUID) (MFAEnrollment, bool, error)
|
|
84
|
+
PutPendingEnrollment(context.Context, uuid.UUID, string) error
|
|
85
|
+
ConfirmEnrollment(context.Context, uuid.UUID, string, []string) error
|
|
86
|
+
Disable(context.Context, uuid.UUID) error
|
|
87
|
+
CreateChallenge(context.Context, string, MFAChallenge) error
|
|
88
|
+
ConsumeChallenge(context.Context, string) (MFAChallenge, bool, error)
|
|
89
|
+
ConsumeRecoveryCode(context.Context, uuid.UUID, string) (bool, error)
|
|
90
|
+
}
|