-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
Showing
112 changed files
with
3,065 additions
and
1,134 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -45,3 +45,4 @@ node_modules/ | |
/tests-examples/ | ||
/allure-results/ | ||
/allure-report/ | ||
/save |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.