diff --git a/front/package.json b/front/package.json index 004370f65..4a85f6469 100644 --- a/front/package.json +++ b/front/package.json @@ -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", diff --git a/front/src/components/auth/Authorize.vue b/front/src/components/auth/Authorize.vue index 91cd0e71f..1ef8c97bc 100644 --- a/front/src/components/auth/Authorize.vue +++ b/front/src/components/auth/Authorize.vue @@ -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'}, ] } }, diff --git a/front/src/components/auth/LoginForm.vue b/front/src/components/auth/LoginForm.vue index 5e9ec22a1..ee0b2cccc 100644 --- a/front/src/components/auth/LoginForm.vue +++ b/front/src/components/auth/LoginForm.vue @@ -1,5 +1,5 @@ - + We cannot log you in @@ -12,39 +12,46 @@ {{ error }} - - - Username or email - - | - - Create an account + + + + Username or email + + | + + Create an account + + + + + + + + Password | + + Reset your password - - - - - - - Password | - - Reset your password - - - + + - + + + + + You will be redirected to %{ domain } to authenticate. + + - Login + Login @@ -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 = "" diff --git a/front/src/main.js b/front/src/main.js index 9047d6744..514a5539a 100644 --- a/front/src/main.js +++ b/front/src/main.js @@ -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({ diff --git a/front/src/router/index.js b/front/src/router/index.js index 5c6a42e24..87d571cda 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -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", diff --git a/front/src/store/auth.js b/front/src/store/auth.js index 8919dc122..ed69bfc44 100644 --- a/front/src/store/auth.js +++ b/front/src/store/auth.js @@ -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) + }, } } diff --git a/front/src/store/instance.js b/front/src/store/instance.js index 5d70688dc..1f251bca9 100644 --- a/front/src/store/instance.js +++ b/front/src/store/instance.js @@ -124,6 +124,9 @@ export default { let parser = document.createElement("a") parser.href = url return parser.hostname + }, + appDomain: (state) => { + return document.domain } }, actions: { diff --git a/front/src/views/auth/Callback.vue b/front/src/views/auth/Callback.vue new file mode 100644 index 000000000..47e970a0a --- /dev/null +++ b/front/src/views/auth/Callback.vue @@ -0,0 +1,25 @@ + + + + + + + + Logging in… {{ next }} {{ code }} {{ state }} + + + + + + + + diff --git a/front/tests/unit/specs/store/auth.spec.js b/front/tests/unit/specs/store/auth.spec.js index 63c6d2da0..3de563df3 100644 --- a/front/tests/unit/specs/store/auth.spec.js +++ b/front/tests/unit/specs/store/auth.spec.js @@ -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', () => { diff --git a/front/yarn.lock b/front/yarn.lock index 8d39ab4d2..40aa5b9d0 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -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"
+ You will be redirected to %{ domain } to authenticate. +