Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Major application overhaul #23

Merged
merged 2 commits into from
Nov 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading