diff --git a/front/package.json b/front/package.json index 004370f654a71e3bace6673319c87aded82fc94b..4a85f6469ebb2d6ab2898f7586f6eb9b7d5b30b1 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 91cd0e71fd9a571cd194cfc9c8093d1cec2a6f07..1ef8c97bc913735cf5eeb6153454569429bbd667 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 5e9ec22a19f6a7a6f08e784fa114d2f3c74fb526..ee0b2cccc1817247b77a5d9e43bca58fd8988ead 100644 --- a/front/src/components/auth/LoginForm.vue +++ b/front/src/components/auth/LoginForm.vue @@ -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 = "" diff --git a/front/src/main.js b/front/src/main.js index 9047d67440be9cd6e1febe7bab59fb96e81cef34..514a5539a206698af9381f9f72321b4b4c081606 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 5c6a42e241888b343dd39c4cf8c80e047ce21c3d..87d571cda946e3d99a66056d1dff2ae029bd9d55 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 8919dc12265a84a329e167b51e853f7780599f4c..ed69bfc448a4910da68bb1f8f57a9be0d2edcf08 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 5d70688dcd1cc2e553d8b508b3d3575eafb1eb50..1f251bca9d00975654e8c5cfe887a7a7179cfa5e 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 0000000000000000000000000000000000000000..47e970a0ac73f26d998ab13f28c2e7acabba54df --- /dev/null +++ b/front/src/views/auth/Callback.vue @@ -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> diff --git a/front/tests/unit/specs/store/auth.spec.js b/front/tests/unit/specs/store/auth.spec.js index 63c6d2da0667316c45b983c0365e2bf418c6946a..3de563df3efe2e41067a2d68ba0d8acbfd60d75e 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 8d39ab4d2c5c1f9ee887d4b956c9192711a62282..40aa5b9d00c10356d2c6ba8622a62a0fd9f5a1f8 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"