@stacksjs/auth 0.70.88 → 0.70.90
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/authentication.d.ts +43 -0
- package/dist/authentication.js +413 -0
- package/dist/authenticator.d.ts +17 -0
- package/dist/authenticator.js +14 -0
- package/dist/authorizable.d.ts +77 -0
- package/dist/authorizable.js +28 -0
- package/dist/client.d.ts +22 -0
- package/dist/client.js +26 -0
- package/dist/email-verification.d.ts +29 -0
- package/dist/email-verification.js +111 -0
- package/dist/gate.d.ts +180 -0
- package/dist/gate.js +203 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +26 -0
- package/dist/internal-constants.d.ts +19 -0
- package/dist/internal-constants.js +1 -0
- package/dist/middleware.d.ts +14 -0
- package/dist/middleware.js +28 -0
- package/dist/passkey.d.ts +97 -0
- package/dist/passkey.js +76 -0
- package/dist/password/reset.d.ts +10 -0
- package/dist/password/reset.js +156 -0
- package/dist/policy.d.ts +33 -0
- package/dist/policy.js +91 -0
- package/dist/rate-limiter.d.ts +44 -0
- package/dist/rate-limiter.js +91 -0
- package/dist/rbac-seed.d.ts +24 -0
- package/dist/rbac-seed.js +30 -0
- package/dist/rbac-store-bqb.d.ts +18 -0
- package/dist/rbac-store-bqb.js +180 -0
- package/dist/rbac.d.ts +265 -0
- package/dist/rbac.js +324 -0
- package/dist/register.d.ts +3 -0
- package/dist/register.js +43 -0
- package/dist/session-auth.d.ts +67 -0
- package/dist/session-auth.js +151 -0
- package/dist/team.d.ts +121 -0
- package/dist/team.js +88 -0
- package/dist/token.d.ts +16 -0
- package/dist/token.js +21 -0
- package/dist/tokens.d.ts +284 -0
- package/dist/tokens.js +489 -0
- package/dist/two-factor.d.ts +67 -0
- package/dist/two-factor.js +113 -0
- package/dist/user.d.ts +34 -0
- package/dist/user.js +41 -0
- package/package.json +3 -3
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { User } from '@stacksjs/orm';
|
|
2
|
+
import type { Insertable } from '@stacksjs/database';
|
|
3
|
+
import type { VerifiedRegistrationResponse } from '@stacksjs/ts-auth';
|
|
4
|
+
// Re-export WebAuthn types from ts-auth
|
|
5
|
+
export type {
|
|
6
|
+
VerifiedRegistrationResponse,
|
|
7
|
+
VerifiedAuthenticationResponse,
|
|
8
|
+
RegistrationCredential,
|
|
9
|
+
AuthenticationCredential,
|
|
10
|
+
PublicKeyCredentialCreationOptions,
|
|
11
|
+
PublicKeyCredentialRequestOptions,
|
|
12
|
+
RegistrationOptions,
|
|
13
|
+
AuthenticationOptions,
|
|
14
|
+
} from '@stacksjs/ts-auth';
|
|
15
|
+
export declare function getUserPasskeys(userId: number): Promise<PasskeyAttribute[]>;
|
|
16
|
+
export declare function getUserPasskey(userId: number, passkeyId: string): Promise<PasskeyAttribute | undefined>;
|
|
17
|
+
/**
|
|
18
|
+
* Persist the post-verification authenticator counter and refresh the
|
|
19
|
+
* passkey's last-used timestamp. WebAuthn's anti-cloning guarantee
|
|
20
|
+
* depends on the relying party rejecting any authentication whose
|
|
21
|
+
* `newCounter` is **not strictly greater** than the stored value —
|
|
22
|
+
* authenticators monotonically increment their counter on every use,
|
|
23
|
+
* so a counter that doesn't advance (or goes backwards) signals a
|
|
24
|
+
* cloned or replayed credential.
|
|
25
|
+
*
|
|
26
|
+
* Returns `true` when the counter was updated successfully; `false`
|
|
27
|
+
* when the new counter is not greater than the stored one (the
|
|
28
|
+
* authentication MUST be rejected by the caller in that case).
|
|
29
|
+
* stacksjs/stacks#1861 A-4.
|
|
30
|
+
*/
|
|
31
|
+
export declare function updatePasskeyCounter(userId: number, passkeyId: string, newCounter: number): Promise<boolean>;
|
|
32
|
+
export declare function setCurrentRegistrationOptions(user: UserModel, verified: VerifiedRegistrationResponse): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Persist a server-issued WebAuthn challenge for the given user +
|
|
35
|
+
* purpose. Deletes any prior challenge for the same (user, purpose)
|
|
36
|
+
* pair so a fresh `generateOptions` invalidates the previous one.
|
|
37
|
+
*
|
|
38
|
+
* The unique index on `(user_id, purpose)` enforces single-outstanding
|
|
39
|
+
* at the DB layer; this delete makes the upsert safe even on installs
|
|
40
|
+
* that ran an earlier auth:setup before the unique index existed.
|
|
41
|
+
*/
|
|
42
|
+
export declare function storeWebAuthnChallenge(userId: number, challenge: string | Uint8Array, purpose: WebAuthnChallengePurpose, ttlSeconds?: number): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Read + delete (single-use) a WebAuthn challenge for the given user
|
|
45
|
+
* + purpose. Returns `null` when no outstanding challenge exists or
|
|
46
|
+
* when the stored challenge has expired.
|
|
47
|
+
*
|
|
48
|
+
* Single-use semantics matter: a successful verify must invalidate
|
|
49
|
+
* the challenge so a captured assertion can't be replayed even within
|
|
50
|
+
* the TTL window. Callers MUST treat a `null` return as a verification
|
|
51
|
+
* failure.
|
|
52
|
+
*/
|
|
53
|
+
export declare function consumeWebAuthnChallenge(userId: number, purpose: WebAuthnChallengePurpose): Promise<Uint8Array | null>;
|
|
54
|
+
export declare interface PasskeyAttribute {
|
|
55
|
+
id: string
|
|
56
|
+
cred_public_key: string
|
|
57
|
+
user_id: number
|
|
58
|
+
webauthn_user_id: string
|
|
59
|
+
counter: number
|
|
60
|
+
credential_type: string
|
|
61
|
+
device_type: string
|
|
62
|
+
backup_eligible: boolean
|
|
63
|
+
backup_status: boolean
|
|
64
|
+
transports?: string
|
|
65
|
+
created_at?: Date
|
|
66
|
+
last_used_at: string
|
|
67
|
+
}
|
|
68
|
+
declare type UserModel = NonNullable<Awaited<ReturnType<typeof User.find>>>;
|
|
69
|
+
declare type PasskeyInsertable = Insertable<PasskeyAttribute>;
|
|
70
|
+
// =============================================================================
|
|
71
|
+
// WebAuthn challenge persistence (stacksjs/stacks#1866)
|
|
72
|
+
// =============================================================================
|
|
73
|
+
//
|
|
74
|
+
// WebAuthn relying parties MUST verify the assertion's challenge
|
|
75
|
+
// against a server-issued nonce. Previously Stacks returned the
|
|
76
|
+
// challenge in `generateOptions` and trusted the client to echo it
|
|
77
|
+
// back on verify — so any attacker who captured the assertion AND the
|
|
78
|
+
// challenge could replay the response. Persisting the challenge
|
|
79
|
+
// server-side and consuming it on verify closes that gap.
|
|
80
|
+
//
|
|
81
|
+
// The default TTL is 5 minutes — long enough for slow biometric
|
|
82
|
+
// flows, short enough to bound the replay window. Override per-call
|
|
83
|
+
// via the `ttlSeconds` parameter.
|
|
84
|
+
export type WebAuthnChallengePurpose = 'registration' | 'authentication';
|
|
85
|
+
// Re-export WebAuthn functions from ts-auth
|
|
86
|
+
export {
|
|
87
|
+
generateRegistrationOptions,
|
|
88
|
+
generateAuthenticationOptions,
|
|
89
|
+
verifyRegistrationResponse,
|
|
90
|
+
verifyAuthenticationResponse,
|
|
91
|
+
// Browser-side functions (for client use)
|
|
92
|
+
startRegistration,
|
|
93
|
+
startAuthentication,
|
|
94
|
+
browserSupportsWebAuthn,
|
|
95
|
+
browserSupportsWebAuthnAutofill,
|
|
96
|
+
platformAuthenticatorIsAvailable,
|
|
97
|
+
} from '@stacksjs/ts-auth';
|
package/dist/passkey.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { db } from "@stacksjs/database";
|
|
3
|
+
import { User } from "@stacksjs/orm";
|
|
4
|
+
export {
|
|
5
|
+
generateRegistrationOptions,
|
|
6
|
+
generateAuthenticationOptions,
|
|
7
|
+
verifyRegistrationResponse,
|
|
8
|
+
verifyAuthenticationResponse,
|
|
9
|
+
startRegistration,
|
|
10
|
+
startAuthentication,
|
|
11
|
+
browserSupportsWebAuthn,
|
|
12
|
+
browserSupportsWebAuthnAutofill,
|
|
13
|
+
platformAuthenticatorIsAvailable
|
|
14
|
+
} from "@stacksjs/ts-auth";
|
|
15
|
+
export async function getUserPasskeys(userId) {
|
|
16
|
+
return await db.selectFrom("passkeys").selectAll().where("user_id", "=", userId).execute();
|
|
17
|
+
}
|
|
18
|
+
export async function getUserPasskey(userId, passkeyId) {
|
|
19
|
+
return await db.selectFrom("passkeys").selectAll().where("id", "=", passkeyId).where("user_id", "=", userId).executeTakeFirst();
|
|
20
|
+
}
|
|
21
|
+
export async function updatePasskeyCounter(userId, passkeyId, newCounter) {
|
|
22
|
+
const passkey = await getUserPasskey(userId, passkeyId);
|
|
23
|
+
if (!passkey)
|
|
24
|
+
return !1;
|
|
25
|
+
const stored = Number(passkey.counter ?? 0);
|
|
26
|
+
if (newCounter <= stored && !(newCounter === 0 && stored === 0))
|
|
27
|
+
return !1;
|
|
28
|
+
await db.updateTable("passkeys").set({ counter: newCounter, last_used_at: formatDateTime() }).where("id", "=", passkeyId).where("user_id", "=", userId).execute();
|
|
29
|
+
return !0;
|
|
30
|
+
}
|
|
31
|
+
export async function setCurrentRegistrationOptions(user, verified) {
|
|
32
|
+
const credentialId = verified.registrationInfo?.credential.id, credentialPublicKey = verified.registrationInfo?.credential.publicKey;
|
|
33
|
+
if (!credentialId)
|
|
34
|
+
throw Error("[auth/passkey] WebAuthn registration response is missing credential.id");
|
|
35
|
+
if (!credentialPublicKey)
|
|
36
|
+
throw Error("[auth/passkey] WebAuthn registration response is missing credential.publicKey");
|
|
37
|
+
const passkeyData = {
|
|
38
|
+
id: credentialId,
|
|
39
|
+
cred_public_key: JSON.stringify(credentialPublicKey),
|
|
40
|
+
user_id: user.id,
|
|
41
|
+
webauthn_user_id: user.email || "",
|
|
42
|
+
counter: verified.registrationInfo?.credential.counter || 0,
|
|
43
|
+
credential_type: verified.registrationInfo?.credentialType || "",
|
|
44
|
+
device_type: verified.registrationInfo?.credentialDeviceType || "",
|
|
45
|
+
backup_eligible: !1,
|
|
46
|
+
backup_status: verified.registrationInfo?.credentialBackedUp || !1,
|
|
47
|
+
transports: JSON.stringify(["internal"]),
|
|
48
|
+
last_used_at: formatDateTime()
|
|
49
|
+
};
|
|
50
|
+
await db.insertInto("passkeys").values(passkeyData).executeTakeFirstOrThrow();
|
|
51
|
+
}
|
|
52
|
+
function formatDateTime() {
|
|
53
|
+
const date = new Date, pad = (num) => String(num).padStart(2, "0"), year = date.getFullYear(), month = pad(date.getMonth() + 1), day = pad(date.getDate()), hours = pad(date.getHours()), minutes = pad(date.getMinutes()), seconds = pad(date.getSeconds());
|
|
54
|
+
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
55
|
+
}
|
|
56
|
+
const DEFAULT_CHALLENGE_TTL_SECONDS = 300;
|
|
57
|
+
export async function storeWebAuthnChallenge(userId, challenge, purpose, ttlSeconds = DEFAULT_CHALLENGE_TTL_SECONDS) {
|
|
58
|
+
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString(), encodedChallenge = typeof challenge === "string" ? challenge : Buffer.from(challenge).toString("base64url");
|
|
59
|
+
await db.deleteFrom("webauthn_challenges").where("user_id", "=", userId).where("purpose", "=", purpose).execute();
|
|
60
|
+
await db.insertInto("webauthn_challenges").values({
|
|
61
|
+
user_id: userId,
|
|
62
|
+
challenge: encodedChallenge,
|
|
63
|
+
purpose,
|
|
64
|
+
expires_at: expiresAt
|
|
65
|
+
}).execute();
|
|
66
|
+
}
|
|
67
|
+
export async function consumeWebAuthnChallenge(userId, purpose) {
|
|
68
|
+
const row = await db.selectFrom("webauthn_challenges").where("user_id", "=", userId).where("purpose", "=", purpose).selectAll().executeTakeFirst();
|
|
69
|
+
if (!row)
|
|
70
|
+
return null;
|
|
71
|
+
await db.deleteFrom("webauthn_challenges").where("user_id", "=", userId).where("purpose", "=", purpose).execute();
|
|
72
|
+
const expiresAt = row.expires_at ? new Date(String(row.expires_at)).getTime() : 0;
|
|
73
|
+
if (Date.now() > expiresAt)
|
|
74
|
+
return null;
|
|
75
|
+
return Buffer.from(String(row.challenge), "base64url");
|
|
76
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare function passwordResets(email: string): PasswordResetActions;
|
|
2
|
+
export declare interface PasswordResetResult {
|
|
3
|
+
success: boolean
|
|
4
|
+
message?: string
|
|
5
|
+
}
|
|
6
|
+
export declare interface PasswordResetActions {
|
|
7
|
+
sendEmail: () => Promise<void>
|
|
8
|
+
verifyToken: (token: string) => Promise<boolean>
|
|
9
|
+
resetPassword: (token: string, newPassword: string) => Promise<PasswordResetResult>
|
|
10
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { config } from "@stacksjs/config";
|
|
3
|
+
import { db } from "@stacksjs/database";
|
|
4
|
+
import { mail, template } from "@stacksjs/email";
|
|
5
|
+
import { log } from "@stacksjs/logging";
|
|
6
|
+
import { formatDate } from "@stacksjs/orm";
|
|
7
|
+
import { makeHash, verifyHash } from "@stacksjs/security";
|
|
8
|
+
import { sessionDestroyAll } from "../session-auth";
|
|
9
|
+
import { revokeAllTokens } from "../tokens";
|
|
10
|
+
function getTokenExpireMinutes() {
|
|
11
|
+
return config.auth.passwordReset?.expire ?? 60;
|
|
12
|
+
}
|
|
13
|
+
function isWithinExpiry(row) {
|
|
14
|
+
const explicit = row.expires_at;
|
|
15
|
+
if (typeof explicit === "string" || explicit instanceof Date)
|
|
16
|
+
return new Date(explicit).getTime() > Date.now();
|
|
17
|
+
const created = row.created_at;
|
|
18
|
+
if (typeof created === "string" || created instanceof Date) {
|
|
19
|
+
const expireMinutes = getTokenExpireMinutes();
|
|
20
|
+
return new Date(created).getTime() + expireMinutes * 60000 > Date.now();
|
|
21
|
+
}
|
|
22
|
+
return !1;
|
|
23
|
+
}
|
|
24
|
+
async function sendPasswordChangedNotification(userEmail) {
|
|
25
|
+
const appName = config.app.name || "Stacks", supportEmail = config.app.supportEmail || config.email?.from?.address || "", changedAt = new Date().toLocaleString("en-US", {
|
|
26
|
+
dateStyle: "full",
|
|
27
|
+
timeStyle: "short"
|
|
28
|
+
});
|
|
29
|
+
try {
|
|
30
|
+
const { html, text } = await template("password-changed", {
|
|
31
|
+
subject: `Your ${appName} password has been changed`,
|
|
32
|
+
variables: {
|
|
33
|
+
changedAt,
|
|
34
|
+
supportEmail
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
if (!html && !text) {
|
|
38
|
+
await mail.send({
|
|
39
|
+
to: userEmail,
|
|
40
|
+
subject: `Your ${appName} password has been changed`,
|
|
41
|
+
text: `Your ${appName} password was changed on ${changedAt}.${supportEmail ? ` If this wasn't you, contact ${supportEmail}.` : ""}`
|
|
42
|
+
});
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
await mail.send({
|
|
46
|
+
to: userEmail,
|
|
47
|
+
subject: `Your ${appName} password has been changed`,
|
|
48
|
+
text,
|
|
49
|
+
html
|
|
50
|
+
});
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error("[PasswordReset] Failed to send password changed notification:", error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function passwordResets(email) {
|
|
56
|
+
function generateResetToken() {
|
|
57
|
+
return randomBytes(32).toString("hex");
|
|
58
|
+
}
|
|
59
|
+
async function createResetToken() {
|
|
60
|
+
const token = generateResetToken(), hashedToken = await makeHash(token, { algorithm: "bcrypt" }), expireMinutes = getTokenExpireMinutes(), expiresAt = new Date(Date.now() + expireMinutes * 60000).toISOString();
|
|
61
|
+
await db.deleteFrom("password_resets").where("email", "=", email).execute();
|
|
62
|
+
await db.insertInto("password_resets").values({
|
|
63
|
+
email,
|
|
64
|
+
token: hashedToken,
|
|
65
|
+
expires_at: expiresAt
|
|
66
|
+
}).executeTakeFirst();
|
|
67
|
+
return token;
|
|
68
|
+
}
|
|
69
|
+
async function sendEmail() {
|
|
70
|
+
if (!await db.selectFrom("users").where("email", "=", email).selectAll().executeTakeFirst())
|
|
71
|
+
return;
|
|
72
|
+
const token = await createResetToken(), expireMinutes = getTokenExpireMinutes(), appName = config.app.name || "Stacks", base = config.app.url ? `https://${config.app.url}` : `http://localhost:${process.env.PORT || "3000"}`, filled = (config.auth.passwordReset?.url ?? "/password/reset/{token}?email={email}").replace("{token}", token).replace("{email}", encodeURIComponent(email)), resetUrl = /^https?:\/\//.test(filled) ? filled : `${base}${filled.startsWith("/") ? "" : "/"}${filled}`;
|
|
73
|
+
try {
|
|
74
|
+
const { html, text } = await template("password-reset", {
|
|
75
|
+
subject: `Reset Your ${appName} Password`,
|
|
76
|
+
variables: {
|
|
77
|
+
resetUrl,
|
|
78
|
+
expireMinutes
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
if (!html && !text)
|
|
82
|
+
throw Error("password-reset template missing or rendered empty");
|
|
83
|
+
await mail.send({
|
|
84
|
+
to: email,
|
|
85
|
+
subject: `Reset Your ${appName} Password`,
|
|
86
|
+
text,
|
|
87
|
+
html
|
|
88
|
+
});
|
|
89
|
+
} catch (templateError) {
|
|
90
|
+
const msg = templateError instanceof Error ? templateError.message : String(templateError);
|
|
91
|
+
console.warn(`[PasswordReset] template render failed, sending plain-text fallback: ${msg}`);
|
|
92
|
+
await mail.send({
|
|
93
|
+
to: email,
|
|
94
|
+
subject: `Reset Your ${appName} Password`,
|
|
95
|
+
text: `Reset your password by visiting: ${resetUrl}
|
|
96
|
+
|
|
97
|
+
This link expires in ${expireMinutes} minutes. If you didn't request this, you can safely ignore this email.`
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function verifyToken(token) {
|
|
102
|
+
const result = await db.selectFrom("password_resets").where("email", "=", email).selectAll().executeTakeFirst();
|
|
103
|
+
if (!result)
|
|
104
|
+
return !1;
|
|
105
|
+
if (!isWithinExpiry(result)) {
|
|
106
|
+
await db.deleteFrom("password_resets").where("email", "=", email).execute();
|
|
107
|
+
return !1;
|
|
108
|
+
}
|
|
109
|
+
const hashedToken = result.token;
|
|
110
|
+
return await verifyHash(token, hashedToken);
|
|
111
|
+
}
|
|
112
|
+
async function resetPassword(token, newPassword) {
|
|
113
|
+
const result = await db.transaction(async (rawTrx) => {
|
|
114
|
+
const trx = rawTrx, resetRecord = await trx.selectFrom("password_resets").where("email", "=", email).selectAll().executeTakeFirst();
|
|
115
|
+
if (!resetRecord)
|
|
116
|
+
return { success: !1, message: "Invalid or expired reset token" };
|
|
117
|
+
if (!isWithinExpiry(resetRecord)) {
|
|
118
|
+
await trx.deleteFrom("password_resets").where("email", "=", email).execute();
|
|
119
|
+
return { success: !1, message: "This password reset link has expired. Please request a new one." };
|
|
120
|
+
}
|
|
121
|
+
const hashedToken = resetRecord.token;
|
|
122
|
+
if (!await verifyHash(token, hashedToken))
|
|
123
|
+
return { success: !1, message: "Invalid or expired reset token" };
|
|
124
|
+
const user = await trx.selectFrom("users").where("email", "=", email).selectAll().executeTakeFirst();
|
|
125
|
+
if (!user)
|
|
126
|
+
return { success: !1, message: "Invalid or expired reset token" };
|
|
127
|
+
const hashedPassword = await makeHash(newPassword, { algorithm: "bcrypt" });
|
|
128
|
+
try {
|
|
129
|
+
await trx.updateTable("users").set({ password: hashedPassword, password_changed_at: formatDate(new Date) }).where("email", "=", email).executeTakeFirst();
|
|
130
|
+
} catch (err) {
|
|
131
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
132
|
+
if (/password_changed_at|no such column|unknown column/i.test(message)) {
|
|
133
|
+
log.warn("[PasswordReset] password_changed_at column missing \u2014 run `buddy migrate`; resetting without the credential-version stamp");
|
|
134
|
+
await trx.updateTable("users").set({ password: hashedPassword }).where("email", "=", email).executeTakeFirst();
|
|
135
|
+
} else
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
await trx.deleteFrom("password_resets").where("email", "=", email).execute();
|
|
139
|
+
return { success: !0, userId: Number(user.id) };
|
|
140
|
+
});
|
|
141
|
+
if (result.success) {
|
|
142
|
+
await revokeAllTokens(result.userId);
|
|
143
|
+
await sessionDestroyAll(result.userId);
|
|
144
|
+
sendPasswordChangedNotification(email).catch((err) => {
|
|
145
|
+
console.error("[PasswordReset] Failed to send notification:", err);
|
|
146
|
+
});
|
|
147
|
+
return { success: !0 };
|
|
148
|
+
}
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
sendEmail,
|
|
153
|
+
verifyToken,
|
|
154
|
+
resetPassword
|
|
155
|
+
};
|
|
156
|
+
}
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { AuthorizationResponse } from './gate';
|
|
2
|
+
import type { UserModel as OrmUserModel } from '@stacksjs/orm';
|
|
3
|
+
/**
|
|
4
|
+
* Discover and register policies from app/Policies directory
|
|
5
|
+
*/
|
|
6
|
+
export declare function discoverPolicies(): Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* Register inline gates from Gates.ts
|
|
9
|
+
*/
|
|
10
|
+
export declare function registerGates(): Promise<void>;
|
|
11
|
+
/**
|
|
12
|
+
* Initialize authorization system
|
|
13
|
+
*/
|
|
14
|
+
export declare function initializeAuthorization(): Promise<void>;
|
|
15
|
+
// Use the row/instance shape from orm so policies operate on the
|
|
16
|
+
// authenticated user object, not the User class constructor.
|
|
17
|
+
declare type UserModel = OrmUserModel;
|
|
18
|
+
export declare abstract class BasePolicy<T = any> {
|
|
19
|
+
before?(user: UserModel | null, ability: string): boolean | null | Promise<boolean | null>;
|
|
20
|
+
viewAny?(user: UserModel | null): boolean | Promise<boolean> | AuthorizationResponse;
|
|
21
|
+
view?(user: UserModel | null, model: T): boolean | Promise<boolean> | AuthorizationResponse;
|
|
22
|
+
create?(user: UserModel | null): boolean | Promise<boolean> | AuthorizationResponse;
|
|
23
|
+
update?(user: UserModel | null, model: T): boolean | Promise<boolean> | AuthorizationResponse;
|
|
24
|
+
delete?(user: UserModel | null, model: T): boolean | Promise<boolean> | AuthorizationResponse;
|
|
25
|
+
restore?(user: UserModel | null, model: T): boolean | Promise<boolean> | AuthorizationResponse;
|
|
26
|
+
forceDelete?(user: UserModel | null, model: T): boolean | Promise<boolean> | AuthorizationResponse;
|
|
27
|
+
protected allow(message?: string): AuthorizationResponse;
|
|
28
|
+
protected deny(message?: string, code?: string): AuthorizationResponse;
|
|
29
|
+
protected denyIf(condition: boolean, message?: string): AuthorizationResponse | boolean;
|
|
30
|
+
protected denyUnless(condition: boolean, message?: string): AuthorizationResponse | boolean;
|
|
31
|
+
protected allowIf(condition: boolean, message?: string): AuthorizationResponse | boolean;
|
|
32
|
+
}
|
|
33
|
+
export { AuthorizationResponse };
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { AuthorizationResponse } from "./gate";
|
|
2
|
+
|
|
3
|
+
export class BasePolicy {
|
|
4
|
+
allow(message) {
|
|
5
|
+
return AuthorizationResponse.allow(message);
|
|
6
|
+
}
|
|
7
|
+
deny(message, code) {
|
|
8
|
+
return AuthorizationResponse.deny(message, code);
|
|
9
|
+
}
|
|
10
|
+
denyIf(condition, message) {
|
|
11
|
+
if (condition)
|
|
12
|
+
return this.deny(message);
|
|
13
|
+
return !0;
|
|
14
|
+
}
|
|
15
|
+
denyUnless(condition, message) {
|
|
16
|
+
if (!condition)
|
|
17
|
+
return this.deny(message);
|
|
18
|
+
return !0;
|
|
19
|
+
}
|
|
20
|
+
allowIf(condition, message) {
|
|
21
|
+
if (condition)
|
|
22
|
+
return this.allow(message);
|
|
23
|
+
return !1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
import { log } from "@stacksjs/logging";
|
|
27
|
+
import * as p from "@stacksjs/path";
|
|
28
|
+
import { policy as registerPolicy } from "./gate";
|
|
29
|
+
export async function discoverPolicies() {
|
|
30
|
+
const { fs } = await import("@stacksjs/storage"), policiesDir = p.appPath("Policies");
|
|
31
|
+
if (!fs.existsSync(policiesDir)) {
|
|
32
|
+
log.debug("No Policies directory found");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const mappings = (await import(p.appPath("Gates.ts"))).policies || {};
|
|
37
|
+
for (const [modelName, config] of Object.entries(mappings)) {
|
|
38
|
+
const policyFile = typeof config === "string" ? config : config.policy, policyPath = `${policiesDir}/${policyFile}.ts`;
|
|
39
|
+
if (fs.existsSync(policyPath)) {
|
|
40
|
+
const policyModule = await import(policyPath), PolicyClass = policyModule.default || policyModule[policyFile];
|
|
41
|
+
if (PolicyClass) {
|
|
42
|
+
registerPolicy(modelName, PolicyClass);
|
|
43
|
+
log.debug(`Registered policy: ${policyFile} for ${modelName}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
log.debug("No Gates.ts found, using convention-based discovery");
|
|
49
|
+
}
|
|
50
|
+
const policyFiles = fs.readdirSync(policiesDir).filter((file) => file.endsWith("Policy.ts"));
|
|
51
|
+
for (const file of policyFiles) {
|
|
52
|
+
const policyName = file.replace(".ts", ""), modelName = policyName.replace("Policy", ""), policyPath = `${policiesDir}/${file}`;
|
|
53
|
+
try {
|
|
54
|
+
const policyModule = await import(policyPath), PolicyClass = policyModule.default || policyModule[policyName];
|
|
55
|
+
if (PolicyClass) {
|
|
56
|
+
registerPolicy(modelName, PolicyClass);
|
|
57
|
+
log.debug(`Auto-discovered policy: ${policyName} for ${modelName}`);
|
|
58
|
+
}
|
|
59
|
+
} catch (error) {
|
|
60
|
+
log.error(`Failed to load policy ${policyName}:`, error);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function registerGates() {
|
|
65
|
+
const { define, before, after } = await import("./gate");
|
|
66
|
+
try {
|
|
67
|
+
const gatesModule = await import(p.appPath("Gates.ts")), gates = gatesModule.gates || gatesModule.default?.gates || {};
|
|
68
|
+
for (const [ability, callback] of Object.entries(gates))
|
|
69
|
+
if (typeof callback === "function") {
|
|
70
|
+
define(ability, callback);
|
|
71
|
+
log.debug(`Registered gate: ${ability}`);
|
|
72
|
+
}
|
|
73
|
+
const beforeCallbacks = gatesModule.before || gatesModule.default?.before || [];
|
|
74
|
+
for (const callback of beforeCallbacks)
|
|
75
|
+
if (typeof callback === "function")
|
|
76
|
+
before(callback);
|
|
77
|
+
const afterCallbacks = gatesModule.after || gatesModule.default?.after || [];
|
|
78
|
+
for (const callback of afterCallbacks)
|
|
79
|
+
if (typeof callback === "function")
|
|
80
|
+
after(callback);
|
|
81
|
+
log.debug("Gates registered successfully");
|
|
82
|
+
} catch {
|
|
83
|
+
log.debug("No Gates.ts found or failed to load");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export async function initializeAuthorization() {
|
|
87
|
+
await registerGates();
|
|
88
|
+
await discoverPolicies();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export { AuthorizationResponse };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export declare interface RateLimitEntry {
|
|
2
|
+
attempts: number
|
|
3
|
+
lockedUntil: number
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Pluggable backing store for the auth rate limiter.
|
|
7
|
+
*
|
|
8
|
+
* Methods may be sync or async — the limiter awaits them either way. The
|
|
9
|
+
* default {@link MemoryStore} is process-local (fine for single-instance and
|
|
10
|
+
* dev). On a horizontally-scaled deployment the in-memory store is trivially
|
|
11
|
+
* bypassed by spreading attempts across instances, so production should swap
|
|
12
|
+
* in a shared store via `RateLimiter.useSharedStore()` (cache-backed; becomes
|
|
13
|
+
* cluster-wide when the cache driver is Redis) or a custom `useStore()`.
|
|
14
|
+
*/
|
|
15
|
+
export declare interface RateLimiterStore {
|
|
16
|
+
get: (key: string) => Promise<RateLimitEntry | undefined> | RateLimitEntry | undefined
|
|
17
|
+
set: (key: string, entry: RateLimitEntry, ttlMs: number) => Promise<void> | void
|
|
18
|
+
delete: (key: string) => Promise<void> | void
|
|
19
|
+
}
|
|
20
|
+
/** Process-local store — the default. */
|
|
21
|
+
declare class MemoryStore implements RateLimiterStore {
|
|
22
|
+
get(key: string): RateLimitEntry | undefined;
|
|
23
|
+
set(key: string, entry: RateLimitEntry): void;
|
|
24
|
+
delete(key: string): void;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Cache-backed store. Cross-instance when the configured cache driver is
|
|
28
|
+
* Redis; otherwise behaves like an in-memory store with TTL eviction. Entries
|
|
29
|
+
* carry a TTL so attempts decay automatically — no separate eviction pass.
|
|
30
|
+
*/
|
|
31
|
+
declare class CacheStore implements RateLimiterStore {
|
|
32
|
+
get(key: string): Promise<RateLimitEntry | undefined>;
|
|
33
|
+
set(key: string, entry: RateLimitEntry, ttlMs: number): Promise<void>;
|
|
34
|
+
delete(key: string): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
export declare class RateLimiter {
|
|
37
|
+
static useStore(custom: RateLimiterStore): void;
|
|
38
|
+
static useSharedStore(): void;
|
|
39
|
+
static useMemoryStore(): void;
|
|
40
|
+
static isRateLimited(email: string): Promise<boolean>;
|
|
41
|
+
static recordFailedAttempt(email: string): Promise<void>;
|
|
42
|
+
static resetAttempts(email: string): Promise<void>;
|
|
43
|
+
static validateAttempt(email: string): Promise<void>;
|
|
44
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { HttpError } from "@stacksjs/error-handling";
|
|
2
|
+
const MAX_ATTEMPTS = 5, LOCKOUT_DURATION = 900000, MAX_STORE_SIZE = 1e4, EVICTION_INTERVAL = 300000;
|
|
3
|
+
|
|
4
|
+
class MemoryStore {
|
|
5
|
+
store = new Map;
|
|
6
|
+
lastEviction = Date.now();
|
|
7
|
+
evict() {
|
|
8
|
+
const now = Date.now(), intervalElapsed = now - this.lastEviction >= EVICTION_INTERVAL, overCapacity = this.store.size >= MAX_STORE_SIZE;
|
|
9
|
+
if (!intervalElapsed && !overCapacity)
|
|
10
|
+
return;
|
|
11
|
+
this.lastEviction = now;
|
|
12
|
+
for (const [key, value] of this.store)
|
|
13
|
+
if (value.lockedUntil > 0 && value.lockedUntil <= now)
|
|
14
|
+
this.store.delete(key);
|
|
15
|
+
else if (value.lockedUntil === 0 && value.attempts === 0)
|
|
16
|
+
this.store.delete(key);
|
|
17
|
+
}
|
|
18
|
+
get(key) {
|
|
19
|
+
this.evict();
|
|
20
|
+
return this.store.get(key);
|
|
21
|
+
}
|
|
22
|
+
set(key, entry) {
|
|
23
|
+
this.store.set(key, entry);
|
|
24
|
+
}
|
|
25
|
+
delete(key) {
|
|
26
|
+
this.store.delete(key);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class CacheStore {
|
|
31
|
+
prefix = "auth:ratelimit:";
|
|
32
|
+
async get(key) {
|
|
33
|
+
const { cache } = await import("@stacksjs/cache"), raw = await cache.get(`${this.prefix}${key}`);
|
|
34
|
+
if (raw == null)
|
|
35
|
+
return;
|
|
36
|
+
try {
|
|
37
|
+
return typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
38
|
+
} catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async set(key, entry, ttlMs) {
|
|
43
|
+
const { cache } = await import("@stacksjs/cache");
|
|
44
|
+
await cache.set(`${this.prefix}${key}`, JSON.stringify(entry), Math.ceil(ttlMs / 1000));
|
|
45
|
+
}
|
|
46
|
+
async delete(key) {
|
|
47
|
+
const { cache } = await import("@stacksjs/cache");
|
|
48
|
+
await cache.remove(`${this.prefix}${key}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
let store = new MemoryStore;
|
|
52
|
+
|
|
53
|
+
export class RateLimiter {
|
|
54
|
+
static useStore(custom) {
|
|
55
|
+
store = custom;
|
|
56
|
+
}
|
|
57
|
+
static useSharedStore() {
|
|
58
|
+
store = new CacheStore;
|
|
59
|
+
}
|
|
60
|
+
static useMemoryStore() {
|
|
61
|
+
store = new MemoryStore;
|
|
62
|
+
}
|
|
63
|
+
static async isRateLimited(email) {
|
|
64
|
+
email = email.toLowerCase();
|
|
65
|
+
const now = Date.now(), userAttempts = await store.get(email);
|
|
66
|
+
if (!userAttempts)
|
|
67
|
+
return !1;
|
|
68
|
+
if (userAttempts.lockedUntil > 0 && userAttempts.lockedUntil <= now) {
|
|
69
|
+
await store.delete(email);
|
|
70
|
+
return !1;
|
|
71
|
+
}
|
|
72
|
+
return userAttempts.lockedUntil > 0;
|
|
73
|
+
}
|
|
74
|
+
static async recordFailedAttempt(email) {
|
|
75
|
+
email = email.toLowerCase();
|
|
76
|
+
const now = Date.now(), userAttempts = await store.get(email) || { attempts: 0, lockedUntil: 0 };
|
|
77
|
+
userAttempts.attempts++;
|
|
78
|
+
if (userAttempts.attempts >= MAX_ATTEMPTS) {
|
|
79
|
+
userAttempts.lockedUntil = now + LOCKOUT_DURATION;
|
|
80
|
+
userAttempts.attempts = 0;
|
|
81
|
+
}
|
|
82
|
+
await store.set(email, userAttempts, LOCKOUT_DURATION);
|
|
83
|
+
}
|
|
84
|
+
static async resetAttempts(email) {
|
|
85
|
+
await store.delete(email.toLowerCase());
|
|
86
|
+
}
|
|
87
|
+
static async validateAttempt(email) {
|
|
88
|
+
if (await this.isRateLimited(email))
|
|
89
|
+
throw new HttpError(429, "Too many login attempts. Please try again later.");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { RoleRecord } from './rbac';
|
|
2
|
+
/**
|
|
3
|
+
* Idempotently seed the three default role packs. Existing role records
|
|
4
|
+
* with the same `(name, guard_name)` are left untouched.
|
|
5
|
+
*
|
|
6
|
+
* Throws only if the underlying `createRole` throws for a reason other
|
|
7
|
+
* than a unique-constraint race (the BqbRbacStore's `swallowDuplicate`
|
|
8
|
+
* already handles that case). Caller should typically log the error and
|
|
9
|
+
* surface it — a missing roles table means the migrations haven't run
|
|
10
|
+
* yet, which is a different bug than a seeder failure.
|
|
11
|
+
*/
|
|
12
|
+
export declare function seedDefaultRoles(): Promise<SeedDefaultRolesResult>;
|
|
13
|
+
/**
|
|
14
|
+
* The role packs every Stacks install starts with. `useRole()`'s built-in
|
|
15
|
+
* predicates (`isAdmin`, `isDev`, `isClient`) check these names verbatim,
|
|
16
|
+
* so renaming them in a project effectively unbinds the composable from
|
|
17
|
+
* its defaults — that's fine, but obvious to readers.
|
|
18
|
+
* @defaultValue `[ { name: 'admin', guard_name: 'web', description: 'Full access. Sees every dashboard surface, every model, every infra control.', }, { name: 'dev', guard_name: 'web', description: 'Developer / infra. Sees dev-mode surfaces (CI, query inspector, runner alerts) but not billing/admin-only management.', }, { name: 'client', guard_name: 'web', description: 'End user / client. Sees content, orders, profile, billing — no dev tools, no infra surfaces.', }, ]`
|
|
19
|
+
*/
|
|
20
|
+
export declare const DEFAULT_ROLE_PACKS: Array<{ name: string, guard_name: string, description: string }>;
|
|
21
|
+
export declare interface SeedDefaultRolesResult {
|
|
22
|
+
created: RoleRecord[]
|
|
23
|
+
skipped: Array<{ name: string, guard_name: string, reason: 'already_exists' }>
|
|
24
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createRole, findRole } from "./rbac";
|
|
2
|
+
export const DEFAULT_ROLE_PACKS = [
|
|
3
|
+
{
|
|
4
|
+
name: "admin",
|
|
5
|
+
guard_name: "web",
|
|
6
|
+
description: "Full access. Sees every dashboard surface, every model, every infra control."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: "dev",
|
|
10
|
+
guard_name: "web",
|
|
11
|
+
description: "Developer / infra. Sees dev-mode surfaces (CI, query inspector, runner alerts) but not billing/admin-only management."
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: "client",
|
|
15
|
+
guard_name: "web",
|
|
16
|
+
description: "End user / client. Sees content, orders, profile, billing \u2014 no dev tools, no infra surfaces."
|
|
17
|
+
}
|
|
18
|
+
];
|
|
19
|
+
export async function seedDefaultRoles() {
|
|
20
|
+
const created = [], skipped = [];
|
|
21
|
+
for (const pack of DEFAULT_ROLE_PACKS) {
|
|
22
|
+
if (await findRole(pack.name, pack.guard_name)) {
|
|
23
|
+
skipped.push({ name: pack.name, guard_name: pack.guard_name, reason: "already_exists" });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const role = await createRole(pack.name, pack.guard_name, pack.description);
|
|
27
|
+
created.push(role);
|
|
28
|
+
}
|
|
29
|
+
return { created, skipped };
|
|
30
|
+
}
|