@nakedev/go-scaffold 0.4.3 → 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 +567 -513
- package/dist/commands/auth.js +12 -1
- package/dist/commands/check.js +281 -0
- package/dist/commands/create.js +2 -1
- package/dist/commands/generate.js +5 -2
- package/dist/commands/method.js +66 -70
- package/dist/commands/observability.js +4 -53
- package/dist/commands/rbac.js +19 -8
- package/dist/commands/undo.js +6 -3
- package/dist/commands/worker.js +14 -4
- package/dist/index.js +13 -1
- package/dist/templates/auth-manifest.js +46 -45
- package/dist/templates/create-manifest.js +4 -0
- package/dist/templates/module-manifest.js +82 -60
- package/dist/templates/rbac-manifest.js +15 -11
- package/dist/templates/worker-manifest.js +4 -1
- package/dist/types.js +2 -0
- package/dist/utils/auth-patcher.js +22 -22
- package/dist/utils/config.js +24 -5
- package/dist/utils/docs-patcher.js +68 -0
- package/dist/utils/hexagonal-method-patcher.js +334 -0
- package/dist/utils/main-patcher.js +3 -3
- package/dist/utils/module-location.js +17 -11
- package/dist/utils/platform-patcher.js +27 -0
- package/dist/utils/rbac-patcher.js +73 -216
- package/package.json +1 -1
- package/templates/add/auth/cmd/seed/main.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/{browser_policy.go.hbs → adapters/inbound/http/browser_policy.go.hbs} +3 -3
- package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
- package/templates/add/auth/internal/app/user/{handler.go.hbs → adapters/inbound/http/handler.go.hbs} +103 -8
- package/templates/add/auth/internal/app/user/{handler_local.go.hbs → adapters/inbound/http/handler_local.go.hbs} +8 -7
- package/templates/add/auth/internal/app/user/{handler_mfa.go.hbs → adapters/inbound/http/handler_mfa.go.hbs} +6 -6
- package/templates/add/auth/internal/app/user/{handler_oauth.go.hbs → adapters/inbound/http/handler_oauth.go.hbs} +17 -17
- package/templates/add/auth/internal/app/user/{handler_recovery.go.hbs → adapters/inbound/http/handler_recovery.go.hbs} +4 -4
- package/templates/add/auth/internal/app/user/{handler_test.go.hbs → adapters/inbound/http/handler_test.go.hbs} +52 -31
- package/templates/add/auth/internal/app/user/{handler_user.go.hbs → adapters/inbound/http/handler_user.go.hbs} +4 -4
- package/templates/add/auth/internal/app/user/{session_cookie.go.hbs → adapters/inbound/http/session_cookie.go.hbs} +5 -3
- 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/{mfa_store.go.hbs → adapters/outbound/postgres/mfa_store.go.hbs} +22 -20
- package/templates/add/auth/internal/app/user/{mfa_store_test.go.hbs → adapters/outbound/postgres/mfa_store_test.go.hbs} +7 -7
- 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/{tokenstore_pg.go.hbs → adapters/outbound/postgres/tokenstore_pg.go.hbs} +36 -32
- package/templates/add/auth/internal/app/user/{tokenstore_pg_test.go.hbs → adapters/outbound/postgres/tokenstore_pg_test.go.hbs} +10 -3
- package/templates/add/auth/internal/app/user/{tokenstore_recovery.go.hbs → adapters/outbound/postgres/tokenstore_recovery.go.hbs} +29 -3
- package/templates/add/auth/internal/app/user/{tokenstore_redis.go.hbs → adapters/outbound/redis/tokenstore.go.hbs} +43 -36
- package/templates/add/auth/internal/app/user/{tokenstore_redis_test.go.hbs → adapters/outbound/redis/tokenstore_test.go.hbs} +14 -3
- 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/{external_login.go.hbs → application/external_login.go.hbs} +30 -40
- package/templates/add/auth/internal/app/user/{jwt.go.hbs → application/jwt.go.hbs} +6 -3
- package/templates/add/auth/internal/app/user/{local_auth.go.hbs → application/local_auth.go.hbs} +17 -19
- package/templates/add/auth/internal/app/user/{mfa_service.go.hbs → application/mfa_service.go.hbs} +27 -28
- package/templates/add/auth/internal/app/user/{mfa_service_test.go.hbs → application/mfa_service_test.go.hbs} +12 -11
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +3 -3
- package/templates/add/auth/internal/app/user/{provider_test.go.hbs → application/provider_test.go.hbs} +55 -56
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +16 -47
- package/templates/add/auth/internal/app/user/{recovery_service.go.hbs → application/recovery_service.go.hbs} +13 -15
- package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
- package/templates/add/auth/internal/app/user/{service_test.go.hbs → application/service_test.go.hbs} +94 -75
- package/templates/add/auth/internal/app/user/{sessions.go.hbs → application/sessions.go.hbs} +14 -14
- package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
- package/templates/add/auth/internal/app/user/{user_query.go.hbs → application/user_query.go.hbs} +16 -16
- package/templates/add/auth/internal/app/user/composition.go.hbs +89 -86
- 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/migrations/create_auth_tokens.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -1
- 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 +23 -10
- 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 +54 -36
- package/templates/create/base/.golangci.yml.hbs +2 -2
- package/templates/create/base/AGENTS.md.hbs +55 -31
- package/templates/create/base/README.md.hbs +77 -22
- package/templates/create/base/cmd/api/wiring.go.hbs +3 -2
- package/templates/create/base/internal/composition/doc.go.hbs +7 -0
- package/templates/create/features/docs/architecture.md.hbs +64 -26
- package/templates/create/features/docs/patterns.md.hbs +117 -90
- package/templates/create/features/docs/techstack.md.hbs +17 -2
- 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 -537
- package/templates/add/auth/internal/app/user/contracts.go.hbs +0 -88
- package/templates/add/auth/internal/app/user/dto.go.hbs +0 -134
- package/templates/add/auth/internal/app/user/errors.go.hbs +0 -68
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -45
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -32
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
- package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +0 -17
- package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +0 -20
- package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +0 -17
- package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -31
- package/templates/add/auth/internal/app/user/repository.go.hbs +0 -139
- package/templates/add/auth/internal/app/user/service.go.hbs +0 -135
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -58
- 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/commands.go.hbs +0 -95
- package/templates/generate/module/composition.go.hbs +0 -23
- package/templates/generate/module/cqrs_test.go.hbs +0 -7
- 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 -179
- package/templates/generate/module/handler_test.go.hbs +0 -174
- package/templates/generate/module/minimal/commands.go.hbs +0 -34
- package/templates/generate/module/minimal/dto.go.hbs +0 -28
- package/templates/generate/module/minimal/handler.go.hbs +0 -82
- package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
- package/templates/generate/module/minimal/queries.go.hbs +0 -45
- package/templates/generate/module/minimal/service.go.hbs +0 -71
- 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/queries.go.hbs +0 -62
- package/templates/generate/module/repository.go.hbs +0 -103
- package/templates/generate/module/service.go.hbs +0 -164
- package/templates/generate/module/service_test.go.hbs +0 -161
|
@@ -1,134 +0,0 @@
|
|
|
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
|
-
type mfaCodeInput struct {
|
|
36
|
-
Code string `json:"code" binding:"required"`
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
type mfaChallengeInput struct {
|
|
40
|
-
Challenge string `json:"challenge" binding:"required"`
|
|
41
|
-
Code string `json:"code" binding:"required"`
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// authResponse is what the service returns internally (both tokens); the
|
|
45
|
-
// handler sends the refresh token as an httpOnly cookie instead of echoing
|
|
46
|
-
// it in the body, so meResponse never carries it — see toCookieResponse.
|
|
47
|
-
type authResponse struct {
|
|
48
|
-
AccessToken string `json:"access_token"`
|
|
49
|
-
RefreshToken string `json:"-"`
|
|
50
|
-
TokenType string `json:"token_type"`
|
|
51
|
-
ExpiresIn int `json:"expires_in"`
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// authResult keeps the normal token shape source-compatible for callers while
|
|
55
|
-
// representing the pre-session state required by MFA. A challenge never
|
|
56
|
-
// carries an access or refresh token.
|
|
57
|
-
type authResult struct {
|
|
58
|
-
*authResponse
|
|
59
|
-
MFAChallenge string
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
func tokenResult(auth *authResponse) *authResult { return &authResult{authResponse: auth} }
|
|
63
|
-
|
|
64
|
-
func challengeResult(challenge string) *authResult { return &authResult{MFAChallenge: challenge} }
|
|
65
|
-
|
|
66
|
-
type authCookieResponse struct {
|
|
67
|
-
AccessToken string `json:"access_token"`
|
|
68
|
-
TokenType string `json:"token_type"`
|
|
69
|
-
ExpiresIn int `json:"expires_in"`
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
type mfaChallengeResponse struct {
|
|
73
|
-
MFARequired bool `json:"mfa_required"`
|
|
74
|
-
Challenge string `json:"challenge"`
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
type mfaStatusResponse struct {
|
|
78
|
-
Available bool `json:"available"`
|
|
79
|
-
Enabled bool `json:"enabled"`
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
type mfaSetupResponse struct {
|
|
83
|
-
Secret string `json:"secret"`
|
|
84
|
-
OTPAuthURI string `json:"otpauth_uri"`
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
type mfaConfirmResponse struct {
|
|
88
|
-
RecoveryCodes []string `json:"recovery_codes"`
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
func toCookieResponse(a *authResponse) authCookieResponse {
|
|
92
|
-
return authCookieResponse{AccessToken: a.AccessToken, TokenType: a.TokenType, ExpiresIn: a.ExpiresIn}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
type meResponse struct {
|
|
96
|
-
ID uuid.UUID `json:"id"`
|
|
97
|
-
Email string `json:"email"`
|
|
98
|
-
Name string `json:"name"`
|
|
99
|
-
AvatarURL string `json:"avatar_url"`
|
|
100
|
-
EmailVerified bool `json:"email_verified"`
|
|
101
|
-
// go-scaffold:user-me-fields
|
|
102
|
-
CreatedAt time.Time `json:"created_at"`
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
func toMeResponse(u *model.User) meResponse {
|
|
106
|
-
return meResponse{
|
|
107
|
-
ID: u.ID,
|
|
108
|
-
Email: u.Email,
|
|
109
|
-
Name: u.Name,
|
|
110
|
-
AvatarURL: u.AvatarURL,
|
|
111
|
-
EmailVerified: u.EmailVerified,
|
|
112
|
-
CreatedAt: u.CreatedAt,
|
|
113
|
-
// go-scaffold:user-me-values
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// response is the small public profile DTO used by endpoints added through
|
|
118
|
-
// `generate method user ...`. Auth's built-in /users/me response intentionally
|
|
119
|
-
// stays separate because it has a different contract, but generated admin or
|
|
120
|
-
// support lookups still need the same stable id/timestamp shape as ordinary
|
|
121
|
-
// modules.
|
|
122
|
-
//
|
|
123
|
-
//nolint:unused
|
|
124
|
-
type response struct {
|
|
125
|
-
ID uuid.UUID `json:"id"`
|
|
126
|
-
CreatedAt time.Time `json:"created_at"`
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
//nolint:unused
|
|
130
|
-
func toResponse(u *model.User) response {
|
|
131
|
-
return response{ID: u.ID, CreatedAt: u.CreatedAt}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// go-scaffold:user-dto
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
package user
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"fmt"
|
|
5
|
-
"net/http"
|
|
6
|
-
|
|
7
|
-
"{{goModule}}/internal/shared/apperror"
|
|
8
|
-
)
|
|
9
|
-
|
|
10
|
-
// functions, not vars: the error middleware writes RequestID onto the
|
|
11
|
-
// returned pointer directly — a shared instance would race across
|
|
12
|
-
// concurrent requests.
|
|
13
|
-
|
|
14
|
-
func errNotFound() *apperror.AppError {
|
|
15
|
-
return apperror.New(http.StatusNotFound, "USER_NOT_FOUND", "user not found")
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
func errEmailTaken() *apperror.AppError {
|
|
19
|
-
return apperror.New(http.StatusConflict, "USER_EMAIL_TAKEN", "email already registered")
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// errInvalidCredentials is returned for both "no such user" and "wrong
|
|
23
|
-
// password" — a single generic message so a login attempt can't be used to
|
|
24
|
-
// enumerate which emails have accounts.
|
|
25
|
-
// errTooManyAttempts is the account-level lockout, distinct from the per-IP
|
|
26
|
-
// RATE_LIMITED the middleware returns: this one follows the account wherever
|
|
27
|
-
// the attempts come from.
|
|
28
|
-
func errTooManyAttempts() *apperror.AppError {
|
|
29
|
-
return apperror.New(http.StatusTooManyRequests, "AUTH_TOO_MANY_ATTEMPTS", "too many failed attempts — try again later")
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
func errInvalidCredentials() *apperror.AppError {
|
|
33
|
-
return apperror.New(http.StatusUnauthorized, "AUTH_INVALID_CREDENTIALS", "invalid email or password")
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
func errInvalidToken() *apperror.AppError {
|
|
37
|
-
return apperror.New(http.StatusUnauthorized, "AUTH_INVALID_TOKEN", "invalid or expired refresh token")
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
func errAlreadyVerified() *apperror.AppError {
|
|
41
|
-
return apperror.New(http.StatusConflict, "AUTH_ALREADY_VERIFIED", "email is already verified")
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
func errMFAUnavailable() *apperror.AppError {
|
|
45
|
-
return apperror.New(http.StatusServiceUnavailable, "AUTH_MFA_UNAVAILABLE", "multi-factor authentication is not enabled")
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
func errMFAInvalid() *apperror.AppError {
|
|
49
|
-
return apperror.New(http.StatusUnauthorized, "AUTH_MFA_INVALID", "invalid or expired multi-factor authentication code")
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
func errMFAAlreadyEnabled() *apperror.AppError {
|
|
53
|
-
return apperror.New(http.StatusConflict, "AUTH_MFA_ALREADY_ENABLED", "multi-factor authentication is already enabled")
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
func errMFANotEnrolled() *apperror.AppError {
|
|
57
|
-
return apperror.New(http.StatusConflict, "AUTH_MFA_NOT_ENROLLED", "multi-factor authentication is not enabled for this user")
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
func errMFASetupRequired() *apperror.AppError {
|
|
61
|
-
return apperror.New(http.StatusConflict, "AUTH_MFA_SETUP_REQUIRED", "complete multi-factor authentication setup first")
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
func errMFAConfig() *apperror.AppError {
|
|
65
|
-
return apperror.NewInternal(fmt.Errorf("multi-factor authentication encryption key is invalid"))
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// go-scaffold:user-errors
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
package model
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"time"
|
|
5
|
-
|
|
6
|
-
"github.com/google/uuid"
|
|
7
|
-
)
|
|
8
|
-
|
|
9
|
-
// AuthToken is one short-lived token — the Postgres equivalent of the keys the
|
|
10
|
-
// Redis store would hold. Only the SHA-256 hash of the raw token is stored, so
|
|
11
|
-
// a database dump never yields a usable token.
|
|
12
|
-
//
|
|
13
|
-
// `Kind` mirrors the Redis key prefixes one-for-one, deliberately:
|
|
14
|
-
//
|
|
15
|
-
// refresh an active refresh token
|
|
16
|
-
// refresh_used a tombstone left by rotation, so replaying a rotated-out
|
|
17
|
-
// token is detectable as reuse rather than merely unknown
|
|
18
|
-
// pwreset password reset, consumed once
|
|
19
|
-
// emailverify email verification, consumed once
|
|
20
|
-
//
|
|
21
|
-
// Keeping the tombstone a separate row (rather than a used_at column on the
|
|
22
|
-
// refresh row) is what preserves logout's semantics: logout deletes the row
|
|
23
|
-
// outright and leaves no tombstone, so someone replaying a logged-out token
|
|
24
|
-
// gets a plain rejection instead of tripping reuse detection and nuking every
|
|
25
|
-
// session the user has.
|
|
26
|
-
//
|
|
27
|
-
// No foreign key to users on purpose: the development bootstrap wouldn't
|
|
28
|
-
// create one from these tags, and a constraint that exists in production but not in
|
|
29
|
-
// development is the exact mismatch that stops the app booting. A token whose
|
|
30
|
-
// user is gone simply fails the lookup that follows.
|
|
31
|
-
type AuthToken struct {
|
|
32
|
-
TokenHash string `gorm:"primaryKey;type:text"`
|
|
33
|
-
UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_auth_tokens_user_kind,priority:1"`
|
|
34
|
-
Kind string `gorm:"type:varchar(20);not null;index:idx_auth_tokens_user_kind,priority:2"`
|
|
35
|
-
ExpiresAt time.Time `gorm:"not null;index:idx_auth_tokens_expires_at"`
|
|
36
|
-
// AbsoluteExpiresAt is set only for refresh tokens. Recovery tokens and
|
|
37
|
-
// OAuth transactions have no sliding lifetime, so it remains nil there.
|
|
38
|
-
AbsoluteExpiresAt *time.Time `gorm:"index:idx_auth_tokens_absolute_expires_at"`
|
|
39
|
-
Provider string `gorm:"type:varchar(20);not null;default:''"`
|
|
40
|
-
CodeChallenge string `gorm:"type:text;not null;default:''"`
|
|
41
|
-
Nonce string `gorm:"type:text;not null;default:''"`
|
|
42
|
-
CreatedAt time.Time
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
func (AuthToken) TableName() string { return "user_svc.auth_tokens" }
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
package model
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"time"
|
|
5
|
-
|
|
6
|
-
"github.com/google/uuid"
|
|
7
|
-
)
|
|
8
|
-
|
|
9
|
-
type Provider string
|
|
10
|
-
|
|
11
|
-
const (
|
|
12
|
-
ProviderLocal Provider = "local"
|
|
13
|
-
ProviderGoogle Provider = "google"
|
|
14
|
-
)
|
|
15
|
-
|
|
16
|
-
// Identity = how you log in (one row per login method). PasswordHash is set
|
|
17
|
-
// for a "local" identity; ProviderUID is set for an external provider (the
|
|
18
|
-
// provider's own stable subject id). Split from User so adding a login method
|
|
19
|
-
// never touches the profile row — one person can have several methods and
|
|
20
|
-
// resolve to the same account.
|
|
21
|
-
type Identity struct {
|
|
22
|
-
ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
|
|
23
|
-
UserID uuid.UUID `json:"user_id" gorm:"type:uuid;not null;uniqueIndex:idx_identities_user_provider,priority:1"`
|
|
24
|
-
Provider Provider `json:"provider" gorm:"type:varchar(20);not null;uniqueIndex:idx_identities_user_provider,priority:2;uniqueIndex:idx_identities_provider_uid,priority:1"`
|
|
25
|
-
PasswordHash *string `json:"-"`
|
|
26
|
-
ProviderUID *string `json:"-" gorm:"column:provider_uid;uniqueIndex:idx_identities_provider_uid,priority:2"`
|
|
27
|
-
CreatedAt time.Time `json:"created_at"`
|
|
28
|
-
UpdatedAt time.Time `json:"updated_at"`
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// TableName — see User.TableName; same schema, same reasoning.
|
|
32
|
-
func (Identity) TableName() string { return "user_svc.identities" }
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
package model
|
|
2
|
-
|
|
3
|
-
import "time"
|
|
4
|
-
|
|
5
|
-
// LoginThrottle is the failed-attempt counter that makes lockout survive a
|
|
6
|
-
// deploy and mean the same thing on every replica. It is not the rate limiter
|
|
7
|
-
// — that one counts requests per IP and is allowed to be approximate. This
|
|
8
|
-
// counts failures per account, and per OWASP that is the control that actually
|
|
9
|
-
// stops credential stuffing: an attacker with a proxy pool keeps per-IP volume
|
|
10
|
-
// under any threshold you set, but they cannot spread attempts against one
|
|
11
|
-
// account across accounts.
|
|
12
|
-
//
|
|
13
|
-
// EmailHash, not the address: the counter has to be keyed on what the caller
|
|
14
|
-
// typed whether or not an account exists — otherwise "did this get throttled"
|
|
15
|
-
// answers "does this account exist" — and hashing means this table never
|
|
16
|
-
// becomes a directory of who has signed up.
|
|
17
|
-
type LoginThrottle struct {
|
|
18
|
-
EmailHash string `gorm:"primaryKey;type:text"`
|
|
19
|
-
Failures int `gorm:"not null;default:0"`
|
|
20
|
-
// nil until the free attempts are spent; afterwards it moves further out
|
|
21
|
-
// with each failure.
|
|
22
|
-
LockedUntil *time.Time
|
|
23
|
-
UpdatedAt time.Time
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
func (LoginThrottle) TableName() string { return "user_svc.login_throttle" }
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
package model
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"time"
|
|
5
|
-
|
|
6
|
-
"github.com/google/uuid"
|
|
7
|
-
)
|
|
8
|
-
|
|
9
|
-
// MFAChallenge is a one-use pre-session challenge. Only its hash is stored.
|
|
10
|
-
type MFAChallenge struct {
|
|
11
|
-
ChallengeHash string `gorm:"type:text;primaryKey"`
|
|
12
|
-
UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_mfa_challenges_user"`
|
|
13
|
-
ExpiresAt time.Time `gorm:"not null;index:idx_mfa_challenges_expires_at"`
|
|
14
|
-
CreatedAt time.Time
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
func (MFAChallenge) TableName() string { return "user_svc.mfa_challenges" }
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
package model
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"time"
|
|
5
|
-
|
|
6
|
-
"github.com/google/uuid"
|
|
7
|
-
)
|
|
8
|
-
|
|
9
|
-
// MFAEnrollment stores one user's TOTP enrollment. The secret is encrypted by
|
|
10
|
-
// the application before it reaches this model; plaintext secrets never live
|
|
11
|
-
// in the database.
|
|
12
|
-
type MFAEnrollment struct {
|
|
13
|
-
UserID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
|
14
|
-
EncryptedSecret string `gorm:"type:text;not null"`
|
|
15
|
-
Enabled bool `gorm:"not null;default:false"`
|
|
16
|
-
CreatedAt time.Time
|
|
17
|
-
UpdatedAt time.Time
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
func (MFAEnrollment) TableName() string { return "user_svc.mfa_enrollments" }
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
package model
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"time"
|
|
5
|
-
|
|
6
|
-
"github.com/google/uuid"
|
|
7
|
-
)
|
|
8
|
-
|
|
9
|
-
// MFARecoveryCode stores a hash only. A consumed code is deleted atomically,
|
|
10
|
-
// so the table never becomes a second mutable session state machine.
|
|
11
|
-
type MFARecoveryCode struct {
|
|
12
|
-
UserID uuid.UUID `gorm:"type:uuid;primaryKey;index:idx_mfa_recovery_codes_user"`
|
|
13
|
-
CodeHash string `gorm:"type:text;primaryKey"`
|
|
14
|
-
CreatedAt time.Time
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
func (MFARecoveryCode) TableName() string { return "user_svc.mfa_recovery_codes" }
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
// Package model holds this domain's GORM tables.
|
|
2
|
-
package model
|
|
3
|
-
|
|
4
|
-
import (
|
|
5
|
-
"time"
|
|
6
|
-
|
|
7
|
-
"github.com/google/uuid"
|
|
8
|
-
)
|
|
9
|
-
|
|
10
|
-
// User = who you are (profile). How you log in is a separate concept — see
|
|
11
|
-
// Identity — so the same person can have both a password and a Google login
|
|
12
|
-
// resolving to one account.
|
|
13
|
-
type User struct {
|
|
14
|
-
ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
|
|
15
|
-
// index named explicitly, and named the same in create_users.up.sql: an
|
|
16
|
-
// anonymous `uniqueIndex` makes GORM invent one, and the development
|
|
17
|
-
// bootstrap then tries to DROP the differently-named constraint the
|
|
18
|
-
// migration created.
|
|
19
|
-
Email string `json:"email" gorm:"uniqueIndex:idx_users_email;not null"`
|
|
20
|
-
Name string `json:"name"`
|
|
21
|
-
AvatarURL string `json:"avatar_url"`
|
|
22
|
-
EmailVerified bool `json:"email_verified"`
|
|
23
|
-
// go-scaffold:user-fields
|
|
24
|
-
CreatedAt time.Time `json:"created_at"`
|
|
25
|
-
UpdatedAt time.Time `json:"updated_at"`
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// TableName pins this to user_svc rather than GORM's default inflection
|
|
29
|
-
// ("users", schema-less) — every domain gets its own schema, see
|
|
30
|
-
// docs/architect/patterns.md.
|
|
31
|
-
func (User) TableName() string { return "user_svc.users" }
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
package user
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"context"
|
|
5
|
-
"errors"
|
|
6
|
-
"time"
|
|
7
|
-
|
|
8
|
-
"{{goModule}}/internal/app/user/model"
|
|
9
|
-
"{{goModule}}/internal/shared/tx"
|
|
10
|
-
|
|
11
|
-
"github.com/google/uuid"
|
|
12
|
-
"gorm.io/gorm"
|
|
13
|
-
)
|
|
14
|
-
|
|
15
|
-
type Repository struct {
|
|
16
|
-
db *gorm.DB
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
func NewRepository(db *gorm.DB) *Repository {
|
|
20
|
-
return &Repository{db: db}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
func (r *Repository) FindByEmail(ctx context.Context, email string) (*model.User, error) {
|
|
24
|
-
var u model.User
|
|
25
|
-
if err := tx.From(ctx, r.db).WithContext(ctx).First(&u, "email = ?", email).Error; err != nil {
|
|
26
|
-
return nil, err
|
|
27
|
-
}
|
|
28
|
-
return &u, nil
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (*model.User, error) {
|
|
32
|
-
var u model.User
|
|
33
|
-
if err := tx.From(ctx, r.db).WithContext(ctx).First(&u, "id = ?", id).Error; err != nil {
|
|
34
|
-
return nil, err
|
|
35
|
-
}
|
|
36
|
-
return &u, nil
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
func (r *Repository) UpdateUser(ctx context.Context, u *model.User) error {
|
|
40
|
-
return tx.From(ctx, r.db).WithContext(ctx).Save(u).Error
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]model.User, error) {
|
|
44
|
-
var items []model.User
|
|
45
|
-
err := tx.From(ctx, r.db).WithContext(ctx).Order("created_at desc").Limit(limit).Offset(offset).Find(&items).Error
|
|
46
|
-
return items, err
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
func (r *Repository) FindIdentity(ctx context.Context, userID uuid.UUID, provider model.Provider) (*model.Identity, error) {
|
|
50
|
-
var i model.Identity
|
|
51
|
-
if err := tx.From(ctx, r.db).WithContext(ctx).First(&i, "user_id = ? AND provider = ?", userID, provider).Error; err != nil {
|
|
52
|
-
return nil, err
|
|
53
|
-
}
|
|
54
|
-
return &i, nil
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
func (r *Repository) FindIdentityByProviderUID(ctx context.Context, provider model.Provider, providerUID string) (*model.Identity, error) {
|
|
58
|
-
var i model.Identity
|
|
59
|
-
if err := tx.From(ctx, r.db).WithContext(ctx).First(&i, "provider = ? AND provider_uid = ?", provider, providerUID).Error; err != nil {
|
|
60
|
-
return nil, err
|
|
61
|
-
}
|
|
62
|
-
return &i, nil
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// CreateIdentity links a new login method onto an EXISTING user (e.g.
|
|
66
|
-
// Google linking onto an account that registered with a password first) —
|
|
67
|
-
// see CreateUserWithIdentity for the "brand new user" case.
|
|
68
|
-
func (r *Repository) CreateIdentity(ctx context.Context, i *model.Identity) error {
|
|
69
|
-
return tx.From(ctx, r.db).WithContext(ctx).Create(i).Error
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
func (r *Repository) UpdateIdentity(ctx context.Context, i *model.Identity) error {
|
|
73
|
-
return tx.From(ctx, r.db).WithContext(ctx).Save(i).Error
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// CreateUserWithIdentity inserts the profile and its first login method in
|
|
77
|
-
// one transaction — a user with no identity at all can't log in any way, so
|
|
78
|
-
// the two rows always exist together or not at all.
|
|
79
|
-
func (r *Repository) CreateUserWithIdentity(ctx context.Context, u *model.User, i *model.Identity) error {
|
|
80
|
-
return tx.From(ctx, r.db).WithContext(ctx).Transaction(func(t *gorm.DB) error {
|
|
81
|
-
if err := t.Create(u).Error; err != nil {
|
|
82
|
-
return err
|
|
83
|
-
}
|
|
84
|
-
i.UserID = u.ID
|
|
85
|
-
return t.Create(i).Error
|
|
86
|
-
})
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// --- failed-login throttle -------------------------------------------------
|
|
90
|
-
//
|
|
91
|
-
// Keyed on a caller-supplied hash rather than a user id, because the counter
|
|
92
|
-
// has to work for addresses that have no account — see model.LoginThrottle.
|
|
93
|
-
|
|
94
|
-
// LoginLockedUntil reports when this key stops being locked out — zero time
|
|
95
|
-
// when it isn't. Read on every login attempt, so it stays a primary-key hit.
|
|
96
|
-
func (r *Repository) LoginLockedUntil(ctx context.Context, key string) (time.Time, error) {
|
|
97
|
-
var row model.LoginThrottle
|
|
98
|
-
err := tx.From(ctx, r.db).WithContext(ctx).Where("email_hash = ?", key).Take(&row).Error
|
|
99
|
-
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
100
|
-
return time.Time{}, nil
|
|
101
|
-
}
|
|
102
|
-
if err != nil || row.LockedUntil == nil {
|
|
103
|
-
return time.Time{}, err
|
|
104
|
-
}
|
|
105
|
-
return *row.LockedUntil, nil
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// RecordLoginFailure bumps the counter and pushes the lock further out,
|
|
109
|
-
// doubling each time past freeAttempts and never exceeding maxLock.
|
|
110
|
-
//
|
|
111
|
-
// One statement, so two attempts racing cannot both read the same count and
|
|
112
|
-
// write the same lock — which would let an attacker keep the backoff pinned at
|
|
113
|
-
// its first step by running attempts in parallel. Computing the interval in
|
|
114
|
-
// SQL is what buys that; a read-then-write in Go would need a transaction and
|
|
115
|
-
// a row lock to be equally safe.
|
|
116
|
-
func (r *Repository) RecordLoginFailure(ctx context.Context, key string, freeAttempts int, maxLock time.Duration) error {
|
|
117
|
-
return tx.From(ctx, r.db).WithContext(ctx).Exec(`
|
|
118
|
-
INSERT INTO user_svc.login_throttle AS t (email_hash, failures, locked_until, updated_at)
|
|
119
|
-
VALUES (?, 1, NULL, now())
|
|
120
|
-
ON CONFLICT (email_hash) DO UPDATE SET
|
|
121
|
-
failures = t.failures + 1,
|
|
122
|
-
locked_until = CASE
|
|
123
|
-
WHEN t.failures + 1 <= ? THEN NULL
|
|
124
|
-
ELSE now() + make_interval(secs => least(power(2, t.failures + 1 - ?), ?))
|
|
125
|
-
END,
|
|
126
|
-
updated_at = now()`,
|
|
127
|
-
key, freeAttempts, freeAttempts, maxLock.Seconds()).Error
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// ClearLoginFailures drops the row on a successful login, so someone who
|
|
131
|
-
// mistypes twice and then gets in starts clean rather than creeping toward a
|
|
132
|
-
// lockout over weeks.
|
|
133
|
-
func (r *Repository) ClearLoginFailures(ctx context.Context, key string) error {
|
|
134
|
-
return tx.From(ctx, r.db).WithContext(ctx).
|
|
135
|
-
Where("email_hash = ?", key).
|
|
136
|
-
Delete(&model.LoginThrottle{}).Error
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// go-scaffold:user-repository-methods
|
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
package user
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"context"
|
|
5
|
-
"strings"
|
|
6
|
-
"time"
|
|
7
|
-
|
|
8
|
-
"{{goModule}}/internal/app/user/application"
|
|
9
|
-
"{{goModule}}/internal/app/user/model"
|
|
10
|
-
|
|
11
|
-
"github.com/google/uuid"
|
|
12
|
-
)
|
|
13
|
-
|
|
14
|
-
// repository = what the user application needs from the data layer. The
|
|
15
|
-
// interface stays consumer-owned so every use case can be tested without a
|
|
16
|
-
// database, while the concrete GORM implementation remains in repository.go.
|
|
17
|
-
type UserRepository interface {
|
|
18
|
-
FindByEmail(ctx context.Context, email string) (*model.User, error)
|
|
19
|
-
FindByID(ctx context.Context, id uuid.UUID) (*model.User, error)
|
|
20
|
-
UpdateUser(ctx context.Context, u *model.User) error
|
|
21
|
-
FindAll(ctx context.Context, limit, offset int) ([]model.User, error)
|
|
22
|
-
FindIdentity(ctx context.Context, userID uuid.UUID, provider model.Provider) (*model.Identity, error)
|
|
23
|
-
FindIdentityByProviderUID(ctx context.Context, provider model.Provider, providerUID string) (*model.Identity, error)
|
|
24
|
-
CreateUserWithIdentity(ctx context.Context, u *model.User, i *model.Identity) error
|
|
25
|
-
CreateIdentity(ctx context.Context, i *model.Identity) error
|
|
26
|
-
UpdateIdentity(ctx context.Context, i *model.Identity) error
|
|
27
|
-
LoginLockedUntil(ctx context.Context, key string) (time.Time, error)
|
|
28
|
-
RecordLoginFailure(ctx context.Context, key string, freeAttempts int, maxLock time.Duration) error
|
|
29
|
-
ClearLoginFailures(ctx context.Context, key string) error
|
|
30
|
-
// go-scaffold:user-repository-interface
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// repository is kept as a package-local alias so generated method extensions
|
|
34
|
-
// and focused tests can use the short consumer-side name without hiding the
|
|
35
|
-
// public Dependencies contract from callers outside this package.
|
|
36
|
-
type repository = UserRepository
|
|
37
|
-
|
|
38
|
-
// mailer = what the service needs to send an email. The concrete client may
|
|
39
|
-
// send inline or enqueue onto cmd/worker; auth only depends on this port.
|
|
40
|
-
type AuthMailer interface {
|
|
41
|
-
Send(ctx context.Context, to, subject, body string) error
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// go-scaffold:user-interfaces
|
|
45
|
-
|
|
46
|
-
// RoleChecker is the narrow capability the user domain needs when RBAC is
|
|
47
|
-
// installed. Keeping the interface here means auth can be generated on its
|
|
48
|
-
// own; the role domain satisfies it structurally when the optional feature is
|
|
49
|
-
// added.
|
|
50
|
-
type RoleChecker interface {
|
|
51
|
-
CodeExists(ctx context.Context, code string) (bool, error)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Service is the stable facade exposed to handlers, cmd/seed, and generated
|
|
55
|
-
// extensions. The behavior is implemented in focused files by use-case area:
|
|
56
|
-
// local_auth.go, sessions.go, recovery_service.go, external_login.go, and
|
|
57
|
-
// user_query.go. Keeping the facade avoids route and generator compatibility
|
|
58
|
-
// churn while preventing one 600-line service file from becoming the place
|
|
59
|
-
// where every auth concern must be edited.
|
|
60
|
-
type Service struct {
|
|
61
|
-
repo UserRepository
|
|
62
|
-
refreshTokens RefreshTokenStore
|
|
63
|
-
oauthTransactions OAuthTransactionStore
|
|
64
|
-
recoveryTokens RecoveryTokenStore
|
|
65
|
-
mfa MFAStore
|
|
66
|
-
mailer AuthMailer
|
|
67
|
-
providers ProviderRegistry
|
|
68
|
-
recovery *application.Recovery
|
|
69
|
-
config AuthConfig
|
|
70
|
-
roles RoleChecker
|
|
71
|
-
now func() time.Time
|
|
72
|
-
// go-scaffold:user-service-fields
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// NewService is deliberately explicit: infrastructure dependencies and auth
|
|
76
|
-
// policy are separate values, so the application layer never receives the
|
|
77
|
-
// generated project's catch-all shared configuration object.
|
|
78
|
-
func NewService(deps Dependencies, cfg AuthConfig) *Service {
|
|
79
|
-
if cfg.JWTAccessTTL <= 0 {
|
|
80
|
-
cfg.JWTAccessTTL = 15 * time.Minute
|
|
81
|
-
}
|
|
82
|
-
if cfg.JWTRefreshTTL <= 0 {
|
|
83
|
-
cfg.JWTRefreshTTL = 12 * time.Hour
|
|
84
|
-
}
|
|
85
|
-
cfg.JWTRefreshMaxTTL = defaultDuration(cfg.JWTRefreshMaxTTL, cfg.JWTRefreshTTL)
|
|
86
|
-
cfg.OAuthStateTTL = defaultDuration(cfg.OAuthStateTTL, 10*time.Minute)
|
|
87
|
-
if cfg.MFA.RecoveryCodeCount == 0 {
|
|
88
|
-
cfg.MFA.RecoveryCodeCount = defaultMFACodes
|
|
89
|
-
}
|
|
90
|
-
clock := deps.Clock
|
|
91
|
-
if clock == nil {
|
|
92
|
-
clock = time.Now
|
|
93
|
-
}
|
|
94
|
-
providers := deps.Providers
|
|
95
|
-
if providers == nil {
|
|
96
|
-
providers = application.NewProviderRegistry()
|
|
97
|
-
}
|
|
98
|
-
return &Service{
|
|
99
|
-
repo: deps.Repository,
|
|
100
|
-
refreshTokens: deps.RefreshTokens,
|
|
101
|
-
oauthTransactions: deps.OAuthTransactions,
|
|
102
|
-
recoveryTokens: deps.RecoveryTokens,
|
|
103
|
-
mfa: deps.MFA,
|
|
104
|
-
mailer: deps.Mailer,
|
|
105
|
-
providers: providers,
|
|
106
|
-
recovery: newRecovery(deps.Repository, deps.RecoveryTokens),
|
|
107
|
-
config: cfg,
|
|
108
|
-
roles: deps.Roles,
|
|
109
|
-
now: clock,
|
|
110
|
-
// go-scaffold:user-service-init
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
func defaultDuration(value, fallback time.Duration) time.Duration {
|
|
115
|
-
if value > 0 {
|
|
116
|
-
return value
|
|
117
|
-
}
|
|
118
|
-
return fallback
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
func (s *Service) clock() time.Time {
|
|
122
|
-
if s.now == nil {
|
|
123
|
-
return time.Now()
|
|
124
|
-
}
|
|
125
|
-
return s.now()
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// normalizeEmail is applied at every boundary an address enters the service
|
|
129
|
-
// through. The column is a plain case-sensitive UNIQUE, so without this
|
|
130
|
-
// "Foo@x.com" and "foo@x.com" could become two accounts.
|
|
131
|
-
func normalizeEmail(email string) string {
|
|
132
|
-
return strings.ToLower(strings.TrimSpace(email))
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// go-scaffold:user-service-methods
|