OtpDisplay.vue 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. <script setup>
  2. import Spinner from '@/components/Spinner.vue'
  3. import TotpLooper from '@/components/TotpLooper.vue'
  4. import Dots from '@/components/Dots.vue'
  5. import twofaccountService from '@/services/twofaccountService'
  6. import { useUserStore } from '@/stores/user'
  7. import { useNotifyStore } from '@/stores/notify'
  8. import { UseColorMode } from '@vueuse/components'
  9. import { useDisplayablePassword } from '@/composables/helpers'
  10. const user = useUserStore()
  11. const notify = useNotifyStore()
  12. const $2fauth = inject('2fauth')
  13. const { copy, copied } = useClipboard({ legacy: true })
  14. const route = useRoute()
  15. const emit = defineEmits(['please-close-me', 'increment-hotp', 'validation-error'])
  16. const props = defineProps({
  17. otp_type : String,
  18. account : String,
  19. service : String,
  20. icon : String,
  21. secret : String,
  22. digits : Number,
  23. algorithm : String,
  24. period : null,
  25. counter : null,
  26. image : String,
  27. qrcode : null,
  28. uri : String
  29. })
  30. const id = ref(null)
  31. const uri = ref(null)
  32. const otpauthParams = ref({
  33. otp_type : '',
  34. account : '',
  35. service : '',
  36. icon : '',
  37. secret : '',
  38. digits : null,
  39. algorithm : '',
  40. period : null,
  41. counter : null,
  42. image : ''
  43. })
  44. const password = ref('')
  45. const generated_at = ref(null)
  46. const hasTOTP = ref(false)
  47. const showInlineSpinner = ref(false)
  48. const dots = ref()
  49. const totpLooper = ref()
  50. const otpSpanTag = ref()
  51. /***
  52. *
  53. */
  54. const show = async (accountId) => {
  55. // 3 possible cases :
  56. //
  57. // Case 1 : When user asks for an otp of an existing account: the ID is provided so we fetch the account data
  58. // from db but without the uri. This prevent the uri (a sensitive data) to transit via http request unnecessarily.
  59. // In this case this.otp_type is sent by the backend.
  60. //
  61. // Case 2 : When user uses the Quick Uploader and preview the account: No ID but we have an URI.
  62. //
  63. // Case 3 : When user uses the Advanced form and preview the account: We should have all OTP parameter
  64. // to obtain an otp, including Secret and otp_type which are required
  65. otpauthParams.value.otp_type = props.otp_type
  66. otpauthParams.value.account = props.account
  67. otpauthParams.value.service = props.service
  68. otpauthParams.value.icon = props.icon
  69. otpauthParams.value.secret = props.secret
  70. otpauthParams.value.digits = props.digits
  71. otpauthParams.value.algorithm = props.algorithm
  72. otpauthParams.value.period = props.period
  73. otpauthParams.value.counter = props.counter
  74. setLoadingState()
  75. // Case 1
  76. if (accountId) {
  77. id.value = accountId
  78. const { data } = await twofaccountService.get(id.value)
  79. otpauthParams.value.service = data.service
  80. otpauthParams.value.account = data.account
  81. otpauthParams.value.icon = data.icon
  82. otpauthParams.value.otp_type = data.otp_type
  83. if( isHMacBased(data.otp_type) && data.counter ) {
  84. otpauthParams.value.counter = data.counter
  85. }
  86. }
  87. // Case 2
  88. else if(props.uri) {
  89. uri.value = props.uri
  90. otpauthParams.value.otp_type = props.uri.slice(0, 15 ).toLowerCase() === "otpauth://totp/" ? 'totp' : 'hotp'
  91. }
  92. // Case 3
  93. else if (! props.secret) {
  94. notify.error(new Error(trans('errors.cannot_create_otp_without_secret')))
  95. }
  96. else if (! isTimeBased(otpauthParams.value.otp_type) && ! isHMacBased(otpauthParams.value.otp_type)) {
  97. notify.error(new Error(trans('errors.not_a_supported_otp_type')))
  98. }
  99. try {
  100. await getOtp()
  101. focusOnOTP()
  102. }
  103. catch(error) {
  104. clearOTP()
  105. }
  106. }
  107. /**
  108. * Requests and handles a fresh OTP
  109. */
  110. async function getOtp() {
  111. setLoadingState()
  112. await getOtpPromise().then(response => {
  113. let otp = response.data
  114. password.value = otp.password
  115. if(user.preferences.copyOtpOnDisplay) {
  116. copyOTP(otp.password)
  117. }
  118. if (isTimeBased(otp.otp_type)) {
  119. generated_at.value = otp.generated_at
  120. otpauthParams.value.period = otp.period
  121. hasTOTP.value = true
  122. nextTick().then(() => {
  123. totpLooper.value.startLoop()
  124. })
  125. }
  126. else if (isHMacBased(otp.otp_type)) {
  127. otpauthParams.value.counter = otp.counter
  128. // returned counter & uri are incremented
  129. emit('increment-hotp', { nextHotpCounter: otp.counter, nextUri: otp.uri })
  130. }
  131. })
  132. .catch(error => {
  133. if (error.response.status === 422) {
  134. emit('validation-error', error.response)
  135. }
  136. console.log(error)
  137. //throw error
  138. })
  139. .finally(() => {
  140. showInlineSpinner.value = false
  141. })
  142. }
  143. /**
  144. * Shows blacked dots and a loading spinner
  145. */
  146. function setLoadingState() {
  147. showInlineSpinner.value = true
  148. dots.value.turnOff()
  149. }
  150. /**
  151. * Returns the appropriate promise to get a fresh OTP from backend
  152. */
  153. function getOtpPromise() {
  154. if(id.value) {
  155. return twofaccountService.getOtpById(id.value)
  156. }
  157. else if(uri.value) {
  158. return twofaccountService.getOtpByUri(uri.value)
  159. }
  160. else {
  161. return twofaccountService.getOtpByParams(otpauthParams.value)
  162. }
  163. }
  164. /**
  165. * Reset component's refs
  166. */
  167. function clearOTP() {
  168. id.value = otpauthParams.value.counter = generated_at.value = null
  169. otpauthParams.value.service = otpauthParams.value.account = otpauthParams.value.icon = otpauthParams.value.otp_type = otpauthParams.value.secret = ''
  170. password.value = '... ...'
  171. hasTOTP.value = false
  172. totpLooper.value?.clearLooper();
  173. }
  174. /**
  175. * Put focus on the OTP html tag
  176. */
  177. function focusOnOTP() {
  178. nextTick().then(() => {
  179. otpSpanTag.value?.focus()
  180. })
  181. }
  182. /**
  183. * Copies to clipboard and notify
  184. *
  185. * @param {string} otp The password to copy
  186. * @param {*} permit_closing Toggle moddle closing On-Off
  187. */
  188. function copyOTP(otp, permit_closing) {
  189. copy(otp.replace(/ /g, ''))
  190. if (copied) {
  191. if(user.preferences.kickUserAfter == -1 && (permit_closing || false) === true && route.name != 'importAccounts') {
  192. user.logout({ kicked: true})
  193. }
  194. else if(user.preferences.closeOtpOnCopy && (permit_closing || false) === true) {
  195. emit("please-close-me");
  196. clearOTP()
  197. }
  198. notify.success({ text: trans('commons.copied_to_clipboard') })
  199. }
  200. }
  201. /**
  202. * Checks OTP type is Time based (TOTP)
  203. *
  204. * @param {string} otp_type
  205. */
  206. function isTimeBased(otp_type) {
  207. return (otp_type === 'totp' || otp_type === 'steamtotp')
  208. }
  209. /**
  210. * Checks OTP type is HMAC based (HOTP)
  211. *
  212. * @param {string} otp_type
  213. */
  214. function isHMacBased(otp_type) {
  215. return otp_type === 'hotp'
  216. }
  217. /**
  218. * Turns dots On from the first one to the provided one
  219. */
  220. function turnDotOn(dotIndex) {
  221. dots.value.turnOn(dotIndex)
  222. }
  223. defineExpose({
  224. show,
  225. clearOTP
  226. })
  227. </script>
  228. <template>
  229. <div>
  230. <figure class="image is-64x64" :class="{ 'no-icon': !otpauthParams.icon }" style="display: inline-block">
  231. <img :src="$2fauth.config.subdirectory + '/storage/icons/' + otpauthParams.icon" v-if="otpauthParams.icon" :alt="$t('twofaccounts.icon_to_illustrate_the_account')">
  232. </figure>
  233. <UseColorMode v-slot="{ mode }">
  234. <p class="is-size-4 has-ellipsis" :class="mode == 'dark' ? 'has-text-grey-light' : 'has-text-grey'">{{ otpauthParams.service }}</p>
  235. <p class="is-size-6 has-ellipsis" :class="mode == 'dark' ? 'has-text-grey' : 'has-text-grey-light'">{{ otpauthParams.account }}</p>
  236. <p>
  237. <span
  238. v-if="!showInlineSpinner"
  239. id="otp"
  240. role="log"
  241. ref="otpSpanTag"
  242. tabindex="0"
  243. class="otp is-size-1 is-clickable px-3"
  244. :class="mode == 'dark' ? 'has-text-white' : 'has-text-grey-dark'"
  245. @click="copyOTP(password, true)"
  246. @keyup.enter="copyOTP(password, true)"
  247. :title="$t('commons.copy_to_clipboard')"
  248. >
  249. {{ useDisplayablePassword(password) }}
  250. </span>
  251. <span v-else tabindex="0" class="otp is-size-1">
  252. <Spinner :isVisible="showInlineSpinner" :type="'raw'" />
  253. </span>
  254. </p>
  255. </UseColorMode>
  256. <Dots v-show="isTimeBased(otpauthParams.otp_type)" ref="dots"></Dots>
  257. <ul v-show="isHMacBased(otpauthParams.otp_type)">
  258. <li>counter: {{ otpauthParams.counter }}</li>
  259. </ul>
  260. <TotpLooper
  261. v-if="hasTOTP"
  262. :period="otpauthParams.period"
  263. :generated_at="generated_at"
  264. :autostart="false"
  265. v-on:loop-ended="getOtp()"
  266. v-on:loop-started="turnDotOn($event)"
  267. v-on:stepped-up="turnDotOn($event)"
  268. ref="totpLooper"
  269. ></TotpLooper>
  270. </div>
  271. </template>