@nakedev/go-scaffold 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +598 -306
- package/dist/commands/auth.js +65 -23
- package/dist/commands/check.js +281 -0
- package/dist/commands/config.js +50 -0
- package/dist/commands/create.js +33 -2
- package/dist/commands/generate.js +29 -3
- package/dist/commands/method.js +74 -63
- package/dist/commands/migration.js +2 -2
- package/dist/commands/observability.js +4 -53
- package/dist/commands/rbac.js +21 -10
- package/dist/commands/undo.js +11 -3
- package/dist/commands/worker.js +15 -5
- package/dist/index.js +198 -59
- package/dist/prompts/auth-wizard.js +40 -6
- package/dist/prompts/create-wizard.js +42 -1
- package/dist/prompts/generate-wizard.js +89 -9
- package/dist/templates/auth-manifest.js +50 -19
- package/dist/templates/create-manifest.js +8 -0
- package/dist/templates/module-manifest.js +84 -26
- package/dist/templates/rbac-manifest.js +16 -11
- package/dist/templates/worker-manifest.js +4 -1
- package/dist/types.js +8 -0
- package/dist/utils/auth-patcher.js +124 -33
- package/dist/utils/config.js +167 -4
- package/dist/utils/docs-patcher.js +68 -0
- package/dist/utils/hexagonal-method-patcher.js +334 -0
- package/dist/utils/main-patcher.js +32 -30
- package/dist/utils/marker-patch.js +7 -1
- package/dist/utils/module-location.js +17 -11
- package/dist/utils/module-profile.js +32 -0
- package/dist/utils/platform-patcher.js +56 -7
- package/dist/utils/rbac-patcher.js +89 -210
- package/package.json +7 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +15 -3
- package/templates/add/auth/docs/login.yaml.hbs +11 -1
- package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
- package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
- package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
- package/templates/add/auth/docs/register.yaml.hbs +7 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
- package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
- package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
- package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
- package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
- package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/browser_policy.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +228 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +76 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +83 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +70 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_recovery.go.hbs +49 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +311 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/session_cookie.go.hbs +35 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +129 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +174 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
- package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +213 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +103 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_recovery.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +228 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +196 -0
- package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
- package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
- package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
- package/templates/add/auth/internal/app/user/application/external_login.go.hbs +198 -0
- package/templates/add/auth/internal/app/user/application/jwt.go.hbs +58 -0
- package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +96 -0
- package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +449 -0
- package/templates/add/auth/internal/app/user/application/mfa_service_test.go.hbs +200 -0
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
- package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +285 -0
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +82 -0
- package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +112 -0
- package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
- package/templates/add/auth/internal/app/user/application/service_test.go.hbs +891 -0
- package/templates/add/auth/internal/app/user/application/sessions.go.hbs +99 -0
- package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
- package/templates/add/auth/internal/app/user/application/user_query.go.hbs +65 -0
- package/templates/add/auth/internal/app/user/composition.go.hbs +168 -0
- package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
- package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -0
- package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
- package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +10 -5
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
- package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +4 -3
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
- package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
- package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
- package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
- package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
- package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
- package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
- package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
- package/templates/add/rbac/internal/app/role/composition.go.hbs +48 -0
- package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
- package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
- package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
- package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +358 -121
- package/templates/create/base/.env.example.hbs +0 -1
- package/templates/create/base/.golangci.yml.hbs +2 -2
- package/templates/create/base/AGENTS.md.hbs +279 -67
- package/templates/create/base/Makefile.hbs +2 -1
- package/templates/create/base/README.md.hbs +115 -32
- package/templates/create/base/cmd/api/wiring.go.hbs +13 -9
- package/templates/create/base/internal/composition/doc.go.hbs +7 -0
- package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
- package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
- package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
- package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
- package/templates/create/features/docs/architecture.md.hbs +92 -32
- package/templates/create/features/docs/patterns.md.hbs +137 -91
- package/templates/create/features/docs/techstack.md.hbs +18 -3
- package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
- package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
- package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
- package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
- package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
- package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
- package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
- package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
- package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
- package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
- package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
- package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
- package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
- package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
- package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
- package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
- package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
- package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
- package/templates/generate/module/migration.up.sql.hbs +1 -1
- package/dist/utils/method-patcher.js +0 -357
- package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
- package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
- package/templates/add/auth/internal/app/user/dto.go.hbs +0 -77
- package/templates/add/auth/internal/app/user/errors.go.hbs +0 -43
- package/templates/add/auth/internal/app/user/handler.go.hbs +0 -276
- package/templates/add/auth/internal/app/user/jwt.go.hbs +0 -108
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -39
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -31
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
- package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -30
- package/templates/add/auth/internal/app/user/repository.go.hbs +0 -137
- package/templates/add/auth/internal/app/user/service.go.hbs +0 -531
- package/templates/add/auth/internal/app/user/service_test.go.hbs +0 -316
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -30
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +0 -144
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +0 -147
- package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
- package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
- package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
- package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
- package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
- package/templates/generate/module/dto.go.hbs +0 -36
- package/templates/generate/module/errors.go.hbs +0 -33
- package/templates/generate/module/handler.go.hbs +0 -134
- package/templates/generate/module/handler_test.go.hbs +0 -174
- package/templates/generate/module/minimal/dto.go.hbs +0 -28
- package/templates/generate/module/minimal/handler.go.hbs +0 -48
- package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
- package/templates/generate/module/minimal/service.go.hbs +0 -45
- package/templates/generate/module/minimal/service_test.go.hbs +0 -77
- package/templates/generate/module/model/model.go.hbs +0 -36
- package/templates/generate/module/repository.go.hbs +0 -103
- package/templates/generate/module/service.go.hbs +0 -108
- package/templates/generate/module/service_test.go.hbs +0 -161
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
package httpadapter
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"crypto/sha256"
|
|
6
|
+
"encoding/base64"
|
|
7
|
+
"net/http"
|
|
8
|
+
"net/http/httptest"
|
|
9
|
+
"net/url"
|
|
10
|
+
"strings"
|
|
11
|
+
"testing"
|
|
12
|
+
"time"
|
|
13
|
+
|
|
14
|
+
"{{goModule}}/internal/app/user/application"
|
|
15
|
+
"{{goModule}}/internal/shared/middleware"
|
|
16
|
+
|
|
17
|
+
"github.com/gin-gonic/gin"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
type allowAllLimiter struct{}
|
|
21
|
+
|
|
22
|
+
func (allowAllLimiter) Allow(_ context.Context, _ string, _ int, _ time.Duration) bool { return true }
|
|
23
|
+
|
|
24
|
+
type allowAllAuthorizer struct{}
|
|
25
|
+
|
|
26
|
+
func (allowAllAuthorizer) Require(_ string) gin.HandlerFunc {
|
|
27
|
+
return func(c *gin.Context) { c.Next() }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const handlerOAuthVerifier = "client-code-verifier-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
31
|
+
|
|
32
|
+
type stubService struct {
|
|
33
|
+
application.ServicePort
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func (stubService) BeginLogin(_ context.Context, provider string, in application.LoginStartInput) (*application.Authorization, error) {
|
|
37
|
+
if provider != "fake" {
|
|
38
|
+
return nil, application.NewOAuthError(application.OAuthProviderUnavailable, nil)
|
|
39
|
+
}
|
|
40
|
+
return &application.Authorization{URL: "https://provider.example.test/authorize?" + url.Values{
|
|
41
|
+
"state": {in.State},
|
|
42
|
+
"code_challenge": {in.CodeChallenge},
|
|
43
|
+
"code_challenge_method": {in.CodeChallengeMethod},
|
|
44
|
+
}.Encode()}, nil
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func (stubService) ExchangeLogin(_ context.Context, provider string, in application.LoginExchangeInput) (*application.AuthResult, error) {
|
|
48
|
+
if provider != "fake" {
|
|
49
|
+
return nil, application.NewOAuthError(application.OAuthProviderUnavailable, nil)
|
|
50
|
+
}
|
|
51
|
+
if in.State == "" || in.CodeVerifier == "" {
|
|
52
|
+
return nil, application.NewOAuthError(application.OAuthStateInvalid, nil)
|
|
53
|
+
}
|
|
54
|
+
if in.Code == "" {
|
|
55
|
+
return nil, application.NewOAuthError(application.OAuthFailed, nil)
|
|
56
|
+
}
|
|
57
|
+
return application.TokenResult(&application.AuthResponse{
|
|
58
|
+
AccessToken: "access-token",
|
|
59
|
+
RefreshToken: "refresh-token",
|
|
60
|
+
TokenType: "Bearer",
|
|
61
|
+
ExpiresIn: 900,
|
|
62
|
+
}), nil
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func (stubService) Logout(context.Context, string) error { return nil }
|
|
66
|
+
|
|
67
|
+
func pkceChallenge(verifier string) string {
|
|
68
|
+
sum := sha256.Sum256([]byte(verifier))
|
|
69
|
+
return base64.RawURLEncoding.EncodeToString(sum[:])
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func newOAuthTestRouter(t *testing.T) *gin.Engine {
|
|
73
|
+
t.Helper()
|
|
74
|
+
svc := stubService{}
|
|
75
|
+
h := NewHandler(svc, "test-secret", time.Hour, true, "strict", allowAllLimiter{}, allowAllAuthorizer{})
|
|
76
|
+
gin.SetMode(gin.TestMode)
|
|
77
|
+
router := gin.New()
|
|
78
|
+
router.Use(middleware.Error(false))
|
|
79
|
+
h.Register(router)
|
|
80
|
+
return router
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func newCrossSiteTestRouter(t *testing.T) *gin.Engine {
|
|
84
|
+
t.Helper()
|
|
85
|
+
svc := stubService{}
|
|
86
|
+
h := NewHandlerWithOrigins(svc, "test-secret", time.Hour, true, "none", []string{"https://app.example.test"}, allowAllLimiter{}, allowAllAuthorizer{})
|
|
87
|
+
router := gin.New()
|
|
88
|
+
router.Use(middleware.Error(false))
|
|
89
|
+
h.Register(router)
|
|
90
|
+
return router
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func beginOAuthFlow(t *testing.T, router *gin.Engine) (string, string) {
|
|
94
|
+
t.Helper()
|
|
95
|
+
const state = "client-state"
|
|
96
|
+
const verifier = handlerOAuthVerifier
|
|
97
|
+
request := httptest.NewRequest(http.MethodGet, "/auth/fake/login?"+url.Values{
|
|
98
|
+
"state": {state},
|
|
99
|
+
"code_challenge": {pkceChallenge(verifier)},
|
|
100
|
+
"code_challenge_method": {"S256"},
|
|
101
|
+
}.Encode(), nil)
|
|
102
|
+
response := httptest.NewRecorder()
|
|
103
|
+
router.ServeHTTP(response, request)
|
|
104
|
+
if response.Code != http.StatusFound {
|
|
105
|
+
t.Fatalf("login status = %d, want 302; body=%s", response.Code, response.Body.String())
|
|
106
|
+
}
|
|
107
|
+
if cookies := response.Result().Cookies(); len(cookies) != 0 {
|
|
108
|
+
t.Fatalf("client-owned callback must not set a server OAuth state cookie: %#v", cookies)
|
|
109
|
+
}
|
|
110
|
+
location, err := url.Parse(response.Header().Get("Location"))
|
|
111
|
+
if err != nil {
|
|
112
|
+
t.Fatalf("parse provider location: %v", err)
|
|
113
|
+
}
|
|
114
|
+
if location.Query().Get("state") != state || location.Query().Get("code_challenge") != pkceChallenge(verifier) || location.Query().Get("code_challenge_method") != "S256" {
|
|
115
|
+
t.Fatalf("provider location did not preserve state/PKCE: %s", location)
|
|
116
|
+
}
|
|
117
|
+
return state, verifier
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
func TestHandler_ProviderExchangeReturnsJSONAndSetsRefreshCookie(t *testing.T) {
|
|
121
|
+
router := newOAuthTestRouter(t)
|
|
122
|
+
state, verifier := beginOAuthFlow(t, router)
|
|
123
|
+
request := httptest.NewRequest(http.MethodPost, "/auth/fake/exchange", strings.NewReader(
|
|
124
|
+
"{\"code\":\"code\",\"state\":\""+state+"\",\"code_verifier\":\""+verifier+"\"}",
|
|
125
|
+
))
|
|
126
|
+
request.Header.Set("Content-Type", "application/json")
|
|
127
|
+
response := httptest.NewRecorder()
|
|
128
|
+
router.ServeHTTP(response, request)
|
|
129
|
+
|
|
130
|
+
if response.Code != http.StatusOK {
|
|
131
|
+
t.Fatalf("exchange status = %d, want 200; body=%s", response.Code, response.Body.String())
|
|
132
|
+
}
|
|
133
|
+
if response.Header().Get("Location") != "" {
|
|
134
|
+
t.Fatalf("exchange must not redirect: %q", response.Header().Get("Location"))
|
|
135
|
+
}
|
|
136
|
+
if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("Pragma") != "no-cache" {
|
|
137
|
+
t.Fatalf("token response must disable caching: Cache-Control=%q Pragma=%q", response.Header().Get("Cache-Control"), response.Header().Get("Pragma"))
|
|
138
|
+
}
|
|
139
|
+
var refreshCookie *http.Cookie
|
|
140
|
+
for _, cookie := range response.Result().Cookies() {
|
|
141
|
+
if cookie.Name == refreshCookieName && cookie.Value != "" {
|
|
142
|
+
refreshCookie = cookie
|
|
143
|
+
break
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if refreshCookie == nil || !refreshCookie.HttpOnly || !refreshCookie.Secure || refreshCookie.Path != "/" {
|
|
147
|
+
t.Fatalf("expected secure HttpOnly refresh cookie, got %#v", refreshCookie)
|
|
148
|
+
}
|
|
149
|
+
if !strings.Contains(response.Body.String(), "\"access_token\"") || strings.Contains(response.Body.String(), "\"refresh_token\"") {
|
|
150
|
+
t.Fatalf("response must expose only the access token, got %s", response.Body.String())
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func TestHandler_ProviderCallbackIsFrontendOwnedAndErrorsDoNotRedirect(t *testing.T) {
|
|
155
|
+
router := newOAuthTestRouter(t)
|
|
156
|
+
callback := httptest.NewRequest(http.MethodGet, "/auth/fake/callback?error=access_denied&error_description=do-not-leak", nil)
|
|
157
|
+
callbackResponse := httptest.NewRecorder()
|
|
158
|
+
router.ServeHTTP(callbackResponse, callback)
|
|
159
|
+
if callbackResponse.Code != http.StatusNotFound {
|
|
160
|
+
t.Fatalf("API callback status = %d, want 404 for frontend-owned callback", callbackResponse.Code)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
invalid := httptest.NewRequest(http.MethodPost, "/auth/fake/exchange?return_to=https%3A%2F%2Fevil.example", strings.NewReader(
|
|
164
|
+
"{\"code\":\"code\",\"state\":\"\",\"code_verifier\":\""+handlerOAuthVerifier+"\",\"error_description\":\"do-not-leak\"}",
|
|
165
|
+
))
|
|
166
|
+
invalid.Header.Set("Content-Type", "application/json")
|
|
167
|
+
invalidResponse := httptest.NewRecorder()
|
|
168
|
+
router.ServeHTTP(invalidResponse, invalid)
|
|
169
|
+
if invalidResponse.Code != http.StatusBadRequest || !strings.Contains(invalidResponse.Body.String(), "oauth_state_invalid") {
|
|
170
|
+
t.Fatalf("invalid exchange response = %d %s", invalidResponse.Code, invalidResponse.Body.String())
|
|
171
|
+
}
|
|
172
|
+
if invalidResponse.Header().Get("Location") != "" || strings.Contains(invalidResponse.Body.String(), "do-not-leak") || strings.Contains(invalidResponse.Body.String(), "evil.example") {
|
|
173
|
+
t.Fatalf("exchange exposed redirect or provider details: headers=%v body=%s", invalidResponse.Header(), invalidResponse.Body.String())
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
func TestHandler_ProviderLoginAndExchangeUnconfiguredProviderUseControlledJSON(t *testing.T) {
|
|
178
|
+
router := newOAuthTestRouter(t)
|
|
179
|
+
login := httptest.NewRequest(http.MethodGet, "/auth/google/login?"+url.Values{
|
|
180
|
+
"state": {"client-state"},
|
|
181
|
+
"code_challenge": {"client-code-challenge"},
|
|
182
|
+
"code_challenge_method": {"S256"},
|
|
183
|
+
}.Encode(), nil)
|
|
184
|
+
loginResponse := httptest.NewRecorder()
|
|
185
|
+
router.ServeHTTP(loginResponse, login)
|
|
186
|
+
if loginResponse.Code != http.StatusServiceUnavailable || !strings.Contains(loginResponse.Body.String(), "oauth_provider_unavailable") {
|
|
187
|
+
t.Fatalf("unconfigured provider login = %d %s", loginResponse.Code, loginResponse.Body.String())
|
|
188
|
+
}
|
|
189
|
+
if loginResponse.Header().Get("Location") != "" {
|
|
190
|
+
t.Fatalf("unconfigured provider must not redirect: %q", loginResponse.Header().Get("Location"))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
exchange := httptest.NewRequest(http.MethodPost, "/auth/google/exchange", strings.NewReader(
|
|
194
|
+
"{\"code\":\"code\",\"state\":\"state\",\"code_verifier\":\"verifier\"}",
|
|
195
|
+
))
|
|
196
|
+
exchange.Header.Set("Content-Type", "application/json")
|
|
197
|
+
exchangeResponse := httptest.NewRecorder()
|
|
198
|
+
router.ServeHTTP(exchangeResponse, exchange)
|
|
199
|
+
if exchangeResponse.Code != http.StatusServiceUnavailable || !strings.Contains(exchangeResponse.Body.String(), "oauth_provider_unavailable") {
|
|
200
|
+
t.Fatalf("unconfigured provider exchange = %d %s", exchangeResponse.Code, exchangeResponse.Body.String())
|
|
201
|
+
}
|
|
202
|
+
if exchangeResponse.Header().Get("Location") != "" {
|
|
203
|
+
t.Fatalf("unconfigured provider exchange must not redirect: %q", exchangeResponse.Header().Get("Location"))
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
func TestHandler_ProviderExchangeRejectsMissingStatePKCEAndCode(t *testing.T) {
|
|
208
|
+
router := newOAuthTestRouter(t)
|
|
209
|
+
tests := []struct {
|
|
210
|
+
name string
|
|
211
|
+
body string
|
|
212
|
+
code string
|
|
213
|
+
}{
|
|
214
|
+
{name: "malformed JSON", body: "{", code: "oauth_failed"},
|
|
215
|
+
{name: "missing state", body: "{\"code\":\"code\",\"code_verifier\":\"" + handlerOAuthVerifier + "\"}", code: "oauth_state_invalid"},
|
|
216
|
+
{name: "missing verifier", body: "{\"code\":\"code\",\"state\":\"state\"}", code: "oauth_state_invalid"},
|
|
217
|
+
{name: "missing code", body: "{\"state\":\"state\",\"code_verifier\":\"" + handlerOAuthVerifier + "\"}", code: "oauth_failed"},
|
|
218
|
+
}
|
|
219
|
+
for _, tt := range tests {
|
|
220
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
221
|
+
request := httptest.NewRequest(http.MethodPost, "/auth/fake/exchange", strings.NewReader(tt.body))
|
|
222
|
+
request.Header.Set("Content-Type", "application/json")
|
|
223
|
+
response := httptest.NewRecorder()
|
|
224
|
+
router.ServeHTTP(response, request)
|
|
225
|
+
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), tt.code) {
|
|
226
|
+
t.Fatalf("exchange response = %d %s, want %s", response.Code, response.Body.String(), tt.code)
|
|
227
|
+
}
|
|
228
|
+
if response.Header().Get("Location") != "" {
|
|
229
|
+
t.Fatalf("exchange must not redirect: %q", response.Header().Get("Location"))
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
func TestHandler_ValidateBrowserCookiePolicy(t *testing.T) {
|
|
236
|
+
tests := []struct {
|
|
237
|
+
name string
|
|
238
|
+
topology string
|
|
239
|
+
sameSite string
|
|
240
|
+
secure bool
|
|
241
|
+
production bool
|
|
242
|
+
wantErr bool
|
|
243
|
+
}{
|
|
244
|
+
{name: "same-site default", topology: "same-site", sameSite: "strict", secure: false},
|
|
245
|
+
{name: "same-origin strict", topology: "same-origin", sameSite: "strict", secure: true},
|
|
246
|
+
{name: "same-site production without secure", topology: "same-site", sameSite: "strict", secure: false, production: true, wantErr: true},
|
|
247
|
+
{name: "same-origin production secure", topology: "same-origin", sameSite: "strict", secure: true, production: true},
|
|
248
|
+
{name: "cross-site secure none", topology: "cross-site", sameSite: "none", secure: true, production: true},
|
|
249
|
+
{name: "cross-site without secure", topology: "cross-site", sameSite: "none", secure: false, wantErr: true},
|
|
250
|
+
{name: "cross-site lax", topology: "cross-site", sameSite: "lax", secure: true, wantErr: true},
|
|
251
|
+
{name: "unknown topology", topology: "mobile", sameSite: "strict", secure: true, wantErr: true},
|
|
252
|
+
}
|
|
253
|
+
for _, tt := range tests {
|
|
254
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
255
|
+
err := ValidateBrowserCookiePolicy(tt.topology, tt.sameSite, tt.secure, tt.production)
|
|
256
|
+
if (err != nil) != tt.wantErr {
|
|
257
|
+
t.Fatalf("validateBrowserCookiePolicy() error = %v, wantErr %v", err, tt.wantErr)
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
func TestHandler_SetRefreshCookie_CrossSiteUsesNoneAndSecure(t *testing.T) {
|
|
264
|
+
h := &Handler{refreshTTL: time.Hour, cookieSecure: true, cookieSameSite: "none"}
|
|
265
|
+
response := httptest.NewRecorder()
|
|
266
|
+
c, _ := gin.CreateTestContext(response)
|
|
267
|
+
h.setRefreshCookie(c, "refresh-token")
|
|
268
|
+
|
|
269
|
+
cookies := response.Result().Cookies()
|
|
270
|
+
if len(cookies) != 1 || cookies[0].Name != refreshCookieName || cookies[0].SameSite != http.SameSiteNoneMode || !cookies[0].Secure || !cookies[0].HttpOnly {
|
|
271
|
+
t.Fatalf("expected a cross-site None + Secure HttpOnly refresh cookie, got %#v", cookies)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
func TestHandler_CrossSiteStateChangingRequestsRequireAnAllowedOrigin(t *testing.T) {
|
|
276
|
+
router := newCrossSiteTestRouter(t)
|
|
277
|
+
tests := []struct {
|
|
278
|
+
name string
|
|
279
|
+
origin string
|
|
280
|
+
wantStatus int
|
|
281
|
+
}{
|
|
282
|
+
{name: "missing origin", wantStatus: http.StatusForbidden},
|
|
283
|
+
{name: "untrusted origin", origin: "https://evil.example.test", wantStatus: http.StatusForbidden},
|
|
284
|
+
{name: "allowed exact origin", origin: "https://app.example.test", wantStatus: http.StatusNoContent},
|
|
285
|
+
}
|
|
286
|
+
for _, tt := range tests {
|
|
287
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
288
|
+
request := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
|
|
289
|
+
if tt.origin != "" {
|
|
290
|
+
request.Header.Set("Origin", tt.origin)
|
|
291
|
+
}
|
|
292
|
+
response := httptest.NewRecorder()
|
|
293
|
+
router.ServeHTTP(response, request)
|
|
294
|
+
if response.Code != tt.wantStatus {
|
|
295
|
+
t.Fatalf("logout status = %d, want %d; body=%s", response.Code, tt.wantStatus, response.Body.String())
|
|
296
|
+
}
|
|
297
|
+
if tt.wantStatus == http.StatusForbidden && !strings.Contains(response.Body.String(), "CSRF_ORIGIN_INVALID") {
|
|
298
|
+
t.Fatalf("missing controlled CSRF error: %s", response.Body.String())
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
func TestHandler_SetNoStoreHeaders(t *testing.T) {
|
|
305
|
+
response := httptest.NewRecorder()
|
|
306
|
+
c, _ := gin.CreateTestContext(response)
|
|
307
|
+
setNoStoreHeaders(c)
|
|
308
|
+
if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("Pragma") != "no-cache" {
|
|
309
|
+
t.Fatalf("unexpected no-store headers: %v", response.Header())
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
package httpadapter
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
|
|
6
|
+
"{{goModule}}/internal/shared/middleware"
|
|
7
|
+
|
|
8
|
+
"github.com/gin-gonic/gin"
|
|
9
|
+
"github.com/google/uuid"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func (h *Handler) resendVerification(c *gin.Context) {
|
|
13
|
+
userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
|
|
14
|
+
if err := h.svc.ResendVerificationEmail(c.Request.Context(), userID); err != nil {
|
|
15
|
+
c.Error(toHTTPError(err))
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
c.Status(http.StatusNoContent)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// logoutAll ends every session for the caller, including the cookie used by
|
|
22
|
+
// this request.
|
|
23
|
+
func (h *Handler) logoutAll(c *gin.Context) {
|
|
24
|
+
userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
|
|
25
|
+
if err := h.svc.LogoutAll(c.Request.Context(), userID); err != nil {
|
|
26
|
+
c.Error(toHTTPError(err))
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
h.clearRefreshCookie(c)
|
|
30
|
+
c.Status(http.StatusNoContent)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func (h *Handler) me(c *gin.Context) {
|
|
34
|
+
userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
|
|
35
|
+
u, err := h.svc.Get(c.Request.Context(), userID)
|
|
36
|
+
if err != nil {
|
|
37
|
+
c.Error(toHTTPError(err))
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
c.JSON(http.StatusOK, toMeResponse(u))
|
|
41
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
package httpadapter
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
|
|
6
|
+
"{{goModule}}/internal/app/user/application"
|
|
7
|
+
|
|
8
|
+
"github.com/gin-gonic/gin"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func (h *Handler) setRefreshCookie(c *gin.Context, token string) {
|
|
12
|
+
c.SetSameSite(sameSiteFrom(h.cookieSameSite))
|
|
13
|
+
c.SetCookie(refreshCookieName, token, int(h.refreshTTL.Seconds()), "/", "", h.cookieSecure, true)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func (h *Handler) clearRefreshCookie(c *gin.Context) {
|
|
17
|
+
c.SetSameSite(sameSiteFrom(h.cookieSameSite))
|
|
18
|
+
c.SetCookie(refreshCookieName, "", -1, "/", "", h.cookieSecure, true)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Token responses must not be retained by browsers, shared caches, or
|
|
22
|
+
// intermediary middleware.
|
|
23
|
+
func setNoStoreHeaders(c *gin.Context) {
|
|
24
|
+
c.Header("Cache-Control", "no-store")
|
|
25
|
+
c.Header("Pragma", "no-cache")
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func (h *Handler) writeAuthResult(c *gin.Context, status int, result *application.AuthResult) {
|
|
29
|
+
if result.MFAChallenge != "" {
|
|
30
|
+
c.JSON(http.StatusOK, mfaChallengeResponse{MFARequired: true, Challenge: result.MFAChallenge})
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
h.setRefreshCookie(c, result.RefreshToken)
|
|
34
|
+
c.JSON(status, toCookieResponse(result.AuthResponse))
|
|
35
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
package password
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
userports "{{goModule}}/internal/app/user/ports"
|
|
5
|
+
|
|
6
|
+
"golang.org/x/crypto/bcrypt"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// BCryptHasher is the production password adapter. Keeping bcrypt here means
|
|
10
|
+
// register, login, reset, and seed all share one algorithm boundary.
|
|
11
|
+
type BCryptHasher struct {
|
|
12
|
+
cost int
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
func NewBCryptHasher() *BCryptHasher {
|
|
16
|
+
return &BCryptHasher{cost: bcrypt.DefaultCost}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func (h *BCryptHasher) Hash(password string) (string, error) {
|
|
20
|
+
cost := h.cost
|
|
21
|
+
if cost <= 0 {
|
|
22
|
+
cost = bcrypt.DefaultCost
|
|
23
|
+
}
|
|
24
|
+
hash, err := bcrypt.GenerateFromPassword([]byte(password), cost)
|
|
25
|
+
if err != nil {
|
|
26
|
+
return "", err
|
|
27
|
+
}
|
|
28
|
+
return string(hash), nil
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func (*BCryptHasher) Compare(hash, password string) error {
|
|
32
|
+
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
var _ userports.PasswordHasher = (*BCryptHasher)(nil)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
package password
|
|
2
|
+
|
|
3
|
+
import "testing"
|
|
4
|
+
|
|
5
|
+
func TestBCryptHasherRoundTrip(t *testing.T) {
|
|
6
|
+
hasher := NewBCryptHasher()
|
|
7
|
+
hash, err := hasher.Hash("correct horse battery staple")
|
|
8
|
+
if err != nil {
|
|
9
|
+
t.Fatalf("hash password: %v", err)
|
|
10
|
+
}
|
|
11
|
+
if hash == "" {
|
|
12
|
+
t.Fatal("expected a password hash")
|
|
13
|
+
}
|
|
14
|
+
if err := hasher.Compare(hash, "correct horse battery staple"); err != nil {
|
|
15
|
+
t.Fatalf("compare matching password: %v", err)
|
|
16
|
+
}
|
|
17
|
+
if err := hasher.Compare(hash, "wrong password"); err == nil {
|
|
18
|
+
t.Fatal("expected a mismatched password to be rejected")
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
package postgres
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"{{goModule}}/internal/app/user/ports"
|
|
11
|
+
"{{goModule}}/internal/shared/tx"
|
|
12
|
+
|
|
13
|
+
"github.com/google/uuid"
|
|
14
|
+
"gorm.io/gorm"
|
|
15
|
+
"gorm.io/gorm/clause"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// PostgresMFAStore is intentionally independent of the selected refresh-token
|
|
19
|
+
// adapter. MFA challenges and recovery codes need durable, atomic semantics in
|
|
20
|
+
// every deployment, including Redis-backed auth behind multiple replicas.
|
|
21
|
+
type PostgresMFAStore struct {
|
|
22
|
+
db *gorm.DB
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
var _ ports.MFAStore = (*PostgresMFAStore)(nil)
|
|
26
|
+
|
|
27
|
+
func NewPostgresMFAStore(db *gorm.DB) *PostgresMFAStore { return &PostgresMFAStore{db: db} }
|
|
28
|
+
|
|
29
|
+
func (s *PostgresMFAStore) GetEnrollment(ctx context.Context, userID uuid.UUID) (ports.MFAEnrollment, bool, error) {
|
|
30
|
+
var row MFAEnrollment
|
|
31
|
+
err := tx.From(ctx, s.db).WithContext(ctx).First(&row, "user_id = ?", userID).Error
|
|
32
|
+
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
33
|
+
return ports.MFAEnrollment{}, false, nil
|
|
34
|
+
}
|
|
35
|
+
if err != nil {
|
|
36
|
+
return ports.MFAEnrollment{}, false, err
|
|
37
|
+
}
|
|
38
|
+
return ports.MFAEnrollment{EncryptedSecret: row.EncryptedSecret, Enabled: row.Enabled}, true, nil
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func (s *PostgresMFAStore) PutPendingEnrollment(ctx context.Context, userID uuid.UUID, encryptedSecret string) error {
|
|
42
|
+
if userID == uuid.Nil || encryptedSecret == "" {
|
|
43
|
+
return errors.New("MFA enrollment is incomplete")
|
|
44
|
+
}
|
|
45
|
+
row := MFAEnrollment{UserID: userID, EncryptedSecret: encryptedSecret, Enabled: false}
|
|
46
|
+
return tx.From(ctx, s.db).WithContext(ctx).
|
|
47
|
+
Where("user_id = ? AND enabled = ?", userID, false).
|
|
48
|
+
Assign(map[string]any{"encrypted_secret": encryptedSecret, "enabled": false, "updated_at": time.Now()}).
|
|
49
|
+
FirstOrCreate(&row).Error
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func (s *PostgresMFAStore) ConfirmEnrollment(ctx context.Context, userID uuid.UUID, encryptedSecret string, recoveryCodeHashes []string) error {
|
|
53
|
+
if userID == uuid.Nil || encryptedSecret == "" || len(recoveryCodeHashes) == 0 {
|
|
54
|
+
return errors.New("MFA enrollment confirmation is incomplete")
|
|
55
|
+
}
|
|
56
|
+
return tx.From(ctx, s.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
|
|
57
|
+
var enrollment MFAEnrollment
|
|
58
|
+
if err := db.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", userID).First(&enrollment).Error; err != nil {
|
|
59
|
+
return err
|
|
60
|
+
}
|
|
61
|
+
if enrollment.Enabled {
|
|
62
|
+
return errors.New("MFA enrollment is already enabled")
|
|
63
|
+
}
|
|
64
|
+
if err := db.Model(&enrollment).Updates(map[string]any{
|
|
65
|
+
"encrypted_secret": encryptedSecret,
|
|
66
|
+
"enabled": true,
|
|
67
|
+
"updated_at": time.Now(),
|
|
68
|
+
}).Error; err != nil {
|
|
69
|
+
return err
|
|
70
|
+
}
|
|
71
|
+
if err := db.Where("user_id = ?", userID).Delete(&MFARecoveryCode{}).Error; err != nil {
|
|
72
|
+
return err
|
|
73
|
+
}
|
|
74
|
+
rows := make([]MFARecoveryCode, len(recoveryCodeHashes))
|
|
75
|
+
for i, hash := range recoveryCodeHashes {
|
|
76
|
+
rows[i] = MFARecoveryCode{UserID: userID, CodeHash: hash}
|
|
77
|
+
}
|
|
78
|
+
return db.Create(&rows).Error
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func (s *PostgresMFAStore) Disable(ctx context.Context, userID uuid.UUID) error {
|
|
83
|
+
return tx.From(ctx, s.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
|
|
84
|
+
if err := db.Where("user_id = ?", userID).Delete(&MFARecoveryCode{}).Error; err != nil {
|
|
85
|
+
return err
|
|
86
|
+
}
|
|
87
|
+
return db.Where("user_id = ?", userID).Delete(&MFAEnrollment{}).Error
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func (s *PostgresMFAStore) CreateChallenge(ctx context.Context, hash string, challenge ports.MFAChallenge) error {
|
|
92
|
+
if hash == "" || challenge.UserID == uuid.Nil || !challenge.ExpiresAt.After(time.Now()) {
|
|
93
|
+
return fmt.Errorf("MFA challenge is invalid")
|
|
94
|
+
}
|
|
95
|
+
row := MFAChallenge{ChallengeHash: hash, UserID: challenge.UserID, ExpiresAt: challenge.ExpiresAt}
|
|
96
|
+
return tx.From(ctx, s.db).WithContext(ctx).Create(&row).Error
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func (s *PostgresMFAStore) ConsumeChallenge(ctx context.Context, hash string) (ports.MFAChallenge, bool, error) {
|
|
100
|
+
var challenge ports.MFAChallenge
|
|
101
|
+
err := tx.From(ctx, s.db).WithContext(ctx).Raw(
|
|
102
|
+
`DELETE FROM user_svc.mfa_challenges
|
|
103
|
+
WHERE challenge_hash = ? AND expires_at > now()
|
|
104
|
+
RETURNING user_id, expires_at`, hash,
|
|
105
|
+
).Row().Scan(&challenge.UserID, &challenge.ExpiresAt)
|
|
106
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
107
|
+
return ports.MFAChallenge{}, false, nil
|
|
108
|
+
}
|
|
109
|
+
if err != nil {
|
|
110
|
+
return ports.MFAChallenge{}, false, err
|
|
111
|
+
}
|
|
112
|
+
return challenge, true, nil
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
func (s *PostgresMFAStore) ConsumeRecoveryCode(ctx context.Context, userID uuid.UUID, hash string) (bool, error) {
|
|
116
|
+
var consumed string
|
|
117
|
+
err := tx.From(ctx, s.db).WithContext(ctx).Raw(
|
|
118
|
+
`DELETE FROM user_svc.mfa_recovery_codes
|
|
119
|
+
WHERE user_id = ? AND code_hash = ?
|
|
120
|
+
RETURNING code_hash`, userID, hash,
|
|
121
|
+
).Row().Scan(&consumed)
|
|
122
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
123
|
+
return false, nil
|
|
124
|
+
}
|
|
125
|
+
if err != nil {
|
|
126
|
+
return false, err
|
|
127
|
+
}
|
|
128
|
+
return consumed != "", nil
|
|
129
|
+
}
|