RealBlocks / server /src /routes /auth.ts
Sebebeb's picture
Added Settings
db82489
Raw
History Blame Contribute Delete
11.6 kB
import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import { z } from 'zod';
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { config } from '../config';
import { getDatabase } from '../database';
import { authenticate, AuthRequest } from '../middleware/auth';
import { authLimiter } from '../middleware/rateLimiter';
import { hashToken } from '../services/encryption';
import { sendVerificationEmail, sendPasswordResetEmail } from '../services/email';
const router = Router();
passport.use(new GoogleStrategy({
clientID: config.google.clientId,
clientSecret: config.google.clientSecret,
callbackURL: config.google.callbackUrl || `${config.serverUrl}/api/auth/google/callback`,
scope: ['profile', 'email'],
}, async (_accessToken, _refreshToken, profile, done) => {
try {
const db = getDatabase();
const email = profile.emails?.[0]?.value || `${profile.id}@google-oauth.local`;
const googleId = profile.id;
const displayName = profile.displayName || email.split('@')[0];
const avatarUrl = profile.photos?.[0]?.value || null;
const existing = db.prepare('SELECT * FROM users WHERE google_id = ?').get(googleId) as any;
let user = existing;
if (!user) {
const existingEmail = db.prepare('SELECT * FROM users WHERE email = ?').get(email) as any;
if (existingEmail) {
db.prepare('UPDATE users SET google_id = ?, avatar_url = COALESCE(?, avatar_url), updated_at = ? WHERE id = ?')
.run(googleId, avatarUrl, Date.now(), existingEmail.id);
user = db.prepare('SELECT * FROM users WHERE id = ?').get(existingEmail.id) as any;
}
}
if (!user) {
const id = uuidv4();
const now = Date.now();
let username = displayName.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase().slice(0, 30);
const existingUsername = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (existingUsername) {
username = `${username}_${googleId.slice(0, 6)}`;
}
db.prepare(`
INSERT INTO users (id, email, username, password_hash, verified, google_id, avatar_url, created_at, updated_at)
VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?)
`).run(id, email, username, 'google-auth', googleId, avatarUrl, now, now);
const inserted = db.prepare('SELECT * FROM users WHERE id = ?').get(id) as any;
user = inserted;
}
done(null, user);
} catch (err) {
done(err as Error);
}
}));
const registerSchema = z.object({
email: z.string().email(),
username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/),
password: z.string().min(8).max(128),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
const forgotSchema = z.object({
email: z.string().email(),
});
const resetSchema = z.object({
token: z.string().min(1),
password: z.string().min(8).max(128),
});
const verifySchema = z.object({
token: z.string().min(1),
});
router.post('/register', authLimiter, async (req: Request, res: Response) => {
try {
const { email, username, password } = registerSchema.parse(req.body);
const db = getDatabase();
const existing = db.prepare('SELECT id FROM users WHERE email = ? OR username = ?').get(email, username);
if (existing) {
res.status(409).json({ error: 'Email or username already taken' });
return;
}
const id = uuidv4();
const passwordHash = await bcrypt.hash(password, 10);
const verificationToken = uuidv4();
const now = Date.now();
db.prepare(`
INSERT INTO users (id, email, username, password_hash, verified, verification_token, created_at, updated_at)
VALUES (?, ?, ?, ?, 0, ?, ?, ?)
`).run(id, email, username, passwordHash, verificationToken, now, now);
// Email is non-blocking - fire and forget
sendVerificationEmail(email, verificationToken).catch(() => {});
const token = jwt.sign({ userId: id, email }, config.jwtSecret, {
expiresIn: config.jwtExpiresIn,
} as any);
const ua = req.headers['user-agent'] || '';
const deviceInfo = ua.includes('Mobile') ? 'Mobile' : ua.includes('Chrome') ? 'Chrome' : ua.includes('Firefox') ? 'Firefox' : 'Browser';
db.prepare('INSERT INTO sessions (id, user_id, token, device_info, ip, user_agent, last_active, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
.run(uuidv4(), id, token, deviceInfo, req.ip || '', ua, now, now + 7 * 24 * 60 * 60 * 1000, now);
res.status(201).json({ token, user: { id, email, username } });
} catch (error: any) {
if (error instanceof z.ZodError) {
res.status(400).json({ error: 'Invalid input', details: error.errors });
return;
}
console.error('Register error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post('/login', authLimiter, async (req: Request, res: Response) => {
try {
const { email, password } = loginSchema.parse(req.body);
const db = getDatabase();
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email) as any;
if (!user) {
res.status(401).json({ error: 'Invalid email or password' });
return;
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
res.status(401).json({ error: 'Invalid email or password' });
return;
}
const now = Date.now();
const token = jwt.sign({ userId: user.id, email: user.email }, config.jwtSecret, {
expiresIn: config.jwtExpiresIn,
} as any);
const ua = req.headers['user-agent'] || '';
const deviceInfo = ua.includes('Mobile') ? 'Mobile' : ua.includes('Chrome') ? 'Chrome' : ua.includes('Firefox') ? 'Firefox' : 'Browser';
db.prepare('INSERT INTO sessions (id, user_id, token, device_info, ip, user_agent, last_active, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
.run(uuidv4(), user.id, token, deviceInfo, req.ip || '', ua, now, now + 7 * 24 * 60 * 60 * 1000, now);
res.json({ token, user: { id: user.id, email: user.email, username: user.username } });
} catch (error: any) {
if (error instanceof z.ZodError) {
res.status(400).json({ error: 'Invalid input', details: error.errors });
return;
}
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post('/logout', authenticate, (req: AuthRequest, res: Response) => {
const db = getDatabase();
const authHeader = req.headers.authorization!;
const token = authHeader.substring(7);
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
res.json({ message: 'Logged out successfully' });
});
router.post('/forgot-password', authLimiter, async (req: Request, res: Response) => {
try {
const { email } = forgotSchema.parse(req.body);
const db = getDatabase();
const user = db.prepare('SELECT id FROM users WHERE email = ?').get(email) as any;
if (!user) {
res.json({ message: 'If the email exists, a reset link has been sent' });
return;
}
const resetToken = uuidv4();
const resetTokenHash = hashToken(resetToken);
const expiresAt = Date.now() + 60 * 60 * 1000;
db.prepare('UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?')
.run(resetTokenHash, expiresAt, user.id);
try {
await sendPasswordResetEmail(email, resetToken);
} catch {
// Email failure logged already
}
res.json({ message: 'If the email exists, a reset link has been sent' });
} catch (error: any) {
if (error instanceof z.ZodError) {
res.status(400).json({ error: 'Invalid input' });
return;
}
console.error('Forgot password error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post('/reset-password', async (req: Request, res: Response) => {
try {
const { token, password } = resetSchema.parse(req.body);
const db = getDatabase();
const tokenHash = hashToken(token);
const user = db.prepare(
'SELECT id FROM users WHERE reset_token = ? AND reset_token_expires > ?'
).get(tokenHash, Date.now()) as any;
if (!user) {
res.status(400).json({ error: 'Invalid or expired reset token' });
return;
}
const passwordHash = await bcrypt.hash(password, 10);
const now = Date.now();
db.prepare(
'UPDATE users SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL, updated_at = ? WHERE id = ?'
).run(passwordHash, now, user.id);
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id);
res.json({ message: 'Password reset successfully' });
} catch (error: any) {
if (error instanceof z.ZodError) {
res.status(400).json({ error: 'Invalid input' });
return;
}
console.error('Reset password error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post('/verify-email', async (req: Request, res: Response) => {
try {
const { token } = verifySchema.parse(req.body);
const db = getDatabase();
const result = db.prepare(
'UPDATE users SET verified = 1, verification_token = NULL WHERE verification_token = ?'
).run(token);
if (result.changes === 0) {
res.status(400).json({ error: 'Invalid verification token' });
return;
}
res.json({ message: 'Email verified successfully' });
} catch (error: any) {
if (error instanceof z.ZodError) {
res.status(400).json({ error: 'Invalid input' });
return;
}
console.error('Verify email error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/google', passport.authenticate('google', { session: false }));
const clientOrigin = process.env.SERVER_URL ? config.serverUrl : config.corsOrigin;
router.get('/google/callback',
passport.authenticate('google', { session: false, failureRedirect: `${clientOrigin}/login?error=google-auth-failed` }),
(req: Request, res: Response) => {
const user = (req as any).user;
const token = jwt.sign({ userId: user.id, email: user.email }, config.jwtSecret, {
expiresIn: config.jwtExpiresIn,
} as any);
const db = getDatabase();
const now = Date.now();
const ua = req.headers['user-agent'] || '';
const deviceInfo = ua.includes('Mobile') ? 'Mobile' : ua.includes('Chrome') ? 'Chrome' : ua.includes('Firefox') ? 'Firefox' : 'Browser';
db.prepare('INSERT INTO sessions (id, user_id, token, device_info, ip, user_agent, last_active, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
.run(uuidv4(), user.id, token, deviceInfo, req.ip || '', ua, now, now + 7 * 24 * 60 * 60 * 1000, now);
res.redirect(`${clientOrigin}/auth/callback?token=${token}`);
}
);
router.get('/me', authenticate, (req: AuthRequest, res: Response) => {
const db = getDatabase();
const user = db.prepare('SELECT id, email, username, verified, created_at, password_hash, avatar_path FROM users WHERE id = ?')
.get(req.userId) as any;
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
res.json({
user: {
id: user.id,
email: user.email,
username: user.username,
verified: user.verified,
created_at: user.created_at,
hasPassword: user.password_hash !== 'google-auth',
avatarPath: user.avatar_path || null,
},
});
});
export default router;