Skip to content

Commit

Permalink
Major application overhaul (#23)
Browse files Browse the repository at this point in the history
* Major application overhaul

- edge-compatible cryptography replacing bcrypt
- Magic link authentication flow
- Error bubbling pattern across app
- Refactored server actions, improved reliability, better error handling
- Reorganized codebase structure for better maintainability
- Name changes
- Stuff(...)

🏗️ Core Improvements:
- Centralized error handling
- Enhanced type safety
- Edge runtime compatibility
- Server actions error handling pattern updated
- Auth flow modifications
- Magic-link feature.
- Prisma schema more in line with docs
- Auth.js handling more in line with docs
- Credentials with CustomVerificationToken.
- VerificationToken with magic-link
- Ready to start implementing WebAuthn
  • Loading branch information
zenWai authored Nov 24, 2024
1 parent b5b1186 commit 69eff23
Show file tree
Hide file tree
Showing 112 changed files with 3,065 additions and 1,134 deletions.
41 changes: 36 additions & 5 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,41 @@
"extends": ["next/core-web-vitals", "prettier", "next"],
"plugins": ["import", "jsx-a11y", "react-hooks", "react"],
"rules": {
"react/function-component-definition": [
"warn",
{
"namedComponents": "arrow-function"
}
],
"react/jsx-pascal-case": "warn",
"import/no-anonymous-default-export": "warn",
"import/no-extraneous-dependencies": "error",
"react/jsx-filename-extension": [1, { "extensions": [".jsx", ".tsx"] }],
"react/jsx-filename-extension": [
1,
{
"extensions": [".jsx", ".tsx"]
}
],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"jsx-a11y/accessible-emoji": "warn",
"no-console": ["error", { "allow": ["warn", "error"] }],
"no-console": [
"error",
{
"allow": ["warn", "error"]
}
],
"consistent-return": "error",
"import/order": [
"error",
{ "groups": ["builtin", "external", "internal", "parent", "sibling", "index"], "newlines-between": "always" }
{
"groups": ["builtin", "external", "internal", "parent", "sibling", "index", "object", "type"],
"newlines-between": "always",
"alphabetize": {
"order": "asc",
"caseInsensitive": true
}
}
],
"react/jsx-key": "warn",
"react/no-array-index-key": "warn",
Expand All @@ -29,10 +54,16 @@
},
"overrides": [
{
"files": ["e2e-tests/**/*", "allure-report/**/*"],
"files": ["e2e-tests/**/*", "allure-report/**/*", "app/**/*.{js,jsx,ts,tsx}"],
"rules": {
"no-console": "off",
"consistent-return": "off"
"consistent-return": "off",
"react/function-component-definition": [
"warn",
{
"namedComponents": "function-declaration"
}
]
}
}
],
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ node_modules/
/tests-examples/
/allure-results/
/allure-report/
/save
16 changes: 7 additions & 9 deletions actions/admin.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
'use server';

import { UserRole } from '@prisma/client';
import { sessionHasRole } from '@/lib/auth/auth-utils';
import { messages } from '@/lib/constants/messages/actions/messages';

import { currentSessionRole } from '@/lib/auth-utils';

export const admin = async () => {
const role = await currentSessionRole();

if (role === UserRole.ADMIN) {
return { success: 'Allowed Server Action!' };
export const adminAction = async () => {
const isAdmin = await sessionHasRole('ADMIN');
if (!isAdmin) {
return { error: messages.admin.errors.FORBIDDEN_SA };
}

return { error: 'Forbidden Server Action!' };
return { success: messages.admin.success.ALLOWED_SA };
};
251 changes: 156 additions & 95 deletions actions/login.ts
Original file line number Diff line number Diff line change
@@ -1,129 +1,190 @@
'use server';

import { PrismaClientKnownRequestError, PrismaClientInitializationError } from '@prisma/client/runtime/library';
import { AuthError } from 'next-auth';
import * as zod from 'zod';
import bcrypt from 'bcryptjs';

import { getVerificationTokenByEmail } from '@/data/verification-token';
import { getTwoFactorConfirmationByUserId } from '@/data/two-factor-confirmation';
import { getTwoFactorTokenByEmail } from '@/data/two-factor-token';
import { db } from '@/lib/db';
import { getUserByEmail } from '@/data/user';
import { sendVerificationEmail, sendTwoFactorTokenEmail } from '@/lib/mail';
import { generateVerificationToken, generateTwoFactorToken } from '@/lib/tokens';
import { DEFAULT_LOGIN_REDIRECT } from '@/routes';
import { LoginSchema } from '@/schemas';
import { signIn } from '@/auth';

export const login = async (values: zod.infer<typeof LoginSchema>, callbackUrl?: string | null) => {
const validatedFields = LoginSchema.safeParse(values);

if (!validatedFields.success) {
return { error: 'Invalid fields!' };
}

const { email, password, code } = validatedFields.data;

const existingUser = await getUserByEmail(email);
if (!existingUser || !existingUser.email || !existingUser.password) {
return { error: 'Invalid credentials' };
}

// Verify password before proceeding with any other checks
const passwordsMatch = await bcrypt.compare(password, existingUser.password);
if (!passwordsMatch) {
return { error: 'Invalid credentials' };
}
import { signIn } from '@/auth';
import { generateTwoFactorToken } from '@/data/db/tokens/two-factor/create';
import { generateCustomVerificationToken } from '@/data/db/tokens/verification-email/create';
import { deleteCustomVerificationTokenById } from '@/data/db/tokens/verification-email/delete';
import { consumeTwoFactorToken, getUserLoginAuthData } from '@/data/db/user/login';
import { CustomLoginAuthError } from '@/lib/constants/errors/errors';
import { messages } from '@/lib/constants/messages/actions/messages';
import { verifyPassword } from '@/lib/crypto/hash-edge-compatible';
import { sendVerificationEmail, sendTwoFactorTokenEmail } from '@/lib/mail/mail';
import { DEFAULT_LOGIN_REDIRECT } from '@/routes';
import { CallbackUrlSchema, LoginSchema } from '@/schemas';

import type { VerifiedUserForAuth } from '@/lib/auth/types';

type LoginActionResult =
| { success: string; error?: never; twoFactor?: never }
| { error: string; success?: never; twoFactor?: never }
| { twoFactor: true; success?: never; error?: never };

/**
* Server action to handle user authentication with support for 2FA and email verification
*
* Manages the complete login flow including credentials verification, 2FA handling,
* email verification status, and proper session creation. Supports callback URLs
* and handles various authentication scenarios.
*
* 1. Validate input fields and callback URL
* 2. Fetch user authentication data
* 3. Verify password
* - Check needs update to Reset Password
* 4. Check email verification status
* - Send verification email if needed
* 5. Handle 2FA if enabled
* - Generate and send 2FA token if needed
* - Send user to 2FA form
* - Verify 2FA code if provided
* 6. Create authenticated session with auth.js
*
* @notes
* - Successful login throws NEXT_REDIRECT (normal Auth.js behavior)
* - 2FA tokens are single-use
* - Email verification tokens are reissued if expired
* - Supports custom callback URLs with validation
*/
export const loginAction = async (
values: zod.infer<typeof LoginSchema>,
callbackUrl: string | null
): Promise<LoginActionResult> => {
try {
const validatedFields = LoginSchema.safeParse(values);
const validatedCallbackUrl = CallbackUrlSchema.safeParse(callbackUrl);

/** Confirmation email token recently sent?
* if not, generates and send email
*/
if (!existingUser.emailVerified) {
const existingToken = await getVerificationTokenByEmail(email);
if (existingToken) {
const hasExpired = new Date(existingToken.expires) < new Date();
if (!hasExpired) {
return { error: 'Confirmation email already sent! Check your inbox!' };
}
if (!validatedFields.success) {
throw new CustomLoginAuthError('InvalidFields');
}

const verificationToken = await generateVerificationToken(email, existingUser.id);
callbackUrl = validatedCallbackUrl.success ? validatedCallbackUrl.data : null;
const { email, password, twoFactorCode } = validatedFields.data;

await sendVerificationEmail(verificationToken.email, verificationToken.token);

return { success: 'Confirmation email sent!' };
}
// Get all user auth data in a single query
const { user, activeCustomVerificationToken, activeTwoFactorToken } = await getUserLoginAuthData(email);

/** 2FA code logic
* Currently if current token is unexpired it does not re-send a new one
* Reduce db calls and e-mail sents on this preview
*/
if (existingUser.isTwoFactorEnabled && existingUser.email) {
// If user is already at the 2fa on loginForm
if (code) {
const twoFactorToken = await getTwoFactorTokenByEmail(existingUser.email);
if (!twoFactorToken) {
return { error: 'Invalid two factor token' };
}
if (!user?.email || !user?.password) {
throw new CustomLoginAuthError('WrongCredentials');
}

if (twoFactorToken.token !== code) {
return { error: 'Invalid code' };
}
// Verify password, this handles crypto version changes
const { isPasswordValid, passwordNeedsUpdate } = await verifyPassword(password, user.password);
if (passwordNeedsUpdate) {
throw new CustomLoginAuthError('PasswordNeedUpdate');
}
if (!isPasswordValid) {
throw new CustomLoginAuthError('WrongCredentials');
}

const hasExpired = new Date(twoFactorToken.expires) < new Date();
if (hasExpired) {
return { error: 'Code expired!' };
// Handle email verification
if (!user.emailVerified) {
if (activeCustomVerificationToken) {
throw new CustomLoginAuthError('ConfirmationEmailAlreadySent');
}

await db.twoFactorToken.delete({
where: { id: twoFactorToken.id },
const customVerificationToken = await generateCustomVerificationToken({
email,
userId: user.id,
});

const existingConfirmation = await getTwoFactorConfirmationByUserId(existingUser.id);
if (existingConfirmation) {
await db.twoFactorConfirmation.delete({
where: { id: existingConfirmation.id },
});
const emailResponse = await sendVerificationEmail(customVerificationToken.email, customVerificationToken.token);
if (emailResponse.error) {
await deleteCustomVerificationTokenById(customVerificationToken.id);
throw new CustomLoginAuthError('ResendEmailError');
}
// consumed by the signIn callback
await db.twoFactorConfirmation.create({
data: { userId: existingUser.id },
});
} else {
// return { twoFactor: true }; sends the user to the 2fa on loginForm
const existingTwoFactorToken = await getTwoFactorTokenByEmail(existingUser.email);
if (existingTwoFactorToken) {
const hasExpired = new Date(existingTwoFactorToken.expires) < new Date();
if (!hasExpired) {
return { twoFactor: true };
throw new CustomLoginAuthError('NewConfirmationEmailSent');
}

// Handle 2FA
if (user.isTwoFactorEnabled) {
if (twoFactorCode) {
if (!activeTwoFactorToken) {
throw new CustomLoginAuthError('TwoFactorTokenNotExists');
}
}
const twoFactorToken = await generateTwoFactorToken(existingUser.email, existingUser.id);

await sendTwoFactorTokenEmail(existingUser.email, twoFactorToken.token);
if (activeTwoFactorToken.token !== twoFactorCode) {
throw new CustomLoginAuthError('TwoFactorCodeInvalid');
}

return { twoFactor: true };
await consumeTwoFactorToken(activeTwoFactorToken.token, user.id);
} else {
// At this point user is logging in and have 2FA Activated
if (!activeTwoFactorToken) {
const twoFactorToken = await generateTwoFactorToken(user.email, user.id);
const emailResponse = await sendTwoFactorTokenEmail(user.email, twoFactorToken.token);
if (emailResponse.error) {
throw new CustomLoginAuthError('ResendEmailError');
}
}
// We send user to the 2FA Code Form
return { twoFactor: true };
}
}
}

try {
const verifiedUser: VerifiedUserForAuth = {
id: user.id,
email: user.email,
name: user.name ?? null,
role: user.role,
isTwoFactorEnabled: user.isTwoFactorEnabled,
emailVerified: user.emailVerified,
image: user.image,
isOauth: false,
};
// Stringify user object since Auth.js credentials only accept strings
// Will be JSON.parsed in the auth callback
// by doing JSON.stringify, is easier to construct the object again.
// Could use formData too
await signIn('credentials', {
email,
password,
redirectTo: callbackUrl || DEFAULT_LOGIN_REDIRECT,
user: JSON.stringify(verifiedUser),
redirectTo: callbackUrl ?? DEFAULT_LOGIN_REDIRECT,
});
} catch (error) {
if (error instanceof CustomLoginAuthError) {
switch (error.type) {
case 'InvalidFields':
return { error: messages.login.errors.INVALID_FIELDS };
case 'WrongCredentials':
return { error: messages.login.errors.WRONG_CREDENTIALS };
case 'ConfirmationEmailAlreadySent':
return { error: messages.login.errors.CONFIRMATION_EMAIL_ALREADY_SENT };
case 'ResendEmailError':
return { error: messages.login.errors.RESEND_EMAIL_ERROR };
case 'NewConfirmationEmailSent':
return { error: messages.login.errors.NEW_CONFIRMATION_EMAIL_SENT };
case 'TwoFactorTokenNotExists':
return { error: messages.login.errors.TWO_FACTOR_TOKEN_NOT_EXISTS };
case 'TwoFactorCodeInvalid':
return { error: messages.login.errors.TWO_FACTOR_CODE_INVALID };
case 'PasswordNeedUpdate':
return { error: messages.login.errors.ASK_USER_RESET_PASSWORD };
default:
return { error: messages.generic.errors.UNKNOWN_ERROR };
}
}

if (error instanceof PrismaClientKnownRequestError || error instanceof PrismaClientInitializationError) {
console.error('Database error:', error);
return { error: messages.generic.errors.DB_CONNECTION_ERROR };
}

if (error instanceof AuthError) {
switch (error.type) {
case 'CredentialsSignin':
return { error: 'Invalid credentials' };
return { error: messages.login.errors.AUTH_ERROR };
default:
return { error: 'An error occurred' };
return { error: messages.login.errors.AUTH_ERROR };
}
}

throw error;
if (error instanceof Error && error.message?.includes('NEXT_REDIRECT')) {
throw error; // This is necessary for the redirect to work
}

return { error: messages.generic.errors.UNEXPECTED_ERROR };
}

return { error: 'Something went wrong!' };
return { error: messages.generic.errors.NASTY_WEIRD_ERROR };
};
4 changes: 2 additions & 2 deletions actions/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { signOut } from '@/auth';

export const logout = async () => {
export const logoutAction = async () => {
// some server stuff
await signOut();
await signOut({ redirectTo: '/' });
};
Loading

0 comments on commit 69eff23

Please sign in to comment.