diff options
Diffstat (limited to 'core/src/components/login')
-rw-r--r-- | core/src/components/login/LoginButton.vue | 56 | ||||
-rw-r--r-- | core/src/components/login/LoginForm.vue | 43 | ||||
-rw-r--r-- | core/src/components/login/PasswordLessLoginForm.vue | 216 | ||||
-rw-r--r-- | core/src/components/login/ResetPassword.vue | 101 | ||||
-rw-r--r-- | core/src/components/login/UpdatePassword.vue | 2 |
5 files changed, 349 insertions, 69 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..c75b6f2c7ea 100644 --- a/core/src/components/login/LoginForm.vue +++ b/core/src/components/login/LoginForm.vue @@ -20,9 +20,10 @@ --> <template> - <form method="post" + <form ref="loginForm" + method="post" name="login" - :action="OC.generateUrl('login')" + :action="loginActionUrl" @submit="submit"> <fieldset> <div v-if="apacheAuthFailed" @@ -46,7 +47,7 @@ class="hidden"> <img class="float-spinner" alt="" - :src="OC.imagePath('core', 'loading-dark.gif')"> + :src="loadingIcon"> <span id="messageText" /> <!-- the following div ensures that the spinner is always inside the #message div --> <div style="clear: both;" /> @@ -58,12 +59,13 @@ v-model="user" type="text" name="user" + autocapitalize="off" :autocomplete="autoCompleteAllowed ? 'on' : 'off'" :placeholder="t('core', 'Username or email')" :aria-label="t('core', 'Username or email')" required @change="updateUsername"> - <label for="user" class="infield">{{ t('core', 'Username or email') }}</label> + <label for="user" class="infield">{{ t('core', 'Username or email') }}</label> </p> <p class="groupbottom" @@ -80,23 +82,11 @@ <label for="password" class="infield">{{ t('Password') }}</label> <a href="#" class="toggle-password" @click.stop.prevent="togglePassword"> - <img :src="OC.imagePath('core', 'actions/toggle.svg')"> + <img :src="toggleIcon" :alt="t('core', 'Toggle password visibility')"> </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"> @@ -104,7 +94,7 @@ </p> <p v-else-if="userDisabled" class="warning userDisabledMsg"> - {{ t('lib', 'User disabled') }} + {{ t('core', 'User disabled') }} </p> <p v-if="throttleDelay && throttleDelay > 5000" @@ -135,9 +125,15 @@ <script> import jstz from 'jstimezonedetect' +import LoginButton from './LoginButton' +import { + generateUrl, + imagePath, +} from '@nextcloud/router' export default { name: 'LoginForm', + components: { LoginButton }, props: { username: { type: String, @@ -193,6 +189,15 @@ export default { userDisabled() { return this.errors.indexOf('userdisabled') !== -1 }, + toggleIcon() { + return imagePath('core', 'actions/toggle.svg') + }, + loadingIcon() { + return imagePath('core', 'loading-dark.gif') + }, + loginActionUrl() { + return generateUrl('login') + }, }, mounted() { if (this.username === '') { diff --git a/core/src/components/login/PasswordLessLoginForm.vue b/core/src/components/login/PasswordLessLoginForm.vue new file mode 100644 index 00000000000..0203bd74c48 --- /dev/null +++ b/core/src/components/login/PasswordLessLoginForm.vue @@ -0,0 +1,216 @@ +<template> + <form v-if="(isHttps || isLocalhost) && 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 && !isLocalhost"> + {{ t('core', 'Passwordless authentication is only available over a secure connection.') }} + </div> +</template> + +<script> +import { + startAuthentication, + finishAuthentication, +} from '../../services/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, + }, + isLocalhost: { + 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)) + } + + const arrayToString = function(a) { + return 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 ? arrayToString(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> diff --git a/core/src/components/login/ResetPassword.vue b/core/src/components/login/ResetPassword.vue index 2d045692285..79589a96aca 100644 --- a/core/src/components/login/ResetPassword.vue +++ b/core/src/components/login/ResetPassword.vue @@ -21,61 +21,64 @@ <template> <form @submit.prevent="submit"> - <p> - <input id="user" - v-model="user" - type="text" - name="user" - :placeholder="t('core', 'Username or email')" - :aria-label="t('core', 'Username or email')" - required - @change="updateUsername"> - <!--<?php p($_['user_autofocus'] ? 'autofocus' : ''); ?> - autocomplete="<?php p($_['login_form_autocomplete']); ?>" autocapitalize="none" autocorrect="off"--> - <label for="user" class="infield">{{ t('core', 'Username or email') }}</label> - </p> - <div id="reset-password-wrapper"> - <input id="reset-password-submit" - type="submit" - class="login primary" - title="" - :value="t('core', 'Reset password')"> - <div class="submit-icon" - :class="{ - 'icon-confirm-white': !loading, - 'icon-loading-small': loading && invertedColors, - 'icon-loading-small-dark': loading && !invertedColors, - }" /> - </div> - <p v-if="message === 'send-success'" - class="update"> - {{ t('core', 'A password reset message has been sent to the e-mail address of this account. If you do not receive it, check your spam/junk folders or ask your local administrator for help.') }} - <br> - {{ t('core', 'If it is not there ask your local administrator.') }} - </p> - <p v-else-if="message === 'send-error'" - class="update warning"> - {{ t('core', 'Couldn\'t send reset email. Please contact your administrator.') }} - </p> - <p v-else-if="message === 'reset-error'" - class="update warning"> - {{ t('core', 'Password can not be changed. Please contact your administrator.') }} - </p> - <p v-else-if="message" - class="update" - :class="{warning: error}" /> + <fieldset> + <p> + <input id="user" + v-model="user" + type="text" + name="user" + autocapitalize="off" + :placeholder="t('core', 'Username or email')" + :aria-label="t('core', 'Username or email')" + required + @change="updateUsername"> + <!--<?php p($_['user_autofocus'] ? 'autofocus' : ''); ?> + autocomplete="<?php p($_['login_form_autocomplete']); ?>" autocapitalize="none" autocorrect="off"--> + <label for="user" class="infield">{{ t('core', 'Username or email') }}</label> + </p> + <div id="reset-password-wrapper"> + <input id="reset-password-submit" + type="submit" + class="login primary" + title="" + :value="t('core', 'Reset password')"> + <div class="submit-icon" + :class="{ + 'icon-confirm-white': !loading, + 'icon-loading-small': loading && invertedColors, + 'icon-loading-small-dark': loading && !invertedColors, + }" /> + </div> + <p v-if="message === 'send-success'" + class="update"> + {{ t('core', 'A password reset message has been sent to the email address of this account. If you do not receive it, check your spam/junk folders or ask your local administrator for help.') }} + <br> + {{ t('core', 'If it is not there ask your local administrator.') }} + </p> + <p v-else-if="message === 'send-error'" + class="update warning"> + {{ t('core', 'Couldn\'t send reset email. Please contact your administrator.') }} + </p> + <p v-else-if="message === 'reset-error'" + class="update warning"> + {{ t('core', 'Password cannot be changed. Please contact your administrator.') }} + </p> + <p v-else-if="message" + class="update" + :class="{warning: error}" /> - <a href="#" - @click.prevent="$emit('abort')"> - {{ t('core', 'Back to login') }} - </a> + <a href="#" + @click.prevent="$emit('abort')"> + {{ t('core', 'Back to login') }} + </a> + </fieldset> </form> </template> <script> import axios from '@nextcloud/axios' -import { generateUrl } from '../../OC/routing' +import { generateUrl } from '@nextcloud/router' export default { name: 'ResetPassword', @@ -130,7 +133,7 @@ export default { this.message = 'send-success' }) .catch(e => { - console.error('could not send reset e-mail request', e) + console.error('could not send reset email request', e) this.error = true this.message = 'send-error' diff --git a/core/src/components/login/UpdatePassword.vue b/core/src/components/login/UpdatePassword.vue index 3fa3c60773c..cb615a846d2 100644 --- a/core/src/components/login/UpdatePassword.vue +++ b/core/src/components/login/UpdatePassword.vue @@ -125,7 +125,7 @@ export default { } } catch (e) { this.error = true - this.message = e.message ? e.message : t('core', 'Password can not be changed. Please contact your administrator.') + this.message = e.message ? e.message : t('core', 'Password cannot be changed. Please contact your administrator.') } finally { this.loading = false } |