Merge branch '1108-oauth' into 'develop'
See #1108: support using OAuth instead of JWT in front when logging in to a different domain See merge request funkwhale/funkwhale!1127
This commit is contained in:
commit
f4e9037f95
|
@ -16,6 +16,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.18.0",
|
||||
"axios-auth-refresh": "^2.2.6",
|
||||
"core-js": "^3.6.4",
|
||||
"diff": "^4.0.1",
|
||||
"django-channels": "^1.1.6",
|
||||
|
|
|
@ -93,6 +93,8 @@ export default {
|
|||
{id: "filters", icon: 'eye slash'},
|
||||
{id: "notifications", icon: 'bell'},
|
||||
{id: "edits", icon: 'pencil alternate'},
|
||||
{id: "security", icon: 'lock'},
|
||||
{id: "reports", icon: 'warning sign'},
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<form class="ui form" @submit.prevent="submit()">
|
||||
<form class="ui form" @submit.prevent="submit">
|
||||
<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>
|
||||
<ul class="list">
|
||||
|
@ -12,39 +12,46 @@
|
|||
<li v-else>{{ error }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>
|
||||
<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>
|
||||
<template v-if="$store.getters['instance/appDomain'] === $store.getters['instance/domain']" >
|
||||
<div class="field">
|
||||
<label>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</label>
|
||||
<password-input :index="2" required v-model="credentials.password" />
|
||||
</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">
|
||||
<translate translate-context="*/Login/*/Verb">Login</translate>
|
||||
<translate translate-context="*/Login/*/Verb">Login</translate>
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
@ -79,7 +86,9 @@ export default {
|
|||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.username.focus()
|
||||
if (this.$refs.username) {
|
||||
this.$refs.username.focus()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
labels() {
|
||||
|
@ -90,7 +99,15 @@ export default {
|
|||
}
|
||||
},
|
||||
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
|
||||
self.isLoading = true
|
||||
this.error = ""
|
||||
|
|
|
@ -16,6 +16,7 @@ import store from './store'
|
|||
import GetTextPlugin from 'vue-gettext'
|
||||
import { sync } from 'vuex-router-sync'
|
||||
import locales from '@/locales'
|
||||
import createAuthRefreshInterceptor from 'axios-auth-refresh';
|
||||
|
||||
import filters from '@/filters' // eslint-disable-line
|
||||
import {parseAPIErrors} from '@/utils'
|
||||
|
@ -73,7 +74,7 @@ axios.defaults.xsrfHeaderName = 'X-CSRFToken'
|
|||
axios.interceptors.request.use(function (config) {
|
||||
|
||||
// Do something before request is sent
|
||||
if (store.state.auth.token) {
|
||||
if (store.state.auth.oauth.accessToken) {
|
||||
config.headers['Authorization'] = store.getters['auth/header']
|
||||
}
|
||||
return config
|
||||
|
@ -87,7 +88,7 @@ axios.interceptors.response.use(function (response) {
|
|||
return response
|
||||
}, function (error) {
|
||||
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)
|
||||
logger.default.warn('Received 401 response from API, redirecting to login form', 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)
|
||||
})
|
||||
|
||||
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(() => {
|
||||
/* eslint-disable no-new */
|
||||
new Vue({
|
||||
|
|
|
@ -65,6 +65,16 @@ export default new Router({
|
|||
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",
|
||||
name: "auth.email-confirm",
|
||||
|
|
|
@ -9,6 +9,41 @@ function getDefaultScopedTokens () {
|
|||
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 {
|
||||
namespaced: true,
|
||||
state: {
|
||||
|
@ -22,12 +57,13 @@ export default {
|
|||
},
|
||||
profile: null,
|
||||
token: '',
|
||||
oauth: getDefaultOauth(),
|
||||
scopedTokens: getDefaultScopedTokens()
|
||||
},
|
||||
getters: {
|
||||
header: state => {
|
||||
if (state.token) {
|
||||
return 'JWT ' + state.token
|
||||
if (state.oauth.accessToken) {
|
||||
return 'Bearer ' + state.oauth.accessToken
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -39,6 +75,7 @@ export default {
|
|||
state.fullUsername = ''
|
||||
state.token = ''
|
||||
state.scopedTokens = getDefaultScopedTokens()
|
||||
state.oauth = getDefaultOauth()
|
||||
state.availablePermissions = {
|
||||
federation: false,
|
||||
settings: false,
|
||||
|
@ -84,6 +121,14 @@ export default {
|
|||
lodash.keys(payload).forEach((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: {
|
||||
|
@ -105,8 +150,12 @@ export default {
|
|||
onError(response)
|
||||
})
|
||||
},
|
||||
async logout ({commit}) {
|
||||
await axios.post('users/logout')
|
||||
async logout ({state, commit}) {
|
||||
try {
|
||||
await axios.post('users/logout')
|
||||
} catch {
|
||||
console.log('Error while logging out, probably logged in via oauth')
|
||||
}
|
||||
let modules = [
|
||||
'auth',
|
||||
'favorites',
|
||||
|
@ -177,5 +226,45 @@ export default {
|
|||
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")
|
||||
parser.href = url
|
||||
return parser.hostname
|
||||
},
|
||||
appDomain: (state) => {
|
||||
return document.domain
|
||||
}
|
||||
},
|
||||
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', () => {
|
||||
it('header', () => {
|
||||
const state = { token: 'helloworld' }
|
||||
expect(store.getters['header'](state)).to.equal('JWT helloworld')
|
||||
const state = { oauth: {accessToken: 'helloworld' }}
|
||||
expect(store.getters['header'](state)).to.equal('Bearer helloworld')
|
||||
})
|
||||
})
|
||||
describe('actions', () => {
|
||||
|
|
|
@ -1934,6 +1934,11 @@ aws4@^1.8.0:
|
|||
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f"
|
||||
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:
|
||||
version "0.18.1"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-0.18.1.tgz#ff3f0de2e7b5d180e757ad98000f1081b87bcea3"
|
||||
|
|
Loading…
Reference in New Issue