@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
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
package user
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"net/url"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
"time"
|
|
10
|
+
|
|
11
|
+
"{{goModule}}/internal/app/user/application"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
type fakeLoginProvider struct {
|
|
15
|
+
name string
|
|
16
|
+
beginFn func(application.LoginStartInput) (application.Authorization, error)
|
|
17
|
+
completeFn func(application.LoginCompleteInput) (application.ExternalIdentity, error)
|
|
18
|
+
beginCnt int
|
|
19
|
+
completeCnt int
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func (p *fakeLoginProvider) Name() string { return p.name }
|
|
23
|
+
|
|
24
|
+
func (p *fakeLoginProvider) Begin(_ context.Context, in application.LoginStartInput) (application.Authorization, error) {
|
|
25
|
+
p.beginCnt++
|
|
26
|
+
return p.beginFn(in)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func (p *fakeLoginProvider) Complete(_ context.Context, in application.LoginCompleteInput) (application.ExternalIdentity, error) {
|
|
30
|
+
p.completeCnt++
|
|
31
|
+
return p.completeFn(in)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func newTestServiceWithProviders(repo repository, tokens testTokenStore, providers ...application.LoginProvider) *Service {
|
|
35
|
+
return NewService(Dependencies{
|
|
36
|
+
Repository: repo,
|
|
37
|
+
RefreshTokens: tokens,
|
|
38
|
+
OAuthTransactions: tokens,
|
|
39
|
+
RecoveryTokens: tokens,
|
|
40
|
+
MFA: newFakeMFAStore(),
|
|
41
|
+
Mailer: fakeMailer{},
|
|
42
|
+
Providers: application.NewProviderRegistry(providers...),
|
|
43
|
+
}, AuthConfig{
|
|
44
|
+
JWTSecret: "test-secret",
|
|
45
|
+
JWTAccessTTL: time.Minute,
|
|
46
|
+
JWTRefreshTTL: time.Hour,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func oauthErrorCode(t *testing.T, err error) application.OAuthErrorCode {
|
|
51
|
+
t.Helper()
|
|
52
|
+
var oauthErr *application.OAuthError
|
|
53
|
+
if !errors.As(err, &oauthErr) {
|
|
54
|
+
t.Fatalf("expected *application.OAuthError, got %T: %v", err, err)
|
|
55
|
+
}
|
|
56
|
+
return oauthErr.Code
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const testOAuthVerifier = "client-code-verifier-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
60
|
+
|
|
61
|
+
func validLoginStart() application.LoginStartInput {
|
|
62
|
+
return application.LoginStartInput{
|
|
63
|
+
State: "client state",
|
|
64
|
+
CodeChallenge: pkceChallenge(testOAuthVerifier),
|
|
65
|
+
CodeChallengeMethod: "S256",
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func validLoginExchange() loginExchangeInput {
|
|
70
|
+
return loginExchangeInput{Code: "code", State: "client state", CodeVerifier: testOAuthVerifier}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func validFakeProvider(t *testing.T) (*fakeLoginProvider, *Service) {
|
|
74
|
+
t.Helper()
|
|
75
|
+
provider := &fakeLoginProvider{name: "fake"}
|
|
76
|
+
provider.beginFn = func(in application.LoginStartInput) (application.Authorization, error) {
|
|
77
|
+
expected := validLoginStart()
|
|
78
|
+
if in.State != expected.State || in.CodeChallenge != expected.CodeChallenge || in.CodeChallengeMethod != expected.CodeChallengeMethod || strings.TrimSpace(in.Nonce) == "" {
|
|
79
|
+
t.Fatalf("unexpected begin input: %+v", in)
|
|
80
|
+
}
|
|
81
|
+
return application.Authorization{URL: "https://provider.example.test/authorize?" + url.Values{
|
|
82
|
+
"state": {in.State},
|
|
83
|
+
"code_challenge": {in.CodeChallenge},
|
|
84
|
+
"code_challenge_method": {in.CodeChallengeMethod},
|
|
85
|
+
"nonce": {in.Nonce},
|
|
86
|
+
}.Encode()}, nil
|
|
87
|
+
}
|
|
88
|
+
provider.completeFn = func(in application.LoginCompleteInput) (application.ExternalIdentity, error) {
|
|
89
|
+
if in.Code != "code" || in.CodeVerifier != testOAuthVerifier || strings.TrimSpace(in.Nonce) == "" {
|
|
90
|
+
t.Fatalf("unexpected complete input: %+v", in)
|
|
91
|
+
}
|
|
92
|
+
return application.ExternalIdentity{
|
|
93
|
+
Provider: "fake",
|
|
94
|
+
Subject: "subject-1",
|
|
95
|
+
Email: "user@example.com",
|
|
96
|
+
EmailVerified: true,
|
|
97
|
+
Name: "User",
|
|
98
|
+
}, nil
|
|
99
|
+
}
|
|
100
|
+
return provider, newTestServiceWithProviders(&fakeRepo{}, newFakeTokenStore(), provider)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func TestService_BeginLoginUsesRegistryAndPreservesClientStateAndS256PKCE(t *testing.T) {
|
|
104
|
+
provider, svc := validFakeProvider(t)
|
|
105
|
+
start, err := svc.BeginLogin(context.Background(), "fake", validLoginStart())
|
|
106
|
+
if err != nil {
|
|
107
|
+
t.Fatalf("begin login: %v", err)
|
|
108
|
+
}
|
|
109
|
+
if provider.beginCnt != 1 || start == nil || start.URL == "" {
|
|
110
|
+
t.Fatalf("unexpected begin result: provider=%+v start=%+v", provider, start)
|
|
111
|
+
}
|
|
112
|
+
parsed, err := url.Parse(start.URL)
|
|
113
|
+
if err != nil {
|
|
114
|
+
t.Fatalf("parse authorization URL: %v", err)
|
|
115
|
+
}
|
|
116
|
+
if parsed.Query().Get("state") != "client state" || parsed.Query().Get("code_challenge") != pkceChallenge(testOAuthVerifier) || parsed.Query().Get("code_challenge_method") != "S256" || parsed.Query().Get("nonce") == "" {
|
|
117
|
+
t.Fatalf("authorization URL did not preserve client state/PKCE: %s", start.URL)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
func TestService_BeginLoginRejectsMissingStateOrNonS256PKCE(t *testing.T) {
|
|
122
|
+
provider, svc := validFakeProvider(t)
|
|
123
|
+
tests := []struct {
|
|
124
|
+
name string
|
|
125
|
+
in application.LoginStartInput
|
|
126
|
+
}{
|
|
127
|
+
{name: "missing state", in: application.LoginStartInput{CodeChallenge: "challenge", CodeChallengeMethod: "S256"}},
|
|
128
|
+
{name: "missing challenge", in: application.LoginStartInput{State: "state", CodeChallengeMethod: "S256"}},
|
|
129
|
+
{name: "plain challenge method", in: application.LoginStartInput{State: "state", CodeChallenge: "challenge", CodeChallengeMethod: "plain"}},
|
|
130
|
+
{name: "short challenge", in: application.LoginStartInput{State: "state", CodeChallenge: "short", CodeChallengeMethod: "S256"}},
|
|
131
|
+
{name: "invalid challenge character", in: application.LoginStartInput{State: "state", CodeChallenge: strings.Repeat("!", 43), CodeChallengeMethod: "S256"}},
|
|
132
|
+
{name: "lowercase challenge method", in: application.LoginStartInput{State: "state", CodeChallenge: strings.Repeat("A", 43), CodeChallengeMethod: "s256"}},
|
|
133
|
+
}
|
|
134
|
+
for _, tt := range tests {
|
|
135
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
136
|
+
_, err := svc.BeginLogin(context.Background(), "fake", tt.in)
|
|
137
|
+
if got := oauthErrorCode(t, err); got != application.OAuthStateInvalid {
|
|
138
|
+
t.Fatalf("oauth code = %q, want %q", got, application.OAuthStateInvalid)
|
|
139
|
+
}
|
|
140
|
+
if provider.beginCnt != 0 {
|
|
141
|
+
t.Fatal("provider must not receive an invalid state/PKCE request")
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func TestService_ExchangeLoginReturnsSessionForNormalizedProviderIdentity(t *testing.T) {
|
|
148
|
+
provider, svc := validFakeProvider(t)
|
|
149
|
+
if _, err := svc.BeginLogin(context.Background(), "fake", validLoginStart()); err != nil {
|
|
150
|
+
t.Fatalf("begin login: %v", err)
|
|
151
|
+
}
|
|
152
|
+
auth, err := svc.ExchangeLogin(context.Background(), "fake", validLoginExchange())
|
|
153
|
+
if err != nil {
|
|
154
|
+
t.Fatalf("exchange login: %v", err)
|
|
155
|
+
}
|
|
156
|
+
if auth.AccessToken == "" || auth.RefreshToken == "" || provider.completeCnt != 1 {
|
|
157
|
+
t.Fatalf("expected local session after provider completion: auth=%+v complete calls=%d", auth, provider.completeCnt)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func TestService_ExchangeLoginRejectsIdentityFromAnotherProvider(t *testing.T) {
|
|
162
|
+
provider, svc := validFakeProvider(t)
|
|
163
|
+
if _, err := svc.BeginLogin(context.Background(), "fake", validLoginStart()); err != nil {
|
|
164
|
+
t.Fatalf("begin login: %v", err)
|
|
165
|
+
}
|
|
166
|
+
provider.completeFn = func(application.LoginCompleteInput) (application.ExternalIdentity, error) {
|
|
167
|
+
return application.ExternalIdentity{
|
|
168
|
+
Provider: "another-provider",
|
|
169
|
+
Subject: "subject-1",
|
|
170
|
+
Email: "user@example.com",
|
|
171
|
+
EmailVerified: true,
|
|
172
|
+
}, nil
|
|
173
|
+
}
|
|
174
|
+
_, err := svc.ExchangeLogin(context.Background(), "fake", validLoginExchange())
|
|
175
|
+
if got := oauthErrorCode(t, err); got != application.OAuthFailed {
|
|
176
|
+
t.Fatalf("oauth code = %q, want %q", got, application.OAuthFailed)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
func TestService_ExchangeLoginMapsProviderErrorsToControlledCodes(t *testing.T) {
|
|
181
|
+
tests := []struct {
|
|
182
|
+
name string
|
|
183
|
+
providerError error
|
|
184
|
+
wantCode application.OAuthErrorCode
|
|
185
|
+
}{
|
|
186
|
+
{name: "provider exchange failure", providerError: application.NewProviderFailure(errors.New("exchange failed")), wantCode: application.OAuthFailed},
|
|
187
|
+
{name: "provider unavailable", providerError: application.NewProviderUnavailable(errors.New("upstream timeout")), wantCode: application.OAuthProviderUnavailable},
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for _, tt := range tests {
|
|
191
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
192
|
+
provider, svc := validFakeProvider(t)
|
|
193
|
+
if _, err := svc.BeginLogin(context.Background(), "fake", validLoginStart()); err != nil {
|
|
194
|
+
t.Fatalf("begin login: %v", err)
|
|
195
|
+
}
|
|
196
|
+
provider.completeFn = func(application.LoginCompleteInput) (application.ExternalIdentity, error) {
|
|
197
|
+
return application.ExternalIdentity{}, tt.providerError
|
|
198
|
+
}
|
|
199
|
+
_, err := svc.ExchangeLogin(context.Background(), "fake", validLoginExchange())
|
|
200
|
+
if got := oauthErrorCode(t, err); got != tt.wantCode {
|
|
201
|
+
t.Fatalf("oauth code = %q, want %q", got, tt.wantCode)
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
func TestService_ExchangeLoginRejectsMissingStatePKCEAndCode(t *testing.T) {
|
|
208
|
+
_, svc := validFakeProvider(t)
|
|
209
|
+
tests := []struct {
|
|
210
|
+
name string
|
|
211
|
+
in loginExchangeInput
|
|
212
|
+
want application.OAuthErrorCode
|
|
213
|
+
}{
|
|
214
|
+
{name: "missing state", in: loginExchangeInput{Code: "code", CodeVerifier: testOAuthVerifier}, want: application.OAuthStateInvalid},
|
|
215
|
+
{name: "missing verifier", in: loginExchangeInput{Code: "code", State: "state"}, want: application.OAuthStateInvalid},
|
|
216
|
+
{name: "missing code", in: loginExchangeInput{State: "state", CodeVerifier: testOAuthVerifier}, want: application.OAuthFailed},
|
|
217
|
+
}
|
|
218
|
+
for _, tt := range tests {
|
|
219
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
220
|
+
_, err := svc.ExchangeLogin(context.Background(), "fake", tt.in)
|
|
221
|
+
if got := oauthErrorCode(t, err); got != tt.want {
|
|
222
|
+
t.Fatalf("oauth code = %q, want %q", got, tt.want)
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
func TestService_ExchangeLoginConsumesStateAndBindsThePKCEVerifier(t *testing.T) {
|
|
229
|
+
provider, svc := validFakeProvider(t)
|
|
230
|
+
if _, err := svc.BeginLogin(context.Background(), "fake", validLoginStart()); err != nil {
|
|
231
|
+
t.Fatalf("begin login: %v", err)
|
|
232
|
+
}
|
|
233
|
+
wrong := validLoginExchange()
|
|
234
|
+
wrong.CodeVerifier = testOAuthVerifier + "x"
|
|
235
|
+
if _, err := svc.ExchangeLogin(context.Background(), "fake", wrong); oauthErrorCode(t, err) != application.OAuthStateInvalid {
|
|
236
|
+
t.Fatalf("wrong verifier should be rejected as state invalid: %v", err)
|
|
237
|
+
}
|
|
238
|
+
if _, err := svc.ExchangeLogin(context.Background(), "fake", validLoginExchange()); oauthErrorCode(t, err) != application.OAuthStateInvalid {
|
|
239
|
+
t.Fatalf("a rejected exchange must consume the one-time transaction: %v", err)
|
|
240
|
+
}
|
|
241
|
+
if provider.completeCnt != 0 {
|
|
242
|
+
t.Fatal("provider must not receive a wrong PKCE verifier")
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
func TestService_UnconfiguredProviderDoesNotBuildAURL(t *testing.T) {
|
|
247
|
+
svc := newTestServiceWithProviders(&fakeRepo{}, newFakeTokenStore())
|
|
248
|
+
start, err := svc.BeginLogin(context.Background(), "google", validLoginStart())
|
|
249
|
+
if start != nil {
|
|
250
|
+
t.Fatalf("unconfigured provider returned a login start: %+v", start)
|
|
251
|
+
}
|
|
252
|
+
if got := oauthErrorCode(t, err); got != application.OAuthProviderUnavailable {
|
|
253
|
+
t.Fatalf("oauth code = %q, want %q", got, application.OAuthProviderUnavailable)
|
|
254
|
+
}
|
|
255
|
+
_, err = svc.ExchangeLogin(context.Background(), "google", validLoginExchange())
|
|
256
|
+
if got := oauthErrorCode(t, err); got != application.OAuthProviderUnavailable {
|
|
257
|
+
t.Fatalf("exchange oauth code = %q, want %q", got, application.OAuthProviderUnavailable)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
func TestProviderRegistry_UsesProviderNameAsTheOnlyLookupKey(t *testing.T) {
|
|
262
|
+
provider := &fakeLoginProvider{name: "fake"}
|
|
263
|
+
registry := application.NewProviderRegistry(provider)
|
|
264
|
+
if got, ok := registry.Lookup("fake"); !ok || got != provider {
|
|
265
|
+
t.Fatalf("registry lookup = (%v, %v), want fake provider", got, ok)
|
|
266
|
+
}
|
|
267
|
+
if _, ok := registry.Lookup("google"); ok {
|
|
268
|
+
t.Fatal("registry must not synthesize an unconfigured provider")
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
func TestService_BeginLoginRejectsEmptyProviderAuthorizationURL(t *testing.T) {
|
|
273
|
+
provider := &fakeLoginProvider{name: "fake", beginFn: func(application.LoginStartInput) (application.Authorization, error) {
|
|
274
|
+
return application.Authorization{}, nil
|
|
275
|
+
}}
|
|
276
|
+
svc := newTestServiceWithProviders(&fakeRepo{}, newFakeTokenStore(), provider)
|
|
277
|
+
_, err := svc.BeginLogin(context.Background(), "fake", validLoginStart())
|
|
278
|
+
if got := oauthErrorCode(t, err); got != application.OAuthProviderUnavailable {
|
|
279
|
+
t.Fatalf("oauth code = %q, want %q", got, application.OAuthProviderUnavailable)
|
|
280
|
+
}
|
|
281
|
+
if strings.Contains(err.Error(), "Location") {
|
|
282
|
+
t.Fatalf("empty authorization must not become a redirect: %v", err)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
var _ application.LoginProvider = (*fakeLoginProvider)(nil)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
package user
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"log/slog"
|
|
8
|
+
|
|
9
|
+
"{{goModule}}/internal/app/user/application"
|
|
10
|
+
"{{goModule}}/internal/app/user/model"
|
|
11
|
+
"{{goModule}}/internal/shared/apperror"
|
|
12
|
+
|
|
13
|
+
"github.com/google/uuid"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// ForgotPassword always returns success from the caller's perspective. Only a
|
|
17
|
+
// local password identity gets a token, so this endpoint cannot enumerate
|
|
18
|
+
// accounts or issue a reset for an external-only account.
|
|
19
|
+
func (s *Service) ForgotPassword(ctx context.Context, email string) error {
|
|
20
|
+
key := throttleKey("pwreset", email)
|
|
21
|
+
if s.throttled(ctx, key) {
|
|
22
|
+
return nil
|
|
23
|
+
}
|
|
24
|
+
if err := s.repo.RecordLoginFailure(ctx, key, loginFreeAttempts, loginMaxLock); err != nil {
|
|
25
|
+
slog.Error("record password reset attempt", "error", err)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
u, err := s.repo.FindByEmail(ctx, normalizeEmail(email))
|
|
29
|
+
if err != nil {
|
|
30
|
+
return nil
|
|
31
|
+
}
|
|
32
|
+
ident, err := s.repo.FindIdentity(ctx, u.ID, model.ProviderLocal)
|
|
33
|
+
if err != nil || ident.PasswordHash == nil {
|
|
34
|
+
return nil
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
raw, err := randomToken()
|
|
38
|
+
if err != nil {
|
|
39
|
+
return apperror.NewInternal(err)
|
|
40
|
+
}
|
|
41
|
+
if err := s.recoveryTokens.SetPasswordResetToken(ctx, hashToken(raw), u.ID, s.config.PasswordResetTTL); err != nil {
|
|
42
|
+
return apperror.NewInternal(err)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
link := s.config.PasswordResetURL + "?token=" + raw
|
|
46
|
+
if err := s.mailer.Send(ctx, u.Email, "Reset your password", "Reset your password: "+link); err != nil {
|
|
47
|
+
// The token is stored already. Log the delivery failure so it is visible
|
|
48
|
+
// operationally while preserving the anti-enumeration response.
|
|
49
|
+
slog.Error("send password reset email", "error", err)
|
|
50
|
+
}
|
|
51
|
+
return nil
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ResetPassword consumes a one-time token and updates the local identity in
|
|
55
|
+
// one transaction, then revokes all sessions. If the cross-store revoke fails,
|
|
56
|
+
// restoring the token lets the caller retry the complete operation.
|
|
57
|
+
func (s *Service) ResetPassword(ctx context.Context, rawToken, newPassword string) error {
|
|
58
|
+
tokenHash := hashToken(rawToken)
|
|
59
|
+
userID, err := s.recovery.ResetPassword(ctx, tokenHash, newPassword)
|
|
60
|
+
if err != nil {
|
|
61
|
+
if errors.Is(err, application.ErrInvalidToken) {
|
|
62
|
+
return errInvalidToken()
|
|
63
|
+
}
|
|
64
|
+
return apperror.NewInternal(fmt.Errorf("reset password: %w", err))
|
|
65
|
+
}
|
|
66
|
+
if err := s.refreshTokens.RevokeAllRefreshTokens(ctx, userID); err != nil {
|
|
67
|
+
if restoreErr := s.recoveryTokens.SetPasswordResetToken(ctx, tokenHash, userID, s.config.PasswordResetTTL); restoreErr != nil {
|
|
68
|
+
return apperror.NewInternal(fmt.Errorf("revoke sessions after password reset: %w; restore reset token for retry: %v", err, restoreErr))
|
|
69
|
+
}
|
|
70
|
+
return apperror.NewInternal(fmt.Errorf("revoke sessions after password reset (reset token restored for retry): %w", err))
|
|
71
|
+
}
|
|
72
|
+
return nil
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// sendVerificationEmail issues a one-time token and sends its link. It is
|
|
76
|
+
// shared by registration and the authenticated resend flow.
|
|
77
|
+
func (s *Service) sendVerificationEmail(ctx context.Context, u *model.User) {
|
|
78
|
+
raw, err := randomToken()
|
|
79
|
+
if err != nil {
|
|
80
|
+
slog.Error("generate email verification token", "error", err)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
if err := s.recoveryTokens.SetEmailVerifyToken(ctx, hashToken(raw), u.ID, s.config.EmailVerifyTTL); err != nil {
|
|
84
|
+
slog.Error("store email verification token", "error", err)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
link := s.config.EmailVerifyURL + "?token=" + raw
|
|
88
|
+
if err := s.mailer.Send(ctx, u.Email, "Verify your email", "Verify your email: "+link); err != nil {
|
|
89
|
+
slog.Error("send email verification email", "error", err)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func (s *Service) ResendVerificationEmail(ctx context.Context, userID uuid.UUID) error {
|
|
94
|
+
u, err := s.repo.FindByID(ctx, userID)
|
|
95
|
+
if err != nil {
|
|
96
|
+
return apperror.NewInternal(err)
|
|
97
|
+
}
|
|
98
|
+
if u.EmailVerified {
|
|
99
|
+
return errAlreadyVerified()
|
|
100
|
+
}
|
|
101
|
+
s.sendVerificationEmail(ctx, u)
|
|
102
|
+
return nil
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func (s *Service) VerifyEmail(ctx context.Context, rawToken string) error {
|
|
106
|
+
_, err := s.recovery.VerifyEmail(ctx, hashToken(rawToken))
|
|
107
|
+
if err != nil {
|
|
108
|
+
if errors.Is(err, application.ErrInvalidToken) {
|
|
109
|
+
return errInvalidToken()
|
|
110
|
+
}
|
|
111
|
+
return apperror.NewInternal(fmt.Errorf("verify email: %w", err))
|
|
112
|
+
}
|
|
113
|
+
return nil
|
|
114
|
+
}
|