@nakedev/go-scaffold 0.4.3 → 0.5.1
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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
package application
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
|
|
6
|
+
"{{goModule}}/internal/app/user/domain"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// Use-case errors carry a stable business code and a domain sentinel. The
|
|
10
|
+
// inbound adapter decides how those errors map to HTTP; application code does
|
|
11
|
+
// not know status codes or response envelopes.
|
|
12
|
+
func errNotFound() error {
|
|
13
|
+
return domain.Rule("USER_NOT_FOUND", "user not found", domain.ErrNotFound)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func errEmailTaken() error {
|
|
17
|
+
return domain.Rule("USER_EMAIL_TAKEN", "email already registered", domain.ErrEmailTaken)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func errTooManyAttempts() error {
|
|
21
|
+
return domain.Rule("AUTH_TOO_MANY_ATTEMPTS", "too many failed attempts - try again later", domain.ErrTooManyAttempts)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func errInvalidCredentials() error {
|
|
25
|
+
return domain.Rule("AUTH_INVALID_CREDENTIALS", "invalid email or password", domain.ErrInvalidCredential)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func errInvalidToken() error {
|
|
29
|
+
return domain.Rule("AUTH_INVALID_TOKEN", "invalid or expired token", domain.ErrInvalidToken)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func errAlreadyVerified() error {
|
|
33
|
+
return domain.Rule("AUTH_ALREADY_VERIFIED", "email is already verified", domain.ErrAlreadyVerified)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func errMFAUnavailable() error {
|
|
37
|
+
return domain.Rule("AUTH_MFA_UNAVAILABLE", "multi-factor authentication is not enabled", domain.ErrMFAUnavailable)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func errMFAInvalid() error {
|
|
41
|
+
return domain.Rule("AUTH_MFA_INVALID", "invalid or expired multi-factor authentication code", domain.ErrMFAInvalid)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func errMFAAlreadyEnabled() error {
|
|
45
|
+
return domain.Rule("AUTH_MFA_ALREADY_ENABLED", "multi-factor authentication is already enabled", domain.ErrMFAAlreadyEnabled)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func errMFANotEnrolled() error {
|
|
49
|
+
return domain.Rule("AUTH_MFA_NOT_ENROLLED", "multi-factor authentication is not enabled for this user", domain.ErrMFANotEnrolled)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func errMFASetupRequired() error {
|
|
53
|
+
return domain.Rule("AUTH_MFA_SETUP_REQUIRED", "complete multi-factor authentication setup first", domain.ErrMFASetupRequired)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func errMFAConfig() error {
|
|
57
|
+
return fmt.Errorf("multi-factor authentication encryption key is invalid")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func errUnknownRole() error {
|
|
61
|
+
return domain.Rule("USER_UNKNOWN_ROLE", "unknown role code", domain.ErrUnknownRole)
|
|
62
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
package
|
|
1
|
+
package application
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
@@ -10,31 +10,27 @@ import (
|
|
|
10
10
|
"strings"
|
|
11
11
|
"time"
|
|
12
12
|
|
|
13
|
-
"{{goModule}}/internal/app/user/
|
|
14
|
-
"{{goModule}}/internal/app/user/model"
|
|
15
|
-
"{{goModule}}/internal/shared/dberr"
|
|
13
|
+
"{{goModule}}/internal/app/user/domain"
|
|
16
14
|
"{{goModule}}/internal/shared/id"
|
|
17
|
-
|
|
18
|
-
"gorm.io/gorm"
|
|
19
15
|
)
|
|
20
16
|
|
|
21
17
|
// BeginLogin accepts the browser client's state and S256 PKCE challenge and
|
|
22
18
|
// asks the registered provider adapter to construct its authorization URL.
|
|
23
19
|
// The server persists a hashed state transaction binding provider, challenge,
|
|
24
20
|
// and OIDC nonce before the callback reaches ExchangeLogin.
|
|
25
|
-
func (s *Service) BeginLogin(ctx context.Context, providerName string, in
|
|
21
|
+
func (s *Service) BeginLogin(ctx context.Context, providerName string, in LoginStartInput) (*Authorization, error) {
|
|
26
22
|
provider, ok := s.providers.Lookup(providerName)
|
|
27
23
|
if !ok {
|
|
28
|
-
return nil,
|
|
24
|
+
return nil, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("provider %q is not configured", providerName))
|
|
29
25
|
}
|
|
30
26
|
// OAuth values are opaque: validate their wire-safe shape without trimming
|
|
31
27
|
// or rewriting what the frontend and provider saw.
|
|
32
28
|
if !validOAuthValue(in.State) || !validPKCEChallenge(in.CodeChallenge) || in.CodeChallengeMethod != "S256" {
|
|
33
|
-
return nil,
|
|
29
|
+
return nil, NewOAuthError(OAuthStateInvalid, fmt.Errorf("state and S256 code challenge are required"))
|
|
34
30
|
}
|
|
35
31
|
nonce, err := randomToken()
|
|
36
32
|
if err != nil {
|
|
37
|
-
return nil,
|
|
33
|
+
return nil, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("create oauth transaction: %w", err))
|
|
38
34
|
}
|
|
39
35
|
in.Nonce = nonce
|
|
40
36
|
authorization, err := provider.Begin(ctx, in)
|
|
@@ -42,59 +38,53 @@ func (s *Service) BeginLogin(ctx context.Context, providerName string, in applic
|
|
|
42
38
|
return nil, mapProviderError(err)
|
|
43
39
|
}
|
|
44
40
|
if strings.TrimSpace(authorization.URL) == "" {
|
|
45
|
-
return nil,
|
|
41
|
+
return nil, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("provider returned an empty authorization URL"))
|
|
46
42
|
}
|
|
47
43
|
clock := s.now
|
|
48
44
|
if clock == nil {
|
|
49
45
|
clock = time.Now
|
|
50
46
|
}
|
|
51
|
-
if err := s.oauthTransactions.SetLoginTransaction(ctx, hashToken(in.State),
|
|
47
|
+
if err := s.oauthTransactions.SetLoginTransaction(ctx, hashToken(in.State), LoginTransaction{
|
|
52
48
|
Provider: providerName,
|
|
53
49
|
CodeChallenge: in.CodeChallenge,
|
|
54
50
|
Nonce: nonce,
|
|
55
51
|
ExpiresAt: clock().Add(s.config.OAuthStateTTL),
|
|
56
52
|
}); err != nil {
|
|
57
|
-
return nil,
|
|
53
|
+
return nil, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("store oauth transaction: %w", err))
|
|
58
54
|
}
|
|
59
55
|
return &authorization, nil
|
|
60
56
|
}
|
|
61
57
|
|
|
62
|
-
|
|
63
|
-
Code string `json:"code"`
|
|
64
|
-
State string `json:"state"`
|
|
65
|
-
CodeVerifier string `json:"code_verifier"`
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
func (s *Service) ExchangeLogin(ctx context.Context, providerName string, in loginExchangeInput) (*authResult, error) {
|
|
58
|
+
func (s *Service) ExchangeLogin(ctx context.Context, providerName string, in LoginExchangeInput) (*AuthResult, error) {
|
|
69
59
|
provider, ok := s.providers.Lookup(providerName)
|
|
70
60
|
if !ok {
|
|
71
|
-
return nil,
|
|
61
|
+
return nil, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("provider %q is not configured", providerName))
|
|
72
62
|
}
|
|
73
63
|
if !validOAuthValue(in.State) || !validPKCEValue(in.CodeVerifier) {
|
|
74
|
-
return nil,
|
|
64
|
+
return nil, NewOAuthError(OAuthStateInvalid, fmt.Errorf("state and code verifier are required"))
|
|
75
65
|
}
|
|
76
66
|
if !validOAuthValue(in.Code) {
|
|
77
|
-
return nil,
|
|
67
|
+
return nil, NewOAuthError(OAuthFailed, fmt.Errorf("authorization code is missing"))
|
|
78
68
|
}
|
|
79
69
|
|
|
80
70
|
transaction, ok, err := s.oauthTransactions.ConsumeLoginTransaction(ctx, hashToken(in.State))
|
|
81
71
|
if err != nil {
|
|
82
|
-
return nil,
|
|
72
|
+
return nil, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("consume oauth transaction: %w", err))
|
|
83
73
|
}
|
|
84
74
|
if !ok || transaction.Provider != providerName || !validPKCEVerifier(in.CodeVerifier, transaction.CodeChallenge) {
|
|
85
|
-
return nil,
|
|
75
|
+
return nil, NewOAuthError(OAuthStateInvalid, fmt.Errorf("oauth state or PKCE verifier is invalid"))
|
|
86
76
|
}
|
|
87
77
|
|
|
88
|
-
identity, err := provider.Complete(ctx,
|
|
78
|
+
identity, err := provider.Complete(ctx, LoginCompleteInput{Code: in.Code, CodeVerifier: in.CodeVerifier, Nonce: transaction.Nonce})
|
|
89
79
|
if err != nil {
|
|
90
80
|
return nil, mapProviderError(err)
|
|
91
81
|
}
|
|
92
82
|
if identity.Provider != providerName {
|
|
93
|
-
return nil,
|
|
83
|
+
return nil, NewOAuthError(OAuthFailed, fmt.Errorf("provider identity name does not match the requested provider"))
|
|
94
84
|
}
|
|
95
85
|
u, err := s.findOrCreateExternalUser(ctx, identity)
|
|
96
86
|
if err != nil {
|
|
97
|
-
return nil,
|
|
87
|
+
return nil, NewOAuthError(OAuthFailed, fmt.Errorf("resolve external identity: %w", err))
|
|
98
88
|
}
|
|
99
89
|
return s.completeLogin(ctx, u)
|
|
100
90
|
}
|
|
@@ -150,20 +140,20 @@ func pkceChallenge(verifier string) string {
|
|
|
150
140
|
}
|
|
151
141
|
|
|
152
142
|
func mapProviderError(err error) error {
|
|
153
|
-
var oauthErr *
|
|
143
|
+
var oauthErr *OAuthError
|
|
154
144
|
if errors.As(err, &oauthErr) {
|
|
155
145
|
return oauthErr
|
|
156
146
|
}
|
|
157
|
-
if
|
|
158
|
-
return
|
|
147
|
+
if IsProviderUnavailable(err) {
|
|
148
|
+
return NewOAuthError(OAuthProviderUnavailable, err)
|
|
159
149
|
}
|
|
160
|
-
return
|
|
150
|
+
return NewOAuthError(OAuthFailed, err)
|
|
161
151
|
}
|
|
162
152
|
|
|
163
153
|
// findOrCreateExternalUser is provider-neutral identity resolution. A stable
|
|
164
154
|
// provider subject wins; only a verified external email may link to an
|
|
165
155
|
// existing account.
|
|
166
|
-
func (s *Service) findOrCreateExternalUser(ctx context.Context, info
|
|
156
|
+
func (s *Service) findOrCreateExternalUser(ctx context.Context, info ExternalIdentity) (*domain.User, error) {
|
|
167
157
|
if strings.TrimSpace(info.Provider) == "" || strings.TrimSpace(info.Subject) == "" || strings.TrimSpace(info.Email) == "" {
|
|
168
158
|
return nil, fmt.Errorf("external identity is missing provider, subject, or email")
|
|
169
159
|
}
|
|
@@ -171,14 +161,14 @@ func (s *Service) findOrCreateExternalUser(ctx context.Context, info application
|
|
|
171
161
|
return nil, fmt.Errorf("external identity provider name is too long")
|
|
172
162
|
}
|
|
173
163
|
|
|
174
|
-
provider :=
|
|
164
|
+
provider := domain.Provider(info.Provider)
|
|
175
165
|
if ident, err := s.repo.FindIdentityByProviderUID(ctx, provider, info.Subject); err == nil {
|
|
176
166
|
user, findErr := s.repo.FindByID(ctx, ident.UserID)
|
|
177
167
|
if findErr != nil {
|
|
178
168
|
return nil, fmt.Errorf("find user for existing identity: %w", findErr)
|
|
179
169
|
}
|
|
180
170
|
return user, nil
|
|
181
|
-
} else if !errors.Is(err,
|
|
171
|
+
} else if !errors.Is(err, domain.ErrNotFound) {
|
|
182
172
|
return nil, fmt.Errorf("find external identity: %w", err)
|
|
183
173
|
}
|
|
184
174
|
|
|
@@ -186,20 +176,20 @@ func (s *Service) findOrCreateExternalUser(ctx context.Context, info application
|
|
|
186
176
|
email := normalizeEmail(info.Email)
|
|
187
177
|
if info.EmailVerified {
|
|
188
178
|
if u, err := s.repo.FindByEmail(ctx, email); err == nil {
|
|
189
|
-
ident := &
|
|
179
|
+
ident := &domain.Identity{ID: id.New(), UserID: u.ID, Provider: provider, ProviderUID: &providerUID}
|
|
190
180
|
if err := s.repo.CreateIdentity(ctx, ident); err != nil {
|
|
191
181
|
return nil, fmt.Errorf("link external identity: %w", err)
|
|
192
182
|
}
|
|
193
183
|
return u, nil
|
|
194
|
-
} else if !errors.Is(err,
|
|
184
|
+
} else if !errors.Is(err, domain.ErrNotFound) {
|
|
195
185
|
return nil, fmt.Errorf("find user by verified external email: %w", err)
|
|
196
186
|
}
|
|
197
187
|
}
|
|
198
188
|
|
|
199
|
-
u := &
|
|
200
|
-
ident := &
|
|
189
|
+
u := &domain.User{ID: id.New(), Email: email, Name: info.Name, AvatarURL: info.AvatarURL, EmailVerified: info.EmailVerified, Role: domain.DefaultRole}
|
|
190
|
+
ident := &domain.Identity{ID: id.New(), Provider: provider, ProviderUID: &providerUID}
|
|
201
191
|
if err := s.repo.CreateUserWithIdentity(ctx, u, ident); err != nil {
|
|
202
|
-
if
|
|
192
|
+
if errors.Is(err, domain.ErrConflict) {
|
|
203
193
|
return nil, errEmailTaken()
|
|
204
194
|
}
|
|
205
195
|
return nil, fmt.Errorf("create external user: %w", err)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
package
|
|
1
|
+
package application
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"crypto/rand"
|
|
@@ -16,18 +16,21 @@ const (
|
|
|
16
16
|
// accessClaims — see internal/shared/middleware/auth.go for why this struct
|
|
17
17
|
// is duplicated there instead of imported.
|
|
18
18
|
type accessClaims struct {
|
|
19
|
-
Typ
|
|
19
|
+
Typ string `json:"typ"`
|
|
20
|
+
Role string `json:"role,omitempty"`
|
|
20
21
|
// go-scaffold:jwt-claims
|
|
21
22
|
jwt.RegisteredClaims
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
func (s *Service) issueAccessToken(
|
|
25
26
|
userID uuid.UUID,
|
|
27
|
+
role string,
|
|
26
28
|
// go-scaffold:issue-access-token-params
|
|
27
29
|
) (string, error) {
|
|
28
30
|
now := s.clock()
|
|
29
31
|
claims := accessClaims{
|
|
30
|
-
Typ:
|
|
32
|
+
Typ: tokenTypeAccess,
|
|
33
|
+
Role: role,
|
|
31
34
|
// go-scaffold:jwt-claims-values
|
|
32
35
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
33
36
|
Subject: userID.String(),
|
package/templates/add/auth/internal/app/user/{local_auth.go.hbs → application/local_auth.go.hbs}
RENAMED
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
package
|
|
1
|
+
package application
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
5
7
|
"log/slog"
|
|
6
8
|
"time"
|
|
7
9
|
|
|
8
|
-
"{{goModule}}/internal/app/user/
|
|
9
|
-
"{{goModule}}/internal/shared/apperror"
|
|
10
|
-
"{{goModule}}/internal/shared/dberr"
|
|
10
|
+
"{{goModule}}/internal/app/user/domain"
|
|
11
11
|
"{{goModule}}/internal/shared/id"
|
|
12
|
-
|
|
13
|
-
"golang.org/x/crypto/bcrypt"
|
|
14
12
|
)
|
|
15
13
|
|
|
16
14
|
// Failed-attempt policy. These values are security posture rather than
|
|
@@ -36,23 +34,23 @@ func (s *Service) throttled(ctx context.Context, key string) bool {
|
|
|
36
34
|
slog.Error("read login throttle", "error", err)
|
|
37
35
|
return false
|
|
38
36
|
}
|
|
39
|
-
return !until.IsZero() &&
|
|
37
|
+
return !until.IsZero() && s.clock().Before(until)
|
|
40
38
|
}
|
|
41
39
|
|
|
42
|
-
func (s *Service) Register(ctx context.Context, in
|
|
43
|
-
hash, err :=
|
|
40
|
+
func (s *Service) Register(ctx context.Context, in RegisterInput) (*AuthResult, error) {
|
|
41
|
+
hash, err := s.passwords.Hash(in.Password)
|
|
44
42
|
if err != nil {
|
|
45
|
-
return nil,
|
|
43
|
+
return nil, fmt.Errorf("hash password: %w", err)
|
|
46
44
|
}
|
|
47
45
|
hashStr := string(hash)
|
|
48
46
|
|
|
49
|
-
u := &
|
|
50
|
-
i := &
|
|
47
|
+
u := &domain.User{ID: id.New(), Email: normalizeEmail(in.Email), Name: in.Name, Role: domain.DefaultRole}
|
|
48
|
+
i := &domain.Identity{ID: id.New(), Provider: domain.ProviderLocal, PasswordHash: &hashStr}
|
|
51
49
|
if err := s.repo.CreateUserWithIdentity(ctx, u, i); err != nil {
|
|
52
|
-
if
|
|
50
|
+
if errors.Is(err, domain.ErrConflict) {
|
|
53
51
|
return nil, errEmailTaken()
|
|
54
52
|
}
|
|
55
|
-
return nil,
|
|
53
|
+
return nil, fmt.Errorf("create user: %w", err)
|
|
56
54
|
}
|
|
57
55
|
// Best-effort: a mail failure should not block registration. The user can
|
|
58
56
|
// request another link through ResendVerificationEmail.
|
|
@@ -61,10 +59,10 @@ func (s *Service) Register(ctx context.Context, in registerInput) (*authResult,
|
|
|
61
59
|
if err != nil {
|
|
62
60
|
return nil, err
|
|
63
61
|
}
|
|
64
|
-
return
|
|
62
|
+
return TokenResult(auth), nil
|
|
65
63
|
}
|
|
66
64
|
|
|
67
|
-
func (s *Service) Login(ctx context.Context, in
|
|
65
|
+
func (s *Service) Login(ctx context.Context, in LoginInput) (*AuthResult, error) {
|
|
68
66
|
key := throttleKey("login", in.Email)
|
|
69
67
|
if s.throttled(ctx, key) {
|
|
70
68
|
return nil, errTooManyAttempts()
|
|
@@ -72,7 +70,7 @@ func (s *Service) Login(ctx context.Context, in loginInput) (*authResult, error)
|
|
|
72
70
|
|
|
73
71
|
// Every failure records the same key and returns the same error whether or
|
|
74
72
|
// not the account exists, preventing account enumeration through the counter.
|
|
75
|
-
fail := func() (*
|
|
73
|
+
fail := func() (*AuthResult, error) {
|
|
76
74
|
if err := s.repo.RecordLoginFailure(ctx, key, loginFreeAttempts, loginMaxLock); err != nil {
|
|
77
75
|
slog.Error("record login failure", "error", err)
|
|
78
76
|
}
|
|
@@ -83,11 +81,11 @@ func (s *Service) Login(ctx context.Context, in loginInput) (*authResult, error)
|
|
|
83
81
|
if err != nil {
|
|
84
82
|
return fail()
|
|
85
83
|
}
|
|
86
|
-
ident, err := s.repo.FindIdentity(ctx, u.ID,
|
|
84
|
+
ident, err := s.repo.FindIdentity(ctx, u.ID, domain.ProviderLocal)
|
|
87
85
|
if err != nil || ident.PasswordHash == nil {
|
|
88
86
|
return fail()
|
|
89
87
|
}
|
|
90
|
-
if err :=
|
|
88
|
+
if err := s.passwords.Compare(*ident.PasswordHash, in.Password); err != nil {
|
|
91
89
|
return fail()
|
|
92
90
|
}
|
|
93
91
|
|
package/templates/add/auth/internal/app/user/{mfa_service.go.hbs → application/mfa_service.go.hbs}
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
package
|
|
1
|
+
package application
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
@@ -18,8 +18,7 @@ import (
|
|
|
18
18
|
"strings"
|
|
19
19
|
"time"
|
|
20
20
|
|
|
21
|
-
"{{goModule}}/internal/app/user/
|
|
22
|
-
"{{goModule}}/internal/shared/apperror"
|
|
21
|
+
"{{goModule}}/internal/app/user/domain"
|
|
23
22
|
|
|
24
23
|
"github.com/google/uuid"
|
|
25
24
|
)
|
|
@@ -87,41 +86,41 @@ func ValidateMFASettings(settings MFASettings) error {
|
|
|
87
86
|
return nil
|
|
88
87
|
}
|
|
89
88
|
|
|
90
|
-
func (s *Service) completeLogin(ctx context.Context, u *
|
|
89
|
+
func (s *Service) completeLogin(ctx context.Context, u *domain.User) (*AuthResult, error) {
|
|
91
90
|
settings := s.mfaSettings()
|
|
92
91
|
if !settings.Enabled {
|
|
93
92
|
auth, err := s.issueTokens(ctx, u)
|
|
94
93
|
if err != nil {
|
|
95
94
|
return nil, err
|
|
96
95
|
}
|
|
97
|
-
return
|
|
96
|
+
return TokenResult(auth), nil
|
|
98
97
|
}
|
|
99
98
|
if s.mfa == nil {
|
|
100
|
-
return nil,
|
|
99
|
+
return nil, errors.New("MFA is enabled but no MFA store was configured")
|
|
101
100
|
}
|
|
102
101
|
enrollment, found, err := s.mfa.GetEnrollment(ctx, u.ID)
|
|
103
102
|
if err != nil {
|
|
104
|
-
return nil,
|
|
103
|
+
return nil, fmt.Errorf("read MFA enrollment: %w", err)
|
|
105
104
|
}
|
|
106
105
|
if !found || !enrollment.Enabled {
|
|
107
106
|
auth, err := s.issueTokens(ctx, u)
|
|
108
107
|
if err != nil {
|
|
109
108
|
return nil, err
|
|
110
109
|
}
|
|
111
|
-
return
|
|
110
|
+
return TokenResult(auth), nil
|
|
112
111
|
}
|
|
113
112
|
|
|
114
113
|
rawChallenge, err := randomToken()
|
|
115
114
|
if err != nil {
|
|
116
|
-
return nil,
|
|
115
|
+
return nil, fmt.Errorf("create MFA challenge: %w", err)
|
|
117
116
|
}
|
|
118
117
|
if err := s.mfa.CreateChallenge(ctx, hashToken(rawChallenge), MFAChallenge{
|
|
119
118
|
UserID: u.ID,
|
|
120
119
|
ExpiresAt: s.clock().Add(settings.ChallengeTTL),
|
|
121
120
|
}); err != nil {
|
|
122
|
-
return nil,
|
|
121
|
+
return nil, fmt.Errorf("store MFA challenge: %w", err)
|
|
123
122
|
}
|
|
124
|
-
return
|
|
123
|
+
return ChallengeResult(rawChallenge), nil
|
|
125
124
|
}
|
|
126
125
|
|
|
127
126
|
func (s *Service) MFAStatus(ctx context.Context, userID uuid.UUID) (MFAStatus, error) {
|
|
@@ -131,7 +130,7 @@ func (s *Service) MFAStatus(ctx context.Context, userID uuid.UUID) (MFAStatus, e
|
|
|
131
130
|
}
|
|
132
131
|
enrollment, found, err := s.mfa.GetEnrollment(ctx, userID)
|
|
133
132
|
if err != nil {
|
|
134
|
-
return MFAStatus{},
|
|
133
|
+
return MFAStatus{}, fmt.Errorf("read MFA enrollment: %w", err)
|
|
135
134
|
}
|
|
136
135
|
return MFAStatus{Available: true, Enabled: found && enrollment.Enabled}, nil
|
|
137
136
|
}
|
|
@@ -142,11 +141,11 @@ func (s *Service) SetupMFA(ctx context.Context, userID uuid.UUID) (*MFASetup, er
|
|
|
142
141
|
return nil, err
|
|
143
142
|
}
|
|
144
143
|
if s.mfa == nil {
|
|
145
|
-
return nil,
|
|
144
|
+
return nil, errors.New("MFA store is not configured")
|
|
146
145
|
}
|
|
147
146
|
enrollment, found, err := s.mfa.GetEnrollment(ctx, userID)
|
|
148
147
|
if err != nil {
|
|
149
|
-
return nil,
|
|
148
|
+
return nil, fmt.Errorf("read MFA enrollment: %w", err)
|
|
150
149
|
}
|
|
151
150
|
if found && enrollment.Enabled {
|
|
152
151
|
return nil, errMFAAlreadyEnabled()
|
|
@@ -157,14 +156,14 @@ func (s *Service) SetupMFA(ctx context.Context, userID uuid.UUID) (*MFASetup, er
|
|
|
157
156
|
}
|
|
158
157
|
secret, err := newTOTPSecret()
|
|
159
158
|
if err != nil {
|
|
160
|
-
return nil,
|
|
159
|
+
return nil, fmt.Errorf("generate MFA secret: %w", err)
|
|
161
160
|
}
|
|
162
161
|
encrypted, err := encryptMFASecret(settings.EncryptionKey, secret)
|
|
163
162
|
if err != nil {
|
|
164
163
|
return nil, errMFAConfig()
|
|
165
164
|
}
|
|
166
165
|
if err := s.mfa.PutPendingEnrollment(ctx, userID, encrypted); err != nil {
|
|
167
|
-
return nil,
|
|
166
|
+
return nil, fmt.Errorf("store MFA enrollment: %w", err)
|
|
168
167
|
}
|
|
169
168
|
return &MFASetup{
|
|
170
169
|
Secret: secret,
|
|
@@ -178,11 +177,11 @@ func (s *Service) ConfirmMFA(ctx context.Context, userID uuid.UUID, code string)
|
|
|
178
177
|
return nil, err
|
|
179
178
|
}
|
|
180
179
|
if s.mfa == nil {
|
|
181
|
-
return nil,
|
|
180
|
+
return nil, errors.New("MFA store is not configured")
|
|
182
181
|
}
|
|
183
182
|
enrollment, found, err := s.mfa.GetEnrollment(ctx, userID)
|
|
184
183
|
if err != nil {
|
|
185
|
-
return nil,
|
|
184
|
+
return nil, fmt.Errorf("read MFA enrollment: %w", err)
|
|
186
185
|
}
|
|
187
186
|
if !found {
|
|
188
187
|
return nil, errMFASetupRequired()
|
|
@@ -200,10 +199,10 @@ func (s *Service) ConfirmMFA(ctx context.Context, userID uuid.UUID, code string)
|
|
|
200
199
|
|
|
201
200
|
codes, hashes, err := newRecoveryCodes(settings.RecoveryCodeCount)
|
|
202
201
|
if err != nil {
|
|
203
|
-
return nil,
|
|
202
|
+
return nil, fmt.Errorf("generate MFA recovery codes: %w", err)
|
|
204
203
|
}
|
|
205
204
|
if err := s.mfa.ConfirmEnrollment(ctx, userID, enrollment.EncryptedSecret, hashes); err != nil {
|
|
206
|
-
return nil,
|
|
205
|
+
return nil, fmt.Errorf("confirm MFA enrollment: %w", err)
|
|
207
206
|
}
|
|
208
207
|
return codes, nil
|
|
209
208
|
}
|
|
@@ -217,11 +216,11 @@ func (s *Service) DisableMFA(ctx context.Context, userID uuid.UUID, code string)
|
|
|
217
216
|
return err
|
|
218
217
|
}
|
|
219
218
|
if s.mfa == nil {
|
|
220
|
-
return
|
|
219
|
+
return errors.New("MFA store is not configured")
|
|
221
220
|
}
|
|
222
221
|
enrollment, found, err := s.mfa.GetEnrollment(ctx, userID)
|
|
223
222
|
if err != nil {
|
|
224
|
-
return
|
|
223
|
+
return fmt.Errorf("read MFA enrollment: %w", err)
|
|
225
224
|
}
|
|
226
225
|
if !found || !enrollment.Enabled {
|
|
227
226
|
return errMFANotEnrolled()
|
|
@@ -234,7 +233,7 @@ func (s *Service) DisableMFA(ctx context.Context, userID uuid.UUID, code string)
|
|
|
234
233
|
return errMFAInvalid()
|
|
235
234
|
}
|
|
236
235
|
if err := s.mfa.Disable(ctx, userID); err != nil {
|
|
237
|
-
return
|
|
236
|
+
return fmt.Errorf("disable MFA: %w", err)
|
|
238
237
|
}
|
|
239
238
|
return nil
|
|
240
239
|
}
|
|
@@ -242,24 +241,24 @@ func (s *Service) DisableMFA(ctx context.Context, userID uuid.UUID, code string)
|
|
|
242
241
|
// VerifyMFA consumes the challenge before checking the code. This makes every
|
|
243
242
|
// challenge single-use, including a failed attempt, and prevents an attacker
|
|
244
243
|
// from turning a leaked challenge into an unlimited six-digit guessing oracle.
|
|
245
|
-
func (s *Service) VerifyMFA(ctx context.Context, rawChallenge, code string) (*
|
|
244
|
+
func (s *Service) VerifyMFA(ctx context.Context, rawChallenge, code string) (*AuthResponse, error) {
|
|
246
245
|
settings, err := s.requireMFA()
|
|
247
246
|
if err != nil {
|
|
248
247
|
return nil, err
|
|
249
248
|
}
|
|
250
249
|
if s.mfa == nil {
|
|
251
|
-
return nil,
|
|
250
|
+
return nil, errors.New("MFA store is not configured")
|
|
252
251
|
}
|
|
253
252
|
challenge, found, err := s.mfa.ConsumeChallenge(ctx, hashToken(rawChallenge))
|
|
254
253
|
if err != nil {
|
|
255
|
-
return nil,
|
|
254
|
+
return nil, fmt.Errorf("consume MFA challenge: %w", err)
|
|
256
255
|
}
|
|
257
256
|
if !found {
|
|
258
257
|
return nil, errMFAInvalid()
|
|
259
258
|
}
|
|
260
259
|
enrollment, found, err := s.mfa.GetEnrollment(ctx, challenge.UserID)
|
|
261
260
|
if err != nil {
|
|
262
|
-
return nil,
|
|
261
|
+
return nil, fmt.Errorf("read MFA enrollment: %w", err)
|
|
263
262
|
}
|
|
264
263
|
if !found || !enrollment.Enabled {
|
|
265
264
|
return nil, errMFAInvalid()
|
|
@@ -275,7 +274,7 @@ func (s *Service) VerifyMFA(ctx context.Context, rawChallenge, code string) (*au
|
|
|
275
274
|
}
|
|
276
275
|
used, err := s.mfa.ConsumeRecoveryCode(ctx, challenge.UserID, recoveryHash)
|
|
277
276
|
if err != nil {
|
|
278
|
-
return nil,
|
|
277
|
+
return nil, fmt.Errorf("consume MFA recovery code: %w", err)
|
|
279
278
|
}
|
|
280
279
|
if !used {
|
|
281
280
|
return nil, errMFAInvalid()
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
package
|
|
1
|
+
package application
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
+
"errors"
|
|
5
6
|
"strings"
|
|
6
7
|
"testing"
|
|
7
8
|
"time"
|
|
8
9
|
|
|
9
|
-
"{{goModule}}/internal/app/user/
|
|
10
|
-
"{{goModule}}/internal/shared/apperror"
|
|
10
|
+
"{{goModule}}/internal/app/user/domain"
|
|
11
11
|
|
|
12
12
|
"github.com/google/uuid"
|
|
13
13
|
)
|
|
@@ -18,7 +18,7 @@ func configuredMFATestService(t *testing.T) (*Service, *fakeMFAStore, uuid.UUID,
|
|
|
18
18
|
t.Helper()
|
|
19
19
|
userID := uuid.New()
|
|
20
20
|
mfa := newFakeMFAStore()
|
|
21
|
-
svc := newTestService(&fakeRepo{user: &
|
|
21
|
+
svc := newTestService(&fakeRepo{user: &domain.User{ID: userID, Email: "mfa@example.com"}}, newFakeTokenStore())
|
|
22
22
|
now := time.Now()
|
|
23
23
|
svc.mfa = mfa
|
|
24
24
|
svc.now = func() time.Time { return now }
|
|
@@ -46,7 +46,7 @@ func TestTOTPCodeMatchesRFC6238SHA1Vector(t *testing.T) {
|
|
|
46
46
|
|
|
47
47
|
func TestMFADefaultIsDisabled(t *testing.T) {
|
|
48
48
|
userID := uuid.New()
|
|
49
|
-
svc := newTestService(&fakeRepo{user: &
|
|
49
|
+
svc := newTestService(&fakeRepo{user: &domain.User{ID: userID, Email: "default@example.com"}}, newFakeTokenStore())
|
|
50
50
|
|
|
51
51
|
status, err := svc.MFAStatus(context.Background(), userID)
|
|
52
52
|
if err != nil {
|
|
@@ -147,8 +147,8 @@ func TestMFAChallengeIsRequiredAndOneUse(t *testing.T) {
|
|
|
147
147
|
t.Fatalf("confirm: %v", err)
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
result, err := svc.completeLogin(ctx, &
|
|
151
|
-
if err != nil || result.MFAChallenge == "" || result.
|
|
150
|
+
result, err := svc.completeLogin(ctx, &domain.User{ID: userID, Email: "mfa@example.com"})
|
|
151
|
+
if err != nil || result.MFAChallenge == "" || result.AuthResponse != nil {
|
|
152
152
|
t.Fatalf("expected pre-session MFA challenge: %+v, %v", result, err)
|
|
153
153
|
}
|
|
154
154
|
auth, err := svc.VerifyMFA(ctx, result.MFAChallenge, code)
|
|
@@ -172,14 +172,14 @@ func TestMFARecoveryCodeIsOneUse(t *testing.T) {
|
|
|
172
172
|
if err != nil {
|
|
173
173
|
t.Fatalf("confirm: %v", err)
|
|
174
174
|
}
|
|
175
|
-
result, err := svc.completeLogin(ctx, &
|
|
175
|
+
result, err := svc.completeLogin(ctx, &domain.User{ID: userID, Email: "mfa@example.com"})
|
|
176
176
|
if err != nil {
|
|
177
177
|
t.Fatalf("login challenge: %v", err)
|
|
178
178
|
}
|
|
179
179
|
if _, err := svc.VerifyMFA(ctx, result.MFAChallenge, recoveryCodes[0]); err != nil {
|
|
180
180
|
t.Fatalf("verify with recovery code: %v", err)
|
|
181
181
|
}
|
|
182
|
-
result, err = svc.completeLogin(ctx, &
|
|
182
|
+
result, err = svc.completeLogin(ctx, &domain.User{ID: userID, Email: "mfa@example.com"})
|
|
183
183
|
if err != nil {
|
|
184
184
|
t.Fatalf("second login challenge: %v", err)
|
|
185
185
|
}
|
|
@@ -192,8 +192,9 @@ func codeOfMFAError(err error) string {
|
|
|
192
192
|
if err == nil {
|
|
193
193
|
return ""
|
|
194
194
|
}
|
|
195
|
-
|
|
196
|
-
|
|
195
|
+
var ruleErr *domain.RuleError
|
|
196
|
+
if errors.As(err, &ruleErr) {
|
|
197
|
+
return ruleErr.Code
|
|
197
198
|
}
|
|
198
199
|
return ""
|
|
199
200
|
}
|
|
@@ -74,10 +74,10 @@ func (r ProviderRegistry) Lookup(name string) (LoginProvider, bool) {
|
|
|
74
74
|
type OAuthErrorCode string
|
|
75
75
|
|
|
76
76
|
const (
|
|
77
|
-
OAuthDenied
|
|
78
|
-
OAuthStateInvalid
|
|
77
|
+
OAuthDenied OAuthErrorCode = "oauth_denied"
|
|
78
|
+
OAuthStateInvalid OAuthErrorCode = "oauth_state_invalid"
|
|
79
79
|
OAuthProviderUnavailable OAuthErrorCode = "oauth_provider_unavailable"
|
|
80
|
-
OAuthFailed
|
|
80
|
+
OAuthFailed OAuthErrorCode = "oauth_failed"
|
|
81
81
|
)
|
|
82
82
|
|
|
83
83
|
// OAuthError is the public, controlled error contract for the browser login
|