Added password recovery and login attempt guards.
This commit is contained in:
79
components/emails/recovery.vue
Normal file
79
components/emails/recovery.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Heading,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Preview,
|
||||||
|
Text,
|
||||||
|
} from '@vue-email/components';
|
||||||
|
|
||||||
|
import config from 'config';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
user: {
|
||||||
|
type: Object,
|
||||||
|
default: () => mockUser,
|
||||||
|
},
|
||||||
|
verifyUrl: {
|
||||||
|
type: String,
|
||||||
|
default: 'http://localhost:5100/recovery?userId=0&token=00000000000000000000000000000000',
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
type: String,
|
||||||
|
default: 'http://localhost:5100',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
|
||||||
|
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
|
||||||
|
// <script setup> itself, since that block gets hoisted out of the setup() function.
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
username: 'DebuggingLibrarian',
|
||||||
|
email: 'debug@alerts.traxxx.me',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Html>
|
||||||
|
<Body style="font-family: sans-serif;">
|
||||||
|
<Preview>Recovering your traxxx password.</Preview>
|
||||||
|
|
||||||
|
<Container>
|
||||||
|
<Img
|
||||||
|
:src="`${address}/img/logo.png`"
|
||||||
|
width="150"
|
||||||
|
alt="traxxx"
|
||||||
|
style="margin: 15px auto 25px auto;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Heading
|
||||||
|
as="h3"
|
||||||
|
style="color: #f65596; margin: 0 auto 0 auto;"
|
||||||
|
>Hi, {{ user.username }}</Heading>
|
||||||
|
|
||||||
|
<Text>
|
||||||
|
<strong>You requested to reset the password for your traxxx account.</strong>
|
||||||
|
<br>
|
||||||
|
Please set a new password by clicking the button below:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
:href="verifyUrl"
|
||||||
|
target="_blank"
|
||||||
|
style="background: #f65596; color: #fff; font-size: 16px; padding: 1rem 1.5rem; border-radius: .5rem; margin: 1rem 0;"
|
||||||
|
><strong>Reset password</strong></Button>
|
||||||
|
|
||||||
|
<Text style="font-size: 16px; margin: 20px 0;">
|
||||||
|
The link will expire in {{ config.auth.passwordTokenExpiry }} minutes, or when you request another password reset.
|
||||||
|
If you did not request a password reset, you can safely ignore this e-mail.
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
</template>
|
||||||
@@ -154,7 +154,13 @@ module.exports = {
|
|||||||
siteKey: '10000000-ffff-ffff-ffff-000000000001',
|
siteKey: '10000000-ffff-ffff-ffff-000000000001',
|
||||||
secretKey: '0x0000000000000000000000000000000000000000',
|
secretKey: '0x0000000000000000000000000000000000000000',
|
||||||
},
|
},
|
||||||
|
loginCredentialAttempts: 5,
|
||||||
|
loginCredentialCooldown: 10, // minutes,
|
||||||
|
loginIpAttempts: 20, // shared IPs might result in more non-malicious failed attempt
|
||||||
|
loginIpCooldown: 10, // minutes
|
||||||
|
tokenCooldown: 10, // minutes, how often a token can be requested
|
||||||
emailTokenExpiry: 120, // minutes
|
emailTokenExpiry: 120, // minutes
|
||||||
|
passwordTokenExpiry: 120, // minutes
|
||||||
},
|
},
|
||||||
bans: {
|
bans: {
|
||||||
defaultExpiry: 60 * 24 * 3, // in minutes, 3 days
|
defaultExpiry: 60 * 24 * 3, // in minutes, 3 days
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
export default {
|
export default {
|
||||||
meta: {
|
meta: {
|
||||||
|
title: {
|
||||||
|
env: {
|
||||||
|
server: true,
|
||||||
|
client: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
data: {
|
data: {
|
||||||
env: {
|
env: {
|
||||||
server: true,
|
server: true,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export async function onBeforeRender(pageContext) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
pageContext: {
|
pageContext: {
|
||||||
title: 'actors',
|
|
||||||
pageProps: {
|
pageProps: {
|
||||||
actors,
|
actors,
|
||||||
countries,
|
countries,
|
||||||
|
|||||||
1
pages/actors/+title.js
Normal file
1
pages/actors/+title.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default 'Actors';
|
||||||
@@ -9,10 +9,7 @@ import navigate from '#/src/navigate.js';
|
|||||||
const pageContext = inject('pageContext');
|
const pageContext = inject('pageContext');
|
||||||
const user = pageContext.user;
|
const user = pageContext.user;
|
||||||
const allowSignup = pageContext.env.allowSignup;
|
const allowSignup = pageContext.env.allowSignup;
|
||||||
// const allowRecovery = pageContext.env.emailEnabled;
|
const allowRecovery = pageContext.env.emailEnabled;
|
||||||
const allowRecovery = false; // TODO: implement
|
|
||||||
|
|
||||||
console.log(pageContext.env);
|
|
||||||
|
|
||||||
const username = ref('');
|
const username = ref('');
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
|
|||||||
1
pages/auth/login/+title.js
Normal file
1
pages/auth/login/+title.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default 'Login';
|
||||||
218
pages/auth/recovery/+Page.vue
Normal file
218
pages/auth/recovery/+Page.vue
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<script setup>
|
||||||
|
import { inject, ref } from 'vue';
|
||||||
|
|
||||||
|
import Password from '#/components/form/password.vue';
|
||||||
|
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||||
|
|
||||||
|
import { post } from '#/src/api.js';
|
||||||
|
|
||||||
|
const pageContext = inject('pageContext');
|
||||||
|
const { userId, token } = pageContext.urlParsed.search;
|
||||||
|
const allowRecovery = pageContext.env.emailEnabled;
|
||||||
|
|
||||||
|
const credential = ref('');
|
||||||
|
|
||||||
|
const submitted = ref(false);
|
||||||
|
const success = ref(false);
|
||||||
|
const errorMsg = ref(null);
|
||||||
|
const password = ref(null);
|
||||||
|
|
||||||
|
async function request() {
|
||||||
|
submitted.value = true;
|
||||||
|
errorMsg.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await post('/passwords/request', {
|
||||||
|
credential: credential.value,
|
||||||
|
}, {
|
||||||
|
successFeedback: 'Password reset requested, please check your e-mail inbox',
|
||||||
|
appendErrorMessage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
success.value = true;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
errorMsg.value = error.message;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitted.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reset() {
|
||||||
|
submitted.value = true;
|
||||||
|
errorMsg.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await post('/passwords/reset', {
|
||||||
|
userId,
|
||||||
|
token,
|
||||||
|
password: password.value,
|
||||||
|
}, {
|
||||||
|
successFeedback: 'Password reset successfully',
|
||||||
|
appendErrorMessage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
success.value = true;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
errorMsg.value = error.message;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitted.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="recovery-container">
|
||||||
|
<h3 class="heading">
|
||||||
|
<template v-if="userId && token">Password reset</template>
|
||||||
|
<template v-else>Account recovery</template>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<template v-if="allowRecovery">
|
||||||
|
<strong
|
||||||
|
v-if="errorMsg"
|
||||||
|
class="error"
|
||||||
|
>{{ errorMsg }}</strong>
|
||||||
|
|
||||||
|
<form
|
||||||
|
v-else-if="userId && token"
|
||||||
|
class="login-panel"
|
||||||
|
@submit.prevent="reset"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="success"
|
||||||
|
class="success"
|
||||||
|
>
|
||||||
|
<strong class="success-heading">Password reset successfully</strong>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="link"
|
||||||
|
>Go to login</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<Password
|
||||||
|
:password="password"
|
||||||
|
@input="(newPassword) => password = newPassword"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Ellipsis v-if="submitted" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="button button-submit"
|
||||||
|
>Reset password</button>
|
||||||
|
</template>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form
|
||||||
|
v-else-if="allowRecovery"
|
||||||
|
class="login-panel"
|
||||||
|
@submit.prevent="request"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="success"
|
||||||
|
class="success"
|
||||||
|
>
|
||||||
|
<strong class="success-heading">Password reset requested</strong>
|
||||||
|
If the account exists, instructions have been sent to the associated e-mail address.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<input
|
||||||
|
v-model="credential"
|
||||||
|
placeholder="Username or e-mail"
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
|
|
||||||
|
<Ellipsis v-if="submitted" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="button button-submit"
|
||||||
|
>Request password reset</button>
|
||||||
|
</template>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="error"
|
||||||
|
>Password recovery is currently unavailable</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
text-align: center;
|
||||||
|
margin: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--background-base-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel {
|
||||||
|
width: 20rem;
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 0 1rem 1rem 1rem;
|
||||||
|
border-radius: .5rem;
|
||||||
|
box-shadow: 0 0 3px var(--shadow-weak-30);
|
||||||
|
background: var(--background-base);
|
||||||
|
font-size: 1rem;
|
||||||
|
|
||||||
|
.button {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
margin-top: .5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.link {
|
||||||
|
display: block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-heading {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-container {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem 1rem .5rem 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
pages/auth/recovery/+route.js
Normal file
1
pages/auth/recovery/+route.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default '/recovery';
|
||||||
1
pages/auth/recovery/+title.js
Normal file
1
pages/auth/recovery/+title.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default `Account recovery`;
|
||||||
1
pages/auth/signup/+title.js
Normal file
1
pages/auth/signup/+title.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default 'Sign up';
|
||||||
1
pages/recovery/+title.js
Normal file
1
pages/recovery/+title.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default 'Account recovery';
|
||||||
@@ -270,7 +270,7 @@ function scrollHorizontal(event) {
|
|||||||
gap: .5rem;
|
gap: .5rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: .5rem 0;
|
padding: .5rem 0;
|
||||||
margin-top: .5rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.domain {
|
.domain {
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import { dangerouslySkipEscape, escapeInject } from 'vike/server';
|
|||||||
|
|
||||||
import { createApp } from './app.js';
|
import { createApp } from './app.js';
|
||||||
|
|
||||||
function getTitle(location) {
|
function getTitle(location, pageContext) {
|
||||||
if (!location) {
|
if (!location) {
|
||||||
return config.title;
|
return config.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof location === 'function') {
|
||||||
|
return getTitle(location(pageContext), pageContext);
|
||||||
|
}
|
||||||
|
|
||||||
return `${config.title} - ${location.slice(0, 1).toUpperCase()}${location.slice(1)}`;
|
return `${config.title} - ${location.slice(0, 1).toUpperCase()}${location.slice(1)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,8 +48,7 @@ async function onRenderHtml(pageContext) {
|
|||||||
const appHtml = await renderToString(app);
|
const appHtml = await renderToString(app);
|
||||||
|
|
||||||
// See https://vike.dev/head
|
// See https://vike.dev/head
|
||||||
const { documentProps } = pageContext.exports;
|
const title = getTitle(pageContext.title || pageContext.config.title, pageContext);
|
||||||
const title = getTitle(documentProps?.title || pageContext.title);
|
|
||||||
|
|
||||||
const documentHtml = escapeInject`<!DOCTYPE html>
|
const documentHtml = escapeInject`<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!filters.includeAliased) {
|
if (!filters.includeAliased && !filters.stashId) {
|
||||||
builder.where('alias_for', 0);
|
builder.where('alias_for', 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
236
src/auth.js
236
src/auth.js
@@ -46,19 +46,71 @@ async function generateAvatar(user) {
|
|||||||
logger.verbose(`Generated avatar for '${user.username}' (${user.id})`);
|
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) {
|
export async function login(credentials, userIp) {
|
||||||
if (!config.auth.login) {
|
if (!config.auth.login) {
|
||||||
throw new HttpError('Logins are currently disabled', 503);
|
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(), {
|
const { user } = await fetchUser(credentials.username.trim(), {
|
||||||
email: true,
|
email: true,
|
||||||
raw: 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);
|
throw new HttpError('Username or password incorrect', 401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
await verifyPassword(credentials.password, user.password);
|
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')
|
await knex('users')
|
||||||
.update('last_login', 'NOW()')
|
.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) {
|
export async function sendEmailVerification(user, customEmail, trigger) {
|
||||||
if (!config.email.enabled) {
|
if (!config.email.enabled) {
|
||||||
throw new HttpError('E-mail verification is disabled', 503);
|
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);
|
throw new HttpError('No e-mail address provided', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await tokenCooldown(email);
|
||||||
|
|
||||||
const tokenKey = `traxxx:token_email:${user.id}`;
|
const tokenKey = `traxxx:token_email:${user.id}`;
|
||||||
const token = crypto.randomBytes(32).toString('hex');
|
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) {
|
export async function signup(credentials, userIp) {
|
||||||
if (!config.auth.signup) {
|
if (!config.auth.signup) {
|
||||||
throw new HttpError('Sign-ups are currently disabled', 503);
|
throw new HttpError('Sign-ups are currently disabled', 503);
|
||||||
@@ -256,43 +453,6 @@ async function updateEmail(email, reqUser) {
|
|||||||
await sendEmailVerification(reqUser, email, 'change');
|
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) {
|
export async function updateUser({ credentials, password }, reqUser, reqSession) {
|
||||||
if (!reqUser) {
|
if (!reqUser) {
|
||||||
throw new HttpError('You are not logged in', 401);
|
throw new HttpError('You are not logged in', 401);
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
flushUserKeys,
|
flushUserKeys,
|
||||||
login,
|
login,
|
||||||
removeUserKey,
|
removeUserKey,
|
||||||
|
resetPassword,
|
||||||
sendEmailVerification,
|
sendEmailVerification,
|
||||||
|
sendPasswordReset,
|
||||||
signup,
|
signup,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
updateUser,
|
updateUser,
|
||||||
@@ -184,6 +186,20 @@ async function verifyEmailApi(req, res) {
|
|||||||
res.status(204).send();
|
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) {
|
async function updateSettingsApi(req, res) {
|
||||||
const updatedSettings = await updateSettings(req.body, req.user);
|
const updatedSettings = await updateSettings(req.body, req.user);
|
||||||
|
|
||||||
@@ -234,6 +250,8 @@ authRouter.patch('/api/me/settings', updateSettingsApi);
|
|||||||
// EMAIL VERIFICATION
|
// EMAIL VERIFICATION
|
||||||
authRouter.post('/api/me/email_verification', sendEmailVerificationApi);
|
authRouter.post('/api/me/email_verification', sendEmailVerificationApi);
|
||||||
authRouter.post('/api/emails/verify', verifyEmailApi); // usable without session
|
authRouter.post('/api/emails/verify', verifyEmailApi); // usable without session
|
||||||
|
authRouter.post('/api/passwords/request', sendPasswordResetApi);
|
||||||
|
authRouter.post('/api/passwords/reset', resetPasswordApi);
|
||||||
|
|
||||||
// API KEYS
|
// API KEYS
|
||||||
authRouter.get('/api/me/keys', fetchUserKeysApi);
|
authRouter.get('/api/me/keys', fetchUserKeysApi);
|
||||||
|
|||||||
Reference in New Issue
Block a user