feat: add admin password reset

This commit is contained in:
2026-06-16 06:40:55 -06:00
parent 502afffa02
commit 979a70439b
8 changed files with 168 additions and 6 deletions

View File

@@ -2,8 +2,8 @@ import { afterEach, describe, expect, it, setSystemTime } from 'bun:test'
import { createEmptyBook, createEmptyTeachingDesign } from '../src/domain/teachingDesign'
import {
createBook, deleteBook, getBook, listBooks, openDb, renameBook, saveBookData,
createUser, findUserByUsername, findUserById, listUsers, deleteUser,
createRefreshToken, findRefreshTokenByHash, deleteRefreshTokenByHash,
createUser, findUserByUsername, findUserById, listUsers, deleteUser, updateUserPasswordHash,
createRefreshToken, findRefreshTokenByHash, deleteRefreshTokenByHash, deleteRefreshTokensForUser,
} from './db'
afterEach(() => {
@@ -143,6 +143,19 @@ describe('users and refresh tokens', () => {
expect(deleteUser(db, 'missing')).toBe(false)
})
it('updates a user password hash', () => {
const db = openDb(':memory:')
const user = createUser(db, { username: 'frank', passwordHash: 'old-hash', role: 'user' })
expect(updateUserPasswordHash(db, user.id, 'new-hash')).toBe(true)
expect(findUserById(db, user.id)?.passwordHash).toBe('new-hash')
})
it('returns false when updating password hash for missing user', () => {
const db = openDb(':memory:')
expect(updateUserPasswordHash(db, 'missing', 'new-hash')).toBe(false)
})
it('creates and finds a refresh token by hash', () => {
const db = openDb(':memory:')
const user = createUser(db, { username: 'dave', passwordHash: 'h', role: 'user' })
@@ -159,4 +172,16 @@ describe('users and refresh tokens', () => {
expect(deleteRefreshTokenByHash(db, 'xyz')).toBe(true)
expect(findRefreshTokenByHash(db, 'xyz')).toBeNull()
})
it('deletes refresh tokens for one user', () => {
const db = openDb(':memory:')
const first = createUser(db, { username: 'grace', passwordHash: 'h', role: 'user' })
const second = createUser(db, { username: 'heidi', passwordHash: 'h', role: 'user' })
createRefreshToken(db, { userId: first.id, tokenHash: 'first-token', expiresAt: '2099-01-01T00:00:00.000Z' })
createRefreshToken(db, { userId: second.id, tokenHash: 'second-token', expiresAt: '2099-01-01T00:00:00.000Z' })
expect(deleteRefreshTokensForUser(db, first.id)).toBe(1)
expect(findRefreshTokenByHash(db, 'first-token')).toBeNull()
expect(findRefreshTokenByHash(db, 'second-token')).not.toBeNull()
})
})

View File

@@ -206,6 +206,11 @@ export function deleteUser(db: Database, id: string): boolean {
return result.changes > 0
}
export function updateUserPasswordHash(db: Database, id: string, passwordHash: string): boolean {
const result = db.run('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, id])
return result.changes > 0
}
export function createRefreshToken(
db: Database,
params: { userId: string; tokenHash: string; expiresAt: string },
@@ -233,3 +238,8 @@ export function deleteRefreshTokenByHash(db: Database, tokenHash: string): boole
const result = db.run('DELETE FROM refresh_tokens WHERE token_hash = ?', [tokenHash])
return result.changes > 0
}
export function deleteRefreshTokensForUser(db: Database, userId: string): number {
const result = db.run('DELETE FROM refresh_tokens WHERE user_id = ?', [userId])
return result.changes
}

View File

@@ -1,8 +1,8 @@
import { beforeEach, describe, expect, it } from 'bun:test'
import type { Database } from 'bun:sqlite'
import { Hono } from 'hono'
import { hashPassword, signAccessToken } from '../auth'
import { createUser, openDb } from '../db'
import { hashPassword, signAccessToken, verifyPassword } from '../auth'
import { createRefreshToken, createUser, findRefreshTokenByHash, findUserById, openDb } from '../db'
import { createAdminRouter } from './admin'
const JWT_SECRET = 'test-secret'
@@ -95,4 +95,41 @@ describe('admin routes', () => {
})
expect(res.status).toBe(404)
})
it('resets a user password to the fixed temporary password', async () => {
createRefreshToken(db, { userId, tokenHash: 'user-refresh-token', expiresAt: '2099-01-01T00:00:00.000Z' })
const res = await app.request(`/api/admin/users/${userId}/reset-password`, {
method: 'POST',
headers: { Authorization: `Bearer ${adminToken}` },
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ ok: true })
const updated = findUserById(db, userId)
expect(updated).not.toBeNull()
expect(await verifyPassword('123456', updated!.passwordHash)).toBe(true)
expect(await verifyPassword('userpass', updated!.passwordHash)).toBe(false)
expect(findRefreshTokenByHash(db, 'user-refresh-token')).toBeNull()
})
it('returns 404 when resetting password for missing user', async () => {
const res = await app.request('/api/admin/users/missing/reset-password', {
method: 'POST',
headers: { Authorization: `Bearer ${adminToken}` },
})
expect(res.status).toBe(404)
expect(await res.json()).toEqual({ error: '用户不存在' })
})
it('returns 403 when non-admin resets a password', async () => {
const res = await app.request(`/api/admin/users/${adminId}/reset-password`, {
method: 'POST',
headers: { Authorization: `Bearer ${userToken}` },
})
expect(res.status).toBe(403)
})
})

View File

@@ -1,9 +1,11 @@
import type { Database } from 'bun:sqlite'
import { Hono } from 'hono'
import { hashPassword } from '../auth'
import { createUser, deleteUser, listUsers } from '../db'
import { createUser, deleteRefreshTokensForUser, deleteUser, findUserById, listUsers, updateUserPasswordHash } from '../db'
import { bearerAuth, type AuthVariables } from '../middleware/bearerAuth'
const RESET_PASSWORD = '123456'
export function createAdminRouter(db: Database, jwtSecret: string) {
const app = new Hono<{ Variables: AuthVariables }>()
@@ -39,6 +41,18 @@ export function createAdminRouter(db: Database, jwtSecret: string) {
return c.json({ id: user.id, username: user.username, role: user.role, createdAt: user.createdAt })
})
app.post('/users/:id/reset-password', async (c) => {
const targetId = c.req.param('id')
if (!findUserById(db, targetId)) {
return c.json({ error: '用户不存在' }, 404)
}
const passwordHash = await hashPassword(RESET_PASSWORD)
updateUserPasswordHash(db, targetId, passwordHash)
deleteRefreshTokensForUser(db, targetId)
return c.json({ ok: true })
})
app.delete('/users/:id', (c) => {
const targetId = c.req.param('id')
if (targetId === c.get('userId')) {