378 lines
11 KiB
JavaScript
378 lines
11 KiB
JavaScript
const express = require('express');
|
|
const bcrypt = require('bcryptjs');
|
|
const jwt = require('jsonwebtoken');
|
|
const passport = require('passport');
|
|
const OAuth2Strategy = require('passport-oauth2').Strategy;
|
|
const pool = require('../config/database');
|
|
const { validateRequest } = require('../utils/validation');
|
|
const { registerSchema, loginSchema, changePasswordSchema } = require('../utils/validation');
|
|
const { AppError } = require('../middleware/errorHandler');
|
|
const { authenticateToken } = require('../middleware/auth');
|
|
|
|
const router = express.Router();
|
|
|
|
// Configure Authentik OAuth2 strategy
|
|
if (process.env.AUTHENTIK_CLIENT_ID && process.env.AUTHENTIK_CLIENT_SECRET && process.env.AUTHENTIK_BASE_URL) {
|
|
passport.use('authentik', new OAuth2Strategy({
|
|
authorizationURL: `${process.env.AUTHENTIK_BASE_URL}/application/o/authorize/`,
|
|
tokenURL: `${process.env.AUTHENTIK_BASE_URL}/application/o/token/`,
|
|
clientID: process.env.AUTHENTIK_CLIENT_ID,
|
|
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET,
|
|
callbackURL: process.env.AUTHENTIK_CALLBACK_URL || '/api/auth/authentik/callback'
|
|
}, async (accessToken, refreshToken, profile, done) => {
|
|
try {
|
|
// Get user info from Authentik
|
|
const axios = require('axios');
|
|
const userInfoResponse = await axios.get(`${process.env.AUTHENTIK_BASE_URL}/application/o/userinfo/`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`
|
|
}
|
|
});
|
|
|
|
const userInfo = userInfoResponse.data;
|
|
|
|
// Check if user already exists
|
|
const existingUser = await pool.query(
|
|
'SELECT * FROM users WHERE oauth_provider = $1 AND oauth_id = $2',
|
|
['authentik', userInfo.sub]
|
|
);
|
|
|
|
if (existingUser.rows.length > 0) {
|
|
return done(null, existingUser.rows[0]);
|
|
}
|
|
|
|
// Check if user exists with same email
|
|
const emailUser = await pool.query(
|
|
'SELECT * FROM users WHERE email = $1',
|
|
[userInfo.email]
|
|
);
|
|
|
|
if (emailUser.rows.length > 0) {
|
|
// Link Authentik account to existing user
|
|
await pool.query(
|
|
'UPDATE users SET oauth_provider = $1, oauth_id = $2 WHERE id = $3',
|
|
['authentik', userInfo.sub, emailUser.rows[0].id]
|
|
);
|
|
return done(null, emailUser.rows[0]);
|
|
}
|
|
|
|
// Create new user
|
|
const newUser = await pool.query(
|
|
`INSERT INTO users (email, first_name, last_name, oauth_provider, oauth_id)
|
|
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
|
[
|
|
userInfo.email,
|
|
userInfo.given_name || userInfo.name?.split(' ')[0] || 'User',
|
|
userInfo.family_name || userInfo.name?.split(' ')[1] || '',
|
|
'authentik',
|
|
userInfo.sub
|
|
]
|
|
);
|
|
|
|
return done(null, newUser.rows[0]);
|
|
} catch (error) {
|
|
console.error('Authentik OAuth error:', error);
|
|
return done(error, null);
|
|
}
|
|
}));
|
|
}
|
|
|
|
passport.serializeUser((user, done) => {
|
|
done(null, user.id);
|
|
});
|
|
|
|
passport.deserializeUser(async (id, done) => {
|
|
try {
|
|
const user = await pool.query('SELECT * FROM users WHERE id = $1', [id]);
|
|
done(null, user.rows[0]);
|
|
} catch (error) {
|
|
done(error, null);
|
|
}
|
|
});
|
|
|
|
// Generate JWT token
|
|
const generateToken = (userId) => {
|
|
return jwt.sign(
|
|
{ userId },
|
|
process.env.JWT_SECRET,
|
|
{ expiresIn: '7d' }
|
|
);
|
|
};
|
|
|
|
// @route POST /api/auth/register
|
|
// @desc Register a new user
|
|
// @access Public
|
|
router.post('/register', validateRequest(registerSchema), async (req, res, next) => {
|
|
try {
|
|
const { email, password, firstName, lastName } = req.body;
|
|
|
|
// Check if user already exists
|
|
const existingUser = await pool.query(
|
|
'SELECT id FROM users WHERE email = $1',
|
|
[email.toLowerCase()]
|
|
);
|
|
|
|
if (existingUser.rows.length > 0) {
|
|
throw new AppError('User with this email already exists', 409);
|
|
}
|
|
|
|
// Hash password
|
|
const saltRounds = 12;
|
|
const passwordHash = await bcrypt.hash(password, saltRounds);
|
|
|
|
// Create user
|
|
const newUser = await pool.query(
|
|
`INSERT INTO users (email, password_hash, first_name, last_name)
|
|
VALUES ($1, $2, $3, $4) RETURNING id, email, first_name, last_name, role`,
|
|
[email.toLowerCase(), passwordHash, firstName, lastName]
|
|
);
|
|
|
|
const user = newUser.rows[0];
|
|
const token = generateToken(user.id);
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
message: 'User registered successfully',
|
|
data: {
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
firstName: user.first_name,
|
|
lastName: user.last_name,
|
|
role: user.role
|
|
},
|
|
token
|
|
}
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// @route POST /api/auth/login
|
|
// @desc Login user
|
|
// @access Public
|
|
router.post('/login', validateRequest(loginSchema), async (req, res, next) => {
|
|
try {
|
|
const { email, password } = req.body;
|
|
|
|
// Find user
|
|
const userResult = await pool.query(
|
|
'SELECT id, email, password_hash, first_name, last_name, role FROM users WHERE email = $1',
|
|
[email.toLowerCase()]
|
|
);
|
|
|
|
if (userResult.rows.length === 0) {
|
|
throw new AppError('Invalid email or password', 401);
|
|
}
|
|
|
|
const user = userResult.rows[0];
|
|
|
|
// Check password
|
|
if (!user.password_hash) {
|
|
throw new AppError('Please use Google sign-in for this account', 401);
|
|
}
|
|
|
|
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
|
if (!isValidPassword) {
|
|
throw new AppError('Invalid email or password', 401);
|
|
}
|
|
|
|
const token = generateToken(user.id);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Login successful',
|
|
data: {
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
firstName: user.first_name,
|
|
lastName: user.last_name,
|
|
role: user.role
|
|
},
|
|
token
|
|
}
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// @route GET /api/auth/authentik
|
|
// @desc Start Authentik OAuth2 flow
|
|
// @access Public
|
|
router.get('/authentik', (req, res, next) => {
|
|
if (!process.env.AUTHENTIK_CLIENT_ID) {
|
|
return res.status(503).json({
|
|
success: false,
|
|
message: 'Authentik OAuth not configured'
|
|
});
|
|
}
|
|
|
|
passport.authenticate('authentik', {
|
|
scope: 'openid profile email'
|
|
})(req, res, next);
|
|
});
|
|
|
|
// @route GET /api/auth/authentik/callback
|
|
// @desc Authentik OAuth2 callback
|
|
// @access Public
|
|
router.get('/authentik/callback',
|
|
passport.authenticate('authentik', { session: false }),
|
|
(req, res) => {
|
|
const token = generateToken(req.user.id);
|
|
|
|
// Redirect to frontend with token
|
|
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
|
res.redirect(`${frontendUrl}/auth/callback?token=${token}`);
|
|
}
|
|
);
|
|
|
|
// @route POST /api/auth/change-password
|
|
// @desc Change user password
|
|
// @access Private
|
|
router.post('/change-password', authenticateToken, validateRequest(changePasswordSchema), async (req, res, next) => {
|
|
try {
|
|
const { currentPassword, newPassword } = req.body;
|
|
const userId = req.user.id;
|
|
|
|
// Get current user
|
|
const userResult = await pool.query(
|
|
'SELECT password_hash FROM users WHERE id = $1',
|
|
[userId]
|
|
);
|
|
|
|
const user = userResult.rows[0];
|
|
|
|
if (!user.password_hash) {
|
|
throw new AppError('Cannot change password for OAuth accounts', 400);
|
|
}
|
|
|
|
// Verify current password
|
|
const isValidPassword = await bcrypt.compare(currentPassword, user.password_hash);
|
|
if (!isValidPassword) {
|
|
throw new AppError('Current password is incorrect', 401);
|
|
}
|
|
|
|
// Hash new password
|
|
const saltRounds = 12;
|
|
const newPasswordHash = await bcrypt.hash(newPassword, saltRounds);
|
|
|
|
// Update password
|
|
await pool.query(
|
|
'UPDATE users SET password_hash = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
|
[newPasswordHash, userId]
|
|
);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Password changed successfully'
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// @route POST /api/auth/forgot-password
|
|
// @desc Request password reset
|
|
// @access Public
|
|
router.post('/forgot-password', async (req, res, next) => {
|
|
try {
|
|
const { email } = req.body;
|
|
|
|
// Check if user exists
|
|
const userResult = await pool.query(
|
|
'SELECT id, first_name FROM users WHERE email = $1',
|
|
[email.toLowerCase()]
|
|
);
|
|
|
|
// Always return success for security (don't reveal if email exists)
|
|
res.json({
|
|
success: true,
|
|
message: 'If an account with that email exists, a password reset link has been sent'
|
|
});
|
|
|
|
// TODO: Implement email sending logic
|
|
// In a real application, you would:
|
|
// 1. Generate a secure reset token
|
|
// 2. Store it in database with expiration
|
|
// 3. Send email with reset link
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// @route GET /api/auth/me
|
|
// @desc Get current user info
|
|
// @access Private
|
|
router.get('/me', authenticateToken, async (req, res, next) => {
|
|
try {
|
|
const userResult = await pool.query(
|
|
'SELECT id, email, first_name, last_name, role, created_at FROM users WHERE id = $1',
|
|
[req.user.id]
|
|
);
|
|
|
|
const user = userResult.rows[0];
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
firstName: user.first_name,
|
|
lastName: user.last_name,
|
|
role: user.role,
|
|
createdAt: user.created_at
|
|
}
|
|
}
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// @route GET /api/auth/registration-status
|
|
// @desc Get registration status (public endpoint)
|
|
// @access Public
|
|
router.get('/registration-status', async (req, res, next) => {
|
|
try {
|
|
// Create admin_settings table if it doesn't exist
|
|
await pool.query(`
|
|
CREATE TABLE IF NOT EXISTS admin_settings (
|
|
id SERIAL PRIMARY KEY,
|
|
setting_key VARCHAR(255) UNIQUE NOT NULL,
|
|
setting_value TEXT NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`);
|
|
|
|
// Insert default registration setting if it doesn't exist
|
|
await pool.query(`
|
|
INSERT INTO admin_settings (setting_key, setting_value)
|
|
VALUES ('registrationEnabled', 'true')
|
|
ON CONFLICT (setting_key) DO NOTHING
|
|
`);
|
|
|
|
// Get registration setting
|
|
const settingResult = await pool.query(
|
|
'SELECT setting_value FROM admin_settings WHERE setting_key = $1',
|
|
['registrationEnabled']
|
|
);
|
|
|
|
const enabled = settingResult.rows.length > 0 ? settingResult.rows[0].setting_value === 'true' : true;
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
enabled
|
|
}
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
module.exports = router; |