feat(auth): add initial setup flow and harden auth/session handling

This commit is contained in:
neon
2026-03-10 04:17:33 -06:00
parent 17e8ba0f49
commit 86a1b512cc
18 changed files with 599 additions and 109 deletions
+4 -6
View File
@@ -51,12 +51,9 @@ Both must be running. The client proxies all `/api` and `/socket.io` requests to
### First steps after setup
1. **Log in** - a default admin account is created automatically:
- Username: `admin`
- Password: `admin`
2. **Change password** - you'll be prompted to set a new password on first login. Use `Admin123!` for local dev.
3. **Create a library** - open Settings (gear icon) > Libraries > Create Library. Add a folder path pointing to some books on your machine, e.g. any directory containing `.epub`, `.pdf`, or `.cbz` files.
4. **Scan** - the library scans automatically after creation. You should see books appear on the home page once the scan completes.
1. **Complete initial setup** - you'll be redirected to `/setup` to create the first administrator account (username, password, name, and email).
2. **Create a library** - open Settings (gear icon) > Libraries > Create Library. Add a folder path pointing to some books on your machine, e.g. any directory containing `.epub`, `.pdf`, or `.cbz` files.
3. **Scan** - the library scans automatically after creation. You should see books appear on the home page once the scan completes.
> **Where does data go?** `BOOKS_PATH` in `server/.env` defaults to `../local/data` (resolves to `<project-root>/local/data`). This is the app's data directory where extracted cover images and thumbnails are stored (`local/data/covers/`). It is **not** where your book files live - those paths are configured per-library in the UI. The `local/` folder is gitignored.
@@ -225,6 +222,7 @@ Server environment is configured in `server/.env` (created from `.env.example` d
| `JWT_SECRET` | `change-me-in-production` | JWT signing secret |
| `JWT_EXPIRES_IN` | `15m` | Access token lifetime |
| `JWT_REFRESH_EXPIRES_IN` | `7d` | Refresh token lifetime |
| `SETUP_BOOTSTRAP_TOKEN` | (empty) | Required in production for initial `/api/v1/auth/setup` |
| `BOOKS_PATH` | `../local/data` | App data directory for cover images (not where books live) |
| `SMTP_*` | (empty) | Optional SMTP config for password-reset emails |
| `APP_URL` | `http://localhost:5173` | Client URL (used in emails) |
+3
View File
@@ -5,6 +5,7 @@ import { Moon, Sun } from 'lucide-vue-next'
import { ACCENT_VIVID, ACCENT_PASTEL, ACCENT_OPTIONS, useThemeStore } from '@/stores/theme'
import { useAuth } from './composables/useAuth'
import { useOidc } from './composables/useOidc'
import { useSetupStatus } from './composables/useSetupStatus'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const themeStore = useThemeStore()
@@ -13,6 +14,7 @@ const currentAccent = computed(() => ACCENT_OPTIONS.find((o) => o.id === themeSt
const { login } = useAuth()
const { getPublicConfig, initiateLogin } = useOidc()
const { setupStatusError } = useSetupStatus()
const username = ref('')
const password = ref('')
@@ -157,6 +159,7 @@ async function handleOidcLogin() {
</div>
<div v-if="error" class="text-sm text-destructive">{{ error }}</div>
<div v-if="setupStatusError" class="text-sm text-destructive">{{ setupStatusError }}</div>
<button
type="submit"
+138
View File
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useAuth } from './composables/useAuth'
const { setup } = useAuth()
const username = ref('')
const name = ref('')
const email = ref('')
const password = ref('')
const confirmPassword = ref('')
const setupToken = ref('')
const loading = ref(false)
const error = ref<string | null>(null)
async function handleSubmit() {
error.value = null
if (password.value !== confirmPassword.value) {
error.value = 'Passwords do not match'
return
}
loading.value = true
try {
await setup({
username: username.value,
name: name.value,
email: email.value,
password: password.value,
setupToken: setupToken.value || undefined,
})
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to complete setup'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-background px-4">
<div class="w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-sm">
<div class="mb-6">
<h1 class="text-xl font-semibold text-foreground">Initial setup</h1>
<p class="text-sm text-muted-foreground mt-1">Create the first administrator account.</p>
</div>
<form class="space-y-4" @submit.prevent="handleSubmit">
<div class="space-y-1.5">
<label for="setup-username" class="text-sm font-medium text-foreground">Username</label>
<input
id="setup-username"
v-model="username"
type="text"
autocomplete="username"
required
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div class="space-y-1.5">
<label for="setup-name" class="text-sm font-medium text-foreground">Full name</label>
<input
id="setup-name"
v-model="name"
type="text"
autocomplete="name"
required
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div class="space-y-1.5">
<label for="setup-email" class="text-sm font-medium text-foreground">Email</label>
<input
id="setup-email"
v-model="email"
type="email"
autocomplete="email"
required
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div class="space-y-1.5">
<label for="setup-password" class="text-sm font-medium text-foreground">Password</label>
<input
id="setup-password"
v-model="password"
type="password"
autocomplete="new-password"
required
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
<p class="text-xs text-muted-foreground">Min. 8 characters with uppercase, lowercase, and a digit</p>
</div>
<div class="space-y-1.5">
<label for="setup-confirm-password" class="text-sm font-medium text-foreground">Confirm password</label>
<input
id="setup-confirm-password"
v-model="confirmPassword"
type="password"
autocomplete="new-password"
required
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div class="space-y-1.5">
<label for="setup-token" class="text-sm font-medium text-foreground">
Setup token
<span class="text-muted-foreground">(required in production)</span>
</label>
<input
id="setup-token"
v-model="setupToken"
type="text"
autocomplete="off"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div v-if="error" class="text-sm text-destructive">{{ error }}</div>
<button
type="submit"
:disabled="loading"
class="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{{ loading ? 'Creating account…' : 'Create administrator account' }}
</button>
</form>
</div>
</div>
</template>
@@ -2,6 +2,7 @@ import { ref } from 'vue'
import type { AuthUser, AuthResponse } from '@projectx/types'
import { api, setAccessToken, setOnAuthFailure } from '@/lib/api'
import router from '@/router'
import { useSetupStatus } from './useSetupStatus'
const user = ref<AuthUser | null>(null)
const isLoading = ref(false)
@@ -13,7 +14,8 @@ function clearAuth() {
setOnAuthFailure(() => {
clearAuth()
router.push('/login')
const { needsSetup } = useSetupStatus()
router.push(needsSetup.value ? '/setup' : '/login')
})
async function me(): Promise<void> {
@@ -56,13 +58,44 @@ export function useAuth() {
user.value = data.user
if (data.user.isDefaultPassword) {
router.push('/change-password')
router.push('/')
} else {
const redirect = router.currentRoute.value.query.redirect as string | undefined
router.push(redirect ?? '/')
}
}
async function setup(payload: { username: string; name: string; email: string; password: string; setupToken?: string }): Promise<void> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (payload.setupToken) {
headers['x-setup-token'] = payload.setupToken
}
const res = await fetch('/api/v1/auth/setup', {
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify({
username: payload.username,
name: payload.name,
email: payload.email,
password: payload.password,
}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.message ?? 'Failed to complete setup')
}
const data: AuthResponse = await res.json()
setAccessToken(data.accessToken)
user.value = data.user
useSetupStatus().markSetupComplete()
router.push('/')
}
async function logout(): Promise<void> {
try {
const res = await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' })
@@ -80,5 +113,5 @@ export function useAuth() {
router.push('/login')
}
return { user, isLoading, init, login, logout, me }
return { user, isLoading, init, login, logout, me, setup }
}
@@ -0,0 +1,38 @@
import { ref } from 'vue'
const needsSetup = ref<boolean | null>(null)
const setupStatusError = ref<string | null>(null)
let inFlight: Promise<boolean> | null = null
export function useSetupStatus() {
async function fetchSetupStatus(force = false): Promise<boolean> {
if (inFlight) return inFlight
if (!force && needsSetup.value !== null) return needsSetup.value
inFlight = (async () => {
try {
const res = await fetch('/api/v1/auth/setup-status', { credentials: 'include' })
if (!res.ok) throw new Error('Failed to load setup status')
const data = (await res.json()) as { needsSetup?: boolean }
setupStatusError.value = null
needsSetup.value = data.needsSetup === true
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load setup status'
setupStatusError.value = message
throw err
} finally {
inFlight = null
}
return needsSetup.value ?? false
})()
return inFlight
}
function markSetupComplete(): void {
needsSetup.value = false
setupStatusError.value = null
}
return { needsSetup, setupStatusError, fetchSetupStatus, markSetupComplete }
}
@@ -24,7 +24,8 @@ const emit = defineEmits<{
const initial = computed(() => props.author.name.trim().charAt(0).toUpperCase() || '?')
const fallbackStyle = computed(() => bookCoverStyle(props.author.name || String(props.author.id)))
const hasImage = computed(() => Boolean(props.author.imageUrl) && !imageFailed.value)
const imageSrc = computed(() => props.author.imageUrl ?? undefined)
const hasImage = computed(() => Boolean(imageSrc.value) && !imageFailed.value)
const imageFailed = ref(false)
watch(
@@ -67,7 +68,7 @@ function handleDelete() {
>
<img
v-if="hasImage"
:src="author.imageUrl"
:src="imageSrc"
:alt="`${author.name} portrait`"
class="absolute inset-0 h-full w-full object-cover"
loading="lazy"
@@ -192,13 +192,15 @@ async function refreshSelectedAuthorsMetadata() {
markRefreshing(ids)
try {
const result = await bulkRefreshAuthorsMetadata(ids, (event) => {
if (event.imageUpdated && event.imageUrl) {
if (event.imageUpdated) {
const index = items.value.findIndex((item) => item.id === event.authorId)
if (index !== -1) {
const next = [...items.value]
const current = next[index]
if (!current) return
next[index] = {
...next[index],
imageUrl: event.imageUrl,
...current,
imageUrl: event.imageUrl ?? null,
}
items.value = next
}
@@ -229,7 +231,9 @@ async function refreshSingleAuthorMetadata(authorId: number) {
const index = items.value.findIndex((item) => item.id === authorId)
if (index !== -1) {
const next = [...items.value]
next[index] = { ...next[index], ...updated }
const current = next[index]
if (!current) return
next[index] = { ...current, ...updated }
items.value = next
}
void maybeLoadSecondaryPanels()
+13 -4
View File
@@ -6,16 +6,25 @@ import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { useAuth } from './features/auth/composables/useAuth'
import { useSetupStatus } from './features/auth/composables/useSetupStatus'
const app = createApp(App)
app.use(createPinia())
// Init auth before installing router — app.use(router) triggers the initial
// navigation which runs the beforeEach guard. If auth isn't resolved yet,
// user.value is null and the guard redirects every reload to /login.
// Resolve setup status/auth before installing router.
// app.use(router) triggers initial navigation and guard execution.
const { fetchSetupStatus, needsSetup } = useSetupStatus()
try {
await fetchSetupStatus()
} catch {
// If setup-status check fails, continue with normal auth bootstrap.
}
const { init } = useAuth()
await init()
if (needsSetup.value !== true) {
await init()
}
app.use(router)
app.mount('#app')
+18 -1
View File
@@ -1,9 +1,26 @@
import type { Router } from 'vue-router'
import { useAuth } from '@/features/auth/composables/useAuth'
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
import { useSetupStatus } from '@/features/auth/composables/useSetupStatus'
export function registerAuthGuard(router: Router): void {
router.beforeEach((to) => {
router.beforeEach(async (to) => {
const { fetchSetupStatus } = useSetupStatus()
let requiresSetup = false
try {
requiresSetup = await fetchSetupStatus()
} catch {
// If setup-status cannot be loaded, fall back to normal auth checks.
}
if (requiresSetup && to.path !== '/setup') {
return { path: '/setup' }
}
if (!requiresSetup && to.path === '/setup') {
const { user } = useAuth()
return user.value ? { path: '/' } : { path: '/login' }
}
if (to.meta.public) return true
const { user } = useAuth()
+6
View File
@@ -71,6 +71,12 @@ const router = createRouter({
component: () => import('@/features/auth/LoginPage.vue'),
meta: { public: true },
},
{
path: '/setup',
name: 'setup',
component: () => import('@/features/auth/SetupPage.vue'),
meta: { public: true },
},
{
path: '/forgot-password',
name: 'forgot-password',
+2
View File
@@ -9,6 +9,8 @@ NODE_ENV=development
JWT_SECRET=change-me-in-production
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# Required in production for initial /auth/setup (x-setup-token header)
SETUP_BOOTSTRAP_TOKEN=
# Storage
# App data directory for cover images and generated files.
+1
View File
@@ -13,6 +13,7 @@ export const authConfig = registerAs('auth', () => ({
jwtSecret: process.env.JWT_SECRET ?? 'change-me-in-production',
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '15m',
jwtRefreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN ?? '7d',
setupBootstrapToken: process.env.SETUP_BOOTSTRAP_TOKEN ?? '',
}));
export const storageConfig = registerAs('storage', () => ({
+4
View File
@@ -13,6 +13,7 @@ const envSchema = z.object({
),
JWT_EXPIRES_IN: z.string().default('15m'),
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
SETUP_BOOTSTRAP_TOKEN: z.string().optional(),
BOOKS_PATH: z.string().default('/data/books'),
STAGING_PATH: z.string().optional(),
CLIENT_URL: z.string().url().optional(),
@@ -33,5 +34,8 @@ export function validateEnv(config: Record<string, unknown>) {
const errors = result.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n');
throw new Error(`Environment validation failed:\n${errors}`);
}
if (result.data.NODE_ENV === 'production' && !result.data.SETUP_BOOTSTRAP_TOKEN?.trim()) {
throw new Error('Environment validation failed:\n SETUP_BOOTSTRAP_TOKEN: SETUP_BOOTSTRAP_TOKEN is required in production');
}
return result.data;
}
+15 -1
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Req, Res } from '@nestjs/common';
import { Body, Controller, Delete, Get, Headers, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Req, Res } from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@@ -11,6 +11,7 @@ import { LoginDto } from './dto/login.dto';
import { OidcCallbackDto } from './dto/oidc-callback.dto';
import { RegisterDto } from './dto/register.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { SetupDto } from './dto/setup.dto';
import { OidcService } from './oidc/oidc.service';
@Controller('auth')
@@ -26,6 +27,19 @@ export class AuthController {
return this.authService.register(dto);
}
@Public()
@Get('setup-status')
setupStatus() {
return this.authService.setupStatus();
}
@Public()
@Post('setup')
@HttpCode(HttpStatus.CREATED)
setup(@Body() dto: SetupDto, @Headers('x-setup-token') setupToken: string | undefined, @Res({ passthrough: true }) reply: FastifyReply) {
return this.authService.setup(dto, setupToken, reply);
}
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
+165 -23
View File
@@ -2,7 +2,7 @@ import { BadRequestException, ConflictException, ForbiddenException, ServiceUnav
import { AuthService } from './auth.service';
function makeDb(overrides?: Partial<ReturnType<typeof makeDb>>) {
function makeDb(overrides?: Record<string, unknown>) {
const db: Record<string, unknown> = {
query: {
appSettings: { findFirst: jest.fn() },
@@ -11,20 +11,18 @@ function makeDb(overrides?: Partial<ReturnType<typeof makeDb>>) {
roles: { findFirst: jest.fn() },
passwordResetTokens: { findFirst: jest.fn() },
},
$count: jest.fn().mockResolvedValue(0),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
onConflictDoNothing: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue([]),
select: jest.fn().mockReturnThis(),
from: jest.fn().mockResolvedValue([{ total: 0 }]),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockReturnThis(),
transaction: jest.fn().mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) => {
const tx = {
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockResolvedValue(undefined),
};
return fn(tx);
}),
transaction: jest.fn().mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) => fn(db)),
};
return { ...db, ...overrides } as never;
}
@@ -65,7 +63,7 @@ function makeFullUser(overrides?: Partial<Record<string, unknown>>) {
} as never;
}
function makeService(dbOverrides?: Partial<ReturnType<typeof makeDb>>) {
function makeService(dbOverrides?: Record<string, unknown>) {
const db = makeDb(dbOverrides);
const userService = {
findByUsername: jest.fn(),
@@ -83,6 +81,7 @@ function makeService(dbOverrides?: Partial<ReturnType<typeof makeDb>>) {
if (key === 'auth.jwtRefreshExpiresIn') return '7d';
if (key === 'auth.jwtExpiresIn') return '15m';
if (key === 'app.nodeEnv') return 'test';
if (key === 'auth.setupBootstrapToken') return 'bootstrap-token';
return undefined;
}),
};
@@ -116,6 +115,80 @@ function makeService(dbOverrides?: Partial<ReturnType<typeof makeDb>>) {
}
describe('AuthService', () => {
describe('setupStatus', () => {
it('returns needsSetup=true when there are no users', async () => {
const { service, db } = makeService();
((db as unknown as Record<string, unknown>).$count as jest.Mock).mockResolvedValue(0);
await expect(service.setupStatus()).resolves.toEqual({ needsSetup: true });
});
it('returns needsSetup=false when at least one user exists', async () => {
const { service, db } = makeService();
((db as unknown as Record<string, unknown>).$count as jest.Mock).mockResolvedValue(1);
await expect(service.setupStatus()).resolves.toEqual({ needsSetup: false });
});
});
describe('setup', () => {
it('throws ForbiddenException when setup token is invalid in production', async () => {
const { service, config } = makeService();
config.get.mockImplementation((key: string) => {
if (key === 'app.nodeEnv') return 'production';
if (key === 'auth.setupBootstrapToken') return 'expected-token';
if (key === 'auth.jwtRefreshExpiresIn') return '7d';
if (key === 'auth.jwtExpiresIn') return '15m';
return undefined;
});
await expect(
service.setup({ username: 'admin', name: 'Admin', email: 'admin@example.com', password: 'Admin1234' } as never, 'wrong-token', makeReply()),
).rejects.toThrow(ForbiddenException);
});
it('throws ConflictException when setup is already completed', async () => {
const { service, db } = makeService();
((db as unknown as Record<string, unknown>).returning as jest.Mock).mockResolvedValueOnce([]);
await expect(
service.setup({ username: 'admin', name: 'Admin', email: 'admin@example.com', password: 'Admin1234' } as never, undefined, makeReply()),
).rejects.toThrow(ConflictException);
});
it('creates initial admin and returns auth payload', async () => {
const { service, db, userService } = makeService();
const reply = makeReply();
((db as unknown as Record<string, unknown>).returning as jest.Mock)
.mockResolvedValueOnce([{ id: 99 }])
.mockResolvedValueOnce([{ id: 7, tokenVersion: 1 }]);
(db.query as never as Record<string, Record<string, jest.Mock>>).users.findFirst.mockResolvedValue(null);
(db.query as never as Record<string, Record<string, jest.Mock>>).roles.findFirst.mockResolvedValue({ id: 1, name: 'Admin' });
userService.findByIdWithRolesAndPermissions.mockResolvedValue(
makeFullUser({
id: 7,
username: 'owner',
name: 'Owner',
email: 'owner@example.com',
roles: [{ id: 1, name: 'Admin', description: '', isSuperuser: true, isSystem: true, permissions: [] }],
}),
);
const result = await service.setup(
{ username: 'owner', name: 'Owner', email: 'owner@example.com', password: 'Owner1234' } as never,
undefined,
reply,
);
expect(result).toMatchObject({
accessToken: 'signed-jwt',
user: { id: 7, username: 'owner', email: 'owner@example.com' },
});
expect((reply as unknown as { setCookie: jest.Mock }).setCookie).toHaveBeenCalled();
});
});
describe('register', () => {
it('throws ForbiddenException when registration is closed', async () => {
const { service, db } = makeService();
@@ -127,9 +200,9 @@ describe('AuthService', () => {
});
it('throws ConflictException when username already exists', async () => {
const { service, db, userService } = makeService();
const { service, db } = makeService();
(db.query as never as Record<string, Record<string, jest.Mock>>).appSettings.findFirst.mockResolvedValue({ value: 'true' });
userService.findByUsername.mockResolvedValue({ id: 99, username: 'existing' });
(db.query as never as Record<string, Record<string, jest.Mock>>).users.findFirst.mockResolvedValueOnce({ id: 99, username: 'existing' });
await expect(service.register({ username: 'existing', name: 'E', password: 'P@ssw0rd!', email: undefined } as never)).rejects.toThrow(
ConflictException,
@@ -137,10 +210,11 @@ describe('AuthService', () => {
});
it('throws ConflictException when email already in use', async () => {
const { service, db, userService } = makeService();
const { service, db } = makeService();
(db.query as never as Record<string, Record<string, jest.Mock>>).appSettings.findFirst.mockResolvedValue({ value: 'true' });
userService.findByUsername.mockResolvedValue(null);
userService.findByEmail.mockResolvedValue({ id: 88, email: 'existing@example.com' });
(db.query as never as Record<string, Record<string, jest.Mock>>).users.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 88, email: 'existing@example.com' });
await expect(
service.register({ username: 'newuser', name: 'N', password: 'P@ssw0rd!', email: 'existing@example.com' } as never),
@@ -148,14 +222,11 @@ describe('AuthService', () => {
});
it('registers user successfully and assigns User role', async () => {
const { service, db, userService } = makeService();
const { service, db } = makeService();
(db.query as never as Record<string, Record<string, jest.Mock>>).appSettings.findFirst.mockResolvedValue({ value: 'true' });
userService.findByUsername.mockResolvedValue(null);
userService.findByEmail.mockResolvedValue(null);
userService.create.mockResolvedValue({ id: 1, username: 'jdoe', name: 'John Doe' });
(db.query as never as Record<string, Record<string, jest.Mock>>).users.findFirst.mockResolvedValueOnce(null);
((db as unknown as Record<string, unknown>).returning as jest.Mock).mockResolvedValueOnce([{ id: 1, username: 'jdoe', name: 'John Doe' }]);
(db.query as never as Record<string, Record<string, jest.Mock>>).roles.findFirst.mockResolvedValue({ id: 5, name: 'User' });
(db.insert as jest.Mock).mockReturnThis();
(db.values as jest.Mock).mockResolvedValue(undefined);
const result = await service.register({ username: 'jdoe', name: 'John Doe', password: 'P@ssw0rd!', email: undefined } as never);
expect(result).toEqual({ id: 1, username: 'jdoe', name: 'John Doe' });
@@ -279,6 +350,27 @@ describe('AuthService', () => {
await expect(service.refresh(makeRequest({ refresh_token: 'expired-token' }), makeReply())).rejects.toThrow(UnauthorizedException);
});
it('rotates token and sets cookies when refresh succeeds', async () => {
const { service, db } = makeService();
const reply = makeReply();
(db.query as never as Record<string, Record<string, jest.Mock>>).refreshTokens.findFirst.mockResolvedValue({
id: 11,
userId: 5,
revokedAt: null,
expiresAt: new Date(Date.now() + 60_000),
});
(db.query as never as Record<string, Record<string, jest.Mock>>).users.findFirst.mockResolvedValue({
id: 5,
tokenVersion: 2,
active: true,
});
const result = await service.refresh(makeRequest({ refresh_token: 'ok-token' }), reply);
expect(result).toEqual({ accessToken: 'signed-jwt' });
expect(db.update).toHaveBeenCalled();
expect((reply as unknown as { setCookie: jest.Mock }).setCookie).toHaveBeenCalled();
});
});
describe('validateUser', () => {
@@ -357,15 +449,27 @@ describe('AuthService', () => {
await expect(service.changePassword(1, { currentPassword: 'old', newPassword: 'New@1234' }, makeReply())).rejects.toThrow(BadRequestException);
});
it('throws UnauthorizedException when current password is wrong', async () => {
const { service, db } = makeService();
(db.query as never as Record<string, Record<string, jest.Mock>>).users.findFirst.mockResolvedValue({
id: 1,
provisioningMethod: 'local',
passwordHash: '$2b$12$N4G7fngl8wXlWv2vN7INzuLe6Qw3sJwN6gI6s2zQm6A2f0r7WQX1y', // hash for a different password
});
await expect(service.changePassword(1, { currentPassword: 'wrong-current', newPassword: 'New@1234' }, makeReply())).rejects.toThrow(
UnauthorizedException,
);
});
});
describe('getSessions', () => {
it('filters out expired sessions', async () => {
it('returns active sessions', async () => {
const { service, db } = makeService();
const now = new Date();
(db.query as never as Record<string, Record<string, jest.Mock>>).refreshTokens.findMany.mockResolvedValue([
{ id: 1, createdAt: new Date(now.getTime() - 1000), expiresAt: new Date(now.getTime() + 60000) },
{ id: 2, createdAt: new Date(now.getTime() - 2000), expiresAt: new Date(now.getTime() - 1000) },
]);
const sessions = await service.getSessions(1);
@@ -374,6 +478,44 @@ describe('AuthService', () => {
});
});
describe('revokeSession', () => {
it('throws ForbiddenException when session belongs to another user', async () => {
const { service, db } = makeService();
(db.query as never as Record<string, Record<string, jest.Mock>>).refreshTokens.findFirst.mockResolvedValue({
id: 9,
userId: 999,
});
await expect(service.revokeSession(1, 9)).rejects.toThrow(ForbiddenException);
});
});
describe('resetPassword', () => {
it('throws BadRequestException when reset token is expired', async () => {
const { service, db } = makeService();
(db.query as never as Record<string, Record<string, jest.Mock>>).passwordResetTokens.findFirst.mockResolvedValue({
id: 1,
userId: 1,
expiresAt: new Date(Date.now() - 10_000),
usedAt: null,
});
await expect(service.resetPassword({ token: 'expired', newPassword: 'NewPassword1' })).rejects.toThrow(BadRequestException);
});
it('throws BadRequestException when reset token is already used', async () => {
const { service, db } = makeService();
(db.query as never as Record<string, Record<string, jest.Mock>>).passwordResetTokens.findFirst.mockResolvedValue({
id: 1,
userId: 1,
expiresAt: new Date(Date.now() + 10_000),
usedAt: new Date(),
});
await expect(service.resetPassword({ token: 'used', newPassword: 'NewPassword1' })).rejects.toThrow(BadRequestException);
});
});
describe('logout', () => {
it('returns empty object when no refresh cookie', async () => {
const { service } = makeService();
+114 -28
View File
@@ -3,6 +3,7 @@ import {
ConflictException,
ForbiddenException,
Inject,
InternalServerErrorException,
Injectable,
Logger,
ServiceUnavailableException,
@@ -13,7 +14,7 @@ import { JwtService } from '@nestjs/jwt';
import { compare, hash } from 'bcryptjs';
import { createHash, randomBytes } from 'crypto';
import '@fastify/cookie';
import { and, eq, isNull, sql } from 'drizzle-orm';
import { and, count, eq, gt, isNull, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { FastifyReply, FastifyRequest } from 'fastify';
@@ -28,6 +29,7 @@ import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { SetupDto } from './dto/setup.dto';
import { OidcDiscoveryService } from './oidc/oidc-discovery.service';
import { OidcSessionRepository } from './oidc/oidc-session.repository';
@@ -43,6 +45,8 @@ function sha256(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
const INITIAL_SETUP_KEY = 'initial_setup_completed_at';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
@@ -66,33 +70,99 @@ export class AuthService {
throw new ForbiddenException('Registration is not open');
}
const existingUsername = await this.userService.findByUsername(dto.username);
if (existingUsername) throw new ConflictException('Username already taken');
if (dto.email) {
const existingEmail = await this.userService.findByEmail(dto.email);
if (existingEmail) throw new ConflictException('Email already in use');
}
const passwordHash = await hash(dto.password, 12);
const user = await this.userService.create({
username: dto.username,
name: dto.name,
email: dto.email,
passwordHash,
isDefaultPassword: false,
return this.db.transaction(async (tx) => {
const existingUsername = await tx.query.users.findFirst({ where: eq(schema.users.username, dto.username) });
if (existingUsername) throw new ConflictException('Username already taken');
if (dto.email) {
const existingEmail = await tx.query.users.findFirst({ where: eq(schema.users.email, dto.email) });
if (existingEmail) throw new ConflictException('Email already in use');
}
const [user] = await tx
.insert(schema.users)
.values({
username: dto.username,
name: dto.name,
email: dto.email,
passwordHash,
isDefaultPassword: false,
})
.returning({ id: schema.users.id, username: schema.users.username, name: schema.users.name });
const userRole = await tx.query.roles.findFirst({
where: eq(schema.roles.name, 'User'),
});
if (!userRole) {
this.logger.error(`User role not found while registering ${user.username}`);
throw new InternalServerErrorException('User role not found');
}
await tx.insert(schema.userRoles).values({ userId: user.id, roleId: userRole.id });
return user;
});
}
async setupStatus(): Promise<{ needsSetup: boolean }> {
const count = await this.db.$count(schema.users);
return { needsSetup: count === 0 };
}
async setup(dto: SetupDto, setupToken: string | undefined, reply: FastifyReply) {
this.assertSetupToken(setupToken);
const passwordHash = await hash(dto.password, 12);
const created = await this.db.transaction(async (tx) => {
const [setupMarker] = await tx
.insert(schema.appSettings)
.values({ key: INITIAL_SETUP_KEY, value: new Date().toISOString() })
.onConflictDoNothing({ target: schema.appSettings.key })
.returning({ id: schema.appSettings.id });
if (!setupMarker) {
throw new ConflictException('Setup already completed');
}
const [{ total }] = await tx.select({ total: count() }).from(schema.users);
if (Number(total) > 0) {
throw new ConflictException('Setup already completed');
}
const existingUsername = await tx.query.users.findFirst({ where: eq(schema.users.username, dto.username) });
if (existingUsername) {
throw new ConflictException('Username already taken');
}
const existingEmail = await tx.query.users.findFirst({ where: eq(schema.users.email, dto.email) });
if (existingEmail) {
throw new ConflictException('Email already in use');
}
const [user] = await tx
.insert(schema.users)
.values({
username: dto.username,
name: dto.name,
email: dto.email,
passwordHash,
isDefaultPassword: false,
})
.returning({
id: schema.users.id,
tokenVersion: schema.users.tokenVersion,
});
const adminRole = await tx.query.roles.findFirst({ where: eq(schema.roles.name, 'Admin') });
if (!adminRole) {
throw new InternalServerErrorException('Admin role not found');
}
await tx.insert(schema.userRoles).values({ userId: user.id, roleId: adminRole.id }).onConflictDoNothing();
return user;
});
const userRole = await this.db.query.roles.findFirst({
where: eq(schema.roles.name, 'User'),
});
if (userRole) {
await this.db.insert(schema.userRoles).values({ userId: user.id, roleId: userRole.id });
} else {
this.logger.warn(`User role not found — registered user ${user.username} has no role`);
}
return { id: user.id, username: user.username, name: user.name };
return this.issueTokensForUser(created.id, reply);
}
async login(dto: LoginDto, reply: FastifyReply) {
@@ -174,6 +244,11 @@ export class AuthService {
const userForToken = await this.db.query.users.findFirst({ where: eq(schema.users.id, row.userId) });
if (!userForToken) throw new UnauthorizedException();
if (!userForToken.active) {
this.clearRefreshCookie(reply);
this.clearAccessCookie(reply);
throw new UnauthorizedException('Account disabled');
}
const { accessToken, rawRefreshToken } = await this.issueTokenPair(row.userId, userForToken.tokenVersion);
this.setRefreshCookie(reply, rawRefreshToken);
@@ -236,16 +311,17 @@ export class AuthService {
async getSessions(userId: number) {
const rows = await this.db.query.refreshTokens.findMany({
where: and(eq(schema.refreshTokens.userId, userId), isNull(schema.refreshTokens.revokedAt)),
where: and(eq(schema.refreshTokens.userId, userId), isNull(schema.refreshTokens.revokedAt), gt(schema.refreshTokens.expiresAt, new Date())),
});
return rows.filter((r) => r.expiresAt > new Date()).map(({ id, createdAt, expiresAt }) => ({ id, createdAt, expiresAt }));
return rows.map(({ id, createdAt, expiresAt }) => ({ id, createdAt, expiresAt }));
}
async revokeSession(userId: number, sessionId: number) {
const row = await this.db.query.refreshTokens.findFirst({
where: eq(schema.refreshTokens.id, sessionId),
});
if (!row || row.userId !== userId) throw new UnauthorizedException();
if (!row) throw new UnauthorizedException();
if (row.userId !== userId) throw new ForbiddenException('You do not have access to this session');
await this.db.update(schema.refreshTokens).set({ revokedAt: new Date() }).where(eq(schema.refreshTokens.id, sessionId));
}
@@ -334,6 +410,16 @@ export class AuthService {
return { accessToken, rawRefreshToken };
}
private assertSetupToken(setupToken: string | undefined) {
const isProduction = this.config.get<string>('app.nodeEnv') === 'production';
if (!isProduction) return;
const expected = this.config.get<string>('auth.setupBootstrapToken') ?? '';
if (!expected || setupToken !== expected) {
throw new ForbiddenException('Invalid setup token');
}
}
private setRefreshCookie(reply: FastifyReply, rawToken: string) {
const ttlSeconds = parseDurationMs(this.config.get<string>('auth.jwtRefreshExpiresIn') ?? '7d') / 1000;
+21
View File
@@ -0,0 +1,21 @@
import { IsEmail, IsString, Matches, MinLength } from 'class-validator';
export class SetupDto {
@IsString()
@MinLength(3)
username: string;
@IsString()
@MinLength(1)
name: string;
@IsEmail()
email: string;
@IsString()
@MinLength(8)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/, {
message: 'Password must contain at least one uppercase letter, one lowercase letter, and one digit',
})
password: string;
}
+10 -37
View File
@@ -1,5 +1,4 @@
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { hash } from 'bcryptjs';
import { Inject, Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { and, eq, inArray, isNull } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
@@ -49,25 +48,20 @@ const USER_PERMISSIONS = [
@Injectable()
export class SeedService implements OnApplicationBootstrap {
private readonly logger = new Logger(SeedService.name);
constructor(@Inject(DB) private readonly db: NodePgDatabase<typeof schema>) {}
async onApplicationBootstrap() {
await this.seedPermissions();
await this.seedRoles();
await this.seedAppSettings();
await this.seedDefaultAdmin();
await this.seedEmailDefaults();
}
private async seedPermissions() {
for (const perm of BUILT_IN_PERMISSIONS) {
await this.db
.insert(schema.permissions)
.values(perm)
.onConflictDoUpdate({ target: schema.permissions.name, set: { isSystem: true } });
}
await this.db
.insert(schema.permissions)
.values(BUILT_IN_PERMISSIONS)
.onConflictDoUpdate({ target: schema.permissions.name, set: { isSystem: true } });
}
private async seedRoles() {
@@ -99,9 +93,11 @@ export class SeedService implements OnApplicationBootstrap {
const perms = await this.db.query.permissions.findMany({
where: inArray(schema.permissions.name, permissionNames),
});
for (const perm of perms) {
await this.db.insert(schema.rolePermissions).values({ roleId, permissionId: perm.id }).onConflictDoNothing();
}
if (perms.length === 0) return;
await this.db
.insert(schema.rolePermissions)
.values(perms.map((perm) => ({ roleId, permissionId: perm.id })))
.onConflictDoNothing();
}
private async seedAppSettings() {
@@ -189,27 +185,4 @@ export class SeedService implements OnApplicationBootstrap {
await this.db.insert(schema.appSettings).values(setting).onConflictDoNothing({ target: schema.appSettings.key });
}
}
private async seedDefaultAdmin() {
const count = await this.db.$count(schema.users);
if (count > 0) return;
const passwordHash = await hash('admin', 12);
const [user] = await this.db
.insert(schema.users)
.values({
username: 'admin',
name: 'Administrator',
passwordHash,
isDefaultPassword: true,
})
.returning();
const adminRole = await this.db.query.roles.findFirst({ where: eq(schema.roles.name, 'Admin') });
if (adminRole) {
await this.db.insert(schema.userRoles).values({ userId: user.id, roleId: adminRole.id });
}
this.logger.warn('WARNING: Default admin account created. Login with admin/admin and change your password immediately.');
}
}