feat: add auth routes (login, refresh, logout, me)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
125
server/routes/auth.test.ts
Normal file
125
server/routes/auth.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
75
server/routes/auth.ts
Normal file
75
server/routes/auth.ts
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user