2026-01-30 13:41:32 +01:00
|
|
|
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({
|
|
|
|
|
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)
|
|
|
|
|
},
|
|
|
|
|
}),
|
2026-01-31 14:13:16 +01:00
|
|
|
// Credentials provider for email/password login and invite token auth
|
2026-01-30 13:41:32 +01:00
|
|
|
CredentialsProvider({
|
|
|
|
|
name: 'credentials',
|
|
|
|
|
credentials: {
|
|
|
|
|
email: { label: 'Email', type: 'email' },
|
|
|
|
|
password: { label: 'Password', type: 'password' },
|
2026-01-31 14:13:16 +01:00
|
|
|
inviteToken: { label: 'Invite Token', type: 'text' },
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
async authorize(credentials) {
|
2026-01-31 14:13:16 +01:00
|
|
|
// 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(),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: user.id,
|
|
|
|
|
email: user.email,
|
|
|
|
|
name: user.name,
|
|
|
|
|
role: user.role,
|
|
|
|
|
mustSetPassword: true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
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)
|
|
|
|
|
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)
|
|
|
|
|
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 from INVITED to ACTIVE on first login
|
|
|
|
|
if (dbUser?.status === 'INVITED') {
|
|
|
|
|
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
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|