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:
parent
1517f989d3
commit
c536707f3a
6 changed files with 148 additions and 7 deletions
|
@ -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'})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
|
@ -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()
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
66
src/views/user/OpenIdAuth.vue
Normal file
66
src/views/user/OpenIdAuth.vue
Normal 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>
|
Loading…
Reference in a new issue