#291 [auth][mfa] MFA
This commit is contained in:
parent
feb08abfe9
commit
113b040f25
|
@ -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>
|
||||
|
||||
|
|
|
@ -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')">
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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 l’adresse 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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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: '記述'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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: 'Описание'
|
||||
|
|
|
@ -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: 'באַשרײַבונג'
|
||||
|
|
|
@ -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: '傳記'
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
|
|
85
yarn.lock
85
yarn.lock
|
@ -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"
|
||||
|
|
Reference in New Issue