feat: add JWT authentication with register, login, refresh, and me endpoints

Adds bcrypt password hashing, JWT access/refresh token generation, requireAuth middleware, and /api/auth routes (POST /register, POST /login, POST /refresh, GET /me).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 11:42:11 +08:00
parent 2a15cbaead
commit 2ed957762c
9 changed files with 353 additions and 4 deletions

View File

@@ -14,6 +14,17 @@
"node": ">=20"
},
"pnpm": {
"onlyBuiltDependencies": ["@prisma/client", "@prisma/engines", "esbuild", "prisma"]
"onlyBuiltDependencies": [
"@prisma/client",
"@prisma/engines",
"esbuild",
"prisma"
]
},
"devDependencies": {
"prisma": "6.19.3"
},
"dependencies": {
"@prisma/client": "6.19.3"
}
}

View File

@@ -10,13 +10,17 @@
},
"dependencies": {
"@agent-fox/shared": "workspace:*",
"express": "^5.0.0",
"bcrypt": "^6.0.0",
"cors": "^2.8.5",
"express": "^5.0.0",
"jsonwebtoken": "^9.0.3",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/bcrypt": "^6.0.0",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.10",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}

View File

@@ -1,5 +1,6 @@
import express from 'express';
import cors from 'cors';
import authRouter from './routes/auth.js';
const app = express();
app.use(cors());
@@ -9,6 +10,8 @@ app.get('/api/health', (_req, res) => {
res.json({ success: true, data: { status: 'ok' } });
});
app.use('/api/auth', authRouter);
const port = process.env.SERVER_PORT || 3000;
app.listen(port, () => {
console.log(`Server running on port ${port}`);

View File

@@ -0,0 +1,34 @@
import jwt from 'jsonwebtoken';
const ACCESS_SECRET = process.env.JWT_SECRET || 'dev-secret';
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret';
const ACCESS_EXPIRY = '15m';
const REFRESH_EXPIRY = '7d';
export type TokenPayload = {
userId: string;
email: string;
};
export function generateAccessToken(payload: TokenPayload): string {
return jwt.sign(payload, ACCESS_SECRET, { expiresIn: ACCESS_EXPIRY });
}
export function generateRefreshToken(payload: TokenPayload): string {
return jwt.sign(payload, REFRESH_SECRET, { expiresIn: REFRESH_EXPIRY });
}
export function verifyAccessToken(token: string): TokenPayload {
return jwt.verify(token, ACCESS_SECRET) as TokenPayload;
}
export function verifyRefreshToken(token: string): TokenPayload {
return jwt.verify(token, REFRESH_SECRET) as TokenPayload;
}
export function generateTokenPair(payload: TokenPayload) {
return {
accessToken: generateAccessToken(payload),
refreshToken: generateRefreshToken(payload),
};
}

View File

@@ -0,0 +1,11 @@
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}

View File

@@ -0,0 +1,26 @@
import type { Request, Response, NextFunction } from 'express';
import { verifyAccessToken, type TokenPayload } from '../lib/jwt.js';
declare global {
namespace Express {
interface Request {
user?: TokenPayload;
}
}
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid token' } });
return;
}
try {
const token = header.slice(7);
req.user = verifyAccessToken(token);
next();
} catch {
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid or expired token' } });
}
}

View File

@@ -0,0 +1,103 @@
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';
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' } });
}
});
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 },
});
if (!user) {
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });
return;
}
res.json({ success: true, data: user });
});
export default router;

156
pnpm-lock.yaml generated
View File

@@ -6,7 +6,15 @@ settings:
importers:
.: {}
.:
dependencies:
'@prisma/client':
specifier: 6.19.3
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
devDependencies:
prisma:
specifier: 6.19.3
version: 6.19.3(typescript@5.9.3)
packages/mcp:
dependencies:
@@ -44,22 +52,34 @@ importers:
'@agent-fox/shared':
specifier: workspace:*
version: link:../shared
bcrypt:
specifier: ^6.0.0
version: 6.0.0
cors:
specifier: ^2.8.5
version: 2.8.6
express:
specifier: ^5.0.0
version: 5.2.1
jsonwebtoken:
specifier: ^9.0.3
version: 9.0.3
zod:
specifier: ^3.24.0
version: 3.25.76
devDependencies:
'@types/bcrypt':
specifier: ^6.0.0
version: 6.0.0
'@types/cors':
specifier: ^2.8.17
version: 2.8.19
'@types/express':
specifier: ^5.0.0
version: 5.0.6
'@types/jsonwebtoken':
specifier: ^9.0.10
version: 9.0.10
tsx:
specifier: ^4.19.0
version: 4.21.0
@@ -415,6 +435,9 @@ packages:
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
'@types/bcrypt@6.0.0':
resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==}
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
@@ -433,6 +456,12 @@ packages:
'@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
'@types/jsonwebtoken@9.0.10':
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@25.5.0':
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
@@ -463,10 +492,17 @@ packages:
ajv@8.18.0:
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
bcrypt@6.0.0:
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
engines: {node: '>= 18'}
body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -563,6 +599,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -730,6 +769,16 @@ packages:
json-schema-typed@8.0.2:
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
jsonwebtoken@9.0.3:
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
engines: {node: '>=12', npm: '>=6'}
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
@@ -804,6 +853,27 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
lodash.isboolean@3.0.3:
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
lodash.isinteger@4.0.4:
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
lodash.isnumber@3.0.3:
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.isstring@4.0.1:
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -836,9 +906,17 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
node-addon-api@8.7.0:
resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==}
engines: {node: ^18 || ^20 || >= 21}
node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
nypm@0.6.5:
resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==}
engines: {node: '>=18'}
@@ -949,9 +1027,17 @@ packages:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
send@1.2.1:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
@@ -1319,6 +1405,10 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/bcrypt@6.0.0':
dependencies:
'@types/node': 25.5.0
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
@@ -1347,6 +1437,13 @@ snapshots:
'@types/http-errors@2.0.5': {}
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 25.5.0
'@types/ms@2.1.0': {}
'@types/node@25.5.0':
dependencies:
undici-types: 7.18.2
@@ -1380,6 +1477,11 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
bcrypt@6.0.0:
dependencies:
node-addon-api: 8.7.0
node-gyp-build: 4.8.4
body-parser@2.2.2:
dependencies:
bytes: 3.1.2
@@ -1394,6 +1496,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
buffer-equal-constant-time@1.0.1: {}
bytes@3.1.2: {}
c12@3.1.0:
@@ -1476,6 +1580,10 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {}
effect@3.21.0:
@@ -1677,6 +1785,30 @@ snapshots:
json-schema-typed@8.0.2: {}
jsonwebtoken@9.0.3:
dependencies:
jws: 4.0.1
lodash.includes: 4.3.0
lodash.isboolean: 3.0.3
lodash.isinteger: 4.0.4
lodash.isnumber: 3.0.3
lodash.isplainobject: 4.0.6
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.7.4
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@4.0.1:
dependencies:
jwa: 2.0.1
safe-buffer: 5.2.1
lightningcss-android-arm64@1.32.0:
optional: true
@@ -1726,6 +1858,20 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
lodash.includes@4.3.0: {}
lodash.isboolean@3.0.3: {}
lodash.isinteger@4.0.4: {}
lodash.isnumber@3.0.3: {}
lodash.isplainobject@4.0.6: {}
lodash.isstring@4.0.1: {}
lodash.once@4.1.1: {}
math-intrinsics@1.1.0: {}
media-typer@1.1.0: {}
@@ -1744,8 +1890,12 @@ snapshots:
negotiator@1.0.0: {}
node-addon-api@8.7.0: {}
node-fetch-native@1.6.7: {}
node-gyp-build@4.8.4: {}
nypm@0.6.5:
dependencies:
citty: 0.2.2
@@ -1868,8 +2018,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
safe-buffer@5.2.1: {}
safer-buffer@2.1.2: {}
semver@7.7.4: {}
send@1.2.1:
dependencies:
debug: 4.4.3

View File

@@ -1,2 +1,5 @@
packages:
- "packages/*"
onlyBuiltDependencies:
- bcrypt