Commit 8b47af8b authored by Eliot Berriot's avatar Eliot Berriot 💬

Merge branch '629-cookie-auth' into 'develop'

Resolve "Use cookies instead of local storage for auth in Web UI"

Closes #629

See merge request !666
parents c0055b3b c395076f
Pipeline #3583 passed with stages
in 2 minutes and 23 seconds
......@@ -75,6 +75,9 @@ 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"),
]
......
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.sessions import SessionMiddlewareStack
from django.conf.urls import url
from funkwhale_api.common.auth import TokenAuthMiddleware
......@@ -7,8 +8,12 @@ from funkwhale_api.instance import consumers
application = ProtocolTypeRouter(
{
# Empty for now (http->django views is added by default)
"websocket": TokenAuthMiddleware(
URLRouter([url("^api/v1/activity$", consumers.InstanceActivityConsumer)])
"websocket": SessionMiddlewareStack(
TokenAuthMiddleware(
URLRouter(
[url("^api/v1/activity$", consumers.InstanceActivityConsumer)]
)
)
)
}
)
......@@ -330,7 +330,7 @@ AUTHENTICATION_BACKENDS = (
"funkwhale_api.users.auth_backends.ModelBackend",
"allauth.account.auth_backends.AuthenticationBackend",
)
SESSION_COOKIE_HTTPONLY = False
SESSION_COOKIE_HTTPONLY = True
# 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 = True
CSRF_USE_SESSIONS = False
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
# Playlist settings
......
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:
assert self.scope["user"].pk is not None
except (AssertionError, AttributeError, KeyError):
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:
return self.close()
def accept(self):
super().accept()
......
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
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"),
]
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)
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
Now use cookie-based auth in browser instead of JWT/LocalStorage in (#629)
......@@ -17,7 +17,6 @@
"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",
......
......@@ -175,11 +175,9 @@ 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?token=${token}`)
let url = this.$store.getters['instance/absoluteUrl'](`api/v1/activity`)
url = url.replace('http://', 'ws://')
url = url.replace('https://', 'wss://')
bridge.connect(
......
......@@ -71,15 +71,6 @@ 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 () {
......
......@@ -154,13 +154,6 @@ 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() {
......
......@@ -16,6 +16,7 @@ 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
......@@ -70,8 +71,9 @@ Vue.directive('title', function (el, binding) {
)
axios.interceptors.request.use(function (config) {
// Do something before request is sent
if (store.state.auth.token) {
config.headers['Authorization'] = store.getters['auth/header']
let csrfToken = cookie.get('csrftoken')
if (csrfToken) {
config.headers['X-CSRFToken'] = csrfToken
}
return config
}, function (error) {
......@@ -85,10 +87,15 @@ 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}})
}
}
if (error.response.status === 404) {
error.backendErrors.push('Resource not found')
} else if (error.response.status === 403) {
......
import axios from 'axios'
import jwtDecode from 'jwt-decode'
import logger from '@/logging'
import router from '@/router'
......@@ -15,13 +14,6 @@ export default {
moderation: false
},
profile: null,
token: '',
tokenData: {}
},
getters: {
header: state => {
return 'JWT ' + state.token
}
},
mutations: {
reset (state) {
......@@ -29,8 +21,6 @@ export default {
state.profile = null
state.username = ''
state.fullUsername = ''
state.token = ''
state.tokenData = {}
state.availablePermissions = {
federation: false,
settings: false,
......@@ -46,8 +36,6 @@ export default {
if (value === false) {
state.username = null
state.fullUsername = null
state.token = null
state.tokenData = null
state.profile = null
state.availablePermissions = {}
}
......@@ -63,24 +51,15 @@ 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 and save the returned JWT
// Send a request to the login URL
login ({commit, dispatch}, {next, credentials, onError}) {
return axios.post('token/', credentials).then(response => {
return axios.post('auth/login/', 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)
......@@ -102,29 +81,28 @@ 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'})
})
},
check ({commit, dispatch, state}) {
logger.default.info('Checking authentication...')
var jwt = state.token
if (jwt) {
commit('token', jwt)
dispatch('fetchProfile')
dispatch('refreshToken')
} else {
dispatch('fetchProfile').then(() => {
logger.default.info('Welcome back!')
}).catch(() => {
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/').then((response) => {
axios.get('users/users/me/', {skipLoginRedirect: true}).then((response) => {
logger.default.info('Successfully fetched user profile')
dispatch('updateProfile', response.data).then(() => {
resolve(response.data)
......@@ -156,13 +134,5 @@ 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)
})
}
}
}
......@@ -114,7 +114,6 @@ 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')
......
export default {
get (name) {
var value = "; " + document.cookie;
var parts = value.split("; " + name + "=");
if (parts.length == 2) return parts.pop().split(";").shift();
}
}
......@@ -37,54 +37,21 @@ 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({
......@@ -100,30 +67,8 @@ 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('token/', {
moxios.stubRequest('auth/login/', {
status: 200,
response: {
token: 'test'
......@@ -135,9 +80,7 @@ describe('store/auth', () => {
testAction({
action: store.actions.login,
payload: {credentials: credentials},
expectedMutations: [
{ type: 'token', payload: 'test' }
],
expectedMutations: [],
expectedActions: [
{ type: 'fetchProfile' }
]
......@@ -187,18 +130,5 @@ 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' }
]
})
})
})
})
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment