@cosmicdrift/kumiko-bundled-features 0.158.2 → 0.160.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -6
- package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
- package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
- package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
- package/src/auth-email-password/constants.ts +15 -0
- package/src/auth-email-password/email-templates.ts +32 -0
- package/src/auth-email-password/errors.ts +9 -0
- package/src/auth-email-password/feature.ts +37 -5
- package/src/auth-email-password/handlers/change-password.write.ts +2 -1
- package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
- package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
- package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
- package/src/auth-email-password/i18n.ts +42 -0
- package/src/auth-email-password/index.ts +2 -0
- package/src/auth-email-password/lockout-store.ts +10 -5
- package/src/auth-email-password/password-policy.ts +2108 -0
- package/src/auth-email-password/signed-token.ts +1 -0
- package/src/auth-email-password/unlock-token.ts +28 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +525 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +57 -0
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +126 -2
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +125 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +131 -0
- package/src/auth-email-password/web/auth-client.ts +33 -0
- package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
- package/src/auth-email-password/web/index.ts +6 -0
- package/src/auth-email-password/web/login-screen.tsx +15 -0
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
- package/src/auth-foundation/__tests__/feature.test.ts +33 -0
- package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
- package/src/auth-foundation/boot-checks.ts +91 -0
- package/src/auth-foundation/feature.ts +103 -0
- package/src/auth-foundation/index.ts +23 -0
- package/src/auth-foundation/types.ts +107 -0
- package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +44 -0
- package/src/auth-mfa/handlers/verify.write.ts +1 -1
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
- package/src/channel-email/pii-guard.ts +6 -3
- package/src/config/__tests__/app-overrides.test.ts +11 -0
- package/src/config/__tests__/backing-secrets.integration.test.ts +18 -0
- package/src/config/__tests__/cascade.integration.test.ts +39 -0
- package/src/config/__tests__/config.integration.test.ts +14 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +23 -2
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
- package/src/config/feature.ts +2 -4
- package/src/config/handlers/set.write.ts +7 -1
- package/src/config/resolver.ts +1 -1
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
- package/src/delivery/handlers/log.query.ts +1 -1
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
- package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +154 -0
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +331 -0
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
- package/src/inbound-provider-inmemory/feature.ts +2 -2
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
- package/src/jobs/handlers/detail.query.ts +1 -1
- package/src/jobs/handlers/list.query.ts +1 -1
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
- package/src/managed-pages/handlers/branding.query.ts +5 -1
- package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
- package/src/personal-access-tokens/feature.ts +23 -8
- package/src/personal-access-tokens/handlers/list.query.ts +1 -1
- package/src/personal-access-tokens/index.ts +0 -14
- package/src/personal-access-tokens/resolver.ts +2 -2
- package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
- package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
- package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
- package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
- package/src/sessions/__tests__/test-helpers.ts +15 -2
- package/src/sessions/feature.ts +31 -3
- package/src/sessions/handlers/detail.query.ts +2 -2
- package/src/sessions/handlers/list.query.ts +4 -2
- package/src/sessions/handlers/mine.query.ts +4 -2
- package/src/sessions/testing.ts +14 -1
- package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
- package/src/shared/decrypt-stored-pii.ts +12 -5
- package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
- package/src/tags/web/__tests__/tag-manager.test.tsx +277 -8
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
- package/src/tenant/handlers/invitations.query.ts +4 -2
- package/src/tenant/handlers/members.query.ts +2 -2
- package/src/tenant/seeding.ts +2 -2
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
- package/src/tenant-lifecycle/boot-checks.ts +53 -0
- package/src/tenant-lifecycle/feature.ts +7 -0
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
- package/src/tier-engine/feature.ts +2 -2
- package/src/user-data-rights/COMPLIANCE.md +20 -0
- package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +184 -1
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
- package/src/user-data-rights/boot-checks.ts +86 -0
- package/src/user-data-rights/feature.ts +116 -6
- package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
- package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
- package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
- package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
- package/src/user-data-rights/run-export-jobs.ts +3 -3
- package/src/user-data-rights/run-forget-cleanup.ts +1 -1
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
- package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
- package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
- package/src/user-profile/__tests__/profile-screen.test.tsx +1 -0
- package/src/user-profile/handlers/change-email.write.ts +1 -1
- package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { MIN_HMAC_SECRET_LENGTH } from "./constants";
|
|
4
4
|
import type { AuthMailLocale } from "./email-templates";
|
|
5
5
|
import { changePasswordWrite } from "./handlers/change-password.write";
|
|
6
|
+
import { createConfirmAccountUnlockHandler } from "./handlers/confirm-account-unlock.write";
|
|
6
7
|
import { createInviteAcceptHandler } from "./handlers/invite-accept.write";
|
|
7
8
|
import { createInviteAcceptWithLoginHandler } from "./handlers/invite-accept-with-login.write";
|
|
8
9
|
import {
|
|
@@ -12,6 +13,10 @@ import {
|
|
|
12
13
|
import { createInviteSignupCompleteHandler } from "./handlers/invite-signup-complete.write";
|
|
13
14
|
import { createLoginHandler, type LoginHandlerOptions } from "./handlers/login.write";
|
|
14
15
|
import { logoutWrite } from "./handlers/logout.write";
|
|
16
|
+
import {
|
|
17
|
+
createRequestAccountUnlockHandler,
|
|
18
|
+
type RequestAccountUnlockOptions,
|
|
19
|
+
} from "./handlers/request-account-unlock.write";
|
|
15
20
|
import { createRequestEmailVerificationHandler } from "./handlers/request-email-verification.write";
|
|
16
21
|
import { createRequestPasswordResetHandler } from "./handlers/request-password-reset.write";
|
|
17
22
|
import { createResetPasswordHandler } from "./handlers/reset-password.write";
|
|
@@ -109,6 +114,15 @@ export type SignupOptions = SignupRequestOptions;
|
|
|
109
114
|
// 7 Tage (industry standard).
|
|
110
115
|
export type InviteOptions = InviteCreateOptions;
|
|
111
116
|
|
|
117
|
+
// Account-Unlock Magic-Link (#1266). When set, registers the
|
|
118
|
+
// request-account-unlock + confirm-account-unlock handlers — the
|
|
119
|
+
// self-service escape hatch for the monotonic lockout counter (see
|
|
120
|
+
// lockout-store.ts). Only meaningful paired with `accountLockout`, but
|
|
121
|
+
// wired independently like passwordReset/emailVerification — no implicit
|
|
122
|
+
// auto-enable, so apps without lockout don't get an unasked-for new
|
|
123
|
+
// public endpoint.
|
|
124
|
+
export type AccountUnlockOptions = RequestAccountUnlockOptions;
|
|
125
|
+
|
|
112
126
|
export type AuthEmailPasswordOptions = {
|
|
113
127
|
// Second-factor gate. auth-mfa (if mounted) wires this in via
|
|
114
128
|
// mfaStatusCheckerFromFeature() at app-composition time — see
|
|
@@ -119,6 +133,7 @@ export type AuthEmailPasswordOptions = {
|
|
|
119
133
|
readonly accountLockout?: AccountLockoutOptions;
|
|
120
134
|
readonly signup?: SignupOptions;
|
|
121
135
|
readonly invite?: InviteOptions;
|
|
136
|
+
readonly accountUnlock?: AccountUnlockOptions;
|
|
122
137
|
};
|
|
123
138
|
|
|
124
139
|
// Auth feature — email+password login. Depends on the user feature for
|
|
@@ -140,13 +155,18 @@ export function createAuthEmailPasswordFeature(
|
|
|
140
155
|
`[auth-email-password] emailVerification.hmacSecret must be ≥${MIN_HMAC_SECRET_LENGTH} chars (HMAC-SHA256 token signing)`,
|
|
141
156
|
);
|
|
142
157
|
}
|
|
158
|
+
if (opts.accountUnlock && opts.accountUnlock.hmacSecret.length < MIN_HMAC_SECRET_LENGTH) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`[auth-email-password] accountUnlock.hmacSecret must be ≥${MIN_HMAC_SECRET_LENGTH} chars (HMAC-SHA256 token signing)`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
143
163
|
|
|
144
164
|
const strictVerification =
|
|
145
165
|
opts.emailVerification !== undefined && (opts.emailVerification.mode ?? "strict") === "strict";
|
|
146
166
|
|
|
147
167
|
return defineFeature("auth-email-password", (r) => {
|
|
148
168
|
r.describe(
|
|
149
|
-
"Provides email+password authentication: the always-on handlers are `login`, `changePassword`, and `logout`; optional flows
|
|
169
|
+
"Provides email+password authentication: the always-on handlers are `login`, `changePassword`, and `logout`; optional flows — password reset, email verification, magic-link self-signup, tenant invite, and account-unlock — are registered only when you pass their respective option objects (`passwordReset`, `emailVerification`, `signup`, `invite`, `accountUnlock`) to `createAuthEmailPasswordFeature(opts)`. All five magic-link flows dispatch their mail through the `delivery` feature via `ctx.notify`, so mounting any of them additionally requires `delivery`. Tokens are HMAC-signed (reset/verify/unlock) or opaque-random in Redis (signup/invite). `accountUnlock` is the self-service escape hatch for `accountLockout`'s monotonic failure-counter (#1266): confirming the mailed token clears the Redis lockout state without touching the user entity. Requires the `user` and `tenant` features, and declares `JWT_SECRET` (≥ 32 chars) in `authEmailPasswordEnvSchema` so a missing secret surfaces at boot validation rather than on the first login attempt.",
|
|
150
170
|
);
|
|
151
171
|
r.uiHints({
|
|
152
172
|
displayLabel: "Auth \u00b7 Email + Password",
|
|
@@ -162,14 +182,21 @@ export function createAuthEmailPasswordFeature(
|
|
|
162
182
|
},
|
|
163
183
|
{ key: "signup", label: "Self-Signup-Flow", type: "boolean", default: false },
|
|
164
184
|
{ key: "invite", label: "Tenant-Invite-Flow", type: "boolean", default: false },
|
|
185
|
+
{ key: "accountUnlock", label: "Account-Unlock-Flow", type: "boolean", default: false },
|
|
165
186
|
],
|
|
166
187
|
});
|
|
167
188
|
r.requires("user");
|
|
168
189
|
r.requires("tenant");
|
|
169
|
-
// All
|
|
170
|
-
// ctx.notify → delivery must be mounted. Fail closed at boot instead
|
|
171
|
-
// silently dropping the mail.
|
|
172
|
-
if (
|
|
190
|
+
// All five magic-link flows (reset/verify/signup/invite/unlock) dispatch
|
|
191
|
+
// via ctx.notify → delivery must be mounted. Fail closed at boot instead
|
|
192
|
+
// of silently dropping the mail.
|
|
193
|
+
if (
|
|
194
|
+
opts.passwordReset ||
|
|
195
|
+
opts.emailVerification ||
|
|
196
|
+
opts.signup ||
|
|
197
|
+
opts.invite ||
|
|
198
|
+
opts.accountUnlock
|
|
199
|
+
) {
|
|
173
200
|
r.requires("delivery");
|
|
174
201
|
}
|
|
175
202
|
r.envSchema(authEmailPasswordEnvSchema);
|
|
@@ -208,6 +235,11 @@ export function createAuthEmailPasswordFeature(
|
|
|
208
235
|
r.writeHandler(createInviteSignupCompleteHandler());
|
|
209
236
|
}
|
|
210
237
|
|
|
238
|
+
if (opts.accountUnlock) {
|
|
239
|
+
r.writeHandler(createRequestAccountUnlockHandler(opts.accountUnlock));
|
|
240
|
+
r.writeHandler(createConfirmAccountUnlockHandler(opts.accountUnlock));
|
|
241
|
+
}
|
|
242
|
+
|
|
211
243
|
return { handlers };
|
|
212
244
|
});
|
|
213
245
|
}
|
|
@@ -8,6 +8,7 @@ import { z } from "zod";
|
|
|
8
8
|
import { hashPassword, verifyPassword } from "../../shared";
|
|
9
9
|
import { UserHandlers, UserQueries } from "../../user";
|
|
10
10
|
import { invalidCredentials } from "../errors";
|
|
11
|
+
import { passwordSchema } from "../password-policy";
|
|
11
12
|
|
|
12
13
|
// Change-password — authenticated. The user supplies their current password
|
|
13
14
|
// (re-auth) and the new one. The new hash is written via ctx.writeAs(system)
|
|
@@ -17,7 +18,7 @@ export const changePasswordWrite = defineWriteHandler({
|
|
|
17
18
|
name: "change-password",
|
|
18
19
|
schema: z.object({
|
|
19
20
|
oldPassword: z.string().min(1),
|
|
20
|
-
newPassword:
|
|
21
|
+
newPassword: passwordSchema,
|
|
21
22
|
}),
|
|
22
23
|
access: { roles: access.authenticated },
|
|
23
24
|
handler: async (event, ctx) => {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import {
|
|
3
|
+
InternalError,
|
|
4
|
+
UnprocessableError,
|
|
5
|
+
writeFailure,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { AuthErrors } from "../constants";
|
|
9
|
+
import { invalidUnlockToken } from "../errors";
|
|
10
|
+
import { clearLockoutState } from "../lockout-store";
|
|
11
|
+
import { verifyUnlockToken } from "../unlock-token";
|
|
12
|
+
|
|
13
|
+
export type ConfirmAccountUnlockOptions = {
|
|
14
|
+
readonly hmacSecret: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Confirm step of the unlock flow. Unlike reset/verify, there's no entity
|
|
18
|
+
// write: the token's HMAC signature already proves it was minted for an
|
|
19
|
+
// existing, non-deleted user (see request-account-unlock.write.ts's
|
|
20
|
+
// findForAuth + extraSilentSkip), so there's no user to (re-)load or
|
|
21
|
+
// validate here. Clearing the Redis lockout state is naturally idempotent
|
|
22
|
+
// (DEL on an already-cleared/missing key is a no-op), so a replayed unlock
|
|
23
|
+
// link within its TTL just re-clears — harmless, unlike reset which sets a
|
|
24
|
+
// password. That's why this skips the single-use burn-store confirm-token-
|
|
25
|
+
// flow.ts needs to protect a state-changing write.
|
|
26
|
+
export function createConfirmAccountUnlockHandler(opts: ConfirmAccountUnlockOptions) {
|
|
27
|
+
return defineWriteHandler({
|
|
28
|
+
name: "confirm-account-unlock",
|
|
29
|
+
schema: z.object({
|
|
30
|
+
token: z.string().min(1),
|
|
31
|
+
}),
|
|
32
|
+
access: { roles: ["all"] },
|
|
33
|
+
handler: async (event, ctx) => {
|
|
34
|
+
if (!opts.hmacSecret) {
|
|
35
|
+
return writeFailure(
|
|
36
|
+
new UnprocessableError(AuthErrors.unlockNotConfigured, {
|
|
37
|
+
i18nKey: "auth.errors.unlockNotConfigured",
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (!ctx.redis) {
|
|
42
|
+
return writeFailure(
|
|
43
|
+
new InternalError({
|
|
44
|
+
message: "account-unlock requires ctx.redis to clear lockout state",
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// All verify failures (malformed / bad_signature / expired) fold into
|
|
50
|
+
// the same invalid_unlock_token error — a probing caller can't
|
|
51
|
+
// distinguish tampered from stale from random-string.
|
|
52
|
+
const verify = verifyUnlockToken(event.payload.token, opts.hmacSecret);
|
|
53
|
+
if (!verify.ok) return invalidUnlockToken();
|
|
54
|
+
|
|
55
|
+
await clearLockoutState(ctx.redis, verify.userId);
|
|
56
|
+
return { isSuccess: true, data: { kind: "account-unlocked" as const } };
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -108,6 +108,7 @@ export function createInviteAcceptWithLoginHandler() {
|
|
|
108
108
|
const invitationTenantId = invitation.tenantId;
|
|
109
109
|
const invitationEmail = await decryptStoredPii(
|
|
110
110
|
invitation.email,
|
|
111
|
+
"email",
|
|
111
112
|
"auth:invite-accept-with-login",
|
|
112
113
|
);
|
|
113
114
|
const invitationRole = invitation.role;
|
|
@@ -101,7 +101,11 @@ export function createInviteAcceptHandler() {
|
|
|
101
101
|
return invalidInviteToken();
|
|
102
102
|
|
|
103
103
|
const invitationTenantId = invitation.tenantId;
|
|
104
|
-
const invitationEmail = await decryptStoredPii(
|
|
104
|
+
const invitationEmail = await decryptStoredPii(
|
|
105
|
+
invitation.email,
|
|
106
|
+
"email",
|
|
107
|
+
"auth:invite-accept",
|
|
108
|
+
);
|
|
105
109
|
const invitationRole = invitation.role;
|
|
106
110
|
const invitationVersion = invitation.version;
|
|
107
111
|
|
|
@@ -112,7 +116,7 @@ export function createInviteAcceptHandler() {
|
|
|
112
116
|
id: event.user.id,
|
|
113
117
|
});
|
|
114
118
|
const userEmail = userRow?.email
|
|
115
|
-
? await decryptStoredPii(userRow.email, "auth:invite-accept")
|
|
119
|
+
? await decryptStoredPii(userRow.email, "email", "auth:invite-accept")
|
|
116
120
|
: undefined;
|
|
117
121
|
if (!userRow || !userEmail || userEmail.toLowerCase() !== invitationEmail) {
|
|
118
122
|
return inviteEmailMismatch();
|
|
@@ -112,6 +112,7 @@ export function createInviteSignupCompleteHandler() {
|
|
|
112
112
|
const invitationTenantId = invitation.tenantId;
|
|
113
113
|
const invitationEmail = await decryptStoredPii(
|
|
114
114
|
invitation.email,
|
|
115
|
+
"email",
|
|
115
116
|
"auth:invite-signup-complete",
|
|
116
117
|
);
|
|
117
118
|
const invitationRole = invitation.role;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { AUTH_UNLOCK_DEFAULT_TTL_MINUTES, AuthErrors } from "../constants";
|
|
2
|
+
import { renderUnlockAccountEmail } from "../email-templates";
|
|
3
|
+
import { signUnlockToken } from "../unlock-token";
|
|
4
|
+
import {
|
|
5
|
+
createTokenRequestHandler,
|
|
6
|
+
type TokenRequestData,
|
|
7
|
+
type TokenRequestOptions,
|
|
8
|
+
} from "./token-request-handler";
|
|
9
|
+
|
|
10
|
+
const UNLOCK_NOTIFICATION_TYPE = "auth-email-password:account-unlock";
|
|
11
|
+
|
|
12
|
+
export type RequestAccountUnlockOptions = TokenRequestOptions;
|
|
13
|
+
|
|
14
|
+
// Public shape re-exported for callers that build custom routes on top of
|
|
15
|
+
// the dispatcher (bypassing the framework's auth-routes).
|
|
16
|
+
export type RequestUnlockData = TokenRequestData<"unlock-requested">;
|
|
17
|
+
|
|
18
|
+
// @wrapper-known semantic-alias
|
|
19
|
+
export function createRequestAccountUnlockHandler(opts: RequestAccountUnlockOptions) {
|
|
20
|
+
return createTokenRequestHandler(
|
|
21
|
+
{
|
|
22
|
+
handlerName: "request-account-unlock",
|
|
23
|
+
successKind: "unlock-requested",
|
|
24
|
+
defaultTtlMinutes: AUTH_UNLOCK_DEFAULT_TTL_MINUTES,
|
|
25
|
+
sign: signUnlockToken,
|
|
26
|
+
notConfiguredError: AuthErrors.unlockNotConfigured,
|
|
27
|
+
notConfiguredI18nKey: "auth.errors.unlockNotConfigured",
|
|
28
|
+
// No extra skip condition — any existing, non-deleted user can
|
|
29
|
+
// request an unlock link regardless of whether they're actually
|
|
30
|
+
// locked right now. Confirming just clears the (possibly already
|
|
31
|
+
// empty) lockout state — harmless no-op if they weren't locked.
|
|
32
|
+
extraSilentSkip: () => false,
|
|
33
|
+
notificationType: UNLOCK_NOTIFICATION_TYPE,
|
|
34
|
+
renderContent: renderUnlockAccountEmail,
|
|
35
|
+
},
|
|
36
|
+
opts,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { hashPassword } from "../../shared";
|
|
5
5
|
import { AuthErrors } from "../constants";
|
|
6
6
|
import { invalidResetToken } from "../errors";
|
|
7
|
+
import { passwordSchema } from "../password-policy";
|
|
7
8
|
import { verifyResetToken } from "../reset-token";
|
|
8
9
|
import { runConfirmTokenFlow } from "./confirm-token-flow";
|
|
9
10
|
|
|
@@ -22,7 +23,7 @@ export function createResetPasswordHandler(opts: ResetPasswordOptions) {
|
|
|
22
23
|
name: "reset-password",
|
|
23
24
|
schema: z.object({
|
|
24
25
|
token: z.string().min(1),
|
|
25
|
-
newPassword:
|
|
26
|
+
newPassword: passwordSchema,
|
|
26
27
|
}),
|
|
27
28
|
access: { roles: ["all"] },
|
|
28
29
|
handler: async (event, ctx) => {
|
|
@@ -33,6 +33,7 @@ import { z } from "zod";
|
|
|
33
33
|
// kumiko-lint-ignore cross-feature-import signup-confirm reads tenants.key for slug-uniqueness check (TOCTOU + DB-unique-index zusammen)
|
|
34
34
|
import { tenantTable } from "../../tenant/schema/tenant";
|
|
35
35
|
import { invalidSignupToken, signupEmailAlreadyRegistered } from "../errors";
|
|
36
|
+
import { passwordSchema } from "../password-policy";
|
|
36
37
|
// kumiko-lint-ignore cross-feature-import provisioning needs cross-feature seeding helpers
|
|
37
38
|
import { INITIAL_SIGNUP_ROLES, provisionSignupAccount } from "../seeding";
|
|
38
39
|
import {
|
|
@@ -44,7 +45,7 @@ import {
|
|
|
44
45
|
|
|
45
46
|
const SignupConfirmSchema = z.object({
|
|
46
47
|
token: z.string().min(8),
|
|
47
|
-
password:
|
|
48
|
+
password: passwordSchema,
|
|
48
49
|
});
|
|
49
50
|
|
|
50
51
|
// Mirror der login-handler-Shape (kind: "auth-session", session: SessionUser)
|
|
@@ -18,6 +18,7 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
18
18
|
"auth.login.submit": "Einloggen",
|
|
19
19
|
"auth.login.submitting": "…",
|
|
20
20
|
"auth.login.forgotPassword": "Passwort vergessen?",
|
|
21
|
+
"auth.login.unlockAccount": "Konto entsperren?",
|
|
21
22
|
"auth.login.resendVerification": "Bestätigungs-Mail erneut senden",
|
|
22
23
|
"auth.login.resendSuccess": "Wir haben dir eine neue Bestätigungs-Mail geschickt.",
|
|
23
24
|
"auth.login.resendRateLimited": "Bitte warte kurz und versuche es erneut.",
|
|
@@ -37,6 +38,8 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
37
38
|
"auth.errors.invalidResetToken":
|
|
38
39
|
"Der Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.",
|
|
39
40
|
"auth.errors.invalidVerificationToken": "Der Bestätigungs-Link ist ungültig oder abgelaufen.",
|
|
41
|
+
"auth.errors.invalidUnlockToken":
|
|
42
|
+
"Der Entsperren-Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.",
|
|
40
43
|
"auth.errors.invalidSignupToken":
|
|
41
44
|
"Der Aktivierungs-Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.",
|
|
42
45
|
"auth.errors.signupEmailAlreadyRegistered":
|
|
@@ -74,6 +77,25 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
74
77
|
"Der Link ist ungültig oder abgelaufen. Bitte fordere eine neue Bestätigungs-Mail an.",
|
|
75
78
|
"auth.verifyEmail.goToLogin": "Zum Login",
|
|
76
79
|
"auth.verifyEmail.missingToken": "Der Bestätigungs-Link enthält keinen Token.",
|
|
80
|
+
"auth.requestUnlock.title": "Konto entsperren",
|
|
81
|
+
"auth.requestUnlock.intro":
|
|
82
|
+
"Gib deine E-Mail-Adresse ein. Falls dein Konto gesperrt ist, schicken wir dir einen Entsperren-Link.",
|
|
83
|
+
"auth.requestUnlock.email": "E-Mail",
|
|
84
|
+
"auth.requestUnlock.submit": "Link anfordern",
|
|
85
|
+
"auth.requestUnlock.submitting": "…",
|
|
86
|
+
"auth.requestUnlock.successTitle": "Mail gesendet",
|
|
87
|
+
"auth.requestUnlock.successBody":
|
|
88
|
+
"Falls die E-Mail in unserem System existiert und gesperrt ist, ist eine Nachricht mit einem Entsperren-Link unterwegs. Bitte schau in deinen Posteingang.",
|
|
89
|
+
"auth.requestUnlock.backToLogin": "Zurück zum Login",
|
|
90
|
+
"auth.unlockAccount.verifying": "Konto wird entsperrt …",
|
|
91
|
+
"auth.unlockAccount.successTitle": "Konto entsperrt",
|
|
92
|
+
"auth.unlockAccount.successBody":
|
|
93
|
+
"Dein Konto ist wieder entsperrt. Du kannst dich jetzt anmelden.",
|
|
94
|
+
"auth.unlockAccount.errorTitle": "Entsperren fehlgeschlagen",
|
|
95
|
+
"auth.unlockAccount.errorBody":
|
|
96
|
+
"Der Link ist ungültig oder abgelaufen. Bitte fordere einen neuen Entsperren-Link an.",
|
|
97
|
+
"auth.unlockAccount.goToLogin": "Zum Login",
|
|
98
|
+
"auth.unlockAccount.missingToken": "Der Entsperren-Link enthält keinen Token.",
|
|
77
99
|
"auth.signup.title": "Account erstellen",
|
|
78
100
|
"auth.signup.intro":
|
|
79
101
|
"Gib deine E-Mail-Adresse ein. Wir schicken dir einen Aktivierungs-Link, mit dem du dein Passwort setzt.",
|
|
@@ -122,6 +144,7 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
122
144
|
"auth.login.submit": "Sign in",
|
|
123
145
|
"auth.login.submitting": "…",
|
|
124
146
|
"auth.login.forgotPassword": "Forgot password?",
|
|
147
|
+
"auth.login.unlockAccount": "Unlock account?",
|
|
125
148
|
"auth.login.resendVerification": "Send verification email again",
|
|
126
149
|
"auth.login.resendSuccess": "We've sent you a new verification email.",
|
|
127
150
|
"auth.login.resendRateLimited": "Please wait a moment and try again.",
|
|
@@ -140,6 +163,8 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
140
163
|
"This app doesn't support two-factor verification. Please contact support.",
|
|
141
164
|
"auth.errors.invalidResetToken": "Link is invalid or expired. Please request a new one.",
|
|
142
165
|
"auth.errors.invalidVerificationToken": "Verification link is invalid or expired.",
|
|
166
|
+
"auth.errors.invalidUnlockToken":
|
|
167
|
+
"Unlock link is invalid or expired. Please request a new one.",
|
|
143
168
|
"auth.errors.invalidSignupToken":
|
|
144
169
|
"Activation link is invalid or expired. Please request a new one.",
|
|
145
170
|
"auth.errors.signupEmailAlreadyRegistered":
|
|
@@ -176,6 +201,23 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
176
201
|
"Link is invalid or expired. Please request a new verification email.",
|
|
177
202
|
"auth.verifyEmail.goToLogin": "Go to sign in",
|
|
178
203
|
"auth.verifyEmail.missingToken": "Verification link is missing a token.",
|
|
204
|
+
"auth.requestUnlock.title": "Unlock account",
|
|
205
|
+
"auth.requestUnlock.intro":
|
|
206
|
+
"Enter your email address. If your account is locked, we'll send you an unlock link.",
|
|
207
|
+
"auth.requestUnlock.email": "Email",
|
|
208
|
+
"auth.requestUnlock.submit": "Request link",
|
|
209
|
+
"auth.requestUnlock.submitting": "…",
|
|
210
|
+
"auth.requestUnlock.successTitle": "Email sent",
|
|
211
|
+
"auth.requestUnlock.successBody":
|
|
212
|
+
"If that email exists in our system and is locked, a message with an unlock link is on its way. Please check your inbox.",
|
|
213
|
+
"auth.requestUnlock.backToLogin": "Back to sign in",
|
|
214
|
+
"auth.unlockAccount.verifying": "Unlocking account …",
|
|
215
|
+
"auth.unlockAccount.successTitle": "Account unlocked",
|
|
216
|
+
"auth.unlockAccount.successBody": "Your account is unlocked again. You can sign in now.",
|
|
217
|
+
"auth.unlockAccount.errorTitle": "Unlock failed",
|
|
218
|
+
"auth.unlockAccount.errorBody": "Link is invalid or expired. Please request a new unlock link.",
|
|
219
|
+
"auth.unlockAccount.goToLogin": "Go to sign in",
|
|
220
|
+
"auth.unlockAccount.missingToken": "Unlock link is missing a token.",
|
|
179
221
|
"auth.signup.title": "Create account",
|
|
180
222
|
"auth.signup.intro":
|
|
181
223
|
"Enter your email. We'll send you an activation link to set your password.",
|
|
@@ -22,10 +22,12 @@ export {
|
|
|
22
22
|
renderActivationEmail,
|
|
23
23
|
renderInviteEmail,
|
|
24
24
|
renderResetPasswordEmail,
|
|
25
|
+
renderUnlockAccountEmail,
|
|
25
26
|
renderVerifyEmail,
|
|
26
27
|
} from "./email-templates";
|
|
27
28
|
export type {
|
|
28
29
|
AccountLockoutOptions,
|
|
30
|
+
AccountUnlockOptions,
|
|
29
31
|
AuthEmailPasswordOptions,
|
|
30
32
|
EmailVerificationOptions,
|
|
31
33
|
InviteOptions,
|
|
@@ -31,9 +31,13 @@ export type LockoutState = {
|
|
|
31
31
|
// Consequence of the monotonic counter: once a user has been locked, the
|
|
32
32
|
// NEXT wrong password after the lock expires re-locks immediately — the
|
|
33
33
|
// INCR still returns a value ≥ threshold, so the SET NX re-arms the lock.
|
|
34
|
-
// A successful login is
|
|
35
|
-
//
|
|
36
|
-
//
|
|
34
|
+
// A successful login is one way to reset the streak; the other is the
|
|
35
|
+
// account-unlock magic-link flow (#1266, see
|
|
36
|
+
// handlers/confirm-account-unlock.write.ts), a deliberate escape hatch for
|
|
37
|
+
// a legitimate user who can't currently produce the right password (e.g.
|
|
38
|
+
// they forgot it too) but can prove mailbox ownership. Intentional:
|
|
39
|
+
// brute-force resistance favours strictness over UX for anonymous login
|
|
40
|
+
// attempts, while the unlock flow keeps the DoS from being permanent.
|
|
37
41
|
const COUNT_KEY_PREFIX = "kumiko:auth:lockout:count:";
|
|
38
42
|
const UNTIL_KEY_PREFIX = "kumiko:auth:lockout:until:";
|
|
39
43
|
|
|
@@ -111,8 +115,9 @@ export async function recordFailedAttempt(
|
|
|
111
115
|
return { failureCount: count, lockedUntil };
|
|
112
116
|
}
|
|
113
117
|
|
|
114
|
-
// Called on successful login
|
|
115
|
-
//
|
|
118
|
+
// Called on successful login, and on a confirmed account-unlock token
|
|
119
|
+
// (#1266). Idempotent — deleting missing keys is a no-op, so a replayed
|
|
120
|
+
// unlock link just re-clears harmlessly.
|
|
116
121
|
export async function clearLockoutState(redis: Redis, userId: string): Promise<void> {
|
|
117
122
|
await redis.del(countKey(userId), untilKey(userId));
|
|
118
123
|
}
|