Files
Gallus_Pub/backend/src/routes/auth.ts
Kenzo daccc43677 Add CMS features with admin interface and OAuth authentication integration
- Introduced Caddy server for serving frontend and API backend.
- Implemented admin dashboard for creating, editing, and managing events.
- Replaced session-based authentication with token-based OAuth using Gitea.
- Added support for drag-and-drop event reordering in the admin interface.
- Standardized Fastify route validation with JSON schemas.
- Enhanced authentication flow with cookie-based state and secure token storage.
- Reworked backend routes to handle publishing, event management, and content updates.
- Updated `Dockerfile.caddy` and `fly.toml` for deployment configuration.
2025-12-08 18:09:29 +01:00

189 lines
5.3 KiB
TypeScript

import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { db } from '../config/database.js';
import { users } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { GiteaService } from '../services/gitea.service.js';
import { env } from '../config/env.js';
// Use explicit JSON schema for Fastify route validation to avoid provider issues
const callbackQueryJsonSchema = {
type: 'object',
required: ['code', 'state'],
properties: {
code: { type: 'string' },
state: { type: 'string' },
},
} as const;
const authRoute: FastifyPluginAsync = async (fastify) => {
const giteaService = new GiteaService();
/**
* GET /auth/gitea
* Initiate OAuth flow
*/
fastify.get('/auth/gitea', async (request, reply) => {
// Generate CSRF state token
const state = giteaService.generateState();
// Store state in a short-lived cookie
reply.setCookie('oauth_state', state, {
path: '/',
httpOnly: true,
sameSite: 'lax',
// Use HTTPS-based detection to avoid setting Secure on localhost HTTP
secure: !!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https'),
maxAge: 10 * 60, // 10 minutes
});
// Generate authorization URL
const authUrl = giteaService.getAuthorizationUrl(state);
// Redirect to Gitea
return reply.redirect(authUrl);
});
/**
* GET /auth/callback
* OAuth callback endpoint
*/
fastify.get('/auth/callback', {
schema: {
querystring: callbackQueryJsonSchema,
},
}, async (request, reply) => {
try {
const { code, state } = request.query as { code: string; state: string };
// Verify CSRF state from cookie
const expectedState = request.cookies?.oauth_state as string | undefined;
if (!expectedState || state !== expectedState) {
return reply.code(400).send({ error: 'Invalid state parameter' });
}
// Clear state cookie
reply.clearCookie('oauth_state', { path: '/' });
// Exchange code for access token
const tokenResponse = await giteaService.exchangeCodeForToken(code);
// Fetch user info from Gitea
const giteaUser = await giteaService.getUserInfo(tokenResponse.access_token);
// Check if user is allowed
if (!giteaService.isUserAllowed(giteaUser.login)) {
return reply.code(403).send({
error: 'Access denied. You are not authorized to access this CMS.'
});
}
// Find or create user in database
let [user] = await db
.select()
.from(users)
.where(eq(users.giteaId, giteaUser.id.toString()))
.limit(1);
if (!user) {
// Create new user
[user] = await db.insert(users).values({
giteaId: giteaUser.id.toString(),
giteaUsername: giteaUser.login,
giteaEmail: giteaUser.email,
displayName: giteaUser.full_name,
avatarUrl: giteaUser.avatar_url,
lastLogin: new Date(),
}).returning();
} else {
// Update existing user
[user] = await db
.update(users)
.set({
giteaUsername: giteaUser.login,
giteaEmail: giteaUser.email,
displayName: giteaUser.full_name,
avatarUrl: giteaUser.avatar_url,
lastLogin: new Date(),
})
.where(eq(users.id, user.id))
.returning();
}
// Generate JWT for session management
const token = fastify.jwt.sign(
{
id: user.id,
giteaId: user.giteaId,
username: user.giteaUsername || '',
role: user.role ?? 'admin',
},
{ expiresIn: '24h' }
);
// Also set token as HttpOnly cookie so subsequent API calls authenticate reliably
reply.setCookie('token', token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: !!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https'),
maxAge: 60 * 60 * 24, // 24h
});
// Redirect to admin dashboard
const frontendUrl = env.FRONTEND_URL;
return reply.redirect(`${frontendUrl}/admin`);
} catch (error) {
fastify.log.error({ err: error }, 'OAuth callback error');
return reply.code(500).send({ error: 'Authentication failed' });
}
});
/**
* GET /auth/me
* Get current user info
*/
fastify.get('/auth/me', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const userId = request.user.id;
const [user] = await db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user) {
return reply.code(404).send({ error: 'User not found' });
}
return {
user: {
id: user.id,
giteaUsername: user.giteaUsername,
giteaEmail: user.giteaEmail,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
role: user.role,
},
};
});
/**
* POST /auth/logout
* Logout (client-side token deletion)
*/
fastify.post('/auth/logout', {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
// For JWT, logout is primarily client-side (delete token)
// You could maintain a token blacklist in Redis for production
reply.clearCookie('token', { path: '/' });
return { message: 'Logged out successfully' };
});
};
export default authRoute;