Files
MOPC-Portal/src/lib/auth.ts

317 lines
10 KiB
TypeScript
Raw Normal View History

import NextAuth from 'next-auth'
import EmailProvider from 'next-auth/providers/email'
import CredentialsProvider from 'next-auth/providers/credentials'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from './prisma'
import { sendMagicLinkEmail } from './email'
import { verifyPassword } from './password'
import type { UserRole } from '@prisma/client'
import { authConfig } from './auth.config'
// Failed login attempt tracking (in-memory)
const failedAttempts = new Map<string, { count: number; lockedUntil: number }>()
const MAX_LOGIN_ATTEMPTS = 5
const LOCKOUT_DURATION_MS = 15 * 60 * 1000 // 15 minutes
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
adapter: PrismaAdapter(prisma),
providers: [
// Email provider for magic links (used for first login and password reset)
EmailProvider({
// Server config required by NextAuth validation but not used —
// sendVerificationRequest below fully overrides email sending via getTransporter()
server: {
host: process.env.SMTP_HOST || 'localhost',
port: Number(process.env.SMTP_PORT || 587),
auth: {
user: process.env.SMTP_USER || '',
pass: process.env.SMTP_PASS || '',
},
},
from: process.env.EMAIL_FROM || 'MOPC Platform <noreply@monaco-opc.com>',
maxAge: parseInt(process.env.MAGIC_LINK_EXPIRY || '900'), // 15 minutes
sendVerificationRequest: async ({ identifier: email, url }) => {
await sendMagicLinkEmail(email, url)
},
}),
// Credentials provider for email/password login and invite token auth
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
inviteToken: { label: 'Invite Token', type: 'text' },
},
async authorize(credentials) {
// Handle invite token authentication
if (credentials?.inviteToken) {
const token = credentials.inviteToken as string
const user = await prisma.user.findUnique({
where: { inviteToken: token },
select: {
id: true,
email: true,
name: true,
role: true,
status: true,
inviteTokenExpiresAt: true,
},
})
if (!user || user.status !== 'INVITED') {
return null
}
if (user.inviteTokenExpiresAt && user.inviteTokenExpiresAt < new Date()) {
return null
}
// Clear token, activate user, mark as needing password
await prisma.user.update({
where: { id: user.id },
data: {
inviteToken: null,
inviteTokenExpiresAt: null,
status: 'ACTIVE',
mustSetPassword: true,
lastLoginAt: new Date(),
},
})
// Log invitation accepted
await prisma.auditLog.create({
data: {
userId: user.id,
action: 'INVITATION_ACCEPTED',
entityType: 'User',
entityId: user.id,
detailsJson: { email: user.email, role: user.role },
},
}).catch(() => {})
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
mustSetPassword: true,
}
}
if (!credentials?.email || !credentials?.password) {
return null
}
const email = (credentials.email as string).toLowerCase()
const password = credentials.password as string
// Check if account is temporarily locked
const attempts = failedAttempts.get(email)
if (attempts && Date.now() < attempts.lockedUntil) {
throw new Error('Account temporarily locked due to too many failed attempts. Try again later.')
}
// Find user by email
const user = await prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
name: true,
role: true,
status: true,
passwordHash: true,
mustSetPassword: true,
},
})
if (!user || user.status === 'SUSPENDED' || !user.passwordHash) {
// Track failed attempt (don't reveal whether user exists)
const current = failedAttempts.get(email) || { count: 0, lockedUntil: 0 }
current.count++
if (current.count >= MAX_LOGIN_ATTEMPTS) {
current.lockedUntil = Date.now() + LOCKOUT_DURATION_MS
current.count = 0
}
failedAttempts.set(email, current)
// Log failed login
await prisma.auditLog.create({
data: {
userId: null,
action: 'LOGIN_FAILED',
entityType: 'User',
detailsJson: { email, reason: !user ? 'user_not_found' : user.status === 'SUSPENDED' ? 'suspended' : 'no_password' },
},
}).catch(() => {})
return null
}
// Verify password
const isValid = await verifyPassword(password, user.passwordHash)
if (!isValid) {
// Track failed attempt
const current = failedAttempts.get(email) || { count: 0, lockedUntil: 0 }
current.count++
if (current.count >= MAX_LOGIN_ATTEMPTS) {
current.lockedUntil = Date.now() + LOCKOUT_DURATION_MS
current.count = 0
}
failedAttempts.set(email, current)
// Log failed login
await prisma.auditLog.create({
data: {
userId: user.id,
action: 'LOGIN_FAILED',
entityType: 'User',
entityId: user.id,
detailsJson: { email, reason: 'invalid_password' },
},
}).catch(() => {})
return null
}
// Clear failed attempts on successful login
failedAttempts.delete(email)
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
mustSetPassword: user.mustSetPassword,
}
},
}),
],
callbacks: {
...authConfig.callbacks,
async jwt({ token, user, trigger }) {
// Initial sign in
if (user) {
token.id = user.id as string
token.role = user.role as UserRole
token.mustSetPassword = user.mustSetPassword
}
// On session update, refresh from database
if (trigger === 'update') {
const dbUser = await prisma.user.findUnique({
where: { id: token.id as string },
select: { role: true, mustSetPassword: true },
})
if (dbUser) {
token.role = dbUser.role
token.mustSetPassword = dbUser.mustSetPassword
}
}
return token
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string
session.user.role = token.role as UserRole
session.user.mustSetPassword = token.mustSetPassword as boolean | undefined
}
return session
},
async signIn({ user, account }) {
// For email provider (magic link), check user status and get password info
if (account?.provider === 'email') {
const dbUser = await prisma.user.findUnique({
where: { email: user.email! },
select: {
id: true,
status: true,
passwordHash: true,
mustSetPassword: true,
role: true,
},
})
if (dbUser?.status === 'SUSPENDED') {
return false // Block suspended users
}
// Update status to ACTIVE on first login (from NONE or INVITED)
if (dbUser?.status === 'INVITED' || dbUser?.status === 'NONE') {
await prisma.user.update({
where: { email: user.email! },
data: { status: 'ACTIVE' },
})
}
// Add user data for JWT callback
if (dbUser) {
user.id = dbUser.id
user.role = dbUser.role
user.mustSetPassword = dbUser.mustSetPassword || !dbUser.passwordHash
}
}
// Update last login time on actual sign-in
if (user.email) {
await prisma.user.update({
where: { email: user.email },
data: { lastLoginAt: new Date() },
}).catch(() => {
// Ignore errors from updating last login
})
// Log successful login
await prisma.auditLog.create({
data: {
userId: user.id as string,
action: 'LOGIN_SUCCESS',
entityType: 'User',
entityId: user.id as string,
detailsJson: { method: account?.provider || 'unknown', email: user.email },
},
}).catch(() => {})
}
return true
},
async redirect({ url, baseUrl }) {
// Check if user needs to set password and redirect accordingly
// This is called after successful authentication
if (url.startsWith(baseUrl)) {
return url
}
// Allow relative redirects
if (url.startsWith('/')) {
return `${baseUrl}${url}`
}
return baseUrl
},
},
})
// Helper to get session in server components
export async function getServerSession() {
return auth()
}
// Helper to require authentication
export async function requireAuth() {
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
return session
}
// Helper to require specific role(s)
export async function requireRole(...roles: UserRole[]) {
const session = await requireAuth()
if (!roles.includes(session.user.role)) {
throw new Error('Forbidden')
}
return session
}