#291 [auth][mfa] MFA

This commit is contained in:
Avris 2021-12-18 18:54:36 +00:00
parent feb08abfe9
commit 113b040f25
25 changed files with 750 additions and 28 deletions

View File

@ -139,6 +139,9 @@
<SocialConnection :provider="provider" :providerOptions="providerOptions" :connection="socialConnections[provider]"
@disconnected="socialConnections[provider] = undefined" @setAvatar="setAvatar"/>
</li>
<li :class="['list-group-item', $user().mfa ? 'profile-current' : '']">
<MfaConnection/>
</li>
</ul>
</Loading>

View File

@ -2,7 +2,7 @@
<section>
<Alert type="danger" :message="error"/>
<div class="card">
<div class="card shadow">
<div class="card-body">
<div v-if="token === null">
<p v-if="$te('user.login.help')">

View File

@ -0,0 +1,104 @@
<template>
<div class="d-flex flex-column flex-md-row justify-content-between align-items-center">
<span class="my-2 me-3 text-nowrap">
<Icon v="mobile"/>
<T>user.mfa.header</T>
</span>
<Spinner v-if="requesting" size="2rem"/>
<template v-else>
<div v-if="$user().mfa">
<span class="badge bg-success">
<Icon v="shield-check"/>
<T>user.mfa.enabled</T>
</span>
<button class="badge bg-light text-dark border" @click.prevent="disable">
<Icon v="unlink"/>
<T>user.mfa.disable</T>
</button>
</div>
<div v-else-if="!secret">
<button class="badge bg-light text-dark border" @click="getLink">
<Icon v="link"/>
<T>user.mfa.enable</T>
</button>
</div>
<div v-else-if="recoveryCodes === null" class="text-center">
<div class="alert alert-info">
<Icon v="info-circle"/>
<T>user.mfa.init</T>
</div>
<p>
<img :src="secret.qr" alt="QR Code" class="mw-100"/>
<br/>
<small>{{secret.base32}}</small>
</p>
<form @submit.prevent="init" class="input-group mb-3">
<input type="text" class="form-control text-center" v-model="code"
placeholder="000000" autofocus required minlength="0" maxlength="6"
inputmode="numeric" pattern="[0-9]{6}" autocomplete="one-time-code"
ref="code"
/>
<button class="btn btn-outline-primary">
<Icon v="key"/>
<T>user.code.action</T>
</button>
</form>
</div>
<div v-else>
<p>
<Icon v="info-circle"/>
<T>user.mfa.recovery.save</T>
</p>
<ul class="mb-4">
<li v-for="code in recoveryCodes">{{code}}</li>
</ul>
<p>
<button class="btn btn-primary" @click="$user().mfa = true">
<Icon v="shield-check"/>
<T>user.mfa.recovery.saved</T>
</button>
</p>
</div>
</template>
</div>
</template>
<script>
export default {
data() {
return {
secret: null,
code: '',
validation: null,
recoveryCodes: null,
requesting: false,
}
},
methods: {
async getLink() {
this.requesting = true;
this.secret = await this.$axios.$get('/mfa/get-url');
this.requesting = false;
},
async init() {
this.requesting = true;
try {
this.recoveryCodes = await this.$axios.$post('/mfa/init', {
secret: this.secret.base32,
token: this.code,
});
} catch {
await this.$alert(this.$t('user.code.invalid'), 'danger');
} finally {
this.requesting = false;
this.code = '';
}
},
async disable() {
await this.$confirm(this.$t('user.mfa.disableConfirm'), 'danger');
await this.$post('/mfa/disable');
window.location.reload();
},
},
}
</script>

View File

@ -0,0 +1,106 @@
<template>
<section>
<Alert type="danger" :message="error"/>
<div class="card shadow">
<div class="card-body">
<p class="h4">
<Icon v="mobile"/>
<T>user.mfa.header</T>
</p>
<form @submit.prevent="validate" :disabled="saving">
<div class="input-group mb-3">
<input v-if="!recovery" type="text" class="form-control text-center" v-model="code"
placeholder="000000" autofocus required minlength="0" maxlength="6"
inputmode="numeric" pattern="[0-9]{6}" autocomplete="one-time-code"
/>
<input v-else type="text" class="form-control text-center" v-model="recoveryCode"
:placeholder="$t('user.mfa.recovery.header')" autofocus required minlength="0" maxlength="24"
autocomplete="one-time-code"
/>
<button class="btn btn-primary">
<Icon v="key"/>
<T>user.code.action</T>
</button>
</div>
</form>
</div>
<div class="card-footer small d-flex justify-content-around">
<a href="#" @click.prevent="recoverySwitch">
<Icon v="ambulance"/>
<T>user.mfa.recovery.enter</T>
</a>
<a href="#" @click.prevent="cancel">
<Icon v="sign-out"/>
<T>user.mfa.cancel</T>
</a>
</div>
</div>
</section>
</template>
<script>
import {mapState} from "vuex";
export default {
data() {
return {
code: '',
recoveryCode: '',
recovery: false,
saving: false,
error: '',
}
},
mounted() {
this.focus();
},
methods: {
async validate() {
if (this.saving) {
return;
}
this.error = '';
this.saving = true;
try {
const res = await this.$axios.$post(`/mfa/validate`, {
code: this.recovery ? this.recoveryCode : this.code,
recovery: this.recovery,
}, {
headers: {
authorization: 'Bearer ' + this.preToken,
},
});
if (res.error) {
this.error = res.error;
return;
}
this.$store.commit('setToken', res.token);
} finally {
this.saving = false;
this.code = '';
this.recoveryCode = '';
this.recovery = false;
this.focus();
}
},
cancel() {
this.$store.commit('cancelMfa');
},
recoverySwitch() {
this.recovery = !this.recovery;
this.code = '';
this.recoveryCode = '';
this.focus();
},
focus() {
this.$nextTick(() => this.$el.querySelector('input').focus());
}
},
computed: {
...mapState([
'preToken',
]),
},
};
</script>

View File

@ -514,6 +514,23 @@ user:
refresh: 'Refresh'
disconnect: 'Disconnect'
disconnectConfirm: 'Are you sure you want to remove this connection? (You can always log in using email %email%)'
mfa:
header: 'Multi-factor authentication'
init: >
Scan this QR code (or enter the text code below) in your TOTP authenticator app (eg. {https://authy.com/=Authy})
and then enter the initial token that gets generated.
recovery:
header: 'Recovery code'
save: >
Save the following recovery codes in a safe place.
You'll be able to use them to bypass MFA in case you ever lose your authentication device.
saved: 'OK, I''ve saved them!'
enter: 'Enter recovery code'
cancel: 'Cancel login'
enabled: 'Enabled'
enable: 'Enable MFA'
disable: 'Disable MFA'
disableConfirm: 'Are you sure you want to disable MFA?'
profile:
description: 'Description'

View File

@ -412,6 +412,24 @@ user:
refresh: 'Aktualisieren'
disconnect: 'Verbindung trennen'
disconnectConfirm: 'Bist du sicher, dass du die Verbindung trennen möchtest? (Du kannst dich jederzeit mit der E-Mail %email% anmelden)'
# TODO
mfa:
header: 'Multi-factor authentication'
init: >
Scan this QR code (or enter the text code below) in your TOTP authenticator app (eg. {https://authy.com/=Authy})
and then enter the initial token that gets generated.
recovery:
header: 'Recovery code'
save: >
Save the following recovery codes in a safe place.
You'll be able to use them to bypass MFA in case you ever lose your authentication device.
saved: 'OK, I''ve saved them!'
enter: 'Enter recovery code'
cancel: 'Cancel login'
enabled: 'Enabled'
enable: 'Enable MFA'
disable: 'Disable MFA'
disableConfirm: 'Are you sure you want to disable MFA?'
profile:
description: 'Beschreibung'

View File

@ -515,6 +515,23 @@ user:
refresh: 'Refresh'
disconnect: 'Disconnect'
disconnectConfirm: 'Are you sure you want to remove this connection? (You can always log in using email %email%)'
mfa:
header: 'Multi-factor authentication'
init: >
Scan this QR code (or enter the text code below) in your TOTP authenticator app (eg. {https://authy.com/=Authy})
and then enter the initial token that gets generated.
recovery:
header: 'Recovery code'
save: >
Save the following recovery codes in a safe place.
You'll be able to use them to bypass MFA in case you ever lose your authentication device.
saved: 'OK, I''ve saved them!'
enter: 'Enter recovery code'
cancel: 'Cancel login'
enabled: 'Enabled'
enable: 'Enable MFA'
disable: 'Disable MFA'
disableConfirm: 'Are you sure you want to disable MFA?'
profile:
description: 'Description'

View File

@ -425,6 +425,24 @@ user:
refresh: 'Actualizar'
disconnect: 'Desconectar'
disconnectConfirm: '¿Confirmas que quieres eliminar esta conexión? (Siempre puedes iniciar sesión usando el correo electrónico %email%)'
# TODO
mfa:
header: 'Multi-factor authentication'
init: >
Scan this QR code (or enter the text code below) in your TOTP authenticator app (eg. {https://authy.com/=Authy})
and then enter the initial token that gets generated.
recovery:
header: 'Recovery code'
save: >
Save the following recovery codes in a safe place.
You'll be able to use them to bypass MFA in case you ever lose your authentication device.
saved: 'OK, I''ve saved them!'
enter: 'Enter recovery code'
cancel: 'Cancel login'
enabled: 'Enabled'
enable: 'Enable MFA'
disable: 'Disable MFA'
disableConfirm: 'Are you sure you want to disable MFA?'
profile:
description: 'Descripción'

View File

@ -418,6 +418,24 @@ user:
refresh: 'Rafraîchir'
disconnect: 'Déconnecter'
disconnectConfirm: 'Êtes-vous sûr·e de vouloir retirer cette connexion ? (Vous pouvez toujours vous connecter en utilisant ladresse mail %email%)'
# TODO
mfa:
header: 'Multi-factor authentication'
init: >
Scan this QR code (or enter the text code below) in your TOTP authenticator app (eg. {https://authy.com/=Authy})
and then enter the initial token that gets generated.
recovery:
header: 'Recovery code'
save: >
Save the following recovery codes in a safe place.
You'll be able to use them to bypass MFA in case you ever lose your authentication device.
saved: 'OK, I''ve saved them!'
enter: 'Enter recovery code'
cancel: 'Cancel login'
enabled: 'Enabled'
enable: 'Enable MFA'
disable: 'Disable MFA'
disableConfirm: 'Are you sure you want to disable MFA?'
profile:
description: 'Description'

View File

@ -424,6 +424,24 @@ user:
refresh: 'Atualizar'
disconnect: 'Desconectar'
disconnectConfirm: 'Confirma que quer excluir esta conexão? (Sempre pode iniciar sessão usando seu endereço %email%)'
# TODO
mfa:
header: 'Multi-factor authentication'
init: >
Scan this QR code (or enter the text code below) in your TOTP authenticator app (eg. {https://authy.com/=Authy})
and then enter the initial token that gets generated.
recovery:
header: 'Recovery code'
save: >
Save the following recovery codes in a safe place.
You'll be able to use them to bypass MFA in case you ever lose your authentication device.
saved: 'OK, I''ve saved them!'
enter: 'Enter recovery code'
cancel: 'Cancel login'
enabled: 'Enabled'
enable: 'Enable MFA'
disable: 'Disable MFA'
disableConfirm: 'Are you sure you want to disable MFA?'
profile:
description: 'Descrição'

View File

@ -428,6 +428,24 @@ user:
refresh: '更新'
disconnect: '切断'
disconnectConfirm: 'この接続を削除してもよろしいですか?まだメールアドレスでログインできます。(%email%'
# TODO
mfa:
header: 'Multi-factor authentication'
init: >
Scan this QR code (or enter the text code below) in your TOTP authenticator app (eg. {https://authy.com/=Authy})
and then enter the initial token that gets generated.
recovery:
header: 'Recovery code'
save: >
Save the following recovery codes in a safe place.
You'll be able to use them to bypass MFA in case you ever lose your authentication device.
saved: 'OK, I''ve saved them!'
enter: 'Enter recovery code'
cancel: 'Cancel login'
enabled: 'Enabled'
enable: 'Enable MFA'
disable: 'Disable MFA'
disableConfirm: 'Are you sure you want to disable MFA?'
profile:
description: '記述'

View File

@ -417,6 +417,24 @@ user:
refresh: 'Vernieuw'
disconnect: 'Ontkoppel'
disconnectConfirm: 'Weet je zeker dat je deze koppeling wil verwijderen? (Je kunt altijd inloggen met de email %email%)'
# TODO
mfa:
header: 'Multi-factor authentication'
init: >
Scan this QR code (or enter the text code below) in your TOTP authenticator app (eg. {https://authy.com/=Authy})
and then enter the initial token that gets generated.
recovery:
header: 'Recovery code'
save: >
Save the following recovery codes in a safe place.
You'll be able to use them to bypass MFA in case you ever lose your authentication device.
saved: 'OK, I''ve saved them!'
enter: 'Enter recovery code'
cancel: 'Cancel login'
enabled: 'Enabled'
enable: 'Enable MFA'
disable: 'Disable MFA'
disableConfirm: 'Are you sure you want to disable MFA?'
profile:
description: 'Omschrijving'

View File

@ -420,6 +420,24 @@ user:
refresh: 'Last inn på nytt'
disconnect: 'Koble fra'
disconnectConfirm: 'Er du sikker på at du vil kobla fra? (Du kan alltid logge inn via email %email%)'
# TODO
mfa:
header: 'Multi-factor authentication'
init: >
Scan this QR code (or enter the text code below) in your TOTP authenticator app (eg. {https://authy.com/=Authy})
and then enter the initial token that gets generated.
recovery:
header: 'Recovery code'
save: >
Save the following recovery codes in a safe place.
You'll be able to use them to bypass MFA in case you ever lose your authentication device.
saved: 'OK, I''ve saved them!'
enter: 'Enter recovery code'
cancel: 'Cancel login'
enabled: 'Enabled'
enable: 'Enable MFA'
disable: 'Disable MFA'
disableConfirm: 'Are you sure you want to disable MFA?'
profile:
description: 'Beskrivelse'

View File

@ -1212,6 +1212,23 @@ user:
refresh: 'Odśwież'
disconnect: 'Rozłącz'
disconnectConfirm: 'Czy na pewno chcesz usunąć to połączenie? (Zawsze możesz logować się przez maila %email%)'
mfa:
header: 'Uwierzytelnianie wieloskładnikowe (MFA)'
init: >
Zeskanuj poniższy kod QR (lub wklej kod tekstowy pod spodem) do swojej apki TOTP (np. {https://authy.com/=Authy}),
a następnie wpisz wstępny token, jaki zostanie wygenerowany.
recovery:
header: 'Kod odzyskiwania'
save: >
Zapisz poniższe kody odzyskiwania w bezpiecznym miejscu.
Będziesz mogłx ich użyć, aby obejść MFA na wypadek utraty urządzenia uwierzytelniającego.
saved: 'OK, zapisane!'
enter: 'Wpisz kod odzyskiwania'
cancel: 'Anuluj logowanie'
enabled: 'Włączone'
enable: 'Włącz MFA'
disable: 'Wyłącz MFA'
disableConfirm: 'Czy na pewno chcesz wyłączyć MFA?'
profile:
description: 'Opis'

View File

@ -420,6 +420,24 @@ user:
refresh: 'Atualizar'
disconnect: 'Desconectar'
disconnectConfirm: 'Confirma que quer excluir esta conexão? (Sempre pode iniciar sessão usando seu endereço %email%)'
# TODO
mfa:
header: 'Multi-factor authentication'
init: >
Scan this QR code (or enter the text code below) in your TOTP authenticator app (eg. {https://authy.com/=Authy})
and then enter the initial token that gets generated.
recovery:
header: 'Recovery code'
save: >
Save the following recovery codes in a safe place.
You'll be able to use them to bypass MFA in case you ever lose your authentication device.
saved: 'OK, I''ve saved them!'
enter: 'Enter recovery code'
cancel: 'Cancel login'
enabled: 'Enabled'
enable: 'Enable MFA'
disable: 'Disable MFA'
disableConfirm: 'Are you sure you want to disable MFA?'
profile:
description: 'Descrição'

View File

@ -453,6 +453,24 @@ user:
refresh: 'Обновить'
disconnect: 'Отсоединить'
disconnectConfirm: 'Вы уверены, что хотите отсоединить привязанную социальную сеть? (Вы всегда можете войти в аккаунт, используя почту %email%)'
# TODO
mfa:
header: 'Multi-factor authentication'
init: >
Scan this QR code (or enter the text code below) in your TOTP authenticator app (eg. {https://authy.com/=Authy})
and then enter the initial token that gets generated.
recovery:
header: 'Recovery code'
save: >
Save the following recovery codes in a safe place.
You'll be able to use them to bypass MFA in case you ever lose your authentication device.
saved: 'OK, I''ve saved them!'
enter: 'Enter recovery code'
cancel: 'Cancel login'
enabled: 'Enabled'
enable: 'Enable MFA'
disable: 'Disable MFA'
disableConfirm: 'Are you sure you want to disable MFA?'
profile:
description: 'Описание'

View File

@ -420,6 +420,24 @@ user:
refresh: 'Refresh'
disconnect: 'Disconnect'
disconnectConfirm: 'Are you sure you want to remove this connection? (You can always log in using email %email%)'
# TODO
mfa:
header: 'Multi-factor authentication'
init: >
Scan this QR code (or enter the text code below) in your TOTP authenticator app (eg. {https://authy.com/=Authy})
and then enter the initial token that gets generated.
recovery:
header: 'Recovery code'
save: >
Save the following recovery codes in a safe place.
You'll be able to use them to bypass MFA in case you ever lose your authentication device.
saved: 'OK, I''ve saved them!'
enter: 'Enter recovery code'
cancel: 'Cancel login'
enabled: 'Enabled'
enable: 'Enable MFA'
disable: 'Disable MFA'
disableConfirm: 'Are you sure you want to disable MFA?'
profile:
description: 'באַשרײַבונג'

View File

@ -405,6 +405,24 @@ user:
refresh: '祓飾'
disconnect: '掉線'
disconnectConfirm: '確定要刪除此連接嗎? (您始終可以使用電子郵件登錄 %email%)'
# TODO
mfa:
header: 'Multi-factor authentication'
init: >
Scan this QR code (or enter the text code below) in your TOTP authenticator app (eg. {https://authy.com/=Authy})
and then enter the initial token that gets generated.
recovery:
header: 'Recovery code'
save: >
Save the following recovery codes in a safe place.
You'll be able to use them to bypass MFA in case you ever lose your authentication device.
saved: 'OK, I''ve saved them!'
enter: 'Enter recovery code'
cancel: 'Cancel login'
enabled: 'Enabled'
enable: 'Enable MFA'
disable: 'Disable MFA'
disableConfirm: 'Are you sure you want to disable MFA?'
profile:
description: '傳記'

View File

@ -40,8 +40,10 @@
"node-fetch": "^2.6.1",
"nuxt": "^2.15.2",
"pageres": "^6.2.3",
"qrcode": "^1.5.0",
"rtlcss": "^3.1.2",
"sha1": "^1.1.1",
"speakeasy": "^2.0.0",
"sql-template-strings": "^2.2.2",
"sqlite": "^4.0.12",
"sqlite3": "^5.0.0",

View File

@ -6,14 +6,21 @@
</h2>
<Account v-if="$user()"/>
<MfaValidation v-else-if="preToken"/>
<Login v-else/>
</div>
</template>
<script>
import { head } from "../src/helpers";
import {mapState} from "vuex";
export default {
computed: {
...mapState([
'preToken',
]),
},
head() {
return head({
title: this.$t('user.headerLong'),

View File

@ -11,6 +11,7 @@ import cookieSettings from "../src/cookieSettings";
import SQL from "sql-template-strings";
global.config = loadSuml('config');
global.translations = loadSuml('translations');
const app = express()
app.enable('trust proxy')
@ -92,6 +93,7 @@ app.use(require('./routes/banner').default);
app.use(require('./routes/user').default);
app.use(require('./routes/profile').default);
app.use(require('./routes/admin').default);
app.use(require('./routes/mfa').default);
app.use(require('./routes/pronouns').default);
app.use(require('./routes/sources').default);

129
server/routes/mfa.js Normal file
View File

@ -0,0 +1,129 @@
import { Router } from 'express';
import {handleErrorAsync} from "../../src/helpers";
import speakeasy from 'speakeasy';
import QRCode from 'qrcode';
import {saveAuthenticator, findAuthenticatorsByUser, invalidateAuthenticator, issueAuthentication, normalise} from './user';
import cookieSettings from "../../src/cookieSettings";
export const addMfaInfo = async (db, user, guard = false) => {
const auths = await findAuthenticatorsByUser(db, user, 'mfa_secret');
if (auths.length) {
user.mfa = true;
if (guard) {
user.mfaRequired = true;
user.authenticated = false;
}
} else {
user.mfa = false;
}
return user;
}
const disableMfa = async (db, user) => {
for (let authenticator of [
...await findAuthenticatorsByUser(db, user, 'mfa_secret'),
...await findAuthenticatorsByUser(db, user, 'mfa_recovery'),
]) {
await invalidateAuthenticator(db, authenticator.id);
}
}
const router = Router();
router.get('/mfa/get-url', handleErrorAsync(async (req, res) => {
if (!req.user || req.user.mfa) {
return res.status(401).json({error: 'Unauthorised'});
}
const secret = speakeasy.generateSecret({
length: 16,
name: global.translations.title,
});
secret.qr = await QRCode.toDataURL(secret.otpauth_url, {
margin: 2,
width: 200,
});
return res.json(secret);
}));
router.post('/mfa/init', handleErrorAsync(async (req, res) => {
if (!req.user || req.user.mfa) {
return res.status(401).json({error: 'Unauthorised'});
}
const verified = speakeasy.totp.verify({
secret: req.body.secret,
encoding: 'base32',
token: req.body.token
});
if (!verified) {
return res.status(400).json({error: 'Invalid token'});
}
await saveAuthenticator(req.db, 'mfa_secret', req.user, req.body.secret);
const recoveryCodes = [];
for (let i = 0; i < 5; i++) {
const code = speakeasy.generateSecretASCII(24);
recoveryCodes.push(code);
await saveAuthenticator(req.db, 'mfa_recovery', req.user, code);
}
const token = await issueAuthentication(req.db, req.user);
return res.cookie('token', token, cookieSettings).json(recoveryCodes);
}));
router.post('/mfa/validate', handleErrorAsync(async (req, res) => {
if (!req.rawUser || !req.rawUser.mfaRequired) {
return res.json({error: 'user.tokenExpired'});
}
if (req.body.recovery) {
for (let authenticator of await findAuthenticatorsByUser(req.db, req.rawUser, 'mfa_recovery')) {
if (authenticator.payload === req.body.code.trim()) {
await disableMfa(req.db, req.rawUser);
const token = await issueAuthentication(req.db, req.rawUser, true, false, { mfa: false, mfaRequired: false });
return res.cookie('token', token, cookieSettings).json({token: token});
}
}
return res.json({error: 'user.code.invalid'});
}
const authenticator = (await findAuthenticatorsByUser(req.db, req.rawUser, 'mfa_secret'))[0];
const tokenValidates = speakeasy.totp.verify({
secret: authenticator.payload,
encoding: 'base32',
token: normalise(req.body.code),
window: 6
});
if (!tokenValidates) {
return res.json({error: 'user.code.invalid'});
}
const token = await issueAuthentication(req.db, req.rawUser, true, false, { mfaRequired: false });
return res.cookie('token', token, cookieSettings).json({token: token});
}));
router.post('/mfa/disable', handleErrorAsync(async (req, res) => {
if (!req.user || !req.user.mfa) {
return res.status(401).json({error: 'Unauthorised'});
}
await disableMfa(req.db, req.user);
const token = await issueAuthentication(req.db, req.user);
return res.cookie('token', token, cookieSettings).json({token: token});
}));
export default router;

View File

@ -10,13 +10,14 @@ import { config as socialLoginConfig, handlers as socialLoginHandlers } from '..
import cookieSettings from "../../src/cookieSettings";
import {validateCaptcha} from "../captcha";
import assert from "assert";
import {addMfaInfo} from './mfa';
const config = loadSuml('config');
const translations = loadSuml('translations');
const USERNAME_CHARS = 'A-Za-zĄĆĘŁŃÓŚŻŹąćęłńóśżź0-9._-';
const normalise = s => s.trim().toLowerCase();
export const normalise = s => s.trim().toLowerCase();
const isSpam = (email) => {
const noDots = email.replace(/\./g, '');
@ -32,7 +33,7 @@ const replaceExtension = username => username
.replace(/\.$/, '')
;
const saveAuthenticator = async (db, type, user, payload, validForMinutes = null) => {
export const saveAuthenticator = async (db, type, user, payload, validForMinutes = null) => {
const id = ulid();
await db.get(SQL`INSERT INTO authenticators (id, userId, type, payload, validUntil) VALUES (
${id},
@ -44,7 +45,7 @@ const saveAuthenticator = async (db, type, user, payload, validForMinutes = null
return id;
}
const findAuthenticator = async (db, id, type) => {
export const findAuthenticatorById = async (db, id, type) => {
const authenticator = await db.get(SQL`SELECT * FROM authenticators
WHERE id = ${id}
AND type = ${type}
@ -58,6 +59,21 @@ const findAuthenticator = async (db, id, type) => {
return authenticator
}
export const findAuthenticatorsByUser = async (db, user, type) => {
const authenticators = await db.all(SQL`
SELECT * FROM authenticators
WHERE userId = ${user.id}
AND type = ${type}
AND (validUntil IS NULL OR validUntil > ${now()})
`);
return authenticators.map(a => {
a.payload = JSON.parse(a.payload);
return a;
});
}
const findLatestEmailAuthenticator = async (db, email, type) => {
const authenticator = await db.get(SQL`SELECT * FROM authenticators
WHERE payload LIKE ${'%"email":"' + email + '"%'}
@ -73,7 +89,7 @@ const findLatestEmailAuthenticator = async (db, email, type) => {
return authenticator
}
const invalidateAuthenticator = async (db, id) => {
export const invalidateAuthenticator = async (db, id) => {
await db.get(SQL`UPDATE authenticators
SET validUntil = ${now()}
WHERE id = ${id}
@ -122,13 +138,31 @@ const fetchOrCreateUser = async (db, user, avatarSource = 'gravatar') => {
return dbUser;
}
const issueAuthentication = async (db, user) => {
const dbUser = await fetchOrCreateUser(db, user);
export const issueAuthentication = async (db, user, fetch = true, guardMfa = false, extend = undefined) => {
if (fetch) {
user = await fetchOrCreateUser(db, user);
}
return jwt.sign({
...dbUser,
authenticated: true,
});
if (user.mfa === undefined && user.id) {
user = await addMfaInfo(db, user, guardMfa);
}
if (!user.mfaRequired) {
user.authenticated = true;
}
user.avatar = await avatar(db, user);
delete user.suspiciousChecked;
delete user.bannedBy;
if (extend) {
user = {
...user,
...extend,
}
}
return jwt.sign(user);
}
const validateEmail = async (email) => {
@ -172,7 +206,7 @@ const reloadUser = async (req, res, next) => {
return;
}
const dbUser = await req.db.get(SQL`SELECT * FROM users WHERE id = ${req.user.id}`);
let dbUser = await req.db.get(SQL`SELECT * FROM users WHERE id = ${req.user.id}`);
if (!dbUser) {
res.clearCookie('token');
@ -180,6 +214,8 @@ const reloadUser = async (req, res, next) => {
return;
}
dbUser = await addMfaInfo(req.db, dbUser);
await req.db.get(SQL`UPDATE users SET lastActive = ${+new Date} WHERE id = ${req.user.id}`);
if (req.user.username !== dbUser.username
@ -187,15 +223,12 @@ const reloadUser = async (req, res, next) => {
|| req.user.roles !== dbUser.roles
|| req.user.avatarSource !== dbUser.avatarSource
|| req.user.bannedReason !== dbUser.bannedReason
|| req.user.mfa !== dbUser.mfa
) {
const newUser = {
...dbUser,
authenticated: true,
avatar: await avatar(req.db, dbUser),
};
const token = jwt.sign(newUser);
const token = await issueAuthentication(req.db, dbUser, false);
res.cookie('token', token, cookieSettings);
req.user = {...req.user, ...newUser};
req.rawUser = jwt.validate(token);
req.user = req.rawUser;
}
next();
}
@ -278,7 +311,7 @@ router.post('/user/validate', handleErrorAsync(async (req, res) => {
return res.json({error: 'user.tokenExpired'});
}
const authenticator = await findAuthenticator(req.db, req.rawUser.codeKey, 'email');
const authenticator = await findAuthenticatorById(req.db, req.rawUser.codeKey, 'email');
if (!authenticator) {
return res.json({error: 'user.tokenExpired'});
}
@ -289,7 +322,7 @@ router.post('/user/validate', handleErrorAsync(async (req, res) => {
await invalidateAuthenticator(req.db, authenticator);
return res.json({token: await issueAuthentication(req.db, req.rawUser)});
return res.json({token: await issueAuthentication(req.db, req.rawUser, true, true)});
}));
router.post('/user/change-username', handleErrorAsync(async (req, res) => {
@ -347,7 +380,7 @@ router.post('/user/change-email', handleErrorAsync(async (req, res) => {
return res.json({ authId });
}
const authenticator = await findAuthenticator(req.db, req.body.authId, 'changeEmail');
const authenticator = await findAuthenticatorById(req.db, req.body.authId, 'changeEmail');
if (!authenticator) {
return res.json({error: 'user.tokenExpired'});
}
@ -424,10 +457,7 @@ router.get('/user/social/:provider', handleErrorAsync(async (req, res) => {
name: payload.name,
}, req.params.provider);
const token = jwt.sign({
...dbUser,
authenticated: true,
});
const token = await issueAuthentication(req.db, dbUser, false, true);
if (auth) {
await invalidateAuthenticator(req.db, auth.id);

View File

@ -3,6 +3,7 @@ import jwt from 'jsonwebtoken';
export const state = () => ({
token: null,
user: null,
preToken: null,
spelling: 'traditional',
darkMode: false,
})
@ -27,7 +28,12 @@ export const mutations = {
user = null;
}
if (user && user.mfaRequired) {
state.preToken = token;
}
if (user && user.authenticated) {
state.preToken = null;
state.token = token;
state.user = user;
return;
@ -36,6 +42,9 @@ export const mutations = {
state.token = null;
state.user = null;
},
cancelMfa(state) {
state.preToken = null;
},
setSpelling(state, spelling) {
state.spelling = spelling;
},

View File

@ -2158,6 +2158,11 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
base32.js@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/base32.js/-/base32.js-0.0.1.tgz#d045736a57b1f6c139f0c7df42518a84e91bb2ba"
integrity sha1-0EVzalex9sE58MffQlGKhOkbsro=
base64-js@^1.0.2, base64-js@^1.3.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
@ -2815,6 +2820,15 @@ cliui@^4.0.0:
strip-ansi "^4.0.0"
wrap-ansi "^2.0.0"
cliui@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1"
integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==
dependencies:
string-width "^4.2.0"
strip-ansi "^6.0.0"
wrap-ansi "^6.2.0"
clone-deep@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387"
@ -3649,6 +3663,11 @@ diffie-hellman@^5.0.0:
miller-rabin "^4.0.0"
randombytes "^2.0.0"
dijkstrajs@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.2.tgz#2e48c0d3b825462afe75ab4ad5e829c8ece36257"
integrity sha512-QV6PMaHTCNmKSeP6QoXhVTw9snc9VD8MulTT0Bd99Pacp4SS1cjcrYPgBPmibqKVtMJJfqC6XvOXgPMEEPH/fg==
dir-glob@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
@ -3856,6 +3875,11 @@ emojis-list@^3.0.0:
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78"
integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==
encode-utf8@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda"
integrity sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==
encodeurl@~1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
@ -4389,7 +4413,7 @@ find-up@^3.0.0:
dependencies:
locate-path "^3.0.0"
find-up@^4.0.0:
find-up@^4.0.0, find-up@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
@ -4599,6 +4623,11 @@ get-caller-file@^1.0.1:
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a"
integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==
get-caller-file@^2.0.1:
version "2.0.5"
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
get-intrinsic@^1.0.2:
version "1.1.1"
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6"
@ -7396,6 +7425,11 @@ plur@^4.0.0:
dependencies:
irregular-plurals "^3.2.0"
pngjs@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
pnp-webpack-plugin@^1.6.4:
version "1.6.4"
resolved "https://registry.yarnpkg.com/pnp-webpack-plugin/-/pnp-webpack-plugin-1.6.4.tgz#c9711ac4dc48a685dabafc86f8b6dd9f8df84149"
@ -8294,6 +8328,16 @@ q@^1.1.2:
resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=
qrcode@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.0.tgz#95abb8a91fdafd86f8190f2836abbfc500c72d1b"
integrity sha512-9MgRpgVc+/+47dFvQeD6U2s0Z92EsKzcHogtum4QB+UNd025WOJSHvn/hjk9xmzj7Stj95CyUAs31mrjxliEsQ==
dependencies:
dijkstrajs "^1.0.1"
encode-utf8 "^1.0.3"
pngjs "^5.0.0"
yargs "^15.3.1"
qs@6.7.0:
version "6.7.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
@ -8635,6 +8679,11 @@ require-main-filename@^1.0.1:
resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1"
integrity sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=
require-main-filename@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==
requires-port@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
@ -9177,6 +9226,13 @@ spdx-license-ids@^3.0.0:
resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.9.tgz#8a595135def9592bda69709474f1cbeea7c2467f"
integrity sha512-Ki212dKK4ogX+xDo4CtOZBVIwhsKBEfsEEcwmJfLQzirgc2jIWdzg40Unxz/HzEUqM1WFzVlQSMF9kZZ2HboLQ==
speakeasy@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/speakeasy/-/speakeasy-2.0.0.tgz#85c91a071b09a5cb8642590d983566165f57613a"
integrity sha1-hckaBxsJpcuGQlkNmDVmFl9XYTo=
dependencies:
base32.js "0.0.1"
split-on-first@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f"
@ -10528,7 +10584,7 @@ wrap-ansi@^2.0.0:
string-width "^1.0.1"
strip-ansi "^3.0.1"
wrap-ansi@^6.0.0:
wrap-ansi@^6.0.0, wrap-ansi@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==
@ -10640,6 +10696,14 @@ yargs-parser@^11.1.1:
camelcase "^5.0.0"
decamelize "^1.2.0"
yargs-parser@^18.1.2:
version "18.1.3"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"
integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==
dependencies:
camelcase "^5.0.0"
decamelize "^1.2.0"
yargs@^12.0.1:
version "12.0.5"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-12.0.5.tgz#05f5997b609647b64f66b81e3b4b10a368e7ad13"
@ -10658,6 +10722,23 @@ yargs@^12.0.1:
y18n "^3.2.1 || ^4.0.0"
yargs-parser "^11.1.1"
yargs@^15.3.1:
version "15.4.1"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8"
integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==
dependencies:
cliui "^6.0.0"
decamelize "^1.2.0"
find-up "^4.1.0"
get-caller-file "^2.0.1"
require-directory "^2.1.1"
require-main-filename "^2.0.0"
set-blocking "^2.0.0"
string-width "^4.2.0"
which-module "^2.0.0"
y18n "^4.0.0"
yargs-parser "^18.1.2"
yauzl@^2.10.0:
version "2.10.0"
resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"