OtpDisplayer.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  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" :alt="$t('twofaccounts.icon_to_illustrate_the_account')">
  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 tabindex="0" class="is-size-1 has-text-white is-clickable" :title="$t('commons.copy_to_clipboard')" v-clipboard="() => internal_password.replace(/ /g, '')" v-clipboard:success="clipboardSuccessHandler">{{ displayedOtp }}</p>
  9. <ul class="dots" v-show="isTimeBased(internal_otp_type)">
  10. <li v-for="n in 10" :key="n"></li>
  11. </ul>
  12. <ul v-show="isHMacBased(internal_otp_type)">
  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. uri : String
  53. },
  54. computed: {
  55. displayedOtp() {
  56. let pwd = this.internal_password
  57. if (this.internal_otp_type !== 'steamtotp') {
  58. const spacePosition = Math.ceil(this.internal_password.length / 2)
  59. pwd = this.internal_password.substr(0, spacePosition) + " " + this.internal_password.substr(spacePosition)
  60. }
  61. return this.$root.appSettings.showOtpAsDot ? pwd.replace(/[0-9]/g, '●') : pwd
  62. },
  63. },
  64. mounted: function() {
  65. this.show()
  66. },
  67. methods: {
  68. isTimeBased: function(otp_type) {
  69. return (otp_type === 'totp' || otp_type === 'steamtotp')
  70. },
  71. isHMacBased: function(otp_type) {
  72. return otp_type === 'hotp'
  73. },
  74. async show(id) {
  75. // 3 possible cases :
  76. // - Trigger when user ask for an otp of an existing account: the ID is provided so we fetch the account data
  77. // from db but without the uri.
  78. // This prevent the uri (a sensitive data) to transit via http request unnecessarily. In this
  79. // case this.otp_type is sent by the backend.
  80. // - Trigger when user use the Quick Uploader and preview the account: No ID but we have an URI.
  81. // - Trigger when user use the Advanced form and preview the account: We should have all OTP parameter
  82. // to obtain an otp, including Secret and otp_type which are required
  83. this.internal_otp_type = this.otp_type
  84. this.internal_account = this.account
  85. this.internal_service = this.service
  86. this.internal_icon = this.icon
  87. this.internal_secret = this.secret
  88. this.internal_digits = this.digits
  89. this.internal_algorithm = this.algorithm
  90. this.internal_period = this.period
  91. this.internal_counter = this.counter
  92. if( id ) {
  93. this.internal_id = id
  94. const { data } = await this.axios.get('api/v1/twofaccounts/' + this.internal_id)
  95. this.internal_service = data.service
  96. this.internal_account = data.account
  97. this.internal_icon = data.icon
  98. this.internal_otp_type = data.otp_type
  99. if( this.isHMacBased(data.otp_type) && data.counter ) {
  100. this.internal_counter = data.counter
  101. }
  102. }
  103. // We force the otp_type to be based on the uri
  104. if( this.uri ) {
  105. this.internal_uri = this.uri
  106. this.internal_otp_type = this.uri.slice(0, 15 ).toLowerCase() === "otpauth://totp/" ? 'totp' : 'hotp';
  107. }
  108. if( this.internal_id || this.uri || this.secret ) { // minimun required vars to get an otp from the backend
  109. try {
  110. if(this.isTimeBased(this.internal_otp_type)) {
  111. await this.startTotpLoop()
  112. }
  113. else if(this.isHMacBased(this.internal_otp_type)) {
  114. await this.getHOTP()
  115. }
  116. else this.$router.push({ name: 'genericError', params: { err: this.$t('errors.not_a_supported_otp_type') } });
  117. this.$parent.isActive = true
  118. this.$parent.$refs.closeModalButton.focus()
  119. }
  120. catch(error) {
  121. this.clearOTP()
  122. }
  123. }
  124. },
  125. getOtp: async function() {
  126. try {
  127. let request, password
  128. if(this.internal_id) {
  129. request = {
  130. method: 'get',
  131. url: '/api/v1/twofaccounts/' + this.internal_id + '/otp'
  132. }
  133. }
  134. else if(this.internal_uri) {
  135. request = {
  136. method: 'post',
  137. url: '/api/v1/twofaccounts/otp',
  138. data: {
  139. uri: this.internal_uri
  140. }
  141. }
  142. }
  143. else {
  144. request = {
  145. method: 'post',
  146. url: '/api/v1/twofaccounts/otp',
  147. data: {
  148. service : this.internal_service,
  149. account : this.internal_account,
  150. icon : this.internal_icon,
  151. otp_type : this.internal_otp_type,
  152. secret : this.internal_secret,
  153. digits : this.internal_digits,
  154. algorithm : this.internal_algorithm,
  155. period : this.internal_period,
  156. counter : this.internal_counter,
  157. }
  158. }
  159. }
  160. await this.axios(request).then(response => {
  161. if(this.$root.appSettings.copyOtpOnDisplay) {
  162. this.copyAndNotify(response.data.password)
  163. }
  164. password = response.data
  165. })
  166. return password
  167. }
  168. catch(error) {
  169. if (error.response.status === 422) {
  170. this.$emit('validation-error', error.response)
  171. }
  172. throw error
  173. }
  174. },
  175. startTotpLoop: async function() {
  176. let otp = await this.getOtp()
  177. this.internal_password = otp.password
  178. this.internal_otp_type = otp.otp_type
  179. let generated_at = otp.generated_at
  180. let period = otp.period
  181. let elapsedTimeInCurrentPeriod,
  182. remainingTimeBeforeEndOfPeriod,
  183. durationBetweenTwoDots,
  184. durationFromFirstToNextDot,
  185. dots
  186. // |<----period p----->|
  187. // | | |
  188. // |------- ··· ------------|--------|----------|---------->
  189. // | | | |
  190. // unix T0 Tp.start Tgen_at Tp.end
  191. // | | |
  192. // elapsedTimeInCurrentPeriod--|<------>| |
  193. // (in ms) | | |
  194. // ● ● ● ● ●|● ◌ ◌ ◌ ◌ |
  195. // | | || |
  196. // | | |<-------->|--remainingTimeBeforeEndOfPeriod (for remainingTimeout)
  197. // durationBetweenTwoDots-->|-|< ||
  198. // (for dotToDotInterval) | | >||<---durationFromFirstToNextDot (for firstDotToNextOneTimeout)
  199. // |
  200. // |
  201. // dotIndex
  202. // The elapsed time from the start of the period that contains the OTP generated_at timestamp and the OTP generated_at timestamp itself
  203. elapsedTimeInCurrentPeriod = generated_at % period
  204. // Switch off all dots
  205. dots = this.$el.querySelector('.dots')
  206. while (dots.querySelector('[data-is-active]')) {
  207. dots.querySelector('[data-is-active]').removeAttribute('data-is-active');
  208. }
  209. // We determine the position of the closest dot next to the generated_at timestamp
  210. let relativePosition = (elapsedTimeInCurrentPeriod * 10) / period
  211. let dotIndex = (Math.floor(relativePosition) +1)
  212. // We switch the dot on
  213. this.lastActiveDot = dots.querySelector('li:nth-child(' + dotIndex + ')');
  214. this.lastActiveDot.setAttribute('data-is-active', true);
  215. // Main timeout that run until the end of the period
  216. remainingTimeBeforeEndOfPeriod = period - elapsedTimeInCurrentPeriod
  217. let self = this; // because of the setInterval/setTimeout closures
  218. this.remainingTimeout = setTimeout(function() {
  219. self.stopLoop()
  220. self.startTotpLoop();
  221. }, remainingTimeBeforeEndOfPeriod*1000);
  222. // During the remainingTimeout countdown we have to show a next dot every durationBetweenTwoDots seconds
  223. // except for the first next dot
  224. durationBetweenTwoDots = period / 10 // we have 10 dots
  225. durationFromFirstToNextDot = (Math.ceil(elapsedTimeInCurrentPeriod / durationBetweenTwoDots) * durationBetweenTwoDots) - elapsedTimeInCurrentPeriod
  226. this.firstDotToNextOneTimeout = setTimeout(function() {
  227. if( durationFromFirstToNextDot > 0 ) {
  228. self.activateNextDot()
  229. dotIndex += 1
  230. }
  231. self.dotToDotInterval = setInterval(function() {
  232. self.activateNextDot()
  233. dotIndex += 1
  234. }, durationBetweenTwoDots*1000)
  235. }, durationFromFirstToNextDot*1000)
  236. },
  237. getHOTP: async function() {
  238. let otp = await this.getOtp()
  239. this.internal_password = otp.password
  240. this.internal_counter = otp.counter
  241. // returned counter & uri are incremented
  242. this.$emit('increment-hotp', { nextHotpCounter: otp.counter, nextUri: otp.uri })
  243. },
  244. clearOTP: function() {
  245. this.stopLoop()
  246. this.internal_id = this.remainingTimeout = this.dotToDotInterval = this.firstDotToNextOneTimeout = this.elapsedTimeInCurrentPeriod = this.internal_counter = null
  247. this.internal_service = this.internal_account = this.internal_icon = this.internal_otp_type = this.internal_secret = ''
  248. this.internal_password = '... ...'
  249. try {
  250. this.$el.querySelector('[data-is-active]').removeAttribute('data-is-active');
  251. this.$el.querySelector('.dots li:first-child').setAttribute('data-is-active', true);
  252. }
  253. catch(e) {
  254. // we do not throw anything
  255. }
  256. },
  257. stopLoop: function() {
  258. if( this.isTimeBased(this.internal_otp_type) ) {
  259. clearTimeout(this.remainingTimeout)
  260. clearTimeout(this.firstDotToNextOneTimeout)
  261. clearInterval(this.dotToDotInterval)
  262. }
  263. },
  264. activateNextDot: function() {
  265. if(this.lastActiveDot.nextSibling !== null) {
  266. this.lastActiveDot.removeAttribute('data-is-active')
  267. this.lastActiveDot.nextSibling.setAttribute('data-is-active', true)
  268. this.lastActiveDot = this.lastActiveDot.nextSibling
  269. }
  270. },
  271. clipboardSuccessHandler ({ value, event }) {
  272. if(this.$root.appSettings.kickUserAfter == -1) {
  273. this.appLogout()
  274. }
  275. else if(this.$root.appSettings.closeOtpOnCopy) {
  276. this.$parent.isActive = false
  277. this.clearOTP()
  278. }
  279. this.$notify({ type: 'is-success', text: this.$t('commons.copied_to_clipboard') })
  280. },
  281. clipboardErrorHandler ({ value, event }) {
  282. console.log('error', value)
  283. },
  284. copyAndNotify (strToCopy) {
  285. // see https://web.dev/async-clipboard/ for futur Clipboard API usage.
  286. // The API should allow to copy the password on each trip without user interaction.
  287. // For now too many browsers don't support the clipboard-write permission
  288. // (see https://developer.mozilla.org/en-US/docs/Web/API/Permissions#browser_support)
  289. this.$clipboard(strToCopy)
  290. this.$notify({ type: 'is-success', text: this.$t('commons.copied_to_clipboard') })
  291. },
  292. },
  293. beforeDestroy () {
  294. this.stopLoop()
  295. }
  296. }
  297. </script>