Skip to content

Commit 1bff056

Browse files
committed
feat: ratelimits for a bunch of routes (small)
1 parent df449b1 commit 1bff056

File tree

24 files changed

+419
-411
lines changed

24 files changed

+419
-411
lines changed

src/lib/ratelimits.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const secondlyRatelimit = (seconds: number) => ({
2+
config: { rateLimit: { max: 1, timeWindow: `${seconds} seconds`, allowList: [] } },
3+
});

src/server/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ async function main() {
109109
max: config.ratelimit.max,
110110
timeWindow: config.ratelimit.window ?? undefined,
111111
keyGenerator: (req) => {
112-
return req.user?.id;
112+
return `${req.user?.id ?? req.ip}-${req.url}-${req.method}`;
113113
},
114114
allowList: async (req, key) => {
115115
if (config.ratelimit.adminBypass && isAdministrator(req.user?.role)) return true;
@@ -118,6 +118,11 @@ async function main() {
118118

119119
return false;
120120
},
121+
onExceeded(req, key) {
122+
logger
123+
.c('ratelimit')
124+
.warn(`rate limit exceeded for user ${req.user?.username ?? req.ip ?? 'unknown'}`, { key });
125+
},
121126
});
122127
} catch (e) {
123128
if (process.env.DEBUG) console.error(e);

src/server/routes/api/auth/invites/index.ts

Lines changed: 33 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { parseExpiry } from '@/lib/uploader/parseHeaders';
77
import { administratorMiddleware } from '@/server/middleware/administrator';
88
import { userMiddleware } from '@/server/middleware/user';
99
import fastifyPlugin from 'fastify-plugin';
10+
import { secondlyRatelimit } from '@/lib/ratelimits';
1011

1112
export type ApiAuthInvitesResponse = Invite | Invite[];
1213

@@ -20,50 +21,47 @@ const logger = log('api').c('auth').c('invites');
2021
export const PATH = '/api/auth/invites';
2122
export default fastifyPlugin(
2223
(server, _, done) => {
23-
server.route<{
24-
Body: Body;
25-
}>({
26-
url: PATH,
27-
method: ['GET', 'POST'],
28-
preHandler: [userMiddleware, administratorMiddleware],
29-
handler: async (req, res) => {
30-
if (req.method === 'POST') {
31-
const { expiresAt, maxUses } = req.body;
24+
server.post<{ Body: Body }>(
25+
PATH,
26+
{ preHandler: [userMiddleware, administratorMiddleware], ...secondlyRatelimit(1) },
27+
async (req, res) => {
28+
const { expiresAt, maxUses } = req.body;
3229

33-
if (!expiresAt) return res.badRequest('expiresAt is required');
34-
let expires = null;
30+
if (!expiresAt) return res.badRequest('expiresAt is required');
31+
let expires = null;
3532

36-
if (expiresAt !== 'never') expires = parseExpiry(expiresAt);
33+
if (expiresAt !== 'never') expires = parseExpiry(expiresAt);
3734

38-
const invite = await prisma.invite.create({
39-
data: {
40-
code: randomCharacters(config.invites.length),
41-
expiresAt: expires,
42-
maxUses: maxUses ?? null,
43-
inviterId: req.user.id,
44-
},
45-
include: {
46-
inviter: inviteInviterSelect,
47-
},
48-
});
49-
50-
logger.info(`${req.user.username} created an invite`, {
51-
maxUses,
52-
expiresAt,
53-
code: invite.code,
54-
});
55-
56-
return res.send(invite);
57-
}
58-
59-
const invites = await prisma.invite.findMany({
35+
const invite = await prisma.invite.create({
36+
data: {
37+
code: randomCharacters(config.invites.length),
38+
expiresAt: expires,
39+
maxUses: maxUses ?? null,
40+
inviterId: req.user.id,
41+
},
6042
include: {
6143
inviter: inviteInviterSelect,
6244
},
6345
});
6446

65-
return res.send(invites);
47+
logger.info(`${req.user.username} created an invite`, {
48+
maxUses,
49+
expiresAt,
50+
code: invite.code,
51+
});
52+
53+
return res.send(invite);
6654
},
55+
);
56+
57+
server.get(PATH, { preHandler: [userMiddleware, administratorMiddleware] }, async (req, res) => {
58+
const invites = await prisma.invite.findMany({
59+
include: {
60+
inviter: inviteInviterSelect,
61+
},
62+
});
63+
64+
return res.send(invites);
6765
});
6866

6967
done();

src/server/routes/api/auth/login.ts

Lines changed: 47 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -22,77 +22,71 @@ const logger = log('api').c('auth').c('login');
2222
export const PATH = '/api/auth/login';
2323
export default fastifyPlugin(
2424
(server, _, done) => {
25-
server.route<{
26-
Body: Body;
27-
}>({
28-
url: PATH,
29-
method: ['POST'],
30-
handler: async (req, res) => {
31-
const session = await getSession(req, res);
25+
server.post<{ Body: Body }>(PATH, async (req, res) => {
26+
const session = await getSession(req, res);
3227

33-
session.id = null;
34-
session.sessionId = null;
28+
session.id = null;
29+
session.sessionId = null;
3530

36-
const { username, password, code } = req.body;
31+
const { username, password, code } = req.body;
3732

38-
if (!username) return res.badRequest('Username is required');
39-
if (!password) return res.badRequest('Password is required');
33+
if (!username) return res.badRequest('Username is required');
34+
if (!password) return res.badRequest('Password is required');
4035

41-
const user = await prisma.user.findUnique({
42-
where: {
43-
username,
44-
},
45-
select: {
46-
...userSelect,
47-
password: true,
48-
token: true,
49-
},
36+
const user = await prisma.user.findUnique({
37+
where: {
38+
username,
39+
},
40+
select: {
41+
...userSelect,
42+
password: true,
43+
token: true,
44+
},
45+
});
46+
if (!user) return res.badRequest('Invalid username');
47+
48+
if (!user.password) return res.badRequest('User does not have a password, login through a provider');
49+
const valid = await verifyPassword(password, user.password);
50+
if (!valid) {
51+
logger.warn('invalid login attempt', {
52+
username,
53+
ip: req.ip ?? 'unknown',
54+
ua: req.headers['user-agent'],
5055
});
51-
if (!user) return res.badRequest('Invalid username');
56+
return res.badRequest('Invalid password');
57+
}
5258

53-
if (!user.password) return res.badRequest('User does not have a password, login through a provider');
54-
const valid = await verifyPassword(password, user.password);
59+
if (user.totpSecret && code) {
60+
const valid = verifyTotpCode(code, user.totpSecret);
5561
if (!valid) {
56-
logger.warn('invalid login attempt', {
62+
logger.warn('invalid totp code', {
5763
username,
5864
ip: req.ip ?? 'unknown',
5965
ua: req.headers['user-agent'],
6066
});
61-
return res.badRequest('Invalid password');
62-
}
6367

64-
if (user.totpSecret && code) {
65-
const valid = verifyTotpCode(code, user.totpSecret);
66-
if (!valid) {
67-
logger.warn('invalid totp code', {
68-
username,
69-
ip: req.ip ?? 'unknown',
70-
ua: req.headers['user-agent'],
71-
});
72-
73-
return res.badRequest('Invalid code');
74-
}
68+
return res.badRequest('Invalid code');
7569
}
70+
}
7671

77-
if (user.totpSecret && !code)
78-
return res.send({
79-
totp: true,
80-
});
72+
if (user.totpSecret && !code)
73+
return res.send({
74+
totp: true,
75+
});
8176

82-
await saveSession(session, user, false);
77+
await saveSession(session, user, false);
8378

84-
delete (user as any).password;
79+
delete (user as any).password;
8580

86-
logger.info('user logged in successfully', {
87-
username,
88-
ip: req.ip ?? 'unknown',
89-
ua: req.headers['user-agent'],
90-
});
81+
logger.info('user logged in successfully', {
82+
username,
83+
ip: req.ip ?? 'unknown',
84+
ua: req.headers['user-agent'],
85+
});
9186

92-
return res.send({
93-
user,
94-
});
95-
},
87+
return res.send({
88+
user,
89+
});
9690
});
9791

9892
done();

src/server/routes/api/auth/register.ts

Lines changed: 61 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import { config } from '@/lib/config';
22
import { createToken, hashPassword } from '@/lib/crypto';
33
import { prisma } from '@/lib/db';
44
import { User, userSelect } from '@/lib/db/models/user';
5+
import { log } from '@/lib/logger';
6+
import { secondlyRatelimit } from '@/lib/ratelimits';
57
import { getSession, saveSession } from '@/server/session';
68
import fastifyPlugin from 'fastify-plugin';
79
import { ApiLoginResponse } from './login';
8-
import { log } from '@/lib/logger';
910

1011
export type ApiAuthRegisterResponse = ApiLoginResponse;
1112

@@ -20,85 +21,78 @@ const logger = log('api').c('auth').c('register');
2021
export const PATH = '/api/auth/register';
2122
export default fastifyPlugin(
2223
(server, _, done) => {
23-
server.route<{
24-
Body: Body;
25-
}>({
26-
url: PATH,
27-
method: ['POST'],
28-
handler: async (req, res) => {
29-
const session = await getSession(req, res);
24+
server.post<{ Body: Body }>(PATH, { ...secondlyRatelimit(5) }, async (req, res) => {
25+
const session = await getSession(req, res);
3026

31-
const { username, password, code } = req.body;
27+
const { username, password, code } = req.body;
3228

33-
if (code && !config.invites.enabled) return res.badRequest("Invites aren't enabled");
34-
if (!code && !config.features.userRegistration)
35-
return res.badRequest('User registration is disabled');
29+
if (code && !config.invites.enabled) return res.badRequest("Invites aren't enabled");
30+
if (!code && !config.features.userRegistration) return res.badRequest('User registration is disabled');
3631

37-
if (!username) return res.badRequest('Username is required');
38-
if (!password) return res.badRequest('Password is required');
32+
if (!username) return res.badRequest('Username is required');
33+
if (!password) return res.badRequest('Password is required');
34+
35+
const oUser = await prisma.user.findUnique({
36+
where: {
37+
username,
38+
},
39+
});
40+
if (oUser) return res.badRequest('Username is taken');
3941

40-
const oUser = await prisma.user.findUnique({
42+
if (code) {
43+
const invite = await prisma.invite.findFirst({
4144
where: {
42-
username,
45+
OR: [{ id: code }, { code }],
4346
},
4447
});
45-
if (oUser) return res.badRequest('Username is taken');
46-
47-
if (code) {
48-
const invite = await prisma.invite.findFirst({
49-
where: {
50-
OR: [{ id: code }, { code }],
51-
},
52-
});
53-
54-
if (!invite) return res.badRequest('Invalid invite code');
55-
if (invite.expiresAt && new Date(invite.expiresAt) < new Date())
56-
return res.badRequest('Invalid invite code');
57-
if (invite.maxUses && invite.uses >= invite.maxUses) return res.badRequest('Invalid invite code');
58-
59-
await prisma.invite.update({
60-
where: {
61-
id: invite.id,
62-
},
63-
data: {
64-
uses: invite.uses + 1,
65-
},
66-
});
67-
68-
logger.info('invite used', {
69-
user: username,
70-
invite: invite.id,
71-
});
72-
}
73-
74-
const user = await prisma.user.create({
75-
data: {
76-
username,
77-
password: await hashPassword(password),
78-
role: 'USER',
79-
token: createToken(),
48+
49+
if (!invite) return res.badRequest('Invalid invite code');
50+
if (invite.expiresAt && new Date(invite.expiresAt) < new Date())
51+
return res.badRequest('Invalid invite code');
52+
if (invite.maxUses && invite.uses >= invite.maxUses) return res.badRequest('Invalid invite code');
53+
54+
await prisma.invite.update({
55+
where: {
56+
id: invite.id,
8057
},
81-
select: {
82-
...userSelect,
83-
password: true,
84-
token: true,
58+
data: {
59+
uses: invite.uses + 1,
8560
},
8661
});
8762

88-
await saveSession(session, <User>user);
89-
90-
delete (user as any).password;
91-
92-
logger.info('user registered successfully', {
93-
username,
94-
ip: req.ip ?? 'unknown',
95-
ua: req.headers['user-agent'],
63+
logger.info('invite used', {
64+
user: username,
65+
invite: invite.id,
9666
});
67+
}
9768

98-
return res.send({
99-
user,
100-
});
101-
},
69+
const user = await prisma.user.create({
70+
data: {
71+
username,
72+
password: await hashPassword(password),
73+
role: 'USER',
74+
token: createToken(),
75+
},
76+
select: {
77+
...userSelect,
78+
password: true,
79+
token: true,
80+
},
81+
});
82+
83+
await saveSession(session, <User>user);
84+
85+
delete (user as any).password;
86+
87+
logger.info('user registered successfully', {
88+
username,
89+
ip: req.ip ?? 'unknown',
90+
ua: req.headers['user-agent'],
91+
});
92+
93+
return res.send({
94+
user,
95+
});
10296
});
10397

10498
done();

0 commit comments

Comments
 (0)