Skip to content

Commit 8feb6ee

Browse files
committed
Feat: Adds authentication middleware and route whitelisting closes #52
Implements authentication middleware to protect routes. Introduces route whitelisting, allowing specified routes to be accessed without authentication. Includes a composable for accessing the current user. Updates documentation to explain the new whitelisting feature. Refactors CLI output for clarity.
1 parent 21193b1 commit 8feb6ee

18 files changed

Lines changed: 358 additions & 6 deletions

File tree

docs/guide/authentication.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,23 @@ The module provides a complete authentication system with:
1111
- HTTP-only cookies for security
1212
- Automatic token management
1313

14+
## Whitelisting Routes
15+
16+
By default, all pages (except `/login`) require authentication. You can whitelist routes that should be accessible without authentication using the `auth.whitelist` option in your `nuxt.config.ts`.
17+
18+
If you want to add other pages what can be accessed without authentication, like a `/register` page, you can do so like this:
19+
20+
```ts
21+
export default defineNuxtConfig({
22+
modules: ['nuxt-users'],
23+
nuxtUsers: {
24+
auth: {
25+
whitelist: ['/login', '/register'],
26+
},
27+
},
28+
})
29+
```
30+
1431
## Authentication Flow
1532

1633
Upon successful login via the `/api/login` endpoint:

src/cli/create-user.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,11 @@ export default defineCommand({
4040

4141
try {
4242
const user = await createUser({ email, name, password, role }, options)
43-
console.log(`[DB:Create User] User created successfully: ${user.email} (role: ${user.role})`)
43+
console.log(`[Nuxt Users] User created successfully: ${user.email} (role: ${user.role})`)
4444
process.exit(0)
4545
}
4646
catch (error) {
47-
console.error('[DB:Create User] Error:', error)
47+
console.error('[Nuxt Users] Error:', error)
4848
process.exit(1)
4949
}
5050
}

src/module.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { defineNuxtModule, createResolver, addServerHandler, addComponent, addPlugin } from '@nuxt/kit'
1+
import { defineNuxtModule, createResolver, addServerHandler, addComponent, addPlugin, addImportsDir } from '@nuxt/kit'
22
import { defu } from 'defu'
33
import type { ModuleOptions } from './types'
44

@@ -27,6 +27,9 @@ export const defaultOptions: ModuleOptions = {
2727
from: '"Nuxt Users Module" <noreply@example.com>',
2828
},
2929
},
30+
auth: {
31+
whitelist: ['/login'],
32+
},
3033
}
3134

3235
export default defineNuxtModule<ModuleOptions>({
@@ -51,13 +54,18 @@ export default defineNuxtModule<ModuleOptions>({
5154
personalAccessTokens: options.tables?.personalAccessTokens || defaultOptions.tables.personalAccessTokens,
5255
passwordResetTokens: options.tables?.passwordResetTokens || defaultOptions.tables.passwordResetTokens,
5356
},
57+
auth: {
58+
whitelist: options.auth?.whitelist || defaultOptions.auth?.whitelist || ['/login'],
59+
},
5460
}
5561

5662
addPlugin({
5763
src: resolver.resolve('./runtime/plugin'),
5864
mode: 'server'
5965
})
6066

67+
addImportsDir(resolver.resolve('./runtime/composables'))
68+
6169
// Register API routes
6270
addServerHandler({
6371
route: '/api/login',

src/runtime/composables/useAuth.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { useState } from '#app'
2+
import type { User } from '../../types'
3+
4+
export const useAuth = () => {
5+
const user = useState<User | null>('user', () => null)
6+
7+
return {
8+
user,
9+
}
10+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { defineNuxtRouteMiddleware, useRuntimeConfig, navigateTo } from '#app'
2+
import { useAuth } from '../composables/useAuth'
3+
4+
export default defineNuxtRouteMiddleware((to, _from) => {
5+
const { nuxtUsers } = useRuntimeConfig()
6+
const { user } = useAuth()
7+
8+
// Always allow access to login page
9+
if (to.path === '/login') {
10+
return
11+
}
12+
13+
if (
14+
!user.value
15+
&& !nuxtUsers.auth?.whitelist?.includes(to.path)
16+
) {
17+
return navigateTo('/login')
18+
}
19+
})

src/runtime/plugin.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ export default defineNuxtPlugin(async (_nuxtApp) => {
66
const { nuxtUsers } = useRuntimeConfig()
77
const options = nuxtUsers as ModuleOptions
88

9-
console.log({ nuxtUsers })
10-
119
const hasMigrationsTable = await checkTableExists(options, options.tables.migrations)
1210
if (!hasMigrationsTable) {
1311
console.warn('[Nuxt Users] ⚠️ Migrations table does not exist, you should run the migration script to create it by running: npx nuxt-users migrate')

src/types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,14 @@ export interface ModuleOptions {
3434
* Skip database checks during module setup to prevent hanging
3535
* @default false
3636
*/
37-
skipDatabaseChecks?: boolean
37+
auth?: {
38+
/**
39+
* Whitelisted routes that do not require authentication
40+
* @default ['/login']
41+
* @example ['/login', '/register']
42+
*/
43+
whitelist: string[]
44+
}
3845
}
3946

4047
export interface MailerOptions {

test/auth-whitelist.test.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2+
import { setup, $fetch } from '@nuxt/test-utils/e2e'
3+
import type { Database } from 'db0'
4+
import { createUser } from '../src/utils/user'
5+
import { createUsersTable } from '../src/utils/create-users-table'
6+
import { createPersonalAccessTokensTable } from '../src/utils/create-personal-access-tokens-table'
7+
import type { DatabaseConfig, DatabaseType, ModuleOptions, User } from '../src/types'
8+
import { fileURLToPath } from 'node:url'
9+
import { cleanupTestSetup, createTestSetup } from './test-setup'
10+
11+
describe('Auth Whitelist Middleware', async () => {
12+
let db: Database
13+
let testOptions: ModuleOptions
14+
let dbType: DatabaseType
15+
let dbConfig: DatabaseConfig
16+
let _testUser: Omit<User, 'password'>
17+
18+
await setup({
19+
rootDir: fileURLToPath(new URL('./fixtures/auth-whitelist', import.meta.url)),
20+
})
21+
22+
beforeEach(async () => {
23+
dbType = process.env.DB_CONNECTOR as DatabaseType || 'sqlite'
24+
if (dbType === 'sqlite') {
25+
dbConfig = {
26+
path: './_auth-whitelist-test',
27+
}
28+
}
29+
if (dbType === 'mysql') {
30+
dbConfig = {
31+
host: process.env.DB_HOST,
32+
port: Number.parseInt(process.env.DB_PORT || '3306'),
33+
user: process.env.DB_USER,
34+
password: process.env.DB_PASSWORD,
35+
database: process.env.DB_NAME
36+
}
37+
}
38+
if (dbType === 'postgresql') {
39+
dbConfig = {
40+
host: process.env.DB_HOST,
41+
port: Number.parseInt(process.env.DB_PORT || '5432'),
42+
user: process.env.DB_USER,
43+
password: process.env.DB_PASSWORD,
44+
database: process.env.DB_NAME
45+
}
46+
}
47+
48+
const settings = await createTestSetup({
49+
dbType,
50+
dbConfig,
51+
})
52+
53+
db = settings.db
54+
testOptions = settings.testOptions
55+
56+
await createUsersTable(testOptions)
57+
await createPersonalAccessTokensTable(testOptions)
58+
59+
// Create a test user
60+
_testUser = await createUser({
61+
email: 'test@example.com',
62+
name: 'Test User',
63+
password: 'password123',
64+
}, testOptions)
65+
})
66+
67+
afterEach(async () => {
68+
await cleanupTestSetup(dbType, db, [testOptions.connector!.options.path!], testOptions.tables.users)
69+
await cleanupTestSetup(dbType, db, [testOptions.connector!.options.path!], testOptions.tables.personalAccessTokens)
70+
})
71+
72+
it('should allow access to whitelisted routes without authentication', async () => {
73+
// Test login page (always accessible)
74+
const loginResponse = await $fetch('/login')
75+
expect(loginResponse).toBeDefined()
76+
77+
// Test public page (whitelisted)
78+
const publicResponse = await $fetch('/public')
79+
expect(publicResponse).toBeDefined()
80+
81+
// Test register page (whitelisted)
82+
const registerResponse = await $fetch('/register')
83+
expect(registerResponse).toBeDefined()
84+
85+
// Test about page (whitelisted)
86+
const aboutResponse = await $fetch('/about')
87+
expect(aboutResponse).toBeDefined()
88+
})
89+
90+
it('should redirect to login when accessing protected routes without authentication', async () => {
91+
try {
92+
await $fetch('/')
93+
}
94+
catch (error: unknown) {
95+
const fetchError = error as { response: { status: number } }
96+
// Should redirect to login (302 redirect)
97+
expect(fetchError.response.status).toBe(302)
98+
}
99+
100+
try {
101+
await $fetch('/dashboard')
102+
}
103+
catch (error: unknown) {
104+
const fetchError = error as { response: { status: number } }
105+
// Should redirect to login (302 redirect)
106+
expect(fetchError.response.status).toBe(302)
107+
}
108+
})
109+
110+
it('should allow access to protected routes when authenticated', async () => {
111+
// First login to get authentication
112+
const loginResponse = await $fetch('/api/login', {
113+
method: 'POST',
114+
body: {
115+
email: 'test@example.com',
116+
password: 'password123',
117+
},
118+
})
119+
120+
expect(loginResponse).toBeDefined()
121+
122+
// Now try to access protected routes
123+
const indexResponse = await $fetch('/')
124+
expect(indexResponse).toBeDefined()
125+
126+
const dashboardResponse = await $fetch('/dashboard')
127+
expect(dashboardResponse).toBeDefined()
128+
})
129+
130+
it('should handle custom whitelist configuration', async () => {
131+
// The test fixture is configured with custom whitelist: ['/public', '/register', '/about']
132+
// Test that these specific routes are accessible
133+
const whitelistedRoutes = ['/public', '/register', '/about']
134+
135+
for (const route of whitelistedRoutes) {
136+
const response = await $fetch(route)
137+
expect(response).toBeDefined()
138+
}
139+
140+
// Test that non-whitelisted routes are protected
141+
const protectedRoutes = ['/', '/dashboard']
142+
143+
for (const route of protectedRoutes) {
144+
try {
145+
await $fetch(route)
146+
}
147+
catch (error: unknown) {
148+
const fetchError = error as { response: { status: number } }
149+
expect(fetchError.response.status).toBe(302) // Redirect to login
150+
}
151+
}
152+
})
153+
154+
it('should always allow access to login page regardless of whitelist', async () => {
155+
// Login page should always be accessible, even when not in whitelist
156+
const loginResponse = await $fetch('/login')
157+
expect(loginResponse).toBeDefined()
158+
})
159+
160+
it('should maintain authentication state across requests', async () => {
161+
// Login first
162+
await $fetch('/api/login', {
163+
method: 'POST',
164+
body: {
165+
email: 'test@example.com',
166+
password: 'password123',
167+
},
168+
})
169+
170+
// Access multiple protected routes
171+
const routes = ['/', '/dashboard']
172+
173+
for (const route of routes) {
174+
const response = await $fetch(route)
175+
expect(response).toBeDefined()
176+
}
177+
})
178+
179+
it('should handle edge cases in whitelist configuration', async () => {
180+
// Test with trailing slashes
181+
const loginWithSlash = await $fetch('/login/')
182+
expect(loginWithSlash).toBeDefined()
183+
184+
// Test with query parameters
185+
const loginWithQuery = await $fetch('/login?redirect=/dashboard')
186+
expect(loginWithQuery).toBeDefined()
187+
188+
// Test with hash fragments
189+
const loginWithHash = await $fetch('/login#section')
190+
expect(loginWithHash).toBeDefined()
191+
})
192+
})
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<template>
2+
<div>
3+
<NuxtPage />
4+
</div>
5+
</template>
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { getTestOptions } from '../../test-setup'
2+
import NuxtUsers from '../../../src/module'
3+
import type { DatabaseConfig, DatabaseType } from '../../../src/types'
4+
import { BASE_CONFIG } from '../../../src/constants'
5+
6+
const dbType = process.env.DB_CONNECTOR as DatabaseType || 'sqlite'
7+
let dbConfig = {} as DatabaseConfig
8+
9+
if (dbType === 'sqlite') {
10+
dbConfig = {
11+
path: './_auth-whitelist-test',
12+
}
13+
}
14+
if (dbType === 'mysql') {
15+
dbConfig = {
16+
host: process.env.DB_HOST,
17+
port: Number.parseInt(process.env.DB_PORT || '3306'),
18+
user: process.env.DB_USER,
19+
password: process.env.DB_PASSWORD,
20+
database: process.env.DB_NAME
21+
}
22+
}
23+
if (dbType === 'postgresql') {
24+
dbConfig = {
25+
host: process.env.DB_HOST,
26+
port: Number.parseInt(process.env.DB_PORT || '5432'),
27+
user: process.env.DB_USER,
28+
password: process.env.DB_PASSWORD,
29+
database: process.env.DB_NAME
30+
}
31+
}
32+
33+
const options = getTestOptions(dbType, dbConfig)
34+
35+
export default defineNuxtConfig({
36+
modules: [NuxtUsers],
37+
...BASE_CONFIG,
38+
nuxtUsers: {
39+
...options,
40+
auth: {
41+
whitelist: ['/public', '/register', '/about']
42+
}
43+
},
44+
})

0 commit comments

Comments
 (0)