Skip to content

Commit

Permalink
Pre-calculate next OTP & Add user pref to show/hide it - Closes #416
Browse files Browse the repository at this point in the history
  • Loading branch information
Bubka committed Mar 3, 2025
1 parent 47a13b8 commit 46c1131
Show file tree
Hide file tree
Showing 12 changed files with 140 additions and 39 deletions.
6 changes: 5 additions & 1 deletion app/Api/v1/Resources/TwoFAccountReadResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);
}
),
],
Expand Down
4 changes: 2 additions & 2 deletions app/Models/Dto/OtpDto.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

class OtpDto
{
/* @var integer */
/* @var string */
public string $password;

/* @var integer */
/* @var string */
public string $otp_type;
}
3 changes: 3 additions & 0 deletions app/Models/Dto/TotpDto.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,7 @@ class TotpDto extends OtpDto

/* @var integer */
public int $period;

/* @var string */
public string $next_password;
}
19 changes: 13 additions & 6 deletions app/Models/TwoFAccount.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
1 change: 1 addition & 0 deletions config/2fauth.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@

'preferences' => [
'showOtpAsDot' => false,
'showNextOtp' => false,
'revealDottedOTP' => false,
'closeOtpOnCopy' => false,
'copyOtpOnDisplay' => false,
Expand Down
38 changes: 34 additions & 4 deletions resources/js/assets/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -346,10 +346,6 @@ img.qrcode {
vertical-align: sub;
}

.tfa-container span {
display: block;
}

.fullscreen-streamer {
position: fixed;
top: 7%;
Expand Down Expand Up @@ -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;
Expand Down
41 changes: 35 additions & 6 deletions resources/js/components/OtpDisplay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -169,15 +182,15 @@
//throw error
})
.finally(() => {
showInlineSpinner.value = false
showMainSpinner.value = false
})
}
/**
* Shows blacked dots and a loading spinner
*/
function setLoadingState() {
showInlineSpinner.value = true
showMainSpinner.value = true
dots.value.turnOff()
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -280,6 +294,7 @@
*/
function turnDotOn(dotIndex) {
dots.value.turnOn(dotIndex)
opacity.value = 'is-opacity-' + dotIndex
}
defineExpose({
Expand Down Expand Up @@ -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"
Expand All @@ -324,14 +339,28 @@
{{ 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>
<Dots v-show="isTimeBased(otpauthParams.otp_type)" ref="dots"></Dots>
<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']" />
Expand Down
2 changes: 2 additions & 0 deletions resources/js/views/settings/Options.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 -->
Expand Down
57 changes: 38 additions & 19 deletions resources/js/views/twofaccounts/Accounts.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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
}
/**
Expand All @@ -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) {
Expand All @@ -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
Expand All @@ -284,7 +302,6 @@
})
})
.finally(() => {
isRenewingOTPs.value = false
renewedPeriod.value = null
})
}
Expand Down Expand Up @@ -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">
Expand Down
1 change: 1 addition & 0 deletions resources/lang/en/commons.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,5 @@
'one_month' => '1 mo.',
'x_month' => ':x mos.',
'one_year' => '1 yr.',
'copy_next_password' => 'Copy next password to clipboard',
];
6 changes: 5 additions & 1 deletion resources/lang/en/settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -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>',
Expand All @@ -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.'
Expand Down
1 change: 1 addition & 0 deletions tests/Api/v1/Controllers/TwoFAccountControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down

0 comments on commit 46c1131

Please sign in to comment.