OtpDisplay.vue 9.8 KB

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