diff --git a/server/routes/auth.test.ts b/server/routes/auth.test.ts new file mode 100644 index 0000000..96c1b9d --- /dev/null +++ b/server/routes/auth.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import type { Database } from 'bun:sqlite' +import { Hono } from 'hono' +import { hashPassword } from '../auth' +import { createUser, openDb } from '../db' +import { createAuthRouter } from './auth' + +const JWT_SECRET = 'test-secret' + +describe('auth routes', () => { + let app: Hono + let db: Database + + beforeEach(async () => { + db = openDb(':memory:') + const hash = await hashPassword('password123') + createUser(db, { username: 'alice', passwordHash: hash, role: 'user' }) + createUser(db, { username: 'admin', passwordHash: await hashPassword('adminpass'), role: 'admin' }) + app = new Hono().route('/api/auth', createAuthRouter(db, JWT_SECRET)) + }) + + it('returns 401 for wrong password', async () => { + const res = await app.request('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'wrong' }), + }) + expect(res.status).toBe(401) + }) + + it('returns 401 for unknown user', async () => { + const res = await app.request('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'nobody', password: 'password123' }), + }) + expect(res.status).toBe(401) + }) + + it('logs in and returns access + refresh tokens', async () => { + const res = await app.request('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'password123' }), + }) + expect(res.status).toBe(200) + const body = await res.json() as { accessToken: string; refreshToken: string; user: { username: string; role: string } } + expect(typeof body.accessToken).toBe('string') + expect(typeof body.refreshToken).toBe('string') + expect(body.user.username).toBe('alice') + expect(body.user.role).toBe('user') + expect(body.user).not.toHaveProperty('passwordHash') + }) + + it('returns user info via /me with valid token', async () => { + const loginRes = await app.request('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'password123' }), + }) + const { accessToken } = await loginRes.json() as { accessToken: string } + + const meRes = await app.request('/api/auth/me', { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + expect(meRes.status).toBe(200) + const me = await meRes.json() as { username: string } + expect(me.username).toBe('alice') + }) + + it('returns 401 for /me without token', async () => { + const res = await app.request('/api/auth/me') + expect(res.status).toBe(401) + }) + + it('refreshes access token using refresh token', async () => { + const loginRes = await app.request('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'password123' }), + }) + const { refreshToken } = await loginRes.json() as { refreshToken: string } + + const refreshRes = await app.request('/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + }) + expect(refreshRes.status).toBe(200) + const body = await refreshRes.json() as { accessToken: string } + expect(typeof body.accessToken).toBe('string') + }) + + it('returns 401 for unknown refresh token', async () => { + const res = await app.request('/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken: 'bogus-token' }), + }) + expect(res.status).toBe(401) + }) + + it('logs out by invalidating refresh token', async () => { + const loginRes = await app.request('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'password123' }), + }) + const { refreshToken } = await loginRes.json() as { refreshToken: string } + + const logoutRes = await app.request('/api/auth/logout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + }) + expect(logoutRes.status).toBe(200) + + const refreshRes = await app.request('/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + }) + expect(refreshRes.status).toBe(401) + }) +}) diff --git a/server/routes/auth.ts b/server/routes/auth.ts new file mode 100644 index 0000000..ab8690c --- /dev/null +++ b/server/routes/auth.ts @@ -0,0 +1,75 @@ +import type { Database } from 'bun:sqlite' +import { Hono } from 'hono' +import { hashToken, signAccessToken, verifyPassword } from '../auth' +import { + createRefreshToken, + deleteRefreshTokenByHash, + findRefreshTokenByHash, + findUserById, + findUserByUsername, +} from '../db' +import { bearerAuth } from '../middleware/bearerAuth' + +function refreshExpiresAt(): string { + const d = new Date() + d.setDate(d.getDate() + 7) + return d.toISOString() +} + +export function createAuthRouter(db: Database, jwtSecret: string): Hono { + const app = new Hono<{ Variables: { userId: string; role: string } }>() + + app.post('/login', async (c) => { + const body = (await c.req.json().catch(() => null)) as { username?: unknown; password?: unknown } | null + if (typeof body?.username !== 'string' || typeof body?.password !== 'string') { + return c.json({ error: '请提供用户名和密码' }, 400) + } + const user = findUserByUsername(db, body.username) + if (!user || !(await verifyPassword(body.password, user.passwordHash))) { + return c.json({ error: '用户名或密码错误' }, 401) + } + const accessToken = await signAccessToken(user.id, user.role, jwtSecret) + const rawRefresh = crypto.randomUUID() + createRefreshToken(db, { + userId: user.id, + tokenHash: hashToken(rawRefresh), + expiresAt: refreshExpiresAt(), + }) + return c.json({ + accessToken, + refreshToken: rawRefresh, + user: { id: user.id, username: user.username, role: user.role }, + }) + }) + + app.post('/refresh', async (c) => { + const body = (await c.req.json().catch(() => null)) as { refreshToken?: unknown } | null + if (typeof body?.refreshToken !== 'string') { + return c.json({ error: '请提供 refresh token' }, 400) + } + const record = findRefreshTokenByHash(db, hashToken(body.refreshToken)) + if (!record || new Date(record.expiresAt) < new Date()) { + return c.json({ error: '无效或已过期的 refresh token' }, 401) + } + const user = findUserById(db, record.userId) + if (!user) return c.json({ error: '用户不存在' }, 401) + const accessToken = await signAccessToken(user.id, user.role, jwtSecret) + return c.json({ accessToken }) + }) + + app.post('/logout', async (c) => { + const body = (await c.req.json().catch(() => null)) as { refreshToken?: unknown } | null + if (typeof body?.refreshToken === 'string') { + deleteRefreshTokenByHash(db, hashToken(body.refreshToken)) + } + return c.json({ ok: true }) + }) + + app.get('/me', bearerAuth(jwtSecret), (c) => { + const user = findUserById(db, c.get('userId')) + if (!user) return c.json({ error: '用户不存在' }, 404) + return c.json({ id: user.id, username: user.username, role: user.role }) + }) + + return app +}