Added password recovery and login attempt guards.
This commit is contained in:
@@ -413,7 +413,7 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!filters.includeAliased) {
|
||||
if (!filters.includeAliased && !filters.stashId) {
|
||||
builder.where('alias_for', 0);
|
||||
}
|
||||
|
||||
|
||||
238
src/auth.js
238
src/auth.js
@@ -46,19 +46,71 @@ async function generateAvatar(user) {
|
||||
logger.verbose(`Generated avatar for '${user.username}' (${user.id})`);
|
||||
}
|
||||
|
||||
async function checkLoginCooldown(identifier, attemptThreshold) {
|
||||
if (!identifier) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cooldownKey = `traxxx:login_cooldown:${slugify(identifier, '_')}`;
|
||||
const attempts = JSON.parse(await redis.get(cooldownKey) || '0');
|
||||
|
||||
if (attempts >= attemptThreshold) {
|
||||
throw new HttpError(`Please wait a few minutes before attempting another login`, 409);
|
||||
}
|
||||
}
|
||||
|
||||
async function setLoginCooldown(identifier, attemptCooldown) {
|
||||
if (!identifier) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cooldownKey = `traxxx:login_cooldown:${slugify(identifier, '_')}`;
|
||||
|
||||
await redis.incr(cooldownKey);
|
||||
await redis.expire(cooldownKey, attemptCooldown * 60);
|
||||
}
|
||||
|
||||
export async function login(credentials, userIp) {
|
||||
if (!config.auth.login) {
|
||||
throw new HttpError('Logins are currently disabled', 503);
|
||||
}
|
||||
|
||||
if (!credentials.username) {
|
||||
throw new HttpError('Please provide a username or e-mail', 400);
|
||||
}
|
||||
|
||||
if (!credentials.password) {
|
||||
throw new HttpError('Please provide a password', 400);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
checkLoginCooldown(credentials.username, config.auth.loginCredentialAttempts),
|
||||
checkLoginCooldown(userIp, config.auth.loginIpAttempts),
|
||||
]);
|
||||
|
||||
const { user } = await fetchUser(credentials.username.trim(), {
|
||||
email: true,
|
||||
raw: true,
|
||||
}).catch(() => {
|
||||
}).catch(async () => {
|
||||
await Promise.all([
|
||||
setLoginCooldown(credentials.username, config.auth.loginCredentialCooldown),
|
||||
setLoginCooldown(userIp, config.auth.loginIpCooldown),
|
||||
]);
|
||||
|
||||
throw new HttpError('Username or password incorrect', 401);
|
||||
});
|
||||
|
||||
await verifyPassword(credentials.password, user.password);
|
||||
try {
|
||||
await verifyPassword(credentials.password, user.password);
|
||||
}
|
||||
catch (error) {
|
||||
await Promise.all([
|
||||
setLoginCooldown(credentials.username, config.auth.loginCredentialCooldown),
|
||||
setLoginCooldown(userIp, config.auth.loginIpCooldown),
|
||||
]);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
await knex('users')
|
||||
.update('last_login', 'NOW()')
|
||||
@@ -90,6 +142,17 @@ async function hashPassword(password) {
|
||||
};
|
||||
}
|
||||
|
||||
async function tokenCooldown(identifier) {
|
||||
const cooldownKey = `traxxx:token_cooldown:${slugify(identifier, '_')}`;
|
||||
|
||||
if (await redis.exists(cooldownKey)) {
|
||||
throw new HttpError(`Please wait a few minutes before requesting another token`, 409);
|
||||
}
|
||||
|
||||
await redis.set(cooldownKey, JSON.stringify(true));
|
||||
await redis.expire(cooldownKey, config.auth.tokenCooldown * 60);
|
||||
}
|
||||
|
||||
export async function sendEmailVerification(user, customEmail, trigger) {
|
||||
if (!config.email.enabled) {
|
||||
throw new HttpError('E-mail verification is disabled', 503);
|
||||
@@ -105,6 +168,8 @@ export async function sendEmailVerification(user, customEmail, trigger) {
|
||||
throw new HttpError('No e-mail address provided', 400);
|
||||
}
|
||||
|
||||
await tokenCooldown(email);
|
||||
|
||||
const tokenKey = `traxxx:token_email:${user.id}`;
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
@@ -135,6 +200,138 @@ export async function sendEmailVerification(user, customEmail, trigger) {
|
||||
}]);
|
||||
}
|
||||
|
||||
export async function verifyEmail(userId, emailToken) {
|
||||
if (!userId) {
|
||||
throw new HttpError('User ID not provided', 400);
|
||||
}
|
||||
|
||||
if (!emailToken) {
|
||||
throw new HttpError('Token not provided', 400);
|
||||
}
|
||||
|
||||
const tokenKey = `traxxx:token_email:${userId}`;
|
||||
const tokenEntry = await redis.getDel(tokenKey);
|
||||
|
||||
if (!tokenEntry) {
|
||||
throw new HttpError('Invalid verification link', 401);
|
||||
}
|
||||
|
||||
const { token, email } = JSON.parse(tokenEntry);
|
||||
|
||||
if (!token
|
||||
|| !email
|
||||
|| emailToken.length !== token.length
|
||||
|| !crypto.timingSafeEqual(Buffer.from(emailToken, 'hex'), Buffer.from(token, 'hex'))) {
|
||||
throw new HttpError('Invalid verification link', 401);
|
||||
}
|
||||
|
||||
const updated = await knex('users')
|
||||
.where('id', userId)
|
||||
.update({
|
||||
email,
|
||||
email_verified: true,
|
||||
});
|
||||
|
||||
if (updated === 0) {
|
||||
throw new HttpError('E-mail verification failed because the user could not be found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPasswordReset(credential) {
|
||||
if (!config.email.enabled) {
|
||||
throw new HttpError('Password reset is disabled', 503);
|
||||
}
|
||||
|
||||
if (!credential) {
|
||||
throw new HttpError('No credential provided', 400);
|
||||
}
|
||||
|
||||
await tokenCooldown(credential);
|
||||
|
||||
const user = await fetchUser(credential, {
|
||||
email: true,
|
||||
includePrivate: true,
|
||||
throwError: false,
|
||||
});
|
||||
|
||||
// we don't throw errors because the requesting user shouldn't know whether the account exists or not
|
||||
if (!user) {
|
||||
logger.info(`Password reset requested for unknown user '${credential}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
logger.info(`Password reset requested for '${credential}', but user '${user.username}' (${user.id}) has no e-mail address known`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenKey = `traxxx:token_password:${user.id}`;
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const tokenData = {
|
||||
token,
|
||||
};
|
||||
|
||||
await redis.set(tokenKey, JSON.stringify(tokenData));
|
||||
await redis.expire(tokenKey, config.auth.passwordTokenExpiry * 60);
|
||||
|
||||
const verifyUrl = `${config.web.address}/recovery?${new URLSearchParams({
|
||||
userId: user.id,
|
||||
token,
|
||||
}).toString()}`;
|
||||
|
||||
logger.info(`Password reset sent to ${user.username} (${user.id}): ${verifyUrl}`);
|
||||
|
||||
await sendEmails([{
|
||||
template: 'recovery',
|
||||
to: user.email,
|
||||
subject: 'Reset your traxxx password',
|
||||
props: {
|
||||
user,
|
||||
verifyUrl,
|
||||
},
|
||||
}]);
|
||||
}
|
||||
|
||||
export async function resetPassword(userId, emailToken, newPassword) {
|
||||
if (!userId) {
|
||||
throw new HttpError('User ID not provided', 400);
|
||||
}
|
||||
|
||||
if (!emailToken) {
|
||||
throw new HttpError('Token not provided', 400);
|
||||
}
|
||||
|
||||
if (!newPassword) {
|
||||
throw new HttpError('New password not provided', 400);
|
||||
}
|
||||
|
||||
const tokenKey = `traxxx:token_password:${userId}`;
|
||||
const tokenEntry = await redis.getDel(tokenKey);
|
||||
|
||||
if (!tokenEntry) {
|
||||
throw new HttpError('Invalid recovery link', 401);
|
||||
}
|
||||
|
||||
const { token } = JSON.parse(tokenEntry);
|
||||
|
||||
if (!token
|
||||
|| emailToken.length !== token.length
|
||||
|| !crypto.timingSafeEqual(Buffer.from(emailToken, 'hex'), Buffer.from(token, 'hex'))) {
|
||||
throw new HttpError('Invalid recovery link', 401);
|
||||
}
|
||||
|
||||
const { storedPassword } = await hashPassword(newPassword);
|
||||
|
||||
const updated = await knex('users')
|
||||
.where('id', userId)
|
||||
.update('password', storedPassword);
|
||||
|
||||
if (updated === 0) {
|
||||
throw new HttpError('Password reset failed because the user could not be found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
export async function signup(credentials, userIp) {
|
||||
if (!config.auth.signup) {
|
||||
throw new HttpError('Sign-ups are currently disabled', 503);
|
||||
@@ -256,43 +453,6 @@ async function updateEmail(email, reqUser) {
|
||||
await sendEmailVerification(reqUser, email, 'change');
|
||||
}
|
||||
|
||||
export async function verifyEmail(userId, emailToken) {
|
||||
if (!userId) {
|
||||
throw new HttpError('User ID not provided', 400);
|
||||
}
|
||||
|
||||
if (!emailToken) {
|
||||
throw new HttpError('Token not provided', 400);
|
||||
}
|
||||
|
||||
const tokenKey = `traxxx:token_email:${userId}`;
|
||||
const tokenEntry = await redis.getDel(tokenKey);
|
||||
|
||||
if (!tokenEntry) {
|
||||
throw new HttpError('Invalid verification link', 401);
|
||||
}
|
||||
|
||||
const { token, email } = JSON.parse(tokenEntry);
|
||||
|
||||
if (!token
|
||||
|| !email
|
||||
|| emailToken.length !== token.length
|
||||
|| !crypto.timingSafeEqual(Buffer.from(emailToken, 'hex'), Buffer.from(token, 'hex'))) {
|
||||
throw new HttpError('Invalid verification link', 401);
|
||||
}
|
||||
|
||||
const updated = await knex('users')
|
||||
.where('id', userId)
|
||||
.update({
|
||||
email,
|
||||
email_verified: true,
|
||||
});
|
||||
|
||||
if (updated === 0) {
|
||||
throw new HttpError('E-mail verification failed because the user could not be found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUser({ credentials, password }, reqUser, reqSession) {
|
||||
if (!reqUser) {
|
||||
throw new HttpError('You are not logged in', 401);
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
flushUserKeys,
|
||||
login,
|
||||
removeUserKey,
|
||||
resetPassword,
|
||||
sendEmailVerification,
|
||||
sendPasswordReset,
|
||||
signup,
|
||||
updateSettings,
|
||||
updateUser,
|
||||
@@ -184,6 +186,20 @@ async function verifyEmailApi(req, res) {
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
async function sendPasswordResetApi(req, res) {
|
||||
await sendPasswordReset(req.body.credential);
|
||||
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
async function resetPasswordApi(req, res) {
|
||||
const userId = Number(req.body.userId) || null;
|
||||
|
||||
await resetPassword(userId, req.body.token, req.body.password);
|
||||
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
async function updateSettingsApi(req, res) {
|
||||
const updatedSettings = await updateSettings(req.body, req.user);
|
||||
|
||||
@@ -234,6 +250,8 @@ authRouter.patch('/api/me/settings', updateSettingsApi);
|
||||
// EMAIL VERIFICATION
|
||||
authRouter.post('/api/me/email_verification', sendEmailVerificationApi);
|
||||
authRouter.post('/api/emails/verify', verifyEmailApi); // usable without session
|
||||
authRouter.post('/api/passwords/request', sendPasswordResetApi);
|
||||
authRouter.post('/api/passwords/reset', resetPasswordApi);
|
||||
|
||||
// API KEYS
|
||||
authRouter.get('/api/me/keys', fetchUserKeysApi);
|
||||
|
||||
Reference in New Issue
Block a user