@adonis-agora/authkit-server 0.54.0 → 0.55.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/index.d.ts +4 -2
- package/build/index.js +2 -1
- package/build/providers/authkit_server_provider.js +18 -0
- package/build/src/accounts/lucid_account_store.d.ts +35 -0
- package/build/src/accounts/lucid_account_store.js +9 -0
- package/build/src/accounts/lucid_store/core.js +88 -19
- package/build/src/accounts/lucid_store/shared.d.ts +14 -0
- package/build/src/accounts/lucid_store/token_hash.d.ts +79 -0
- package/build/src/accounts/lucid_store/token_hash.js +145 -0
- package/build/src/define_config.d.ts +93 -8
- package/build/src/define_config.js +28 -2
- package/build/src/host/account_api/account_api_controller.js +5 -4
- package/build/src/host/admin_api/admin_users_service.js +2 -1
- package/build/src/host/admin_api/api_orgs_controller.js +2 -1
- package/build/src/host/admin_console/console_impersonation_controller.d.ts +15 -1
- package/build/src/host/admin_console/console_impersonation_controller.js +26 -2
- package/build/src/host/admin_console/console_orgs_controller.js +3 -1
- package/build/src/host/admin_validators.d.ts +3 -3
- package/build/src/host/auth_host_config.d.ts +30 -0
- package/build/src/host/auth_host_config.js +15 -0
- package/build/src/host/config_locks.d.ts +29 -0
- package/build/src/host/config_locks.js +46 -0
- package/build/src/host/console_session.d.ts +38 -2
- package/build/src/host/console_session.js +46 -2
- package/build/src/host/controllers/account_mfa_controller.js +5 -5
- package/build/src/host/controllers/account_orgs_controller.js +2 -1
- package/build/src/host/controllers/account_security_controller.js +6 -5
- package/build/src/host/controllers/interaction_controller.js +122 -26
- package/build/src/host/controllers/registration_controller.js +5 -4
- package/build/src/host/controllers/social_controller.js +37 -0
- package/build/src/host/default_mailer.d.ts +36 -5
- package/build/src/host/default_mailer.js +63 -10
- package/build/src/host/i18n.d.ts +10 -0
- package/build/src/host/i18n.js +16 -0
- package/build/src/host/login_attempt.d.ts +88 -2
- package/build/src/host/login_attempt.js +101 -48
- package/build/src/host/login_notify.js +2 -2
- package/build/src/host/oidc_rp_guard.d.ts +25 -2
- package/build/src/host/oidc_rp_guard.js +55 -9
- package/build/src/host/origin.d.ts +21 -0
- package/build/src/host/origin.js +22 -0
- package/build/src/host/register_auth_host.d.ts +104 -3
- package/build/src/host/register_auth_host.js +222 -26
- package/build/src/host/runtime_settings.d.ts +16 -0
- package/build/src/host/runtime_settings.js +24 -0
- package/build/src/host/runtime_toggles.d.ts +10 -0
- package/build/src/host/runtime_toggles.js +4 -0
- package/build/src/host/security_notice_service.d.ts +4 -2
- package/build/src/host/security_notice_service.js +4 -2
- package/build/src/host/sudo/index.d.ts +8 -0
- package/build/src/host/sudo/index.js +8 -0
- package/build/src/host/sudo/methods/magic_link.d.ts +17 -3
- package/build/src/host/sudo/methods/magic_link.js +37 -13
- package/build/src/host/sudo/runtime.d.ts +44 -4
- package/build/src/host/sudo/runtime.js +90 -6
- package/build/src/host/sudo/satisfiability.d.ts +62 -0
- package/build/src/host/sudo/satisfiability.js +89 -0
- package/build/src/password/common_passwords.js +27 -7
- package/build/src/provider/oidc_service.js +30 -13
- package/build/src/provider/token_exchange.d.ts +25 -1
- package/build/src/provider/token_exchange.js +29 -2
- package/package.json +6 -3
- /package/build/{password → src/password}/common_passwords.txt +0 -0
package/build/index.d.ts
CHANGED
|
@@ -55,7 +55,9 @@ export { resolveMessages, translate, DEFAULT_MESSAGES, PT_BR_MESSAGES, BUILTIN_M
|
|
|
55
55
|
export type { I18nConfig, AuthMessages } from './src/host/i18n.js';
|
|
56
56
|
export type { AuthHostRenderer, AuthSocialConfig } from './src/define_config.js';
|
|
57
57
|
export { registerAuthHost } from './src/host/register_auth_host.js';
|
|
58
|
-
export type { AuthHostOptions } from './src/host/register_auth_host.js';
|
|
58
|
+
export type { AuthHostOptions, AuthHostRouteMap, AccountScreensOptions, } from './src/host/register_auth_host.js';
|
|
59
|
+
export type { PolicyRouteOption } from './src/host/config_locks.js';
|
|
60
|
+
export { POLICY_ROUTE_OPTIONS } from './src/host/config_locks.js';
|
|
59
61
|
export { getAdminPrefix, setAdminPrefix, normalizeAdminPrefix, getAdminApiPrefix, setAdminApiPrefix, normalizeAdminApiPrefix, } from './src/host/admin_prefix.js';
|
|
60
62
|
/**
|
|
61
63
|
* Helpers de path do console de conta (`/account/*`). Um host que precisa casar
|
|
@@ -125,7 +127,7 @@ export { SETTING_KEYS, resolveEffectiveRegistration, resolveEffectiveRequireVeri
|
|
|
125
127
|
export type { SettingKey, RegistrationSetting, RequireVerifiedEmailSetting, MaintenanceModeSetting, ResolvedMaintenanceMode, AuthMethodsSetting, ResolvedAuthMethods, AuthMethodsCapabilities, AuthMethodsConfigOverride, } from './src/host/runtime_toggles.js';
|
|
126
128
|
export { resolveRegistration } from './src/define_config.js';
|
|
127
129
|
export type { RegistrationConfigInput, ResolvedRegistrationConfig, } from './src/define_config.js';
|
|
128
|
-
export { getAccountId, hasAccountSession, consoleLoginUrl, } from './src/host/console_session.js';
|
|
130
|
+
export { getAccountId, realAccountId, hasAccountSession, consoleLoginUrl, } from './src/host/console_session.js';
|
|
129
131
|
export { ACCOUNT_SESSION_KEY } from './src/host/middleware/account_auth.js';
|
|
130
132
|
export { rememberAccessToken, startImpersonation, impersonationState, stopImpersonation, } from './src/host/impersonation_session.js';
|
|
131
133
|
export type { StartImpersonationParams, ImpersonationState, } from './src/host/impersonation_session.js';
|
package/build/index.js
CHANGED
|
@@ -33,6 +33,7 @@ export { edgeRenderer } from './src/host/renderers/edge_renderer.js';
|
|
|
33
33
|
export { brandFor, isFirstParty } from './src/host/branding.js';
|
|
34
34
|
export { resolveMessages, translate, DEFAULT_MESSAGES, PT_BR_MESSAGES, BUILTIN_MESSAGES, DEFAULT_LOCALE, } from './src/host/i18n.js';
|
|
35
35
|
export { registerAuthHost } from './src/host/register_auth_host.js';
|
|
36
|
+
export { POLICY_ROUTE_OPTIONS } from './src/host/config_locks.js';
|
|
36
37
|
export { getAdminPrefix, setAdminPrefix, normalizeAdminPrefix, getAdminApiPrefix, setAdminApiPrefix, normalizeAdminApiPrefix, } from './src/host/admin_prefix.js';
|
|
37
38
|
/**
|
|
38
39
|
* Helpers de path do console de conta (`/account/*`). Um host que precisa casar
|
|
@@ -85,7 +86,7 @@ export { resolveEffectiveBotProtection } from './src/host/bot_protection.js';
|
|
|
85
86
|
// Runtime toggles (registration, require_verified_email, maintenance_mode).
|
|
86
87
|
export { SETTING_KEYS, resolveEffectiveRegistration, resolveEffectiveRequireVerifiedEmail, resolveEffectiveMaintenanceMode, resolveEffectiveAuthMethods, configLockedAuthMethods, } from './src/host/runtime_toggles.js';
|
|
87
88
|
export { resolveRegistration } from './src/define_config.js';
|
|
88
|
-
export { getAccountId, hasAccountSession, consoleLoginUrl, } from './src/host/console_session.js';
|
|
89
|
+
export { getAccountId, realAccountId, hasAccountSession, consoleLoginUrl, } from './src/host/console_session.js';
|
|
89
90
|
export { ACCOUNT_SESSION_KEY } from './src/host/middleware/account_auth.js';
|
|
90
91
|
// Session impersonation — RP-side glue that routes through the IdP's RFC 8693
|
|
91
92
|
// token-exchange (the IdP validates the admin role + audits). See
|
|
@@ -37,11 +37,13 @@ export default class AuthkitServerProvider {
|
|
|
37
37
|
// Config locks: trava as settings definidas explicitamente no defineConfig
|
|
38
38
|
// (config vence em runtime; a UI/Admin API não pode alterá-las). Fail-safe:
|
|
39
39
|
// qualquer erro → sem locks (comportamento legado).
|
|
40
|
+
let resolvedConfig = null;
|
|
40
41
|
try {
|
|
41
42
|
const value = this.app.config.get('authkit');
|
|
42
43
|
if (value) {
|
|
43
44
|
const config = (await configProvider.resolve(this.app, value));
|
|
44
45
|
if (config) {
|
|
46
|
+
resolvedConfig = config;
|
|
45
47
|
if (config.lockedSettingKeys?.length) {
|
|
46
48
|
const { setLockedSettingKeys } = await import('../src/host/config_locks.js');
|
|
47
49
|
setLockedSettingKeys(config.lockedSettingKeys);
|
|
@@ -55,6 +57,13 @@ export default class AuthkitServerProvider {
|
|
|
55
57
|
rateLimit: config.rateLimit,
|
|
56
58
|
adminEnabled: config.admin.enabled,
|
|
57
59
|
adminApiEnabled: config.adminApi.enabled,
|
|
60
|
+
// `config.sudo.methods` passa a decidir também o que é MONTADO — sem
|
|
61
|
+
// isto o host teria de repetir a lista no `registerAuthHost`, e as
|
|
62
|
+
// duas divergiriam (tela oferecendo endpoint que dá 404).
|
|
63
|
+
sudoMethods: config.sudo?.methods,
|
|
64
|
+
// Defaults estruturais de `config.routes` (o argumento ainda vence).
|
|
65
|
+
routes: typeof config.routes === 'object' ? config.routes : undefined,
|
|
66
|
+
lockedRouteOptions: config.lockedRouteOptions,
|
|
58
67
|
});
|
|
59
68
|
}
|
|
60
69
|
}
|
|
@@ -62,6 +71,15 @@ export default class AuthkitServerProvider {
|
|
|
62
71
|
catch {
|
|
63
72
|
/* sem locks / sem stash → registerAuthHost cai em opts/defaults */
|
|
64
73
|
}
|
|
74
|
+
// Auto-montagem das rotas (`config.routes`). FORA do try/catch fail-safe
|
|
75
|
+
// acima de propósito: "as rotas não subiram" não pode degradar em silêncio —
|
|
76
|
+
// seria um app inteiro em 404 sem nenhuma pista. Chama a MESMA função
|
|
77
|
+
// exportada que o `start/routes.ts` chamaria; não há segunda implementação.
|
|
78
|
+
if (resolvedConfig?.routes) {
|
|
79
|
+
const router = await this.app.container.make('router');
|
|
80
|
+
const { autoMountAuthHost } = await import('../src/host/register_auth_host.js');
|
|
81
|
+
autoMountAuthHost(router);
|
|
82
|
+
}
|
|
65
83
|
// Registra o disco "authkit" no edge.js para que os templates sejam referenciados
|
|
66
84
|
// como `authkit::login`, `authkit::account/tokens`, etc.
|
|
67
85
|
// Resolve o diretório das views tanto em produção (provider compilado em
|
|
@@ -4,6 +4,35 @@ import type { FetchLike, PwnedLogger } from '../password/pwned.js';
|
|
|
4
4
|
import type { AccountStore } from './account_store.js';
|
|
5
5
|
import { type AccountSecretEncrypter, type WebauthnCeremonies } from './lucid_store/shared.js';
|
|
6
6
|
export type { AccountSecretEncrypter, WebauthnCeremonies };
|
|
7
|
+
/**
|
|
8
|
+
* TTLs dos tokens de verificação de e-mail / troca de e-mail (plan 009).
|
|
9
|
+
*
|
|
10
|
+
* Segue o mesmo padrão input/resolved/`resolveX` usado em `define_config.ts`
|
|
11
|
+
* (ex.: `resolveOrganizations`) — mas vive AQUI, não lá: `defineConfig` não
|
|
12
|
+
* constrói o `accountStore` (o host já o entrega pronto, via
|
|
13
|
+
* `config.accountStore`, tipicamente construído por `lucidAccountStore`/
|
|
14
|
+
* `lucidStores` ANTES de chamar `defineConfig`), então não há caminho para um
|
|
15
|
+
* valor resolvido em `define_config.ts` alcançar `buildCore` sem alterar a
|
|
16
|
+
* assinatura dos métodos do `AccountStore` e todos os controllers que os
|
|
17
|
+
* chamam (como acontece com `invitationTtlHours`/`createOrgInvitation`, que
|
|
18
|
+
* recebe o TTL por parâmetro em cada chamada) — fora do escopo desta mudança.
|
|
19
|
+
* O ponto real de wiring é aqui, na construção do store.
|
|
20
|
+
*/
|
|
21
|
+
export interface EmailTokensConfigInput {
|
|
22
|
+
/** TTL do token de verificação de e-mail (cadastro). Default: 24h. */
|
|
23
|
+
verificationTtlHours?: number;
|
|
24
|
+
/**
|
|
25
|
+
* TTL do token de troca de e-mail (self-service). Default: 1h — mesma
|
|
26
|
+
* janela do reset de senha, porque reescreve o identificador de recovery
|
|
27
|
+
* da conta.
|
|
28
|
+
*/
|
|
29
|
+
changeTtlHours?: number;
|
|
30
|
+
}
|
|
31
|
+
export interface ResolvedEmailTokensConfig {
|
|
32
|
+
verificationTtlHours: number;
|
|
33
|
+
changeTtlHours: number;
|
|
34
|
+
}
|
|
35
|
+
export declare function resolveEmailTokens(input?: EmailTokensConfigInput): ResolvedEmailTokensConfig;
|
|
7
36
|
/**
|
|
8
37
|
* Serviço de encryption do app (APP_KEY), carregado LAZY via import dinâmico.
|
|
9
38
|
*
|
|
@@ -117,6 +146,12 @@ export interface LucidAccountStoreOptions {
|
|
|
117
146
|
MemberModel: any;
|
|
118
147
|
InvitationModel: any;
|
|
119
148
|
};
|
|
149
|
+
/**
|
|
150
|
+
* TTLs dos tokens de verificação de e-mail / troca de e-mail. Ver
|
|
151
|
+
* {@link EmailTokensConfigInput}. Ausente → 24h / 1h (defaults de
|
|
152
|
+
* `resolveEmailTokens`).
|
|
153
|
+
*/
|
|
154
|
+
emailTokens?: EmailTokensConfigInput;
|
|
120
155
|
}
|
|
121
156
|
/**
|
|
122
157
|
* Implementação default do {@link AccountStore} sobre um model Lucid composto
|
|
@@ -8,6 +8,12 @@ import { buildProviderIdentity } from './lucid_store/provider_identity.js';
|
|
|
8
8
|
import { hasTable, } from './lucid_store/shared.js';
|
|
9
9
|
import { buildDeletion, buildEmailVerificationStatus, buildProfile, buildStatus, hasColumn, } from './lucid_store/status_profile.js';
|
|
10
10
|
import { buildWebauthn } from './lucid_store/webauthn.js';
|
|
11
|
+
export function resolveEmailTokens(input) {
|
|
12
|
+
return {
|
|
13
|
+
verificationTtlHours: input?.verificationTtlHours ?? 24,
|
|
14
|
+
changeTtlHours: input?.changeTtlHours ?? 1,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
11
17
|
let encSvc;
|
|
12
18
|
let encLoading;
|
|
13
19
|
/** Resolve quando o `loadEncryption()` corrente terminar (sucesso OU falha). */
|
|
@@ -116,6 +122,7 @@ export function appKeyEncrypter() {
|
|
|
116
122
|
export function lucidAccountStore(Model, options = {}) {
|
|
117
123
|
const mfaIssuer = options.mfaIssuer ?? 'AuthKit';
|
|
118
124
|
const recoveryCodeCount = options.recoveryCodeCount ?? 8;
|
|
125
|
+
const emailTokens = resolveEmailTokens(options.emailTokens);
|
|
119
126
|
// Default seguro: encripta o TOTP com APP_KEY. `false` desliga (plaintext).
|
|
120
127
|
const encrypter = options.encrypter === false ? undefined : (options.encrypter ?? appKeyEncrypter());
|
|
121
128
|
const ProviderIdentityModel = options.providerIdentityModel;
|
|
@@ -145,6 +152,8 @@ export function lucidAccountStore(Model, options = {}) {
|
|
|
145
152
|
recoveryCodeCount,
|
|
146
153
|
passwords,
|
|
147
154
|
audit: options.audit,
|
|
155
|
+
emailVerificationTtlHours: emailTokens.verificationTtlHours,
|
|
156
|
+
emailChangeTtlHours: emailTokens.changeTtlHours,
|
|
148
157
|
// Encripta o segredo antes de persistir (no-op sem encrypter).
|
|
149
158
|
sealSecret: (secret) => (encrypter ? encrypter.encrypt(secret) : secret),
|
|
150
159
|
// Decripta o segredo armazenado; retorna null em falha/adulteração (no-op sem encrypter).
|
|
@@ -3,7 +3,21 @@ import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt';
|
|
|
3
3
|
import { DateTime } from 'luxon';
|
|
4
4
|
import { OTP_LOGIN_PREFIX, decodeOtpToken, encodeOtpToken, evaluateLoginOtp, generateOtpCode, hashLoginOtp, linkTokenFromOtpUrl, } from '../../host/otp_login.js';
|
|
5
5
|
import { hasColumn } from './status_profile.js';
|
|
6
|
-
|
|
6
|
+
import { generateExpiringHashedToken, generateHashedToken, parseExpiringTokenExp, rawToDbToken, rawToExpiringDbToken, sha256Hex, } from './token_hash.js';
|
|
7
|
+
/**
|
|
8
|
+
* Prefixo do token de troca de e-mail (reaproveita a coluna emailVerificationToken).
|
|
9
|
+
*
|
|
10
|
+
* Formato ARMAZENADO (plan 009): `ec:<b64email>:<exp>:sha256(<random>)` — o
|
|
11
|
+
* e-mail continua em base64url (inalterado, plan 007 não mexeu nisso), mas
|
|
12
|
+
* agora carrega uma deadline (`exp`, epoch ms) e o `random` vai HASHEADO em
|
|
13
|
+
* repouso (antes ia em claro). Ver a análise de segurança do `exp` embutido
|
|
14
|
+
* em `token_hash.ts` (`rawToExpiringDbToken`/`parseExpiringTokenExp`).
|
|
15
|
+
*
|
|
16
|
+
* Limite prático: com um e-mail codificado em base64url, o valor cabe em
|
|
17
|
+
* VARCHAR(255) para e-mails de até ~129 bytes (`3 + b64(129) + 1 + 13 + 1 +
|
|
18
|
+
* 64 = 254`); acima disso o valor gravado excederia a coluna. Nenhum e-mail
|
|
19
|
+
* real chega perto disso — é uma folga generosa, não um limite apertado.
|
|
20
|
+
*/
|
|
7
21
|
const EMAIL_CHANGE_PREFIX = 'ec:';
|
|
8
22
|
/** Prefixo do magic link (reaproveita as colunas de reset de senha). */
|
|
9
23
|
const MAGIC_LINK_PREFIX = 'ml:';
|
|
@@ -125,8 +139,11 @@ export function buildCore(ctx) {
|
|
|
125
139
|
const row = await Model.query().where('email', email).first();
|
|
126
140
|
if (!row)
|
|
127
141
|
return null;
|
|
128
|
-
|
|
129
|
-
|
|
142
|
+
// Token BRUTO devolvido ao chamador (vai pro e-mail); só o HASH (sha256 da
|
|
143
|
+
// parte aleatória) é persistido — um dump da tabela não rende um token
|
|
144
|
+
// usável (mesmo padrão de host/otp_lockout.ts).
|
|
145
|
+
const { raw: token, dbValue } = generateHashedToken();
|
|
146
|
+
row.passwordResetToken = dbValue;
|
|
130
147
|
row.passwordResetExpiresAt = DateTime.now().plus({ hours: 1 });
|
|
131
148
|
await row.save();
|
|
132
149
|
return { token, account: toAccount(row) };
|
|
@@ -134,9 +151,10 @@ export function buildCore(ctx) {
|
|
|
134
151
|
async consumePasswordResetToken(token, newPassword) {
|
|
135
152
|
// Magic links (`ml:` e `ml2:` com OTP) NÃO são tokens de reset de senha —
|
|
136
153
|
// só o fluxo de consumeMagicLinkToken pode consumi-los (não trocam senha).
|
|
154
|
+
// Checagem de prefixo acontece ANTES do hash, sobre o token BRUTO recebido.
|
|
137
155
|
if (token.startsWith(MAGIC_LINK_PREFIX) || token.startsWith(OTP_LOGIN_PREFIX))
|
|
138
156
|
return false;
|
|
139
|
-
const row = await Model.query().where('passwordResetToken', token).first();
|
|
157
|
+
const row = await Model.query().where('passwordResetToken', rawToDbToken('', token)).first();
|
|
140
158
|
if (!row)
|
|
141
159
|
return false;
|
|
142
160
|
if (!row.passwordResetExpiresAt || row.passwordResetExpiresAt < DateTime.now())
|
|
@@ -161,8 +179,11 @@ export function buildCore(ctx) {
|
|
|
161
179
|
return null;
|
|
162
180
|
// Token `ml:<random>` nas colunas de reset (sem migração); o prefixo o
|
|
163
181
|
// distingue de um token de reset de senha. Curta duração (15 min).
|
|
164
|
-
|
|
165
|
-
|
|
182
|
+
// O prefixo fica em CLARO no valor persistido — só a parte aleatória é
|
|
183
|
+
// hasheada — porque os guards de discriminação de fluxo leem esse prefixo
|
|
184
|
+
// direto do valor armazenado (ver consumePasswordResetToken/consumeMagicLinkToken).
|
|
185
|
+
const { raw: token, dbValue } = generateHashedToken(MAGIC_LINK_PREFIX);
|
|
186
|
+
row.passwordResetToken = dbValue;
|
|
166
187
|
row.passwordResetExpiresAt = DateTime.now().plus({ minutes: 15 });
|
|
167
188
|
await row.save();
|
|
168
189
|
return { token, account: toAccount(row) };
|
|
@@ -178,8 +199,13 @@ export function buildCore(ctx) {
|
|
|
178
199
|
const linkToken = linkTokenFromOtpUrl(token);
|
|
179
200
|
if (!linkToken)
|
|
180
201
|
return null;
|
|
202
|
+
// O slot armazena o HASH do link-token (não o valor bruto da URL); o
|
|
203
|
+
// padrão do LIKE precisa ser construído sobre o hash. `sha256Hex` sempre
|
|
204
|
+
// devolve hex minúsculo — sem metacaractere de LIKE, então isso não
|
|
205
|
+
// reabre a guarda de LIKE-injection que `linkTokenFromOtpUrl` já fecha.
|
|
206
|
+
const linkTokenHash = sha256Hex(linkToken);
|
|
181
207
|
const row = await Model.query()
|
|
182
|
-
.where('passwordResetToken', 'like', `${OTP_LOGIN_PREFIX}${
|
|
208
|
+
.where('passwordResetToken', 'like', `${OTP_LOGIN_PREFIX}${linkTokenHash}:%`)
|
|
183
209
|
.first();
|
|
184
210
|
if (!row)
|
|
185
211
|
return null;
|
|
@@ -192,7 +218,9 @@ export function buildCore(ctx) {
|
|
|
192
218
|
}
|
|
193
219
|
if (!token.startsWith(MAGIC_LINK_PREFIX))
|
|
194
220
|
return null;
|
|
195
|
-
const row = await Model.query()
|
|
221
|
+
const row = await Model.query()
|
|
222
|
+
.where('passwordResetToken', rawToDbToken(MAGIC_LINK_PREFIX, token))
|
|
223
|
+
.first();
|
|
196
224
|
if (!row)
|
|
197
225
|
return null;
|
|
198
226
|
if (!row.passwordResetExpiresAt || row.passwordResetExpiresAt < DateTime.now())
|
|
@@ -208,12 +236,20 @@ export function buildCore(ctx) {
|
|
|
208
236
|
const row = await Model.query().where('email', email).first();
|
|
209
237
|
if (!row)
|
|
210
238
|
return null;
|
|
239
|
+
// `linkToken` BRUTO vai só na URL; o slot armazena o HASH dele (NÃO o
|
|
240
|
+
// valor bruto) — `codeHash` já é sha256(uid:code) e NÃO é re-hasheado aqui.
|
|
211
241
|
const linkToken = randomBytes(32).toString('hex');
|
|
242
|
+
const linkTokenHash = sha256Hex(linkToken);
|
|
212
243
|
const code = generateOtpCode(opts.digits);
|
|
213
244
|
const codeHash = hashLoginOtp(uid, code);
|
|
214
245
|
const codeExpMs = DateTime.now().plus({ minutes: opts.ttlMinutes }).toMillis();
|
|
215
246
|
// Slot `ml2:` — código + link juntos, contador em 0. Ver host/otp_login.ts.
|
|
216
|
-
row.passwordResetToken = encodeOtpToken({
|
|
247
|
+
row.passwordResetToken = encodeOtpToken({
|
|
248
|
+
linkToken: linkTokenHash,
|
|
249
|
+
codeHash,
|
|
250
|
+
codeExpMs,
|
|
251
|
+
attempts: 0,
|
|
252
|
+
});
|
|
217
253
|
// O LINK herda a validade padrão do magic link (15 min); o CÓDIGO carrega o
|
|
218
254
|
// próprio `codeExpMs` (mais curto) embutido no slot.
|
|
219
255
|
row.passwordResetExpiresAt = DateTime.now().plus({ minutes: 15 });
|
|
@@ -285,8 +321,15 @@ export function buildCore(ctx) {
|
|
|
285
321
|
const row = await Model.query().where('email', email).first();
|
|
286
322
|
if (!row)
|
|
287
323
|
return null;
|
|
288
|
-
|
|
289
|
-
|
|
324
|
+
// Token `<exp>:<random>` (sem prefixo — mesma coluna de sempre); só o
|
|
325
|
+
// HASH da parte aleatória é persistido, e o `exp` (deadline, epoch ms)
|
|
326
|
+
// vai em CLARO dentro do valor gravado. Ver a análise de segurança em
|
|
327
|
+
// `token_hash.ts` (generateExpiringHashedToken): o `exp` só é confiável
|
|
328
|
+
// DEPOIS que a igualdade de hash bater — é essa igualdade que prova que
|
|
329
|
+
// ele não foi adulterado pelo cliente.
|
|
330
|
+
const expiresAt = DateTime.now().plus({ hours: ctx.emailVerificationTtlHours ?? 24 });
|
|
331
|
+
const { raw: token, dbValue } = generateExpiringHashedToken('', expiresAt);
|
|
332
|
+
row.emailVerificationToken = dbValue;
|
|
290
333
|
await row.save();
|
|
291
334
|
return { token, account: toAccount(row) };
|
|
292
335
|
},
|
|
@@ -297,9 +340,20 @@ export function buildCore(ctx) {
|
|
|
297
340
|
// fluxo de confirmEmailChange pode consumi-los.
|
|
298
341
|
if (token.startsWith(EMAIL_CHANGE_PREFIX))
|
|
299
342
|
return false;
|
|
300
|
-
const
|
|
343
|
+
const dbValue = rawToExpiringDbToken('', token);
|
|
344
|
+
const row = await Model.query().where('emailVerificationToken', dbValue).first();
|
|
301
345
|
if (!row)
|
|
302
346
|
return false;
|
|
347
|
+
// O `exp` só é lido AGORA, depois que a busca por `dbValue` já achou uma
|
|
348
|
+
// linha — esse match prova que este `exp` é o mesmo gravado no issue,
|
|
349
|
+
// não um valor que o cliente escreveu na hora. `null` (ausente/não-
|
|
350
|
+
// parseável — inclusive tokens gravados por uma versão anterior desta
|
|
351
|
+
// lib, sem `exp` nenhum) conta como EXPIRADO (fail-closed), mirando o
|
|
352
|
+
// mesmo formato de `!row.passwordResetExpiresAt || ... < DateTime.now()`
|
|
353
|
+
// usado no reset de senha.
|
|
354
|
+
const exp = parseExpiringTokenExp('', token);
|
|
355
|
+
if (exp === null || exp < DateTime.now().toMillis())
|
|
356
|
+
return false;
|
|
303
357
|
row.emailVerifiedAt = DateTime.now();
|
|
304
358
|
row.emailVerificationToken = null;
|
|
305
359
|
await row.save();
|
|
@@ -361,13 +415,19 @@ export function buildCore(ctx) {
|
|
|
361
415
|
const taken = await Model.query().where('email', newEmail).first();
|
|
362
416
|
if (taken && taken.id !== row.id)
|
|
363
417
|
return null;
|
|
364
|
-
// Token = `ec:<base64url(newEmail)>:<random>`. Reaproveita a coluna
|
|
418
|
+
// Token = `ec:<base64url(newEmail)>:<exp>:<random>`. Reaproveita a coluna
|
|
365
419
|
// emailVerificationToken (sem migração nova); o prefixo `ec:` distingue do
|
|
366
420
|
// token de verificação de cadastro. O e-mail viaja codificado no próprio
|
|
367
|
-
// token
|
|
421
|
+
// token (sem coluna extra para o "pending email"); o `exp` (deadline,
|
|
422
|
+
// epoch ms) também viaja em CLARO no token — ver a análise de segurança
|
|
423
|
+
// em `token_hash.ts` (generateExpiringHashedToken/rawToExpiringDbToken):
|
|
424
|
+
// só a parte `random` é hasheada, e é a igualdade sobre a string INTEIRA
|
|
425
|
+
// (prefixo + e-mail + exp + hash) que impede o cliente de adulterar o
|
|
426
|
+
// `exp` sem invalidar o próprio token.
|
|
368
427
|
const encodedEmail = Buffer.from(newEmail, 'utf8').toString('base64url');
|
|
369
|
-
const
|
|
370
|
-
|
|
428
|
+
const expiresAt = DateTime.now().plus({ hours: ctx.emailChangeTtlHours ?? 1 });
|
|
429
|
+
const { raw: token, dbValue } = generateExpiringHashedToken(`${EMAIL_CHANGE_PREFIX}${encodedEmail}:`, expiresAt);
|
|
430
|
+
row.emailVerificationToken = dbValue;
|
|
371
431
|
await row.save();
|
|
372
432
|
return { token, account: toAccount(row), newEmail };
|
|
373
433
|
},
|
|
@@ -375,8 +435,9 @@ export function buildCore(ctx) {
|
|
|
375
435
|
if (!token || !token.startsWith(EMAIL_CHANGE_PREFIX))
|
|
376
436
|
return { ok: false };
|
|
377
437
|
const parts = token.split(':');
|
|
378
|
-
// Forma esperada: ['ec', '<b64email>', '<random>']
|
|
379
|
-
|
|
438
|
+
// Forma esperada (plan 009): ['ec', '<b64email>', '<exp>', '<random>'] —
|
|
439
|
+
// 4 partes (era 3 antes de embutir o `exp`).
|
|
440
|
+
if (parts.length !== 4)
|
|
380
441
|
return { ok: false };
|
|
381
442
|
let newEmail;
|
|
382
443
|
try {
|
|
@@ -387,9 +448,17 @@ export function buildCore(ctx) {
|
|
|
387
448
|
}
|
|
388
449
|
if (!newEmail)
|
|
389
450
|
return { ok: false };
|
|
390
|
-
const
|
|
451
|
+
const prefix = `${EMAIL_CHANGE_PREFIX}${parts[1]}:`;
|
|
452
|
+
const dbValue = rawToExpiringDbToken(prefix, token);
|
|
453
|
+
const row = await Model.query().where('emailVerificationToken', dbValue).first();
|
|
391
454
|
if (!row)
|
|
392
455
|
return { ok: false };
|
|
456
|
+
// O `exp` só é lido DEPOIS do match acima (mesmo raciocínio de
|
|
457
|
+
// consumeEmailVerificationToken) — `null` (ausente/não-parseável) conta
|
|
458
|
+
// como EXPIRADO, fail-closed.
|
|
459
|
+
const exp = parseExpiringTokenExp(prefix, token);
|
|
460
|
+
if (exp === null || exp < DateTime.now().toMillis())
|
|
461
|
+
return { ok: false };
|
|
393
462
|
// Defesa contra corrida: o e-mail pode ter sido tomado entre o pedido e a
|
|
394
463
|
// confirmação por outra conta.
|
|
395
464
|
const taken = await Model.query().where('email', newEmail).first();
|
|
@@ -58,6 +58,20 @@ export interface LucidStoreContext {
|
|
|
58
58
|
* pela verificação de histórico de senhas.
|
|
59
59
|
*/
|
|
60
60
|
nativeVerifyHash?: (hash: string, plain: string) => Promise<boolean>;
|
|
61
|
+
/**
|
|
62
|
+
* TTL (em horas) do token de verificação de e-mail (cadastro). Default: 24h
|
|
63
|
+
* (aplicado em `buildCore` quando ausente). Ver `resolveEmailTokens` em
|
|
64
|
+
* `lucid_account_store.ts` — não vem de `define_config.ts` porque o
|
|
65
|
+
* `defineConfig` não constrói o `accountStore` (o host já o entrega pronto
|
|
66
|
+
* via `config.accountStore`); o valor precisa chegar aqui na construção.
|
|
67
|
+
*/
|
|
68
|
+
emailVerificationTtlHours?: number;
|
|
69
|
+
/**
|
|
70
|
+
* TTL (em horas) do token de troca de e-mail (self-service). Default: 1h —
|
|
71
|
+
* mesma janela do reset de senha, porque reescreve o identificador de
|
|
72
|
+
* recovery da conta. Ver nota de `emailVerificationTtlHours`.
|
|
73
|
+
*/
|
|
74
|
+
emailChangeTtlHours?: number;
|
|
61
75
|
}
|
|
62
76
|
export declare const sha256: (value: string) => string;
|
|
63
77
|
/** Recovery code legível: 10 chars hex em duas metades (ex.: a1b2c-3d4e5). */
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { DateTime } from 'luxon';
|
|
2
|
+
/**
|
|
3
|
+
* Hashing dos tokens armazenados nas colunas `passwordResetToken` /
|
|
4
|
+
* `emailVerificationToken` (reset de senha, magic link, magic link com OTP).
|
|
5
|
+
*
|
|
6
|
+
* Mesmo padrão de `host/otp_lockout.ts` (`generateOtpUnlockToken` /
|
|
7
|
+
* `rawToDbOtpUnlockToken`): hasheia SÓ a parte aleatória do token — o prefixo
|
|
8
|
+
* (`ml:`, `ml2:`, etc., ou nenhum, no caso do reset) fica em texto claro no
|
|
9
|
+
* valor gravado, porque os guards de discriminação de fluxo (ex.:
|
|
10
|
+
* `consumePasswordResetToken` recusando `ml:`/`ml2:`; `consumeMagicLinkToken`
|
|
11
|
+
* recusando o que não é `ml:`) leem esse prefixo direto do valor armazenado —
|
|
12
|
+
* hashear a string inteira (prefixo incluso) tornaria essa leitura impossível.
|
|
13
|
+
*
|
|
14
|
+
* Motivo de existir como módulo próprio (em vez de reusar `otp_lockout.ts`):
|
|
15
|
+
* aquele arquivo já está correto e é o EXEMPLAR, não o alvo — mantê-lo intocado
|
|
16
|
+
* evita qualquer risco de regressão nele por este changeset.
|
|
17
|
+
*/
|
|
18
|
+
/** sha256 hex de uma string. */
|
|
19
|
+
export declare function sha256Hex(value: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Gera um token `prefix + <64 hex aleatórios>` e devolve tanto o valor BRUTO
|
|
22
|
+
* (vai para o e-mail/URL) quanto o valor a persistir no DB
|
|
23
|
+
* (`prefix + sha256(<parte aleatória>)`).
|
|
24
|
+
*
|
|
25
|
+
* `prefix` pode ser vazio (caso do reset de senha, que não tem prefixo).
|
|
26
|
+
*/
|
|
27
|
+
export declare function generateHashedToken(prefix?: string): {
|
|
28
|
+
raw: string;
|
|
29
|
+
dbValue: string;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Converte um token BRUTO recebido (com o `prefix` esperado) no valor de DB
|
|
33
|
+
* correspondente (`prefix + sha256(<parte aleatória>)`), para lookup por
|
|
34
|
+
* igualdade. Não valida o prefixo — o chamador já deve ter confirmado
|
|
35
|
+
* `raw.startsWith(prefix)` antes de chegar aqui (guards de discriminação).
|
|
36
|
+
*/
|
|
37
|
+
export declare function rawToDbToken(prefix: string, raw: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* Gera um token expirável `<prefix><exp>:<random>` e devolve tanto o BRUTO
|
|
40
|
+
* (vai pro e-mail/URL) quanto o valor de DB (`<prefix><exp>:sha256(<random>)`).
|
|
41
|
+
*
|
|
42
|
+
* `prefix` pode incluir estrutura própria (ex.: `ec:<b64email>:` na troca de
|
|
43
|
+
* e-mail) — é só um prefixo literal, igual em `generateHashedToken`. `expiresAt`
|
|
44
|
+
* é a deadline; é serializada como epoch ms (`toMillis()`) dentro do próprio
|
|
45
|
+
* token — ver o comentário do bloco acima para a análise de segurança.
|
|
46
|
+
*/
|
|
47
|
+
export declare function generateExpiringHashedToken(prefix: string, expiresAt: DateTime): {
|
|
48
|
+
raw: string;
|
|
49
|
+
dbValue: string;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Reconstrói o valor de DB (`<prefix><exp>:sha256(<random>)`) a partir de um
|
|
53
|
+
* token BRUTO recebido, para lookup por igualdade — mesmo padrão de
|
|
54
|
+
* `rawToDbToken`, mas preservando o segmento `<exp>` em claro (ele faz parte
|
|
55
|
+
* da string comparada; não é hasheado). Usa o `exp` EXATAMENTE como veio no
|
|
56
|
+
* token recebido (mesmo que adulterado/ilegível) — é a comparação por
|
|
57
|
+
* igualdade no banco que decide se ele é válido, não este helper.
|
|
58
|
+
*
|
|
59
|
+
* Se o token não tiver o separador esperado, devolve um valor que não pode
|
|
60
|
+
* bater com nada gerado por `generateExpiringHashedToken` (fail-closed: a
|
|
61
|
+
* query simplesmente não encontra linha).
|
|
62
|
+
*/
|
|
63
|
+
export declare function rawToExpiringDbToken(prefix: string, raw: string): string;
|
|
64
|
+
/**
|
|
65
|
+
* Extrai o `exp` (epoch ms) de um token BRUTO recebido, sem consultar o banco.
|
|
66
|
+
*
|
|
67
|
+
* ⚠️ O valor devolvido só deve ser CONSULTADO pelo chamador depois que a busca
|
|
68
|
+
* por `rawToExpiringDbToken` já tiver encontrado uma linha — é esse match que
|
|
69
|
+
* prova que este `exp` é o mesmo que foi gravado no `issue*`, não um valor que
|
|
70
|
+
* o cliente inventou. Ler o `exp` antes do match (ou usá-lo quando a busca não
|
|
71
|
+
* achou linha nenhuma) não prova nada.
|
|
72
|
+
*
|
|
73
|
+
* Devolve `null` quando o segmento `exp` está ausente (sem separador) OU não é
|
|
74
|
+
* uma sequência de dígitos — o chamador DEVE tratar `null` como EXPIRADO
|
|
75
|
+
* (fail-closed), nunca como "sem prazo". Isso cobre tanto tokens adulterados
|
|
76
|
+
* quanto tokens gravados por uma versão anterior desta lib (sem `exp`
|
|
77
|
+
* nenhum).
|
|
78
|
+
*/
|
|
79
|
+
export declare function parseExpiringTokenExp(prefix: string, raw: string): number | null;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Hashing dos tokens armazenados nas colunas `passwordResetToken` /
|
|
4
|
+
* `emailVerificationToken` (reset de senha, magic link, magic link com OTP).
|
|
5
|
+
*
|
|
6
|
+
* Mesmo padrão de `host/otp_lockout.ts` (`generateOtpUnlockToken` /
|
|
7
|
+
* `rawToDbOtpUnlockToken`): hasheia SÓ a parte aleatória do token — o prefixo
|
|
8
|
+
* (`ml:`, `ml2:`, etc., ou nenhum, no caso do reset) fica em texto claro no
|
|
9
|
+
* valor gravado, porque os guards de discriminação de fluxo (ex.:
|
|
10
|
+
* `consumePasswordResetToken` recusando `ml:`/`ml2:`; `consumeMagicLinkToken`
|
|
11
|
+
* recusando o que não é `ml:`) leem esse prefixo direto do valor armazenado —
|
|
12
|
+
* hashear a string inteira (prefixo incluso) tornaria essa leitura impossível.
|
|
13
|
+
*
|
|
14
|
+
* Motivo de existir como módulo próprio (em vez de reusar `otp_lockout.ts`):
|
|
15
|
+
* aquele arquivo já está correto e é o EXEMPLAR, não o alvo — mantê-lo intocado
|
|
16
|
+
* evita qualquer risco de regressão nele por este changeset.
|
|
17
|
+
*/
|
|
18
|
+
/** sha256 hex de uma string. */
|
|
19
|
+
export function sha256Hex(value) {
|
|
20
|
+
return createHash('sha256').update(value).digest('hex');
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Gera um token `prefix + <64 hex aleatórios>` e devolve tanto o valor BRUTO
|
|
24
|
+
* (vai para o e-mail/URL) quanto o valor a persistir no DB
|
|
25
|
+
* (`prefix + sha256(<parte aleatória>)`).
|
|
26
|
+
*
|
|
27
|
+
* `prefix` pode ser vazio (caso do reset de senha, que não tem prefixo).
|
|
28
|
+
*/
|
|
29
|
+
export function generateHashedToken(prefix = '') {
|
|
30
|
+
const random = randomBytes(32).toString('hex');
|
|
31
|
+
return { raw: `${prefix}${random}`, dbValue: `${prefix}${sha256Hex(random)}` };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Converte um token BRUTO recebido (com o `prefix` esperado) no valor de DB
|
|
35
|
+
* correspondente (`prefix + sha256(<parte aleatória>)`), para lookup por
|
|
36
|
+
* igualdade. Não valida o prefixo — o chamador já deve ter confirmado
|
|
37
|
+
* `raw.startsWith(prefix)` antes de chegar aqui (guards de discriminação).
|
|
38
|
+
*/
|
|
39
|
+
export function rawToDbToken(prefix, raw) {
|
|
40
|
+
const random = raw.startsWith(prefix) ? raw.slice(prefix.length) : raw;
|
|
41
|
+
return `${prefix}${sha256Hex(random)}`;
|
|
42
|
+
}
|
|
43
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
44
|
+
// Variante EXPIRÁVEL (plan 009): `emailVerificationToken` (verificação de
|
|
45
|
+
// cadastro, sem prefixo) e `ec:` (troca de e-mail) embutem uma deadline
|
|
46
|
+
// (`exp`, epoch ms) dentro do PRÓPRIO token — não numa coluna separada.
|
|
47
|
+
//
|
|
48
|
+
// Por quê: esta lib não é dona da tabela `users` (ver header de
|
|
49
|
+
// `src/schema/ensure.ts`), então não pode adicionar uma coluna
|
|
50
|
+
// `emailVerificationExpiresAt` como o reset de senha tem. A alternativa óbvia
|
|
51
|
+
// — embutir o `exp` no valor que o cliente devolve — parecia insegura antes de
|
|
52
|
+
// 007: um valor sob controle do cliente poderia ter o `exp` adulterado.
|
|
53
|
+
//
|
|
54
|
+
// 007 fechou essa brecha ao fazer o LOOKUP reconstruir o valor de DB a partir
|
|
55
|
+
// do token bruto recebido e buscar por IGUALDADE (em vez de ler a linha
|
|
56
|
+
// primeiro e comparar depois). Isso vale integralmente aqui: o `exp` fica em
|
|
57
|
+
// CLARO dentro do valor gravado (`prefix<exp>:sha256(<random>)`), mas ele faz
|
|
58
|
+
// parte da STRING inteira que é comparada por igualdade na query. Se o
|
|
59
|
+
// cliente reescrever o `exp` no token que devolve, a reconstrução
|
|
60
|
+
// (`prefix<expAdulterado>:sha256(<random>)`) produz uma string DIFERENTE da
|
|
61
|
+
// que está gravada — a query não encontra NENHUMA linha, e o token é
|
|
62
|
+
// simplesmente rejeitado.
|
|
63
|
+
//
|
|
64
|
+
// Ou seja: o MATCH em si (achar a linha) já prova que o `exp` que acabou de
|
|
65
|
+
// ser lido é exatamente o mesmo que foi gerado no `issue*` — nenhuma
|
|
66
|
+
// assinatura/HMAC separada é necessária. É só DEPOIS desse match que o `exp`
|
|
67
|
+
// pode ser avaliado contra o relógio com confiança. NÃO SIMPLIFIQUE isso lendo
|
|
68
|
+
// o `exp` do token ANTES do match, nem tratando um `exp` ausente/inválido como
|
|
69
|
+
// "sem prazo" — as duas coisas reabririam a adulteração que este desenho
|
|
70
|
+
// fecha (ver testes de mutação no plano 009).
|
|
71
|
+
//
|
|
72
|
+
// Este raciocínio SÓ é válido enquanto o token continuar hasheado em repouso
|
|
73
|
+
// (a parte `random` do valor). Se algum dia alguém reverter o hashing, o `exp`
|
|
74
|
+
// embutido volta a ficar sob controle total do cliente.
|
|
75
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
76
|
+
/**
|
|
77
|
+
* Gera um token expirável `<prefix><exp>:<random>` e devolve tanto o BRUTO
|
|
78
|
+
* (vai pro e-mail/URL) quanto o valor de DB (`<prefix><exp>:sha256(<random>)`).
|
|
79
|
+
*
|
|
80
|
+
* `prefix` pode incluir estrutura própria (ex.: `ec:<b64email>:` na troca de
|
|
81
|
+
* e-mail) — é só um prefixo literal, igual em `generateHashedToken`. `expiresAt`
|
|
82
|
+
* é a deadline; é serializada como epoch ms (`toMillis()`) dentro do próprio
|
|
83
|
+
* token — ver o comentário do bloco acima para a análise de segurança.
|
|
84
|
+
*/
|
|
85
|
+
export function generateExpiringHashedToken(prefix, expiresAt) {
|
|
86
|
+
const exp = expiresAt.toMillis();
|
|
87
|
+
const random = randomBytes(24).toString('hex');
|
|
88
|
+
return {
|
|
89
|
+
raw: `${prefix}${exp}:${random}`,
|
|
90
|
+
dbValue: `${prefix}${exp}:${sha256Hex(random)}`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Separa `<exp>:<random>` de um token BRUTO `<prefix><exp>:<random>` (depois de
|
|
95
|
+
* remover o `prefix`). `null` se não há `:` após o prefixo (token mal formado —
|
|
96
|
+
* nem `exp` nem `random` são extraíveis).
|
|
97
|
+
*/
|
|
98
|
+
function splitExpiringRaw(prefix, raw) {
|
|
99
|
+
const rest = raw.startsWith(prefix) ? raw.slice(prefix.length) : raw;
|
|
100
|
+
const sepIdx = rest.indexOf(':');
|
|
101
|
+
if (sepIdx === -1)
|
|
102
|
+
return null;
|
|
103
|
+
return { expPart: rest.slice(0, sepIdx), random: rest.slice(sepIdx + 1) };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Reconstrói o valor de DB (`<prefix><exp>:sha256(<random>)`) a partir de um
|
|
107
|
+
* token BRUTO recebido, para lookup por igualdade — mesmo padrão de
|
|
108
|
+
* `rawToDbToken`, mas preservando o segmento `<exp>` em claro (ele faz parte
|
|
109
|
+
* da string comparada; não é hasheado). Usa o `exp` EXATAMENTE como veio no
|
|
110
|
+
* token recebido (mesmo que adulterado/ilegível) — é a comparação por
|
|
111
|
+
* igualdade no banco que decide se ele é válido, não este helper.
|
|
112
|
+
*
|
|
113
|
+
* Se o token não tiver o separador esperado, devolve um valor que não pode
|
|
114
|
+
* bater com nada gerado por `generateExpiringHashedToken` (fail-closed: a
|
|
115
|
+
* query simplesmente não encontra linha).
|
|
116
|
+
*/
|
|
117
|
+
export function rawToExpiringDbToken(prefix, raw) {
|
|
118
|
+
const parts = splitExpiringRaw(prefix, raw);
|
|
119
|
+
if (!parts) {
|
|
120
|
+
const rest = raw.startsWith(prefix) ? raw.slice(prefix.length) : raw;
|
|
121
|
+
return `${prefix}${sha256Hex(rest)}`;
|
|
122
|
+
}
|
|
123
|
+
return `${prefix}${parts.expPart}:${sha256Hex(parts.random)}`;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Extrai o `exp` (epoch ms) de um token BRUTO recebido, sem consultar o banco.
|
|
127
|
+
*
|
|
128
|
+
* ⚠️ O valor devolvido só deve ser CONSULTADO pelo chamador depois que a busca
|
|
129
|
+
* por `rawToExpiringDbToken` já tiver encontrado uma linha — é esse match que
|
|
130
|
+
* prova que este `exp` é o mesmo que foi gravado no `issue*`, não um valor que
|
|
131
|
+
* o cliente inventou. Ler o `exp` antes do match (ou usá-lo quando a busca não
|
|
132
|
+
* achou linha nenhuma) não prova nada.
|
|
133
|
+
*
|
|
134
|
+
* Devolve `null` quando o segmento `exp` está ausente (sem separador) OU não é
|
|
135
|
+
* uma sequência de dígitos — o chamador DEVE tratar `null` como EXPIRADO
|
|
136
|
+
* (fail-closed), nunca como "sem prazo". Isso cobre tanto tokens adulterados
|
|
137
|
+
* quanto tokens gravados por uma versão anterior desta lib (sem `exp`
|
|
138
|
+
* nenhum).
|
|
139
|
+
*/
|
|
140
|
+
export function parseExpiringTokenExp(prefix, raw) {
|
|
141
|
+
const parts = splitExpiringRaw(prefix, raw);
|
|
142
|
+
if (!parts)
|
|
143
|
+
return null;
|
|
144
|
+
return /^\d+$/.test(parts.expPart) ? Number(parts.expPart) : null;
|
|
145
|
+
}
|