Files
Todo/lib/auth-actions.ts

370 lines
11 KiB
TypeScript

'use server'
import { prisma } from './db'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import { signIn, signOut } from 'next-auth/react'
import { checkRateLimit, resetRateLimit, LOGIN_RATE_LIMIT, REGISTER_RATE_LIMIT } from './rate-limit'
import { headers } from 'next/headers'
import { getClientIdentifier } from './rate-limit'
// Validation schemas
const registerSchema = z.object({
email: z.string().email({ message: 'Invalid email address' }),
password: z.string().min(8, { message: 'Password must be at least 8 characters' }),
name: z.string().optional(),
})
const loginSchema = z.object({
email: z.string().email({ message: 'Invalid email address' }),
password: z.string().min(1, { message: 'Password is required' }),
})
export type RegisterResult =
| { success: true; userId: string }
| { success: false; error: string }
export type LoginResult =
| { success: true }
| { success: false; error: string }
/**
* Register a new user with email and password
* Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 9.3 (rate limiting)
*/
export async function registerUser(
email: string,
password: string,
name?: string
): Promise<RegisterResult> {
try {
// Rate limiting - Requirement 9.3
// Only apply rate limiting when headers are available (not in test environment)
try {
const headersList = await headers()
const clientId = getClientIdentifier(headersList)
const rateLimitKey = `register:${clientId}`
const rateLimit = checkRateLimit(rateLimitKey, REGISTER_RATE_LIMIT)
if (!rateLimit.allowed) {
const minutesUntilReset = Math.ceil((rateLimit.retryAfter || 0) / 60000)
return {
success: false,
error: `Too many registration attempts. Please try again in ${minutesUntilReset} minutes.`
}
}
} catch (headerError) {
// Headers not available (e.g., in test environment) - skip rate limiting
console.log('Rate limiting skipped: headers not available')
}
// Validate input (Requirement 1.5 - password length validation)
const validation = registerSchema.safeParse({ email, password, name })
if (!validation.success) {
const errors = validation.error.issues.map((issue) => issue.message).join(', ')
return { success: false, error: errors }
}
const { email: validEmail, password: validPassword, name: validName } = validation.data
// Check if email already exists (Requirement 1.2 - duplicate email rejection)
const existingUser = await prisma.user.findUnique({
where: { email: validEmail },
})
if (existingUser) {
return { success: false, error: 'Email already registered' }
}
// Hash password (Requirement 1.3 - password hashing)
const passwordHash = await bcrypt.hash(validPassword, 10)
// Create user (Requirement 1.1 - create user with unique identifier)
const user = await prisma.user.create({
data: {
email: validEmail,
passwordHash,
name: validName || null,
},
})
// Create session after successful registration (Requirement 1.4)
try {
await signIn('credentials', {
email: validEmail,
password: validPassword,
redirect: false,
})
} catch (error) {
// If session creation fails, still return success since user was created
console.error('Session creation failed after registration:', error)
}
return { success: true, userId: user.id }
} catch (error) {
console.error('Registration error:', error)
// Handle unique constraint violation (shouldn't happen due to check above, but just in case)
if (error instanceof Error && error.message.includes('Unique constraint')) {
return { success: false, error: 'Email already registered' }
}
return { success: false, error: 'Registration failed. Please try again.' }
}
}
/**
* Login user with email and password
* Requirements: 2.1, 2.2, 9.3 (rate limiting)
*/
export async function loginUser(
email: string,
password: string
): Promise<LoginResult> {
try {
// Rate limiting - Requirement 9.3
// Use email as identifier to prevent brute force attacks on specific accounts
const rateLimitKey = `login:${email.toLowerCase()}`
const rateLimit = checkRateLimit(rateLimitKey, LOGIN_RATE_LIMIT)
if (!rateLimit.allowed) {
const minutesUntilReset = Math.ceil((rateLimit.retryAfter || 0) / 60000)
return {
success: false,
error: `Too many login attempts. Please try again in ${minutesUntilReset} minutes.`
}
}
// Validate input (Requirement 2.1 - credential validation)
const validation = loginSchema.safeParse({ email, password })
if (!validation.success) {
const errors = validation.error.issues.map((issue) => issue.message).join(', ')
return { success: false, error: errors }
}
const { email: validEmail, password: validPassword } = validation.data
// Attempt to sign in (Requirement 2.1 - authenticate and create session)
// The authorize function in auth.ts handles:
// - Querying user from database
// - Password verification with bcrypt
// - Session creation on success
await signIn('credentials', {
email: validEmail,
password: validPassword,
redirect: false,
})
// If signIn doesn't throw an error, authentication was successful
// Reset rate limit on successful login
resetRateLimit(rateLimitKey)
return { success: true }
} catch (error) {
// Handle invalid credentials (Requirement 2.2)
// NextAuth throws an error for invalid credentials
console.error('Login error:', error)
// Return generic error message to avoid revealing whether email exists
return { success: false, error: 'Invalid email or password' }
}
}
/**
* Logout user and terminate session
* Requirement: 2.5 - Logout terminates session and clears authentication tokens
*/
export async function logoutUser(): Promise<void> {
try {
// Sign out using NextAuth - this will:
// 1. Clear the session token
// 2. Remove authentication cookies
// 3. Terminate the session
await signOut({ redirect: false })
} catch (error) {
console.error('Logout error:', error)
throw new Error('Logout failed')
}
}
/**
* Get current session
* Requirements: 3.2 - Session validation and persistence
*/
export async function getCurrentSession() {
try {
const { getServerSession } = await import('next-auth/next')
const { authOptions } = await import('@/pages/api/auth/[...nextauth]')
const session = await getServerSession(authOptions)
return session
} catch (error) {
console.error('Session retrieval error:', error)
return null
}
}
/**
* Validate if user is authenticated
* Requirements: 2.4, 3.3 - Session expiration and validation
*/
export async function isAuthenticated(): Promise<boolean> {
const session = await getCurrentSession()
return !!session?.user
}
// Profile management schemas
const updateProfileSchema = z.object({
name: z.string().min(1, { message: 'Name is required' }).max(100, { message: 'Name must be less than 100 characters' }).optional(),
email: z.string().email({ message: 'Invalid email address' }).optional(),
})
const updatePasswordSchema = z.object({
currentPassword: z.string().min(1, { message: 'Current password is required' }),
newPassword: z.string().min(8, { message: 'New password must be at least 8 characters' }),
})
export type UpdateProfileResult =
| { success: true }
| { success: false; error: string }
export type UpdatePasswordResult =
| { success: true }
| { success: false; error: string }
/**
* Update user profile information
* Requirements: 7.1, 7.4, 7.5
*/
export async function updateProfile(
userId: string,
data: { name?: string; email?: string }
): Promise<UpdateProfileResult> {
try {
// Validate input
const validation = updateProfileSchema.safeParse(data)
if (!validation.success) {
const errors = validation.error.issues.map((issue) => issue.message).join(', ')
return { success: false, error: errors }
}
const { name, email } = validation.data
// Check if there's anything to update
if (!name && !email) {
return { success: false, error: 'No data to update' }
}
// If email is being updated, check uniqueness (Requirement 7.4)
if (email) {
const existingUser = await prisma.user.findUnique({
where: { email },
})
// Check if email is already in use by another user
if (existingUser && existingUser.id !== userId) {
return { success: false, error: 'Email already in use' }
}
}
// Update user profile (Requirement 7.1)
await prisma.user.update({
where: { id: userId },
data: {
...(name !== undefined && { name }),
...(email !== undefined && { email }),
},
})
return { success: true }
} catch (error) {
console.error('Profile update error:', error)
// Handle unique constraint violation
if (error instanceof Error && error.message.includes('Unique constraint')) {
return { success: false, error: 'Email already in use' }
}
return { success: false, error: 'Profile update failed. Please try again.' }
}
}
/**
* Update user password
* Requirements: 7.2, 7.3
*/
export async function updatePassword(
userId: string,
currentPassword: string,
newPassword: string
): Promise<UpdatePasswordResult> {
try {
// Validate input
const validation = updatePasswordSchema.safeParse({ currentPassword, newPassword })
if (!validation.success) {
const errors = validation.error.issues.map((issue) => issue.message).join(', ')
return { success: false, error: errors }
}
// Get user from database
const user = await prisma.user.findUnique({
where: { id: userId },
})
if (!user || !user.passwordHash) {
return { success: false, error: 'User not found' }
}
// Verify current password (Requirement 7.2)
const isCurrentPasswordValid = await bcrypt.compare(currentPassword, user.passwordHash)
if (!isCurrentPasswordValid) {
return { success: false, error: 'Current password is incorrect' }
}
// Hash new password (Requirement 7.3)
const newPasswordHash = await bcrypt.hash(newPassword, 10)
// Update password
await prisma.user.update({
where: { id: userId },
data: {
passwordHash: newPasswordHash,
},
})
return { success: true }
} catch (error) {
console.error('Password update error:', error)
return { success: false, error: 'Password update failed. Please try again.' }
}
}
/**
* Get user profile (without sensitive data)
* Requirement: 7.5 - Profile data exposure protection
*/
export async function getUserProfile(userId: string) {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
createdAt: true,
updatedAt: true,
// Explicitly exclude passwordHash (Requirement 7.5)
},
})
return user
} catch (error) {
console.error('Get user profile error:', error)
return null
}
}