OtpDisplayer.vue 15 KB

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