Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions server/modules/auth/auth.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,21 @@ import { userDb, appConfigDb } from '../database/index.js';

const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';

export const AUTH_TOKEN_GENERATION_KEY = 'auth_token_generation';

// Use env var if set, otherwise auto-generate a unique secret per installation
const JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret();

const getCurrentAuthTokenGeneration = () => appConfigDb.getStrict(AUTH_TOKEN_GENERATION_KEY);

const isTokenGenerationValid = (decoded) => {
const currentGeneration = getCurrentAuthTokenGeneration();
if (!currentGeneration) {
return true;
}
return decoded.authTokenGeneration === currentGeneration;
};

// Optional API key middleware
const validateApiKey = (req, res, next) => {
// Skip API key validation if not configured
Expand Down Expand Up @@ -59,6 +71,14 @@ const authenticateToken = async (req, res, next) => {
try {
const decoded = jwt.verify(token, JWT_SECRET);

if (!isTokenGenerationValid(decoded)) {
res.setHeader('X-Auth-Error', 'invalid-token');
return res.status(401).json({
error: 'Invalid token. Please sign in again.',
code: 'AUTH_TOKEN_INVALID',
});
}

// Verify user still exists and is active
const user = userDb.getUserById(decoded.userId);
if (!user) {
Expand Down Expand Up @@ -104,14 +124,16 @@ const authenticateToken = async (req, res, next) => {

// Generate JWT token
const generateToken = (user) => {
return jwt.sign(
{
userId: user.id,
username: user.username
},
JWT_SECRET,
{ expiresIn: '7d' }
);
const payload = {
userId: user.id,
username: user.username
};
const currentGeneration = getCurrentAuthTokenGeneration();
if (currentGeneration) {
payload.authTokenGeneration = currentGeneration;
}

return jwt.sign(payload, JWT_SECRET, { expiresIn: '7d' });
};

// WebSocket authentication function
Expand All @@ -137,6 +159,10 @@ const authenticateWebSocket = (token) => {

try {
const decoded = jwt.verify(token, JWT_SECRET);
if (!isTokenGenerationValid(decoded)) {
return null;
}

// Verify user actually exists in database (matches REST authenticateToken behavior)
const user = userDb.getUserById(decoded.userId);
if (!user) {
Expand Down
16 changes: 14 additions & 2 deletions server/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { randomUUID } from 'node:crypto';
import { createRequire } from 'node:module';

import { getConnection, userDb } from '@/modules/database/index.js';
import { appConfigDb, getConnection, userDb } from '@/modules/database/index.js';

import { authenticateToken, generateToken } from './auth.middleware.js';
import {
AUTH_TOKEN_GENERATION_KEY,
authenticateToken,
generateToken,
} from './auth.middleware.js';
import { createAuthRouter } from './auth.routes.js';
import { createAuthService } from './auth.service.js';

Expand All @@ -22,13 +27,20 @@ const authService = createAuthService({
hasUsers: () => userDb.hasUsers(),
createUser: (username, passwordHash) => userDb.createUser(username, passwordHash),
getUserByUsername: (username) => userDb.getUserByUsername(username),
getUserAuthById: (userId) => userDb.getUserAuthById(userId),
updatePasswordHash: (userId, passwordHash) => userDb.updatePasswordHash(userId, passwordHash),
updateLastLogin: (userId) => userDb.updateLastLogin(userId),
},
authConfig: {
setTokenGeneration: (value) => appConfigDb.set(AUTH_TOKEN_GENERATION_KEY, value),
},
transaction: {
begin: () => databaseConnection.prepare('BEGIN').run(),
commit: () => databaseConnection.prepare('COMMIT').run(),
rollback: () => databaseConnection.prepare('ROLLBACK').run(),
},
isPlatform: process.env.VITE_IS_PLATFORM === 'true',
createTokenGeneration: randomUUID,
hashPassword: (password) => bcrypt.hash(password, 12),
comparePassword: (password, passwordHash) => bcrypt.compare(password, passwordHash),
generateToken,
Expand Down
13 changes: 13 additions & 0 deletions server/modules/auth/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ export function createAuthRouter(
res.json(service.getCurrentUser((req as AuthenticatedRequest).user));
});

router.post('/change-password', authenticateToken, async (req, res, next) => {
try {
const body = req.body as { currentPassword?: unknown; newPassword?: unknown };
res.json(await service.changePassword(
(req as AuthenticatedRequest).user,
body.currentPassword,
body.newPassword,
));
} catch (error) {
next(error);
}
});

router.post('/refresh', authenticateToken, (req, res) => {
res.json(service.refreshSession((req as AuthenticatedRequest).user));
});
Expand Down
72 changes: 71 additions & 1 deletion server/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,20 @@ type AuthDependencies = {
hasUsers(): boolean;
createUser(username: string, passwordHash: string): AuthUser;
getUserByUsername(username: string): AuthLoginUser | undefined;
getUserAuthById(userId: number): AuthLoginUser | undefined;
updatePasswordHash(userId: number, passwordHash: string): void;
updateLastLogin(userId: number): void;
};
authConfig: {
setTokenGeneration(value: string): void;
};
transaction: {
begin(): void;
commit(): void;
rollback(): void;
};
isPlatform: boolean;
createTokenGeneration(): string;
hashPassword(password: string): Promise<string>;
comparePassword(password: string, passwordHash: string): Promise<boolean>;
generateToken(user: AuthUser): string;
Expand Down Expand Up @@ -65,6 +72,7 @@ export function createAuthService(dependencies: AuthDependencies) {
);
}

const passwordHash = await dependencies.hashPassword(password);
dependencies.transaction.begin();
try {
if (dependencies.users.hasUsers()) {
Expand All @@ -74,7 +82,6 @@ export function createAuthService(dependencies: AuthDependencies) {
});
}

const passwordHash = await dependencies.hashPassword(password);
const user = dependencies.users.createUser(username, passwordHash);
const token = dependencies.generateToken(user);
dependencies.transaction.commit();
Expand Down Expand Up @@ -130,6 +137,69 @@ export function createAuthService(dependencies: AuthDependencies) {
return { user };
},

async changePassword(userInput: unknown, currentPasswordInput: unknown, newPasswordInput: unknown) {
if (dependencies.isPlatform) {
throw new AppError('Password changes are not available in platform mode', {
code: 'AUTH_PASSWORD_CHANGE_UNAVAILABLE',
statusCode: 403,
});
}

const currentPassword = typeof currentPasswordInput === 'string' ? currentPasswordInput : '';
const newPassword = typeof newPasswordInput === 'string' ? newPasswordInput : '';
if (!currentPassword || !newPassword) {
throw new AppError('Current password and new password are required', {
code: 'AUTH_PASSWORDS_REQUIRED',
statusCode: 400,
});
}
if (newPassword.length < 6) {
throw new AppError('New password must be at least 6 characters', {
code: 'AUTH_PASSWORD_TOO_SHORT',
statusCode: 400,
});
}
if (
typeof userInput !== 'object'
|| userInput === null
|| !('id' in userInput)
|| (typeof userInput.id !== 'number' && typeof userInput.id !== 'bigint')
) {
throw new AppError('Authenticated user is required', {
code: 'AUTH_USER_REQUIRED',
statusCode: 401,
});
}

const user = dependencies.users.getUserAuthById(numericUserId(userInput.id));
if (!user) {
throw new AppError('Invalid token. User not found.', {
code: 'AUTH_TOKEN_INVALID',
statusCode: 401,
});
}
if (!(await dependencies.comparePassword(currentPassword, user.password_hash))) {
throw new AppError('Current password is incorrect', {
code: 'AUTH_CURRENT_PASSWORD_INCORRECT',
statusCode: 401,
});
}

const passwordHash = await dependencies.hashPassword(newPassword);
const nextTokenGeneration = dependencies.createTokenGeneration();
dependencies.transaction.begin();
try {
dependencies.users.updatePasswordHash(numericUserId(user.id), passwordHash);
dependencies.authConfig.setTokenGeneration(nextTokenGeneration);
dependencies.transaction.commit();
} catch (error) {
dependencies.transaction.rollback();
throw error;
}

return { success: true, message: 'Password updated. Please sign in again.' };
},

refreshSession(user: unknown) {
if (
typeof user !== 'object'
Expand Down
Loading