Files
agent-fox/packages/server/src/routes/auth.ts
YANG JIANKUAN 9733b82c9c feat: 支持 OAuth 无密码用户设置密码和查看 API Key
- 新增 POST /auth/set-password 端点(仅限无密码用户)
- /auth/me 返回 hasPassword 字段
- SettingsDialog:无密码用户显示"设置密码"表单(无需当前密码)
- API Key reveal/copy:无密码时引导用户先设置密码
- 中英双语 i18n 支持

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:39:46 +08:00

266 lines
8.9 KiB
TypeScript

import { Router, type Router as RouterType } from 'express';
import { z } from 'zod';
import { prisma } from '@agent-fox/shared';
import { hashPassword, verifyPassword } from '../lib/password.js';
import { generateTokenPair, verifyRefreshToken } from '../lib/jwt.js';
import { requireAuth } from '../middleware/auth.js';
import { generateApiKey } from '../lib/api-key.js';
import { encryptApiKey, decryptApiKey } from '../lib/crypto.js';
const router: RouterType = Router();
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(1).max(100),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
});
router.post('/register', async (req, res) => {
const parsed = registerSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
return;
}
const { email, password, name } = parsed.data;
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
res.status(409).json({ success: false, error: { code: 'CONFLICT', message: 'Email already registered' } });
return;
}
const passwordHash = await hashPassword(password);
const user = await prisma.user.create({
data: { email, passwordHash, name },
});
const tokens = generateTokenPair({ userId: user.id, email: user.email });
res.status(201).json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name }, ...tokens } });
});
router.post('/login', async (req, res) => {
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
return;
}
const { email, password } = parsed.data;
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !user.passwordHash) {
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } });
return;
}
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) {
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } });
return;
}
const tokens = generateTokenPair({ userId: user.id, email: user.email });
res.json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name }, ...tokens } });
});
router.post('/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Refresh token required' } });
return;
}
try {
const payload = verifyRefreshToken(refreshToken);
const user = await prisma.user.findUnique({ where: { id: payload.userId } });
if (!user) {
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'User not found' } });
return;
}
const tokens = generateTokenPair({ userId: user.id, email: user.email });
res.json({ success: true, data: tokens });
} catch {
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid refresh token' } });
}
});
const setPasswordSchema = z.object({
password: z.string().min(8),
});
router.post('/set-password', requireAuth, async (req, res) => {
const parsed = setPasswordSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
return;
}
const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
if (!user) {
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });
return;
}
if (user.passwordHash) {
res.status(400).json({ success: false, error: { code: 'ALREADY_HAS_PASSWORD', message: 'Password already set. Use change-password instead.' } });
return;
}
const passwordHash = await hashPassword(parsed.data.password);
await prisma.user.update({ where: { id: user.id }, data: { passwordHash } });
res.json({ success: true, data: { message: 'Password set successfully' } });
});
const changePasswordSchema = z.object({
currentPassword: z.string(),
newPassword: z.string().min(8),
});
router.post('/change-password', requireAuth, async (req, res) => {
const parsed = changePasswordSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
return;
}
const { currentPassword, newPassword } = parsed.data;
const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
if (!user || !user.passwordHash) {
res.status(400).json({ success: false, error: { code: 'NO_PASSWORD', message: 'No password set for this account' } });
return;
}
const valid = await verifyPassword(currentPassword, user.passwordHash);
if (!valid) {
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Current password is incorrect' } });
return;
}
const newHash = await hashPassword(newPassword);
await prisma.user.update({ where: { id: user.id }, data: { passwordHash: newHash } });
res.json({ success: true, data: { message: 'Password changed' } });
});
const profileSchema = z.object({
name: z.string().min(1).max(100),
});
router.put('/profile', requireAuth, async (req, res) => {
const parsed = profileSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
return;
}
const user = await prisma.user.update({
where: { id: req.user!.userId },
data: { name: parsed.data.name },
select: { id: true, email: true, name: true },
});
res.json({ success: true, data: user });
});
router.get('/me', requireAuth, async (req, res) => {
const user = await prisma.user.findUnique({
where: { id: req.user!.userId },
select: { id: true, email: true, name: true, avatarUrl: true, passwordHash: true },
});
if (!user) {
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });
return;
}
const { passwordHash, ...rest } = user;
res.json({ success: true, data: { ...rest, hasPassword: !!passwordHash } });
});
// --- API Key Management ---
router.get('/api-key/status', requireAuth, async (req, res) => {
const user = await prisma.user.findUnique({
where: { id: req.user!.userId },
select: { apiKeyPrefix: true, apiKeyHash: true },
});
res.json({
success: true,
data: { hasKey: !!user?.apiKeyHash, prefix: user?.apiKeyPrefix || null },
});
});
router.post('/api-key/generate', requireAuth, async (req, res) => {
const user = await prisma.user.findUnique({
where: { id: req.user!.userId },
select: { apiKeyHash: true },
});
if (user?.apiKeyHash) {
res.status(400).json({ success: false, error: { code: 'ALREADY_EXISTS', message: 'API key already exists. Use rotate to replace it.' } });
return;
}
const { raw, hash } = generateApiKey();
const encrypted = encryptApiKey(raw);
const prefix = raw.slice(0, 12);
await prisma.user.update({
where: { id: req.user!.userId },
data: { apiKeyHash: hash, apiKeyEncrypted: encrypted, apiKeyPrefix: prefix },
});
res.json({ success: true, data: { apiKey: raw } });
});
router.post('/api-key/rotate', requireAuth, async (req, res) => {
const { raw, hash } = generateApiKey();
const encrypted = encryptApiKey(raw);
const prefix = raw.slice(0, 12);
await prisma.user.update({
where: { id: req.user!.userId },
data: { apiKeyHash: hash, apiKeyEncrypted: encrypted, apiKeyPrefix: prefix },
});
res.json({ success: true, data: { apiKey: raw } });
});
const revealSchema = z.object({ password: z.string() });
router.post('/api-key/reveal', requireAuth, async (req, res) => {
const parsed = revealSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Password is required' } });
return;
}
const user = await prisma.user.findUnique({
where: { id: req.user!.userId },
select: { passwordHash: true, apiKeyEncrypted: true },
});
if (!user?.passwordHash) {
res.status(400).json({ success: false, error: { code: 'NO_PASSWORD', message: 'No password set' } });
return;
}
const valid = await verifyPassword(parsed.data.password, user.passwordHash);
if (!valid) {
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Incorrect password' } });
return;
}
if (!user.apiKeyEncrypted) {
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'No API key generated' } });
return;
}
const apiKey = decryptApiKey(user.apiKeyEncrypted);
res.json({ success: true, data: { apiKey } });
});
export default router;