mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-09 20:27:44 +00:00
feat: 2fa
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.435.0",
|
||||
"next": "14.2.5",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^18",
|
||||
|
||||
Binary file not shown.
@@ -12,7 +12,6 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { GitHubLogoIcon } from "@radix-ui/react-icons";
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/client";
|
||||
|
||||
|
||||
87
dev/next-app/src/app/(auth)/two-factor/page.tsx
Normal file
87
dev/next-app/src/app/(auth)/two-factor/page.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { AlertCircle, CheckCircle2 } from "lucide-react"
|
||||
import { authClient } from "@/lib/client"
|
||||
|
||||
export default function Component() {
|
||||
const [totpCode, setTotpCode] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (totpCode.length !== 6 || !/^\d+$/.test(totpCode)) {
|
||||
setError("TOTP code must be 6 digits")
|
||||
return
|
||||
}
|
||||
authClient.verifyTotp({
|
||||
body: {
|
||||
code: totpCode,
|
||||
callbackURL: "/"
|
||||
}
|
||||
}).then((res) => {
|
||||
console.log(res)
|
||||
if (res.data?.status) {
|
||||
setSuccess(true)
|
||||
setError("")
|
||||
} else {
|
||||
setError("Invalid TOTP code")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-center justify-center min-h-screen">
|
||||
<Card className="w-[350px]">
|
||||
<CardHeader>
|
||||
<CardTitle>TOTP Verification</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your 6-digit TOTP code to authenticate
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!success ? (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="totp">TOTP Code</Label>
|
||||
<Input
|
||||
id="totp"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="\d{6}"
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value)}
|
||||
placeholder="Enter 6-digit code"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="flex items-center mt-2 text-red-500">
|
||||
<AlertCircle className="w-4 h-4 mr-2" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" className="w-full mt-4">
|
||||
Verify
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center space-y-2">
|
||||
<CheckCircle2 className="w-12 h-12 text-green-500" />
|
||||
<p className="text-lg font-semibold">Verification Successful</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="text-sm text-muted-foreground">
|
||||
Protect your account with TOTP-based authentication
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -2,13 +2,35 @@
|
||||
|
||||
import { authClient } from "@/lib/client";
|
||||
import { useAuthStore } from "better-auth/react"
|
||||
import { Button } from "./ui/button";
|
||||
export function Client() {
|
||||
const session = useAuthStore(authClient.$session)
|
||||
|
||||
type S = NonNullable<typeof session>
|
||||
const a: S['user'] = {
|
||||
id: "1",
|
||||
name: "test",
|
||||
email: "test@test.com",
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{JSON.stringify(session)}
|
||||
{
|
||||
session ? <div>
|
||||
<Button onClick={async () => {
|
||||
if (session.user.twoFactorEnabled) {
|
||||
await authClient.disableTotp()
|
||||
} else {
|
||||
await authClient.enableTotp()
|
||||
}
|
||||
}}>
|
||||
{
|
||||
session.user.twoFactorEnabled ? "Disable" : "Enable"
|
||||
}
|
||||
</Button>
|
||||
</div> : null
|
||||
}
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { github, passkey } from "better-auth/provider";
|
||||
import { organization } from "better-auth/plugins";
|
||||
import { twoFactor } from "better-auth/plugins";
|
||||
|
||||
export const auth = betterAuth({
|
||||
basePath: "/api/auth",
|
||||
@@ -22,4 +22,10 @@ export const auth = betterAuth({
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
plugins: [
|
||||
twoFactor({
|
||||
issuer: "BetterAuth",
|
||||
twoFactorURL: "/two-factor",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -46,11 +46,13 @@
|
||||
"@nanostores/react": "^0.7.3",
|
||||
"@nanostores/solid": "^0.4.2",
|
||||
"@nanostores/vue": "^0.10.0",
|
||||
"@noble/ciphers": "^0.6.0",
|
||||
"@noble/hashes": "^1.4.0",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@simplewebauthn/browser": "^10.0.0",
|
||||
"@simplewebauthn/server": "^10.0.1",
|
||||
"arctic": "^1.9.2",
|
||||
"better-call": "^0.1.20",
|
||||
"better-call": "^0.1.28",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"consola": "^3.2.3",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createRouter } from "better-call";
|
||||
import { createRouter, Endpoint } from "better-call";
|
||||
import {
|
||||
signInOAuth,
|
||||
callbackOAuth,
|
||||
@@ -35,6 +35,31 @@ export const router = <C extends AuthContext>(ctx: C) => {
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
|
||||
const middlewares =
|
||||
ctx.options.plugins
|
||||
?.map((plugin) =>
|
||||
plugin.middlewares?.map((m) => {
|
||||
const middleware = (async (context: any) => {
|
||||
return m.middleware({
|
||||
...context,
|
||||
context: {
|
||||
...ctx,
|
||||
...context.context,
|
||||
},
|
||||
});
|
||||
}) as Endpoint;
|
||||
middleware.path = m.path;
|
||||
middleware.options = m.middleware.options;
|
||||
middleware.headers = m.middleware.headers;
|
||||
return {
|
||||
path: m.path,
|
||||
middleware,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.filter((plugin) => plugin !== undefined)
|
||||
.flat() || [];
|
||||
|
||||
const baseEndpoints = {
|
||||
signInOAuth,
|
||||
callbackOAuth,
|
||||
@@ -74,6 +99,7 @@ export const router = <C extends AuthContext>(ctx: C) => {
|
||||
path: "/**",
|
||||
middleware: csrfMiddleware,
|
||||
},
|
||||
...middlewares,
|
||||
],
|
||||
onError(e) {
|
||||
ctx.logger.error(e);
|
||||
|
||||
@@ -20,7 +20,7 @@ export const csrfMiddleware = createAuthMiddleware(
|
||||
const csrfToken = ctx.body?.csrfToken;
|
||||
const csrfCookie = await ctx.getSignedCookie(
|
||||
ctx.context.authCookies.csrfToken.name,
|
||||
ctx.context.options.secret,
|
||||
ctx.context.secret,
|
||||
);
|
||||
const [token, hash] = csrfCookie?.split("!") || [null, null];
|
||||
if (
|
||||
@@ -37,7 +37,7 @@ export const csrfMiddleware = createAuthMiddleware(
|
||||
message: "Invalid CSRF Token",
|
||||
});
|
||||
}
|
||||
const expectedHash = await hs256(ctx.context.options.secret, token);
|
||||
const expectedHash = await hs256(ctx.context.secret, token);
|
||||
if (hash !== expectedHash) {
|
||||
ctx.setCookie(ctx.context.authCookies.csrfToken.name, "", {
|
||||
maxAge: 0,
|
||||
|
||||
@@ -98,7 +98,7 @@ export const callbackOAuth = createAuthEndpoint(
|
||||
await c.setSignedCookie(
|
||||
c.context.authCookies.sessionToken.name,
|
||||
session.id,
|
||||
c.context.options.secret,
|
||||
c.context.secret,
|
||||
c.context.authCookies.sessionToken.options,
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -10,7 +10,7 @@ export const getCSRFToken = createAuthEndpoint(
|
||||
async (ctx) => {
|
||||
const csrfToken = await ctx.getSignedCookie(
|
||||
ctx.context.authCookies.csrfToken.name,
|
||||
ctx.context.options.secret,
|
||||
ctx.context.secret,
|
||||
);
|
||||
if (csrfToken) {
|
||||
return {
|
||||
@@ -18,12 +18,12 @@ export const getCSRFToken = createAuthEndpoint(
|
||||
};
|
||||
}
|
||||
const token = generateRandomString(32, alphabet("a-z", "0-9", "A-Z"));
|
||||
const hash = await hs256(ctx.context.options.secret, token);
|
||||
const hash = await hs256(ctx.context.secret, token);
|
||||
const cookie = `${token}!${hash}`;
|
||||
await ctx.setSignedCookie(
|
||||
ctx.context.authCookies.csrfToken.name,
|
||||
cookie,
|
||||
ctx.context.options.secret,
|
||||
ctx.context.secret,
|
||||
ctx.context.authCookies.csrfToken.options,
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -10,7 +10,7 @@ export const getSession = createAuthEndpoint(
|
||||
async (ctx) => {
|
||||
const sessionCookieToken = await ctx.getSignedCookie(
|
||||
ctx.context.authCookies.sessionToken.name,
|
||||
ctx.context.options.secret,
|
||||
ctx.context.secret,
|
||||
);
|
||||
if (!sessionCookieToken) {
|
||||
return ctx.json(null, {
|
||||
|
||||
@@ -44,14 +44,14 @@ export const signInOAuth = createAuthEndpoint(
|
||||
await c.setSignedCookie(
|
||||
cookie.state.name,
|
||||
state.code,
|
||||
c.context.options.secret,
|
||||
c.context.secret,
|
||||
cookie.state.options,
|
||||
);
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
await c.setSignedCookie(
|
||||
cookie.pkCodeVerifier.name,
|
||||
codeVerifier,
|
||||
c.context.options.secret,
|
||||
c.context.secret,
|
||||
cookie.pkCodeVerifier.options,
|
||||
);
|
||||
const url = await provider.provider.createAuthorizationURL(
|
||||
@@ -121,13 +121,14 @@ export const signInCredential = createAuthEndpoint(
|
||||
body: { message: "Invalid email or password" },
|
||||
});
|
||||
}
|
||||
|
||||
const session = await ctx.context.internalAdapter.createSession(
|
||||
user.user.id,
|
||||
);
|
||||
await ctx.setSignedCookie(
|
||||
ctx.context.authCookies.sessionToken.name,
|
||||
session.id,
|
||||
ctx.context.options.secret,
|
||||
ctx.context.secret,
|
||||
ctx.context.authCookies.sessionToken.options,
|
||||
);
|
||||
return ctx.json(
|
||||
|
||||
@@ -14,7 +14,7 @@ export const signOut = createAuthEndpoint(
|
||||
async (ctx) => {
|
||||
const sessionCookieToken = await ctx.getSignedCookie(
|
||||
ctx.context.authCookies.sessionToken.name,
|
||||
ctx.context.options.secret,
|
||||
ctx.context.secret,
|
||||
);
|
||||
if (!sessionCookieToken) {
|
||||
return ctx.json(null);
|
||||
|
||||
@@ -73,7 +73,7 @@ export const signUpCredential = createAuthEndpoint(
|
||||
await ctx.setSignedCookie(
|
||||
ctx.context.authCookies.sessionToken.name,
|
||||
session.id,
|
||||
ctx.context.options.secret,
|
||||
ctx.context.secret,
|
||||
ctx.context.authCookies.sessionToken.options,
|
||||
);
|
||||
if (ctx.body.callbackUrl) {
|
||||
|
||||
@@ -47,6 +47,7 @@ function handleEdgeCases(str: string) {
|
||||
|
||||
const knownPathMethods: Record<string, "POST" | "GET"> = {
|
||||
"/sign-out": "POST",
|
||||
"enable/totp": "POST",
|
||||
};
|
||||
|
||||
function getMethod(path: string, args?: BetterFetchOption) {
|
||||
|
||||
@@ -17,10 +17,46 @@ export function getSessionAtom<Auth extends BetterAuth>(client: BetterFetch) {
|
||||
};
|
||||
}
|
||||
? Field extends Record<string, FieldAttribute>
|
||||
? InferFieldOutput<Field>
|
||||
? {
|
||||
[key in keyof Field]: InferFieldOutput<Field[key]>;
|
||||
}
|
||||
: {}
|
||||
: {}
|
||||
: {};
|
||||
type AdditionalUserFields = Auth["options"]["plugins"] extends Array<infer T>
|
||||
? T extends {
|
||||
schema: {
|
||||
user: {
|
||||
fields: infer Field;
|
||||
};
|
||||
};
|
||||
}
|
||||
? Field extends Record<infer Key, FieldAttribute>
|
||||
? Prettify<
|
||||
{
|
||||
[key in Key as Field[key]["required"] extends false
|
||||
? never
|
||||
: Field[key]["defaultValue"] extends
|
||||
| boolean
|
||||
| string
|
||||
| number
|
||||
| Date
|
||||
| Function
|
||||
? key
|
||||
: never]: InferFieldOutput<Field[key]>;
|
||||
} & {
|
||||
[key in Key as Field[key]["returned"] extends false
|
||||
? never
|
||||
: key]?: InferFieldOutput<Field[key]>;
|
||||
}
|
||||
>
|
||||
: {}
|
||||
: {}
|
||||
: {};
|
||||
|
||||
type UserWithAdditionalFields = User & AdditionalUserFields;
|
||||
type SessionWithAdditionalFields = Session & AdditionalSessionFields;
|
||||
|
||||
const $signal = atom<boolean>(false);
|
||||
const $session = computed($signal, () =>
|
||||
task(async () => {
|
||||
@@ -29,8 +65,8 @@ export function getSessionAtom<Auth extends BetterAuth>(client: BetterFetch) {
|
||||
method: "GET",
|
||||
});
|
||||
return session.data as {
|
||||
user: User;
|
||||
session: Prettify<Session & AdditionalSessionFields>;
|
||||
user: Prettify<UserWithAdditionalFields>;
|
||||
session: Prettify<SessionWithAdditionalFields>;
|
||||
} | null;
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { sha256 } from "@noble/hashes/sha256";
|
||||
import { managedNonce } from "@noble/ciphers/webcrypto";
|
||||
import { xchacha20poly1305 } from "@noble/ciphers/chacha";
|
||||
import { bytesToHex, hexToBytes, utf8ToBytes } from "@noble/ciphers/utils";
|
||||
|
||||
export async function hs256(secretKey: string, message: string) {
|
||||
const enc = new TextEncoder();
|
||||
const algorithm = { name: "HMAC", hash: "SHA-256" };
|
||||
@@ -15,3 +20,27 @@ export async function hs256(secretKey: string, message: string) {
|
||||
);
|
||||
return btoa(String.fromCharCode(...new Uint8Array(signature)));
|
||||
}
|
||||
|
||||
export type SymmetricEncryptOptions = {
|
||||
key: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export const symmetricEncrypt = ({ key, data }: SymmetricEncryptOptions) => {
|
||||
const keyAsBytes = sha256(key);
|
||||
const dataAsBytes = utf8ToBytes(data);
|
||||
const chacha = managedNonce(xchacha20poly1305)(keyAsBytes);
|
||||
return bytesToHex(chacha.encrypt(dataAsBytes));
|
||||
};
|
||||
|
||||
export type SymmetricDecryptOptions = {
|
||||
key: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export const symmetricDecrypt = ({ key, data }: SymmetricDecryptOptions) => {
|
||||
const keyAsBytes = sha256(key);
|
||||
const dataAsBytes = hexToBytes(data);
|
||||
const chacha = managedNonce(xchacha20poly1305)(keyAsBytes);
|
||||
return chacha.decrypt(dataAsBytes);
|
||||
};
|
||||
|
||||
@@ -29,18 +29,12 @@ export type InferValueType<T extends FieldType> = T extends "string"
|
||||
? Date
|
||||
: never;
|
||||
|
||||
export type InferFieldOutput<
|
||||
T extends Record<string, FieldAttribute>,
|
||||
K extends keyof T = keyof T,
|
||||
> = T[K]["returned"] extends false
|
||||
? never
|
||||
: T[K]["required"] extends false
|
||||
? {
|
||||
[key in K]?: InferValueType<T[K]["type"]>;
|
||||
}
|
||||
: {
|
||||
[key in K]: InferValueType<T[K]["type"]>;
|
||||
};
|
||||
export type InferFieldOutput<T extends FieldAttribute> =
|
||||
T["returned"] extends false
|
||||
? never
|
||||
: T["required"] extends false
|
||||
? InferValueType<T["type"]> | undefined
|
||||
: InferValueType<T["type"]>;
|
||||
|
||||
export type FieldAttributeConfig<T extends FieldType = FieldType> = {
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,11 @@ import { createKyselyAdapter } from "./adapters/kysely";
|
||||
import { getAdapter } from "./adapters/utils";
|
||||
import { createInternalAdapter } from "./db";
|
||||
import { BetterAuthOptions } from "./types";
|
||||
import { BetterAuthCookies, getCookies } from "./utils/cookies";
|
||||
import {
|
||||
BetterAuthCookies,
|
||||
createCookieGetter,
|
||||
getCookies,
|
||||
} from "./utils/cookies";
|
||||
import { createLogger } from "./utils/logger";
|
||||
|
||||
export const init = (options: BetterAuthOptions) => {
|
||||
@@ -10,6 +14,11 @@ export const init = (options: BetterAuthOptions) => {
|
||||
const db = createKyselyAdapter(options);
|
||||
return {
|
||||
options,
|
||||
secret:
|
||||
options.secret ||
|
||||
process.env.BETTER_AUTH_SECRET ||
|
||||
process.env.AUTH_SECRET ||
|
||||
"better-auth-secret-123456789",
|
||||
authCookies: getCookies(options),
|
||||
logger: createLogger({
|
||||
disabled: options.disableLog,
|
||||
@@ -17,6 +26,7 @@ export const init = (options: BetterAuthOptions) => {
|
||||
db,
|
||||
adapter: adapter,
|
||||
internalAdapter: createInternalAdapter(adapter, options),
|
||||
createAuthCookie: createCookieGetter(options),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -27,4 +37,6 @@ export type AuthContext = {
|
||||
db: ReturnType<typeof createKyselyAdapter>;
|
||||
adapter: ReturnType<typeof getAdapter>;
|
||||
internalAdapter: ReturnType<typeof createInternalAdapter>;
|
||||
createAuthCookie: ReturnType<typeof createCookieGetter>;
|
||||
secret: string;
|
||||
};
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./organization";
|
||||
export * from "./two-factor";
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { alphabet, generateRandomString } from "oslo/crypto";
|
||||
import { TwoFactorProvider, UserWithTwoFactor } from "../types";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "../../../crypto";
|
||||
import { z } from "zod";
|
||||
import { createAuthEndpoint } from "../../../api/call";
|
||||
import { verifyTwoFactorMiddleware } from "../verify-middleware";
|
||||
import { sessionMiddleware } from "../../../api/middlewares/session";
|
||||
|
||||
export interface BackupCodeOptions {
|
||||
/**
|
||||
* The amount of backup codes to generate
|
||||
*
|
||||
* @default 10
|
||||
*/
|
||||
amount?: number;
|
||||
/**
|
||||
* The length of the backup codes
|
||||
*
|
||||
* @default 10
|
||||
*/
|
||||
length?: number;
|
||||
customBackupCodesGenerate?: () => string[];
|
||||
}
|
||||
|
||||
function generateBackupCodesFn(options?: BackupCodeOptions) {
|
||||
return Array.from({ length: options?.amount ?? 10 })
|
||||
.fill(null)
|
||||
.map(() =>
|
||||
generateRandomString(options?.length ?? 10, alphabet("a-z", "0-9")),
|
||||
)
|
||||
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`);
|
||||
}
|
||||
|
||||
export async function generateBackupCodes(
|
||||
secret: string,
|
||||
options?: BackupCodeOptions,
|
||||
) {
|
||||
const key = secret;
|
||||
const backupCodes = options?.customBackupCodesGenerate
|
||||
? options.customBackupCodesGenerate()
|
||||
: generateBackupCodesFn();
|
||||
const encCodes = symmetricEncrypt({
|
||||
data: JSON.stringify(backupCodes),
|
||||
key: key,
|
||||
});
|
||||
return {
|
||||
backupCodes,
|
||||
encryptedBackupCodes: encCodes,
|
||||
};
|
||||
}
|
||||
|
||||
export function verifyBackupCode(
|
||||
data: {
|
||||
user: UserWithTwoFactor;
|
||||
code: string;
|
||||
},
|
||||
key: string,
|
||||
) {
|
||||
const codes = getBackupCodes(data.user, key);
|
||||
if (!codes) {
|
||||
return false;
|
||||
}
|
||||
return codes.includes(data.code);
|
||||
}
|
||||
|
||||
export function getBackupCodes(user: UserWithTwoFactor, key: string) {
|
||||
const secret = Buffer.from(
|
||||
symmetricDecrypt({ key, data: user.twoFactorBackupCodes }),
|
||||
).toString("utf-8");
|
||||
const data = JSON.parse(secret);
|
||||
const result = z.array(z.string()).safeParse(data);
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const backupCode2fa = (options?: BackupCodeOptions) => {
|
||||
return {
|
||||
id: "backup_code",
|
||||
verify: createAuthEndpoint(
|
||||
"/verify/backup-code",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
code: z.string(),
|
||||
}),
|
||||
use: [verifyTwoFactorMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
const validate = verifyBackupCode(
|
||||
{
|
||||
user: ctx.context.session.user,
|
||||
code: ctx.body.code,
|
||||
},
|
||||
ctx.context.secret,
|
||||
);
|
||||
if (!validate) {
|
||||
return ctx.json(
|
||||
{ status: false },
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
return ctx.json({ status: true });
|
||||
},
|
||||
),
|
||||
customActions: {
|
||||
generateBackupCodes: createAuthEndpoint(
|
||||
"/generate/backup-codes",
|
||||
{
|
||||
method: "POST",
|
||||
use: [sessionMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
const backupCodes = await generateBackupCodes(
|
||||
ctx.context.secret,
|
||||
options,
|
||||
);
|
||||
await ctx.context.adapter.update({
|
||||
model: "user",
|
||||
update: {
|
||||
twoFactorEnabled: true,
|
||||
twoFactorBackupCodes: backupCodes.encryptedBackupCodes,
|
||||
},
|
||||
where: [
|
||||
{
|
||||
field: "id",
|
||||
value: ctx.context.session.user.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
return ctx.json({
|
||||
status: true,
|
||||
backupCodes: backupCodes.backupCodes,
|
||||
});
|
||||
},
|
||||
),
|
||||
viewBackupCodes: createAuthEndpoint(
|
||||
"/view/backup-codes",
|
||||
{
|
||||
method: "GET",
|
||||
use: [sessionMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
const user = ctx.context.session.user as UserWithTwoFactor;
|
||||
const backupCodes = getBackupCodes(user, ctx.context.secret);
|
||||
return ctx.json({
|
||||
status: true,
|
||||
backupCodes: backupCodes,
|
||||
});
|
||||
},
|
||||
),
|
||||
},
|
||||
} satisfies TwoFactorProvider;
|
||||
};
|
||||
2
packages/better-auth/src/plugins/two-factor/constant.ts
Normal file
2
packages/better-auth/src/plugins/two-factor/constant.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const TWO_FACTOR_COOKIE_NAME = "better-auth.two-factor";
|
||||
export const OTP_RANDOM_NUMBER_COOKIE_NAME = "otp.counter";
|
||||
@@ -1,8 +1,168 @@
|
||||
import { z } from "zod";
|
||||
import { createAuthEndpoint } from "../../api/call";
|
||||
import { Plugin } from "../../types/plugins";
|
||||
import { totp2fa } from "./totp";
|
||||
import { TwoFactorOptions, UserWithTwoFactor } from "./types";
|
||||
import {
|
||||
twoFactorMiddleware,
|
||||
verifyTwoFactorMiddleware,
|
||||
} from "./verify-middleware";
|
||||
import { sessionMiddleware } from "../../api/middlewares/session";
|
||||
import { alphabet, generateRandomString } from "oslo/crypto";
|
||||
import { backupCode2fa, generateBackupCodes } from "./backup-codes";
|
||||
import { otp2fa } from "./otp";
|
||||
import { symmetricEncrypt } from "../../crypto";
|
||||
|
||||
export const multiFactor = () => {
|
||||
export const twoFactor = <O extends TwoFactorOptions>(options: O) => {
|
||||
const totp = totp2fa({
|
||||
issuer: options.issuer,
|
||||
...options.totpOptions,
|
||||
});
|
||||
const backupCode = backupCode2fa(options.backupCodeOptions);
|
||||
const otp = otp2fa(options.otpOptions);
|
||||
const providers = [totp, backupCode, otp];
|
||||
return {
|
||||
id: "multi-factor",
|
||||
endpoints: {},
|
||||
id: "two-factor",
|
||||
endpoints: {
|
||||
...totp.customActions,
|
||||
...otp.customActions,
|
||||
...backupCode.customActions,
|
||||
enableTwoFactor: createAuthEndpoint(
|
||||
"/enable/two-factor",
|
||||
{
|
||||
method: "POST",
|
||||
use: [sessionMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
const user = ctx.context.session.user as UserWithTwoFactor;
|
||||
const secret = generateRandomString(16, alphabet("a-z", "0-9", "-"));
|
||||
const encryptedSecret = symmetricEncrypt({
|
||||
key: ctx.context.secret,
|
||||
data: secret,
|
||||
});
|
||||
const backupCodes = await generateBackupCodes(
|
||||
ctx.context.secret,
|
||||
options.backupCodeOptions,
|
||||
);
|
||||
await ctx.context.adapter.update({
|
||||
model: "user",
|
||||
update: {
|
||||
twoFactorSecret: encryptedSecret,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorBackupCodes: backupCodes.encryptedBackupCodes,
|
||||
},
|
||||
where: [
|
||||
{
|
||||
field: "id",
|
||||
value: user.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
return ctx.json({ status: true });
|
||||
},
|
||||
),
|
||||
disableTwoFactor: createAuthEndpoint(
|
||||
"/disable/two-factor",
|
||||
{
|
||||
method: "POST",
|
||||
use: [sessionMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
const user = ctx.context.session.user as UserWithTwoFactor;
|
||||
await ctx.context.adapter.update({
|
||||
model: "user",
|
||||
update: {
|
||||
twoFactorEnabled: false,
|
||||
},
|
||||
where: [
|
||||
{
|
||||
field: "id",
|
||||
value: user.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
return ctx.json({ status: true });
|
||||
},
|
||||
),
|
||||
verifyTwoFactor: createAuthEndpoint(
|
||||
"/verify/two-factor",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
/**
|
||||
* The code to validate
|
||||
*/
|
||||
code: z.string(),
|
||||
with: z.enum(["totp", "otp", "backup_code"]),
|
||||
callbackURL: z.string().optional(),
|
||||
}),
|
||||
use: [verifyTwoFactorMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
const providerId = ctx.body.with;
|
||||
const provider = providers.find((p) => p.id === providerId);
|
||||
if (!provider) {
|
||||
return ctx.json(
|
||||
{ status: false },
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
const res = await provider.verify(ctx);
|
||||
if (!res.status) {
|
||||
return ctx.json(
|
||||
{ status: false },
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
await ctx.context.createSession();
|
||||
if (ctx.body.callbackURL) {
|
||||
return ctx.json({
|
||||
status: true,
|
||||
callbackURL: ctx.body.callbackURL,
|
||||
redirect: true,
|
||||
});
|
||||
}
|
||||
return ctx.json({ status: true });
|
||||
},
|
||||
),
|
||||
},
|
||||
options: options,
|
||||
middlewares: [
|
||||
{
|
||||
path: "/sign-in/credential",
|
||||
middleware: twoFactorMiddleware(options),
|
||||
},
|
||||
],
|
||||
schema: {
|
||||
user: {
|
||||
fields: {
|
||||
twoFactorEnabled: {
|
||||
type: "boolean",
|
||||
required: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
twoFactorSecret: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
backupCodes: {
|
||||
type: "string",
|
||||
required: false,
|
||||
returned: false,
|
||||
},
|
||||
/**
|
||||
* list of two factor providers id separated by comma
|
||||
*/
|
||||
twoFactorProviders: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Plugin;
|
||||
};
|
||||
|
||||
119
packages/better-auth/src/plugins/two-factor/otp/index.ts
Normal file
119
packages/better-auth/src/plugins/two-factor/otp/index.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { APIError } from "better-call";
|
||||
import { createAuthEndpoint } from "../../../api/call";
|
||||
import { sessionMiddleware } from "../../../api/middlewares/session";
|
||||
import { TwoFactorProvider, UserWithTwoFactor } from "../types";
|
||||
import { generateHOTP } from "oslo/otp";
|
||||
import { generateRandomInteger } from "oslo/crypto";
|
||||
import { OTP_RANDOM_NUMBER_COOKIE_NAME } from "../constant";
|
||||
import { z } from "zod";
|
||||
import { verifyTwoFactorMiddleware } from "../verify-middleware";
|
||||
|
||||
export interface OTPOptions {
|
||||
/**
|
||||
* How long the opt will be valid for
|
||||
*
|
||||
* @default "5 mins"
|
||||
*/
|
||||
period?: number;
|
||||
sendOTP: (user: UserWithTwoFactor, otp: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The otp adapter is created from the totp adapter.
|
||||
*/
|
||||
export const otp2fa = (options?: OTPOptions) => {
|
||||
/**
|
||||
* Generate OTP and send it to the user.
|
||||
*/
|
||||
const generateOTP = createAuthEndpoint(
|
||||
"/generate/otp",
|
||||
{
|
||||
method: "POST",
|
||||
use: [sessionMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
if (!options || !options.sendOTP) {
|
||||
ctx.context.logger.error(
|
||||
"otp isn't configured. please pass otp option on two factor plugin to enable otp",
|
||||
);
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "otp isn't configured",
|
||||
});
|
||||
}
|
||||
const randomNumber = generateRandomInteger(100000);
|
||||
const otp = await generateHOTP(
|
||||
Buffer.from(ctx.context.secret),
|
||||
randomNumber,
|
||||
);
|
||||
await options.sendOTP(ctx.context.session.user as UserWithTwoFactor, otp);
|
||||
const cookie = ctx.context.createAuthCookie(
|
||||
OTP_RANDOM_NUMBER_COOKIE_NAME,
|
||||
{
|
||||
maxAge: options.period,
|
||||
},
|
||||
);
|
||||
await ctx.setSignedCookie(
|
||||
cookie.name,
|
||||
randomNumber.toString(),
|
||||
ctx.context.secret,
|
||||
cookie.options,
|
||||
);
|
||||
return ctx.json({ status: true });
|
||||
},
|
||||
);
|
||||
|
||||
const verifyOTP = createAuthEndpoint(
|
||||
"/verify/otp",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
code: z.string(),
|
||||
}),
|
||||
use: [verifyTwoFactorMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
const user = ctx.context.session.user;
|
||||
if (!user.twoFactorEnabled) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "two factor isn't enabled",
|
||||
});
|
||||
}
|
||||
const cookie = ctx.context.createAuthCookie(
|
||||
OTP_RANDOM_NUMBER_COOKIE_NAME,
|
||||
);
|
||||
const randomNumber = await ctx.getSignedCookie(
|
||||
cookie.name,
|
||||
ctx.context.secret,
|
||||
);
|
||||
if (!randomNumber) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "counter cookie not found",
|
||||
});
|
||||
}
|
||||
const toCheckOtp = await generateHOTP(
|
||||
Buffer.from(ctx.context.secret),
|
||||
parseInt(randomNumber),
|
||||
);
|
||||
|
||||
if (toCheckOtp !== ctx.body.code) {
|
||||
await ctx.context.createSession();
|
||||
return ctx.json({ status: true });
|
||||
} else {
|
||||
return ctx.json(
|
||||
{ status: false },
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
id: "otp",
|
||||
verify: verifyOTP,
|
||||
customActions: {
|
||||
generateOTP: generateOTP,
|
||||
},
|
||||
} satisfies TwoFactorProvider;
|
||||
};
|
||||
226
packages/better-auth/src/plugins/two-factor/totp/index.ts
Normal file
226
packages/better-auth/src/plugins/two-factor/totp/index.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { TimeSpan } from "oslo";
|
||||
import { alphabet, generateRandomString } from "oslo/crypto";
|
||||
import { TOTPController, createTOTPKeyURI } from "oslo/otp";
|
||||
import { z } from "zod";
|
||||
import { createAuthEndpoint } from "../../../api/call";
|
||||
import { sessionMiddleware } from "../../../api/middlewares/session";
|
||||
import { APIError } from "better-call";
|
||||
import { TwoFactorProvider, UserWithTwoFactor } from "../types";
|
||||
import { verifyTwoFactorMiddleware } from "../verify-middleware";
|
||||
import { BackupCodeOptions, generateBackupCodes } from "../backup-codes";
|
||||
import { symmetricDecrypt } from "../../../crypto";
|
||||
|
||||
export type TOTPOptions = {
|
||||
/**
|
||||
* Issuer
|
||||
*/
|
||||
issuer: string;
|
||||
/**
|
||||
* How many digits the otp to be
|
||||
*
|
||||
* @default 6
|
||||
*/
|
||||
digits?: 6 | 8;
|
||||
/**
|
||||
* Period for otp in seconds.
|
||||
* @default 30
|
||||
*/
|
||||
period?: number;
|
||||
/**
|
||||
* Backup codes configuration
|
||||
*/
|
||||
backupCodes?: BackupCodeOptions;
|
||||
};
|
||||
|
||||
export const totp2fa = (options: TOTPOptions) => {
|
||||
const opts = {
|
||||
digits: 6,
|
||||
period: new TimeSpan(options?.period || 30, "s"),
|
||||
secret: {
|
||||
field: "twoFactorSecret",
|
||||
},
|
||||
};
|
||||
|
||||
const enableTOTP = createAuthEndpoint(
|
||||
"/enable/totp",
|
||||
{
|
||||
method: "POST",
|
||||
use: [sessionMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
if (!options) {
|
||||
ctx.context.logger.error(
|
||||
"totp isn't configured. please pass totp option on two factor plugin to enable totp",
|
||||
);
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "totp isn't configured",
|
||||
});
|
||||
}
|
||||
const secret = generateRandomString(16, alphabet("a-z", "0-9", "-"));
|
||||
const user = ctx.context.session.user as UserWithTwoFactor;
|
||||
const uri = createTOTPKeyURI(
|
||||
options.issuer || "BetterAuth",
|
||||
user.name,
|
||||
Buffer.from(secret),
|
||||
opts,
|
||||
);
|
||||
const backupCodes = await generateBackupCodes(
|
||||
secret,
|
||||
options.backupCodes,
|
||||
);
|
||||
await ctx.context.adapter.update({
|
||||
model: "user",
|
||||
update: {
|
||||
twoFactorSecret: secret,
|
||||
twoFactorEnabled: true,
|
||||
backupCodes: backupCodes.encryptedBackupCodes,
|
||||
},
|
||||
where: [
|
||||
{
|
||||
field: "id",
|
||||
value: user.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
return ctx.json({ uri, backupCodes: backupCodes.backupCodes });
|
||||
},
|
||||
);
|
||||
|
||||
async function enable(user: UserWithTwoFactor) {
|
||||
const secret = generateRandomString(16, alphabet("a-z", "0-9", "-"));
|
||||
const uri = createTOTPKeyURI(
|
||||
options.issuer,
|
||||
user.name,
|
||||
Buffer.from(secret),
|
||||
opts,
|
||||
);
|
||||
const backupCodes = await generateBackupCodes(secret, options.backupCodes);
|
||||
return {
|
||||
uri,
|
||||
backupCodes: backupCodes.backupCodes,
|
||||
};
|
||||
}
|
||||
|
||||
const disableTOTP = createAuthEndpoint(
|
||||
"/disable/totp",
|
||||
{
|
||||
method: "POST",
|
||||
use: [sessionMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
if (!options) {
|
||||
ctx.context.logger.error(
|
||||
"totp isn't configured. please pass totp option on two factor plugin to enable totp",
|
||||
);
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "totp isn't configured",
|
||||
});
|
||||
}
|
||||
const user = ctx.context.session.user as UserWithTwoFactor;
|
||||
await ctx.context.adapter.update({
|
||||
model: "user",
|
||||
update: {
|
||||
twoFactorEnabled: false,
|
||||
},
|
||||
where: [
|
||||
{
|
||||
field: "id",
|
||||
value: user.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
return ctx.json({ status: true });
|
||||
},
|
||||
);
|
||||
|
||||
const generateTOTP = createAuthEndpoint(
|
||||
"/generate/totp",
|
||||
{
|
||||
method: "POST",
|
||||
use: [sessionMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
if (!options) {
|
||||
ctx.context.logger.error(
|
||||
"totp isn't configured. please pass totp option on two factor plugin to enable totp",
|
||||
);
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "totp isn't configured",
|
||||
});
|
||||
}
|
||||
const session = ctx.context.session;
|
||||
const totp = new TOTPController(opts);
|
||||
const secret = (session.user as any).secret;
|
||||
const code = await totp.generate(secret);
|
||||
return { code };
|
||||
},
|
||||
);
|
||||
|
||||
const getTOTPURI = createAuthEndpoint(
|
||||
"/get/totp/uri",
|
||||
{
|
||||
method: "GET",
|
||||
use: [sessionMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
if (!options) {
|
||||
ctx.context.logger.error(
|
||||
"totp isn't configured. please pass totp option on two factor plugin to enable totp",
|
||||
);
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "totp isn't configured",
|
||||
});
|
||||
}
|
||||
const user = ctx.context.session.user as UserWithTwoFactor;
|
||||
return {
|
||||
totpURI: createTOTPKeyURI(
|
||||
options?.issuer || "BetterAuth",
|
||||
user.name,
|
||||
Buffer.from(user.twoFactorSecret),
|
||||
opts,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const verifyTOTP = createAuthEndpoint(
|
||||
"/verify/totp",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
code: z.string(),
|
||||
callbackURL: z.string().optional(),
|
||||
}),
|
||||
use: [verifyTwoFactorMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
if (!options) {
|
||||
ctx.context.logger.error(
|
||||
"totp isn't configured. please pass totp option on two factor plugin to enable totp",
|
||||
);
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "totp isn't configured",
|
||||
});
|
||||
}
|
||||
const totp = new TOTPController(opts);
|
||||
const secret = Buffer.from(
|
||||
symmetricDecrypt({
|
||||
key: ctx.context.secret,
|
||||
data: ctx.context.session.user.twoFactorSecret,
|
||||
}),
|
||||
);
|
||||
const status = await totp.verify(ctx.body.code, secret);
|
||||
return {
|
||||
status,
|
||||
};
|
||||
},
|
||||
);
|
||||
return {
|
||||
id: "totp",
|
||||
verify: verifyTOTP,
|
||||
customActions: {
|
||||
generateTOTP: generateTOTP,
|
||||
viewTOTPURI: getTOTPURI,
|
||||
},
|
||||
} satisfies TwoFactorProvider;
|
||||
};
|
||||
56
packages/better-auth/src/plugins/two-factor/types.ts
Normal file
56
packages/better-auth/src/plugins/two-factor/types.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ZodObject, ZodSchema } from "zod";
|
||||
import { User } from "../../adapters/schema";
|
||||
import { AuthEndpoint } from "../../api/call";
|
||||
import { LiteralString } from "../../types/helper";
|
||||
import { BackupCodeOptions } from "./backup-codes";
|
||||
import { TOTPOptions } from "./totp";
|
||||
import { Endpoint } from "better-call";
|
||||
import { OTPOptions } from "./otp";
|
||||
|
||||
export interface TwoFactorOptions {
|
||||
issuer: string;
|
||||
totpOptions?: Omit<TOTPOptions, "issuer">;
|
||||
otpOptions?: OTPOptions;
|
||||
backupCodeOptions?: BackupCodeOptions;
|
||||
requireOn?: {
|
||||
signIn: () => boolean;
|
||||
};
|
||||
/**
|
||||
* The url to redirect to after the user has
|
||||
* signed in to validate the two factor. If not
|
||||
* provided, the callbackURL will be used. If
|
||||
* callbackURL is not provided, the user will be
|
||||
* redirected to the root path.
|
||||
*
|
||||
* @default "/"
|
||||
*/
|
||||
twoFactorURL?: string;
|
||||
}
|
||||
|
||||
export interface UserWithTwoFactor extends User {
|
||||
/**
|
||||
* If the user has enabled two factor authentication.
|
||||
*/
|
||||
twoFactorEnabled: boolean;
|
||||
/**
|
||||
* The secret used to generate the TOTP or OTP.
|
||||
*/
|
||||
twoFactorSecret: string;
|
||||
/**
|
||||
* List of backup codes separated by a
|
||||
* comma
|
||||
*/
|
||||
twoFactorBackupCodes: string;
|
||||
}
|
||||
|
||||
export interface TwoFactorProvider {
|
||||
id: LiteralString;
|
||||
enable?: (user: UserWithTwoFactor) => Promise<void>;
|
||||
disable?: () => Promise<void>;
|
||||
verify: Endpoint<
|
||||
(ctx: any) => Promise<{
|
||||
status: boolean;
|
||||
}>
|
||||
>;
|
||||
customActions?: Record<string, AuthEndpoint>;
|
||||
}
|
||||
154
packages/better-auth/src/plugins/two-factor/verify-middleware.ts
Normal file
154
packages/better-auth/src/plugins/two-factor/verify-middleware.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { APIError } from "better-call";
|
||||
import { Session } from "../../adapters/schema";
|
||||
import { createAuthMiddleware } from "../../api/call";
|
||||
import { hs256 } from "../../crypto";
|
||||
import { TWO_FACTOR_COOKIE_NAME } from "./constant";
|
||||
import { TwoFactorOptions, UserWithTwoFactor } from "./types";
|
||||
import { z } from "zod";
|
||||
|
||||
export const verifyTwoFactorMiddleware = createAuthMiddleware(async (ctx) => {
|
||||
const cookie = await ctx.getSignedCookie(
|
||||
TWO_FACTOR_COOKIE_NAME,
|
||||
ctx.context.secret,
|
||||
);
|
||||
if (!cookie) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
message: "two factor isn't enabled",
|
||||
});
|
||||
}
|
||||
const [userId, hash] = cookie.split("!");
|
||||
if (!userId || !hash) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
message: "invalid two factor cookie",
|
||||
});
|
||||
}
|
||||
const sessions = await ctx.context.adapter.findMany<Session>({
|
||||
model: "session",
|
||||
where: [
|
||||
{
|
||||
field: "userId",
|
||||
value: userId,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!sessions.length) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
message: "invalid session",
|
||||
});
|
||||
}
|
||||
const activeSessions = sessions.filter(
|
||||
(session) => session.expiresAt > new Date(),
|
||||
);
|
||||
if (!activeSessions) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
message: "invalid session",
|
||||
});
|
||||
}
|
||||
for (const session of activeSessions) {
|
||||
const hashToMatch = await hs256(ctx.context.secret, session.id);
|
||||
const user = await ctx.context.adapter.findOne<UserWithTwoFactor>({
|
||||
model: "user",
|
||||
where: [
|
||||
{
|
||||
field: "id",
|
||||
value: session.userId,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!user) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
message: "invalid session",
|
||||
});
|
||||
}
|
||||
if (hashToMatch === hash) {
|
||||
return {
|
||||
createSession: async () => {
|
||||
/**
|
||||
* Set the session cookie
|
||||
*/
|
||||
await ctx.setSignedCookie(
|
||||
ctx.context.authCookies.sessionToken.name,
|
||||
session.id,
|
||||
ctx.context.secret,
|
||||
ctx.context.authCookies.sessionToken.options,
|
||||
);
|
||||
},
|
||||
session: {
|
||||
id: session.id,
|
||||
userId: session.userId,
|
||||
expiresAt: session.expiresAt,
|
||||
user,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
message: "invalid two factor authentication",
|
||||
});
|
||||
});
|
||||
|
||||
export const twoFactorMiddleware = (options: TwoFactorOptions) =>
|
||||
createAuthMiddleware(
|
||||
{
|
||||
body: z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string(),
|
||||
/**
|
||||
* Callback URL to
|
||||
* redirect to after
|
||||
* the user has signed in.
|
||||
*/
|
||||
callbackURL: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
async (ctx) => {
|
||||
//@ts-ignore
|
||||
const signIn = await signInCredential({
|
||||
...ctx,
|
||||
body: ctx.body,
|
||||
});
|
||||
if (!signIn?.user) {
|
||||
return new Response(null, {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
const user = signIn.user as UserWithTwoFactor;
|
||||
if (!user.twoFactorEnabled) {
|
||||
return new Response(JSON.stringify(signIn), {
|
||||
headers: ctx.responseHeader,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* remove the session cookie. It's set by the sign in credential
|
||||
*/
|
||||
ctx.setCookie(ctx.context.authCookies.sessionToken.name, "", {
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
maxAge: 0,
|
||||
});
|
||||
const hash = await hs256(ctx.context.secret, signIn.session.id);
|
||||
/**
|
||||
* We set the user id and the session
|
||||
* id as a hash. Later will fetch for
|
||||
* sessions with the user id compare
|
||||
* the hash and set that as session.
|
||||
*/
|
||||
await ctx.setSignedCookie(
|
||||
"better-auth.two-factor",
|
||||
`${signIn.session.userId}!${hash}`,
|
||||
ctx.context.secret,
|
||||
ctx.context.authCookies.sessionToken.options,
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
url: options.twoFactorURL || ctx.body.callbackURL || "/",
|
||||
redirect: true,
|
||||
}),
|
||||
{
|
||||
headers: ctx.responseHeader,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -130,7 +130,7 @@ export const passkey = (options: PasskeyOptions) => {
|
||||
await ctx.setSignedCookie(
|
||||
opts.advanced.webAuthnChallengeCookie,
|
||||
JSON.stringify(data),
|
||||
ctx.context.options.secret,
|
||||
ctx.context.secret,
|
||||
{
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
@@ -189,7 +189,7 @@ export const passkey = (options: PasskeyOptions) => {
|
||||
await ctx.setSignedCookie(
|
||||
opts.advanced.webAuthnChallengeCookie,
|
||||
JSON.stringify(data),
|
||||
ctx.context.options.secret,
|
||||
ctx.context.secret,
|
||||
{
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
@@ -224,7 +224,7 @@ export const passkey = (options: PasskeyOptions) => {
|
||||
|
||||
const challengeString = await ctx.getSignedCookie(
|
||||
opts.advanced.webAuthnChallengeCookie,
|
||||
ctx.context.options.secret,
|
||||
ctx.context.secret,
|
||||
);
|
||||
if (!challengeString) {
|
||||
return ctx.json(null, {
|
||||
@@ -329,7 +329,7 @@ export const passkey = (options: PasskeyOptions) => {
|
||||
await ctx.setSignedCookie(
|
||||
ctx.context.authCookies.sessionToken.name,
|
||||
s.id,
|
||||
ctx.context.options.secret,
|
||||
ctx.context.secret,
|
||||
ctx.context.authCookies.sessionToken.options,
|
||||
);
|
||||
const user = await ctx.context.internalAdapter.findUserById(
|
||||
|
||||
@@ -25,12 +25,29 @@ export interface BetterAuthOptions {
|
||||
*/
|
||||
basePath?: string;
|
||||
/**
|
||||
* The secret used to sign the session token. This is required for the session to work.
|
||||
* to generate a good secret you can use the following command:
|
||||
* The secret to use for encryption,
|
||||
* signing and hashing.
|
||||
*
|
||||
* @example openssl rand -base64 32
|
||||
* By default better auth will look for
|
||||
* the following environment variables:
|
||||
* process.env.BETTER_AUTH_SECRET,
|
||||
* process.env.AUTH_SECRET
|
||||
* If none of these environment
|
||||
* variables are set,
|
||||
* it will default to
|
||||
* "better-auth-secret-123456789".
|
||||
*
|
||||
* on production if it's not set
|
||||
* it will throw an error.
|
||||
*
|
||||
* you can generate a good secret
|
||||
* using the following command:
|
||||
* @example
|
||||
* ```bash
|
||||
* openssl rand -base64 32
|
||||
* ```
|
||||
*/
|
||||
secret: string;
|
||||
secret?: string;
|
||||
/**
|
||||
* list of oauth providers
|
||||
*/
|
||||
@@ -129,5 +146,11 @@ export interface BetterAuthOptions {
|
||||
* @default 32
|
||||
*/
|
||||
minPasswordLength?: number;
|
||||
/**
|
||||
* Two factor configuration
|
||||
*/
|
||||
twoFactor?: {
|
||||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Migration } from "kysely";
|
||||
import { AuthEndpoint } from "../api/call";
|
||||
import { AuthEndpoint, AuthMiddleware } from "../api/call";
|
||||
import { FieldAttribute } from "../db/field";
|
||||
import { LiteralString } from "./helper";
|
||||
import { Endpoint } from "better-call";
|
||||
|
||||
export type PluginSchema = {
|
||||
[table: string]: {
|
||||
@@ -17,6 +18,10 @@ export type Plugin = {
|
||||
endpoints: {
|
||||
[key: string]: AuthEndpoint;
|
||||
};
|
||||
middlewares?: {
|
||||
path: string;
|
||||
middleware: Endpoint;
|
||||
}[];
|
||||
/**
|
||||
* Schema the plugin needs
|
||||
*
|
||||
@@ -53,4 +58,8 @@ export type Plugin = {
|
||||
* the tables.
|
||||
*/
|
||||
migrations?: Record<string, Migration>;
|
||||
/**
|
||||
* The options of the plugin
|
||||
*/
|
||||
options?: Record<string, any>;
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { User } from "../adapters/schema";
|
||||
import { FieldAttribute } from "../db";
|
||||
import { Migration } from "kysely";
|
||||
import { AuthEndpoint } from "../api/call";
|
||||
import { Context, Endpoint } from "better-call";
|
||||
|
||||
export interface BaseProvider {
|
||||
id: LiteralString;
|
||||
@@ -43,6 +44,17 @@ export interface OAuthProvider extends BaseProvider {
|
||||
userInfo: OAuthUserInfo;
|
||||
}
|
||||
|
||||
export interface TwoFactorProvider extends BaseProvider {
|
||||
type: "two-factor";
|
||||
endpoints: {
|
||||
[key: string]: AuthEndpoint;
|
||||
};
|
||||
verify?: (ctx: any) => Promise<{
|
||||
status: boolean;
|
||||
}>;
|
||||
isEnabled: (user: User) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface CustomProvider extends BaseProvider {
|
||||
type: "custom";
|
||||
endpoints: {
|
||||
@@ -50,6 +62,6 @@ export interface CustomProvider extends BaseProvider {
|
||||
};
|
||||
}
|
||||
|
||||
export type Provider = OAuthProvider | CustomProvider;
|
||||
export type Provider = OAuthProvider | CustomProvider | TwoFactorProvider;
|
||||
|
||||
export type OAuthProviderList = typeof oAuthProviderList;
|
||||
|
||||
@@ -3,7 +3,9 @@ import { BetterAuthOptions } from "../types/options";
|
||||
import { TimeSpan } from "oslo";
|
||||
|
||||
export function getCookies(options: BetterAuthOptions) {
|
||||
const secure = !!options.advanced?.useSecureCookies;
|
||||
const secure =
|
||||
!!options.advanced?.useSecureCookies ||
|
||||
process.env.NODE_ENV === "production";
|
||||
const secureCookiePrefix = secure ? "__Secure-" : "";
|
||||
const cookiePrefix = "better-auth";
|
||||
const sessionMaxAge = new TimeSpan(7, "d").seconds();
|
||||
@@ -61,4 +63,27 @@ export function getCookies(options: BetterAuthOptions) {
|
||||
};
|
||||
}
|
||||
|
||||
export function createCookieGetter(options: BetterAuthOptions) {
|
||||
const secure =
|
||||
!!options.advanced?.useSecureCookies ||
|
||||
process.env.NODE_ENV === "production";
|
||||
const secureCookiePrefix = secure ? "__Secure-" : "";
|
||||
const cookiePrefix = "better-auth";
|
||||
function getCookie(cookieName: string, options?: CookieOptions) {
|
||||
return {
|
||||
name:
|
||||
process.env.NODE_ENV === "production"
|
||||
? `${secureCookiePrefix}${cookiePrefix}.${cookieName}`
|
||||
: `${cookiePrefix}${cookieName}`,
|
||||
options: {
|
||||
secure,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 60 * 15, // 15 minutes in seconds
|
||||
...options,
|
||||
} as CookieOptions,
|
||||
};
|
||||
}
|
||||
return getCookie;
|
||||
}
|
||||
export type BetterAuthCookies = ReturnType<typeof getCookies>;
|
||||
|
||||
49
pnpm-lock.yaml
generated
49
pnpm-lock.yaml
generated
@@ -51,7 +51,7 @@ importers:
|
||||
devDependencies:
|
||||
'@types/bun':
|
||||
specifier: latest
|
||||
version: 1.1.6
|
||||
version: 1.1.7
|
||||
vite:
|
||||
specifier: ^5.3.5
|
||||
version: 5.3.5(@types/node@20.14.12)
|
||||
@@ -109,6 +109,9 @@ importers:
|
||||
input-otp:
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
lucide-react:
|
||||
specifier: ^0.435.0
|
||||
version: 0.435.0(react@18.3.1)
|
||||
next:
|
||||
specifier: 14.2.5
|
||||
version: 14.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -176,6 +179,12 @@ importers:
|
||||
'@nanostores/vue':
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.0(nanostores@0.11.2)(vue@3.4.38(typescript@5.5.4))
|
||||
'@noble/ciphers':
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0
|
||||
'@noble/hashes':
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
'@paralleldrive/cuid2':
|
||||
specifier: ^2.2.2
|
||||
version: 2.2.2
|
||||
@@ -189,8 +198,8 @@ importers:
|
||||
specifier: ^1.9.2
|
||||
version: 1.9.2
|
||||
better-call:
|
||||
specifier: ^0.1.20
|
||||
version: 0.1.20(typescript@5.5.4)
|
||||
specifier: ^0.1.28
|
||||
version: 0.1.28(typescript@5.5.4)
|
||||
chalk:
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
@@ -805,6 +814,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@noble/ciphers@0.6.0':
|
||||
resolution: {integrity: sha512-mIbq/R9QXk5/cTfESb1OKtyFnk7oc1Om/8onA1158K9/OZUQFDEVy55jVTato+xmp3XX6F6Qh0zz0Nc1AxAlRQ==}
|
||||
|
||||
'@noble/hashes@1.4.0':
|
||||
resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==}
|
||||
engines: {node: '>= 16'}
|
||||
@@ -1569,8 +1581,8 @@ packages:
|
||||
'@types/better-sqlite3@7.6.11':
|
||||
resolution: {integrity: sha512-i8KcD3PgGtGBLl3+mMYA8PdKkButvPyARxA7IQAd6qeslht13qxb1zzO8dRCtE7U3IoJS782zDBAeoKiM695kg==}
|
||||
|
||||
'@types/bun@1.1.6':
|
||||
resolution: {integrity: sha512-uJgKjTdX0GkWEHZzQzFsJkWp5+43ZS7HC8sZPFnOwnSo1AsNl2q9o2bFeS23disNDqbggEgyFkKCHl/w8iZsMA==}
|
||||
'@types/bun@1.1.7':
|
||||
resolution: {integrity: sha512-iIIn26SOX8qI5E8Juh+0rUgBmFHvll1akscwerhp9O/fHZGdQBWNLJkkRg/3z2Mh6a3ZgWUIkXViLZZYg47TXw==}
|
||||
|
||||
'@types/estree@1.0.5':
|
||||
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
|
||||
@@ -1721,8 +1733,8 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: ^5.0.0
|
||||
|
||||
better-call@0.1.20:
|
||||
resolution: {integrity: sha512-IEw2rU5ViT/yaZKY51tEl6QGeB3JTUhpAQKoTKbkNYqweFh9xmfNFYOja7v25G+OqrV6j6GPYyD5FqUBqtTHlg==}
|
||||
better-call@0.1.28:
|
||||
resolution: {integrity: sha512-9jl3q7Mb+3lRGeJUqkHAzAHph8BKcGHsj84h1gkfcMy2skyrCUbpFuhxMNAKBDtg2ppa66wTWDv0mGLOshxtEA==}
|
||||
peerDependencies:
|
||||
typescript: ^5.0.0
|
||||
|
||||
@@ -1757,8 +1769,8 @@ packages:
|
||||
bun-html-live-reload@0.1.3:
|
||||
resolution: {integrity: sha512-PW1sp9ZmBAqiAa0aUhHpFc6sEQmC6FgRNKVAvcjSDUMqASzgq7xYpNkEt2Z6VjuiPXKtOx/49b6sLLmjyojrOw==}
|
||||
|
||||
bun-types@1.1.17:
|
||||
resolution: {integrity: sha512-Z4+OplcSd/YZq7ZsrfD00DKJeCwuNY96a1IDJyR73+cTBaFIS7SC6LhpY/W3AMEXO9iYq5NJ58WAwnwL1p5vKg==}
|
||||
bun-types@1.1.25:
|
||||
resolution: {integrity: sha512-WpRb8/N3S5IE8UYdIn39+0Is1XzxsC78+MCe5cIdaer0lfFs6+DREtQH9TM6KJNKTxBYDvbx81RwbvxS5+CkVQ==}
|
||||
|
||||
bundle-require@5.0.0:
|
||||
resolution: {integrity: sha512-GuziW3fSSmopcx4KRymQEJVbZUfqlCqcq7dvs6TYwKRZiegK/2buMxQTPs6MGlNv50wms1699qYO54R8XfRX4w==}
|
||||
@@ -2297,6 +2309,11 @@ packages:
|
||||
resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==}
|
||||
engines: {node: '>=16.14'}
|
||||
|
||||
lucide-react@0.435.0:
|
||||
resolution: {integrity: sha512-we5GKfzjMDw9m9SsyZJvWim9qaT+Ya5kaRS+OGFqgLqXUrPM1h+7CiMw5pKdEIoaBqfXz2pyv9TASAdpIAJs0Q==}
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc
|
||||
|
||||
magic-string@0.30.10:
|
||||
resolution: {integrity: sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==}
|
||||
|
||||
@@ -3613,6 +3630,8 @@ snapshots:
|
||||
'@next/swc-win32-x64-msvc@14.2.5':
|
||||
optional: true
|
||||
|
||||
'@noble/ciphers@0.6.0': {}
|
||||
|
||||
'@noble/hashes@1.4.0': {}
|
||||
|
||||
'@node-rs/argon2-android-arm-eabi@1.7.0':
|
||||
@@ -4282,9 +4301,9 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 20.14.12
|
||||
|
||||
'@types/bun@1.1.6':
|
||||
'@types/bun@1.1.7':
|
||||
dependencies:
|
||||
bun-types: 1.1.17
|
||||
bun-types: 1.1.25
|
||||
|
||||
'@types/estree@1.0.5': {}
|
||||
|
||||
@@ -4468,7 +4487,7 @@ snapshots:
|
||||
rou3: 0.5.1
|
||||
typescript: 5.5.4
|
||||
|
||||
better-call@0.1.20(typescript@5.5.4):
|
||||
better-call@0.1.28(typescript@5.5.4):
|
||||
dependencies:
|
||||
'@better-fetch/fetch': 1.1.4
|
||||
'@types/set-cookie-parser': 2.4.10
|
||||
@@ -4522,7 +4541,7 @@ snapshots:
|
||||
|
||||
bun-html-live-reload@0.1.3: {}
|
||||
|
||||
bun-types@1.1.17:
|
||||
bun-types@1.1.25:
|
||||
dependencies:
|
||||
'@types/node': 20.12.14
|
||||
'@types/ws': 8.5.11
|
||||
@@ -5065,6 +5084,10 @@ snapshots:
|
||||
|
||||
lru-cache@8.0.5: {}
|
||||
|
||||
lucide-react@0.435.0(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
magic-string@0.30.10:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
||||
Reference in New Issue
Block a user