255 lines
6.6 KiB
TypeScript
255 lines
6.6 KiB
TypeScript
import * as jose from 'jose';
|
|
import * as argon2 from 'argon2';
|
|
import bcrypt from 'bcryptjs';
|
|
import crypto from 'crypto';
|
|
import { Context } from 'hono';
|
|
import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js';
|
|
import { eq, and, gt } from 'drizzle-orm';
|
|
import { generateId, getNow, toDbDate } from './utils.js';
|
|
|
|
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'your-super-secret-key-change-in-production');
|
|
const JWT_ISSUER = 'spanglish';
|
|
const JWT_AUDIENCE = 'spanglish-app';
|
|
|
|
export interface JWTPayload {
|
|
sub: string;
|
|
email: string;
|
|
role: string;
|
|
iat: number;
|
|
exp: number;
|
|
}
|
|
|
|
// Password hashing with Argon2 (spec requirement)
|
|
export async function hashPassword(password: string): Promise<string> {
|
|
return argon2.hash(password, {
|
|
type: argon2.argon2id,
|
|
memoryCost: 65536, // 64 MB
|
|
timeCost: 3,
|
|
parallelism: 4,
|
|
});
|
|
}
|
|
|
|
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
|
// Support both bcrypt (legacy) and argon2 hashes for migration
|
|
if (hash.startsWith('$argon2')) {
|
|
return argon2.verify(hash, password);
|
|
}
|
|
// Legacy bcrypt support
|
|
return bcrypt.compare(password, hash);
|
|
}
|
|
|
|
// Generate secure random token for magic links
|
|
export function generateSecureToken(): string {
|
|
return crypto.randomBytes(32).toString('hex');
|
|
}
|
|
|
|
// Create magic link token
|
|
export async function createMagicLinkToken(
|
|
userId: string,
|
|
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification',
|
|
expiresInMinutes: number = 10
|
|
): Promise<string> {
|
|
const token = generateSecureToken();
|
|
const now = getNow();
|
|
const expiresAt = toDbDate(new Date(Date.now() + expiresInMinutes * 60 * 1000));
|
|
|
|
await (db as any).insert(magicLinkTokens).values({
|
|
id: generateId(),
|
|
userId,
|
|
token,
|
|
type,
|
|
expiresAt,
|
|
createdAt: now,
|
|
});
|
|
|
|
return token;
|
|
}
|
|
|
|
// Verify and consume magic link token
|
|
export async function verifyMagicLinkToken(
|
|
token: string,
|
|
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification'
|
|
): Promise<{ valid: boolean; userId?: string; error?: string }> {
|
|
const now = getNow();
|
|
|
|
const tokenRecord = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(magicLinkTokens)
|
|
.where(
|
|
and(
|
|
eq((magicLinkTokens as any).token, token),
|
|
eq((magicLinkTokens as any).type, type)
|
|
)
|
|
)
|
|
);
|
|
|
|
if (!tokenRecord) {
|
|
return { valid: false, error: 'Invalid token' };
|
|
}
|
|
|
|
if (tokenRecord.usedAt) {
|
|
return { valid: false, error: 'Token already used' };
|
|
}
|
|
|
|
if (new Date(tokenRecord.expiresAt) < new Date()) {
|
|
return { valid: false, error: 'Token expired' };
|
|
}
|
|
|
|
// Mark token as used
|
|
await (db as any)
|
|
.update(magicLinkTokens)
|
|
.set({ usedAt: now })
|
|
.where(eq((magicLinkTokens as any).id, tokenRecord.id));
|
|
|
|
return { valid: true, userId: tokenRecord.userId };
|
|
}
|
|
|
|
// Create user session
|
|
export async function createUserSession(
|
|
userId: string,
|
|
userAgent?: string,
|
|
ipAddress?: string
|
|
): Promise<string> {
|
|
const sessionToken = generateSecureToken();
|
|
const now = getNow();
|
|
const expiresAt = toDbDate(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)); // 30 days
|
|
|
|
await (db as any).insert(userSessions).values({
|
|
id: generateId(),
|
|
userId,
|
|
token: sessionToken,
|
|
userAgent: userAgent || null,
|
|
ipAddress: ipAddress || null,
|
|
lastActiveAt: now,
|
|
expiresAt,
|
|
createdAt: now,
|
|
});
|
|
|
|
return sessionToken;
|
|
}
|
|
|
|
// Get user's active sessions
|
|
export async function getUserSessions(userId: string) {
|
|
const now = getNow();
|
|
|
|
return dbAll(
|
|
(db as any)
|
|
.select()
|
|
.from(userSessions)
|
|
.where(
|
|
and(
|
|
eq((userSessions as any).userId, userId),
|
|
gt((userSessions as any).expiresAt, now)
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
// Invalidate a specific session
|
|
export async function invalidateSession(sessionId: string, userId: string): Promise<boolean> {
|
|
const result = await (db as any)
|
|
.delete(userSessions)
|
|
.where(
|
|
and(
|
|
eq((userSessions as any).id, sessionId),
|
|
eq((userSessions as any).userId, userId)
|
|
)
|
|
);
|
|
|
|
return true;
|
|
}
|
|
|
|
// Invalidate all user sessions (logout everywhere)
|
|
export async function invalidateAllUserSessions(userId: string): Promise<void> {
|
|
await (db as any)
|
|
.delete(userSessions)
|
|
.where(eq((userSessions as any).userId, userId));
|
|
}
|
|
|
|
// Password validation (min 10 characters per spec)
|
|
export function validatePassword(password: string): { valid: boolean; error?: string } {
|
|
if (password.length < 10) {
|
|
return { valid: false, error: 'Password must be at least 10 characters long' };
|
|
}
|
|
return { valid: true };
|
|
}
|
|
|
|
export async function createToken(userId: string, email: string, role: string): Promise<string> {
|
|
const token = await new jose.SignJWT({ sub: userId, email, role })
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.setIssuedAt()
|
|
.setIssuer(JWT_ISSUER)
|
|
.setAudience(JWT_AUDIENCE)
|
|
.setExpirationTime('7d')
|
|
.sign(JWT_SECRET);
|
|
|
|
return token;
|
|
}
|
|
|
|
export async function createRefreshToken(userId: string): Promise<string> {
|
|
const token = await new jose.SignJWT({ sub: userId, type: 'refresh' })
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.setIssuedAt()
|
|
.setIssuer(JWT_ISSUER)
|
|
.setExpirationTime('30d')
|
|
.sign(JWT_SECRET);
|
|
|
|
return token;
|
|
}
|
|
|
|
export async function verifyToken(token: string): Promise<JWTPayload | null> {
|
|
try {
|
|
const { payload } = await jose.jwtVerify(token, JWT_SECRET, {
|
|
issuer: JWT_ISSUER,
|
|
audience: JWT_AUDIENCE,
|
|
});
|
|
return payload as unknown as JWTPayload;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getAuthUser(c: Context): Promise<any | null> {
|
|
const authHeader = c.req.header('Authorization');
|
|
if (!authHeader?.startsWith('Bearer ')) {
|
|
return null;
|
|
}
|
|
|
|
const token = authHeader.slice(7);
|
|
const payload = await verifyToken(token);
|
|
|
|
if (!payload) {
|
|
return null;
|
|
}
|
|
|
|
const user = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).id, payload.sub))
|
|
);
|
|
return user || null;
|
|
}
|
|
|
|
export function requireAuth(roles?: string[]) {
|
|
return async (c: Context, next: () => Promise<void>) => {
|
|
const user = await getAuthUser(c);
|
|
|
|
if (!user) {
|
|
return c.json({ error: 'Unauthorized' }, 401);
|
|
}
|
|
|
|
if (roles && !roles.includes(user.role)) {
|
|
return c.json({ error: 'Forbidden' }, 403);
|
|
}
|
|
|
|
c.set('user', user);
|
|
await next();
|
|
};
|
|
}
|
|
|
|
export async function isFirstUser(): Promise<boolean> {
|
|
const result = await dbAll(
|
|
(db as any).select().from(users).limit(1)
|
|
);
|
|
return !result || result.length === 0;
|
|
}
|