summaryrefslogtreecommitdiffstats
path: root/core/src/components
diff options
context:
space:
mode:
authorRoeland Jago Douma <roeland@famdouma.nl>2020-02-09 20:06:08 +0100
committerRoeland Jago Douma <roeland@famdouma.nl>2020-03-31 22:17:07 +0200
commit53db05a1f67fc974dba904ec158b2d67fa72df95 (patch)
treecc306fb0b96ccb8ee057af4a86be161aa1b76e2a /core/src/components
parentf04f34b94b7e61f9d11fc07608d7eb2ae2163de8 (diff)
downloadnextcloud-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.vue56
-rw-r--r--core/src/components/login/LoginForm.vue19
-rw-r--r--core/src/components/login/PasswordLessLoginForm.vue208
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>