diff options
author | Roeland Jago Douma <roeland@famdouma.nl> | 2020-02-09 20:06:08 +0100 |
---|---|---|
committer | Roeland Jago Douma <roeland@famdouma.nl> | 2020-03-31 22:17:07 +0200 |
commit | 53db05a1f67fc974dba904ec158b2d67fa72df95 (patch) | |
tree | cc306fb0b96ccb8ee057af4a86be161aa1b76e2a /core/src/components | |
parent | f04f34b94b7e61f9d11fc07608d7eb2ae2163de8 (diff) | |
download | nextcloud-server-53db05a1f67fc974dba904ec158b2d67fa72df95.tar.gz nextcloud-server-53db05a1f67fc974dba904ec158b2d67fa72df95.zip |
Start with webauthn
Signed-off-by: Roeland Jago Douma <roeland@famdouma.nl>
Signed-off-by: npmbuildbot[bot] <npmbuildbot[bot]@users.noreply.github.com>
Diffstat (limited to 'core/src/components')
-rw-r--r-- | core/src/components/login/LoginButton.vue | 56 | ||||
-rw-r--r-- | core/src/components/login/LoginForm.vue | 19 | ||||
-rw-r--r-- | core/src/components/login/PasswordLessLoginForm.vue | 208 |
3 files changed, 269 insertions, 14 deletions
diff --git a/core/src/components/login/LoginButton.vue b/core/src/components/login/LoginButton.vue new file mode 100644 index 00000000000..f7d426e6c63 --- /dev/null +++ b/core/src/components/login/LoginButton.vue @@ -0,0 +1,56 @@ +<!-- + - @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + --> + +<template> + <div id="submit-wrapper" @click="$emit('click')"> + <input id="submit-form" + type="submit" + class="login primary" + title="" + :value="!loading ? t('core', 'Log in') : t('core', 'Logging in …')"> + <div class="submit-icon" + :class="{ + 'icon-confirm-white': !loading, + 'icon-loading-small': loading && invertedColors, + 'icon-loading-small-dark': loading && !invertedColors, + }" /> + </div> +</template> + +<script> +export default { + name: 'LoginButton', + props: { + loading: { + type: Boolean, + required: true, + }, + invertedColors: { + type: Boolean, + default: false, + }, + }, +} +</script> + +<style scoped> + +</style> diff --git a/core/src/components/login/LoginForm.vue b/core/src/components/login/LoginForm.vue index 687896ceb54..a20ce6dc4c2 100644 --- a/core/src/components/login/LoginForm.vue +++ b/core/src/components/login/LoginForm.vue @@ -20,7 +20,8 @@ --> <template> - <form method="post" + <form ref="loginForm" + method="post" name="login" :action="OC.generateUrl('login')" @submit="submit"> @@ -84,19 +85,7 @@ </a> </p> - <div id="submit-wrapper"> - <input id="submit-form" - type="submit" - class="login primary" - title="" - :value="!loading ? t('core', 'Log in') : t('core', 'Logging in …')"> - <div class="submit-icon" - :class="{ - 'icon-confirm-white': !loading, - 'icon-loading-small': loading && invertedColors, - 'icon-loading-small-dark': loading && !invertedColors, - }" /> - </div> + <LoginButton :loading="loading" :inverted-colors="invertedColors" /> <p v-if="invalidPassword" class="warning wrongPasswordMsg"> @@ -135,9 +124,11 @@ <script> import jstz from 'jstimezonedetect' +import LoginButton from './LoginButton' export default { name: 'LoginForm', + components: { LoginButton }, props: { username: { type: String, diff --git a/core/src/components/login/PasswordLessLoginForm.vue b/core/src/components/login/PasswordLessLoginForm.vue new file mode 100644 index 00000000000..028f7d547da --- /dev/null +++ b/core/src/components/login/PasswordLessLoginForm.vue @@ -0,0 +1,208 @@ +<template> + <form v-if="isHttps && hasPublicKeyCredential" + ref="loginForm" + method="post" + name="login" + @submit.prevent="submit"> + <fieldset> + <p class="grouptop groupbottom"> + <input id="user" + ref="user" + v-model="user" + type="text" + name="user" + :autocomplete="autoCompleteAllowed ? 'on' : 'off'" + :placeholder="t('core', 'Username or email')" + :aria-label="t('core', 'Username or email')" + required + @change="$emit('update:username', user)"> + <label for="user" class="infield">{{ t('core', 'Username or email') }}</label> + </p> + + <div v-if="!validCredentials"> + {{ t('core', 'Your account is not setup for passwordless login.') }} + </div> + + <LoginButton v-if="validCredentials" + :loading="loading" + :inverted-colors="invertedColors" + @click="authenticate" /> + </fieldset> + </form> + <div v-else-if="!hasPublicKeyCredential"> + {{ t('core', 'Passwordless authentication is not supported in your browser.')}} + </div> + <div v-else-if="!isHttps"> + {{ t('core', 'Passwordless authentication is only available over a secure connection.')}} + </div> +</template> + +<script> +import { + startAuthentication, + finishAuthentication, +} from '../../service/WebAuthnAuthenticationService' +import LoginButton from './LoginButton' + +class NoValidCredentials extends Error { + +} + +export default { + name: 'PasswordLessLoginForm', + components: { + LoginButton, + }, + props: { + username: { + type: String, + default: '', + }, + redirectUrl: { + type: String, + }, + invertedColors: { + type: Boolean, + default: false, + }, + autoCompleteAllowed: { + type: Boolean, + default: true, + }, + isHttps: { + type: Boolean, + default: false, + }, + hasPublicKeyCredential: { + type: Boolean, + default: false, + } + }, + data() { + return { + user: this.username, + loading: false, + validCredentials: true, + } + }, + methods: { + authenticate() { + console.debug('passwordless login initiated') + + this.getAuthenticationData(this.user) + .then(publicKey => { + console.debug(publicKey) + return publicKey + }) + .then(this.sign) + .then(this.completeAuthentication) + .catch(error => { + if (error instanceof NoValidCredentials) { + this.validCredentials = false + return + } + console.debug(error) + }) + }, + getAuthenticationData(uid) { + const base64urlDecode = function(input) { + // Replace non-url compatible chars with base64 standard chars + input = input + .replace(/-/g, '+') + .replace(/_/g, '/') + + // Pad out with standard base64 required padding characters + const pad = input.length % 4 + if (pad) { + if (pad === 1) { + throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding') + } + input += new Array(5 - pad).join('=') + } + + return window.atob(input) + } + + return startAuthentication(uid) + .then(publicKey => { + console.debug('Obtained PublicKeyCredentialRequestOptions') + console.debug(publicKey) + + if (!Object.prototype.hasOwnProperty.call(publicKey, 'allowCredentials')) { + console.debug('No credentials found.') + throw new NoValidCredentials() + } + + publicKey.challenge = Uint8Array.from(base64urlDecode(publicKey.challenge), c => c.charCodeAt(0)) + publicKey.allowCredentials = publicKey.allowCredentials.map(function(data) { + return { + ...data, + 'id': Uint8Array.from(base64urlDecode(data.id), c => c.charCodeAt(0)), + } + }) + + console.debug('Converted PublicKeyCredentialRequestOptions') + console.debug(publicKey) + return publicKey + }) + .catch(error => { + console.debug('Error while obtaining data') + throw error + }) + }, + sign(publicKey) { + const arrayToBase64String = function(a) { + return window.btoa(String.fromCharCode(...a)) + } + + return navigator.credentials.get({ publicKey }) + .then(data => { + console.debug(data) + console.debug(new Uint8Array(data.rawId)) + console.debug(arrayToBase64String(new Uint8Array(data.rawId))) + return { + id: data.id, + type: data.type, + rawId: arrayToBase64String(new Uint8Array(data.rawId)), + response: { + authenticatorData: arrayToBase64String(new Uint8Array(data.response.authenticatorData)), + clientDataJSON: arrayToBase64String(new Uint8Array(data.response.clientDataJSON)), + signature: arrayToBase64String(new Uint8Array(data.response.signature)), + userHandle: data.response.userHandle ? arrayToBase64String(new Uint8Array(data.response.userHandle)) : null, + }, + } + }) + .then(challenge => { + console.debug(challenge) + return challenge + }) + .catch(error => { + console.debug('GOT AN ERROR!') + console.debug(error) // Example: timeout, interaction refused... + }) + }, + completeAuthentication(challenge) { + console.debug('TIME TO COMPLETE') + + const location = this.redirectUrl + + return finishAuthentication(JSON.stringify(challenge)) + .then(data => { + console.debug('Logged in redirecting') + window.location.href = location + }) + .catch(error => { + console.debug('GOT AN ERROR WHILE SUBMITTING CHALLENGE!') + console.debug(error) // Example: timeout, interaction refused... + }) + }, + submit() { + // noop + }, + }, +} +</script> + +<style scoped> + +</style> |