Your commit message here
This commit is contained in:
173
src/lib/auth.js
Normal file
173
src/lib/auth.js
Normal file
@@ -0,0 +1,173 @@
|
||||
import NextAuth from "next-auth"
|
||||
import CredentialsProvider from "next-auth/providers/credentials"
|
||||
import db from "./db.js"
|
||||
import bcrypt from "bcryptjs"
|
||||
import { z } from "zod"
|
||||
import { randomBytes } from "crypto"
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email("Invalid email format"),
|
||||
password: z.string().min(6, "Password must be at least 6 characters")
|
||||
})
|
||||
|
||||
export const authOptions = {
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" }
|
||||
},
|
||||
async authorize(credentials, req) {
|
||||
try {
|
||||
// Validate input
|
||||
const validatedFields = loginSchema.parse(credentials)
|
||||
|
||||
// Check if user exists and is active
|
||||
const user = db.prepare(`
|
||||
SELECT id, email, name, password_hash, role, is_active,
|
||||
failed_login_attempts, locked_until
|
||||
FROM users
|
||||
WHERE email = ? AND is_active = 1
|
||||
`).get(validatedFields.email)
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Invalid credentials")
|
||||
}
|
||||
|
||||
// Check if account is locked
|
||||
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||
throw new Error("Account temporarily locked")
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValidPassword = await bcrypt.compare(validatedFields.password, user.password_hash)
|
||||
|
||||
if (!isValidPassword) {
|
||||
// Increment failed attempts
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET failed_login_attempts = failed_login_attempts + 1,
|
||||
locked_until = CASE
|
||||
WHEN failed_login_attempts >= 4
|
||||
THEN datetime('now', '+15 minutes')
|
||||
ELSE locked_until
|
||||
END
|
||||
WHERE id = ?
|
||||
`).run(user.id)
|
||||
|
||||
throw new Error("Invalid credentials")
|
||||
}
|
||||
|
||||
// Reset failed attempts and update last login
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET failed_login_attempts = 0,
|
||||
locked_until = NULL,
|
||||
last_login = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(user.id)
|
||||
|
||||
// Log successful login
|
||||
logAuditEvent(user.id, 'LOGIN_SUCCESS', 'user', user.id, req)
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Login error:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
})
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
updateAge: 24 * 60 * 60, // 24 hours
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user, account }) {
|
||||
if (user) {
|
||||
token.role = user.role
|
||||
token.userId = user.id
|
||||
|
||||
// Create session in database
|
||||
const sessionToken = randomBytes(32).toString('hex')
|
||||
const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO sessions (session_token, user_id, expires)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(sessionToken, user.id, expires.toISOString())
|
||||
|
||||
token.sessionToken = sessionToken
|
||||
}
|
||||
return token
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (token) {
|
||||
session.user.id = token.userId
|
||||
session.user.role = token.role
|
||||
|
||||
// Verify session is still valid in database
|
||||
const dbSession = db.prepare(`
|
||||
SELECT user_id FROM sessions
|
||||
WHERE session_token = ? AND expires > datetime('now')
|
||||
`).get(token.sessionToken)
|
||||
|
||||
if (!dbSession) {
|
||||
// Session expired or invalid
|
||||
return null
|
||||
}
|
||||
}
|
||||
return session
|
||||
},
|
||||
async signIn({ user, account, profile, email, credentials }) {
|
||||
return true
|
||||
}
|
||||
},
|
||||
pages: {
|
||||
signIn: '/auth/signin',
|
||||
signOut: '/auth/signout',
|
||||
error: '/auth/error'
|
||||
},
|
||||
events: {
|
||||
async signOut({ token }) {
|
||||
// Remove session from database
|
||||
if (token?.sessionToken) {
|
||||
db.prepare(`
|
||||
DELETE FROM sessions WHERE session_token = ?
|
||||
`).run(token.sessionToken)
|
||||
|
||||
if (token.userId) {
|
||||
logAuditEvent(token.userId, 'LOGOUT', 'user', token.userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Audit logging helper
|
||||
function logAuditEvent(userId, action, resourceType, resourceId, req = null) {
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO audit_logs (user_id, action, resource_type, resource_id, ip_address, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
userId,
|
||||
action,
|
||||
resourceType,
|
||||
resourceId,
|
||||
req?.ip || req?.socket?.remoteAddress || 'unknown',
|
||||
req?.headers?.['user-agent'] || 'unknown'
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Audit log error:", error)
|
||||
}
|
||||
}
|
||||
|
||||
export default NextAuth(authOptions)
|
||||
@@ -162,4 +162,52 @@ export default function initializeDatabase() {
|
||||
} catch (e) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
// Authorization tables
|
||||
db.exec(`
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
name TEXT NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT CHECK(role IN ('admin', 'project_manager', 'user', 'read_only')) DEFAULT 'user',
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
last_login TEXT,
|
||||
failed_login_attempts INTEGER DEFAULT 0,
|
||||
locked_until TEXT
|
||||
);
|
||||
|
||||
-- NextAuth.js sessions table (simplified for custom implementation)
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
session_token TEXT UNIQUE NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
expires TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Audit log table for security tracking
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT,
|
||||
resource_id TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
details TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- Create indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(session_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp ON audit_logs(user_id, timestamp);
|
||||
`);
|
||||
}
|
||||
|
||||
116
src/lib/middleware/auth.js
Normal file
116
src/lib/middleware/auth.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import { getToken } from "next-auth/jwt"
|
||||
import { NextResponse } from "next/server"
|
||||
import db from "../db.js"
|
||||
|
||||
// Role hierarchy for permission checking
|
||||
const ROLE_HIERARCHY = {
|
||||
'admin': 4,
|
||||
'project_manager': 3,
|
||||
'user': 2,
|
||||
'read_only': 1
|
||||
}
|
||||
|
||||
export function withAuth(handler, options = {}) {
|
||||
return async (req, context) => {
|
||||
try {
|
||||
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET })
|
||||
|
||||
// Check if user is authenticated
|
||||
if (!token?.userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Authentication required" },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user account is active
|
||||
const user = db.prepare("SELECT is_active FROM users WHERE id = ?").get(token.userId)
|
||||
if (!user?.is_active) {
|
||||
return NextResponse.json(
|
||||
{ error: "Account deactivated" },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check role-based permissions
|
||||
if (options.requiredRole && !hasPermission(token.role, options.requiredRole)) {
|
||||
logAuditEvent(token.userId, 'ACCESS_DENIED', options.resource || 'api', req.url)
|
||||
return NextResponse.json(
|
||||
{ error: "Insufficient permissions" },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check resource-specific permissions
|
||||
if (options.checkResourceAccess) {
|
||||
const hasAccess = await options.checkResourceAccess(token, context.params)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: "Access denied to this resource" },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Add user info to request
|
||||
req.user = {
|
||||
id: token.userId,
|
||||
email: token.email,
|
||||
name: token.name,
|
||||
role: token.role
|
||||
}
|
||||
|
||||
// Call the original handler
|
||||
return await handler(req, context)
|
||||
} catch (error) {
|
||||
console.error("Auth middleware error:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function hasPermission(userRole, requiredRole) {
|
||||
return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[requiredRole]
|
||||
}
|
||||
|
||||
// Helper for read-only operations
|
||||
export function withReadAuth(handler) {
|
||||
return withAuth(handler, { requiredRole: 'read_only' })
|
||||
}
|
||||
|
||||
// Helper for user-level operations
|
||||
export function withUserAuth(handler) {
|
||||
return withAuth(handler, { requiredRole: 'user' })
|
||||
}
|
||||
|
||||
// Helper for project manager operations
|
||||
export function withManagerAuth(handler) {
|
||||
return withAuth(handler, { requiredRole: 'project_manager' })
|
||||
}
|
||||
|
||||
// Helper for admin operations
|
||||
export function withAdminAuth(handler) {
|
||||
return withAuth(handler, { requiredRole: 'admin' })
|
||||
}
|
||||
|
||||
// Audit logging helper
|
||||
function logAuditEvent(userId, action, resourceType, resourceId, req = null) {
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO audit_logs (user_id, action, resource_type, resource_id, ip_address, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
userId,
|
||||
action,
|
||||
resourceType,
|
||||
resourceId,
|
||||
req?.ip || req?.socket?.remoteAddress || 'unknown',
|
||||
req?.headers?.['user-agent'] || 'unknown'
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Audit log error:", error)
|
||||
}
|
||||
}
|
||||
125
src/lib/userManagement.js
Normal file
125
src/lib/userManagement.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import db from "./db.js"
|
||||
import bcrypt from "bcryptjs"
|
||||
import { randomBytes } from "crypto"
|
||||
|
||||
// Create a new user
|
||||
export async function createUser({ name, email, password, role = 'user' }) {
|
||||
const existingUser = db.prepare("SELECT id FROM users WHERE email = ?").get(email)
|
||||
if (existingUser) {
|
||||
throw new Error("User with this email already exists")
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12)
|
||||
const userId = randomBytes(16).toString('hex')
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO users (id, name, email, password_hash, role)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(userId, name, email, passwordHash, role)
|
||||
|
||||
return { id: userId, name, email, role }
|
||||
}
|
||||
|
||||
// Get user by ID
|
||||
export function getUserById(id) {
|
||||
return db.prepare(`
|
||||
SELECT id, name, email, role, created_at, last_login, is_active
|
||||
FROM users WHERE id = ?
|
||||
`).get(id)
|
||||
}
|
||||
|
||||
// Get user by email
|
||||
export function getUserByEmail(email) {
|
||||
return db.prepare(`
|
||||
SELECT id, name, email, role, created_at, last_login, is_active
|
||||
FROM users WHERE email = ?
|
||||
`).get(email)
|
||||
}
|
||||
|
||||
// Get all users (for admin)
|
||||
export function getAllUsers() {
|
||||
return db.prepare(`
|
||||
SELECT id, name, email, role, created_at, last_login, is_active,
|
||||
failed_login_attempts, locked_until
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
`).all()
|
||||
}
|
||||
|
||||
// Update user role
|
||||
export function updateUserRole(userId, role) {
|
||||
const validRoles = ['admin', 'project_manager', 'user', 'read_only']
|
||||
if (!validRoles.includes(role)) {
|
||||
throw new Error("Invalid role")
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(role, userId)
|
||||
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
// Activate/deactivate user
|
||||
export function setUserActive(userId, isActive) {
|
||||
const result = db.prepare(`
|
||||
UPDATE users SET is_active = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(isActive ? 1 : 0, userId)
|
||||
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
// Change user password
|
||||
export async function changeUserPassword(userId, newPassword) {
|
||||
const passwordHash = await bcrypt.hash(newPassword, 12)
|
||||
|
||||
const result = db.prepare(`
|
||||
UPDATE users
|
||||
SET password_hash = ?, updated_at = CURRENT_TIMESTAMP,
|
||||
failed_login_attempts = 0, locked_until = NULL
|
||||
WHERE id = ?
|
||||
`).run(passwordHash, userId)
|
||||
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
// Clean up expired sessions
|
||||
export function cleanupExpiredSessions() {
|
||||
const result = db.prepare(`
|
||||
DELETE FROM sessions WHERE expires < datetime('now')
|
||||
`).run()
|
||||
|
||||
return result.changes
|
||||
}
|
||||
|
||||
// Get user sessions
|
||||
export function getUserSessions(userId) {
|
||||
return db.prepare(`
|
||||
SELECT id, session_token, expires, created_at
|
||||
FROM sessions
|
||||
WHERE user_id = ? AND expires > datetime('now')
|
||||
ORDER BY created_at DESC
|
||||
`).all(userId)
|
||||
}
|
||||
|
||||
// Revoke user session
|
||||
export function revokeSession(sessionToken) {
|
||||
const result = db.prepare(`
|
||||
DELETE FROM sessions WHERE session_token = ?
|
||||
`).run(sessionToken)
|
||||
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
// Get audit logs for user
|
||||
export function getUserAuditLogs(userId, limit = 50) {
|
||||
return db.prepare(`
|
||||
SELECT action, resource_type, resource_id, ip_address, timestamp, details
|
||||
FROM audit_logs
|
||||
WHERE user_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
`).all(userId, limit)
|
||||
}
|
||||
Reference in New Issue
Block a user