@nakedev/go-scaffold 0.3.3 → 0.4.3
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 +288 -50
- package/dist/commands/auth.js +53 -22
- package/dist/commands/config.js +50 -0
- package/dist/commands/create.js +32 -2
- package/dist/commands/generate.js +25 -2
- package/dist/commands/method.js +22 -7
- package/dist/commands/migration.js +2 -2
- package/dist/commands/observability.js +3 -3
- package/dist/commands/rbac.js +3 -3
- package/dist/commands/undo.js +5 -0
- package/dist/commands/worker.js +1 -1
- package/dist/index.js +186 -59
- package/dist/prompts/auth-wizard.js +40 -6
- package/dist/prompts/create-wizard.js +43 -2
- package/dist/prompts/generate-wizard.js +89 -9
- package/dist/templates/auth-manifest.js +31 -1
- package/dist/templates/create-manifest.js +4 -0
- package/dist/templates/module-manifest.js +37 -1
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/types.js +6 -0
- package/dist/utils/auth-patcher.js +115 -24
- package/dist/utils/config.js +147 -3
- package/dist/utils/main-patcher.js +29 -27
- package/dist/utils/marker-patch.js +7 -1
- package/dist/utils/method-patcher.js +261 -81
- package/dist/utils/module-profile.js +32 -0
- package/dist/utils/observability-patcher.js +2 -2
- package/dist/utils/platform-patcher.js +29 -7
- package/dist/utils/rbac-patcher.js +97 -75
- package/package.json +7 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +13 -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/application/oauth.go.hbs +132 -0
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
- package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
- package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
- package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
- package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
- package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
- package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
- package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
- package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
- package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
- package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
- package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
- package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
- package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
- package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
- package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
- package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
- package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
- package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
- package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
- package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
- package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
- package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
- package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
- package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
- package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
- package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
- package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
- package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -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 +9 -4
- package/templates/add/auth/migrations/create_identities.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 +2 -2
- package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
- package/templates/create/base/.env.example.hbs +0 -1
- package/templates/create/base/AGENTS.md.hbs +255 -67
- package/templates/create/base/Makefile.hbs +2 -1
- package/templates/create/base/README.md.hbs +45 -17
- package/templates/create/base/cmd/api/wiring.go.hbs +18 -25
- 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 +38 -16
- package/templates/create/features/docs/patterns.md.hbs +40 -21
- package/templates/create/features/docs/techstack.md.hbs +3 -3
- package/templates/generate/module/commands.go.hbs +95 -0
- package/templates/generate/module/composition.go.hbs +23 -0
- package/templates/generate/module/cqrs_test.go.hbs +7 -0
- package/templates/generate/module/handler.go.hbs +50 -5
- package/templates/generate/module/minimal/commands.go.hbs +34 -0
- package/templates/generate/module/minimal/handler.go.hbs +34 -0
- package/templates/generate/module/minimal/queries.go.hbs +45 -0
- package/templates/generate/module/minimal/service.go.hbs +27 -1
- package/templates/generate/module/queries.go.hbs +62 -0
- package/templates/generate/module/service.go.hbs +61 -5
- package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
- package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
|
@@ -2,29 +2,19 @@ package user
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
-
"encoding/json"
|
|
6
|
-
"errors"
|
|
7
|
-
"log/slog"
|
|
8
|
-
"net/http"
|
|
9
5
|
"strings"
|
|
10
6
|
"time"
|
|
11
7
|
|
|
8
|
+
"{{goModule}}/internal/app/user/application"
|
|
12
9
|
"{{goModule}}/internal/app/user/model"
|
|
13
|
-
"{{goModule}}/internal/shared/apperror"
|
|
14
|
-
"{{goModule}}/internal/shared/config"
|
|
15
|
-
"{{goModule}}/internal/shared/dberr"
|
|
16
|
-
"{{goModule}}/internal/shared/id"
|
|
17
10
|
|
|
18
11
|
"github.com/google/uuid"
|
|
19
|
-
"golang.org/x/crypto/bcrypt"
|
|
20
|
-
"golang.org/x/oauth2"
|
|
21
|
-
"golang.org/x/oauth2/google"
|
|
22
|
-
"gorm.io/gorm"
|
|
23
12
|
)
|
|
24
13
|
|
|
25
|
-
// repository = what the
|
|
26
|
-
// consumer
|
|
27
|
-
|
|
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 {
|
|
28
18
|
FindByEmail(ctx context.Context, email string) (*model.User, error)
|
|
29
19
|
FindByID(ctx context.Context, id uuid.UUID) (*model.User, error)
|
|
30
20
|
UpdateUser(ctx context.Context, u *model.User) error
|
|
@@ -40,492 +30,106 @@ type repository interface {
|
|
|
40
30
|
// go-scaffold:user-repository-interface
|
|
41
31
|
}
|
|
42
32
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
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 {
|
|
48
41
|
Send(ctx context.Context, to, subject, body string) error
|
|
49
42
|
}
|
|
50
43
|
|
|
51
44
|
// go-scaffold:user-interfaces
|
|
52
45
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
refreshTTL time.Duration
|
|
60
|
-
resetTTL time.Duration
|
|
61
|
-
resetURL string
|
|
62
|
-
verifyTTL time.Duration
|
|
63
|
-
verifyURL string
|
|
64
|
-
googleOAuth *oauth2.Config
|
|
65
|
-
// go-scaffold:user-service-fields
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
func NewService(
|
|
69
|
-
repo repository,
|
|
70
|
-
tokens tokenStore,
|
|
71
|
-
mailer mailer,
|
|
72
|
-
cfg config.Config,
|
|
73
|
-
// go-scaffold:user-service-params
|
|
74
|
-
) *Service {
|
|
75
|
-
return &Service{
|
|
76
|
-
repo: repo,
|
|
77
|
-
tokens: tokens,
|
|
78
|
-
mailer: mailer,
|
|
79
|
-
jwtSecret: cfg.JWTSecret,
|
|
80
|
-
accessTTL: cfg.JWTAccessTTL,
|
|
81
|
-
refreshTTL: cfg.JWTRefreshTTL,
|
|
82
|
-
resetTTL: cfg.PasswordResetTTL,
|
|
83
|
-
resetURL: cfg.PasswordResetURL,
|
|
84
|
-
verifyTTL: cfg.EmailVerifyTTL,
|
|
85
|
-
verifyURL: cfg.EmailVerifyURL,
|
|
86
|
-
googleOAuth: &oauth2.Config{
|
|
87
|
-
ClientID: cfg.GoogleClientID,
|
|
88
|
-
ClientSecret: cfg.GoogleClientSecret,
|
|
89
|
-
RedirectURL: cfg.GoogleRedirectURL,
|
|
90
|
-
Scopes: []string{"openid", "email", "profile"},
|
|
91
|
-
Endpoint: google.Endpoint,
|
|
92
|
-
},
|
|
93
|
-
// go-scaffold:user-service-init
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// normalizeEmail is applied at every boundary an address enters the service
|
|
98
|
-
// through. The column is a plain case-sensitive UNIQUE, so without this
|
|
99
|
-
// "Foo@x.com" and "foo@x.com" are two accounts that never collide — and a
|
|
100
|
-
// Google login (Google always reports lowercase) fails to find the local
|
|
101
|
-
// account it should have linked to, silently creating a second user for the
|
|
102
|
-
// same person.
|
|
103
|
-
func normalizeEmail(email string) string {
|
|
104
|
-
return strings.ToLower(strings.TrimSpace(email))
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Failed-attempt policy. Constants rather than config: these are a security
|
|
108
|
-
// posture, not something to tune per environment, and every knob added here is
|
|
109
|
-
// a knob someone can quietly widen until the control stops working. Change the
|
|
110
|
-
// numbers if your threat model differs.
|
|
111
|
-
//
|
|
112
|
-
// The first loginFreeAttempts failures cost nothing — a typo shouldn't lock
|
|
113
|
-
// anyone out. After that each failure doubles the wait (1s, 2s, 4s, ...) up to
|
|
114
|
-
// loginMaxLock, which is the shape OWASP's Authentication Cheat Sheet asks for.
|
|
115
|
-
const (
|
|
116
|
-
loginFreeAttempts = 3
|
|
117
|
-
loginMaxLock = 15 * time.Minute
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
// throttleKey namespaces the counter by what is being attempted, so a locked
|
|
121
|
-
// login never blocks the password-reset that would fix it — someone who forgot
|
|
122
|
-
// their password is exactly the person who trips the login counter.
|
|
123
|
-
//
|
|
124
|
-
// Hashed, because the counter must exist for addresses that have no account
|
|
125
|
-
// (otherwise "was I throttled" answers "does this account exist"), and a table
|
|
126
|
-
// of plain addresses that anyone has ever typed is a user list.
|
|
127
|
-
func throttleKey(purpose, email string) string {
|
|
128
|
-
return hashToken(purpose + ":" + normalizeEmail(email))
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// throttled reports whether this key is inside its lockout window. A failure
|
|
132
|
-
// to read the counter is treated as not-throttled: this is a brake, and it
|
|
133
|
-
// should not be able to lock everybody out on its own.
|
|
134
|
-
func (s *Service) throttled(ctx context.Context, key string) bool {
|
|
135
|
-
until, err := s.repo.LoginLockedUntil(ctx, key)
|
|
136
|
-
if err != nil {
|
|
137
|
-
slog.Error("read login throttle", "error", err)
|
|
138
|
-
return false
|
|
139
|
-
}
|
|
140
|
-
return !until.IsZero() && time.Now().Before(until)
|
|
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)
|
|
141
52
|
}
|
|
142
53
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
func (s *Service) Login(ctx context.Context, in loginInput) (*authResponse, error) {
|
|
165
|
-
key := throttleKey("login", in.Email)
|
|
166
|
-
if s.throttled(ctx, key) {
|
|
167
|
-
return nil, errTooManyAttempts()
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Every failure below is recorded against the same key whether or not the
|
|
171
|
-
// account exists, and answers with the same error — so the counter cannot
|
|
172
|
-
// be used to enumerate accounts either.
|
|
173
|
-
fail := func() (*authResponse, error) {
|
|
174
|
-
if err := s.repo.RecordLoginFailure(ctx, key, loginFreeAttempts, loginMaxLock); err != nil {
|
|
175
|
-
slog.Error("record login failure", "error", err)
|
|
176
|
-
}
|
|
177
|
-
return nil, errInvalidCredentials()
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
u, err := s.repo.FindByEmail(ctx, normalizeEmail(in.Email))
|
|
181
|
-
if err != nil {
|
|
182
|
-
return fail()
|
|
183
|
-
}
|
|
184
|
-
ident, err := s.repo.FindIdentity(ctx, u.ID, model.ProviderLocal)
|
|
185
|
-
if err != nil || ident.PasswordHash == nil {
|
|
186
|
-
return fail()
|
|
187
|
-
}
|
|
188
|
-
if err := bcrypt.CompareHashAndPassword([]byte(*ident.PasswordHash), []byte(in.Password)); err != nil {
|
|
189
|
-
return fail()
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
if err := s.repo.ClearLoginFailures(ctx, key); err != nil {
|
|
193
|
-
slog.Error("clear login failures", "error", err)
|
|
194
|
-
}
|
|
195
|
-
return s.issueTokens(ctx, u)
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Refresh rotates a refresh token: the presented one is consumed (deleted +
|
|
199
|
-
// tombstoned as "used") and a fresh pair is issued. If the presented token
|
|
200
|
-
// isn't active but WAS already used, that means this exact raw value got
|
|
201
|
-
// replayed after rotation — i.e. it leaked — so every session for that user
|
|
202
|
-
// is revoked, not just this one.
|
|
203
|
-
func (s *Service) Refresh(ctx context.Context, rawRefreshToken string) (*authResponse, error) {
|
|
204
|
-
hash := hashToken(rawRefreshToken)
|
|
205
|
-
userID, ok, err := s.tokens.GetRefreshToken(ctx, hash)
|
|
206
|
-
if err != nil {
|
|
207
|
-
return nil, apperror.NewInternal()
|
|
208
|
-
}
|
|
209
|
-
if !ok {
|
|
210
|
-
if reusedBy, used, uerr := s.tokens.IsRefreshTokenUsed(ctx, hash); uerr == nil && used {
|
|
211
|
-
_ = s.tokens.RevokeAllRefreshTokens(ctx, reusedBy)
|
|
212
|
-
}
|
|
213
|
-
return nil, errInvalidToken()
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
if err := s.tokens.DeleteRefreshToken(ctx, hash, userID); err != nil {
|
|
217
|
-
return nil, apperror.NewInternal()
|
|
218
|
-
}
|
|
219
|
-
if err := s.tokens.MarkRefreshTokenUsed(ctx, hash, userID, s.refreshTTL); err != nil {
|
|
220
|
-
return nil, apperror.NewInternal()
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
u, err := s.repo.FindByID(ctx, userID)
|
|
224
|
-
if err != nil {
|
|
225
|
-
return nil, errInvalidToken()
|
|
226
|
-
}
|
|
227
|
-
return s.issueTokens(ctx, u)
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Logout is intentional, not reuse — no tombstoning, and idempotent (a
|
|
231
|
-
// missing/already-gone token still succeeds).
|
|
232
|
-
func (s *Service) Logout(ctx context.Context, rawRefreshToken string) error {
|
|
233
|
-
if rawRefreshToken == "" {
|
|
234
|
-
return nil
|
|
235
|
-
}
|
|
236
|
-
hash := hashToken(rawRefreshToken)
|
|
237
|
-
userID, ok, err := s.tokens.GetRefreshToken(ctx, hash)
|
|
238
|
-
if err != nil || !ok {
|
|
239
|
-
return nil
|
|
240
|
-
}
|
|
241
|
-
return s.tokens.DeleteRefreshToken(ctx, hash, userID)
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// LogoutAll is the authenticated, self-service version of what ResetPassword
|
|
245
|
-
// already does automatically: end every session for this user, not just the
|
|
246
|
-
// one making the request — e.g. "log out everywhere" after a lost device.
|
|
247
|
-
func (s *Service) LogoutAll(ctx context.Context, userID uuid.UUID) error {
|
|
248
|
-
return s.tokens.RevokeAllRefreshTokens(ctx, userID)
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
func (s *Service) Get(ctx context.Context, userID uuid.UUID) (*model.User, error) {
|
|
252
|
-
u, err := s.repo.FindByID(ctx, userID)
|
|
253
|
-
if err != nil {
|
|
254
|
-
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
255
|
-
return nil, errNotFound()
|
|
256
|
-
}
|
|
257
|
-
return nil, apperror.NewInternal()
|
|
258
|
-
}
|
|
259
|
-
return u, nil
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// List is unused until an admin-facing route calls it (see `add rbac`'s
|
|
263
|
-
// GET /users) — kept here rather than gated behind a patch since it's a
|
|
264
|
-
// generically useful piece of data access, same as UpdateUser.
|
|
265
|
-
func (s *Service) List(ctx context.Context, limit, offset int) ([]model.User, error) {
|
|
266
|
-
items, err := s.repo.FindAll(ctx, limit, offset)
|
|
267
|
-
if err != nil {
|
|
268
|
-
return nil, apperror.NewInternal()
|
|
269
|
-
}
|
|
270
|
-
return items, nil
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// ForgotPassword always succeeds from the caller's point of view — whether
|
|
274
|
-
// the email exists or not — so a login-attempt-shaped probe can't be used to
|
|
275
|
-
// enumerate registered accounts. Only if the email resolves to a local
|
|
276
|
-
// (password-based) identity does it actually issue+email a reset token.
|
|
277
|
-
func (s *Service) ForgotPassword(ctx context.Context, email string) error {
|
|
278
|
-
// Its own counter, so hammering this endpoint cannot lock anyone out of
|
|
279
|
-
// logging in, and a locked-out login cannot block the reset that fixes it.
|
|
280
|
-
// Every request counts here, not just failures: what this throttles is
|
|
281
|
-
// using someone else's address as a mail-bomb target.
|
|
282
|
-
key := throttleKey("pwreset", email)
|
|
283
|
-
if s.throttled(ctx, key) {
|
|
284
|
-
return nil // same answer as always — silence is the whole design here
|
|
285
|
-
}
|
|
286
|
-
if err := s.repo.RecordLoginFailure(ctx, key, loginFreeAttempts, loginMaxLock); err != nil {
|
|
287
|
-
slog.Error("record password reset attempt", "error", err)
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
u, err := s.repo.FindByEmail(ctx, normalizeEmail(email))
|
|
291
|
-
if err != nil {
|
|
292
|
-
return nil
|
|
293
|
-
}
|
|
294
|
-
ident, err := s.repo.FindIdentity(ctx, u.ID, model.ProviderLocal)
|
|
295
|
-
if err != nil || ident.PasswordHash == nil {
|
|
296
|
-
return nil // google-only account has no password to reset
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
raw, err := randomToken()
|
|
300
|
-
if err != nil {
|
|
301
|
-
return apperror.NewInternal()
|
|
302
|
-
}
|
|
303
|
-
if err := s.tokens.SetPasswordResetToken(ctx, hashToken(raw), u.ID, s.resetTTL); err != nil {
|
|
304
|
-
return apperror.NewInternal()
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
link := s.resetURL + "?token=" + raw
|
|
308
|
-
if err := s.mailer.Send(ctx, u.Email, "Reset your password", "Reset your password: "+link); err != nil {
|
|
309
|
-
// token's already stored — a mail failure shouldn't fail the request,
|
|
310
|
-
// just get logged so it's visible operationally.
|
|
311
|
-
slog.Error("send password reset email", "error", err)
|
|
312
|
-
}
|
|
313
|
-
return nil
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// ResetPassword consumes a one-time reset token (GETDEL — see
|
|
317
|
-
// tokenStore.ConsumePasswordResetToken), sets the new password, and revokes
|
|
318
|
-
// every existing session for that user: a password reset should end every
|
|
319
|
-
// session an attacker (or the legitimate user on another device) still holds.
|
|
320
|
-
func (s *Service) ResetPassword(ctx context.Context, rawToken, newPassword string) error {
|
|
321
|
-
userID, ok, err := s.tokens.ConsumePasswordResetToken(ctx, hashToken(rawToken))
|
|
322
|
-
if err != nil {
|
|
323
|
-
return apperror.NewInternal()
|
|
324
|
-
}
|
|
325
|
-
if !ok {
|
|
326
|
-
return errInvalidToken()
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
|
330
|
-
if err != nil {
|
|
331
|
-
return apperror.NewInternal()
|
|
332
|
-
}
|
|
333
|
-
hashStr := string(hash)
|
|
334
|
-
|
|
335
|
-
ident, err := s.repo.FindIdentity(ctx, userID, model.ProviderLocal)
|
|
336
|
-
if err != nil {
|
|
337
|
-
return apperror.NewInternal()
|
|
338
|
-
}
|
|
339
|
-
ident.PasswordHash = &hashStr
|
|
340
|
-
if err := s.repo.UpdateIdentity(ctx, ident); err != nil {
|
|
341
|
-
return apperror.NewInternal()
|
|
342
|
-
}
|
|
343
|
-
return s.tokens.RevokeAllRefreshTokens(ctx, userID)
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// sendVerificationEmail issues a one-time token and emails the link — called
|
|
347
|
-
// from Register (best-effort) and ResendVerificationEmail. Not exported:
|
|
348
|
-
// callers that want to send one go through one of those two, which decide
|
|
349
|
-
// whether it's appropriate to (e.g. ResendVerificationEmail checks the user
|
|
350
|
-
// isn't already verified first).
|
|
351
|
-
func (s *Service) sendVerificationEmail(ctx context.Context, u *model.User) {
|
|
352
|
-
raw, err := randomToken()
|
|
353
|
-
if err != nil {
|
|
354
|
-
slog.Error("generate email verification token", "error", err)
|
|
355
|
-
return
|
|
356
|
-
}
|
|
357
|
-
if err := s.tokens.SetEmailVerifyToken(ctx, hashToken(raw), u.ID, s.verifyTTL); err != nil {
|
|
358
|
-
slog.Error("store email verification token", "error", err)
|
|
359
|
-
return
|
|
360
|
-
}
|
|
361
|
-
link := s.verifyURL + "?token=" + raw
|
|
362
|
-
if err := s.mailer.Send(ctx, u.Email, "Verify your email", "Verify your email: "+link); err != nil {
|
|
363
|
-
slog.Error("send email verification email", "error", err)
|
|
364
|
-
}
|
|
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
|
|
365
73
|
}
|
|
366
74
|
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
u, err := s.repo.FindByID(ctx, userID)
|
|
374
|
-
if err != nil {
|
|
375
|
-
return apperror.NewInternal()
|
|
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
|
|
376
81
|
}
|
|
377
|
-
if
|
|
378
|
-
|
|
82
|
+
if cfg.JWTRefreshTTL <= 0 {
|
|
83
|
+
cfg.JWTRefreshTTL = 12 * time.Hour
|
|
379
84
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
// VerifyEmail consumes a one-time verification token (GETDEL — see
|
|
385
|
-
// tokenStore.ConsumeEmailVerifyToken) and marks the user verified.
|
|
386
|
-
func (s *Service) VerifyEmail(ctx context.Context, rawToken string) error {
|
|
387
|
-
userID, ok, err := s.tokens.ConsumeEmailVerifyToken(ctx, hashToken(rawToken))
|
|
388
|
-
if err != nil {
|
|
389
|
-
return apperror.NewInternal()
|
|
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
|
|
390
89
|
}
|
|
391
|
-
|
|
392
|
-
|
|
90
|
+
clock := deps.Clock
|
|
91
|
+
if clock == nil {
|
|
92
|
+
clock = time.Now
|
|
393
93
|
}
|
|
394
|
-
|
|
395
|
-
if
|
|
396
|
-
|
|
94
|
+
providers := deps.Providers
|
|
95
|
+
if providers == nil {
|
|
96
|
+
providers = application.NewProviderRegistry()
|
|
397
97
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
|
410
111
|
}
|
|
411
|
-
return s.googleOAuth.AuthCodeURL(state), nonce, nil
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
type googleUserInfo struct {
|
|
415
|
-
Sub string `json:"sub"`
|
|
416
|
-
Email string `json:"email"`
|
|
417
|
-
EmailVerified bool `json:"email_verified"`
|
|
418
|
-
Name string `json:"name"`
|
|
419
|
-
Picture string `json:"picture"`
|
|
420
112
|
}
|
|
421
113
|
|
|
422
|
-
func (
|
|
423
|
-
if
|
|
424
|
-
return
|
|
425
|
-
}
|
|
426
|
-
tok, err := s.googleOAuth.Exchange(ctx, code)
|
|
427
|
-
if err != nil {
|
|
428
|
-
return nil, errInvalidToken()
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
resp, err := s.googleOAuth.Client(ctx, tok).Get("https://openidconnect.googleapis.com/v1/userinfo")
|
|
432
|
-
if err != nil {
|
|
433
|
-
return nil, apperror.NewInternal()
|
|
434
|
-
}
|
|
435
|
-
defer func() { _ = resp.Body.Close() }()
|
|
436
|
-
if resp.StatusCode != http.StatusOK {
|
|
437
|
-
return nil, apperror.NewInternal()
|
|
114
|
+
func defaultDuration(value, fallback time.Duration) time.Duration {
|
|
115
|
+
if value > 0 {
|
|
116
|
+
return value
|
|
438
117
|
}
|
|
439
|
-
|
|
440
|
-
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
|
441
|
-
return nil, apperror.NewInternal()
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
u, err := s.findOrCreateGoogleUser(ctx, info)
|
|
445
|
-
if err != nil {
|
|
446
|
-
return nil, err
|
|
447
|
-
}
|
|
448
|
-
return s.issueTokens(ctx, u)
|
|
118
|
+
return fallback
|
|
449
119
|
}
|
|
450
120
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
// unverified email can't be used to take over someone else's account; (3)
|
|
455
|
-
// otherwise, brand new user + identity.
|
|
456
|
-
func (s *Service) findOrCreateGoogleUser(ctx context.Context, info googleUserInfo) (*model.User, error) {
|
|
457
|
-
if ident, err := s.repo.FindIdentityByProviderUID(ctx, model.ProviderGoogle, info.Sub); err == nil {
|
|
458
|
-
return s.repo.FindByID(ctx, ident.UserID)
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
providerUID := info.Sub
|
|
462
|
-
email := normalizeEmail(info.Email)
|
|
463
|
-
if info.EmailVerified {
|
|
464
|
-
if u, err := s.repo.FindByEmail(ctx, email); err == nil {
|
|
465
|
-
ident := &model.Identity{ID: id.New(), UserID: u.ID, Provider: model.ProviderGoogle, ProviderUID: &providerUID}
|
|
466
|
-
if err := s.repo.CreateIdentity(ctx, ident); err != nil {
|
|
467
|
-
return nil, apperror.NewInternal()
|
|
468
|
-
}
|
|
469
|
-
return u, nil
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
u := &model.User{ID: id.New(), Email: email, Name: info.Name, AvatarURL: info.Picture, EmailVerified: info.EmailVerified}
|
|
474
|
-
ident := &model.Identity{ID: id.New(), Provider: model.ProviderGoogle, ProviderUID: &providerUID}
|
|
475
|
-
if err := s.repo.CreateUserWithIdentity(ctx, u, ident); err != nil {
|
|
476
|
-
if dberr.IsDuplicate(err) {
|
|
477
|
-
return nil, errEmailTaken()
|
|
478
|
-
}
|
|
479
|
-
return nil, apperror.NewInternal()
|
|
121
|
+
func (s *Service) clock() time.Time {
|
|
122
|
+
if s.now == nil {
|
|
123
|
+
return time.Now()
|
|
480
124
|
}
|
|
481
|
-
return
|
|
125
|
+
return s.now()
|
|
482
126
|
}
|
|
483
127
|
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
email = normalizeEmail(email)
|
|
490
|
-
if u, err := s.repo.FindByEmail(ctx, email); err == nil {
|
|
491
|
-
return u, nil
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
495
|
-
if err != nil {
|
|
496
|
-
return nil, apperror.NewInternal()
|
|
497
|
-
}
|
|
498
|
-
hashStr := string(hash)
|
|
499
|
-
|
|
500
|
-
u := &model.User{ID: id.New(), Email: email, Name: name}
|
|
501
|
-
i := &model.Identity{ID: id.New(), Provider: model.ProviderLocal, PasswordHash: &hashStr}
|
|
502
|
-
if err := s.repo.CreateUserWithIdentity(ctx, u, i); err != nil {
|
|
503
|
-
return nil, apperror.NewInternal()
|
|
504
|
-
}
|
|
505
|
-
return u, nil
|
|
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))
|
|
506
133
|
}
|
|
507
134
|
|
|
508
135
|
// go-scaffold:user-service-methods
|
|
509
|
-
|
|
510
|
-
func (s *Service) issueTokens(ctx context.Context, u *model.User) (*authResponse, error) {
|
|
511
|
-
access, err := s.issueAccessToken(
|
|
512
|
-
u.ID,
|
|
513
|
-
// go-scaffold:issue-access-token-args
|
|
514
|
-
)
|
|
515
|
-
if err != nil {
|
|
516
|
-
return nil, apperror.NewInternal()
|
|
517
|
-
}
|
|
518
|
-
refresh, err := randomToken()
|
|
519
|
-
if err != nil {
|
|
520
|
-
return nil, apperror.NewInternal()
|
|
521
|
-
}
|
|
522
|
-
if err := s.tokens.SetRefreshToken(ctx, hashToken(refresh), u.ID, s.refreshTTL); err != nil {
|
|
523
|
-
return nil, apperror.NewInternal()
|
|
524
|
-
}
|
|
525
|
-
return &authResponse{
|
|
526
|
-
AccessToken: access,
|
|
527
|
-
RefreshToken: refresh,
|
|
528
|
-
TokenType: "Bearer",
|
|
529
|
-
ExpiresIn: int(s.accessTTL.Seconds()),
|
|
530
|
-
}, nil
|
|
531
|
-
}
|