@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
package/templates/add/auth/internal/app/user/{handler.go.hbs → adapters/inbound/http/handler.go.hbs}
RENAMED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
package
|
|
1
|
+
package httpadapter
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"net/http"
|
|
4
6
|
"time"
|
|
5
7
|
|
|
8
|
+
"{{goModule}}/internal/app/user/application"
|
|
9
|
+
"{{goModule}}/internal/app/user/domain"
|
|
10
|
+
"{{goModule}}/internal/shared/apperror"
|
|
11
|
+
"{{goModule}}/internal/shared/httpx"
|
|
6
12
|
"{{goModule}}/internal/shared/middleware"
|
|
7
13
|
|
|
14
|
+
"{{goModule}}/internal/shared/pagination"
|
|
8
15
|
"github.com/gin-gonic/gin"
|
|
9
16
|
// go-scaffold:user-handler-imports
|
|
10
17
|
)
|
|
@@ -13,6 +20,9 @@ const (
|
|
|
13
20
|
refreshCookieName = "refresh_token"
|
|
14
21
|
)
|
|
15
22
|
|
|
23
|
+
const PermUserManageRole = "user:manage-role"
|
|
24
|
+
const PermUserRead = "user:read"
|
|
25
|
+
|
|
16
26
|
// go-scaffold:user-handler-consts
|
|
17
27
|
|
|
18
28
|
// authorizer is the optional capability RBAC supplies to protect admin
|
|
@@ -22,7 +32,7 @@ type authorizer interface {
|
|
|
22
32
|
}
|
|
23
33
|
|
|
24
34
|
type Handler struct {
|
|
25
|
-
svc
|
|
35
|
+
svc application.ServicePort
|
|
26
36
|
jwtSecret string
|
|
27
37
|
refreshTTL time.Duration
|
|
28
38
|
cookieSecure bool
|
|
@@ -36,7 +46,7 @@ type Handler struct {
|
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
func NewHandler(
|
|
39
|
-
svc
|
|
49
|
+
svc application.ServicePort,
|
|
40
50
|
jwtSecret string,
|
|
41
51
|
refreshTTL time.Duration,
|
|
42
52
|
cookieSecure bool,
|
|
@@ -48,11 +58,10 @@ func NewHandler(
|
|
|
48
58
|
return newHandler(svc, jwtSecret, refreshTTL, cookieSecure, cookieSameSite, nil, limiter, authz...)
|
|
49
59
|
}
|
|
50
60
|
|
|
51
|
-
// NewHandlerWithOrigins is the
|
|
52
|
-
//
|
|
53
|
-
// generated before the cross-site Origin guard was added.
|
|
61
|
+
// NewHandlerWithOrigins is the composition entry point when the application
|
|
62
|
+
// uses cookies across browser origins.
|
|
54
63
|
func NewHandlerWithOrigins(
|
|
55
|
-
svc
|
|
64
|
+
svc application.ServicePort,
|
|
56
65
|
jwtSecret string,
|
|
57
66
|
refreshTTL time.Duration,
|
|
58
67
|
cookieSecure bool,
|
|
@@ -66,7 +75,7 @@ func NewHandlerWithOrigins(
|
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
func newHandler(
|
|
69
|
-
svc
|
|
78
|
+
svc application.ServicePort,
|
|
70
79
|
jwtSecret string,
|
|
71
80
|
refreshTTL time.Duration,
|
|
72
81
|
cookieSecure bool,
|
|
@@ -127,7 +136,93 @@ func (h *Handler) Register(rg gin.IRouter) {
|
|
|
127
136
|
usersGroup.POST("/me/mfa/setup", mfaSetupLimit, h.setupMFA)
|
|
128
137
|
usersGroup.POST("/me/mfa/confirm", mfaConfirmLimit, h.confirmMFA)
|
|
129
138
|
usersGroup.POST("/me/mfa/disable", mfaDisableLimit, h.disableMFA)
|
|
139
|
+
// Admin user routes are enabled only when the optional RBAC authorizer is
|
|
140
|
+
// supplied by the composition root. Auth-only projects keep the endpoint
|
|
141
|
+
// surface limited to authentication and the current-user operations.
|
|
142
|
+
if h.authz != nil {
|
|
143
|
+
usersGroup.GET("", h.authz.Require(PermUserRead), h.adminListUsers)
|
|
144
|
+
usersGroup.GET("/:id", h.authz.Require(PermUserRead), h.adminGetUser)
|
|
145
|
+
usersGroup.PATCH("/:id/set-role", h.authz.Require(PermUserManageRole), h.setRole)
|
|
146
|
+
}
|
|
147
|
+
// go-scaffold:handler-routes
|
|
130
148
|
// go-scaffold:user-routes
|
|
131
149
|
}
|
|
132
150
|
|
|
151
|
+
func (h *Handler) adminListUsers(c *gin.Context) {
|
|
152
|
+
p := pagination.Parse(c)
|
|
153
|
+
items, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset)
|
|
154
|
+
if err != nil {
|
|
155
|
+
c.Error(toHTTPError(err))
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
out := make([]meResponse, len(items))
|
|
159
|
+
for i := range items {
|
|
160
|
+
out[i] = toMeResponse(&items[i])
|
|
161
|
+
}
|
|
162
|
+
c.JSON(http.StatusOK, p.Response(out))
|
|
163
|
+
}
|
|
164
|
+
func (h *Handler) adminGetUser(c *gin.Context) {
|
|
165
|
+
id, ok := httpx.ParseID(c)
|
|
166
|
+
if !ok {
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
u, err := h.svc.Get(c.Request.Context(), id)
|
|
170
|
+
if err != nil {
|
|
171
|
+
c.Error(toHTTPError(err))
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
c.JSON(http.StatusOK, toMeResponse(u))
|
|
175
|
+
}
|
|
176
|
+
func (h *Handler) setRole(c *gin.Context) {
|
|
177
|
+
id, ok := httpx.ParseID(c)
|
|
178
|
+
if !ok {
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
var in setRoleInput
|
|
182
|
+
if err := c.ShouldBindJSON(&in); err != nil {
|
|
183
|
+
c.Error(httpx.BindErr(err))
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
u, err := h.svc.SetRole(c.Request.Context(), id, in.Role)
|
|
187
|
+
if err != nil {
|
|
188
|
+
c.Error(toHTTPError(err))
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
c.JSON(http.StatusOK, toMeResponse(u))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
func toHTTPError(err error) error {
|
|
195
|
+
if err == nil {
|
|
196
|
+
return nil
|
|
197
|
+
}
|
|
198
|
+
var appErr *apperror.AppError
|
|
199
|
+
if errors.As(err, &appErr) {
|
|
200
|
+
return err
|
|
201
|
+
}
|
|
202
|
+
var ruleErr *domain.RuleError
|
|
203
|
+
if errors.As(err, &ruleErr) {
|
|
204
|
+
status := http.StatusInternalServerError
|
|
205
|
+
switch ruleErr.Code {
|
|
206
|
+
case "USER_NOT_FOUND":
|
|
207
|
+
status = http.StatusNotFound
|
|
208
|
+
case "USER_EMAIL_TAKEN", "AUTH_ALREADY_VERIFIED", "AUTH_MFA_ALREADY_ENABLED", "AUTH_MFA_NOT_ENROLLED", "AUTH_MFA_SETUP_REQUIRED":
|
|
209
|
+
status = http.StatusConflict
|
|
210
|
+
case "AUTH_INVALID_CREDENTIALS", "AUTH_INVALID_TOKEN", "AUTH_MFA_INVALID":
|
|
211
|
+
status = http.StatusUnauthorized
|
|
212
|
+
case "AUTH_MFA_UNAVAILABLE":
|
|
213
|
+
status = http.StatusServiceUnavailable
|
|
214
|
+
case "AUTH_TOO_MANY_ATTEMPTS":
|
|
215
|
+
status = http.StatusTooManyRequests
|
|
216
|
+
case "USER_UNKNOWN_ROLE":
|
|
217
|
+
status = http.StatusUnprocessableEntity
|
|
218
|
+
}
|
|
219
|
+
return apperror.New(status, ruleErr.Code, ruleErr.Message)
|
|
220
|
+
}
|
|
221
|
+
if errors.Is(err, domain.ErrNotFound) {
|
|
222
|
+
return apperror.NewNotFound("user not found")
|
|
223
|
+
}
|
|
224
|
+
return apperror.NewInternal(err)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// go-scaffold:handler-funcs
|
|
133
228
|
// go-scaffold:user-handler-funcs
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
package
|
|
1
|
+
package httpadapter
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"net/http"
|
|
5
5
|
|
|
6
|
+
"{{goModule}}/internal/app/user/domain"
|
|
6
7
|
"{{goModule}}/internal/shared/httpx"
|
|
7
8
|
|
|
8
9
|
"github.com/gin-gonic/gin"
|
|
@@ -18,9 +19,9 @@ func (h *Handler) register(c *gin.Context) {
|
|
|
18
19
|
c.Error(httpx.BindErr(err))
|
|
19
20
|
return
|
|
20
21
|
}
|
|
21
|
-
auth, err := h.svc.Register(c.Request.Context(), in)
|
|
22
|
+
auth, err := h.svc.Register(c.Request.Context(), toRegisterInput(in))
|
|
22
23
|
if err != nil {
|
|
23
|
-
c.Error(err)
|
|
24
|
+
c.Error(toHTTPError(err))
|
|
24
25
|
return
|
|
25
26
|
}
|
|
26
27
|
h.writeAuthResult(c, http.StatusCreated, auth)
|
|
@@ -36,9 +37,9 @@ func (h *Handler) login(c *gin.Context) {
|
|
|
36
37
|
c.Error(httpx.BindErr(err))
|
|
37
38
|
return
|
|
38
39
|
}
|
|
39
|
-
auth, err := h.svc.Login(c.Request.Context(), in)
|
|
40
|
+
auth, err := h.svc.Login(c.Request.Context(), toLoginInput(in))
|
|
40
41
|
if err != nil {
|
|
41
|
-
c.Error(err)
|
|
42
|
+
c.Error(toHTTPError(err))
|
|
42
43
|
return
|
|
43
44
|
}
|
|
44
45
|
h.writeAuthResult(c, http.StatusOK, auth)
|
|
@@ -51,13 +52,13 @@ func (h *Handler) refresh(c *gin.Context) {
|
|
|
51
52
|
}
|
|
52
53
|
raw, err := c.Cookie(refreshCookieName)
|
|
53
54
|
if err != nil || raw == "" {
|
|
54
|
-
c.Error(
|
|
55
|
+
c.Error(toHTTPError(domain.Rule("AUTH_INVALID_TOKEN", "invalid or expired token", domain.ErrInvalidToken)))
|
|
55
56
|
return
|
|
56
57
|
}
|
|
57
58
|
auth, err := h.svc.Refresh(c.Request.Context(), raw)
|
|
58
59
|
if err != nil {
|
|
59
60
|
h.clearRefreshCookie(c)
|
|
60
|
-
c.Error(err)
|
|
61
|
+
c.Error(toHTTPError(err))
|
|
61
62
|
return
|
|
62
63
|
}
|
|
63
64
|
h.setRefreshCookie(c, auth.RefreshToken)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
package
|
|
1
|
+
package httpadapter
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"net/http"
|
|
@@ -22,7 +22,7 @@ func (h *Handler) verifyMFA(c *gin.Context) {
|
|
|
22
22
|
}
|
|
23
23
|
auth, err := h.svc.VerifyMFA(c.Request.Context(), in.Challenge, in.Code)
|
|
24
24
|
if err != nil {
|
|
25
|
-
c.Error(err)
|
|
25
|
+
c.Error(toHTTPError(err))
|
|
26
26
|
return
|
|
27
27
|
}
|
|
28
28
|
h.setRefreshCookie(c, auth.RefreshToken)
|
|
@@ -33,7 +33,7 @@ func (h *Handler) mfaStatus(c *gin.Context) {
|
|
|
33
33
|
setNoStoreHeaders(c)
|
|
34
34
|
status, err := h.svc.MFAStatus(c.Request.Context(), currentUserID(c))
|
|
35
35
|
if err != nil {
|
|
36
|
-
c.Error(err)
|
|
36
|
+
c.Error(toHTTPError(err))
|
|
37
37
|
return
|
|
38
38
|
}
|
|
39
39
|
c.JSON(http.StatusOK, mfaStatusResponse(status))
|
|
@@ -43,7 +43,7 @@ func (h *Handler) setupMFA(c *gin.Context) {
|
|
|
43
43
|
setNoStoreHeaders(c)
|
|
44
44
|
setup, err := h.svc.SetupMFA(c.Request.Context(), currentUserID(c))
|
|
45
45
|
if err != nil {
|
|
46
|
-
c.Error(err)
|
|
46
|
+
c.Error(toHTTPError(err))
|
|
47
47
|
return
|
|
48
48
|
}
|
|
49
49
|
c.JSON(http.StatusOK, mfaSetupResponse{Secret: setup.Secret, OTPAuthURI: setup.OTPAuthURI})
|
|
@@ -58,7 +58,7 @@ func (h *Handler) confirmMFA(c *gin.Context) {
|
|
|
58
58
|
}
|
|
59
59
|
codes, err := h.svc.ConfirmMFA(c.Request.Context(), currentUserID(c), in.Code)
|
|
60
60
|
if err != nil {
|
|
61
|
-
c.Error(err)
|
|
61
|
+
c.Error(toHTTPError(err))
|
|
62
62
|
return
|
|
63
63
|
}
|
|
64
64
|
c.JSON(http.StatusOK, mfaConfirmResponse{RecoveryCodes: codes})
|
|
@@ -72,7 +72,7 @@ func (h *Handler) disableMFA(c *gin.Context) {
|
|
|
72
72
|
return
|
|
73
73
|
}
|
|
74
74
|
if err := h.svc.DisableMFA(c.Request.Context(), currentUserID(c), in.Code); err != nil {
|
|
75
|
-
c.Error(err)
|
|
75
|
+
c.Error(toHTTPError(err))
|
|
76
76
|
return
|
|
77
77
|
}
|
|
78
78
|
c.Status(http.StatusNoContent)
|
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
package
|
|
1
|
+
package httpadapter
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"errors"
|
|
5
5
|
"log/slog"
|
|
6
6
|
"net/http"
|
|
7
7
|
|
|
8
|
-
"{{goModule}}/internal/app/user/application"
|
|
8
|
+
userapp "{{goModule}}/internal/app/user/application"
|
|
9
9
|
"{{goModule}}/internal/shared/apperror"
|
|
10
10
|
|
|
11
11
|
"github.com/gin-gonic/gin"
|
|
12
12
|
)
|
|
13
13
|
|
|
14
14
|
func (h *Handler) providerLogin(c *gin.Context) {
|
|
15
|
-
start, err := h.svc.BeginLogin(c.Request.Context(), c.Param("provider"),
|
|
15
|
+
start, err := h.svc.BeginLogin(c.Request.Context(), c.Param("provider"), toLoginStartInput(loginStartInput{
|
|
16
16
|
State: c.Query("state"),
|
|
17
17
|
CodeChallenge: c.Query("code_challenge"),
|
|
18
18
|
CodeChallengeMethod: c.Query("code_challenge_method"),
|
|
19
|
-
})
|
|
19
|
+
}))
|
|
20
20
|
if err != nil {
|
|
21
21
|
c.Error(h.oauthAppError(c.Param("provider"), err))
|
|
22
22
|
return
|
|
@@ -31,10 +31,10 @@ func (h *Handler) providerExchange(c *gin.Context) {
|
|
|
31
31
|
}
|
|
32
32
|
var in loginExchangeInput
|
|
33
33
|
if err := c.ShouldBindJSON(&in); err != nil {
|
|
34
|
-
c.Error(h.oauthAppError(c.Param("provider"),
|
|
34
|
+
c.Error(h.oauthAppError(c.Param("provider"), userapp.NewOAuthError(userapp.OAuthFailed, err)))
|
|
35
35
|
return
|
|
36
36
|
}
|
|
37
|
-
auth, err := h.svc.ExchangeLogin(c.Request.Context(), c.Param("provider"), in)
|
|
37
|
+
auth, err := h.svc.ExchangeLogin(c.Request.Context(), c.Param("provider"), toLoginExchangeInput(in))
|
|
38
38
|
if err != nil {
|
|
39
39
|
c.Error(h.oauthAppError(c.Param("provider"), err))
|
|
40
40
|
return
|
|
@@ -43,26 +43,26 @@ func (h *Handler) providerExchange(c *gin.Context) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
func (h *Handler) oauthAppError(provider string, err error) *apperror.AppError {
|
|
46
|
-
code :=
|
|
46
|
+
code := userapp.OAuthFailed
|
|
47
47
|
status := http.StatusBadRequest
|
|
48
48
|
message := "external login failed"
|
|
49
|
-
var oauthErr *
|
|
49
|
+
var oauthErr *userapp.OAuthError
|
|
50
50
|
if errors.As(err, &oauthErr) {
|
|
51
51
|
switch oauthErr.Code {
|
|
52
|
-
case
|
|
53
|
-
code =
|
|
52
|
+
case userapp.OAuthDenied:
|
|
53
|
+
code = userapp.OAuthDenied
|
|
54
54
|
message = "external login was denied"
|
|
55
|
-
case
|
|
56
|
-
code =
|
|
55
|
+
case userapp.OAuthStateInvalid:
|
|
56
|
+
code = userapp.OAuthStateInvalid
|
|
57
57
|
message = "oauth state or PKCE verifier is invalid"
|
|
58
|
-
case
|
|
59
|
-
code =
|
|
58
|
+
case userapp.OAuthProviderUnavailable:
|
|
59
|
+
code = userapp.OAuthProviderUnavailable
|
|
60
60
|
status = http.StatusServiceUnavailable
|
|
61
61
|
message = "external login provider is unavailable"
|
|
62
|
-
case
|
|
63
|
-
code =
|
|
62
|
+
case userapp.OAuthFailed:
|
|
63
|
+
code = userapp.OAuthFailed
|
|
64
64
|
default:
|
|
65
|
-
code =
|
|
65
|
+
code = userapp.OAuthFailed
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
slog.Warn("oauth request failed", "provider", provider, "code", code, "error", err)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
package
|
|
1
|
+
package httpadapter
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"net/http"
|
|
@@ -15,7 +15,7 @@ func (h *Handler) forgotPassword(c *gin.Context) {
|
|
|
15
15
|
return
|
|
16
16
|
}
|
|
17
17
|
if err := h.svc.ForgotPassword(c.Request.Context(), in.Email); err != nil {
|
|
18
|
-
c.Error(err)
|
|
18
|
+
c.Error(toHTTPError(err))
|
|
19
19
|
return
|
|
20
20
|
}
|
|
21
21
|
// Always the same response, whether or not the email exists.
|
|
@@ -29,7 +29,7 @@ func (h *Handler) resetPassword(c *gin.Context) {
|
|
|
29
29
|
return
|
|
30
30
|
}
|
|
31
31
|
if err := h.svc.ResetPassword(c.Request.Context(), in.Token, in.NewPassword); err != nil {
|
|
32
|
-
c.Error(err)
|
|
32
|
+
c.Error(toHTTPError(err))
|
|
33
33
|
return
|
|
34
34
|
}
|
|
35
35
|
c.Status(http.StatusNoContent)
|
|
@@ -42,7 +42,7 @@ func (h *Handler) verifyEmail(c *gin.Context) {
|
|
|
42
42
|
return
|
|
43
43
|
}
|
|
44
44
|
if err := h.svc.VerifyEmail(c.Request.Context(), in.Token); err != nil {
|
|
45
|
-
c.Error(err)
|
|
45
|
+
c.Error(toHTTPError(err))
|
|
46
46
|
return
|
|
47
47
|
}
|
|
48
48
|
c.Status(http.StatusNoContent)
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
package
|
|
1
|
+
package httpadapter
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
+
"crypto/sha256"
|
|
6
|
+
"encoding/base64"
|
|
5
7
|
"net/http"
|
|
6
8
|
"net/http/httptest"
|
|
7
9
|
"net/url"
|
|
@@ -27,38 +29,60 @@ func (allowAllAuthorizer) Require(_ string) gin.HandlerFunc {
|
|
|
27
29
|
|
|
28
30
|
const handlerOAuthVerifier = "client-code-verifier-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"code_challenge_method": {in.CodeChallengeMethod},
|
|
38
|
-
}.Encode()}, nil
|
|
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
39
|
}
|
|
40
|
-
|
|
41
|
-
|
|
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)
|
|
42
50
|
}
|
|
43
|
-
|
|
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{}
|
|
44
75
|
h := NewHandler(svc, "test-secret", time.Hour, true, "strict", allowAllLimiter{}, allowAllAuthorizer{})
|
|
45
76
|
gin.SetMode(gin.TestMode)
|
|
46
77
|
router := gin.New()
|
|
47
78
|
router.Use(middleware.Error(false))
|
|
48
79
|
h.Register(router)
|
|
49
|
-
return router
|
|
80
|
+
return router
|
|
50
81
|
}
|
|
51
82
|
|
|
52
83
|
func newCrossSiteTestRouter(t *testing.T) *gin.Engine {
|
|
53
84
|
t.Helper()
|
|
54
|
-
|
|
55
|
-
provider.beginFn = func(in application.LoginStartInput) (application.Authorization, error) {
|
|
56
|
-
return application.Authorization{URL: "https://provider.example.test/authorize?state=" + url.QueryEscape(in.State)}, nil
|
|
57
|
-
}
|
|
58
|
-
provider.completeFn = func(application.LoginCompleteInput) (application.ExternalIdentity, error) {
|
|
59
|
-
return application.ExternalIdentity{Provider: "fake", Subject: "subject", Email: "user@example.com", EmailVerified: true}, nil
|
|
60
|
-
}
|
|
61
|
-
svc := newTestServiceWithProviders(&fakeRepo{}, newFakeTokenStore(), provider)
|
|
85
|
+
svc := stubService{}
|
|
62
86
|
h := NewHandlerWithOrigins(svc, "test-secret", time.Hour, true, "none", []string{"https://app.example.test"}, allowAllLimiter{}, allowAllAuthorizer{})
|
|
63
87
|
router := gin.New()
|
|
64
88
|
router.Use(middleware.Error(false))
|
|
@@ -94,10 +118,10 @@ func beginOAuthFlow(t *testing.T, router *gin.Engine) (string, string) {
|
|
|
94
118
|
}
|
|
95
119
|
|
|
96
120
|
func TestHandler_ProviderExchangeReturnsJSONAndSetsRefreshCookie(t *testing.T) {
|
|
97
|
-
router
|
|
121
|
+
router := newOAuthTestRouter(t)
|
|
98
122
|
state, verifier := beginOAuthFlow(t, router)
|
|
99
123
|
request := httptest.NewRequest(http.MethodPost, "/auth/fake/exchange", strings.NewReader(
|
|
100
|
-
"{\"code\":\"code\",\"state\":\""
|
|
124
|
+
"{\"code\":\"code\",\"state\":\""+state+"\",\"code_verifier\":\""+verifier+"\"}",
|
|
101
125
|
))
|
|
102
126
|
request.Header.Set("Content-Type", "application/json")
|
|
103
127
|
response := httptest.NewRecorder()
|
|
@@ -128,16 +152,13 @@ func TestHandler_ProviderExchangeReturnsJSONAndSetsRefreshCookie(t *testing.T) {
|
|
|
128
152
|
}
|
|
129
153
|
|
|
130
154
|
func TestHandler_ProviderCallbackIsFrontendOwnedAndErrorsDoNotRedirect(t *testing.T) {
|
|
131
|
-
router
|
|
155
|
+
router := newOAuthTestRouter(t)
|
|
132
156
|
callback := httptest.NewRequest(http.MethodGet, "/auth/fake/callback?error=access_denied&error_description=do-not-leak", nil)
|
|
133
157
|
callbackResponse := httptest.NewRecorder()
|
|
134
158
|
router.ServeHTTP(callbackResponse, callback)
|
|
135
159
|
if callbackResponse.Code != http.StatusNotFound {
|
|
136
160
|
t.Fatalf("API callback status = %d, want 404 for frontend-owned callback", callbackResponse.Code)
|
|
137
161
|
}
|
|
138
|
-
if provider.completeCnt != 0 {
|
|
139
|
-
t.Fatal("provider must not exchange a frontend callback error")
|
|
140
|
-
}
|
|
141
162
|
|
|
142
163
|
invalid := httptest.NewRequest(http.MethodPost, "/auth/fake/exchange?return_to=https%3A%2F%2Fevil.example", strings.NewReader(
|
|
143
164
|
"{\"code\":\"code\",\"state\":\"\",\"code_verifier\":\""+handlerOAuthVerifier+"\",\"error_description\":\"do-not-leak\"}",
|
|
@@ -154,7 +175,7 @@ func TestHandler_ProviderCallbackIsFrontendOwnedAndErrorsDoNotRedirect(t *testin
|
|
|
154
175
|
}
|
|
155
176
|
|
|
156
177
|
func TestHandler_ProviderLoginAndExchangeUnconfiguredProviderUseControlledJSON(t *testing.T) {
|
|
157
|
-
router
|
|
178
|
+
router := newOAuthTestRouter(t)
|
|
158
179
|
login := httptest.NewRequest(http.MethodGet, "/auth/google/login?"+url.Values{
|
|
159
180
|
"state": {"client-state"},
|
|
160
181
|
"code_challenge": {"client-code-challenge"},
|
|
@@ -184,7 +205,7 @@ func TestHandler_ProviderLoginAndExchangeUnconfiguredProviderUseControlledJSON(t
|
|
|
184
205
|
}
|
|
185
206
|
|
|
186
207
|
func TestHandler_ProviderExchangeRejectsMissingStatePKCEAndCode(t *testing.T) {
|
|
187
|
-
router
|
|
208
|
+
router := newOAuthTestRouter(t)
|
|
188
209
|
tests := []struct {
|
|
189
210
|
name string
|
|
190
211
|
body string
|
|
@@ -231,7 +252,7 @@ func TestHandler_ValidateBrowserCookiePolicy(t *testing.T) {
|
|
|
231
252
|
}
|
|
232
253
|
for _, tt := range tests {
|
|
233
254
|
t.Run(tt.name, func(t *testing.T) {
|
|
234
|
-
err :=
|
|
255
|
+
err := ValidateBrowserCookiePolicy(tt.topology, tt.sameSite, tt.secure, tt.production)
|
|
235
256
|
if (err != nil) != tt.wantErr {
|
|
236
257
|
t.Fatalf("validateBrowserCookiePolicy() error = %v, wantErr %v", err, tt.wantErr)
|
|
237
258
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
package
|
|
1
|
+
package httpadapter
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"net/http"
|
|
@@ -12,7 +12,7 @@ import (
|
|
|
12
12
|
func (h *Handler) resendVerification(c *gin.Context) {
|
|
13
13
|
userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
|
|
14
14
|
if err := h.svc.ResendVerificationEmail(c.Request.Context(), userID); err != nil {
|
|
15
|
-
c.Error(err)
|
|
15
|
+
c.Error(toHTTPError(err))
|
|
16
16
|
return
|
|
17
17
|
}
|
|
18
18
|
c.Status(http.StatusNoContent)
|
|
@@ -23,7 +23,7 @@ func (h *Handler) resendVerification(c *gin.Context) {
|
|
|
23
23
|
func (h *Handler) logoutAll(c *gin.Context) {
|
|
24
24
|
userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
|
|
25
25
|
if err := h.svc.LogoutAll(c.Request.Context(), userID); err != nil {
|
|
26
|
-
c.Error(err)
|
|
26
|
+
c.Error(toHTTPError(err))
|
|
27
27
|
return
|
|
28
28
|
}
|
|
29
29
|
h.clearRefreshCookie(c)
|
|
@@ -34,7 +34,7 @@ func (h *Handler) me(c *gin.Context) {
|
|
|
34
34
|
userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
|
|
35
35
|
u, err := h.svc.Get(c.Request.Context(), userID)
|
|
36
36
|
if err != nil {
|
|
37
|
-
c.Error(err)
|
|
37
|
+
c.Error(toHTTPError(err))
|
|
38
38
|
return
|
|
39
39
|
}
|
|
40
40
|
c.JSON(http.StatusOK, toMeResponse(u))
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
package
|
|
1
|
+
package httpadapter
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"net/http"
|
|
5
5
|
|
|
6
|
+
"{{goModule}}/internal/app/user/application"
|
|
7
|
+
|
|
6
8
|
"github.com/gin-gonic/gin"
|
|
7
9
|
)
|
|
8
10
|
|
|
@@ -23,11 +25,11 @@ func setNoStoreHeaders(c *gin.Context) {
|
|
|
23
25
|
c.Header("Pragma", "no-cache")
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
func (h *Handler) writeAuthResult(c *gin.Context, status int, result *
|
|
28
|
+
func (h *Handler) writeAuthResult(c *gin.Context, status int, result *application.AuthResult) {
|
|
27
29
|
if result.MFAChallenge != "" {
|
|
28
30
|
c.JSON(http.StatusOK, mfaChallengeResponse{MFARequired: true, Challenge: result.MFAChallenge})
|
|
29
31
|
return
|
|
30
32
|
}
|
|
31
33
|
h.setRefreshCookie(c, result.RefreshToken)
|
|
32
|
-
c.JSON(status, toCookieResponse(result.
|
|
34
|
+
c.JSON(status, toCookieResponse(result.AuthResponse))
|
|
33
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
|
+
}
|