Authentication with OpenID Connect providers (#305)

Fix setting auth config from api in state

Verify auth state before authenticating

Add showing openid providers on login

Parse auth config from /info

Add authentication through openid

Add openid auth component

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/305
Co-Authored-By: konrad <konrad@kola-entertainments.de>
Co-Committed-By: konrad <konrad@kola-entertainments.de>
This commit is contained in:
konrad 2020-11-21 16:38:40 +00:00
parent 1517f989d3
commit c536707f3a
6 changed files with 148 additions and 7 deletions

View file

@ -32,7 +32,8 @@ export default {
this.$route.name !== 'user.password-reset.request' && this.$route.name !== 'user.password-reset.request' &&
this.$route.name !== 'user.password-reset.reset' && this.$route.name !== 'user.password-reset.reset' &&
this.$route.name !== 'user.register' && this.$route.name !== 'user.register' &&
this.$route.name !== 'link-share.auth' this.$route.name !== 'link-share.auth' &&
this.$route.name !== 'openid.auth'
) { ) {
this.$router.push({name: 'user.login'}) this.$router.push({name: 'user.login'})
} }

View file

@ -8,6 +8,7 @@ import ErrorComponent from '../components/misc/error'
// User Handling // User Handling
import LoginComponent from '../views/user/Login' import LoginComponent from '../views/user/Login'
import RegisterComponent from '../views/user/Register' import RegisterComponent from '../views/user/Register'
import OpenIdAuth from '@/views/user/OpenIdAuth'
// Tasks // Tasks
import ShowTasksInRangeComponent from '../views/tasks/ShowTasksInRange' import ShowTasksInRangeComponent from '../views/tasks/ShowTasksInRange'
import LinkShareAuthComponent from '../views/sharing/LinkSharingAuth' import LinkShareAuthComponent from '../views/sharing/LinkSharingAuth'
@ -267,5 +268,10 @@ export default new Router({
name: 'filters.create', name: 'filters.create',
component: CreateSavedFilter, component: CreateSavedFilter,
}, },
{
path: '/auth/openid/:provider',
name: 'openid.auth',
component: OpenIdAuth,
},
], ],
}) })

View file

@ -98,6 +98,40 @@ export default {
ctx.commit(LOADING, false, {root: true}) ctx.commit(LOADING, false, {root: true})
}) })
}, },
openIdAuth(ctx, {provider, code}) {
const HTTP = HTTPFactory()
ctx.commit(LOADING, true, {root: true})
const data = {
code: code,
}
// Delete an eventually preexisting old token
localStorage.removeItem('token')
return HTTP.post(`/auth/openid/${provider}/callback`, data)
.then(response => {
// Save the token to local storage for later use
localStorage.setItem('token', response.data.token)
// Tell others the user is autheticated
ctx.commit('isLinkShareAuth', false)
ctx.dispatch('checkAuth')
return Promise.resolve()
})
.catch(e => {
if (e.response) {
let errorMsg = e.response.data.message
if (e.response.status === 401) {
errorMsg = 'Wrong username or password.'
}
ctx.commit(ERROR_MESSAGE, errorMsg, {root: true})
}
return Promise.reject()
})
.finally(() => {
ctx.commit(LOADING, false, {root: true})
})
},
linkShareAuth(ctx, hash) { linkShareAuth(ctx, hash) {
const HTTP = HTTPFactory() const HTTP = HTTPFactory()

View file

@ -1,5 +1,8 @@
import Vue from 'vue'
import {CONFIG} from '../mutation-types' import {CONFIG} from '../mutation-types'
import {HTTPFactory} from '@/http-common' import {HTTPFactory} from '@/http-common'
import {objectToCamelCase} from '@/helpers/case'
export default { export default {
namespaced: true, namespaced: true,
@ -20,6 +23,16 @@ export default {
privacyPolicyUrl: '', privacyPolicyUrl: '',
}, },
caldavEnabled: false, caldavEnabled: false,
auth: {
local: {
enabled: true,
},
openidConnect: {
enabled: false,
redirectUrl: '',
providers: [],
},
},
}), }),
mutations: { mutations: {
[CONFIG](state, config) { [CONFIG](state, config) {
@ -36,6 +49,11 @@ export default {
state.legal.imprintUrl = config.legal.imprint_url state.legal.imprintUrl = config.legal.imprint_url
state.legal.privacyPolicyUrl = config.legal.privacy_policy_url state.legal.privacyPolicyUrl = config.legal.privacy_policy_url
state.caldavEnabled = config.caldav_enabled state.caldavEnabled = config.caldav_enabled
const auth = objectToCamelCase(config.auth)
state.auth.local.enabled = auth.local.enabled
state.auth.openidConnect.enabled = auth.openidConnect.enabled
state.auth.openidConnect.redirectUrl = auth.openidConnect.redirectUrl
Vue.set(state.auth.openidConnect, 'providers', auth.openidConnect.providers)
}, },
}, },
actions: { actions: {

View file

@ -6,7 +6,7 @@
You successfully confirmed your email! You can log in now. You successfully confirmed your email! You can log in now.
</div> </div>
<api-config/> <api-config/>
<form @submit.prevent="submit" id="loginform"> <form @submit.prevent="submit" id="loginform" v-if="localAuthEnabled">
<div class="field"> <div class="field">
<label class="label" for="username">Username</label> <label class="label" for="username">Username</label>
<div class="control"> <div class="control">
@ -54,15 +54,16 @@
<div class="field is-grouped login-buttons"> <div class="field is-grouped login-buttons">
<div class="control is-expanded"> <div class="control is-expanded">
<button class="button is-primary" type="submit" v-bind:class="{ 'is-loading': loading}">Login <button class="button is-primary" type="submit" v-bind:class="{ 'is-loading': loading}">
Login
</button> </button>
<router-link :to="{ name: 'user.register' }" class="button" v-if="registrationEnabled">Register <router-link :to="{ name: 'user.register' }" class="button" v-if="registrationEnabled">
Register
</router-link> </router-link>
</div> </div>
<div class="control"> <div class="control">
<router-link :to="{ name: 'user.password-reset.request' }" class="reset-password-link">Reset <router-link :to="{ name: 'user.password-reset.request' }" class="reset-password-link">
your Reset your password
password
</router-link> </router-link>
</div> </div>
</div> </div>
@ -70,6 +71,13 @@
{{ errorMessage }} {{ errorMessage }}
</div> </div>
</form> </form>
<div v-if="openidConnect.enabled && openidConnect.providers.length > 0" class="mt-4">
<a @click="redirectToProvider(p)" v-for="(p, k) in openidConnect.providers" :key="k" class="button is-fullwidth">
Log in with {{ p.name }}
</a>
</div>
<legal/> <legal/>
</div> </div>
</div> </div>
@ -128,6 +136,8 @@ export default {
errorMessage: ERROR_MESSAGE, errorMessage: ERROR_MESSAGE,
needsTotpPasscode: state => state.auth.needsTotpPasscode, needsTotpPasscode: state => state.auth.needsTotpPasscode,
authenticated: state => state.auth.authenticated, authenticated: state => state.auth.authenticated,
localAuthEnabled: state => state.config.auth.local.enabled,
openidConnect: state => state.config.auth.openidConnect,
}), }),
methods: { methods: {
submit() { submit() {
@ -151,6 +161,12 @@ export default {
.catch(() => { .catch(() => {
}) })
}, },
redirectToProvider(provider) {
const state = Math.random().toString(36).substring(2, 24)
localStorage.setItem('state', state)
window.location.href = `${provider.authUrl}?client_id=${provider.clientId}&redirect_uri=${this.openidConnect.redirectUrl}${provider.key}&response_type=code&scope=&state=${state}`
},
}, },
} }
</script> </script>

View file

@ -0,0 +1,66 @@
<template>
<div>
<div class="notification is-danger" v-if="errorMessage">
{{ errorMessage }}
</div>
<div class="notification is-info" v-if="loading">
Authenticating...
</div>
</div>
</template>
<script>
import {mapState} from 'vuex'
import {ERROR_MESSAGE, LOADING} from '@/store/mutation-types'
export default {
name: 'Auth',
computed: mapState({
errorMessage: ERROR_MESSAGE,
loading: LOADING,
}),
mounted() {
this.authenticateWithCode()
},
methods: {
authenticateWithCode() {
// This component gets mounted twice: The first time when the actual auth request hits the frontend,
// the second time after that auth request succeeded and the outer component "content-no-auth" isn't used
// but instead the "content-auth" component is used. Because this component is just a route and thus
// gets mounted as part of a <router-view/> which both the content-auth and content-no-auth components have,
// this re-mounts the component, even if the user is already authenticated.
// To make sure we only try to authenticate the user once, we set this "authenticating" lock in localStorage
// which ensures only one auth request is done at a time. We don't simply check if the user is already
// authenticated to not prevent the whole authentication if some user is already logged in.
if (localStorage.getItem('authenticating')) {
return
}
localStorage.setItem('authenticating', true)
const state = localStorage.getItem('state')
if(typeof this.$route.query.state === 'undefined' || this.$route.query.state !== state) {
localStorage.removeItem('authenticating')
this.$store.commit(ERROR_MESSAGE, 'State does not match, refusing to continue!')
return
}
this.$store.commit(ERROR_MESSAGE, '')
this.$store.dispatch('auth/openIdAuth', {
provider: this.$route.params.provider,
code: this.$route.query.code,
})
.then(() => {
this.$router.push({name: 'home'})
})
.catch(() => {
// Handled through global state
})
.finally(() => {
localStorage.removeItem('authenticating')
})
},
},
}
</script>