Blame Raw
Cohee · 51ad27fb · · 211 lines (8.4 KB)
1 contributor
1import crypto from 'node:crypto';
2
3import storage from 'node-persist';
4import express from 'express';
5import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
6import { getIpAddress, retryAfter } from '../express-common.js';
7import { color, Cache, getConfigValue } from '../util.js';
8import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt, getAccountVersion } from '../users.js';
9
10const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false, 'boolean');
11const PREFER_REAL_IP_HEADER = getConfigValue('rateLimiting.preferRealIpHeader', false, 'boolean');
12const LOGIN_POINTS = getConfigValue('rateLimiting.accountsLoginMaxAttempts', 5, 'number');
13const RECOVER_POINTS = getConfigValue('rateLimiting.accountsRecoverMaxAttempts', 5, 'number');
14const MFA_CACHE = new Cache(5 * 60 * 1000);
15
16const generateRecoveryCode = () => Array.from({ length: 6 }, () => crypto.randomInt(0, 10)).join('');
17
18export const router = express.Router();
19const loginLimiter = new RateLimiterMemory({
20 points: LOGIN_POINTS > 0 ? LOGIN_POINTS : Number.MAX_SAFE_INTEGER,
21 duration: 60,
22});
23const recoverLimiter = new RateLimiterMemory({
24 points: RECOVER_POINTS > 0 ? RECOVER_POINTS : Number.MAX_SAFE_INTEGER,
25 duration: 300,
26});
27
28router.post('/list', async (_request, response) => {
29 try {
30 if (DISCREET_LOGIN) {
31 return response.sendStatus(204);
32 }
33
34 /** @type {import('../users.js').User[]} */
35 const users = await storage.values(x => x.key.startsWith(KEY_PREFIX));
36
37 /** @type {Promise<import('../users.js').UserViewModel>[]} */
38 const viewModelPromises = users
39 .filter(x => x.enabled)
40 .map(user => new Promise(async (resolve) => {
41 getUserAvatar(user.handle).then(avatar =>
42 resolve({
43 handle: user.handle,
44 name: user.name,
45 created: user.created,
46 avatar: avatar,
47 password: !!user.password,
48 }),
49 );
50 }));
51
52 const viewModels = await Promise.all(viewModelPromises);
53 viewModels.sort((x, y) => (x.created ?? 0) - (y.created ?? 0));
54 return response.json(viewModels);
55 } catch (error) {
56 console.error('User list failed:', error);
57 return response.sendStatus(500);
58 }
59});
60
61router.post('/login', async (request, response) => {
62 try {
63 if (!request.body.handle) {
64 console.warn('Login failed: Missing required fields');
65 return response.status(400).json({ error: 'Missing required fields' });
66 }
67
68 const ip = getIpAddress(request, PREFER_REAL_IP_HEADER);
69 await loginLimiter.consume(ip);
70
71 /** @type {import('../users.js').User} */
72 const user = await storage.getItem(toKey(request.body.handle));
73
74 if (!user) {
75 console.error('Login failed: User', request.body.handle, 'not found');
76 return response.status(403).json({ error: 'Incorrect credentials' });
77 }
78
79 if (!user.enabled) {
80 console.warn('Login failed: User', user.handle, 'is disabled');
81 return response.status(403).json({ error: 'User is disabled' });
82 }
83
84 if (user.password && user.password !== getPasswordHash(request.body.password, user.salt)) {
85 console.warn('Login failed: Incorrect password for', user.handle);
86 return response.status(403).json({ error: 'Incorrect credentials' });
87 }
88
89 if (!request.session) {
90 console.error('Session not available');
91 return response.sendStatus(500);
92 }
93
94 await loginLimiter.delete(ip);
95 request.session.handle = user.handle;
96 request.session.version = getAccountVersion(user);
97 console.info('Login successful:', user.handle, 'from', ip, 'at', new Date().toLocaleString());
98 return response.json({ handle: user.handle });
99 } catch (error) {
100 if (error instanceof RateLimiterRes) {
101 console.error('Login failed: Rate limited from', getIpAddress(request, PREFER_REAL_IP_HEADER));
102 return retryAfter(response, error).status(429).send({ error: 'Too many attempts. Try again later or recover your password.' });
103 }
104
105 console.error('Login failed:', error);
106 return response.sendStatus(500);
107 }
108});
109
110router.post('/recover-step1', async (request, response) => {
111 try {
112 if (!request.body.handle) {
113 console.warn('Recover step 1 failed: Missing required fields');
114 return response.status(400).json({ error: 'Missing required fields' });
115 }
116
117 const ip = getIpAddress(request, PREFER_REAL_IP_HEADER);
118 await recoverLimiter.consume(ip);
119
120 /** @type {import('../users.js').User} */
121 const user = await storage.getItem(toKey(request.body.handle));
122
123 if (!user) {
124 console.error('Recover step 1 failed: User', request.body.handle, 'not found');
125 return response.status(404).json({ error: 'User not found' });
126 }
127
128 if (!user.enabled) {
129 console.error('Recover step 1 failed: User', user.handle, 'is disabled');
130 return response.status(403).json({ error: 'User is disabled' });
131 }
132
133 const mfaCode = generateRecoveryCode();
134 console.log();
135 console.log(color.blue(`${user.name}, your password recovery code is: `) + color.magenta(mfaCode));
136 console.log();
137 MFA_CACHE.set(user.handle, mfaCode);
138 return response.sendStatus(204);
139 } catch (error) {
140 if (error instanceof RateLimiterRes) {
141 console.error('Recover step 1 failed: Rate limited from', getIpAddress(request, PREFER_REAL_IP_HEADER));
142 return retryAfter(response, error).status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
143 }
144
145 console.error('Recover step 1 failed:', error);
146 return response.sendStatus(500);
147 }
148});
149
150router.post('/recover-step2', async (request, response) => {
151 try {
152 if (!request.body.handle || !request.body.code) {
153 console.warn('Recover step 2 failed: Missing required fields');
154 return response.status(400).json({ error: 'Missing required fields' });
155 }
156
157 /** @type {import('../users.js').User} */
158 const user = await storage.getItem(toKey(request.body.handle));
159 const ip = getIpAddress(request, PREFER_REAL_IP_HEADER);
160 const rateLimit = await recoverLimiter.get(ip);
161
162 if (rateLimit !== null && rateLimit.consumedPoints > recoverLimiter.points) {
163 throw rateLimit;
164 }
165
166 if (!user) {
167 console.error('Recover step 2 failed: User', request.body.handle, 'not found');
168 return response.status(404).json({ error: 'User not found' });
169 }
170
171 if (!user.enabled) {
172 console.warn('Recover step 2 failed: User', user.handle, 'is disabled');
173 return response.status(403).json({ error: 'User is disabled' });
174 }
175
176 const mfaCode = MFA_CACHE.get(user.handle);
177
178 if (request.body.code !== mfaCode) {
179 await recoverLimiter.consume(ip);
180 console.warn('Recover step 2 failed: Incorrect code');
181 return response.status(403).json({ error: 'Incorrect code' });
182 }
183
184 if (request.body.newPassword) {
185 const salt = getPasswordSalt();
186 user.password = getPasswordHash(request.body.newPassword, salt);
187 user.salt = salt;
188 await storage.setItem(toKey(user.handle), user);
189 } else {
190 user.password = '';
191 user.salt = '';
192 await storage.setItem(toKey(user.handle), user);
193 }
194
195 if (request.session && request.session.handle === user.handle) {
196 request.session.version = getAccountVersion(user);
197 }
198
199 await recoverLimiter.delete(ip);
200 MFA_CACHE.remove(user.handle);
201 return response.sendStatus(204);
202 } catch (error) {
203 if (error instanceof RateLimiterRes) {
204 console.error('Recover step 2 failed: Rate limited from', getIpAddress(request, PREFER_REAL_IP_HEADER));
205 return retryAfter(response, error).status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
206 }
207
208 console.error('Recover step 2 failed:', error);
209 return response.sendStatus(500);
210 }
211});