WebAuthn.vue 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. <template>
  2. <div>
  3. <setting-tabs :activeTab="'settings.webauthn.devices'"></setting-tabs>
  4. <div class="options-tabs">
  5. <form-wrapper>
  6. <div v-if="isRemoteUser" class="notification is-warning has-text-centered" v-html="$t('auth.auth_handled_by_proxy')" />
  7. <h4 class="title is-4 has-text-grey-light">{{ $t('auth.webauthn.security_devices') }}</h4>
  8. <div class="is-size-7-mobile">
  9. {{ $t('auth.webauthn.security_devices_legend')}}
  10. </div>
  11. <div class="mt-3">
  12. <a tabindex="0" @click="register" @keyup.enter="register">
  13. <font-awesome-icon :icon="['fas', 'plus-circle']" />&nbsp;{{ $t('auth.webauthn.register_a_new_device')}}
  14. </a>
  15. </div>
  16. <!-- credentials list -->
  17. <div v-if="credentials.length > 0" class="field">
  18. <div v-for="credential in credentials" :key="credential.id" class="group-item has-text-light is-size-5 is-size-6-mobile">
  19. {{ displayName(credential) }}
  20. <!-- revoke link -->
  21. <button class="button tag is-dark is-pulled-right" @click="revokeCredential(credential.id)" :title="$t('settings.revoke')">
  22. {{ $t('settings.revoke') }}
  23. </button>
  24. <!-- edit link -->
  25. <!-- <router-link :to="{ name: '' }" class="has-text-grey pl-1" :title="$t('commons.rename')">
  26. <font-awesome-icon :icon="['fas', 'pen-square']" />
  27. </router-link> -->
  28. </div>
  29. <div class="mt-2 is-size-7 is-pulled-right">
  30. {{ $t('auth.webauthn.revoking_a_device_is_permanent')}}
  31. </div>
  32. </div>
  33. <div v-if="isFetching && credentials.length === 0" class="has-text-centered mt-6">
  34. <span class="is-size-4">
  35. <font-awesome-icon :icon="['fas', 'spinner']" spin />
  36. </span>
  37. </div>
  38. <h4 class="title is-4 pt-6 has-text-grey-light">{{ $t('settings.options') }}</h4>
  39. <div class="field">
  40. {{ $t('auth.webauthn.need_a_security_device_to_enable_options')}}
  41. </div>
  42. <form>
  43. <!-- use webauthn only -->
  44. <form-checkbox v-on:useWebauthnOnly="saveSetting('useWebauthnOnly', $event)" :form="form" fieldName="useWebauthnOnly" :label="$t('auth.webauthn.use_webauthn_only.label')" :help="$t('auth.webauthn.use_webauthn_only.help')" :disabled="isRemoteUser || credentials.length === 0" />
  45. <!-- default sign in method -->
  46. <form-checkbox v-on:useWebauthnAsDefault="saveSetting('useWebauthnAsDefault', $event)" :form="form" fieldName="useWebauthnAsDefault" :label="$t('auth.webauthn.use_webauthn_as_default.label')" :help="$t('auth.webauthn.use_webauthn_as_default.help')" :disabled="isRemoteUser || credentials.length === 0" />
  47. </form>
  48. <!-- footer -->
  49. <vue-footer :showButtons="true">
  50. <!-- close button -->
  51. <p class="control">
  52. <router-link :to="{ name: 'accounts', params: { toRefresh: false } }" class="button is-dark is-rounded">{{ $t('commons.close') }}</router-link>
  53. </p>
  54. </vue-footer>
  55. </form-wrapper>
  56. </div>
  57. </div>
  58. </template>
  59. <script>
  60. import Form from './../../components/Form'
  61. export default {
  62. data(){
  63. return {
  64. form: new Form({
  65. useWebauthnOnly: null,
  66. useWebauthnAsDefault: null,
  67. }),
  68. credentials: [],
  69. isFetching: false,
  70. isRemoteUser: false,
  71. }
  72. },
  73. async mounted() {
  74. const { data } = await this.form.get('/api/v1/settings')
  75. this.form.fillWithKeyValueObject(data)
  76. this.form.setOriginal()
  77. this.fetchCredentials()
  78. },
  79. methods : {
  80. /**
  81. * Save a setting
  82. */
  83. saveSetting(settingName, event) {
  84. this.axios.put('/api/v1/settings/' + settingName, { value: event }).then(response => {
  85. this.$notify({ type: 'is-success', text: this.$t('settings.forms.setting_saved') })
  86. this.$root.appSettings[response.data.key] = response.data.value
  87. })
  88. },
  89. /**
  90. * Get all credentials from backend
  91. */
  92. async fetchCredentials() {
  93. this.isFetching = true
  94. await this.axios.get('/webauthn/credentials', {returnError: true})
  95. .then(response => {
  96. this.credentials = response.data
  97. })
  98. .catch(error => {
  99. if( error.response.status === 400 ) {
  100. this.isRemoteUser = true
  101. }
  102. else {
  103. this.$router.push({ name: 'genericError', params: { err: error.response } });
  104. }
  105. })
  106. this.isFetching = false
  107. },
  108. /**
  109. * Register a new security device
  110. */
  111. async register() {
  112. if (this.isRemoteUser) {
  113. this.$notify({ type: 'is-warning', text: this.$t('errors.unsupported_with_reverseproxy') })
  114. return false
  115. }
  116. // Check https context
  117. if (!window.isSecureContext) {
  118. this.$notify({ type: 'is-danger', text: this.$t('errors.https_required') })
  119. return false
  120. }
  121. // Check browser support
  122. if (!window.PublicKeyCredential) {
  123. this.$notify({ type: 'is-danger', text: this.$t('errors.browser_does_not_support_webauthn') })
  124. return false
  125. }
  126. const registerOptions = await this.axios.post('/webauthn/register/options').then(res => res.data)
  127. const publicKey = this.parseIncomingServerOptions(registerOptions)
  128. let bufferedCredentials
  129. try {
  130. bufferedCredentials = await navigator.credentials.create({ publicKey })
  131. }
  132. catch (error) {
  133. if (error.name == 'AbortError') {
  134. this.$notify({ type: 'is-warning', text: this.$t('errors.aborted_by_user') })
  135. }
  136. else if (error.name == 'NotAllowedError' || 'InvalidStateError') {
  137. this.$notify({ type: 'is-danger', text: this.$t('errors.security_device_unsupported') })
  138. }
  139. return false
  140. }
  141. const publicKeyCredential = this.parseOutgoingCredentials(bufferedCredentials);
  142. this.axios.post('/webauthn/register', publicKeyCredential).then(response => {
  143. this.$router.push({ name: 'settings.webauthn.editCredential', params: { id: publicKeyCredential.id, name: this.$t('auth.webauthn.my_device') } })
  144. })
  145. },
  146. /**
  147. * revoke a credential
  148. */
  149. async revokeCredential(credentialId) {
  150. if(confirm(this.$t('auth.confirm.revoke_device'))) {
  151. await this.axios.delete('/webauthn/credentials/' + credentialId).then(response => {
  152. // Remove the revoked credential from the collection
  153. this.credentials = this.credentials.filter(a => a.id !== credentialId)
  154. if (this.credentials.length == 0) {
  155. this.form.useWebauthnOnly = false
  156. this.form.useWebauthnAsDefault = false
  157. this.$root.appSettings['useWebauthnOnly'] = false
  158. this.$root.appSettings['useWebauthnAsDefault'] = false
  159. }
  160. this.$notify({ type: 'is-success', text: this.$t('auth.webauthn.device_revoked') })
  161. });
  162. }
  163. },
  164. /**
  165. * Always display a printable name
  166. */
  167. displayName(credential) {
  168. return credential.name ? credential.name : this.$t('auth.webauthn.my_device') + ' (#' + credential.id.substring(0, 10) + ')'
  169. },
  170. },
  171. }
  172. </script>