diff --git a/app/Api/v1/Resources/TwoFAccountReadResource.php b/app/Api/v1/Resources/TwoFAccountReadResource.php index 773be357..3dd98e06 100644 --- a/app/Api/v1/Resources/TwoFAccountReadResource.php +++ b/app/Api/v1/Resources/TwoFAccountReadResource.php @@ -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, + ]); } ), ], diff --git a/app/Models/Dto/OtpDto.php b/app/Models/Dto/OtpDto.php index f5628735..1803fd69 100644 --- a/app/Models/Dto/OtpDto.php +++ b/app/Models/Dto/OtpDto.php @@ -4,9 +4,9 @@ class OtpDto { - /* @var integer */ + /* @var string */ public string $password; - /* @var integer */ + /* @var string */ public string $otp_type; } diff --git a/app/Models/Dto/TotpDto.php b/app/Models/Dto/TotpDto.php index 3300dd8d..c7a834eb 100644 --- a/app/Models/Dto/TotpDto.php +++ b/app/Models/Dto/TotpDto.php @@ -9,4 +9,7 @@ class TotpDto extends OtpDto /* @var integer */ public int $period; + + /* @var string */ + public string $next_password; } diff --git a/app/Models/TwoFAccount.php b/app/Models/TwoFAccount.php index d0792c86..17baae80 100644 --- a/app/Models/TwoFAccount.php +++ b/app/Models/TwoFAccount.php @@ -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; } diff --git a/config/2fauth.php b/config/2fauth.php index 4779966a..2644cbd0 100644 --- a/config/2fauth.php +++ b/config/2fauth.php @@ -109,6 +109,7 @@ 'preferences' => [ 'showOtpAsDot' => false, + 'showNextOtp' => false, 'revealDottedOTP' => false, 'closeOtpOnCopy' => false, 'copyOtpOnDisplay' => false, diff --git a/resources/js/assets/app.scss b/resources/js/assets/app.scss index 6d8609e4..0e170626 100644 --- a/resources/js/assets/app.scss +++ b/resources/js/assets/app.scss @@ -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; diff --git a/resources/js/components/OtpDisplay.vue b/resources/js/components/OtpDisplay.vue index 4adf6075..7b8023ee 100644 --- a/resources/js/components/OtpDisplay.vue +++ b/resources/js/components/OtpDisplay.vue @@ -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 @@
{{ otpauthParams.account }}
-
{{ $t('twofaccounts.forms.counter.label') }}: {{ otpauthParams.counter }}
++ + {{ useDisplayablePassword(next_password, user.preferences.showOtpAsDot && user.preferences.revealDottedOTP && revealPassword) }} + + + +