This commit is contained in:
Mythie
2025-01-02 15:33:37 +11:00
committed by David Nguyen
parent 9183f668d3
commit f7a98180d7
413 changed files with 29538 additions and 1606 deletions

View File

@ -0,0 +1,342 @@
import { zValidator } from '@hono/zod-validator';
import { compare } from '@node-rs/bcrypt';
import { Hono } from 'hono';
import { DateTime } from 'luxon';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { jobsClient } from '@documenso/lib/jobs/client';
import { disableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/disable-2fa';
import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa';
import { isTwoFactorAuthenticationEnabled } from '@documenso/lib/server-only/2fa/is-2fa-availble';
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa';
import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
import { createUser } from '@documenso/lib/server-only/user/create-user';
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
import { getMostRecentVerificationTokenByUserId } from '@documenso/lib/server-only/user/get-most-recent-verification-token-by-user-id';
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
import { prisma } from '@documenso/prisma';
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import { AuthenticationErrorCode } from '../lib/errors/error-codes';
import { onAuthorize } from '../lib/utils/authorizer';
import { getRequiredSession } from '../lib/utils/get-session';
import type { HonoAuthContext } from '../types/context';
import {
ZForgotPasswordSchema,
ZResetPasswordSchema,
ZSignInFormSchema,
ZSignUpRequestSchema,
ZVerifyEmailSchema,
} from '../types/email-password';
export const emailPasswordRoute = new Hono<HonoAuthContext>()
/**
* Authorize endpoint.
*/
.post('/authorize', zValidator('json', ZSignInFormSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { email, password, totpCode, backupCode } = c.req.valid('json');
const user = await prisma.user.findFirst({
where: {
email: email.toLowerCase(),
},
});
if (!user || !user.password) {
throw new AppError(AuthenticationErrorCode.NotFound, {
message: 'User not found',
});
}
const isPasswordsSame = await compare(password, user.password);
if (!isPasswordsSame) {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_FAIL,
},
});
throw new AppError(AuthenticationErrorCode.InvalidCredentials, {
message: 'Invalid email or password',
});
}
const is2faEnabled = isTwoFactorAuthenticationEnabled({ user });
if (is2faEnabled) {
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
if (!isValid) {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_2FA_FAIL,
},
});
throw new AppError(AuthenticationErrorCode.IncorrectTwoFactorCode);
}
}
if (!user.emailVerified) {
const mostRecentToken = await getMostRecentVerificationTokenByUserId({
userId: user.id,
});
if (
!mostRecentToken ||
mostRecentToken.expires.valueOf() <= Date.now() ||
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
) {
await jobsClient.triggerJob({
name: 'send.signup.confirmation.email',
payload: {
email: user.email,
},
});
}
throw new AppError('UNVERIFIED_EMAIL', {
message: 'Unverified email',
});
}
if (user.disabled) {
throw new AppError('ACCOUNT_DISABLED', {
message: 'Account disabled',
});
}
await onAuthorize({ userId: user.id }, c);
return c.text('', 201);
})
/**
* Signup endpoint.
*/
.post('/signup', zValidator('json', ZSignUpRequestSchema), async (c) => {
// if (NEXT_PUBLIC_DISABLE_SIGNUP() === 'true') {
// throw new AppError('SIGNUP_DISABLED', {
// message: 'Signups are disabled.',
// });
// }
const { name, email, password, signature, url } = c.req.valid('json');
// if (IS_BILLING_ENABLED() && url && url.length < 6) {
// throw new AppError(AppErrorCode.PREMIUM_PROFILE_URL, {
// message: 'Only subscribers can have a username shorter than 6 characters',
// });
// }
const user = await createUser({ name, email, password, signature, url });
await jobsClient.triggerJob({
name: 'send.signup.confirmation.email',
payload: {
email: user.email,
},
});
// Todo: Check this.
return c.json({
user,
});
})
/**
* Verify email endpoint.
*/
.post('/verify-email', zValidator('json', ZVerifyEmailSchema), async (c) => {
await verifyEmail({ token: c.req.valid('json').token });
return c.text('OK', 201);
})
/**
* Forgot password endpoint.
*/
.post('/forgot-password', zValidator('json', ZForgotPasswordSchema), async (c) => {
const { email } = c.req.valid('json');
await forgotPassword({
email,
});
return c.text('OK', 201);
})
/**
* Reset password endpoint.
*/
.post('/reset-password', zValidator('json', ZResetPasswordSchema), async (c) => {
const { token, password } = c.req.valid('json');
await resetPassword({
token,
password,
});
return c.text('OK', 201);
})
/**
* Setup two factor authentication.
*/
.post('/2fa/setup', async (c) => {
const { user } = await getRequiredSession(c);
const result = await setupTwoFactorAuthentication({
user,
});
return c.json({
success: true,
secret: result.secret,
uri: result.uri,
});
})
/**
* Enable two factor authentication.
*/
.post(
'/2fa/enable',
zValidator(
'json',
z.object({
code: z.string(),
}),
),
async (c) => {
const requestMetadata = c.get('requestMetadata');
const { user: sessionUser } = await getRequiredSession(c);
const user = await prisma.user.findFirst({
where: {
id: sessionUser.id,
},
select: {
id: true,
email: true,
twoFactorEnabled: true,
twoFactorSecret: true,
},
});
if (!user) {
throw new AppError(AuthenticationErrorCode.InvalidRequest);
}
const { code } = c.req.valid('json');
const result = await enableTwoFactorAuthentication({
user,
code,
requestMetadata,
});
return c.json({
success: true,
recoveryCodes: result.recoveryCodes,
});
},
)
/**
* Disable two factor authentication.
*/
.post(
'/2fa/disable',
zValidator(
'json',
z.object({
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
}),
),
async (c) => {
const requestMetadata = c.get('requestMetadata');
const { user: sessionUser } = await getRequiredSession(c);
const user = await prisma.user.findFirst({
where: {
id: sessionUser.id,
},
select: {
id: true,
email: true,
twoFactorEnabled: true,
twoFactorSecret: true,
},
});
if (!user) {
throw new AppError(AuthenticationErrorCode.InvalidRequest);
}
const { totpCode, backupCode } = c.req.valid('json');
await disableTwoFactorAuthentication({
user,
totpCode,
backupCode,
requestMetadata,
});
return c.json({
success: true,
});
},
)
/**
* View backup codes.
*/
.post(
'/2fa/view-recovery-codes',
zValidator(
'json',
z.object({
token: z.string(),
}),
),
async (c) => {
const { user: sessionUser } = await getRequiredSession(c);
const user = await prisma.user.findFirst({
where: {
id: sessionUser.id,
},
select: {
id: true,
email: true,
twoFactorEnabled: true,
twoFactorSecret: true,
twoFactorBackupCodes: true,
},
});
if (!user) {
throw new AppError(AuthenticationErrorCode.InvalidRequest);
}
const { token } = c.req.valid('json');
const backupCodes = await viewBackupCodes({
user,
token,
});
return c.json({
success: true,
backupCodes,
});
},
);

View File

@ -0,0 +1,202 @@
import { Google, decodeIdToken, generateCodeVerifier, generateState } from 'arctic';
import { Hono } from 'hono';
import { getCookie, setCookie } from 'hono/cookie';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
import { prisma } from '@documenso/prisma';
import { AuthenticationErrorCode } from '../lib/errors/error-codes';
import { onAuthorize } from '../lib/utils/authorizer';
import { getRequiredSession } from '../lib/utils/get-session';
import type { HonoAuthContext } from '../types/context';
const options = {
clientId: import.meta.env.NEXT_PRIVATE_GOOGLE_CLIENT_ID,
clientSecret: import.meta.env.NEXT_PRIVATE_GOOGLE_CLIENT_SECRET,
redirectUri: 'http://localhost:3000/api/auth/google/callback',
scope: ['openid', 'email', 'profile'],
id: 'google',
};
const google = new Google(options.clientId, options.clientSecret, options.redirectUri);
// todo: NEXT_PRIVATE_OIDC_WELL_KNOWN???
export const googleRoute = new Hono<HonoAuthContext>()
/**
* Authorize endpoint.
*/
.post('/authorize', (c) => {
const scopes = options.scope;
const state = generateState();
const codeVerifier = generateCodeVerifier();
const url = google.createAuthorizationURL(state, codeVerifier, scopes);
setCookie(c, 'google_oauth_state', state, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 10, // 10 minutes
sameSite: 'lax',
});
setCookie(c, 'google_code_verifier', codeVerifier, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 10, // 10 minutes
sameSite: 'lax',
});
// return new Response(null, {
// status: 302,
// headers: {
// Location: url.toString()
// }
// });
return c.json({
redirectUrl: url,
});
})
/**
* Google callback verification.
*/
.get('/callback', async (c) => {
// Todo: Use ZValidator to validate query params.
const code = c.req.query('code');
const state = c.req.query('state');
const storedState = getCookie(c, 'google_oauth_state');
const storedCodeVerifier = getCookie(c, 'google_code_verifier');
if (!code || !storedState || state !== storedState || !storedCodeVerifier) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid or missing state',
});
}
const tokens = await google.validateAuthorizationCode(code, storedCodeVerifier);
const accessToken = tokens.accessToken();
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
const idToken = tokens.idToken();
console.log(tokens);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const claims = decodeIdToken(tokens.idToken()) as Record<string, unknown>;
console.log(claims);
const googleEmail = claims.email;
const googleName = claims.name;
const googleSub = claims.sub;
if (
typeof googleEmail !== 'string' ||
typeof googleName !== 'string' ||
typeof googleSub !== 'string'
) {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Invalid google claims',
});
}
if (claims.email_verified !== true) {
throw new AppError(AuthenticationErrorCode.UnverifiedEmail, {
message: 'Account email is not verified',
});
}
// Find the account if possible.
const existingAccount = await prisma.account.findFirst({
where: {
provider: 'google',
providerAccountId: googleSub,
},
include: {
user: true,
},
});
// Directly log in user if account already exists.
if (existingAccount) {
await onAuthorize({ userId: existingAccount.user.id }, c);
return c.redirect('/documents', 302); // Todo: Redirect
}
const userWithSameEmail = await prisma.user.findFirst({
where: {
email: googleEmail,
},
});
// Handle existing user but no account.
if (userWithSameEmail) {
await prisma.account.create({
data: {
type: 'oauth',
provider: 'google',
providerAccountId: googleSub,
access_token: accessToken,
expires_at: Math.floor(accessTokenExpiresAt.getTime() / 1000),
token_type: 'Bearer',
id_token: idToken,
userId: userWithSameEmail.id,
},
});
// Todo: Link account
await onAuthorize({ userId: userWithSameEmail.id }, c);
return c.redirect('/documents', 302); // Todo: Redirect
}
// Handle new user.
const createdUser = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
email: googleEmail,
name: googleName,
},
});
await tx.account.create({
data: {
type: 'oauth',
provider: 'google',
providerAccountId: googleSub,
access_token: accessToken,
expires_at: Math.floor(accessTokenExpiresAt.getTime() / 1000),
token_type: 'Bearer',
id_token: idToken,
userId: user.id,
},
});
return user;
});
await onAuthorize({ userId: createdUser.id }, c);
return c.redirect('/documents', 302); // Todo: Redirect
})
/**
* Setup passkey authentication.
*/
.post('/setup', async (c) => {
const { user } = await getRequiredSession(c);
const result = await setupTwoFactorAuthentication({
user,
});
return c.json({
success: true,
secret: result.secret,
uri: result.uri,
});
});

View File

@ -0,0 +1,144 @@
import { zValidator } from '@hono/zod-validator';
import { UserSecurityAuditLogType } from '@prisma/client';
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { Hono } from 'hono';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TAuthenticationResponseJSONSchema } from '@documenso/lib/types/webauthn';
import { ZAuthenticationResponseJSONSchema } from '@documenso/lib/types/webauthn';
import { getAuthenticatorOptions } from '@documenso/lib/utils/authenticator';
import { prisma } from '@documenso/prisma';
import { onAuthorize } from '../lib/utils/authorizer';
import { getRequiredSession } from '../lib/utils/get-session';
import type { HonoAuthContext } from '../types/context';
import { ZPasskeyAuthorizeSchema } from '../types/passkey';
export const passkeyRoute = new Hono<HonoAuthContext>()
.post('/authorize', zValidator('json', ZPasskeyAuthorizeSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { csrfToken, credential } = c.req.valid('json');
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST);
}
let requestBodyCrediential: TAuthenticationResponseJSONSchema | null = null;
try {
const parsedBodyCredential = JSON.parse(credential);
requestBodyCrediential = ZAuthenticationResponseJSONSchema.parse(parsedBodyCredential);
} catch {
throw new AppError(AppErrorCode.INVALID_REQUEST);
}
const challengeToken = await prisma.anonymousVerificationToken
.delete({
where: {
id: csrfToken,
},
})
.catch(() => null);
if (!challengeToken) {
return null;
}
if (challengeToken.expiresAt < new Date()) {
throw new AppError(AppErrorCode.EXPIRED_CODE);
}
const passkey = await prisma.passkey.findFirst({
where: {
credentialId: Buffer.from(requestBodyCrediential.id, 'base64'),
},
include: {
user: {
select: {
id: true,
email: true,
name: true,
emailVerified: true,
},
},
},
});
if (!passkey) {
throw new AppError(AppErrorCode.NOT_SETUP);
}
const user = passkey.user;
const { rpId, origin } = getAuthenticatorOptions();
const verification = await verifyAuthenticationResponse({
response: requestBodyCrediential,
expectedChallenge: challengeToken.token,
expectedOrigin: origin,
expectedRPID: rpId,
authenticator: {
credentialID: new Uint8Array(Array.from(passkey.credentialId)),
credentialPublicKey: new Uint8Array(passkey.credentialPublicKey),
counter: Number(passkey.counter),
},
}).catch(() => null);
if (!verification?.verified) {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL,
},
});
return null;
}
await prisma.passkey.update({
where: {
id: passkey.id,
},
data: {
lastUsedAt: new Date(),
counter: verification.authenticationInfo.newCounter,
},
});
await onAuthorize({ userId: user.id }, c);
return c.json(
{
url: '/documents',
},
200,
);
})
.post('/register', async (c) => {
const { user } = await getRequiredSession(c);
//
})
.post(
'/pre-authenticate',
zValidator(
'json',
z.object({
code: z.string(),
}),
),
async (c) => {
//
return c.json({
success: true,
recoveryCodes: result.recoveryCodes,
});
},
);

View File

@ -0,0 +1,10 @@
import { Hono } from 'hono';
import type { SessionValidationResult } from '../lib/session/session';
import { getSession } from '../lib/utils/get-session';
export const sessionRoute = new Hono().get('/session', async (c) => {
const session: SessionValidationResult = await getSession(c);
return c.json(session);
});

View File

@ -0,0 +1,29 @@
import { Hono } from 'hono';
import { deleteCookie, getSignedCookie } from 'hono/cookie';
import { invalidateSession, validateSessionToken } from '../lib/session/session';
export const signOutRoute = new Hono().post('/signout', async (c) => {
// todo: secret
const sessionId = await getSignedCookie(c, 'secret', 'sessionId');
if (!sessionId) {
return new Response('No session found', { status: 401 });
}
const { session } = await validateSessionToken(sessionId);
if (!session) {
return new Response('No session found', { status: 401 });
}
await invalidateSession(session.id);
deleteCookie(c, 'sessionId', {
path: '/',
secure: true,
domain: 'example.com',
});
return c.status(200);
});