summaryrefslogtreecommitdiffstats
path: root/core/src
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
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')
-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
-rw-r--r--core/src/login.js3
-rw-r--r--core/src/service/WebAuthnAuthenticationService.js37
-rw-r--r--core/src/views/Login.vue36
6 files changed, 344 insertions, 15 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>
diff --git a/core/src/login.js b/core/src/login.js
index 7270442c83e..bfcfabf169f 100644
--- a/core/src/login.js
+++ b/core/src/login.js
@@ -64,5 +64,8 @@ new View({
resetPasswordTarget: fromStateOr('resetPasswordTarget', ''),
resetPasswordUser: fromStateOr('resetPasswordUser', ''),
directLogin: query.direct === '1',
+ hasPasswordless: fromStateOr('webauthn-available', false),
+ isHttps: window.location.protocol === 'https:',
+ hasPublicKeyCredential: typeof (window.PublicKeyCredential) !== 'undefined',
},
}).$mount('#login')
diff --git a/core/src/service/WebAuthnAuthenticationService.js b/core/src/service/WebAuthnAuthenticationService.js
new file mode 100644
index 00000000000..91f19177066
--- /dev/null
+++ b/core/src/service/WebAuthnAuthenticationService.js
@@ -0,0 +1,37 @@
+/**
+ * @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl>
+ *
+ * @author Roeland Jago Douma <roeland@famdouma.nl>
+ *
+ * @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/>.
+ */
+
+import Axios from '@nextcloud/axios'
+import { generateUrl } from '@nextcloud/router'
+
+export function startAuthentication(loginName) {
+ const url = generateUrl('/login/webauthn/start')
+
+ return Axios.post(url, { loginName })
+ .then(resp => resp.data)
+}
+
+export function finishAuthentication(data) {
+ const url = generateUrl('/login/webauthn/finish')
+
+ return Axios.post(url, { data })
+ .then(resp => resp.data)
+}
diff --git a/core/src/views/Login.vue b/core/src/views/Login.vue
index baea18cbe3c..a50e6c5c72c 100644
--- a/core/src/views/Login.vue
+++ b/core/src/views/Login.vue
@@ -22,7 +22,7 @@
<template>
<div>
<transition name="fade" mode="out-in">
- <div v-if="!resetPassword && resetPasswordTarget === ''"
+ <div v-if="!passwordlessLogin && !resetPassword && resetPasswordTarget === ''"
key="login">
<LoginForm
:username.sync="user"
@@ -45,6 +45,25 @@
@click.prevent="resetPassword = true">
{{ t('core', 'Forgot password?') }}
</a>
+ <br>
+ <a v-if="hasPasswordless" @click.prevent="passwordlessLogin = true">
+ {{ t('core', 'Log in with a device') }}
+ </a>
+ </div>
+ <div v-else-if="!loading && passwordlessLogin"
+ key="reset"
+ class="login-additional">
+ <PasswordLessLoginForm
+ :username.sync="user"
+ :redirect-url="redirectUrl"
+ :inverted-colors="invertedColors"
+ :auto-complete-allowed="autoCompleteAllowed"
+ :isHttps="isHttps"
+ :hasPublicKeyCredential="hasPublicKeyCredential"
+ @submit="loading = true" />
+ <a @click.prevent="passwordlessLogin = false">
+ {{ t('core', 'Back') }}
+ </a>
</div>
<div v-else-if="!loading && canResetPassword"
key="reset"
@@ -69,6 +88,7 @@
<script>
import LoginForm from '../components/login/LoginForm.vue'
+import PasswordLessLoginForm from '../components/login/PasswordLessLoginForm.vue'
import ResetPassword from '../components/login/ResetPassword.vue'
import UpdatePassword from '../components/login/UpdatePassword.vue'
@@ -76,6 +96,7 @@ export default {
name: 'Login',
components: {
LoginForm,
+ PasswordLessLoginForm,
ResetPassword,
UpdatePassword,
},
@@ -120,11 +141,24 @@ export default {
type: Boolean,
default: false,
},
+ hasPasswordless: {
+ type: Boolean,
+ default: false,
+ },
+ isHttps: {
+ type: Boolean,
+ default: false,
+ },
+ hasPublicKeyCredential: {
+ type: Boolean,
+ default: false,
+ },
},
data() {
return {
loading: false,
user: this.username,
+ passwordlessLogin: false,
resetPassword: false,
}
},