mirror of
https://github.com/Bubka/2FAuth.git
synced 2025-03-06 11:11:32 +01:00
Pre-calculate next OTP & Add user pref to show/hide it - Closes #416
This commit is contained in:
parent
47a13b891d
commit
46c1131b10
@ -33,7 +33,11 @@ function () use ($request) {
|
||||
*/
|
||||
$otp = $this->getOtp($request->at);
|
||||
|
||||
return collect(['password' => $otp->password, 'generated_at' => $otp->generated_at]);
|
||||
return collect([
|
||||
'password' => $otp->password,
|
||||
'generated_at' => $otp->generated_at,
|
||||
'next_password' => $otp->next_password,
|
||||
]);
|
||||
}
|
||||
),
|
||||
],
|
||||
|
@ -4,9 +4,9 @@
|
||||
|
||||
class OtpDto
|
||||
{
|
||||
/* @var integer */
|
||||
/* @var string */
|
||||
public string $password;
|
||||
|
||||
/* @var integer */
|
||||
/* @var string */
|
||||
public string $otp_type;
|
||||
}
|
||||
|
@ -9,4 +9,7 @@ class TotpDto extends OtpDto
|
||||
|
||||
/* @var integer */
|
||||
public int $period;
|
||||
|
||||
/* @var string */
|
||||
public string $next_password;
|
||||
}
|
||||
|
@ -427,12 +427,19 @@ public function getOTP(?int $time = null)
|
||||
$this->save();
|
||||
}
|
||||
} else {
|
||||
$OtpDto = new TotpDto;
|
||||
$OtpDto->otp_type = $this->otp_type;
|
||||
$OtpDto->generated_at = $time ?: time();
|
||||
$OtpDto->password = $this->otp_type === self::TOTP
|
||||
? $this->generator->at($OtpDto->generated_at)
|
||||
: SteamTotp::getAuthCode(base64_encode(Base32::decodeUpper($this->secret)));
|
||||
$OtpDto = new TotpDto;
|
||||
$OtpDto->otp_type = $this->otp_type;
|
||||
$OtpDto->generated_at = $time ?: time();
|
||||
$expires_in = $this->generator->expiresIn(); /** @phpstan-ignore-line - expiresIn() is in the TOTPInterface only */
|
||||
|
||||
if ($this->otp_type === self::TOTP) {
|
||||
$OtpDto->password = $this->generator->at($OtpDto->generated_at);
|
||||
$OtpDto->next_password = $this->generator->at($OtpDto->generated_at + $expires_in + 2);
|
||||
}
|
||||
else {
|
||||
$OtpDto->password = SteamTotp::getAuthCode(base64_encode(Base32::decodeUpper($this->secret)));
|
||||
$OtpDto->next_password = SteamTotp::getAuthCode(base64_encode(Base32::decodeUpper($this->secret)), $expires_in + 2);
|
||||
}
|
||||
$OtpDto->period = $this->period;
|
||||
}
|
||||
|
||||
|
@ -109,6 +109,7 @@
|
||||
|
||||
'preferences' => [
|
||||
'showOtpAsDot' => false,
|
||||
'showNextOtp' => false,
|
||||
'revealDottedOTP' => false,
|
||||
'closeOtpOnCopy' => false,
|
||||
'copyOtpOnDisplay' => false,
|
||||
|
38
resources/js/assets/app.scss
vendored
38
resources/js/assets/app.scss
vendored
@ -346,10 +346,6 @@ img.qrcode {
|
||||
vertical-align: sub;
|
||||
}
|
||||
|
||||
.tfa-container span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.fullscreen-streamer {
|
||||
position: fixed;
|
||||
top: 7%;
|
||||
@ -1370,6 +1366,40 @@ footer.menu {
|
||||
}
|
||||
}
|
||||
|
||||
.is-opacity-0 {
|
||||
opacity: 0;
|
||||
}
|
||||
.is-opacity-1 {
|
||||
opacity: 0.1;
|
||||
}
|
||||
.is-opacity-2 {
|
||||
opacity: 0.2;
|
||||
}
|
||||
.is-opacity-3 {
|
||||
opacity: 0.3;
|
||||
}
|
||||
.is-opacity-4 {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.is-opacity-5 {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.is-opacity-6 {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.is-opacity-7 {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.is-opacity-8 {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.is-opacity-9 {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.is-opacity-10 {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .table {
|
||||
background-color: $black-ter;
|
||||
color: $white-bis;
|
||||
|
@ -45,10 +45,12 @@
|
||||
image : ''
|
||||
})
|
||||
const password = ref('')
|
||||
const next_password = ref('')
|
||||
const generated_at = ref(null)
|
||||
const hasTOTP = ref(false)
|
||||
const showInlineSpinner = ref(false)
|
||||
const showMainSpinner = ref(false)
|
||||
const revealPassword = ref(false)
|
||||
const opacity = ref('0')
|
||||
|
||||
const dots = ref()
|
||||
const totpLooper = ref()
|
||||
@ -136,11 +138,22 @@
|
||||
* Requests and handles a fresh OTP
|
||||
*/
|
||||
async function getOtp() {
|
||||
setLoadingState()
|
||||
// We replace the current on screen password with the next_password to avoid having a loader.
|
||||
// The next_password will be confirmed with a new request to be synced with the backend no matter what.
|
||||
if (next_password.value) {
|
||||
password.value = next_password.value
|
||||
next_password.value = ''
|
||||
dots.value.turnOff()
|
||||
turnDotOn(0)
|
||||
}
|
||||
else {
|
||||
setLoadingState()
|
||||
}
|
||||
|
||||
await getOtpPromise().then(response => {
|
||||
let otp = response.data
|
||||
password.value = otp.password
|
||||
next_password.value = otp.next_password
|
||||
|
||||
if(user.preferences.copyOtpOnDisplay) {
|
||||
copyOTP(otp.password)
|
||||
@ -169,7 +182,7 @@
|
||||
//throw error
|
||||
})
|
||||
.finally(() => {
|
||||
showInlineSpinner.value = false
|
||||
showMainSpinner.value = false
|
||||
})
|
||||
}
|
||||
|
||||
@ -177,7 +190,7 @@
|
||||
* Shows blacked dots and a loading spinner
|
||||
*/
|
||||
function setLoadingState() {
|
||||
showInlineSpinner.value = true
|
||||
showMainSpinner.value = true
|
||||
dots.value.turnOff()
|
||||
}
|
||||
|
||||
@ -212,6 +225,7 @@
|
||||
id.value = otpauthParams.value.counter = generated_at.value = null
|
||||
otpauthParams.value.service = otpauthParams.value.account = otpauthParams.value.icon = otpauthParams.value.otp_type = otpauthParams.value.secret = ''
|
||||
password.value = '... ...'
|
||||
next_password.value = ''
|
||||
hasTOTP.value = false
|
||||
clearTimeout(autoCloseTimeout.value)
|
||||
|
||||
@ -280,6 +294,7 @@
|
||||
*/
|
||||
function turnDotOn(dotIndex) {
|
||||
dots.value.turnOn(dotIndex)
|
||||
opacity.value = 'is-opacity-' + dotIndex
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
@ -310,7 +325,7 @@
|
||||
<p class="is-size-6 has-ellipsis" :class="mode == 'dark' ? 'has-text-grey' : 'has-text-grey-light'">{{ otpauthParams.account }}</p>
|
||||
<p>
|
||||
<span
|
||||
v-if="!showInlineSpinner"
|
||||
v-if="!showMainSpinner"
|
||||
id="otp"
|
||||
role="log"
|
||||
ref="otpSpanTag"
|
||||
@ -324,7 +339,7 @@
|
||||
{{ useDisplayablePassword(password, user.preferences.showOtpAsDot && user.preferences.revealDottedOTP && revealPassword) }}
|
||||
</span>
|
||||
<span v-else tabindex="0" class="otp is-size-1">
|
||||
<Spinner :isVisible="showInlineSpinner" :type="'raw'" />
|
||||
<Spinner :isVisible="showMainSpinner" :type="'raw'" />
|
||||
</span>
|
||||
</p>
|
||||
</UseColorMode>
|
||||
@ -332,6 +347,20 @@
|
||||
<p v-show="isHMacBased(otpauthParams.otp_type)">
|
||||
{{ $t('twofaccounts.forms.counter.label') }}: {{ otpauthParams.counter }}
|
||||
</p>
|
||||
<p v-if="user.preferences.showNextOtp" class="mt-3 is-size-4">
|
||||
<span
|
||||
v-if="next_password"
|
||||
class="is-clickable"
|
||||
:class="opacity"
|
||||
@click="copyOTP(next_password, true)"
|
||||
@keyup.enter="copyOTP(next_password, true)"
|
||||
:title="$t('commons.copy_next_password')"
|
||||
>
|
||||
{{ useDisplayablePassword(next_password, user.preferences.showOtpAsDot && user.preferences.revealDottedOTP && revealPassword) }}
|
||||
</span>
|
||||
<!-- <Spinner v-else-if="!showMainSpinner" :isVisible="true" :type="'raw'" /> -->
|
||||
<span v-else> </span>
|
||||
</p>
|
||||
<p v-if="user.preferences.showOtpAsDot && user.preferences.revealDottedOTP" class="mt-3">
|
||||
<button type="button" class="button is-ghost has-text-grey-dark" @click.stop="revealPassword = !revealPassword">
|
||||
<font-awesome-icon v-if="revealPassword" :icon="['fas', 'eye']" />
|
||||
|
@ -173,6 +173,8 @@
|
||||
<FormCheckbox v-model="user.preferences.showOtpAsDot" @update:model-value="val => savePreference('showOtpAsDot', val)" fieldName="showOtpAsDot" label="settings.forms.show_otp_as_dot.label" help="settings.forms.show_otp_as_dot.help" />
|
||||
<!-- reveal dotted OTPs -->
|
||||
<FormCheckbox v-model="user.preferences.revealDottedOTP" @update:model-value="val => savePreference('revealDottedOTP', val)" fieldName="revealDottedOTP" label="settings.forms.reveal_dotted_otp.label" help="settings.forms.reveal_dotted_otp.help" :isDisabled="!user.preferences.showOtpAsDot" :isIndented="true" />
|
||||
<!-- show next OTP -->
|
||||
<FormCheckbox v-model="user.preferences.showNextOtp" @update:model-value="val => savePreference('showNextOtp', val)" fieldName="showNextOtp" label="settings.forms.show_next_otp.label" help="settings.forms.show_next_otp.help" />
|
||||
|
||||
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.notifications') }}</h4>
|
||||
<!-- on new device -->
|
||||
|
@ -32,9 +32,9 @@
|
||||
const showGroupSwitch = ref(false)
|
||||
const showDestinationGroupSelector = ref(false)
|
||||
const isDragging = ref(false)
|
||||
const isRenewingOTPs = ref(false)
|
||||
const renewedPeriod = ref(null)
|
||||
const revealPassword = ref(null)
|
||||
const opacities = ref({})
|
||||
|
||||
const otpDisplay = ref(null)
|
||||
const otpDisplayProps = ref({
|
||||
@ -230,6 +230,10 @@
|
||||
.forEach((dot) => {
|
||||
dot.turnOn(stepIndex)
|
||||
})
|
||||
|
||||
// The is-opacity-* classes are defined from 0 to 10 only.
|
||||
// TODO: Make the opacity refiner support variable number of steps (not only 10, see step_count)
|
||||
opacities.value[period] = 'is-opacity-' + stepIndex
|
||||
}
|
||||
|
||||
/**
|
||||
@ -247,8 +251,6 @@
|
||||
* Updates "Always On" OTPs for all TOTP accounts and (re)starts loopers
|
||||
*/
|
||||
async function updateTotps(period) {
|
||||
isRenewingOTPs.value = true
|
||||
turnDotsOff(period)
|
||||
let fetchPromise
|
||||
|
||||
if (period == undefined) {
|
||||
@ -258,6 +260,22 @@
|
||||
renewedPeriod.value = period
|
||||
fetchPromise = twofaccountService.getByIds(twofaccounts.accountIdsWithPeriod(period).join(','), true)
|
||||
}
|
||||
|
||||
turnDotsOff(period)
|
||||
|
||||
// We replace the current on screen passwords with the next_password to avoid having loaders.
|
||||
// The next_password will be confirmed with a new request to be synced with the backend no matter what.
|
||||
const totpAccountsWithNextPasswordInThePeriod = twofaccounts.items.filter((account) => account.otp_type === 'totp'&& account.period == period && account.otp.next_password)
|
||||
|
||||
if (totpAccountsWithNextPasswordInThePeriod.length > 0) {
|
||||
totpAccountsWithNextPasswordInThePeriod.forEach((account) => {
|
||||
const index = twofaccounts.items.findIndex(acc => acc.id === account.id)
|
||||
if (twofaccounts.items[index].otp.next_password) {
|
||||
twofaccounts.items[index].otp.password = twofaccounts.items[index].otp.next_password
|
||||
}
|
||||
})
|
||||
turnDotsOn(period, 0)
|
||||
}
|
||||
|
||||
fetchPromise.then(response => {
|
||||
let generatedAt = 0
|
||||
@ -284,7 +302,6 @@
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
isRenewingOTPs.value = false
|
||||
renewedPeriod.value = null
|
||||
})
|
||||
}
|
||||
@ -406,30 +423,32 @@
|
||||
<img v-if="account.icon && user.preferences.showAccountsIcons" role="presentation" class="tfa-icon" :src="$2fauth.config.subdirectory + '/storage/icons/' + account.icon" alt="">
|
||||
<img v-else-if="account.icon == null && user.preferences.showAccountsIcons" role="presentation" class="tfa-icon" :src="$2fauth.config.subdirectory + '/storage/noicon.svg'" alt="">
|
||||
{{ account.service ? account.service : $t('twofaccounts.no_service') }}<FontAwesomeIcon class="has-text-danger is-size-5 ml-2" v-if="account.account === $t('errors.indecipherable')" :icon="['fas', 'exclamation-circle']" />
|
||||
<span class="has-ellipsis is-family-primary is-size-6 is-size-7-mobile has-text-grey ">{{ account.account }}</span>
|
||||
<span class="is-block has-ellipsis is-family-primary is-size-6 is-size-7-mobile has-text-grey ">{{ account.account }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="popLater">
|
||||
<div v-show="user.preferences.getOtpOnRequest == false && !bus.inManagementMode" class="has-text-right">
|
||||
<span v-if="account.otp != undefined">
|
||||
<span v-if="isRenewingOTPs == true && (renewedPeriod == -1 || renewedPeriod == account.period)" class="has-nowrap has-text-grey has-text-centered is-size-5">
|
||||
<FontAwesomeIcon :icon="['fas', 'circle-notch']" spin />
|
||||
</span>
|
||||
<span v-else class="always-on-otp is-clickable has-nowrap has-text-grey is-size-5 ml-4" @click="copyToClipboard(account.otp.password)" @keyup.enter="copyToClipboard(account.otp.password)" :title="$t('commons.copy_to_clipboard')">
|
||||
<div v-if="account.otp != undefined">
|
||||
<div class="always-on-otp is-clickable has-nowrap has-text-grey is-size-5 ml-4" @click="copyToClipboard(account.otp.password)" @keyup.enter="copyToClipboard(account.otp.password)" :title="$t('commons.copy_to_clipboard')">
|
||||
{{ useDisplayablePassword(account.otp.password, user.preferences.showOtpAsDot && user.preferences.revealDottedOTP && revealPassword == account.id) }}
|
||||
</span>
|
||||
<Dots
|
||||
v-if="account.otp_type.includes('totp')"
|
||||
:class="'condensed'"
|
||||
ref="dotsRefs"
|
||||
:period="account.period" />
|
||||
</span>
|
||||
<span v-else>
|
||||
</div>
|
||||
<div class="has-nowrap" style="line-height: 0.9;">
|
||||
<span v-if="user.preferences.showNextOtp" class="always-on-otp is-clickable has-nowrap has-text-grey is-size-7 mr-2" :class="opacities[account.period]" @click="copyToClipboard(account.otp.next_password)" @keyup.enter="copyToClipboard(account.otp.next_password)" :title="$t('commons.copy_next_password')">
|
||||
{{ useDisplayablePassword(account.otp.next_password, user.preferences.showOtpAsDot && user.preferences.revealDottedOTP && revealPassword == account.id) }}
|
||||
</span>
|
||||
<Dots
|
||||
v-if="account.otp_type.includes('totp')"
|
||||
:class="'condensed is-inline-block'"
|
||||
ref="dotsRefs"
|
||||
:period="account.period" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- get hotp button -->
|
||||
<button type="button" class="button tag" :class="mode == 'dark' ? 'is-dark' : 'is-white'" @click="showOTP(account)" :title="$t('twofaccounts.import.import_this_account')">
|
||||
{{ $t('commons.generate') }}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<transition name="popLater" v-if="user.preferences.showOtpAsDot && user.preferences.revealDottedOTP">
|
||||
|
@ -91,4 +91,5 @@
|
||||
'one_month' => '1 mo.',
|
||||
'x_month' => ':x mos.',
|
||||
'one_year' => '1 yr.',
|
||||
'copy_next_password' => 'Copy next password to clipboard',
|
||||
];
|
||||
|
@ -59,7 +59,7 @@
|
||||
],
|
||||
'show_otp_as_dot' => [
|
||||
'label' => 'Show generated <abbr title="One-Time Password">OTP</abbr> as dot',
|
||||
'help' => 'Replace generated password caracters with *** to ensure confidentiality. Do not affect the copy/paste feature'
|
||||
'help' => 'Replace generated password characters with *** to ensure confidentiality. Does not affect the copy/paste feature'
|
||||
],
|
||||
'reveal_dotted_otp' => [
|
||||
'label' => 'Reveal obscured <abbr title="One-Time Password">OTP</abbr>',
|
||||
@ -69,6 +69,10 @@
|
||||
'label' => 'Close <abbr title="One-Time Password">OTP</abbr> after copy',
|
||||
'help' => 'Click on a generated password to copy it automatically hides it from the screen'
|
||||
],
|
||||
'show_next_otp' => [
|
||||
'label' => 'Show next <abbr title="One-Time Password">OTP</abbr>',
|
||||
'help' => 'Preview the next password, i.e. the password that will replace the current password when it expires. Preferences set for the current OTP also apply to the next one (formatting, show as dot)'
|
||||
],
|
||||
'auto_close_timeout' => [
|
||||
'label' => 'Auto close <abbr title="One-Time Password">OTP</abbr>',
|
||||
'help' => 'Automatically hide on-screen password after a timeout. This avoids unnecessary requests for fresh passwords if you forget to close the password view.'
|
||||
|
@ -112,6 +112,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
|
||||
private const VALID_EMBEDDED_OTP_RESOURCE_STRUCTURE_FOR_TOTP = [
|
||||
'generated_at',
|
||||
'password',
|
||||
'next_password',
|
||||
];
|
||||
|
||||
private const VALID_OTP_RESOURCE_STRUCTURE_FOR_HOTP = [
|
||||
|
Loading…
Reference in New Issue
Block a user