Changed sign-up flow to require e-mail verification up front, preventing account existence leakage.
This commit is contained in:
@@ -49,7 +49,7 @@ const mockUser = {
|
|||||||
<template>
|
<template>
|
||||||
<Html>
|
<Html>
|
||||||
<Body style="font-family: sans-serif;">
|
<Body style="font-family: sans-serif;">
|
||||||
<Preview>Your traxxx password has been changed.</Preview>
|
<Preview>Your traxxx password has been changed</Preview>
|
||||||
|
|
||||||
<Container>
|
<Container>
|
||||||
<Img
|
<Img
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const mockUser = {
|
|||||||
<template>
|
<template>
|
||||||
<Html>
|
<Html>
|
||||||
<Body style="font-family: sans-serif;">
|
<Body style="font-family: sans-serif;">
|
||||||
<Preview>Recovering your traxxx password.</Preview>
|
<Preview>Reset your traxxx password</Preview>
|
||||||
|
|
||||||
<Container>
|
<Container>
|
||||||
<Img
|
<Img
|
||||||
|
|||||||
80
components/emails/signup.vue
Normal file
80
components/emails/signup.vue
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<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/verify?userId=0&token=00000000000000000000000000000000',
|
||||||
|
},
|
||||||
|
trigger: {
|
||||||
|
type: String,
|
||||||
|
default: 'change',
|
||||||
|
},
|
||||||
|
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>Sign up for traxxx</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,</Heading>
|
||||||
|
|
||||||
|
<Text style="font-size: 16px;">
|
||||||
|
<strong>Thank you for signing up for traxxx!</strong>
|
||||||
|
<br>
|
||||||
|
Please complete your registration 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>Create my account</strong></Button>
|
||||||
|
|
||||||
|
<Text style="font-size: 16px; margin: 20px 0;">The link will expire in {{ config.auth.signupTokenExpiry }} minutes. If you did not sign up, you can safely ignore this e-mail.</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
</template>
|
||||||
62
components/emails/unavailable.vue
Normal file
62
components/emails/unavailable.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Heading,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Preview,
|
||||||
|
Text,
|
||||||
|
} from '@vue-email/components';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
user: {
|
||||||
|
type: Object,
|
||||||
|
default: () => mockUser,
|
||||||
|
},
|
||||||
|
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>Someone tried to use this e-mail address for traxxx</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>
|
||||||
|
|
||||||
|
<Heading
|
||||||
|
as="h3"
|
||||||
|
style="margin-top: 30px;"
|
||||||
|
>Someone tried to assign this e-mail address to a traxxx account.</Heading>
|
||||||
|
|
||||||
|
<Text style="font-size: 16px; margin: 20px 0;">If this was you with this account, your e-mail address is already set correctly. If this was you with another account, you will need to use another e-mail address. If this was not you, you can safely ignore this e-mail.</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
</template>
|
||||||
@@ -46,7 +46,7 @@ const mockUser = {
|
|||||||
<template>
|
<template>
|
||||||
<Html>
|
<Html>
|
||||||
<Body style="font-family: sans-serif;">
|
<Body style="font-family: sans-serif;">
|
||||||
<Preview>Please verify your traxxx e-mail address.</Preview>
|
<Preview>Please verify your traxxx e-mail address</Preview>
|
||||||
|
|
||||||
<Container>
|
<Container>
|
||||||
<Img
|
<Img
|
||||||
@@ -62,8 +62,7 @@ const mockUser = {
|
|||||||
>Hi, {{ user.username }}</Heading>
|
>Hi, {{ user.username }}</Heading>
|
||||||
|
|
||||||
<Text>
|
<Text>
|
||||||
<strong v-if="trigger === 'signup'">Thank you for creating your traxxx account!</strong>
|
<strong>You requested to {{ trigger === 'verify' ? 'verify' : 'change' }} the e-mail address for your traxxx account.</strong>
|
||||||
<strong v-else>You requested to {{ trigger === 'verify' ? 'verify' : 'change' }} the e-mail address for your traxxx account.</strong>
|
|
||||||
<br>
|
<br>
|
||||||
Please confirm your e-mail address by clicking the button below:
|
Please confirm your e-mail address by clicking the button below:
|
||||||
</Text>
|
</Text>
|
||||||
@@ -75,10 +74,7 @@ const mockUser = {
|
|||||||
><strong>Verify e-mail address</strong></Button>
|
><strong>Verify e-mail address</strong></Button>
|
||||||
|
|
||||||
<Text style="font-size: 16px; margin: 20px 0;">
|
<Text style="font-size: 16px; margin: 20px 0;">
|
||||||
The link will expire in {{ config.auth.emailTokenExpiry }} minutes, or when you request an(other) e-mail change or verification.
|
The link will expire in {{ config.auth.emailTokenExpiry }} minutes, or when you request an(other) e-mail change or verification. If you did not request this {{ trigger === 'verify' ? 'verification' : 'change' }}, you can safely ignore this e-mail.</Text>
|
||||||
<template v-if="trigger === 'signup'">If you did not sign up, you can safely ignore this e-mail.</template>
|
|
||||||
<template v-else>If you did not request this {{ trigger === 'verify' ? 'verification' : 'change' }}, you can safely ignore this e-mail.</template>
|
|
||||||
</Text>
|
|
||||||
</Container>
|
</Container>
|
||||||
</Body>
|
</Body>
|
||||||
</Html>
|
</Html>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const saving = ref(false);
|
|||||||
const submitted = ref(false);
|
const submitted = ref(false);
|
||||||
const verifying = ref(false);
|
const verifying = ref(false);
|
||||||
const verified = ref(false);
|
const verified = ref(false);
|
||||||
|
const feedback = ref(null);
|
||||||
|
|
||||||
function getFeedback() {
|
function getFeedback() {
|
||||||
if (credentials.email && Object.entries(credentials).some(([key, value]) => key !== 'email' && value !== null)) {
|
if (credentials.email && Object.entries(credentials).some(([key, value]) => key !== 'email' && value !== null)) {
|
||||||
@@ -38,13 +39,16 @@ function getFeedback() {
|
|||||||
|
|
||||||
async function updateAccount() {
|
async function updateAccount() {
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
|
feedback.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const feedback = getFeedback();
|
||||||
|
|
||||||
await patch('/me', {
|
await patch('/me', {
|
||||||
credentials,
|
credentials,
|
||||||
password: currentPassword.value,
|
password: currentPassword.value,
|
||||||
}, {
|
}, {
|
||||||
successFeedback: getFeedback(),
|
successFeedback: feedback,
|
||||||
errorFeedback: 'Failed to update account',
|
errorFeedback: 'Failed to update account',
|
||||||
appendErrorMessage: true,
|
appendErrorMessage: true,
|
||||||
});
|
});
|
||||||
@@ -54,6 +58,7 @@ async function updateAccount() {
|
|||||||
credentials.password = null;
|
credentials.password = null;
|
||||||
|
|
||||||
submitted.value = true;
|
submitted.value = true;
|
||||||
|
feedback.value = feedback;
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
@@ -175,7 +180,7 @@ async function verifyEmail() {
|
|||||||
<strong
|
<strong
|
||||||
v-else-if="submitted"
|
v-else-if="submitted"
|
||||||
class="success"
|
class="success"
|
||||||
>{{ getFeedback() }}</strong>
|
>{{ feedback }}</strong>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ module.exports = {
|
|||||||
tokenCooldown: 10, // minutes, how often a token can be requested
|
tokenCooldown: 10, // minutes, how often a token can be requested
|
||||||
emailTokenExpiry: 120, // minutes
|
emailTokenExpiry: 120, // minutes
|
||||||
passwordTokenExpiry: 120, // minutes
|
passwordTokenExpiry: 120, // minutes
|
||||||
|
signupTokenExpiry: 120, // minutes
|
||||||
},
|
},
|
||||||
bans: {
|
bans: {
|
||||||
defaultExpiry: 60 * 24 * 3, // in minutes, 3 days
|
defaultExpiry: 60 * 24 * 3, // in minutes, 3 days
|
||||||
|
|||||||
@@ -3,32 +3,29 @@ import VueHCaptcha from '@hcaptcha/vue3-hcaptcha';
|
|||||||
import { inject, onMounted, ref } from 'vue';
|
import { inject, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import Password from '#/components/form/password.vue';
|
import Password from '#/components/form/password.vue';
|
||||||
|
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||||
|
|
||||||
import { post } from '#/src/api.js';
|
import { post } from '#/src/api.js';
|
||||||
import navigate from '#/src/navigate.js';
|
import navigate from '#/src/navigate.js';
|
||||||
|
|
||||||
const pageContext = inject('pageContext');
|
const pageContext = inject('pageContext');
|
||||||
const { user, env } = pageContext;
|
const { user, env } = pageContext;
|
||||||
|
const { token } = pageContext.urlParsed.search;
|
||||||
|
|
||||||
const username = ref('');
|
const username = ref('');
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
const passwordConfirm = ref('');
|
|
||||||
|
|
||||||
const errorMsg = ref(null);
|
const errorMsg = ref(null);
|
||||||
const submitted = ref(false);
|
const submitted = ref(false);
|
||||||
const userInput = ref(null);
|
const success = ref(false);
|
||||||
|
const mainInput = ref(null);
|
||||||
const captcha = ref(null);
|
const captcha = ref(null);
|
||||||
|
|
||||||
async function signup() {
|
async function signup() {
|
||||||
errorMsg.value = null;
|
errorMsg.value = null;
|
||||||
submitted.value = true;
|
submitted.value = true;
|
||||||
|
|
||||||
if (password.value !== passwordConfirm.value) {
|
|
||||||
errorMsg.value = 'Passwords do not match';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (env.captcha.enabled && !captcha.value) {
|
if (env.captcha.enabled && !captcha.value) {
|
||||||
errorMsg.value = 'Please complete the CAPTCHA';
|
errorMsg.value = 'Please complete the CAPTCHA';
|
||||||
return;
|
return;
|
||||||
@@ -36,11 +33,14 @@ async function signup() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const newUser = await post('/users', {
|
const newUser = await post('/users', {
|
||||||
|
token,
|
||||||
username: username.value,
|
username: username.value,
|
||||||
email: email.value,
|
|
||||||
password: password.value,
|
password: password.value,
|
||||||
redirect: pageContext.urlParsed.search.r,
|
redirect: pageContext.urlParsed.search.r,
|
||||||
captcha: captcha.value,
|
captcha: captcha.value,
|
||||||
|
}, {
|
||||||
|
successFeedback: 'Sign-up successful!',
|
||||||
|
appendErrorMessage: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
navigate(`/user/${newUser.username}`, null, { redirect: true });
|
navigate(`/user/${newUser.username}`, null, { redirect: true });
|
||||||
@@ -53,8 +53,30 @@ async function signup() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function request() {
|
||||||
|
errorMsg.value = null;
|
||||||
|
submitted.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await post('/signups', {
|
||||||
|
email: email.value,
|
||||||
|
}, {
|
||||||
|
successFeedback: 'Sign-up requested, please check your inbox',
|
||||||
|
appendErrorMessage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
success.value = true;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
errorMsg.value = error.message;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitted.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
userInput.value.focus();
|
mainInput.value.focus();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -88,7 +110,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
v-else
|
v-else-if="token"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
class="login-panel"
|
class="login-panel"
|
||||||
@submit.prevent="signup"
|
@submit.prevent="signup"
|
||||||
@@ -99,34 +121,19 @@ onMounted(() => {
|
|||||||
>{{ errorMsg }}</div>
|
>{{ errorMsg }}</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
ref="userInput"
|
ref="mainInput"
|
||||||
v-model="username"
|
v-model="username"
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
class="input"
|
class="input"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
|
|
||||||
<input
|
|
||||||
v-model="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="E-mail"
|
|
||||||
class="input"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
|
|
||||||
<Password
|
<Password
|
||||||
:password="password"
|
:password="password"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
@input="(newPassword) => password = newPassword"
|
@input="(newPassword) => password = newPassword"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Password
|
|
||||||
:password="passwordConfirm"
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="Confirm password"
|
|
||||||
@input="(newPassword) => passwordConfirm = newPassword"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<VueHCaptcha
|
<VueHCaptcha
|
||||||
v-if="env.captcha.enabled"
|
v-if="env.captcha.enabled"
|
||||||
:sitekey="env.captcha.siteKey"
|
:sitekey="env.captcha.siteKey"
|
||||||
@@ -135,15 +142,53 @@ onMounted(() => {
|
|||||||
@expired="captcha = null"
|
@expired="captcha = null"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Ellipsis v-if="submitted" />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
v-else
|
||||||
class="button button-submit"
|
class="button button-submit"
|
||||||
:disabled="submitted"
|
:disabled="submitted"
|
||||||
>Sign up</button>
|
>Sign up</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<a
|
<form
|
||||||
href="/login"
|
v-else
|
||||||
class="link"
|
autocomplete="off"
|
||||||
>I already have an account</a>
|
class="login-panel"
|
||||||
|
@submit.prevent="request"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="success"
|
||||||
|
class="success"
|
||||||
|
>
|
||||||
|
<strong class="success-heading">Thank you for signing up!</strong>
|
||||||
|
Please check your e-mail inbox to continue.
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<input
|
||||||
|
ref="mainInput"
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="E-mail"
|
||||||
|
class="input"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
|
||||||
|
<Ellipsis v-if="submitted" />
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<button
|
||||||
|
class="button button-submit"
|
||||||
|
:disabled="submitted"
|
||||||
|
>Send signup e-mail</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="link login"
|
||||||
|
>I already have an account</a>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -179,7 +224,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
margin-top: .5rem;
|
margin-top: 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,4 +280,18 @@ onMounted(() => {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-heading {
|
||||||
|
display: block;
|
||||||
|
color: var(--primary);
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-container {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
118
src/auth.js
118
src/auth.js
@@ -6,7 +6,7 @@ import { shapes } from '@dicebear/collection';
|
|||||||
import { createAvatar } from '@dicebear/core';
|
import { createAvatar } from '@dicebear/core';
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import config from 'config';
|
import config from 'config';
|
||||||
import { verify } from 'hcaptcha';
|
import { verify as verifyCaptcha } from 'hcaptcha';
|
||||||
|
|
||||||
import slugify from '../utils/slugify.js';
|
import slugify from '../utils/slugify.js';
|
||||||
import { sendEmails } from './email.js';
|
import { sendEmails } from './email.js';
|
||||||
@@ -332,11 +332,78 @@ export async function resetPassword(userId, emailToken, newPassword) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendSignup(email) {
|
||||||
|
if (!config.auth.signup || !config.email.enabled) {
|
||||||
|
throw new HttpError('Sign-ups are disabled', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
throw new HttpError('No e-mail address provided', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tokenCooldown(email);
|
||||||
|
|
||||||
|
const user = await fetchUser(email, {
|
||||||
|
email: true,
|
||||||
|
includePrivate: true,
|
||||||
|
throwError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
await sendEmails([{
|
||||||
|
template: 'unavailable',
|
||||||
|
to: email,
|
||||||
|
subject: 'Someone tried to use your e-mail address for traxxx',
|
||||||
|
props: {
|
||||||
|
user,
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
|
||||||
|
logger.info(`Duplicate signup with '${email}' attempted, user e-mailed`);
|
||||||
|
|
||||||
|
// we don't throw an error because the requesting user shouldn't know whether the account exists or not
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
const tokenKey = `traxxx:token_signup:${token}`;
|
||||||
|
|
||||||
|
const tokenData = {
|
||||||
|
email,
|
||||||
|
};
|
||||||
|
|
||||||
|
await redis.set(tokenKey, JSON.stringify(tokenData));
|
||||||
|
await redis.expire(tokenKey, config.auth.signupTokenExpiry * 60);
|
||||||
|
|
||||||
|
const verifyUrl = `${config.web.address}/signup?${new URLSearchParams({
|
||||||
|
token,
|
||||||
|
}).toString()}`;
|
||||||
|
|
||||||
|
logger.info(`Signup e-mail sent to ${email}: ${verifyUrl}`);
|
||||||
|
|
||||||
|
await sendEmails([{
|
||||||
|
template: 'signup',
|
||||||
|
to: email,
|
||||||
|
subject: 'Sign up for traxxx',
|
||||||
|
props: {
|
||||||
|
verifyUrl,
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!credentials.token) {
|
||||||
|
throw new HttpError('Token required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credentials.password?.length < 3) {
|
||||||
|
throw new HttpError('Password must be 3 characters or longer', 400);
|
||||||
|
}
|
||||||
|
|
||||||
const curatedUsername = credentials.username.trim();
|
const curatedUsername = credentials.username.trim();
|
||||||
|
|
||||||
if (!curatedUsername) {
|
if (!curatedUsername) {
|
||||||
@@ -355,30 +422,34 @@ export async function signup(credentials, userIp) {
|
|||||||
throw new HttpError('Username contains invalid characters', 400);
|
throw new HttpError('Username contains invalid characters', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!credentials.email) {
|
const existingUser = await knex('users')
|
||||||
throw new HttpError('E-mail required', 400);
|
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
|
||||||
}
|
.first();
|
||||||
|
|
||||||
if (credentials.password?.length < 3) {
|
if (existingUser) {
|
||||||
throw new HttpError('Password must be 3 characters or longer', 400);
|
throw new HttpError('Username already in use', 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.auth.captcha.enabled) {
|
if (config.auth.captcha.enabled) {
|
||||||
const captchaVerification = await verify(config.auth.captcha.secretKey, credentials.captcha);
|
const captchaVerification = await verifyCaptcha(config.auth.captcha.secretKey, credentials.captcha);
|
||||||
|
|
||||||
if (!captchaVerification.success) {
|
if (!captchaVerification.success) {
|
||||||
logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${credentials.email}, ${userIp})`);
|
logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${userIp})`);
|
||||||
throw new HttpError('Invalid CAPTCHA', 400);
|
throw new HttpError('Invalid CAPTCHA', 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingUser = await knex('users')
|
const tokenKey = `traxxx:token_signup:${credentials.token}`;
|
||||||
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
|
const tokenEntry = await redis.getDel(tokenKey);
|
||||||
.orWhere(knex.raw('lower(email)'), credentials.email.toLowerCase())
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (existingUser) {
|
if (!tokenEntry) {
|
||||||
throw new HttpError('Username or e-mail already in use', 409);
|
throw new HttpError('Invalid sign-up link', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email } = JSON.parse(tokenEntry);
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
throw new HttpError('Invalid sign-up link', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { storedPassword } = await hashPassword(credentials.password);
|
const { storedPassword } = await hashPassword(credentials.password);
|
||||||
@@ -386,8 +457,9 @@ export async function signup(credentials, userIp) {
|
|||||||
const [{ id: userId }] = await knex('users')
|
const [{ id: userId }] = await knex('users')
|
||||||
.insert({
|
.insert({
|
||||||
username: curatedUsername,
|
username: curatedUsername,
|
||||||
email: credentials.email,
|
email,
|
||||||
password: storedPassword,
|
password: storedPassword,
|
||||||
|
email_verified: true,
|
||||||
})
|
})
|
||||||
.returning('id');
|
.returning('id');
|
||||||
|
|
||||||
@@ -399,7 +471,7 @@ export async function signup(credentials, userIp) {
|
|||||||
primary: true,
|
primary: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Signup from '${curatedUsername}' (${userId}, ${credentials.email}, ${userIp})`);
|
logger.info(`Signup from '${curatedUsername}' (${userId}, ${email}, ${userIp})`);
|
||||||
|
|
||||||
await generateAvatar({
|
await generateAvatar({
|
||||||
id: userId,
|
id: userId,
|
||||||
@@ -410,7 +482,8 @@ export async function signup(credentials, userIp) {
|
|||||||
includePrivate: true,
|
includePrivate: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
await sendEmailVerification(user, null, 'signup');
|
// no longer needed with token-first signup flow
|
||||||
|
// await sendEmailVerification(user, null, 'signup');
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
@@ -447,7 +520,16 @@ async function updateEmail(email, reqUser) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
throw new HttpError('This e-mail address is already in use', 409);
|
await sendEmails([{
|
||||||
|
template: 'unavailable',
|
||||||
|
to: email,
|
||||||
|
subject: 'Someone tried to use your e-mail address for traxxx',
|
||||||
|
props: {
|
||||||
|
user, // not reqUser, we don't want to leak details about the requester
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendEmailVerification(reqUser, email, 'change');
|
await sendEmailVerification(reqUser, email, 'change');
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ export async function fetchStashByUsernameAndSlug(usernameOrId, stashSlug, sessi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchUserStashes(usernameOrId, reqUser) {
|
export async function fetchUserStashes(usernameOrId, reqUser) {
|
||||||
|
if (!usernameOrId) {
|
||||||
|
throw new HttpError(`No username or user ID provided`, 400);
|
||||||
|
}
|
||||||
|
|
||||||
const userId = typeof usernameOrId === 'number'
|
const userId = typeof usernameOrId === 'number'
|
||||||
? usernameOrId
|
? usernameOrId
|
||||||
: await knex('users')
|
: await knex('users')
|
||||||
@@ -134,7 +138,7 @@ export async function fetchUserStashes(usernameOrId, reqUser) {
|
|||||||
.then((user) => user?.id);
|
.then((user) => user?.id);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new HttpError(`Could not find user '${usernameOrId}'`);
|
throw new HttpError(`Could not find user '${usernameOrId}'`, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stashes = await knex('stashes')
|
const stashes = await knex('stashes')
|
||||||
|
|||||||
10
src/users.js
10
src/users.js
@@ -56,6 +56,14 @@ function whereUser(builder, userId, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchUser(userId, options = {}, reqUser) {
|
export async function fetchUser(userId, options = {}, reqUser) {
|
||||||
|
if (!userId) {
|
||||||
|
if (options.raw) {
|
||||||
|
return { user: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const user = await knex('users')
|
const user = await knex('users')
|
||||||
.select(knex.raw('users.*, users_roles.abilities as role_abilities'))
|
.select(knex.raw('users.*, users_roles.abilities as role_abilities'))
|
||||||
.modify((builder) => whereUser(builder, userId, options))
|
.modify((builder) => whereUser(builder, userId, options))
|
||||||
@@ -119,8 +127,6 @@ export async function removeTemplate(templateId, reqUser) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createBan(ban, reqUser) {
|
export async function createBan(ban, reqUser) {
|
||||||
console.log(ban);
|
|
||||||
|
|
||||||
if (reqUser.role !== 'admin') {
|
if (reqUser.role !== 'admin') {
|
||||||
throw new HttpError('You do not have sufficient privileges to set a ban', 403);
|
throw new HttpError('You do not have sufficient privileges to set a ban', 403);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
resetPassword,
|
resetPassword,
|
||||||
sendEmailVerification,
|
sendEmailVerification,
|
||||||
sendPasswordReset,
|
sendPasswordReset,
|
||||||
|
sendSignup,
|
||||||
signup,
|
signup,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
updateUser,
|
updateUser,
|
||||||
@@ -23,25 +24,20 @@ import {
|
|||||||
fetchUser,
|
fetchUser,
|
||||||
} from '../users.js';
|
} from '../users.js';
|
||||||
|
|
||||||
// changes every time the process restarts, so a deploy invalidates all cached session users
|
|
||||||
// even if they're within the staleness window below
|
|
||||||
const sessionStaleThreshold = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
// spread into a new object rather than mutating the user we're about to hand back to the
|
// spread into a new object rather than mutating the user we're about to hand back to the
|
||||||
// client (loginApi/signupApi send this straight to the browser) — runtimeId/refreshedAt are
|
// client (loginApi/signupApi send this straight to the browser) — runtimeId/refreshedAt are
|
||||||
// session bookkeeping only, they shouldn't leak into the API response
|
// session bookkeeping only, they shouldn't leak into the API response
|
||||||
function toSession(user) {
|
function toSession(user) {
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
runtimeId,
|
runtimeId,
|
||||||
refreshedAt: Date.now(),
|
refreshedAt: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSessionStale(sessionUser) {
|
|
||||||
return sessionUser.runtimeId !== runtimeId || Date.now() - sessionUser.refreshedAt > sessionStaleThreshold;
|
|
||||||
}
|
|
||||||
|
|
||||||
// concurrent requests for the same user (e.g. the burst of asset/API requests a single page
|
// concurrent requests for the same user (e.g. the burst of asset/API requests a single page
|
||||||
// load fires) share one in-flight refresh instead of each independently hitting the DB
|
// load fires) share one in-flight refresh instead of each independently hitting the DB
|
||||||
const pendingSessionRefreshes = new Map();
|
const pendingSessionRefreshes = new Map();
|
||||||
@@ -90,16 +86,24 @@ function getIp(req) {
|
|||||||
return expandedIp.addressStart?.addressMinusSuffix || null;
|
return expandedIp.addressStart?.addressMinusSuffix || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// changes every time the process restarts, so a deploy invalidates all cached session users
|
||||||
|
// even if they're within the staleness window below
|
||||||
|
const sessionStaleThreshold = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
function isSessionStale(sessionUser) {
|
||||||
|
return !!sessionUser && (sessionUser.runtimeId !== runtimeId || Date.now() - sessionUser.refreshedAt > sessionStaleThreshold);
|
||||||
|
}
|
||||||
|
|
||||||
export async function setUserApi(req, res, next) {
|
export async function setUserApi(req, res, next) {
|
||||||
const ip = getIp(req);
|
const ip = getIp(req);
|
||||||
|
|
||||||
req.userIp = ip;
|
req.userIp = ip;
|
||||||
|
|
||||||
if (req.session.user) {
|
if (isSessionStale(req.session.user)) {
|
||||||
if (isSessionStale(req.session.user)) {
|
req.session.user = await refreshSessionUser(req.session.user);
|
||||||
req.session.user = await refreshSessionUser(req.session.user);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
if (req.session.user) {
|
||||||
req.user = req.session.user;
|
req.user = req.session.user;
|
||||||
req.user.ip = ip;
|
req.user.ip = ip;
|
||||||
}
|
}
|
||||||
@@ -110,10 +114,12 @@ export async function setUserApi(req, res, next) {
|
|||||||
export async function updateSessionUser(req) {
|
export async function updateSessionUser(req) {
|
||||||
const user = toSession(await fetchUser(req.session.user.id, {}, req.session.user));
|
const user = toSession(await fetchUser(req.session.user.id, {}, req.session.user));
|
||||||
|
|
||||||
req.session.user = user;
|
if (user) {
|
||||||
|
req.session.user = user;
|
||||||
|
|
||||||
req.user = user;
|
req.user = user;
|
||||||
req.user.ip = req.userIp;
|
req.user.ip = req.userIp;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginApi(req, res) {
|
export async function loginApi(req, res) {
|
||||||
@@ -133,6 +139,12 @@ export async function logoutApi(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendSignupApi(req, res) {
|
||||||
|
await sendSignup(req.body.email, req.userIp);
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
}
|
||||||
|
|
||||||
export async function signupApi(req, res) {
|
export async function signupApi(req, res) {
|
||||||
const user = await signup(req.body, req.userIp);
|
const user = await signup(req.body, req.userIp);
|
||||||
|
|
||||||
@@ -241,6 +253,7 @@ authRouter.post('/api/session', loginApi);
|
|||||||
authRouter.delete('/api/session', logoutApi);
|
authRouter.delete('/api/session', logoutApi);
|
||||||
|
|
||||||
// USERS
|
// USERS
|
||||||
|
authRouter.post('/api/signups', sendSignupApi);
|
||||||
authRouter.post('/api/users', signupApi);
|
authRouter.post('/api/users', signupApi);
|
||||||
|
|
||||||
// SETTINGS
|
// SETTINGS
|
||||||
|
|||||||
Reference in New Issue
Block a user