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 @@ 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,
]);
}
),
],

View File

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

View File

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

View File

@ -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;
}

View File

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

View File

@ -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;

View File

@ -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>&nbsp;</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']" />

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" />
<!-- 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 -->

View File

@ -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">

View File

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

View File

@ -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.'

View File

@ -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 = [