You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

AddDevice.vue 5.7KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. <!--
  2. - @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl>
  3. -
  4. - @author Roeland Jago Douma <roeland@famdouma.nl>
  5. -
  6. - @license GNU AGPL version 3 or any later version
  7. -
  8. - This program is free software: you can redistribute it and/or modify
  9. - it under the terms of the GNU Affero General Public License as
  10. - published by the Free Software Foundation, either version 3 of the
  11. - License, or (at your option) any later version.
  12. -
  13. - This program is distributed in the hope that it will be useful,
  14. - but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. - GNU Affero General Public License for more details.
  17. -
  18. - You should have received a copy of the GNU Affero General Public License
  19. - along with this program. If not, see <http://www.gnu.org/licenses/>.
  20. -->
  21. <template>
  22. <div v-if="!isHttps">
  23. {{ t('settings', 'Passwordless authentication requires a secure connection.') }}
  24. </div>
  25. <div v-else>
  26. <div v-if="step === RegistrationSteps.READY">
  27. <button @click="start">
  28. {{ t('settings', 'Add Webauthn device') }}
  29. </button>
  30. </div>
  31. <div v-else-if="step === RegistrationSteps.REGISTRATION"
  32. class="new-webauthn-device">
  33. <span class="icon-loading-small webauthn-loading" />
  34. {{ t('settings', 'Please authorize your WebAuthn device.') }}
  35. </div>
  36. <div v-else-if="step === RegistrationSteps.NAMING"
  37. class="new-webauthn-device">
  38. <span class="icon-loading-small webauthn-loading" />
  39. <input v-model="name"
  40. type="text"
  41. :placeholder="t('settings', 'Name your device')"
  42. @:keyup.enter="submit">
  43. <button @click="submit">
  44. {{ t('settings', 'Add') }}
  45. </button>
  46. </div>
  47. <div v-else-if="step === RegistrationSteps.PERSIST"
  48. class="new-webauthn-device">
  49. <span class="icon-loading-small webauthn-loading" />
  50. {{ t('settings', 'Adding your device …') }}
  51. </div>
  52. <div v-else>
  53. Invalid registration step. This should not have happened.
  54. </div>
  55. </div>
  56. </template>
  57. <script>
  58. import confirmPassword from '@nextcloud/password-confirmation'
  59. import logger from '../../logger'
  60. import {
  61. startRegistration,
  62. finishRegistration,
  63. } from '../../service/WebAuthnRegistrationSerice'
  64. const logAndPass = (text) => (data) => {
  65. logger.debug(text)
  66. return data
  67. }
  68. const RegistrationSteps = Object.freeze({
  69. READY: 1,
  70. REGISTRATION: 2,
  71. NAMING: 3,
  72. PERSIST: 4,
  73. })
  74. export default {
  75. name: 'AddDevice',
  76. props: {
  77. httpWarning: Boolean,
  78. isHttps: {
  79. type: Boolean,
  80. default: false
  81. }
  82. },
  83. data() {
  84. return {
  85. name: '',
  86. credential: {},
  87. RegistrationSteps,
  88. step: RegistrationSteps.READY,
  89. }
  90. },
  91. methods: {
  92. arrayToBase64String(a) {
  93. return btoa(String.fromCharCode(...a))
  94. },
  95. start() {
  96. this.step = RegistrationSteps.REGISTRATION
  97. console.debug('Starting WebAuthn registration')
  98. return confirmPassword()
  99. .then(this.getRegistrationData)
  100. .then(this.register.bind(this))
  101. .then(() => { this.step = RegistrationSteps.NAMING })
  102. .catch(err => {
  103. console.error(err.name, err.message)
  104. this.step = RegistrationSteps.READY
  105. })
  106. },
  107. getRegistrationData() {
  108. console.debug('Fetching webauthn registration data')
  109. const base64urlDecode = function(input) {
  110. // Replace non-url compatible chars with base64 standard chars
  111. input = input
  112. .replace(/-/g, '+')
  113. .replace(/_/g, '/')
  114. // Pad out with standard base64 required padding characters
  115. const pad = input.length % 4
  116. if (pad) {
  117. if (pad === 1) {
  118. throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding')
  119. }
  120. input += new Array(5 - pad).join('=')
  121. }
  122. return window.atob(input)
  123. }
  124. return startRegistration()
  125. .then(publicKey => {
  126. console.debug(publicKey)
  127. publicKey.challenge = Uint8Array.from(base64urlDecode(publicKey.challenge), c => c.charCodeAt(0))
  128. publicKey.user.id = Uint8Array.from(publicKey.user.id, c => c.charCodeAt(0))
  129. return publicKey
  130. })
  131. .catch(err => {
  132. console.error('Error getting webauthn registration data from server', err)
  133. throw new Error(t('settings', 'Server error while trying to add webauthn device'))
  134. })
  135. },
  136. register(publicKey) {
  137. console.debug('starting webauthn registration')
  138. return navigator.credentials.create({ publicKey })
  139. .then(data => {
  140. this.credential = {
  141. id: data.id,
  142. type: data.type,
  143. rawId: this.arrayToBase64String(new Uint8Array(data.rawId)),
  144. response: {
  145. clientDataJSON: this.arrayToBase64String(new Uint8Array(data.response.clientDataJSON)),
  146. attestationObject: this.arrayToBase64String(new Uint8Array(data.response.attestationObject)),
  147. },
  148. }
  149. })
  150. },
  151. submit() {
  152. this.step = RegistrationSteps.PERSIST
  153. return confirmPassword()
  154. .then(logAndPass('confirmed password'))
  155. .then(this.saveRegistrationData)
  156. .then(logAndPass('registration data saved'))
  157. .then(() => this.reset())
  158. .then(logAndPass('app reset'))
  159. .catch(console.error.bind(this))
  160. },
  161. async saveRegistrationData() {
  162. try {
  163. const device = await finishRegistration(this.name, JSON.stringify(this.credential))
  164. logger.info('new device added', { device })
  165. this.$emit('added', device)
  166. } catch (err) {
  167. logger.error('Error persisting webauthn registration', { error: err })
  168. throw new Error(t('settings', 'Server error while trying to complete webauthn device registration'))
  169. }
  170. },
  171. reset() {
  172. this.name = ''
  173. this.registrationData = {}
  174. this.step = RegistrationSteps.READY
  175. },
  176. },
  177. }
  178. </script>
  179. <style scoped>
  180. .webauthn-loading {
  181. display: inline-block;
  182. vertical-align: sub;
  183. margin-left: 2px;
  184. margin-right: 2px;
  185. }
  186. .new-webauthn-device {
  187. line-height: 300%;
  188. }
  189. </style>