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:
Agate 2020-05-18 14:55:15 +02:00
commit f4e9037f95
10 changed files with 209 additions and 41 deletions

View File

@ -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",

View File

@ -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'},
]
}
},

View File

@ -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 = ""

View File

@ -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({

View File

@ -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",

View File

@ -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)
},
}
}

View File

@ -124,6 +124,9 @@ export default {
let parser = document.createElement("a")
parser.href = url
return parser.hostname
},
appDomain: (state) => {
return document.domain
}
},
actions: {

View File

@ -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>

View File

@ -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', () => {

View File

@ -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"