@nakedev/go-scaffold 0.5.0 → 0.5.2
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/dist/commands/auth.js +5 -1
- package/dist/index.js +1 -1
- package/dist/templates/auth-manifest.js +1 -0
- package/dist/utils/rbac-patcher.js +12 -4
- package/package.json +1 -1
- package/templates/add/auth/docs/schemas.yaml.hbs +19 -0
- package/templates/add/auth/docs/users-me-session.yaml.hbs +14 -0
- package/templates/add/auth/docs/users-me-sessions.yaml.hbs +13 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +24 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +6 -2
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +9 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +3 -1
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +14 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +30 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +3 -3
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +6 -1
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +42 -5
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +36 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +75 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +36 -0
- package/templates/add/auth/internal/app/user/application/dto.go.hbs +18 -0
- package/templates/add/auth/internal/app/user/application/external_login.go.hbs +1 -1
- package/templates/add/auth/internal/app/user/application/jwt.go.hbs +5 -2
- package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +2 -2
- package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +13 -4
- package/templates/add/auth/internal/app/user/application/service.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/application/service_test.go.hbs +32 -1
- package/templates/add/auth/internal/app/user/application/sessions.go.hbs +62 -8
- package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +1 -0
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +16 -0
- package/templates/add/auth/internal/shared/middleware/auth.go.hbs +9 -3
- package/templates/add/auth/internal/shared/middleware/auth_test.go.hbs +48 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +5 -1
- package/templates/add/auth/migrations/create_mfa.up.sql.hbs +2 -0
- package/templates/create/base/AGENTS.md.hbs +6 -0
- package/templates/create/base/README.md.hbs +4 -0
package/dist/commands/auth.js
CHANGED
|
@@ -36,6 +36,8 @@ const AUTH_OPENAPI_PATHS = [
|
|
|
36
36
|
{ urlPath: "/users/me", file: "./auth/users-me.yaml" },
|
|
37
37
|
{ urlPath: "/users/me/resend-verification", file: "./auth/users-me-resend-verification.yaml" },
|
|
38
38
|
{ urlPath: "/users/me/logout-all", file: "./auth/users-me-logout-all.yaml" },
|
|
39
|
+
{ urlPath: "/users/me/sessions", file: "./auth/users-me-sessions.yaml" },
|
|
40
|
+
{ urlPath: "/users/me/sessions/{id}", file: "./auth/users-me-session.yaml" },
|
|
39
41
|
{ urlPath: "/users/me/mfa", file: "./auth/users-me-mfa.yaml" },
|
|
40
42
|
{ urlPath: "/users/me/mfa/setup", file: "./auth/users-me-mfa-setup.yaml" },
|
|
41
43
|
{ urlPath: "/users/me/mfa/confirm", file: "./auth/users-me-mfa-confirm.yaml" },
|
|
@@ -44,7 +46,8 @@ const AUTH_OPENAPI_PATHS = [
|
|
|
44
46
|
];
|
|
45
47
|
// addAuth scaffolds email/password authentication: a users+identities model
|
|
46
48
|
// pair, JWT access tokens, a selectable refresh token store with
|
|
47
|
-
// rotation + reuse detection,
|
|
49
|
+
// rotation + reuse detection, register/login/refresh/logout/me, and
|
|
50
|
+
// per-user session listing/revocation. No RBAC
|
|
48
51
|
// (no roles/permissions) — that's a separate opt-in on top of this, since
|
|
49
52
|
// most projects need "is this caller logged in" long before they need "can
|
|
50
53
|
// this caller do X".
|
|
@@ -178,6 +181,7 @@ async function addAuth(store = "postgres", projectDir = process.cwd(), browserTo
|
|
|
178
181
|
console.log("registered POST /auth/{register,login,refresh,logout,forgot-password,reset-password,verify-email}, " +
|
|
179
182
|
"GET /auth/{provider}/login, POST /auth/{provider}/exchange, GET /users/me, and " +
|
|
180
183
|
"POST /users/me/{resend-verification,logout-all,mfa/setup,mfa/confirm,mfa/disable}, " +
|
|
184
|
+
"GET /users/me/sessions, DELETE /users/me/sessions/{id}, " +
|
|
181
185
|
"GET /users/me/mfa, and POST /auth/mfa/verify in cmd/api/wiring.go" +
|
|
182
186
|
docsMessage);
|
|
183
187
|
console.log(picocolors_1.default.dim("\nnext: go mod tidy, then apply the new migrations with `make migrate-up` before production\n" +
|
package/dist/index.js
CHANGED
|
@@ -494,7 +494,7 @@ async function resolveQueueBackend(opts) {
|
|
|
494
494
|
}
|
|
495
495
|
add
|
|
496
496
|
.command("auth")
|
|
497
|
-
.description("add email/password auth: JWT access tokens, refresh
|
|
497
|
+
.description("add email/password auth: JWT access tokens, refresh rotation, device-session listing/revocation, register/login/refresh/logout/me (no prerequisites — without `add worker` the verification/reset mail is sent inline)")
|
|
498
498
|
.option("--store <store>", 'where tokens and rate-limit counters live: "postgres" (default, no extra service) or "redis" (exact across replicas)')
|
|
499
499
|
.option("--browser-topology <topology>", "browser deployment topology for cookie/CORS policy: same-origin, same-site (different origin), or cross-site (requires HTTPS deployment)")
|
|
500
500
|
.option("--defaults", "skip store, browser-topology, and confirmation prompts; use Postgres plus local same-site defaults")
|
|
@@ -7,6 +7,7 @@ exports.authFiles = authFiles;
|
|
|
7
7
|
// must create one canonical implementation tree.
|
|
8
8
|
const SHARED = [
|
|
9
9
|
{ template: "add/auth/internal/shared/middleware/auth.go.hbs", output: "internal/shared/middleware/auth.go" },
|
|
10
|
+
{ template: "add/auth/internal/shared/middleware/auth_test.go.hbs", output: "internal/shared/middleware/auth_test.go" },
|
|
10
11
|
{ template: "add/auth/internal/shared/middleware/ratelimit.go.hbs", output: "internal/shared/middleware/ratelimit.go" },
|
|
11
12
|
{ template: "add/auth/internal/app/user/domain/entity.go.hbs", output: "internal/app/user/domain/entity.go" },
|
|
12
13
|
{ template: "add/auth/internal/app/user/domain/errors.go.hbs", output: "internal/app/user/domain/errors.go" },
|
|
@@ -61,7 +61,7 @@ function patchUserModelForRbac(userModelPath) {
|
|
|
61
61
|
function patchMiddlewareAuthForRbac(middlewareAuthPath) {
|
|
62
62
|
let content = fs_extra_1.default.readFileSync(middlewareAuthPath, "utf8");
|
|
63
63
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:middleware-auth-keys", 'const RoleKey = "role"', 'RoleKey = "role"');
|
|
64
|
-
content = (
|
|
64
|
+
content = insertGoLineBeforeMarkerOnce(content, "// go-scaffold:middleware-auth-claims", 'Role string `json:"role,omitempty"`', /^\s*Role\s+string\s+`json:"role,omitempty"`/m);
|
|
65
65
|
content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:middleware-auth-context", "c.Set(RoleKey, claims.Role)", "c.Set(RoleKey");
|
|
66
66
|
fs_extra_1.default.writeFileSync(middlewareAuthPath, content);
|
|
67
67
|
}
|
|
@@ -72,11 +72,19 @@ function patchMiddlewareAuthForRbac(middlewareAuthPath) {
|
|
|
72
72
|
// specifically so a marker patch can extend either without reformatting.
|
|
73
73
|
function patchUserJWTForRbac(jwtGoPath) {
|
|
74
74
|
let content = fs_extra_1.default.readFileSync(jwtGoPath, "utf8");
|
|
75
|
-
content = (
|
|
76
|
-
content = (
|
|
77
|
-
content = (
|
|
75
|
+
content = insertGoLineBeforeMarkerOnce(content, "// go-scaffold:jwt-claims", 'Role string `json:"role,omitempty"`', /^\s*Role\s+string\s+`json:"role,omitempty"`/m);
|
|
76
|
+
content = insertGoLineBeforeMarkerOnce(content, "// go-scaffold:issue-access-token-params", "role string,", /^\s*role\s+string\s*,/m);
|
|
77
|
+
content = insertGoLineBeforeMarkerOnce(content, "// go-scaffold:jwt-claims-values", "Role: role,", /^\s*Role\s*:\s*role\s*,/m);
|
|
78
78
|
fs_extra_1.default.writeFileSync(jwtGoPath, content);
|
|
79
79
|
}
|
|
80
|
+
// Go fmt aligns struct fields and can turn a literal sentinel such as
|
|
81
|
+
// `Role string` into `Role string`. Match the Go line semantically so a
|
|
82
|
+
// subsequent `add rbac` cannot append a duplicate field after formatting.
|
|
83
|
+
function insertGoLineBeforeMarkerOnce(content, marker, block, line) {
|
|
84
|
+
if (line.test(content))
|
|
85
|
+
return content;
|
|
86
|
+
return (0, marker_patch_1.insertBeforeMarker)(content, marker, block);
|
|
87
|
+
}
|
|
80
88
|
// patchUserServiceForRbac verifies the auth application already exposes the
|
|
81
89
|
// optional role capability. The canonical auth template owns this shared user
|
|
82
90
|
// behavior; `add rbac` only supplies the role catalog and authorizer.
|
package/package.json
CHANGED
|
@@ -114,3 +114,22 @@ MeResponse:
|
|
|
114
114
|
email_verified: { type: boolean }
|
|
115
115
|
# go-scaffold:me-response-fields
|
|
116
116
|
created_at: { type: string, format: date-time }
|
|
117
|
+
|
|
118
|
+
SessionResponse:
|
|
119
|
+
type: object
|
|
120
|
+
required: [id, user_agent, created_at, last_used_at, expires_at, current]
|
|
121
|
+
properties:
|
|
122
|
+
id: { type: string, format: uuid }
|
|
123
|
+
user_agent: { type: string, description: browser or client User-Agent captured at sign-in }
|
|
124
|
+
created_at: { type: string, format: date-time }
|
|
125
|
+
last_used_at: { type: string, format: date-time }
|
|
126
|
+
expires_at: { type: string, format: date-time }
|
|
127
|
+
current: { type: boolean, description: true when this session issued the current access token }
|
|
128
|
+
|
|
129
|
+
SessionsResponse:
|
|
130
|
+
type: object
|
|
131
|
+
required: [sessions]
|
|
132
|
+
properties:
|
|
133
|
+
sessions:
|
|
134
|
+
type: array
|
|
135
|
+
items: { $ref: '#/SessionResponse' }
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
delete:
|
|
2
|
+
summary: Revoke one session for the current user
|
|
3
|
+
operationId: revokeMySession
|
|
4
|
+
tags: [users]
|
|
5
|
+
security: [{ bearerAuth: [] }]
|
|
6
|
+
parameters:
|
|
7
|
+
- name: id
|
|
8
|
+
in: path
|
|
9
|
+
required: true
|
|
10
|
+
schema: { type: string, format: uuid }
|
|
11
|
+
responses:
|
|
12
|
+
"204": { description: session revoked }
|
|
13
|
+
"400": { $ref: '../common/responses.yaml#/ValidationError' }
|
|
14
|
+
"401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
get:
|
|
2
|
+
summary: List active sessions for the current user
|
|
3
|
+
description: Returns active refresh-token sessions with safe device metadata. Raw refresh tokens and token hashes are never returned.
|
|
4
|
+
operationId: listMySessions
|
|
5
|
+
tags: [users]
|
|
6
|
+
security: [{ bearerAuth: [] }]
|
|
7
|
+
responses:
|
|
8
|
+
"200":
|
|
9
|
+
description: active sessions
|
|
10
|
+
content:
|
|
11
|
+
application/json:
|
|
12
|
+
schema: { $ref: './schemas.yaml#/SessionsResponse' }
|
|
13
|
+
"401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
|
|
@@ -52,6 +52,10 @@ func toLoginExchangeInput(in loginExchangeInput) userapp.LoginExchangeInput {
|
|
|
52
52
|
return userapp.LoginExchangeInput{Code: in.Code, State: in.State, CodeVerifier: in.CodeVerifier}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
func sessionContext(userAgent string) userapp.SessionContext {
|
|
56
|
+
return userapp.SessionContext{UserAgent: userAgent}
|
|
57
|
+
}
|
|
58
|
+
|
|
55
59
|
type forgotPasswordInput struct {
|
|
56
60
|
Email string `json:"email" binding:"required,email"`
|
|
57
61
|
}
|
|
@@ -99,6 +103,26 @@ type mfaConfirmResponse struct {
|
|
|
99
103
|
RecoveryCodes []string `json:"recovery_codes"`
|
|
100
104
|
}
|
|
101
105
|
|
|
106
|
+
type sessionResponse struct {
|
|
107
|
+
ID uuid.UUID `json:"id"`
|
|
108
|
+
UserAgent string `json:"user_agent"`
|
|
109
|
+
CreatedAt time.Time `json:"created_at"`
|
|
110
|
+
LastUsedAt time.Time `json:"last_used_at"`
|
|
111
|
+
ExpiresAt time.Time `json:"expires_at"`
|
|
112
|
+
Current bool `json:"current"`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type sessionsResponse struct {
|
|
116
|
+
Sessions []sessionResponse `json:"sessions"`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
func toSessionResponse(session userapp.Session) sessionResponse {
|
|
120
|
+
return sessionResponse{
|
|
121
|
+
ID: session.ID, UserAgent: session.UserAgent, CreatedAt: session.CreatedAt,
|
|
122
|
+
LastUsedAt: session.LastUsedAt, ExpiresAt: session.ExpiresAt, Current: session.Current,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
102
126
|
func toCookieResponse(auth *userapp.AuthResponse) authCookieResponse {
|
|
103
127
|
return authCookieResponse{AccessToken: auth.AccessToken, TokenType: auth.TokenType, ExpiresIn: auth.ExpiresIn}
|
|
104
128
|
}
|
|
@@ -132,6 +132,8 @@ func (h *Handler) Register(rg gin.IRouter) {
|
|
|
132
132
|
usersGroup.GET("/me", h.me)
|
|
133
133
|
usersGroup.POST("/me/resend-verification", resendVerificationLimit, h.resendVerification)
|
|
134
134
|
usersGroup.POST("/me/logout-all", h.logoutAll)
|
|
135
|
+
usersGroup.GET("/me/sessions", h.sessions)
|
|
136
|
+
usersGroup.DELETE("/me/sessions/:id", h.revokeSession)
|
|
135
137
|
usersGroup.GET("/me/mfa", h.mfaStatus)
|
|
136
138
|
usersGroup.POST("/me/mfa/setup", mfaSetupLimit, h.setupMFA)
|
|
137
139
|
usersGroup.POST("/me/mfa/confirm", mfaConfirmLimit, h.confirmMFA)
|
|
@@ -19,7 +19,9 @@ func (h *Handler) register(c *gin.Context) {
|
|
|
19
19
|
c.Error(httpx.BindErr(err))
|
|
20
20
|
return
|
|
21
21
|
}
|
|
22
|
-
|
|
22
|
+
input := toRegisterInput(in)
|
|
23
|
+
input.Session = sessionContext(c.GetHeader("User-Agent"))
|
|
24
|
+
auth, err := h.svc.Register(c.Request.Context(), input)
|
|
23
25
|
if err != nil {
|
|
24
26
|
c.Error(toHTTPError(err))
|
|
25
27
|
return
|
|
@@ -37,7 +39,9 @@ func (h *Handler) login(c *gin.Context) {
|
|
|
37
39
|
c.Error(httpx.BindErr(err))
|
|
38
40
|
return
|
|
39
41
|
}
|
|
40
|
-
|
|
42
|
+
input := toLoginInput(in)
|
|
43
|
+
input.Session = sessionContext(c.GetHeader("User-Agent"))
|
|
44
|
+
auth, err := h.svc.Login(c.Request.Context(), input)
|
|
41
45
|
if err != nil {
|
|
42
46
|
c.Error(toHTTPError(err))
|
|
43
47
|
return
|
|
@@ -81,3 +81,12 @@ func (h *Handler) disableMFA(c *gin.Context) {
|
|
|
81
81
|
func currentUserID(c *gin.Context) uuid.UUID {
|
|
82
82
|
return c.MustGet(middleware.UserIDKey).(uuid.UUID)
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
func currentSessionID(c *gin.Context) uuid.UUID {
|
|
86
|
+
if value, ok := c.Get(middleware.SessionIDKey); ok {
|
|
87
|
+
if sessionID, ok := value.(uuid.UUID); ok {
|
|
88
|
+
return sessionID
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return uuid.Nil
|
|
92
|
+
}
|
|
@@ -34,7 +34,9 @@ func (h *Handler) providerExchange(c *gin.Context) {
|
|
|
34
34
|
c.Error(h.oauthAppError(c.Param("provider"), userapp.NewOAuthError(userapp.OAuthFailed, err)))
|
|
35
35
|
return
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
input := toLoginExchangeInput(in)
|
|
38
|
+
input.Session = sessionContext(c.GetHeader("User-Agent"))
|
|
39
|
+
auth, err := h.svc.ExchangeLogin(c.Request.Context(), c.Param("provider"), input)
|
|
38
40
|
if err != nil {
|
|
39
41
|
c.Error(h.oauthAppError(c.Param("provider"), err))
|
|
40
42
|
return
|
|
@@ -151,6 +151,20 @@ func TestHandler_ProviderExchangeReturnsJSONAndSetsRefreshCookie(t *testing.T) {
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
func TestHandler_SessionRoutesAreProtected(t *testing.T) {
|
|
155
|
+
router := newOAuthTestRouter(t)
|
|
156
|
+
for _, route := range []struct{ method, path string }{
|
|
157
|
+
{http.MethodGet, "/users/me/sessions"},
|
|
158
|
+
{http.MethodDelete, "/users/me/sessions/00000000-0000-0000-0000-000000000001"},
|
|
159
|
+
} {
|
|
160
|
+
response := httptest.NewRecorder()
|
|
161
|
+
router.ServeHTTP(response, httptest.NewRequest(route.method, route.path, nil))
|
|
162
|
+
if response.Code != http.StatusUnauthorized {
|
|
163
|
+
t.Fatalf("%s %s status = %d, want 401", route.method, route.path, response.Code)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
154
168
|
func TestHandler_ProviderCallbackIsFrontendOwnedAndErrorsDoNotRedirect(t *testing.T) {
|
|
155
169
|
router := newOAuthTestRouter(t)
|
|
156
170
|
callback := httptest.NewRequest(http.MethodGet, "/auth/fake/callback?error=access_denied&error_description=do-not-leak", nil)
|
|
@@ -3,6 +3,7 @@ package httpadapter
|
|
|
3
3
|
import (
|
|
4
4
|
"net/http"
|
|
5
5
|
|
|
6
|
+
"{{goModule}}/internal/shared/httpx"
|
|
6
7
|
"{{goModule}}/internal/shared/middleware"
|
|
7
8
|
|
|
8
9
|
"github.com/gin-gonic/gin"
|
|
@@ -30,6 +31,35 @@ func (h *Handler) logoutAll(c *gin.Context) {
|
|
|
30
31
|
c.Status(http.StatusNoContent)
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
func (h *Handler) sessions(c *gin.Context) {
|
|
35
|
+
setNoStoreHeaders(c)
|
|
36
|
+
items, err := h.svc.ListSessions(c.Request.Context(), currentUserID(c), currentSessionID(c))
|
|
37
|
+
if err != nil {
|
|
38
|
+
c.Error(toHTTPError(err))
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
out := make([]sessionResponse, len(items))
|
|
42
|
+
for i := range items {
|
|
43
|
+
out[i] = toSessionResponse(items[i])
|
|
44
|
+
}
|
|
45
|
+
c.JSON(http.StatusOK, sessionsResponse{Sessions: out})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func (h *Handler) revokeSession(c *gin.Context) {
|
|
49
|
+
sessionID, ok := httpx.ParseID(c)
|
|
50
|
+
if !ok {
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
if err := h.svc.RevokeSession(c.Request.Context(), currentUserID(c), sessionID); err != nil {
|
|
54
|
+
c.Error(toHTTPError(err))
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
if sessionID == currentSessionID(c) {
|
|
58
|
+
h.clearRefreshCookie(c)
|
|
59
|
+
}
|
|
60
|
+
c.Status(http.StatusNoContent)
|
|
61
|
+
}
|
|
62
|
+
|
|
33
63
|
func (h *Handler) me(c *gin.Context) {
|
|
34
64
|
userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
|
|
35
65
|
u, err := h.svc.Get(c.Request.Context(), userID)
|
|
@@ -92,7 +92,7 @@ func (s *PostgresMFAStore) CreateChallenge(ctx context.Context, hash string, cha
|
|
|
92
92
|
if hash == "" || challenge.UserID == uuid.Nil || !challenge.ExpiresAt.After(time.Now()) {
|
|
93
93
|
return fmt.Errorf("MFA challenge is invalid")
|
|
94
94
|
}
|
|
95
|
-
row := MFAChallenge{ChallengeHash: hash, UserID: challenge.UserID, ExpiresAt: challenge.ExpiresAt}
|
|
95
|
+
row := MFAChallenge{ChallengeHash: hash, UserID: challenge.UserID, SessionID: challenge.SessionID, UserAgent: challenge.UserAgent, ExpiresAt: challenge.ExpiresAt}
|
|
96
96
|
return tx.From(ctx, s.db).WithContext(ctx).Create(&row).Error
|
|
97
97
|
}
|
|
98
98
|
|
|
@@ -101,8 +101,8 @@ func (s *PostgresMFAStore) ConsumeChallenge(ctx context.Context, hash string) (p
|
|
|
101
101
|
err := tx.From(ctx, s.db).WithContext(ctx).Raw(
|
|
102
102
|
`DELETE FROM user_svc.mfa_challenges
|
|
103
103
|
WHERE challenge_hash = ? AND expires_at > now()
|
|
104
|
-
RETURNING user_id, expires_at`, hash,
|
|
105
|
-
).Row().Scan(&challenge.UserID, &challenge.ExpiresAt)
|
|
104
|
+
RETURNING user_id, session_id, user_agent, expires_at`, hash,
|
|
105
|
+
).Row().Scan(&challenge.UserID, &challenge.SessionID, &challenge.UserAgent, &challenge.ExpiresAt)
|
|
106
106
|
if errors.Is(err, sql.ErrNoRows) {
|
|
107
107
|
return ports.MFAChallenge{}, false, nil
|
|
108
108
|
}
|
|
@@ -37,12 +37,15 @@ type AuthToken struct {
|
|
|
37
37
|
TokenHash string `gorm:"primaryKey;type:text"`
|
|
38
38
|
UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_auth_tokens_user_kind,priority:1"`
|
|
39
39
|
Kind string `gorm:"type:varchar(20);not null;index:idx_auth_tokens_user_kind,priority:2"`
|
|
40
|
+
SessionID uuid.UUID `gorm:"type:uuid;not null;index:idx_auth_tokens_user_session,priority:1"`
|
|
41
|
+
UserAgent string `gorm:"type:text;not null;default:''"`
|
|
40
42
|
ExpiresAt time.Time `gorm:"not null;index:idx_auth_tokens_expires_at"`
|
|
41
43
|
AbsoluteExpiresAt *time.Time `gorm:"index:idx_auth_tokens_absolute_expires_at"`
|
|
42
44
|
Provider string `gorm:"type:varchar(20);not null;default:''"`
|
|
43
45
|
CodeChallenge string `gorm:"type:text;not null;default:''"`
|
|
44
46
|
Nonce string `gorm:"type:text;not null;default:''"`
|
|
45
|
-
CreatedAt time.Time
|
|
47
|
+
CreatedAt time.Time `gorm:"not null"`
|
|
48
|
+
LastUsedAt time.Time `gorm:"not null;index:idx_auth_tokens_user_session,priority:3"`
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
func (AuthToken) TableName() string { return "user_svc.auth_tokens" }
|
|
@@ -69,6 +72,8 @@ func (MFAEnrollment) TableName() string { return "user_svc.mfa_enrollments" }
|
|
|
69
72
|
type MFAChallenge struct {
|
|
70
73
|
ChallengeHash string `gorm:"type:text;primaryKey"`
|
|
71
74
|
UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_mfa_challenges_user"`
|
|
75
|
+
SessionID uuid.UUID `gorm:"type:uuid;not null;default:'00000000-0000-0000-0000-000000000000'"`
|
|
76
|
+
UserAgent string `gorm:"type:text;not null;default:''"`
|
|
72
77
|
ExpiresAt time.Time `gorm:"not null;index:idx_mfa_challenges_expires_at"`
|
|
73
78
|
CreatedAt time.Time
|
|
74
79
|
}
|
package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs
CHANGED
|
@@ -66,16 +66,23 @@ func (s *PgTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, to
|
|
|
66
66
|
TokenHash: tokenHash,
|
|
67
67
|
UserID: token.UserID,
|
|
68
68
|
Kind: kindRefresh,
|
|
69
|
+
SessionID: token.SessionID,
|
|
70
|
+
UserAgent: token.UserAgent,
|
|
69
71
|
ExpiresAt: token.ExpiresAt,
|
|
70
72
|
AbsoluteExpiresAt: &token.AbsoluteExpiresAt,
|
|
73
|
+
CreatedAt: token.CreatedAt,
|
|
74
|
+
LastUsedAt: token.LastUsedAt,
|
|
71
75
|
}
|
|
72
76
|
return tx.From(ctx, s.db).WithContext(ctx).
|
|
73
77
|
Where("token_hash = ?", tokenHash).
|
|
74
78
|
Assign(map[string]any{
|
|
75
79
|
"user_id": token.UserID,
|
|
76
80
|
"kind": kindRefresh,
|
|
81
|
+
"session_id": token.SessionID,
|
|
82
|
+
"user_agent": token.UserAgent,
|
|
77
83
|
"expires_at": token.ExpiresAt,
|
|
78
84
|
"absolute_expires_at": token.AbsoluteExpiresAt,
|
|
85
|
+
"last_used_at": token.LastUsedAt,
|
|
79
86
|
"provider": "",
|
|
80
87
|
"code_challenge": "",
|
|
81
88
|
"nonce": "",
|
|
@@ -96,12 +103,15 @@ func (s *PgTokenStore) ConsumeRefreshToken(ctx context.Context, tokenHash string
|
|
|
96
103
|
DELETE FROM user_svc.auth_tokens
|
|
97
104
|
WHERE token_hash = ? AND kind = ? AND expires_at > now()
|
|
98
105
|
AND (absolute_expires_at IS NULL OR absolute_expires_at > now())
|
|
99
|
-
|
|
106
|
+
RETURNING user_id, session_id, user_agent, created_at, last_used_at, expires_at, absolute_expires_at
|
|
100
107
|
)
|
|
101
|
-
INSERT INTO user_svc.auth_tokens (token_hash, user_id, kind, expires_at, absolute_expires_at)
|
|
102
|
-
SELECT ?, user_id, ?, expires_at, COALESCE(absolute_expires_at, expires_at) FROM consumed
|
|
103
|
-
RETURNING user_id, expires_at, absolute_expires_at`,
|
|
104
|
-
tokenHash, kindRefresh, tokenHash, kindRefreshUsed).Row().Scan(
|
|
108
|
+
INSERT INTO user_svc.auth_tokens (token_hash, user_id, kind, session_id, user_agent, expires_at, absolute_expires_at, created_at, last_used_at)
|
|
109
|
+
SELECT ?, user_id, ?, session_id, user_agent, expires_at, COALESCE(absolute_expires_at, expires_at), created_at, last_used_at FROM consumed
|
|
110
|
+
RETURNING user_id, session_id, user_agent, created_at, last_used_at, expires_at, absolute_expires_at`,
|
|
111
|
+
tokenHash, kindRefresh, tokenHash, kindRefreshUsed).Row().Scan(
|
|
112
|
+
&token.UserID, &token.SessionID, &token.UserAgent, &token.CreatedAt, &token.LastUsedAt,
|
|
113
|
+
&token.ExpiresAt, &token.AbsoluteExpiresAt,
|
|
114
|
+
)
|
|
105
115
|
if errors.Is(err, sql.ErrNoRows) {
|
|
106
116
|
return ports.RefreshTokenRecord{}, false, nil
|
|
107
117
|
}
|
|
@@ -129,6 +139,33 @@ func (s *PgTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.U
|
|
|
129
139
|
Delete(&AuthToken{}).Error
|
|
130
140
|
}
|
|
131
141
|
|
|
142
|
+
func (s *PgTokenStore) ListRefreshSessions(ctx context.Context, userID uuid.UUID) ([]ports.RefreshSession, error) {
|
|
143
|
+
var rows []AuthToken
|
|
144
|
+
err := tx.From(ctx, s.db).WithContext(ctx).
|
|
145
|
+
Where("user_id = ? AND kind = ? AND expires_at > now() AND (absolute_expires_at IS NULL OR absolute_expires_at > now())", userID, kindRefresh).
|
|
146
|
+
Order("last_used_at DESC, created_at DESC").Find(&rows).Error
|
|
147
|
+
if err != nil {
|
|
148
|
+
return nil, err
|
|
149
|
+
}
|
|
150
|
+
out := make([]ports.RefreshSession, 0, len(rows))
|
|
151
|
+
for _, row := range rows {
|
|
152
|
+
out = append(out, ports.RefreshSession{
|
|
153
|
+
ID: row.SessionID, UserAgent: row.UserAgent, CreatedAt: row.CreatedAt,
|
|
154
|
+
LastUsedAt: row.LastUsedAt, ExpiresAt: row.ExpiresAt,
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
return out, nil
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
func (s *PgTokenStore) RevokeRefreshSession(ctx context.Context, userID, sessionID uuid.UUID) error {
|
|
161
|
+
if userID == uuid.Nil || sessionID == uuid.Nil {
|
|
162
|
+
return fmt.Errorf("session identity is invalid")
|
|
163
|
+
}
|
|
164
|
+
return tx.From(ctx, s.db).WithContext(ctx).
|
|
165
|
+
Where("user_id = ? AND kind = ? AND session_id = ?", userID, kindRefresh, sessionID).
|
|
166
|
+
Delete(&AuthToken{}).Error
|
|
167
|
+
}
|
|
168
|
+
|
|
132
169
|
func (s *PgTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
133
170
|
return s.lookup(ctx, tokenHash, kindRefreshUsed)
|
|
134
171
|
}
|
package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs
CHANGED
|
@@ -101,3 +101,39 @@ func refreshTokenRecordForTest(userID uuid.UUID) ports.RefreshTokenRecord {
|
|
|
101
101
|
now := time.Now()
|
|
102
102
|
return ports.RefreshTokenRecord{UserID: userID, ExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(24 * time.Hour)}
|
|
103
103
|
}
|
|
104
|
+
|
|
105
|
+
func TestPgTokenStore_ListAndRevokeRefreshSessions(t *testing.T) {
|
|
106
|
+
db := tokenStoreDBForTest(t)
|
|
107
|
+
store := NewPgTokenStore(db)
|
|
108
|
+
ctx := context.Background()
|
|
109
|
+
userID := uuid.New()
|
|
110
|
+
currentID := uuid.New()
|
|
111
|
+
remoteID := uuid.New()
|
|
112
|
+
now := time.Now()
|
|
113
|
+
hashes := []string{"real-pg-session-current-" + uuid.NewString(), "real-pg-session-remote-" + uuid.NewString()}
|
|
114
|
+
for i, sessionID := range []uuid.UUID{currentID, remoteID} {
|
|
115
|
+
if err := store.SetRefreshToken(ctx, hashes[i], ports.RefreshTokenRecord{
|
|
116
|
+
UserID: userID, SessionID: sessionID, UserAgent: "test-device",
|
|
117
|
+
CreatedAt: now.Add(-time.Hour), LastUsedAt: now.Add(-time.Duration(i) * time.Minute),
|
|
118
|
+
ExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(24 * time.Hour),
|
|
119
|
+
}); err != nil {
|
|
120
|
+
t.Fatalf("seed session %d: %v", i, err)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
t.Cleanup(func() { _ = db.Exec("DELETE FROM user_svc.auth_tokens WHERE token_hash IN ?", hashes).Error })
|
|
124
|
+
|
|
125
|
+
sessions, err := store.ListRefreshSessions(ctx, userID)
|
|
126
|
+
if err != nil || len(sessions) != 2 {
|
|
127
|
+
t.Fatalf("list sessions = %+v, err=%v", sessions, err)
|
|
128
|
+
}
|
|
129
|
+
if sessions[0].ID != currentID || sessions[0].UserAgent != "test-device" {
|
|
130
|
+
t.Fatalf("sessions were not ordered/decoded correctly: %+v", sessions)
|
|
131
|
+
}
|
|
132
|
+
if err := store.RevokeRefreshSession(ctx, userID, remoteID); err != nil {
|
|
133
|
+
t.Fatalf("revoke session: %v", err)
|
|
134
|
+
}
|
|
135
|
+
sessions, err = store.ListRefreshSessions(ctx, userID)
|
|
136
|
+
if err != nil || len(sessions) != 1 || sessions[0].ID != currentID {
|
|
137
|
+
t.Fatalf("sessions after revoke = %+v, err=%v", sessions, err)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -76,6 +76,48 @@ redis.call("DEL", KEYS[1])
|
|
|
76
76
|
return #hashes
|
|
77
77
|
`)
|
|
78
78
|
|
|
79
|
+
var listRefreshSessionsScript = redis.NewScript(`
|
|
80
|
+
local sessions = {}
|
|
81
|
+
local hashes = redis.call("SMEMBERS", KEYS[1])
|
|
82
|
+
for _, hash in ipairs(hashes) do
|
|
83
|
+
local key = KEYS[2] .. hash
|
|
84
|
+
local raw = redis.call("GET", key)
|
|
85
|
+
local ttl = redis.call("PTTL", key)
|
|
86
|
+
if not raw or ttl < 1 then
|
|
87
|
+
redis.call("SREM", KEYS[1], hash)
|
|
88
|
+
else
|
|
89
|
+
local decoded, token_data = pcall(cjson.decode, raw)
|
|
90
|
+
if decoded and type(token_data) == "table" and token_data.user_id == ARGV[1] and
|
|
91
|
+
type(token_data.session_id) == "string" and token_data.session_id ~= "" then
|
|
92
|
+
table.insert(sessions, token_data)
|
|
93
|
+
elseif not decoded or type(token_data) ~= "table" or token_data.user_id ~= ARGV[1] then
|
|
94
|
+
redis.call("SREM", KEYS[1], hash)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
return cjson.encode(sessions)
|
|
99
|
+
`)
|
|
100
|
+
|
|
101
|
+
var revokeRefreshSessionScript = redis.NewScript(`
|
|
102
|
+
local hashes = redis.call("SMEMBERS", KEYS[1])
|
|
103
|
+
local revoked = 0
|
|
104
|
+
for _, hash in ipairs(hashes) do
|
|
105
|
+
local key = KEYS[2] .. hash
|
|
106
|
+
local raw = redis.call("GET", key)
|
|
107
|
+
if raw then
|
|
108
|
+
local decoded, token_data = pcall(cjson.decode, raw)
|
|
109
|
+
if decoded and type(token_data) == "table" and token_data.user_id == ARGV[1] and token_data.session_id == ARGV[2] then
|
|
110
|
+
redis.call("DEL", key)
|
|
111
|
+
redis.call("SREM", KEYS[1], hash)
|
|
112
|
+
revoked = revoked + 1
|
|
113
|
+
end
|
|
114
|
+
else
|
|
115
|
+
redis.call("SREM", KEYS[1], hash)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
return revoked
|
|
119
|
+
`)
|
|
120
|
+
|
|
79
121
|
func (s *RedisTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, token ports.RefreshTokenRecord) error {
|
|
80
122
|
ttl := time.Until(token.ExpiresAt)
|
|
81
123
|
if token.UserID == uuid.Nil || ttl <= 0 || token.AbsoluteExpiresAt.IsZero() || token.AbsoluteExpiresAt.Before(token.ExpiresAt) {
|
|
@@ -151,6 +193,39 @@ func (s *RedisTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uui
|
|
|
151
193
|
return err
|
|
152
194
|
}
|
|
153
195
|
|
|
196
|
+
func (s *RedisTokenStore) ListRefreshSessions(ctx context.Context, userID uuid.UUID) ([]ports.RefreshSession, error) {
|
|
197
|
+
result, err := listRefreshSessionsScript.Run(ctx, s.rdb,
|
|
198
|
+
[]string{refreshUserKeyPrefix + userID.String(), refreshKeyPrefix}, userID.String()).Result()
|
|
199
|
+
if err != nil {
|
|
200
|
+
return nil, err
|
|
201
|
+
}
|
|
202
|
+
raw, ok := result.(string)
|
|
203
|
+
if !ok || raw == "" {
|
|
204
|
+
return []ports.RefreshSession{}, nil
|
|
205
|
+
}
|
|
206
|
+
var tokens []ports.RefreshTokenRecord
|
|
207
|
+
if err := json.Unmarshal([]byte(raw), &tokens); err != nil {
|
|
208
|
+
return nil, fmt.Errorf("decode refresh sessions: %w", err)
|
|
209
|
+
}
|
|
210
|
+
out := make([]ports.RefreshSession, 0, len(tokens))
|
|
211
|
+
for _, token := range tokens {
|
|
212
|
+
out = append(out, ports.RefreshSession{
|
|
213
|
+
ID: token.SessionID, UserAgent: token.UserAgent, CreatedAt: token.CreatedAt,
|
|
214
|
+
LastUsedAt: token.LastUsedAt, ExpiresAt: token.ExpiresAt,
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
return out, nil
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
func (s *RedisTokenStore) RevokeRefreshSession(ctx context.Context, userID, sessionID uuid.UUID) error {
|
|
221
|
+
if userID == uuid.Nil || sessionID == uuid.Nil {
|
|
222
|
+
return fmt.Errorf("session identity is invalid")
|
|
223
|
+
}
|
|
224
|
+
_, err := revokeRefreshSessionScript.Run(ctx, s.rdb,
|
|
225
|
+
[]string{refreshUserKeyPrefix + userID.String(), refreshKeyPrefix}, userID.String(), sessionID.String()).Result()
|
|
226
|
+
return err
|
|
227
|
+
}
|
|
228
|
+
|
|
154
229
|
func (s *RedisTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
155
230
|
raw, err := s.rdb.Get(ctx, refreshUsedKeyPrefix+tokenHash).Result()
|
|
156
231
|
if err == redis.Nil {
|
|
@@ -70,6 +70,42 @@ func TestRefreshTokenRecord_JSONUsesLuaFieldNames(t *testing.T) {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
func TestRedisTokenStore_ListAndRevokeRefreshSessions(t *testing.T) {
|
|
74
|
+
client := redisClientForTest(t)
|
|
75
|
+
store := NewRedisTokenStore(client, nil)
|
|
76
|
+
ctx := context.Background()
|
|
77
|
+
userID := uuid.New()
|
|
78
|
+
currentID := uuid.New()
|
|
79
|
+
remoteID := uuid.New()
|
|
80
|
+
current := testRefreshTokenRecord(userID)
|
|
81
|
+
current.SessionID = currentID
|
|
82
|
+
current.UserAgent = "current-device"
|
|
83
|
+
remote := testRefreshTokenRecord(userID)
|
|
84
|
+
remote.SessionID = remoteID
|
|
85
|
+
remote.UserAgent = "remote-device"
|
|
86
|
+
currentHash := "real-redis-session-current-" + uuid.NewString()
|
|
87
|
+
remoteHash := "real-redis-session-remote-" + uuid.NewString()
|
|
88
|
+
if err := store.SetRefreshToken(ctx, currentHash, current); err != nil {
|
|
89
|
+
t.Fatalf("seed current session: %v", err)
|
|
90
|
+
}
|
|
91
|
+
if err := store.SetRefreshToken(ctx, remoteHash, remote); err != nil {
|
|
92
|
+
t.Fatalf("seed remote session: %v", err)
|
|
93
|
+
}
|
|
94
|
+
t.Cleanup(func() { _ = store.RevokeAllRefreshTokens(ctx, userID) })
|
|
95
|
+
|
|
96
|
+
sessions, err := store.ListRefreshSessions(ctx, userID)
|
|
97
|
+
if err != nil || len(sessions) != 2 {
|
|
98
|
+
t.Fatalf("list sessions = %+v, err=%v", sessions, err)
|
|
99
|
+
}
|
|
100
|
+
if err := store.RevokeRefreshSession(ctx, userID, remoteID); err != nil {
|
|
101
|
+
t.Fatalf("revoke session: %v", err)
|
|
102
|
+
}
|
|
103
|
+
sessions, err = store.ListRefreshSessions(ctx, userID)
|
|
104
|
+
if err != nil || len(sessions) != 1 || sessions[0].ID != currentID {
|
|
105
|
+
t.Fatalf("sessions after revoke = %+v, err=%v", sessions, err)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
73
109
|
func TestRedisTokenStore_ConsumeRefreshToken_ConcurrentRealRedis(t *testing.T) {
|
|
74
110
|
client := redisClientForTest(t)
|
|
75
111
|
store := NewRedisTokenStore(client, nil)
|
|
@@ -12,17 +12,35 @@ type RegisterInput struct {
|
|
|
12
12
|
Email string
|
|
13
13
|
Password string
|
|
14
14
|
Name string
|
|
15
|
+
Session SessionContext
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
type LoginInput struct {
|
|
18
19
|
Email string
|
|
19
20
|
Password string
|
|
21
|
+
Session SessionContext
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
type LoginExchangeInput struct {
|
|
23
25
|
Code string
|
|
24
26
|
State string
|
|
25
27
|
CodeVerifier string
|
|
28
|
+
Session SessionContext
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type SessionContext struct {
|
|
32
|
+
ID uuid.UUID
|
|
33
|
+
UserAgent string
|
|
34
|
+
CreatedAt time.Time
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Session struct {
|
|
38
|
+
ID uuid.UUID
|
|
39
|
+
UserAgent string
|
|
40
|
+
CreatedAt time.Time
|
|
41
|
+
LastUsedAt time.Time
|
|
42
|
+
ExpiresAt time.Time
|
|
43
|
+
Current bool
|
|
26
44
|
}
|
|
27
45
|
|
|
28
46
|
type AuthResponse struct {
|
|
@@ -86,7 +86,7 @@ func (s *Service) ExchangeLogin(ctx context.Context, providerName string, in Log
|
|
|
86
86
|
if err != nil {
|
|
87
87
|
return nil, NewOAuthError(OAuthFailed, fmt.Errorf("resolve external identity: %w", err))
|
|
88
88
|
}
|
|
89
|
-
return s.completeLogin(ctx, u)
|
|
89
|
+
return s.completeLogin(ctx, u, in.Session)
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
func validPKCEVerifier(verifier, challenge string) bool {
|
|
@@ -16,8 +16,9 @@ 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
|
|
20
|
-
Role
|
|
19
|
+
Typ string `json:"typ"`
|
|
20
|
+
Role string `json:"role,omitempty"`
|
|
21
|
+
SessionID uuid.UUID `json:"sid,omitempty"`
|
|
21
22
|
// go-scaffold:jwt-claims
|
|
22
23
|
jwt.RegisteredClaims
|
|
23
24
|
}
|
|
@@ -25,12 +26,14 @@ type accessClaims struct {
|
|
|
25
26
|
func (s *Service) issueAccessToken(
|
|
26
27
|
userID uuid.UUID,
|
|
27
28
|
role string,
|
|
29
|
+
sessionID uuid.UUID,
|
|
28
30
|
// go-scaffold:issue-access-token-params
|
|
29
31
|
) (string, error) {
|
|
30
32
|
now := s.clock()
|
|
31
33
|
claims := accessClaims{
|
|
32
34
|
Typ: tokenTypeAccess,
|
|
33
35
|
Role: role,
|
|
36
|
+
SessionID: sessionID,
|
|
34
37
|
// go-scaffold:jwt-claims-values
|
|
35
38
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
36
39
|
Subject: userID.String(),
|
|
@@ -55,7 +55,7 @@ func (s *Service) Register(ctx context.Context, in RegisterInput) (*AuthResult,
|
|
|
55
55
|
// Best-effort: a mail failure should not block registration. The user can
|
|
56
56
|
// request another link through ResendVerificationEmail.
|
|
57
57
|
s.sendVerificationEmail(ctx, u)
|
|
58
|
-
auth, err := s.issueTokens(ctx, u)
|
|
58
|
+
auth, err := s.issueTokens(ctx, u, in.Session)
|
|
59
59
|
if err != nil {
|
|
60
60
|
return nil, err
|
|
61
61
|
}
|
|
@@ -92,5 +92,5 @@ func (s *Service) Login(ctx context.Context, in LoginInput) (*AuthResult, error)
|
|
|
92
92
|
if err := s.repo.ClearLoginFailures(ctx, key); err != nil {
|
|
93
93
|
slog.Error("clear login failures", "error", err)
|
|
94
94
|
}
|
|
95
|
-
return s.completeLogin(ctx, u)
|
|
95
|
+
return s.completeLogin(ctx, u, in.Session)
|
|
96
96
|
}
|
|
@@ -86,10 +86,17 @@ func ValidateMFASettings(settings MFASettings) error {
|
|
|
86
86
|
return nil
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
func (s *Service) completeLogin(ctx context.Context, u *domain.User) (*AuthResult, error) {
|
|
89
|
+
func (s *Service) completeLogin(ctx context.Context, u *domain.User, sessions ...SessionContext) (*AuthResult, error) {
|
|
90
|
+
var session SessionContext
|
|
91
|
+
if len(sessions) > 0 {
|
|
92
|
+
session = sessions[0]
|
|
93
|
+
}
|
|
94
|
+
if session.ID == uuid.Nil {
|
|
95
|
+
session.ID = uuid.New()
|
|
96
|
+
}
|
|
90
97
|
settings := s.mfaSettings()
|
|
91
98
|
if !settings.Enabled {
|
|
92
|
-
auth, err := s.issueTokens(ctx, u)
|
|
99
|
+
auth, err := s.issueTokens(ctx, u, session)
|
|
93
100
|
if err != nil {
|
|
94
101
|
return nil, err
|
|
95
102
|
}
|
|
@@ -103,7 +110,7 @@ func (s *Service) completeLogin(ctx context.Context, u *domain.User) (*AuthResul
|
|
|
103
110
|
return nil, fmt.Errorf("read MFA enrollment: %w", err)
|
|
104
111
|
}
|
|
105
112
|
if !found || !enrollment.Enabled {
|
|
106
|
-
auth, err := s.issueTokens(ctx, u)
|
|
113
|
+
auth, err := s.issueTokens(ctx, u, session)
|
|
107
114
|
if err != nil {
|
|
108
115
|
return nil, err
|
|
109
116
|
}
|
|
@@ -116,6 +123,8 @@ func (s *Service) completeLogin(ctx context.Context, u *domain.User) (*AuthResul
|
|
|
116
123
|
}
|
|
117
124
|
if err := s.mfa.CreateChallenge(ctx, hashToken(rawChallenge), MFAChallenge{
|
|
118
125
|
UserID: u.ID,
|
|
126
|
+
SessionID: session.ID,
|
|
127
|
+
UserAgent: normalizeUserAgent(session.UserAgent),
|
|
119
128
|
ExpiresAt: s.clock().Add(settings.ChallengeTTL),
|
|
120
129
|
}); err != nil {
|
|
121
130
|
return nil, fmt.Errorf("store MFA challenge: %w", err)
|
|
@@ -285,7 +294,7 @@ func (s *Service) VerifyMFA(ctx context.Context, rawChallenge, code string) (*Au
|
|
|
285
294
|
if err != nil {
|
|
286
295
|
return nil, errInvalidCredentials()
|
|
287
296
|
}
|
|
288
|
-
return s.issueTokens(ctx, u)
|
|
297
|
+
return s.issueTokens(ctx, u, SessionContext{ID: challenge.SessionID, UserAgent: challenge.UserAgent})
|
|
289
298
|
}
|
|
290
299
|
|
|
291
300
|
func (s *Service) requireMFA() (MFASettings, error) {
|
|
@@ -124,6 +124,8 @@ type ServicePort interface {
|
|
|
124
124
|
Refresh(context.Context, string) (*AuthResponse, error)
|
|
125
125
|
Logout(context.Context, string) error
|
|
126
126
|
LogoutAll(context.Context, uuid.UUID) error
|
|
127
|
+
ListSessions(context.Context, uuid.UUID, uuid.UUID) ([]Session, error)
|
|
128
|
+
RevokeSession(context.Context, uuid.UUID, uuid.UUID) error
|
|
127
129
|
ForgotPassword(context.Context, string) error
|
|
128
130
|
ResetPassword(context.Context, string, string) error
|
|
129
131
|
VerifyEmail(context.Context, string) error
|
|
@@ -102,6 +102,32 @@ func (f *fakeTokenStore) RevokeAllRefreshTokens(_ context.Context, userID uuid.U
|
|
|
102
102
|
delete(f.sessions, userID)
|
|
103
103
|
return nil
|
|
104
104
|
}
|
|
105
|
+
func (f *fakeTokenStore) ListRefreshSessions(_ context.Context, userID uuid.UUID) ([]RefreshSession, error) {
|
|
106
|
+
f.mu.Lock()
|
|
107
|
+
defer f.mu.Unlock()
|
|
108
|
+
out := make([]RefreshSession, 0)
|
|
109
|
+
for _, token := range f.active {
|
|
110
|
+
if token.UserID != userID || !token.ExpiresAt.After(time.Now()) {
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
out = append(out, RefreshSession{
|
|
114
|
+
ID: token.SessionID, UserAgent: token.UserAgent, CreatedAt: token.CreatedAt,
|
|
115
|
+
LastUsedAt: token.LastUsedAt, ExpiresAt: token.ExpiresAt,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
return out, nil
|
|
119
|
+
}
|
|
120
|
+
func (f *fakeTokenStore) RevokeRefreshSession(_ context.Context, userID, sessionID uuid.UUID) error {
|
|
121
|
+
f.mu.Lock()
|
|
122
|
+
defer f.mu.Unlock()
|
|
123
|
+
for hash, token := range f.active {
|
|
124
|
+
if token.UserID == userID && token.SessionID == sessionID {
|
|
125
|
+
delete(f.active, hash)
|
|
126
|
+
delete(f.sessions[userID], hash)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return nil
|
|
130
|
+
}
|
|
105
131
|
func (f *fakeTokenStore) IsRefreshTokenUsed(_ context.Context, hash string) (uuid.UUID, bool, error) {
|
|
106
132
|
f.mu.Lock()
|
|
107
133
|
defer f.mu.Unlock()
|
|
@@ -552,8 +578,10 @@ func TestService_Refresh_DoesNotExtendTheAbsoluteLifetime(t *testing.T) {
|
|
|
552
578
|
raw := "bounded-refresh-token"
|
|
553
579
|
issuedAt := time.Now()
|
|
554
580
|
absoluteExpiry := issuedAt.Add(2 * time.Hour)
|
|
581
|
+
sessionID := uuid.New()
|
|
555
582
|
if err := tokens.SetRefreshToken(ctx, hashToken(raw), RefreshTokenRecord{
|
|
556
|
-
UserID: userID,
|
|
583
|
+
UserID: userID, SessionID: sessionID, UserAgent: "test-device",
|
|
584
|
+
CreatedAt: issuedAt.Add(-time.Hour), LastUsedAt: issuedAt.Add(-time.Minute),
|
|
557
585
|
ExpiresAt: issuedAt.Add(time.Hour),
|
|
558
586
|
AbsoluteExpiresAt: absoluteExpiry,
|
|
559
587
|
}); err != nil {
|
|
@@ -576,6 +604,9 @@ func TestService_Refresh_DoesNotExtendTheAbsoluteLifetime(t *testing.T) {
|
|
|
576
604
|
if !rotated.ExpiresAt.Equal(absoluteExpiry) {
|
|
577
605
|
t.Fatalf("rotated inactivity expiry was not capped at the absolute expiry: got %s want %s", rotated.ExpiresAt, absoluteExpiry)
|
|
578
606
|
}
|
|
607
|
+
if rotated.SessionID != sessionID || rotated.UserAgent != "test-device" {
|
|
608
|
+
t.Fatalf("refresh rotation changed session identity/metadata: got id=%s ua=%q", rotated.SessionID, rotated.UserAgent)
|
|
609
|
+
}
|
|
579
610
|
}
|
|
580
611
|
|
|
581
612
|
func TestService_Refresh_ReuseRevokeFailureFailsClosed(t *testing.T) {
|
|
@@ -3,6 +3,8 @@ package application
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
5
|
"fmt"
|
|
6
|
+
"sort"
|
|
7
|
+
"strings"
|
|
6
8
|
"time"
|
|
7
9
|
|
|
8
10
|
"{{goModule}}/internal/app/user/domain"
|
|
@@ -36,7 +38,9 @@ func (s *Service) Refresh(ctx context.Context, rawRefreshToken string) (*AuthRes
|
|
|
36
38
|
if err != nil {
|
|
37
39
|
return nil, errInvalidToken()
|
|
38
40
|
}
|
|
39
|
-
return s.issueTokens(ctx, u,
|
|
41
|
+
return s.issueTokens(ctx, u, SessionContext{
|
|
42
|
+
ID: token.SessionID, UserAgent: token.UserAgent, CreatedAt: token.CreatedAt,
|
|
43
|
+
}, token.AbsoluteExpiresAt)
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
// Logout is intentional, not reuse: a missing or already-gone token still
|
|
@@ -58,10 +62,51 @@ func (s *Service) LogoutAll(ctx context.Context, userID uuid.UUID) error {
|
|
|
58
62
|
return s.refreshTokens.RevokeAllRefreshTokens(ctx, userID)
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
func (s *Service)
|
|
65
|
+
func (s *Service) ListSessions(ctx context.Context, userID, currentSessionID uuid.UUID) ([]Session, error) {
|
|
66
|
+
items, err := s.refreshTokens.ListRefreshSessions(ctx, userID)
|
|
67
|
+
if err != nil {
|
|
68
|
+
return nil, fmt.Errorf("list refresh sessions: %w", err)
|
|
69
|
+
}
|
|
70
|
+
out := make([]Session, 0, len(items))
|
|
71
|
+
for _, item := range items {
|
|
72
|
+
out = append(out, Session{
|
|
73
|
+
ID: item.ID, UserAgent: item.UserAgent, CreatedAt: item.CreatedAt,
|
|
74
|
+
LastUsedAt: item.LastUsedAt, ExpiresAt: item.ExpiresAt,
|
|
75
|
+
Current: item.ID == currentSessionID,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
sort.SliceStable(out, func(i, j int) bool {
|
|
79
|
+
return out[i].LastUsedAt.After(out[j].LastUsedAt)
|
|
80
|
+
})
|
|
81
|
+
return out, nil
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func (s *Service) RevokeSession(ctx context.Context, userID, sessionID uuid.UUID) error {
|
|
85
|
+
if userID == uuid.Nil || sessionID == uuid.Nil {
|
|
86
|
+
return fmt.Errorf("session identity is invalid")
|
|
87
|
+
}
|
|
88
|
+
return s.refreshTokens.RevokeRefreshSession(ctx, userID, sessionID)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func (s *Service) issueTokens(ctx context.Context, u *domain.User, session SessionContext, absoluteExpiresAt ...time.Time) (*AuthResponse, error) {
|
|
92
|
+
clock := s.now
|
|
93
|
+
if clock == nil {
|
|
94
|
+
clock = time.Now
|
|
95
|
+
}
|
|
96
|
+
now := clock()
|
|
97
|
+
if session.ID == uuid.Nil {
|
|
98
|
+
session.ID = uuid.New()
|
|
99
|
+
}
|
|
100
|
+
if session.CreatedAt.IsZero() {
|
|
101
|
+
session.CreatedAt = now
|
|
102
|
+
}
|
|
103
|
+
if session.UserAgent = normalizeUserAgent(session.UserAgent); session.UserAgent == "" {
|
|
104
|
+
session.UserAgent = "unknown"
|
|
105
|
+
}
|
|
62
106
|
access, err := s.issueAccessToken(
|
|
63
107
|
u.ID,
|
|
64
108
|
u.Role,
|
|
109
|
+
session.ID,
|
|
65
110
|
// go-scaffold:issue-access-token-args
|
|
66
111
|
)
|
|
67
112
|
if err != nil {
|
|
@@ -71,11 +116,6 @@ func (s *Service) issueTokens(ctx context.Context, u *domain.User, absoluteExpir
|
|
|
71
116
|
if err != nil {
|
|
72
117
|
return nil, fmt.Errorf("generate refresh token: %w", err)
|
|
73
118
|
}
|
|
74
|
-
clock := s.now
|
|
75
|
-
if clock == nil {
|
|
76
|
-
clock = time.Now
|
|
77
|
-
}
|
|
78
|
-
now := clock()
|
|
79
119
|
abs := now.Add(s.config.JWTRefreshMaxTTL)
|
|
80
120
|
if len(absoluteExpiresAt) > 0 && !absoluteExpiresAt[0].IsZero() {
|
|
81
121
|
abs = absoluteExpiresAt[0]
|
|
@@ -87,7 +127,11 @@ func (s *Service) issueTokens(ctx context.Context, u *domain.User, absoluteExpir
|
|
|
87
127
|
if !expiresAt.After(now) || !abs.After(now) {
|
|
88
128
|
return nil, fmt.Errorf("refresh token lifetime is exhausted")
|
|
89
129
|
}
|
|
90
|
-
if err := s.refreshTokens.SetRefreshToken(ctx, hashToken(refresh), RefreshTokenRecord{
|
|
130
|
+
if err := s.refreshTokens.SetRefreshToken(ctx, hashToken(refresh), RefreshTokenRecord{
|
|
131
|
+
UserID: u.ID, SessionID: session.ID, UserAgent: session.UserAgent,
|
|
132
|
+
CreatedAt: session.CreatedAt, LastUsedAt: now, ExpiresAt: expiresAt,
|
|
133
|
+
AbsoluteExpiresAt: abs,
|
|
134
|
+
}); err != nil {
|
|
91
135
|
return nil, fmt.Errorf("store refresh token: %w", err)
|
|
92
136
|
}
|
|
93
137
|
return &AuthResponse{
|
|
@@ -97,3 +141,13 @@ func (s *Service) issueTokens(ctx context.Context, u *domain.User, absoluteExpir
|
|
|
97
141
|
ExpiresIn: int(s.config.JWTAccessTTL.Seconds()),
|
|
98
142
|
}, nil
|
|
99
143
|
}
|
|
144
|
+
|
|
145
|
+
const maxUserAgentLength = 512
|
|
146
|
+
|
|
147
|
+
func normalizeUserAgent(value string) string {
|
|
148
|
+
value = strings.TrimSpace(value)
|
|
149
|
+
if len(value) > maxUserAgentLength {
|
|
150
|
+
return value[:maxUserAgentLength]
|
|
151
|
+
}
|
|
152
|
+
return value
|
|
153
|
+
}
|
|
@@ -8,6 +8,7 @@ type RefreshTokenStore = ports.RefreshTokenStore
|
|
|
8
8
|
type OAuthTransactionStore = ports.OAuthTransactionStore
|
|
9
9
|
type RecoveryTokenStore = ports.RecoveryTokenStore
|
|
10
10
|
type RefreshTokenRecord = ports.RefreshTokenRecord
|
|
11
|
+
type RefreshSession = ports.RefreshSession
|
|
11
12
|
type LoginTransaction = ports.LoginTransaction
|
|
12
13
|
type MFAEnrollment = ports.MFAEnrollment
|
|
13
14
|
type MFAChallenge = ports.MFAChallenge
|
|
@@ -36,16 +36,30 @@ type RoleChecker interface {
|
|
|
36
36
|
|
|
37
37
|
type RefreshTokenRecord struct {
|
|
38
38
|
UserID uuid.UUID `json:"user_id"`
|
|
39
|
+
SessionID uuid.UUID `json:"session_id"`
|
|
40
|
+
UserAgent string `json:"user_agent"`
|
|
41
|
+
CreatedAt time.Time `json:"created_at"`
|
|
42
|
+
LastUsedAt time.Time `json:"last_used_at"`
|
|
39
43
|
ExpiresAt time.Time `json:"expires_at"`
|
|
40
44
|
AbsoluteExpiresAt time.Time `json:"absolute_expires_at"`
|
|
41
45
|
}
|
|
42
46
|
|
|
47
|
+
type RefreshSession struct {
|
|
48
|
+
ID uuid.UUID
|
|
49
|
+
UserAgent string
|
|
50
|
+
CreatedAt time.Time
|
|
51
|
+
LastUsedAt time.Time
|
|
52
|
+
ExpiresAt time.Time
|
|
53
|
+
}
|
|
54
|
+
|
|
43
55
|
type RefreshTokenStore interface {
|
|
44
56
|
SetRefreshToken(context.Context, string, RefreshTokenRecord) error
|
|
45
57
|
GetRefreshToken(context.Context, string) (uuid.UUID, bool, error)
|
|
46
58
|
ConsumeRefreshToken(context.Context, string) (RefreshTokenRecord, bool, error)
|
|
47
59
|
DeleteRefreshToken(context.Context, string, uuid.UUID) error
|
|
48
60
|
RevokeAllRefreshTokens(context.Context, uuid.UUID) error
|
|
61
|
+
ListRefreshSessions(context.Context, uuid.UUID) ([]RefreshSession, error)
|
|
62
|
+
RevokeRefreshSession(context.Context, uuid.UUID, uuid.UUID) error
|
|
49
63
|
IsRefreshTokenUsed(context.Context, string) (uuid.UUID, bool, error)
|
|
50
64
|
}
|
|
51
65
|
|
|
@@ -76,6 +90,8 @@ type MFAEnrollment struct {
|
|
|
76
90
|
|
|
77
91
|
type MFAChallenge struct {
|
|
78
92
|
UserID uuid.UUID
|
|
93
|
+
SessionID uuid.UUID
|
|
94
|
+
UserAgent string
|
|
79
95
|
ExpiresAt time.Time
|
|
80
96
|
}
|
|
81
97
|
|
|
@@ -13,14 +13,17 @@ import (
|
|
|
13
13
|
|
|
14
14
|
const UserIDKey = "user_id"
|
|
15
15
|
|
|
16
|
+
const SessionIDKey = "session_id"
|
|
17
|
+
|
|
16
18
|
// go-scaffold:middleware-auth-keys
|
|
17
19
|
|
|
18
20
|
// accessClaims mirrors internal/app/user's own claims shape — duplicated
|
|
19
21
|
// rather than imported, since shared/ can never import a domain package.
|
|
20
|
-
// The two are kept in sync by convention: sub = user id, typ = "access",
|
|
21
|
-
//
|
|
22
|
+
// The two are kept in sync by convention: sub = user id, typ = "access", and
|
|
23
|
+
// sid = the stable refresh-session identity used by session management.
|
|
22
24
|
type accessClaims struct {
|
|
23
|
-
Typ
|
|
25
|
+
Typ string `json:"typ"`
|
|
26
|
+
SessionID uuid.UUID `json:"sid,omitempty"`
|
|
24
27
|
// go-scaffold:middleware-auth-claims
|
|
25
28
|
jwt.RegisteredClaims
|
|
26
29
|
}
|
|
@@ -53,6 +56,9 @@ func RequireAuth(secret string) gin.HandlerFunc {
|
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
c.Set(UserIDKey, userID)
|
|
59
|
+
if claims.SessionID != uuid.Nil {
|
|
60
|
+
c.Set(SessionIDKey, claims.SessionID)
|
|
61
|
+
}
|
|
56
62
|
// go-scaffold:middleware-auth-context
|
|
57
63
|
c.Next()
|
|
58
64
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
package middleware
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
"net/http/httptest"
|
|
6
|
+
"testing"
|
|
7
|
+
"time"
|
|
8
|
+
|
|
9
|
+
"github.com/gin-gonic/gin"
|
|
10
|
+
"github.com/golang-jwt/jwt/v5"
|
|
11
|
+
"github.com/google/uuid"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func TestRequireAuthExposesSessionIDFromAccessToken(t *testing.T) {
|
|
15
|
+
gin.SetMode(gin.TestMode)
|
|
16
|
+
secret := "test-secret"
|
|
17
|
+
userID := uuid.New()
|
|
18
|
+
sessionID := uuid.New()
|
|
19
|
+
token := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims{
|
|
20
|
+
Typ: "access",
|
|
21
|
+
SessionID: sessionID,
|
|
22
|
+
RegisteredClaims: jwt.RegisteredClaims{
|
|
23
|
+
Subject: userID.String(),
|
|
24
|
+
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
25
|
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
raw, err := token.SignedString([]byte(secret))
|
|
29
|
+
if err != nil {
|
|
30
|
+
t.Fatalf("sign token: %v", err)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
router := gin.New()
|
|
34
|
+
router.Use(RequireAuth(secret))
|
|
35
|
+
router.GET("/", func(c *gin.Context) {
|
|
36
|
+
if got := c.MustGet(SessionIDKey); got != sessionID {
|
|
37
|
+
t.Fatalf("session id = %v, want %s", got, sessionID)
|
|
38
|
+
}
|
|
39
|
+
c.Status(http.StatusNoContent)
|
|
40
|
+
})
|
|
41
|
+
response := httptest.NewRecorder()
|
|
42
|
+
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
43
|
+
request.Header.Set("Authorization", "Bearer "+raw)
|
|
44
|
+
router.ServeHTTP(response, request)
|
|
45
|
+
if response.Code != http.StatusNoContent {
|
|
46
|
+
t.Fatalf("status = %d, want 204", response.Code)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -6,12 +6,15 @@ CREATE TABLE user_svc.auth_tokens (
|
|
|
6
6
|
token_hash TEXT PRIMARY KEY,
|
|
7
7
|
user_id UUID NOT NULL,
|
|
8
8
|
kind VARCHAR(20) NOT NULL,
|
|
9
|
+
session_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
|
|
10
|
+
user_agent TEXT NOT NULL DEFAULT '',
|
|
9
11
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
10
12
|
absolute_expires_at TIMESTAMPTZ,
|
|
11
13
|
provider VARCHAR(20) NOT NULL DEFAULT '',
|
|
12
14
|
code_challenge TEXT NOT NULL DEFAULT '',
|
|
13
15
|
nonce TEXT NOT NULL DEFAULT '',
|
|
14
|
-
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
16
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
17
|
+
last_used_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
15
18
|
);
|
|
16
19
|
|
|
17
20
|
-- Named to match model.AuthToken's own `index:` tags exactly, so the
|
|
@@ -19,3 +22,4 @@ CREATE TABLE user_svc.auth_tokens (
|
|
|
19
22
|
CREATE INDEX idx_auth_tokens_user_kind ON user_svc.auth_tokens (user_id, kind);
|
|
20
23
|
CREATE INDEX idx_auth_tokens_expires_at ON user_svc.auth_tokens (expires_at);
|
|
21
24
|
CREATE INDEX idx_auth_tokens_absolute_expires_at ON user_svc.auth_tokens (absolute_expires_at);
|
|
25
|
+
CREATE INDEX idx_auth_tokens_user_session ON user_svc.auth_tokens (user_id, session_id, last_used_at);
|
|
@@ -12,6 +12,8 @@ CREATE TABLE user_svc.mfa_enrollments (
|
|
|
12
12
|
CREATE TABLE user_svc.mfa_challenges (
|
|
13
13
|
challenge_hash TEXT PRIMARY KEY,
|
|
14
14
|
user_id UUID NOT NULL REFERENCES user_svc.users(id) ON DELETE CASCADE,
|
|
15
|
+
session_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
|
|
16
|
+
user_agent TEXT NOT NULL DEFAULT '',
|
|
15
17
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
16
18
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
17
19
|
);
|
|
@@ -180,6 +180,12 @@ approval before changing their contract.
|
|
|
180
180
|
tombstone. Its inactivity expiry may move on rotation, but its persisted
|
|
181
181
|
absolute expiry never moves. Never replace it with a read-then-delete
|
|
182
182
|
sequence.
|
|
183
|
+
- Authenticated users can inspect active refresh sessions through
|
|
184
|
+
`GET /users/me/sessions` and revoke one with
|
|
185
|
+
`DELETE /users/me/sessions/:id`. Session IDs remain stable through refresh
|
|
186
|
+
rotation; responses expose only User-Agent and lifecycle timestamps, never
|
|
187
|
+
refresh tokens or token hashes. The current access token carries the session
|
|
188
|
+
ID as `sid` so the adapter can mark the current device.
|
|
183
189
|
- Password reset and email verification consume a one-time token in the same
|
|
184
190
|
retry-safe transaction as the user/identity update. A failed post-commit
|
|
185
191
|
session revocation must not make a successful reset impossible to retry.
|
|
@@ -194,6 +194,10 @@ nonce; the frontend still owns the callback route and sends the code to the
|
|
|
194
194
|
exchange endpoint. The provider redirect URI is separate from
|
|
195
195
|
`CORS_ALLOWED_ORIGINS`; the latter remains an exact-origin credentialed API
|
|
196
196
|
policy. Token responses use `Cache-Control: no-store` and `Pragma: no-cache`.
|
|
197
|
+
Authenticated users can list active device sessions with
|
|
198
|
+
`GET /users/me/sessions` and revoke an individual session with
|
|
199
|
+
`DELETE /users/me/sessions/:id`; only User-Agent and session lifecycle
|
|
200
|
+
timestamps are returned.
|
|
197
201
|
For `cross-site`, configure HTTPS origins, `COOKIE_SAMESITE=none`,
|
|
198
202
|
`COOKIE_SECURE=true`, and an exact allowed Origin; the API applies an Origin
|
|
199
203
|
guard independently of CORS.
|