Files
nixpanel-server/server/src/routes/auth/index.ts
T
NixReaper 8ff00c544c feat: v0.4.0 — Apache service stack, shell access, log viewer, changelog
Service stack (backend):
- webserver.ts rewritten: apache2/php-fpm routes replacing nginx; per-account
  Apache log endpoint; PHP-FPM reload per version; apache2ctl configtest/reload
- accounts.ts: added PUT /:id/shell to toggle /bin/bash vs /usr/sbin/nologin,
  runs usermod -s and writes audit log

New UI features (nixserver):
- ShellAccess page: grant/revoke SSH access per account via AccountPicker
- ApacheLogs page: live log viewer (access/error) per account; replaces stub
- NginxLogs: re-exports ApacheLogs (NixPanel uses Apache, not NGINX)
- WebServer page: shows apache2, php8.2-fpm, php8.3-fpm status with reload
  buttons; config test; Apache log viewer

Bug fixes:
- Accounts.tsx: Lucide icon size prop type widened (string|number); removed
  unused Users import; dropped duplicate NGINX log card
- Removed soon badge from ShellAccess and ApacheLogs entries

Project:
- CHANGELOG.md created with full history from 0.2.x through 0.4.0
- All packages bumped from 0.3.2 to 0.4.0
2026-04-11 05:17:49 -05:00

250 lines
8.0 KiB
TypeScript

import type { FastifyInstance } from 'fastify'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import prisma from '../../db/client.js'
import { config } from '../../config.js'
import type { JwtPayload, Role } from '../../types/index.js'
import { requireAuth } from '../../middleware/auth.js'
const LoginSchema = z.object({
username: z.string().min(1).max(64),
password: z.string().min(1),
})
/**
* Generate access + refresh token pair.
*/
function signTokens(
fastify: FastifyInstance,
sub: number,
role: Role,
username: string
) {
const payload: JwtPayload = { sub, role, username }
const accessToken = fastify.jwt.sign(payload, {
expiresIn: config.jwt.expiresIn,
})
const refreshToken = fastify.jwt.sign(
{ ...payload, type: 'refresh' },
{ expiresIn: config.jwt.refreshExpiresIn }
)
return { accessToken, refreshToken }
}
export default async function authRoutes(fastify: FastifyInstance) {
/**
* POST /api/auth/login
* Login for admin, reseller, or NixClient user.
* Body: { username, password }
* Returns: { accessToken, refreshToken, role, user }
*/
fastify.post('/login', async (request, reply) => {
const body = LoginSchema.safeParse(request.body)
if (!body.success) {
return reply.code(400).send({ success: false, error: 'Invalid request body' })
}
const { username, password } = body.data
// Try admin first
const admin = await prisma.adminUser.findUnique({ where: { username } })
if (admin) {
const valid = await bcrypt.compare(password, admin.passwordHash)
if (!valid) {
return reply.code(401).send({ success: false, error: 'Invalid credentials' })
}
const { accessToken, refreshToken } = signTokens(fastify, admin.id, 'admin', admin.username)
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 7)
await prisma.refreshToken.create({
data: { token: refreshToken, role: 'admin', adminId: admin.id, expiresAt },
})
await prisma.adminUser.update({
where: { id: admin.id },
data: { lastLogin: new Date(), lastLoginIp: request.ip },
})
return reply.send({
success: true,
data: {
accessToken,
refreshToken,
role: 'admin',
user: { id: admin.id, username: admin.username, email: admin.email },
},
})
}
// Try reseller
const reseller = await prisma.reseller.findUnique({ where: { username } })
if (reseller) {
if (reseller.status !== 'active') {
return reply.code(403).send({ success: false, error: 'Account suspended' })
}
const valid = await bcrypt.compare(password, reseller.passwordHash)
if (!valid) {
return reply.code(401).send({ success: false, error: 'Invalid credentials' })
}
const { accessToken, refreshToken } = signTokens(fastify, reseller.id, 'reseller', reseller.username)
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 7)
await prisma.refreshToken.create({
data: { token: refreshToken, role: 'reseller', resellerId: reseller.id, expiresAt },
})
return reply.send({
success: true,
data: {
accessToken,
refreshToken,
role: 'reseller',
user: { id: reseller.id, username: reseller.username, email: reseller.email },
},
})
}
// Try hosting account (NixClient user)
const account = await prisma.account.findUnique({ where: { username } })
if (account) {
if (account.status !== 'active') {
return reply.code(403).send({ success: false, error: 'Account suspended' })
}
const valid = await bcrypt.compare(password, account.passwordHash)
if (!valid) {
return reply.code(401).send({ success: false, error: 'Invalid credentials' })
}
const { accessToken, refreshToken } = signTokens(fastify, account.id, 'user', account.username)
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 7)
await prisma.refreshToken.create({
data: { token: refreshToken, role: 'user', accountId: account.id, expiresAt },
})
return reply.send({
success: true,
data: {
accessToken,
refreshToken,
role: 'user',
user: { id: account.id, username: account.username, email: account.email, domain: account.domain },
},
})
}
return reply.code(401).send({ success: false, error: 'Invalid credentials' })
})
/**
* POST /api/auth/refresh
* Exchange a refresh token for a new access token.
*/
fastify.post('/refresh', async (request, reply) => {
const body = z.object({ refreshToken: z.string() }).safeParse(request.body)
if (!body.success) {
return reply.code(400).send({ success: false, error: 'refreshToken required' })
}
let payload: JwtPayload & { type?: string }
try {
payload = fastify.jwt.verify<JwtPayload & { type?: string }>(
body.data.refreshToken
)
} catch {
return reply.code(401).send({ success: false, error: 'Invalid or expired refresh token' })
}
if (payload.type !== 'refresh') {
return reply.code(401).send({ success: false, error: 'Not a refresh token' })
}
const stored = await prisma.refreshToken.findUnique({ where: { token: body.data.refreshToken } })
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
return reply.code(401).send({ success: false, error: 'Refresh token revoked or expired' })
}
// Rotate: revoke old, issue new
await prisma.refreshToken.update({
where: { id: stored.id },
data: { revokedAt: new Date() },
})
const newPayload: JwtPayload = { sub: payload.sub, role: payload.role as Role, username: payload.username }
const accessToken = fastify.jwt.sign(newPayload, { expiresIn: config.jwt.expiresIn })
const newRefresh = fastify.jwt.sign(
{ ...newPayload, type: 'refresh' },
{ expiresIn: config.jwt.refreshExpiresIn }
)
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 7)
const createData = {
token: newRefresh,
role: payload.role,
expiresAt,
adminId: stored.adminId ?? undefined,
resellerId: stored.resellerId ?? undefined,
accountId: stored.accountId ?? undefined,
}
await prisma.refreshToken.create({ data: createData })
return reply.send({ success: true, data: { accessToken, refreshToken: newRefresh } })
})
/**
* POST /api/auth/logout
* Revoke the refresh token.
*/
fastify.post('/logout', { preHandler: requireAuth }, async (request, reply) => {
const body = z.object({ refreshToken: z.string() }).safeParse(request.body)
if (body.success) {
await prisma.refreshToken.updateMany({
where: { token: body.data.refreshToken, revokedAt: null },
data: { revokedAt: new Date() },
})
}
return reply.send({ success: true, data: null })
})
/**
* GET /api/auth/me
* Return the current authenticated user's profile.
*/
fastify.get('/me', { preHandler: requireAuth }, async (request, reply) => {
const user = request.user!
if (user.role === 'admin') {
const admin = await prisma.adminUser.findUnique({
where: { id: user.sub },
select: { id: true, username: true, email: true, lastLogin: true, twoFactorEnabled: true },
})
return reply.send({ success: true, data: { ...admin, role: 'admin' } })
}
if (user.role === 'reseller') {
const reseller = await prisma.reseller.findUnique({
where: { id: user.sub },
select: { id: true, username: true, email: true, company: true, status: true },
})
return reply.send({ success: true, data: { ...reseller, role: 'reseller' } })
}
const account = await prisma.account.findUnique({
where: { id: user.sub },
select: { id: true, username: true, email: true, domain: true, status: true },
})
return reply.send({ success: true, data: { ...account, role: 'user' } })
})
}