Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
224
src/lib/auth.ts
Normal file
224
src/lib/auth.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
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: {
|
||||
host: process.env.SMTP_HOST,
|
||||
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
|
||||
CredentialsProvider({
|
||||
name: 'credentials',
|
||||
credentials: {
|
||||
email: { label: 'Email', type: 'email' },
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user