See #1108: support using OAuth instead of JWT in front when logging in to a different domain
This commit is contained in:
parent
70c5b3be62
commit
566da673da
|
@ -16,6 +16,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.18.0",
|
"axios": "^0.18.0",
|
||||||
|
"axios-auth-refresh": "^2.2.6",
|
||||||
"core-js": "^3.6.4",
|
"core-js": "^3.6.4",
|
||||||
"diff": "^4.0.1",
|
"diff": "^4.0.1",
|
||||||
"django-channels": "^1.1.6",
|
"django-channels": "^1.1.6",
|
||||||
|
|
|
@ -93,6 +93,8 @@ export default {
|
||||||
{id: "filters", icon: 'eye slash'},
|
{id: "filters", icon: 'eye slash'},
|
||||||
{id: "notifications", icon: 'bell'},
|
{id: "notifications", icon: 'bell'},
|
||||||
{id: "edits", icon: 'pencil alternate'},
|
{id: "edits", icon: 'pencil alternate'},
|
||||||
|
{id: "security", icon: 'lock'},
|
||||||
|
{id: "reports", icon: 'warning sign'},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<form class="ui form" @submit.prevent="submit()">
|
<form class="ui form" @submit.prevent="submit">
|
||||||
<div v-if="error" class="ui negative message">
|
<div v-if="error" class="ui negative message">
|
||||||
<div class="header"><translate translate-context="Content/Login/Error message.Title">We cannot log you in</translate></div>
|
<div class="header"><translate translate-context="Content/Login/Error message.Title">We cannot log you in</translate></div>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
|
@ -12,39 +12,46 @@
|
||||||
<li v-else>{{ error }}</li>
|
<li v-else>{{ error }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<template v-if="$store.getters['instance/appDomain'] === $store.getters['instance/domain']" >
|
||||||
<label>
|
<div class="field">
|
||||||
<translate translate-context="Content/Login/Input.Label/Noun">Username or email</translate>
|
<label>
|
||||||
<template v-if="showSignup">
|
<translate translate-context="Content/Login/Input.Label/Noun">Username or email</translate>
|
||||||
|
|
<template v-if="showSignup">
|
||||||
<router-link :to="{path: '/signup'}">
|
|
|
||||||
<translate translate-context="*/Signup/Link/Verb">Create an account</translate>
|
<router-link :to="{path: '/signup'}">
|
||||||
|
<translate translate-context="*/Signup/Link/Verb">Create an account</translate>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref="username"
|
||||||
|
tabindex="1"
|
||||||
|
required
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
autofocus
|
||||||
|
:placeholder="labels.usernamePlaceholder"
|
||||||
|
v-model="credentials.username"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>
|
||||||
|
<translate translate-context="*/*/*">Password</translate> |
|
||||||
|
<router-link :to="{name: 'auth.password-reset', query: {email: credentials.username}}">
|
||||||
|
<translate translate-context="*/Login/*/Verb">Reset your password</translate>
|
||||||
</router-link>
|
</router-link>
|
||||||
</template>
|
</label>
|
||||||
</label>
|
<password-input :index="2" required v-model="credentials.password" />
|
||||||
<input
|
|
||||||
ref="username"
|
|
||||||
tabindex="1"
|
|
||||||
required
|
|
||||||
name="username"
|
|
||||||
type="text"
|
|
||||||
autofocus
|
|
||||||
:placeholder="labels.usernamePlaceholder"
|
|
||||||
v-model="credentials.username"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>
|
|
||||||
<translate translate-context="*/*/*">Password</translate> |
|
|
||||||
<router-link :to="{name: 'auth.password-reset', query: {email: credentials.username}}">
|
|
||||||
<translate translate-context="*/Login/*/Verb">Reset your password</translate>
|
|
||||||
</router-link>
|
|
||||||
</label>
|
|
||||||
<password-input :index="2" required v-model="credentials.password" />
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p>
|
||||||
|
<translate translate-context="Contant/Auth/Paragraph" :translate-params="{domain: $store.getters['instance/domain']}">You will be redirected to %{ domain } to authenticate.</translate>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
<button tabindex="3" :class="['ui', {'loading': isLoading}, 'right', 'floated', buttonClasses, 'button']" type="submit">
|
<button tabindex="3" :class="['ui', {'loading': isLoading}, 'right', 'floated', buttonClasses, 'button']" type="submit">
|
||||||
<translate translate-context="*/Login/*/Verb">Login</translate>
|
<translate translate-context="*/Login/*/Verb">Login</translate>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
@ -79,7 +86,9 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$refs.username.focus()
|
if (this.$refs.username) {
|
||||||
|
this.$refs.username.focus()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
labels() {
|
labels() {
|
||||||
|
@ -90,7 +99,15 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
submit() {
|
async submit() {
|
||||||
|
if (this.$store.getters['instance/appDomain'] === this.$store.getters['instance/domain']) {
|
||||||
|
return await this.submitSession()
|
||||||
|
} else {
|
||||||
|
this.isLoading = true
|
||||||
|
await this.$store.dispatch('auth/oauthLogin', this.next)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async submitSession() {
|
||||||
var self = this
|
var self = this
|
||||||
self.isLoading = true
|
self.isLoading = true
|
||||||
this.error = ""
|
this.error = ""
|
||||||
|
|
|
@ -16,6 +16,7 @@ import store from './store'
|
||||||
import GetTextPlugin from 'vue-gettext'
|
import GetTextPlugin from 'vue-gettext'
|
||||||
import { sync } from 'vuex-router-sync'
|
import { sync } from 'vuex-router-sync'
|
||||||
import locales from '@/locales'
|
import locales from '@/locales'
|
||||||
|
import createAuthRefreshInterceptor from 'axios-auth-refresh';
|
||||||
|
|
||||||
import filters from '@/filters' // eslint-disable-line
|
import filters from '@/filters' // eslint-disable-line
|
||||||
import {parseAPIErrors} from '@/utils'
|
import {parseAPIErrors} from '@/utils'
|
||||||
|
@ -73,7 +74,7 @@ axios.defaults.xsrfHeaderName = 'X-CSRFToken'
|
||||||
axios.interceptors.request.use(function (config) {
|
axios.interceptors.request.use(function (config) {
|
||||||
|
|
||||||
// Do something before request is sent
|
// Do something before request is sent
|
||||||
if (store.state.auth.token) {
|
if (store.state.auth.oauth.accessToken) {
|
||||||
config.headers['Authorization'] = store.getters['auth/header']
|
config.headers['Authorization'] = store.getters['auth/header']
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
|
@ -87,7 +88,7 @@ axios.interceptors.response.use(function (response) {
|
||||||
return response
|
return response
|
||||||
}, function (error) {
|
}, function (error) {
|
||||||
error.backendErrors = []
|
error.backendErrors = []
|
||||||
if (store.state.auth.authenticated && error.response.status === 401) {
|
if (store.state.auth.authenticated && !store.state.auth.oauth.accessToken && error.response.status === 401) {
|
||||||
store.commit('auth/authenticated', false)
|
store.commit('auth/authenticated', false)
|
||||||
logger.default.warn('Received 401 response from API, redirecting to login form', router.currentRoute.fullPath)
|
logger.default.warn('Received 401 response from API, redirecting to login form', router.currentRoute.fullPath)
|
||||||
router.push({name: 'login', query: {next: router.currentRoute.fullPath}})
|
router.push({name: 'login', query: {next: router.currentRoute.fullPath}})
|
||||||
|
@ -140,6 +141,21 @@ axios.interceptors.response.use(function (response) {
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const refreshAuth = (failedRequest) => {
|
||||||
|
if (store.state.auth.oauth.accessToken) {
|
||||||
|
console.log('Failed request, refreshing auth…')
|
||||||
|
// maybe the token was expired, let's try to refresh it
|
||||||
|
return store.dispatch('auth/refreshOauthToken').then(() => {
|
||||||
|
failedRequest.response.config.headers['Authorization'] = store.getters["auth/header"];
|
||||||
|
return Promise.resolve();
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createAuthRefreshInterceptor(axios, refreshAuth);
|
||||||
|
|
||||||
store.dispatch('instance/fetchFrontSettings').finally(() => {
|
store.dispatch('instance/fetchFrontSettings').finally(() => {
|
||||||
/* eslint-disable no-new */
|
/* eslint-disable no-new */
|
||||||
new Vue({
|
new Vue({
|
||||||
|
|
|
@ -65,6 +65,16 @@ export default new Router({
|
||||||
defaultEmail: route.query.email
|
defaultEmail: route.query.email
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/auth/callback",
|
||||||
|
name: "auth.callback",
|
||||||
|
component: () =>
|
||||||
|
import(/* webpackChunkName: "auth-callback" */ "@/views/auth/Callback"),
|
||||||
|
props: route => ({
|
||||||
|
code: route.query.code,
|
||||||
|
state: route.query.state,
|
||||||
|
})
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/auth/email/confirm",
|
path: "/auth/email/confirm",
|
||||||
name: "auth.email-confirm",
|
name: "auth.email-confirm",
|
||||||
|
|
|
@ -9,6 +9,41 @@ function getDefaultScopedTokens () {
|
||||||
listen: null,
|
listen: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asForm (obj) {
|
||||||
|
let data = new FormData()
|
||||||
|
Object.entries(obj).forEach((e) => {
|
||||||
|
data.set(e[0], e[1])
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let baseUrl = `${window.location.protocol}//${window.location.hostname}`
|
||||||
|
if (window.location.port) {
|
||||||
|
baseUrl = `${baseUrl}:${window.location.port}`
|
||||||
|
}
|
||||||
|
function getDefaultOauth () {
|
||||||
|
return {
|
||||||
|
clientId: null,
|
||||||
|
clientSecret: null,
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const NEEDED_SCOPES = [
|
||||||
|
"read",
|
||||||
|
"write",
|
||||||
|
].join(' ')
|
||||||
|
async function createOauthApp(domain) {
|
||||||
|
const payload = {
|
||||||
|
name: `Funkwhale web client at ${window.location.hostname}`,
|
||||||
|
website: baseUrl,
|
||||||
|
scopes: NEEDED_SCOPES,
|
||||||
|
redirect_uris: `${baseUrl}/auth/callback`
|
||||||
|
}
|
||||||
|
return (await axios.post('oauth/apps/', payload)).data
|
||||||
|
}
|
||||||
export default {
|
export default {
|
||||||
namespaced: true,
|
namespaced: true,
|
||||||
state: {
|
state: {
|
||||||
|
@ -22,12 +57,13 @@ export default {
|
||||||
},
|
},
|
||||||
profile: null,
|
profile: null,
|
||||||
token: '',
|
token: '',
|
||||||
|
oauth: getDefaultOauth(),
|
||||||
scopedTokens: getDefaultScopedTokens()
|
scopedTokens: getDefaultScopedTokens()
|
||||||
},
|
},
|
||||||
getters: {
|
getters: {
|
||||||
header: state => {
|
header: state => {
|
||||||
if (state.token) {
|
if (state.oauth.accessToken) {
|
||||||
return 'JWT ' + state.token
|
return 'Bearer ' + state.oauth.accessToken
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -39,6 +75,7 @@ export default {
|
||||||
state.fullUsername = ''
|
state.fullUsername = ''
|
||||||
state.token = ''
|
state.token = ''
|
||||||
state.scopedTokens = getDefaultScopedTokens()
|
state.scopedTokens = getDefaultScopedTokens()
|
||||||
|
state.oauth = getDefaultOauth()
|
||||||
state.availablePermissions = {
|
state.availablePermissions = {
|
||||||
federation: false,
|
federation: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
|
@ -84,6 +121,14 @@ export default {
|
||||||
lodash.keys(payload).forEach((k) => {
|
lodash.keys(payload).forEach((k) => {
|
||||||
Vue.set(state.profile, k, payload[k])
|
Vue.set(state.profile, k, payload[k])
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
oauthApp: (state, payload) => {
|
||||||
|
state.oauth.clientId = payload.client_id
|
||||||
|
state.oauth.clientSecret = payload.client_secret
|
||||||
|
},
|
||||||
|
oauthToken: (state, payload) => {
|
||||||
|
state.oauth.accessToken = payload.access_token
|
||||||
|
state.oauth.refreshToken = payload.refresh_token
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
@ -105,8 +150,12 @@ export default {
|
||||||
onError(response)
|
onError(response)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async logout ({commit}) {
|
async logout ({state, commit}) {
|
||||||
await axios.post('users/logout')
|
try {
|
||||||
|
await axios.post('users/logout')
|
||||||
|
} catch {
|
||||||
|
console.log('Error while logging out, probably logged in via oauth')
|
||||||
|
}
|
||||||
let modules = [
|
let modules = [
|
||||||
'auth',
|
'auth',
|
||||||
'favorites',
|
'favorites',
|
||||||
|
@ -177,5 +226,45 @@ export default {
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
async oauthLogin({ state, rootState, commit, getters }, next) {
|
||||||
|
let app = await createOauthApp(getters["appDomain"])
|
||||||
|
commit("oauthApp", app)
|
||||||
|
const redirectUri = encodeURIComponent(`${baseUrl}/auth/callback`)
|
||||||
|
let params = `response_type=code&scope=${encodeURIComponent(NEEDED_SCOPES)}&redirect_uri=${redirectUri}&state=${next}&client_id=${state.oauth.clientId}`
|
||||||
|
const authorizeUrl = `${rootState.instance.instanceUrl}authorize?${params}`
|
||||||
|
console.log('Redirecting user...', authorizeUrl)
|
||||||
|
window.location = authorizeUrl
|
||||||
|
},
|
||||||
|
async handleOauthCallback({ state, commit, dispatch }, authorizationCode) {
|
||||||
|
console.log('Fetching token...')
|
||||||
|
const payload = {
|
||||||
|
client_id: state.oauth.clientId,
|
||||||
|
client_secret: state.oauth.clientSecret,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: authorizationCode,
|
||||||
|
redirect_uri: `${baseUrl}/auth/callback`
|
||||||
|
}
|
||||||
|
const response = await axios.post(
|
||||||
|
'oauth/token/',
|
||||||
|
asForm(payload),
|
||||||
|
{headers: {'Content-Type': 'multipart/form-data'}}
|
||||||
|
)
|
||||||
|
commit("oauthToken", response.data)
|
||||||
|
await dispatch('fetchProfile')
|
||||||
|
},
|
||||||
|
async refreshOauthToken({ state, commit }, authorizationCode) {
|
||||||
|
const payload = {
|
||||||
|
client_id: state.oauth.clientId,
|
||||||
|
client_secret: state.oauth.clientSecret,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: state.oauth.refreshToken,
|
||||||
|
}
|
||||||
|
let response = await axios.post(
|
||||||
|
`oauth/token/`,
|
||||||
|
asForm(payload),
|
||||||
|
{headers: {'Content-Type': 'multipart/form-data'}}
|
||||||
|
)
|
||||||
|
commit('oauthToken', response.data)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -124,6 +124,9 @@ export default {
|
||||||
let parser = document.createElement("a")
|
let parser = document.createElement("a")
|
||||||
parser.href = url
|
parser.href = url
|
||||||
return parser.hostname
|
return parser.hostname
|
||||||
|
},
|
||||||
|
appDomain: (state) => {
|
||||||
|
return document.domain
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
<template>
|
||||||
|
<main class="main pusher">
|
||||||
|
<section class="ui vertical stripe segment">
|
||||||
|
<div class="ui small text container">
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<div class="ui active inverted dimmer">
|
||||||
|
<div class="ui text loader">
|
||||||
|
<h2><translate translate-context="*/Login/*">Logging in…</translate></h2> {{ next }} {{ code }} {{ state }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ["state", "code"],
|
||||||
|
async mounted () {
|
||||||
|
await this.$store.dispatch('auth/handleOauthCallback', this.code)
|
||||||
|
this.$router.push(this.state || '/library')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -67,8 +67,8 @@ describe('store/auth', () => {
|
||||||
})
|
})
|
||||||
describe('getters', () => {
|
describe('getters', () => {
|
||||||
it('header', () => {
|
it('header', () => {
|
||||||
const state = { token: 'helloworld' }
|
const state = { oauth: {accessToken: 'helloworld' }}
|
||||||
expect(store.getters['header'](state)).to.equal('JWT helloworld')
|
expect(store.getters['header'](state)).to.equal('Bearer helloworld')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
describe('actions', () => {
|
describe('actions', () => {
|
||||||
|
|
|
@ -1934,6 +1934,11 @@ aws4@^1.8.0:
|
||||||
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f"
|
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f"
|
||||||
integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==
|
integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==
|
||||||
|
|
||||||
|
axios-auth-refresh@^2.2.6:
|
||||||
|
version "2.2.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/axios-auth-refresh/-/axios-auth-refresh-2.2.6.tgz#22cde7c961d4caa879da6337947301a63209cdd3"
|
||||||
|
integrity sha512-L3djMIUi/WTIzItr8VO9dFAraPRO9+T4sGkz5VEcxyDyX/gzPVhVvpWHwnqKxhojXZnMrTlZGIs98P12+ba/Ew==
|
||||||
|
|
||||||
axios@^0.18.0:
|
axios@^0.18.0:
|
||||||
version "0.18.1"
|
version "0.18.1"
|
||||||
resolved "https://registry.yarnpkg.com/axios/-/axios-0.18.1.tgz#ff3f0de2e7b5d180e757ad98000f1081b87bcea3"
|
resolved "https://registry.yarnpkg.com/axios/-/axios-0.18.1.tgz#ff3f0de2e7b5d180e757ad98000f1081b87bcea3"
|
||||||
|
|
Loading…
Reference in New Issue