feat(backend): initial setup for cms backend service

This commit is contained in:
Fx64b
2025-11-15 14:56:43 +01:00
parent 193f3ff0bb
commit 688b4de945
32 changed files with 5600 additions and 0 deletions

164
backend/src/routes/auth.ts Normal file
View File

@ -0,0 +1,164 @@
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';
const callbackSchema = z.object({
code: z.string(),
state: z.string(),
});
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 session
request.session.set('oauth_state', state);
// 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: callbackSchema,
},
}, async (request, reply) => {
try {
const { code, state } = request.query as z.infer<typeof callbackSchema>;
// Verify CSRF state
const expectedState = request.session.get('oauth_state');
if (!expectedState || state !== expectedState) {
return reply.code(400).send({ error: 'Invalid state parameter' });
}
// Clear state from session
request.session.delete('oauth_state');
// 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,
},
{ expiresIn: '24h' }
);
// Redirect to frontend with token
const frontendUrl = env.FRONTEND_URL;
return reply.redirect(`${frontendUrl}/auth/callback?token=${token}`);
} catch (error) {
fastify.log.error('OAuth callback error:', 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 {
id: user.id,
username: user.giteaUsername,
email: 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
return { message: 'Logged out successfully' };
});
};
export default authRoute;