193 lines
5.3 KiB
TypeScript
193 lines
5.3 KiB
TypeScript
import { NextRequest } from 'next/server';
|
|
import { cookies } from 'next/headers';
|
|
import prisma from '@/lib/prisma';
|
|
|
|
export const USER_SESSION_COOKIE_NAME = 'afro_session';
|
|
export const DEFAULT_USER_SESSION_DURATION = 7 * 24 * 60 * 60; // 7 days in seconds
|
|
|
|
export interface UserSessionPayload {
|
|
userId: string;
|
|
email: string;
|
|
role: string;
|
|
exp: number;
|
|
iat: number;
|
|
}
|
|
|
|
export const USER_SAFE_SELECT = {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
role: true,
|
|
avatar: true,
|
|
bio: true,
|
|
location: true,
|
|
phone: true,
|
|
countryId: true,
|
|
isSuspended: true,
|
|
suspensionReason: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
emailVerified: true,
|
|
};
|
|
|
|
function getJwtSecret(): string {
|
|
const secret = process.env.JWT_SECRET || process.env.NEXTAUTH_SECRET || process.env.ADMIN_JWT_SECRET;
|
|
if (!secret) {
|
|
if (process.env.NODE_ENV === 'production') {
|
|
throw new Error('CRITICAL: JWT_SECRET or NEXTAUTH_SECRET environment variable must be set in production.');
|
|
}
|
|
return 'dev_only_afrohub_user_jwt_secret_key_change_in_prod';
|
|
}
|
|
return secret;
|
|
}
|
|
|
|
function base64UrlEncode(str: string): string {
|
|
return Buffer.from(str, 'utf-8').toString('base64url');
|
|
}
|
|
|
|
function base64UrlDecode(str: string): string {
|
|
return Buffer.from(str, 'base64url').toString('utf-8');
|
|
}
|
|
|
|
async function getCryptoKey(): Promise<CryptoKey> {
|
|
const enc = new TextEncoder();
|
|
return await crypto.subtle.importKey(
|
|
'raw',
|
|
enc.encode(getJwtSecret()),
|
|
{ name: 'HMAC', hash: 'SHA-256' },
|
|
false,
|
|
['sign', 'verify']
|
|
);
|
|
}
|
|
|
|
export async function signUserToken(
|
|
payload: Omit<UserSessionPayload, 'exp' | 'iat'>,
|
|
expiresInSeconds: number = DEFAULT_USER_SESSION_DURATION
|
|
): Promise<string> {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const fullPayload: UserSessionPayload = {
|
|
...payload,
|
|
iat: now,
|
|
exp: now + expiresInSeconds,
|
|
};
|
|
|
|
const header = { alg: 'HS256', typ: 'JWT' };
|
|
const headerB64 = base64UrlEncode(JSON.stringify(header));
|
|
const payloadB64 = base64UrlEncode(JSON.stringify(fullPayload));
|
|
const dataToSign = `${headerB64}.${payloadB64}`;
|
|
|
|
const key = await getCryptoKey();
|
|
const signatureBuffer = await crypto.subtle.sign(
|
|
'HMAC',
|
|
key,
|
|
new TextEncoder().encode(dataToSign)
|
|
);
|
|
|
|
const signatureB64 = Buffer.from(signatureBuffer).toString('base64url');
|
|
return `${dataToSign}.${signatureB64}`;
|
|
}
|
|
|
|
export async function verifyUserToken(token?: string | null): Promise<UserSessionPayload | null> {
|
|
if (!token || typeof token !== 'string') return null;
|
|
const parts = token.split('.');
|
|
if (parts.length !== 3) return null;
|
|
|
|
const [headerB64, payloadB64, signatureB64] = parts;
|
|
const dataToSign = `${headerB64}.${payloadB64}`;
|
|
|
|
try {
|
|
const key = await getCryptoKey();
|
|
const signatureBytes = Buffer.from(signatureB64, 'base64url');
|
|
|
|
const isValid = await crypto.subtle.verify(
|
|
'HMAC',
|
|
key,
|
|
signatureBytes,
|
|
new TextEncoder().encode(dataToSign)
|
|
);
|
|
|
|
if (!isValid) return null;
|
|
|
|
const payloadJson = base64UrlDecode(payloadB64);
|
|
const payload = JSON.parse(payloadJson) as UserSessionPayload;
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
if (!payload.exp || payload.exp < now || !payload.userId) {
|
|
return null;
|
|
}
|
|
|
|
return payload;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function setUserSessionCookie(
|
|
token: string,
|
|
maxAgeSeconds: number = DEFAULT_USER_SESSION_DURATION
|
|
): Promise<void> {
|
|
const cookieStore = await cookies();
|
|
cookieStore.set(USER_SESSION_COOKIE_NAME, token, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
path: '/',
|
|
maxAge: maxAgeSeconds,
|
|
});
|
|
}
|
|
|
|
export async function clearUserSessionCookie(): Promise<void> {
|
|
const cookieStore = await cookies();
|
|
cookieStore.set(USER_SESSION_COOKIE_NAME, '', {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
path: '/',
|
|
maxAge: 0,
|
|
});
|
|
}
|
|
|
|
export async function getAuthUser(request?: NextRequest) {
|
|
try {
|
|
let token: string | undefined;
|
|
|
|
if (request) {
|
|
token = request.cookies.get(USER_SESSION_COOKIE_NAME)?.value;
|
|
}
|
|
if (!token) {
|
|
const cookieStore = await cookies();
|
|
token = cookieStore.get(USER_SESSION_COOKIE_NAME)?.value;
|
|
}
|
|
|
|
const payload = await verifyUserToken(token);
|
|
if (!payload?.userId) return null;
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: payload.userId },
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
name: true,
|
|
role: true,
|
|
emailVerified: true,
|
|
isSuspended: true,
|
|
deletedAt: true,
|
|
}
|
|
});
|
|
|
|
if (!user || user.deletedAt) {
|
|
return null;
|
|
}
|
|
|
|
return user;
|
|
} catch (error) {
|
|
console.error('Error fetching auth user:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function isAdmin(request?: NextRequest) {
|
|
const user = await getAuthUser(request);
|
|
return user?.role === 'ADMIN' && !user.isSuspended;
|
|
}
|