123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364 |
- <template>
- <div>
- <figure class="image is-64x64" :class="{ 'no-icon': !internal_icon }" style="display: inline-block">
- <img :src="$root.appConfig.subdirectory + '/storage/icons/' + internal_icon" v-if="internal_icon" :alt="$t('twofaccounts.icon_to_illustrate_the_account')">
- </figure>
- <p class="is-size-4 has-ellipsis" :class="$root.showDarkMode ? 'has-text-grey-light' : 'has-text-grey'">{{ internal_service }}</p>
- <p class="is-size-6 has-ellipsis" :class="$root.showDarkMode ? 'has-text-grey' : 'has-text-grey-light'">{{ internal_account }}</p>
- <p>
- <span role="log" ref="otp" tabindex="0" class="otp is-size-1 is-clickable px-3" :class="$root.showDarkMode ? 'has-text-white' : 'has-text-grey-dark'" @click="copyOTP(internal_password)" @keyup.enter="copyOTP(internal_password)" :title="$t('commons.copy_to_clipboard')">
- {{ displayedOtp }}
- </span>
- </p>
- <ul class="dots" v-show="isTimeBased(internal_otp_type)">
- <li v-for="n in 10" :key="n"></li>
- </ul>
- <ul v-show="isHMacBased(internal_otp_type)">
- <li>counter: {{ internal_counter }}</li>
- </ul>
- </div>
- </template>
- <script>
- export default {
- name: 'OtpDisplayer',
- data() {
- return {
- internal_id: null,
- internal_otp_type: '',
- internal_account: '',
- internal_service: '',
- internal_icon: '',
- internal_secret: null,
- internal_digits: null,
- internal_algorithm: null,
- internal_period: null,
- internal_counter: null,
- internal_password : '',
- internal_uri : '',
- lastActiveDot: null,
- remainingTimeout: null,
- firstDotToNextOneTimeout: null,
- dotToDotInterval: null
- }
- },
- props: {
- otp_type : String,
- account : String,
- service : String,
- icon : String,
- secret : String,
- digits : Number,
- algorithm : String,
- period : null,
- counter : null,
- image : String,
- qrcode : null,
- uri : String
- },
- computed: {
- displayedOtp() {
- let pwd = this.internal_password
- if (this.internal_otp_type !== 'steamtotp') {
- const spacePosition = Math.ceil(this.internal_password.length / 2)
- pwd = this.internal_password.substr(0, spacePosition) + " " + this.internal_password.substr(spacePosition)
- }
- return this.$root.appSettings.showOtpAsDot ? pwd.replace(/[0-9]/g, '●') : pwd
- },
- },
- mounted: function() {
- this.show()
- },
- // created() {
- // },
- methods: {
- copyOTP (otp) {
- // see https://web.dev/async-clipboard/ for futur Clipboard API usage.
- // The API should allow to copy the password on each trip without user interaction.
- // For now too many browsers don't support the clipboard-write permission
- // (see https://developer.mozilla.org/en-US/docs/Web/API/Permissions#browser_support)
- const rawOTP = otp.replace(/ /g, '')
- const success = this.$clipboard(rawOTP)
- if (success == true) {
- if(this.$root.appSettings.kickUserAfter == -1) {
- this.appLogout()
- }
- else if(this.$root.appSettings.closeOtpOnCopy) {
- this.$parent.isActive = false
- this.clearOTP()
- }
- this.$notify({ type: 'is-success', text: this.$t('commons.copied_to_clipboard') })
- }
- },
- isTimeBased: function(otp_type) {
- return (otp_type === 'totp' || otp_type === 'steamtotp')
- },
- isHMacBased: function(otp_type) {
- return otp_type === 'hotp'
- },
- async show(id) {
- // 3 possible cases :
- // - Trigger when user ask for an otp of an existing account: the ID is provided so we fetch the account data
- // from db but without the uri.
- // This prevent the uri (a sensitive data) to transit via http request unnecessarily. In this
- // case this.otp_type is sent by the backend.
- // - Trigger when user use the Quick Uploader and preview the account: No ID but we have an URI.
- // - Trigger when user use the Advanced form and preview the account: We should have all OTP parameter
- // to obtain an otp, including Secret and otp_type which are required
- this.internal_otp_type = this.otp_type
- this.internal_account = this.account
- this.internal_service = this.service
- this.internal_icon = this.icon
- this.internal_secret = this.secret
- this.internal_digits = this.digits
- this.internal_algorithm = this.algorithm
- this.internal_period = this.period
- this.internal_counter = this.counter
- if( id ) {
- this.internal_id = id
- const { data } = await this.axios.get('api/v1/twofaccounts/' + this.internal_id)
- this.internal_service = data.service
- this.internal_account = data.account
- this.internal_icon = data.icon
- this.internal_otp_type = data.otp_type
-
- if( this.isHMacBased(data.otp_type) && data.counter ) {
- this.internal_counter = data.counter
- }
- }
- // We force the otp_type to be based on the uri
- if( this.uri ) {
- this.internal_uri = this.uri
- this.internal_otp_type = this.uri.slice(0, 15 ).toLowerCase() === "otpauth://totp/" ? 'totp' : 'hotp';
- }
- if( this.internal_id || this.uri || this.secret ) { // minimun required vars to get an otp from the backend
- try {
- if(this.isTimeBased(this.internal_otp_type)) {
- await this.startTotpLoop()
- }
- else if(this.isHMacBased(this.internal_otp_type)) {
- await this.getHOTP()
- }
- else this.$router.push({ name: 'genericError', params: { err: this.$t('errors.not_a_supported_otp_type') } });
-
- this.$parent.isActive = true
- this.focusOnOTP()
- }
- catch(error) {
- this.clearOTP()
- }
- }
- },
- getOtp: async function() {
- try {
- let request, password
- if(this.internal_id) {
- request = {
- method: 'get',
- url: '/api/v1/twofaccounts/' + this.internal_id + '/otp'
- }
- }
- else if(this.internal_uri) {
- request = {
- method: 'post',
- url: '/api/v1/twofaccounts/otp',
- data: {
- uri: this.internal_uri
- }
- }
- }
- else {
- request = {
- method: 'post',
- url: '/api/v1/twofaccounts/otp',
- data: {
- service : this.internal_service,
- account : this.internal_account,
- icon : this.internal_icon,
- otp_type : this.internal_otp_type,
- secret : this.internal_secret,
- digits : this.internal_digits,
- algorithm : this.internal_algorithm,
- period : this.internal_period,
- counter : this.internal_counter,
- }
- }
- }
- await this.axios(request).then(response => {
- if(this.$root.appSettings.copyOtpOnDisplay) {
- this.copyOTP(response.data.password)
- }
- password = response.data
- })
- return password
- }
- catch(error) {
- if (error.response.status === 422) {
- this.$emit('validation-error', error.response)
- }
- throw error
- }
- },
- startTotpLoop: async function() {
-
- let otp = await this.getOtp()
- this.internal_password = otp.password
- this.internal_otp_type = otp.otp_type
- let generated_at = otp.generated_at
- let period = otp.period
- let elapsedTimeInCurrentPeriod,
- remainingTimeBeforeEndOfPeriod,
- durationBetweenTwoDots,
- durationFromFirstToNextDot,
- dots
- // |<----period p----->|
- // | | |
- // |------- ··· ------------|--------|----------|---------->
- // | | | |
- // unix T0 Tp.start Tgen_at Tp.end
- // | | |
- // elapsedTimeInCurrentPeriod--|<------>| |
- // (in ms) | | |
- // ● ● ● ● ●|● ◌ ◌ ◌ ◌ |
- // | | || |
- // | | |<-------->|--remainingTimeBeforeEndOfPeriod (for remainingTimeout)
- // durationBetweenTwoDots-->|-|< ||
- // (for dotToDotInterval) | | >||<---durationFromFirstToNextDot (for firstDotToNextOneTimeout)
- // |
- // |
- // dotIndex
- // The elapsed time from the start of the period that contains the OTP generated_at timestamp and the OTP generated_at timestamp itself
- elapsedTimeInCurrentPeriod = generated_at % period
- // Switch off all dots
- dots = this.$el.querySelector('.dots')
- while (dots.querySelector('[data-is-active]')) {
- dots.querySelector('[data-is-active]').removeAttribute('data-is-active');
- }
- // We determine the position of the closest dot next to the generated_at timestamp
- let relativePosition = (elapsedTimeInCurrentPeriod * 10) / period
- let dotIndex = (Math.floor(relativePosition) +1)
-
- // We switch the dot on
- this.lastActiveDot = dots.querySelector('li:nth-child(' + dotIndex + ')');
- this.lastActiveDot.setAttribute('data-is-active', true);
- // Main timeout that run until the end of the period
- remainingTimeBeforeEndOfPeriod = period - elapsedTimeInCurrentPeriod
- let self = this; // because of the setInterval/setTimeout closures
- this.remainingTimeout = setTimeout(function() {
- self.stopLoop()
- self.startTotpLoop();
- }, remainingTimeBeforeEndOfPeriod*1000);
- // During the remainingTimeout countdown we have to show a next dot every durationBetweenTwoDots seconds
- // except for the first next dot
- durationBetweenTwoDots = period / 10 // we have 10 dots
- durationFromFirstToNextDot = (Math.ceil(elapsedTimeInCurrentPeriod / durationBetweenTwoDots) * durationBetweenTwoDots) - elapsedTimeInCurrentPeriod
- this.firstDotToNextOneTimeout = setTimeout(function() {
- if( durationFromFirstToNextDot > 0 ) {
- self.activateNextDot()
- dotIndex += 1
- }
- self.dotToDotInterval = setInterval(function() {
- self.activateNextDot()
- dotIndex += 1
- }, durationBetweenTwoDots*1000)
- }, durationFromFirstToNextDot*1000)
- },
- getHOTP: async function() {
- let otp = await this.getOtp()
- this.internal_password = otp.password
- this.internal_counter = otp.counter
- // returned counter & uri are incremented
- this.$emit('increment-hotp', { nextHotpCounter: otp.counter, nextUri: otp.uri })
- },
- clearOTP: function() {
- this.stopLoop()
- this.internal_id = this.remainingTimeout = this.dotToDotInterval = this.firstDotToNextOneTimeout = this.elapsedTimeInCurrentPeriod = this.internal_counter = null
- this.internal_service = this.internal_account = this.internal_icon = this.internal_otp_type = this.internal_secret = ''
- this.internal_password = '... ...'
- try {
- this.$el.querySelector('[data-is-active]').removeAttribute('data-is-active');
- this.$el.querySelector('.dots li:first-child').setAttribute('data-is-active', true);
- }
- catch(e) {
- // we do not throw anything
- }
- },
- stopLoop: function() {
- if( this.isTimeBased(this.internal_otp_type) ) {
- clearTimeout(this.remainingTimeout)
- clearTimeout(this.firstDotToNextOneTimeout)
- clearInterval(this.dotToDotInterval)
- }
- },
- activateNextDot: function() {
- if(this.lastActiveDot.nextSibling !== null) {
- this.lastActiveDot.removeAttribute('data-is-active')
- this.lastActiveDot.nextSibling.setAttribute('data-is-active', true)
- this.lastActiveDot = this.lastActiveDot.nextSibling
- }
- },
- focusOnOTP() {
- this.$nextTick(() => {
- this.$refs.otp.focus()
- })
- }
- },
- beforeDestroy () {
- this.stopLoop()
- }
- }
- </script>
|