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

- +

@@ -332,6 +347,20 @@

{{ $t('twofaccounts.forms.counter.label') }}: {{ otpauthParams.counter }}

+

+ + {{ useDisplayablePassword(next_password, user.preferences.showOtpAsDot && user.preferences.revealDottedOTP && revealPassword) }} + + +   +

- + diff --git a/resources/lang/en/commons.php b/resources/lang/en/commons.php index 17c0c45e..7e92efdb 100644 --- a/resources/lang/en/commons.php +++ b/resources/lang/en/commons.php @@ -91,4 +91,5 @@ 'one_month' => '1 mo.', 'x_month' => ':x mos.', 'one_year' => '1 yr.', + 'copy_next_password' => 'Copy next password to clipboard', ]; diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index 811c4b41..1f0398cd 100644 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -59,7 +59,7 @@ ], 'show_otp_as_dot' => [ 'label' => 'Show generated OTP 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 OTP', @@ -69,6 +69,10 @@ 'label' => 'Close OTP after copy', 'help' => 'Click on a generated password to copy it automatically hides it from the screen' ], + 'show_next_otp' => [ + 'label' => 'Show next OTP', + '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 OTP', 'help' => 'Automatically hide on-screen password after a timeout. This avoids unnecessary requests for fresh passwords if you forget to close the password view.' diff --git a/tests/Api/v1/Controllers/TwoFAccountControllerTest.php b/tests/Api/v1/Controllers/TwoFAccountControllerTest.php index dabd463e..b6b4924f 100644 --- a/tests/Api/v1/Controllers/TwoFAccountControllerTest.php +++ b/tests/Api/v1/Controllers/TwoFAccountControllerTest.php @@ -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 = [