Pre-calculate next OTP & Add user pref to show/hide it - Closes #416

This commit is contained in:
Bubka 2025-03-03 17:02:27 +01:00
parent 47a13b891d
commit 46c1131b10
12 changed files with 140 additions and 39 deletions

View File

@ -33,7 +33,11 @@ class TwoFAccountReadResource extends TwoFAccountStoreResource
*/ */
$otp = $this->getOtp($request->at); $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,
]);
} }
), ),
], ],

View File

@ -4,9 +4,9 @@ namespace App\Models\Dto;
class OtpDto class OtpDto
{ {
/* @var integer */ /* @var string */
public string $password; public string $password;
/* @var integer */ /* @var string */
public string $otp_type; public string $otp_type;
} }

View File

@ -9,4 +9,7 @@ class TotpDto extends OtpDto
/* @var integer */ /* @var integer */
public int $period; public int $period;
/* @var string */
public string $next_password;
} }

View File

@ -427,12 +427,19 @@ class TwoFAccount extends Model implements Sortable
$this->save(); $this->save();
} }
} else { } else {
$OtpDto = new TotpDto; $OtpDto = new TotpDto;
$OtpDto->otp_type = $this->otp_type; $OtpDto->otp_type = $this->otp_type;
$OtpDto->generated_at = $time ?: time(); $OtpDto->generated_at = $time ?: time();
$OtpDto->password = $this->otp_type === self::TOTP $expires_in = $this->generator->expiresIn(); /** @phpstan-ignore-line - expiresIn() is in the TOTPInterface only */
? $this->generator->at($OtpDto->generated_at)
: SteamTotp::getAuthCode(base64_encode(Base32::decodeUpper($this->secret))); 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; $OtpDto->period = $this->period;
} }

View File

@ -109,6 +109,7 @@ return [
'preferences' => [ 'preferences' => [
'showOtpAsDot' => false, 'showOtpAsDot' => false,
'showNextOtp' => false,
'revealDottedOTP' => false, 'revealDottedOTP' => false,
'closeOtpOnCopy' => false, 'closeOtpOnCopy' => false,
'copyOtpOnDisplay' => false, 'copyOtpOnDisplay' => false,

View File

@ -346,10 +346,6 @@ img.qrcode {
vertical-align: sub; vertical-align: sub;
} }
.tfa-container span {
display: block;
}
.fullscreen-streamer { .fullscreen-streamer {
position: fixed; position: fixed;
top: 7%; 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 { :root[data-theme="dark"] .table {
background-color: $black-ter; background-color: $black-ter;
color: $white-bis; color: $white-bis;

View File

@ -45,10 +45,12 @@
image : '' image : ''
}) })
const password = ref('') const password = ref('')
const next_password = ref('')
const generated_at = ref(null) const generated_at = ref(null)
const hasTOTP = ref(false) const hasTOTP = ref(false)
const showInlineSpinner = ref(false) const showMainSpinner = ref(false)
const revealPassword = ref(false) const revealPassword = ref(false)
const opacity = ref('0')
const dots = ref() const dots = ref()
const totpLooper = ref() const totpLooper = ref()
@ -136,11 +138,22 @@
* Requests and handles a fresh OTP * Requests and handles a fresh OTP
*/ */
async function getOtp() { 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 => { await getOtpPromise().then(response => {
let otp = response.data let otp = response.data
password.value = otp.password password.value = otp.password
next_password.value = otp.next_password
if(user.preferences.copyOtpOnDisplay) { if(user.preferences.copyOtpOnDisplay) {
copyOTP(otp.password) copyOTP(otp.password)
@ -169,7 +182,7 @@
//throw error //throw error
}) })
.finally(() => { .finally(() => {
showInlineSpinner.value = false showMainSpinner.value = false
}) })
} }
@ -177,7 +190,7 @@
* Shows blacked dots and a loading spinner * Shows blacked dots and a loading spinner
*/ */
function setLoadingState() { function setLoadingState() {
showInlineSpinner.value = true showMainSpinner.value = true
dots.value.turnOff() dots.value.turnOff()
} }
@ -212,6 +225,7 @@
id.value = otpauthParams.value.counter = generated_at.value = null 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 = '' otpauthParams.value.service = otpauthParams.value.account = otpauthParams.value.icon = otpauthParams.value.otp_type = otpauthParams.value.secret = ''
password.value = '... ...' password.value = '... ...'
next_password.value = ''
hasTOTP.value = false hasTOTP.value = false
clearTimeout(autoCloseTimeout.value) clearTimeout(autoCloseTimeout.value)
@ -280,6 +294,7 @@
*/ */
function turnDotOn(dotIndex) { function turnDotOn(dotIndex) {
dots.value.turnOn(dotIndex) dots.value.turnOn(dotIndex)
opacity.value = 'is-opacity-' + dotIndex
} }
defineExpose({ 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 class="is-size-6 has-ellipsis" :class="mode == 'dark' ? 'has-text-grey' : 'has-text-grey-light'">{{ otpauthParams.account }}</p>
<p> <p>
<span <span
v-if="!showInlineSpinner" v-if="!showMainSpinner"
id="otp" id="otp"
role="log" role="log"
ref="otpSpanTag" ref="otpSpanTag"
@ -324,7 +339,7 @@
{{ useDisplayablePassword(password, user.preferences.showOtpAsDot && user.preferences.revealDottedOTP && revealPassword) }} {{ useDisplayablePassword(password, user.preferences.showOtpAsDot && user.preferences.revealDottedOTP && revealPassword) }}
</span> </span>
<span v-else tabindex="0" class="otp is-size-1"> <span v-else tabindex="0" class="otp is-size-1">
<Spinner :isVisible="showInlineSpinner" :type="'raw'" /> <Spinner :isVisible="showMainSpinner" :type="'raw'" />
</span> </span>
</p> </p>
</UseColorMode> </UseColorMode>
@ -332,6 +347,20 @@
<p v-show="isHMacBased(otpauthParams.otp_type)"> <p v-show="isHMacBased(otpauthParams.otp_type)">
{{ $t('twofaccounts.forms.counter.label') }}: {{ otpauthParams.counter }} {{ $t('twofaccounts.forms.counter.label') }}: {{ otpauthParams.counter }}
</p> </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>&nbsp;</span>
</p>
<p v-if="user.preferences.showOtpAsDot && user.preferences.revealDottedOTP" class="mt-3"> <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"> <button type="button" class="button is-ghost has-text-grey-dark" @click.stop="revealPassword = !revealPassword">
<font-awesome-icon v-if="revealPassword" :icon="['fas', 'eye']" /> <font-awesome-icon v-if="revealPassword" :icon="['fas', 'eye']" />

View File

@ -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" /> <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 --> <!-- 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" /> <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> <h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('settings.notifications') }}</h4>
<!-- on new device --> <!-- on new device -->

View File

@ -32,9 +32,9 @@
const showGroupSwitch = ref(false) const showGroupSwitch = ref(false)
const showDestinationGroupSelector = ref(false) const showDestinationGroupSelector = ref(false)
const isDragging = ref(false) const isDragging = ref(false)
const isRenewingOTPs = ref(false)
const renewedPeriod = ref(null) const renewedPeriod = ref(null)
const revealPassword = ref(null) const revealPassword = ref(null)
const opacities = ref({})
const otpDisplay = ref(null) const otpDisplay = ref(null)
const otpDisplayProps = ref({ const otpDisplayProps = ref({
@ -230,6 +230,10 @@
.forEach((dot) => { .forEach((dot) => {
dot.turnOn(stepIndex) 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 * Updates "Always On" OTPs for all TOTP accounts and (re)starts loopers
*/ */
async function updateTotps(period) { async function updateTotps(period) {
isRenewingOTPs.value = true
turnDotsOff(period)
let fetchPromise let fetchPromise
if (period == undefined) { if (period == undefined) {
@ -259,6 +261,22 @@
fetchPromise = twofaccountService.getByIds(twofaccounts.accountIdsWithPeriod(period).join(','), true) 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 => { fetchPromise.then(response => {
let generatedAt = 0 let generatedAt = 0
@ -284,7 +302,6 @@
}) })
}) })
.finally(() => { .finally(() => {
isRenewingOTPs.value = false
renewedPeriod.value = null 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-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=""> <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']" /> {{ 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>
</div> </div>
<transition name="popLater"> <transition name="popLater">
<div v-show="user.preferences.getOtpOnRequest == false && !bus.inManagementMode" class="has-text-right"> <div v-show="user.preferences.getOtpOnRequest == false && !bus.inManagementMode" class="has-text-right">
<span v-if="account.otp != undefined"> <div 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"> <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')">
<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')">
{{ useDisplayablePassword(account.otp.password, user.preferences.showOtpAsDot && user.preferences.revealDottedOTP && revealPassword == account.id) }} {{ useDisplayablePassword(account.otp.password, user.preferences.showOtpAsDot && user.preferences.revealDottedOTP && revealPassword == account.id) }}
</span> </div>
<Dots <div class="has-nowrap" style="line-height: 0.9;">
v-if="account.otp_type.includes('totp')" <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')">
:class="'condensed'" {{ useDisplayablePassword(account.otp.next_password, user.preferences.showOtpAsDot && user.preferences.revealDottedOTP && revealPassword == account.id) }}
ref="dotsRefs" </span>
:period="account.period" /> <Dots
</span> v-if="account.otp_type.includes('totp')"
<span v-else> :class="'condensed is-inline-block'"
ref="dotsRefs"
:period="account.period" />
</div>
</div>
<div v-else>
<!-- get hotp button --> <!-- 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')"> <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') }} {{ $t('commons.generate') }}
</button> </button>
</span> </div>
</div> </div>
</transition> </transition>
<transition name="popLater" v-if="user.preferences.showOtpAsDot && user.preferences.revealDottedOTP"> <transition name="popLater" v-if="user.preferences.showOtpAsDot && user.preferences.revealDottedOTP">

View File

@ -91,4 +91,5 @@ return [
'one_month' => '1 mo.', 'one_month' => '1 mo.',
'x_month' => ':x mos.', 'x_month' => ':x mos.',
'one_year' => '1 yr.', 'one_year' => '1 yr.',
'copy_next_password' => 'Copy next password to clipboard',
]; ];

View File

@ -59,7 +59,7 @@ return [
], ],
'show_otp_as_dot' => [ 'show_otp_as_dot' => [
'label' => 'Show generated <abbr title="One-Time Password">OTP</abbr> 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' => [ 'reveal_dotted_otp' => [
'label' => 'Reveal obscured <abbr title="One-Time Password">OTP</abbr>', 'label' => 'Reveal obscured <abbr title="One-Time Password">OTP</abbr>',
@ -69,6 +69,10 @@ return [
'label' => 'Close <abbr title="One-Time Password">OTP</abbr> after copy', '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' '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' => [ 'auto_close_timeout' => [
'label' => 'Auto close <abbr title="One-Time Password">OTP</abbr>', '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.' 'help' => 'Automatically hide on-screen password after a timeout. This avoids unnecessary requests for fresh passwords if you forget to close the password view.'

View File

@ -112,6 +112,7 @@ class TwoFAccountControllerTest extends FeatureTestCase
private const VALID_EMBEDDED_OTP_RESOURCE_STRUCTURE_FOR_TOTP = [ private const VALID_EMBEDDED_OTP_RESOURCE_STRUCTURE_FOR_TOTP = [
'generated_at', 'generated_at',
'password', 'password',
'next_password',
]; ];
private const VALID_OTP_RESOURCE_STRUCTURE_FOR_HOTP = [ private const VALID_OTP_RESOURCE_STRUCTURE_FOR_HOTP = [