OtpDisplayer.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. <template>
  2. <div>
  3. <figure class="image is-64x64" :class="{ 'no-icon': !internal_icon }" style="display: inline-block">
  4. <img :src="'/storage/icons/' + internal_icon" v-if="internal_icon">
  5. </figure>
  6. <p class="is-size-4 has-text-grey-light has-ellipsis">{{ internal_service }}</p>
  7. <p class="is-size-6 has-text-grey has-ellipsis">{{ internal_account }}</p>
  8. <p class="is-size-1 has-text-white is-clickable" :title="$t('commons.copy_to_clipboard')" v-clipboard="() => password.replace(/ /g, '')" v-clipboard:success="clipboardSuccessHandler">{{ displayedOtp }}</p>
  9. <ul class="dots" v-show="internal_otp_type === 'totp'">
  10. <li v-for="n in 10"></li>
  11. </ul>
  12. <ul v-show="internal_otp_type === 'hotp'">
  13. <li>counter: {{ internal_counter }}</li>
  14. </ul>
  15. </div>
  16. </template>
  17. <script>
  18. export default {
  19. name: 'OtpDisplayer',
  20. data() {
  21. return {
  22. internal_id: null,
  23. internal_otp_type: '',
  24. internal_account: '',
  25. internal_service: '',
  26. internal_icon: '',
  27. internal_secret: null,
  28. internal_digits: null,
  29. internal_algorithm: null,
  30. internal_period: null,
  31. internal_counter: null,
  32. internal_password : '',
  33. internal_uri : '',
  34. lastActiveDot: null,
  35. remainingTimeout: null,
  36. firstDotToNextOneTimeout: null,
  37. dotToDotInterval: null
  38. }
  39. },
  40. props: {
  41. otp_type : String,
  42. account : String,
  43. service : String,
  44. icon : String,
  45. secret : String,
  46. digits : Number,
  47. algorithm : String,
  48. period : null,
  49. counter : null,
  50. image : String,
  51. qrcode : null,
  52. secretIsBase32Encoded : Number,
  53. uri : String
  54. },
  55. computed: {
  56. displayedOtp() {
  57. const spacePosition = Math.ceil(this.internal_password.length / 2)
  58. let pwd = this.internal_password.substr(0, spacePosition) + " " + this.internal_password.substr(spacePosition)
  59. return this.$root.appSettings.showOtpAsDot ? pwd.replace(/[0-9]/g, '●') : pwd
  60. }
  61. },
  62. mounted: function() {
  63. this.show()
  64. },
  65. methods: {
  66. async show(id) {
  67. // 3 possible cases :
  68. // - Trigger when user ask for an otp of an existing account: the ID is provided so we fetch the account data
  69. // from db but without the uri.
  70. // This prevent the uri (a sensitive data) to transit via http request unnecessarily. In this
  71. // case this.otp_type is sent by the backend.
  72. // - Trigger when user use the Quick Uploader and preview the account: No ID but we have an URI.
  73. // - Trigger when user use the Advanced form and preview the account: We should have all OTP parameter
  74. // to obtain an otp, including Secret and otp_type which are required
  75. this.internal_otp_type = this.otp_type
  76. this.internal_account = this.account
  77. this.internal_service = this.service
  78. this.internal_icon = this.icon
  79. this.internal_secret = this.secret
  80. this.internal_digits = this.digits
  81. this.internal_algorithm = this.algorithm
  82. this.internal_period = this.period
  83. this.internal_counter = this.counter
  84. if( id ) {
  85. this.internal_id = id
  86. const { data } = await this.axios.get('api/twofaccounts/' + this.internal_id)
  87. this.internal_service = data.service
  88. this.internal_account = data.account
  89. this.internal_icon = data.icon
  90. this.internal_otp_type = data.otp_type
  91. if( data.otp_type === 'hotp' && data.counter ) {
  92. this.internal_counter = data.counter
  93. }
  94. }
  95. // We force the otp_type to be based on the uri
  96. if( this.uri ) {
  97. this.internal_uri = this.uri
  98. this.internal_otp_type = this.uri.slice(0, 15 ).toLowerCase() === "otpauth://totp/" ? 'totp' : 'hotp';
  99. }
  100. if( this.internal_id || this.uri || this.secret ) { // minimun required vars to get an otp from the backend
  101. switch(this.internal_otp_type) {
  102. case 'totp':
  103. await this.startTotpLoop()
  104. break;
  105. case 'hotp':
  106. await this.getHOTP()
  107. break;
  108. default:
  109. this.$router.push({ name: 'genericError', params: { err: this.$t('errors.not_a_supported_otp_type') } });
  110. }
  111. this.$parent.isActive = true
  112. }
  113. },
  114. getOtp: async function() {
  115. if(this.internal_id) {
  116. const { data } = await this.axios.get('/api/twofaccounts/' + this.internal_id + '/otp')
  117. return data
  118. }
  119. else if(this.internal_uri) {
  120. const { data } = await this.axios.post('/api/twofaccounts/otp', {
  121. uri: this.internal_uri
  122. })
  123. return data
  124. }
  125. else {
  126. const { data } = await this.axios.post('/api/twofaccounts/otp', {
  127. service : this.internal_service,
  128. account : this.internal_account,
  129. icon : this.internal_icon,
  130. otp_type : this.internal_otp_type,
  131. secret : this.internal_secret,
  132. digits : this.internal_digits,
  133. algorithm : this.internal_algorithm,
  134. period : this.internal_period,
  135. counter : this.internal_counter,
  136. })
  137. return data
  138. }
  139. },
  140. startTotpLoop: async function() {
  141. let otp = await this.getOtp()
  142. this.internal_password = otp.password
  143. this.internal_otp_type = otp.otp_type
  144. let generated_at = otp.generated_at
  145. let period = otp.period
  146. let elapsedTimeInCurrentPeriod,
  147. remainingTimeBeforeEndOfPeriod,
  148. durationBetweenTwoDots,
  149. durationFromFirstToNextDot,
  150. dots
  151. // |<----period p----->|
  152. // | | |
  153. // |------- ··· ------------|--------|----------|---------->
  154. // | | | |
  155. // unix T0 Tp.start Tgen_at Tp.end
  156. // | | |
  157. // elapsedTimeInCurrentPeriod--|<------>| |
  158. // (in ms) | | |
  159. // ● ● ● ● ●|● ◌ ◌ ◌ ◌ |
  160. // | | || |
  161. // | | |<-------->|--remainingTimeBeforeEndOfPeriod (for remainingTimeout)
  162. // durationBetweenTwoDots-->|-|< ||
  163. // (for dotToDotInterval) | | >||<---durationFromFirstToNextDot (for firstDotToNextOneTimeout)
  164. // |
  165. // |
  166. // dotIndex
  167. // The elapsed time from the start of the period that contains the OTP generated_at timestamp and the OTP generated_at timestamp itself
  168. elapsedTimeInCurrentPeriod = generated_at % period
  169. // Switch off all dots
  170. dots = this.$el.querySelector('.dots')
  171. while (dots.querySelector('[data-is-active]')) {
  172. dots.querySelector('[data-is-active]').removeAttribute('data-is-active');
  173. }
  174. // We determine the position of the closest dot next to the generated_at timestamp
  175. let relativePosition = (elapsedTimeInCurrentPeriod * 10) / period
  176. let dotIndex = (Math.floor(relativePosition) +1)
  177. // We switch the dot on
  178. this.lastActiveDot = dots.querySelector('li:nth-child(' + dotIndex + ')');
  179. this.lastActiveDot.setAttribute('data-is-active', true);
  180. // Main timeout that run until the end of the period
  181. remainingTimeBeforeEndOfPeriod = period - elapsedTimeInCurrentPeriod
  182. let self = this; // because of the setInterval/setTimeout closures
  183. this.remainingTimeout = setTimeout(function() {
  184. self.stopLoop()
  185. self.startTotpLoop();
  186. }, remainingTimeBeforeEndOfPeriod*1000);
  187. // During the remainingTimeout countdown we have to show a next dot every durationBetweenTwoDots seconds
  188. // except for the first next dot
  189. durationBetweenTwoDots = period / 10 // we have 10 dots
  190. durationFromFirstToNextDot = (Math.ceil(elapsedTimeInCurrentPeriod / durationBetweenTwoDots) * durationBetweenTwoDots) - elapsedTimeInCurrentPeriod
  191. this.firstDotToNextOneTimeout = setTimeout(function() {
  192. if( durationFromFirstToNextDot > 0 ) {
  193. self.activateNextDot()
  194. dotIndex += 1
  195. }
  196. self.dotToDotInterval = setInterval(function() {
  197. self.activateNextDot()
  198. dotIndex += 1
  199. }, durationBetweenTwoDots*1000)
  200. }, durationFromFirstToNextDot*1000)
  201. },
  202. getHOTP: async function() {
  203. let otp = await this.getOtp()
  204. // returned counter & uri are incremented
  205. this.$emit('increment-hotp', { nextHotpCounter: otp.counter, nextUri: otp.uri })
  206. },
  207. clearOTP: function() {
  208. this.stopLoop()
  209. this.internal_id = this.remainingTimeout = this.dotToDotInterval = this.firstDotToNextOneTimeout = this.elapsedTimeInCurrentPeriod = this.internal_counter = null
  210. this.internal_service = this.internal_account = this.internal_icon = this.internal_otp_type = ''
  211. this.internal_password = '... ...'
  212. try {
  213. this.$el.querySelector('[data-is-active]').removeAttribute('data-is-active');
  214. this.$el.querySelector('.dots li:first-child').setAttribute('data-is-active', true);
  215. }
  216. catch(e) {
  217. // we do not throw anything
  218. }
  219. },
  220. stopLoop: function() {
  221. if( this.internal_otp_type === 'totp' ) {
  222. clearTimeout(this.remainingTimeout)
  223. clearTimeout(this.firstDotToNextOneTimeout)
  224. clearInterval(this.dotToDotInterval)
  225. }
  226. },
  227. activateNextDot: function() {
  228. if(this.lastActiveDot.nextSibling !== null) {
  229. this.lastActiveDot.removeAttribute('data-is-active')
  230. this.lastActiveDot.nextSibling.setAttribute('data-is-active', true)
  231. this.lastActiveDot = this.lastActiveDot.nextSibling
  232. }
  233. },
  234. clipboardSuccessHandler ({ value, event }) {
  235. if(this.$root.appSettings.kickUserAfter == -1) {
  236. this.appLogout()
  237. }
  238. else if(this.$root.appSettings.closeOtpOnCopy) {
  239. this.$parent.isActive = false
  240. this.clearOTP()
  241. }
  242. this.$notify({ type: 'is-success', text: this.$t('commons.copied_to_clipboard') })
  243. },
  244. clipboardErrorHandler ({ value, event }) {
  245. console.log('error', value)
  246. }
  247. },
  248. beforeDestroy () {
  249. this.stopLoop()
  250. }
  251. }
  252. </script>