From b9f0c6aecdbcf341aa12827e41be82486f36aa2f Mon Sep 17 00:00:00 2001 From: Eliot Berriot <contact@eliotberriot.com> Date: Fri, 15 Mar 2019 15:52:30 +0100 Subject: [PATCH] Revert "Merge branch '629-cookie-auth' into 'develop'" This reverts commit 8b47af8b810dd01e6cf64b474d79583aed7c544f, reversing changes made to c0055b3b20d3e9493dfd4cc190b528014481983e. --- api/config/api_urls.py | 3 - api/config/routing.py | 9 +-- api/config/settings/common.py | 4 +- api/funkwhale_api/common/consumers.py | 17 ++-- api/funkwhale_api/users/auth_serializers.py | 16 ---- api/funkwhale_api/users/auth_urls.py | 8 -- api/funkwhale_api/users/auth_views.py | 32 -------- api/tests/users/test_auth_views.py | 86 --------------------- changes/changelog.d/629.bugfix | 1 - front/package.json | 1 + front/src/App.vue | 4 +- front/src/components/audio/Track.vue | 9 +++ front/src/components/library/TrackBase.vue | 7 ++ front/src/main.js | 15 +--- front/src/store/auth.js | 62 +++++++++++---- front/src/store/instance.js | 1 + front/src/utils/cookie.js | 7 -- front/tests/unit/specs/store/auth.spec.js | 74 +++++++++++++++++- 18 files changed, 153 insertions(+), 203 deletions(-) delete mode 100644 api/funkwhale_api/users/auth_serializers.py delete mode 100644 api/funkwhale_api/users/auth_urls.py delete mode 100644 api/funkwhale_api/users/auth_views.py delete mode 100644 api/tests/users/test_auth_views.py delete mode 100644 changes/changelog.d/629.bugfix delete mode 100644 front/src/utils/cookie.js diff --git a/api/config/api_urls.py b/api/config/api_urls.py index c839025e..93138e9a 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -75,9 +75,6 @@ v1_patterns += [ r"^users/", include(("funkwhale_api.users.api_urls", "users"), namespace="users"), ), - url( - r"^auth/", include(("funkwhale_api.users.auth_urls", "auth"), namespace="auth") - ), url(r"^token/$", jwt_views.obtain_jwt_token, name="token"), url(r"^token/refresh/$", jwt_views.refresh_jwt_token, name="token_refresh"), ] diff --git a/api/config/routing.py b/api/config/routing.py index db8a1d91..13a67cd1 100644 --- a/api/config/routing.py +++ b/api/config/routing.py @@ -1,5 +1,4 @@ from channels.routing import ProtocolTypeRouter, URLRouter -from channels.sessions import SessionMiddlewareStack from django.conf.urls import url from funkwhale_api.common.auth import TokenAuthMiddleware @@ -8,12 +7,8 @@ from funkwhale_api.instance import consumers application = ProtocolTypeRouter( { # Empty for now (http->django views is added by default) - "websocket": SessionMiddlewareStack( - TokenAuthMiddleware( - URLRouter( - [url("^api/v1/activity$", consumers.InstanceActivityConsumer)] - ) - ) + "websocket": TokenAuthMiddleware( + URLRouter([url("^api/v1/activity$", consumers.InstanceActivityConsumer)]) ) } ) diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 220ccbc3..5f69c36d 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -330,7 +330,7 @@ AUTHENTICATION_BACKENDS = ( "funkwhale_api.users.auth_backends.ModelBackend", "allauth.account.auth_backends.AuthenticationBackend", ) -SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_HTTPONLY = False # Some really nice defaults ACCOUNT_AUTHENTICATION_METHOD = "username_email" ACCOUNT_EMAIL_REQUIRED = True @@ -537,7 +537,7 @@ MUSICBRAINZ_HOSTNAME = env("MUSICBRAINZ_HOSTNAME", default="musicbrainz.org") # Custom Admin URL, use {% url 'admin:index' %} ADMIN_URL = env("DJANGO_ADMIN_URL", default="^api/admin/") -CSRF_USE_SESSIONS = False +CSRF_USE_SESSIONS = True SESSION_ENGINE = "django.contrib.sessions.backends.cache" # Playlist settings diff --git a/api/funkwhale_api/common/consumers.py b/api/funkwhale_api/common/consumers.py index 94930f63..48c31863 100644 --- a/api/funkwhale_api/common/consumers.py +++ b/api/funkwhale_api/common/consumers.py @@ -1,22 +1,17 @@ -from asgiref.sync import async_to_sync from channels.generic.websocket import JsonWebsocketConsumer -from channels import auth + from funkwhale_api.common import channels class JsonAuthConsumer(JsonWebsocketConsumer): def connect(self): - if "user" not in self.scope: - try: - self.scope["user"] = async_to_sync(auth.get_user)(self.scope) - except (ValueError, AssertionError, AttributeError, KeyError): - return self.close() - - if self.scope["user"] and self.scope["user"].is_authenticated: - return self.accept() - else: + try: + assert self.scope["user"].pk is not None + except (AssertionError, AttributeError, KeyError): return self.close() + return self.accept() + def accept(self): super().accept() for group in self.groups: diff --git a/api/funkwhale_api/users/auth_serializers.py b/api/funkwhale_api/users/auth_serializers.py deleted file mode 100644 index 45197090..00000000 --- a/api/funkwhale_api/users/auth_serializers.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.contrib import auth - -from rest_framework import serializers - - -class LoginSerializer(serializers.Serializer): - username = serializers.CharField() - password = serializers.CharField() - - def validate(self, validated_data): - user = auth.authenticate(request=None, **validated_data) - if user is None: - raise serializers.ValidationError("Invalid username or password") - - validated_data["user"] = user - return validated_data diff --git a/api/funkwhale_api/users/auth_urls.py b/api/funkwhale_api/users/auth_urls.py deleted file mode 100644 index 385e58da..00000000 --- a/api/funkwhale_api/users/auth_urls.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.conf.urls import url - -from . import auth_views - -urlpatterns = [ - url(r"^login/$", auth_views.LoginView.as_view(), name="login"), - url(r"^logout/$", auth_views.LogoutView.as_view(), name="logout"), -] diff --git a/api/funkwhale_api/users/auth_views.py b/api/funkwhale_api/users/auth_views.py deleted file mode 100644 index 697b196c..00000000 --- a/api/funkwhale_api/users/auth_views.py +++ /dev/null @@ -1,32 +0,0 @@ -from django.contrib import auth - -from rest_framework import response -from rest_framework import views - -from . import auth_serializers - - -class LoginView(views.APIView): - authentication_classes = [] - permission_classes = [] - - def post(self, request, *args, **kwargs): - - serializer = auth_serializers.LoginSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - - auth.login(request=request, user=serializer.validated_data["user"]) - - payload = {} - - return response.Response(payload) - - -class LogoutView(views.APIView): - authentication_classes = [] - permission_classes = [] - - def post(self, request, *args, **kwargs): - auth.logout(request) - payload = {} - return response.Response(payload) diff --git a/api/tests/users/test_auth_views.py b/api/tests/users/test_auth_views.py deleted file mode 100644 index 0b375ed3..00000000 --- a/api/tests/users/test_auth_views.py +++ /dev/null @@ -1,86 +0,0 @@ -from django.contrib import auth -from django.urls import reverse - - -def test_restricted_access(api_client, db): - url = reverse("api:v1:artists-list") - response = api_client.get(url) - - assert response.status_code == 401 - - -def test_login_correct(api_client, factories, mocker): - login = mocker.spy(auth, "login") - password = "hellotest" - user = factories["users.User"]() - user.set_password(password) - user.save() - - url = reverse("api:v1:auth:login") - data = {"username": user.username, "password": password} - expected = {} - response = api_client.post(url, data) - - assert response.status_code == 200 - assert response.data == expected - login.assert_called_once_with(request=mocker.ANY, user=user) - - -def test_login_incorrect(api_client, factories, mocker): - login = mocker.spy(auth, "login") - user = factories["users.User"]() - - url = reverse("api:v1:auth:login") - data = {"username": user.username, "password": "invalid"} - response = api_client.post(url, data) - - assert response.status_code == 400 - - login.assert_not_called() - - -def test_login_inactive(api_client, factories, mocker): - login = mocker.spy(auth, "login") - password = "hellotest" - user = factories["users.User"](is_active=False) - user.set_password(password) - user.save() - - url = reverse("api:v1:auth:login") - data = {"username": user.username, "password": password} - response = api_client.post(url, data) - - assert response.status_code == 400 - assert "Invalid username or password" in response.data["non_field_errors"] - - login.assert_not_called() - - -def test_logout(logged_in_api_client, factories, mocker): - logout = mocker.spy(auth, "logout") - - url = reverse("api:v1:auth:logout") - response = logged_in_api_client.post(url) - - assert response.status_code == 200 - assert response.data == {} - logout.assert_called_once_with(request=mocker.ANY) - - -def test_logout_real(api_client, factories): - password = "hellotest" - user = factories["users.User"]() - user.set_password(password) - user.save() - - url = reverse("api:v1:auth:login") - data = {"username": user.username, "password": password} - response = api_client.post(url, data) - - url = reverse("api:v1:auth:logout") - response = api_client.post(url) - - url = reverse("api:v1:artists-list") - response = api_client.get(url) - - assert response.status_code == 401 diff --git a/changes/changelog.d/629.bugfix b/changes/changelog.d/629.bugfix deleted file mode 100644 index d8e58c2b..00000000 --- a/changes/changelog.d/629.bugfix +++ /dev/null @@ -1 +0,0 @@ -Now use cookie-based auth in browser instead of JWT/LocalStorage in (#629) diff --git a/front/package.json b/front/package.json index 5f3826fd..22b5f3bb 100644 --- a/front/package.json +++ b/front/package.json @@ -17,6 +17,7 @@ "django-channels": "^1.1.6", "howler": "^2.0.14", "js-logger": "^1.4.1", + "jwt-decode": "^2.2.0", "lodash": "^4.17.10", "masonry-layout": "^4.2.2", "moment": "^2.22.2", diff --git a/front/src/App.vue b/front/src/App.vue index 46826d6e..fd94a9f4 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -175,9 +175,11 @@ export default { } this.disconnect() let self = this + let token = this.$store.state.auth.token + // let token = 'test' const bridge = new WebSocketBridge() this.bridge = bridge - let url = this.$store.getters['instance/absoluteUrl'](`api/v1/activity`) + let url = this.$store.getters['instance/absoluteUrl'](`api/v1/activity?token=${token}`) url = url.replace('http://', 'ws://') url = url.replace('https://', 'wss://') bridge.connect( diff --git a/front/src/components/audio/Track.vue b/front/src/components/audio/Track.vue index c82b2816..2e0f8c42 100644 --- a/front/src/components/audio/Track.vue +++ b/front/src/components/audio/Track.vue @@ -71,6 +71,15 @@ export default { 'mp3' ) }) + if (this.$store.state.auth.authenticated) { + // we need to send the token directly in url + // so authentication can be checked by the backend + // because for audio files we cannot use the regular Authentication + // header + sources.forEach(e => { + e.url = url.updateQueryString(e.url, 'jwt', this.$store.state.auth.token) + }) + } return sources }, updateProgressThrottled () { diff --git a/front/src/components/library/TrackBase.vue b/front/src/components/library/TrackBase.vue index ece34f4d..34ce4a29 100644 --- a/front/src/components/library/TrackBase.vue +++ b/front/src/components/library/TrackBase.vue @@ -154,6 +154,13 @@ export default { let u = this.$store.getters["instance/absoluteUrl"]( this.upload.listen_url ) + if (this.$store.state.auth.authenticated) { + u = url.updateQueryString( + u, + "jwt", + encodeURI(this.$store.state.auth.token) + ) + } return u }, cover() { diff --git a/front/src/main.js b/front/src/main.js index a722e689..9f058d8e 100644 --- a/front/src/main.js +++ b/front/src/main.js @@ -16,7 +16,6 @@ import GetTextPlugin from 'vue-gettext' import { sync } from 'vuex-router-sync' import locales from '@/locales' -import cookie from '@/utils/cookie' import filters from '@/filters' // eslint-disable-line import globals from '@/components/globals' // eslint-disable-line @@ -71,9 +70,8 @@ Vue.directive('title', function (el, binding) { ) axios.interceptors.request.use(function (config) { // Do something before request is sent - let csrfToken = cookie.get('csrftoken') - if (csrfToken) { - config.headers['X-CSRFToken'] = csrfToken + if (store.state.auth.token) { + config.headers['Authorization'] = store.getters['auth/header'] } return config }, function (error) { @@ -87,14 +85,9 @@ axios.interceptors.response.use(function (response) { }, function (error) { error.backendErrors = [] if (error.response.status === 401) { - if (error.response.config.skipLoginRedirect) { - return Promise.reject(error) - } store.commit('auth/authenticated', false) - if (router.currentRoute.name !== 'login') { - logger.default.warn('Received 401 response from API, redirecting to login form', router.currentRoute.fullPath) - router.push({name: 'login', query: {next: 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}}) } if (error.response.status === 404) { error.backendErrors.push('Resource not found') diff --git a/front/src/store/auth.js b/front/src/store/auth.js index aec40fe0..29c4c5d7 100644 --- a/front/src/store/auth.js +++ b/front/src/store/auth.js @@ -1,4 +1,5 @@ import axios from 'axios' +import jwtDecode from 'jwt-decode' import logger from '@/logging' import router from '@/router' @@ -14,6 +15,13 @@ export default { moderation: false }, profile: null, + token: '', + tokenData: {} + }, + getters: { + header: state => { + return 'JWT ' + state.token + } }, mutations: { reset (state) { @@ -21,6 +29,8 @@ export default { state.profile = null state.username = '' state.fullUsername = '' + state.token = '' + state.tokenData = {} state.availablePermissions = { federation: false, settings: false, @@ -36,6 +46,8 @@ export default { if (value === false) { state.username = null state.fullUsername = null + state.token = null + state.tokenData = null state.profile = null state.availablePermissions = {} } @@ -51,15 +63,24 @@ export default { state.profile.avatar = value } }, + token: (state, value) => { + state.token = value + if (value) { + state.tokenData = jwtDecode(value) + } else { + state.tokenData = {} + } + }, permission: (state, {key, status}) => { state.availablePermissions[key] = status } }, actions: { - // Send a request to the login URL + // Send a request to the login URL and save the returned JWT login ({commit, dispatch}, {next, credentials, onError}) { - return axios.post('auth/login/', credentials).then(response => { + return axios.post('token/', credentials).then(response => { logger.default.info('Successfully logged in as', credentials.username) + commit('token', response.data.token) dispatch('fetchProfile').then(() => { // Redirect to a specified route router.push(next) @@ -81,28 +102,29 @@ export default { modules.forEach(m => { commit(`${m}/reset`, null, {root: true}) }) - return axios.post('auth/logout/').then(response => { - logger.default.info('Successfully logged out') - }, response => { - // we cannot contact the backend but we can at least clear our local cookies - logger.default.info('Backend unreachable, cleaning local cookies…') - }).finally(() => { - logger.default.info('Log out, goodbye!') - router.push({name: 'index'}) - }) + logger.default.info('Log out, goodbye!') + router.push({name: 'index'}) }, check ({commit, dispatch, state}) { logger.default.info('Checking authentication...') - dispatch('fetchProfile').then(() => { - logger.default.info('Welcome back!') - }).catch(() => { + var jwt = state.token + if (jwt) { + commit('token', jwt) + dispatch('fetchProfile') + dispatch('refreshToken') + } else { logger.default.info('Anonymous user') commit('authenticated', false) - }) + } }, fetchProfile ({commit, dispatch, state}) { + if (document) { + // this is to ensure we do not have any leaking cookie set by django + document.cookie = 'sessionid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;' + } + return new Promise((resolve, reject) => { - axios.get('users/users/me/', {skipLoginRedirect: true}).then((response) => { + axios.get('users/users/me/').then((response) => { logger.default.info('Successfully fetched user profile') dispatch('updateProfile', response.data).then(() => { resolve(response.data) @@ -134,5 +156,13 @@ export default { resolve() }) }, + refreshToken ({commit, dispatch, state}) { + return axios.post('token/refresh/', {token: state.token}).then(response => { + logger.default.info('Refreshed auth token') + commit('token', response.data.token) + }, response => { + logger.default.error('Error while refreshing token', response.data) + }) + } } } diff --git a/front/src/store/instance.js b/front/src/store/instance.js index 1215af5e..6af36e80 100644 --- a/front/src/store/instance.js +++ b/front/src/store/instance.js @@ -114,6 +114,7 @@ export default { commit(`${m}/reset`, null, {root: true}) }) }, + // Send a request to the login URL and save the returned JWT fetchSettings ({commit}, payload) { return axios.get('instance/settings/').then(response => { logger.default.info('Successfully fetched instance settings') diff --git a/front/src/utils/cookie.js b/front/src/utils/cookie.js deleted file mode 100644 index 2b8fd8c7..00000000 --- a/front/src/utils/cookie.js +++ /dev/null @@ -1,7 +0,0 @@ -export default { - get (name) { - var value = "; " + document.cookie; - var parts = value.split("; " + name + "="); - if (parts.length == 2) return parts.pop().split(";").shift(); - } -} diff --git a/front/tests/unit/specs/store/auth.spec.js b/front/tests/unit/specs/store/auth.spec.js index 0bb98bb9..05581979 100644 --- a/front/tests/unit/specs/store/auth.spec.js +++ b/front/tests/unit/specs/store/auth.spec.js @@ -37,21 +37,54 @@ describe('store/auth', () => { it('authenticated false', () => { const state = { username: 'dummy', + token: 'dummy', + tokenData: 'dummy', profile: 'dummy', availablePermissions: 'dummy' } store.mutations.authenticated(state, false) expect(state.authenticated).to.equal(false) expect(state.username).to.equal(null) + expect(state.token).to.equal(null) + expect(state.tokenData).to.equal(null) expect(state.profile).to.equal(null) expect(state.availablePermissions).to.deep.equal({}) }) + it('token null', () => { + const state = {} + store.mutations.token(state, null) + expect(state.token).to.equal(null) + expect(state.tokenData).to.deep.equal({}) + }) + it('token real', () => { + // generated on http://kjur.github.io/jsjws/tool_jwt.html + const state = {} + let token = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwczovL2p3dC1pZHAuZXhhbXBsZS5jb20iLCJzdWIiOiJtYWlsdG86bWlrZUBleGFtcGxlLmNvbSIsIm5iZiI6MTUxNTUzMzQyOSwiZXhwIjoxNTE1NTM3MDI5LCJpYXQiOjE1MTU1MzM0MjksImp0aSI6ImlkMTIzNDU2IiwidHlwIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZWdpc3RlciJ9.' + let tokenData = { + iss: 'https://jwt-idp.example.com', + sub: 'mailto:mike@example.com', + nbf: 1515533429, + exp: 1515537029, + iat: 1515533429, + jti: 'id123456', + typ: 'https://example.com/register' + } + store.mutations.token(state, token) + expect(state.token).to.equal(token) + expect(state.tokenData).to.deep.equal(tokenData) + }) it('permissions', () => { const state = { availablePermissions: {} } store.mutations.permission(state, {key: 'admin', status: true}) expect(state.availablePermissions).to.deep.equal({admin: true}) }) }) + describe('getters', () => { + it('header', () => { + const state = { token: 'helloworld' } + expect(store.getters['header'](state)).to.equal('JWT helloworld') + }) + }) describe('actions', () => { it('logout', () => { testAction({ @@ -67,8 +100,30 @@ describe('store/auth', () => { ] }) }) + it('check jwt null', () => { + testAction({ + action: store.actions.check, + params: {state: {}}, + expectedMutations: [ + { type: 'authenticated', payload: false } + ] + }) + }) + it('check jwt set', () => { + testAction({ + action: store.actions.check, + params: {state: {token: 'test', username: 'user'}}, + expectedMutations: [ + { type: 'token', payload: 'test' } + ], + expectedActions: [ + { type: 'fetchProfile' }, + { type: 'refreshToken' } + ] + }) + }) it('login success', () => { - moxios.stubRequest('auth/login/', { + moxios.stubRequest('token/', { status: 200, response: { token: 'test' @@ -80,7 +135,9 @@ describe('store/auth', () => { testAction({ action: store.actions.login, payload: {credentials: credentials}, - expectedMutations: [], + expectedMutations: [ + { type: 'token', payload: 'test' } + ], expectedActions: [ { type: 'fetchProfile' } ] @@ -130,5 +187,18 @@ describe('store/auth', () => { ] }) }) + it('refreshToken', () => { + moxios.stubRequest('token/refresh/', { + status: 200, + response: {token: 'newtoken'} + }) + testAction({ + action: store.actions.refreshToken, + params: {state: {token: 'oldtoken'}}, + expectedMutations: [ + { type: 'token', payload: 'newtoken' } + ] + }) + }) }) }) -- GitLab