Commit ec8dfdb7 authored by Agate's avatar Agate 💬

Use scoped tokens to load <audio> urls instead of JWT

parent 13d28f7b
......@@ -609,6 +609,8 @@ OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "users.AccessToken"
OAUTH2_PROVIDER_GRANT_MODEL = "users.Grant"
OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "users.RefreshToken"
SCOPED_TOKENS_MAX_AGE = 60 * 60 * 24 * 3
# LDAP AUTHENTICATION CONFIGURATION
# ------------------------------------------------------------------------------
AUTH_LDAP_ENABLED = env.bool("LDAP_ENABLED", default=False)
......
......@@ -29,6 +29,7 @@ class TokenAuthMiddleware:
self.inner = inner
def __call__(self, scope):
# XXX: 1.0 remove this, replace with websocket/scopedtoken
auth = TokenHeaderAuth()
try:
user, token = auth.authenticate(scope)
......
......@@ -30,6 +30,7 @@ from funkwhale_api.federation import tasks as federation_tasks
from funkwhale_api.tags.models import Tag, TaggedItem
from funkwhale_api.tags.serializers import TagSerializer
from funkwhale_api.users.oauth import permissions as oauth_permissions
from funkwhale_api.users.authentication import ScopedTokenAuthentication
from . import filters, licenses, models, serializers, tasks, utils
......@@ -571,7 +572,7 @@ class ListenViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
serializer_class = serializers.TrackSerializer
authentication_classes = (
rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES
+ [SignatureAuthentication]
+ [SignatureAuthentication, ScopedTokenAuthentication]
)
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "libraries"
......
from django.conf import settings
from django.core import signing
from rest_framework import authentication
from rest_framework import exceptions
from django.core.exceptions import ValidationError
from .oauth import scopes as available_scopes
from . import models
def generate_scoped_token(user_id, user_secret, scopes):
if set(scopes) & set(available_scopes.SCOPES_BY_ID) != set(scopes):
raise ValueError("{} contains invalid scopes".format(scopes))
return signing.dumps(
{
"user_id": user_id,
"user_secret": str(user_secret),
"scopes": list(sorted(scopes)),
},
salt="scoped_tokens",
)
def authenticate_scoped_token(token):
try:
payload = signing.loads(
token, salt="scoped_tokens", max_age=settings.SCOPED_TOKENS_MAX_AGE,
)
except signing.BadSignature:
raise exceptions.AuthenticationFailed("Invalid token signature")
try:
user_id = int(payload["user_id"])
user_secret = str(payload["user_secret"])
scopes = list(payload["scopes"])
except (KeyError, ValueError, TypeError):
raise exceptions.AuthenticationFailed("Invalid scoped token payload")
try:
user = (
models.User.objects.all()
.for_auth()
.get(pk=user_id, secret_key=user_secret, is_active=True)
)
except (models.User.DoesNotExist, ValidationError):
raise exceptions.AuthenticationFailed("Invalid user")
return user, scopes
class ScopedTokenAuthentication(authentication.BaseAuthentication):
"""
Used when signed token returned by generate_scoped_token are provided via
token= in GET requests. Mostly for <audio src=""> urls, since it's not possible
to override headers sent by the browser when loading media.
"""
def authenticate(self, request):
data = request.GET
token = data.get("token")
if not token:
return None
try:
user, scopes = authenticate_scoped_token(token)
except exceptions.AuthenticationFailed:
raise exceptions.AuthenticationFailed("Invalid token")
setattr(request, "scopes", scopes)
setattr(request, "actor", user.actor)
return user, None
......@@ -77,6 +77,10 @@ class ScopePermission(permissions.BasePermission):
if isinstance(token, models.AccessToken):
return self.has_permission_token(token, required_scope)
elif getattr(request, "scopes", None):
return should_allow(
required_scope=required_scope, request_scopes=set(request.scopes)
)
elif request.user.is_authenticated:
user_scopes = scopes.get_from_permissions(**request.user.get_permissions())
return should_allow(
......
......@@ -22,6 +22,7 @@ from funkwhale_api.moderation import utils as moderation_utils
from . import adapters
from . import models
from . import authentication as users_authentication
@deconstructible
......@@ -220,6 +221,7 @@ class UserReadSerializer(serializers.ModelSerializer):
class MeSerializer(UserReadSerializer):
quota_status = serializers.SerializerMethodField()
summary = serializers.SerializerMethodField()
tokens = serializers.SerializerMethodField()
class Meta(UserReadSerializer.Meta):
fields = UserReadSerializer.Meta.fields + [
......@@ -227,6 +229,7 @@ class MeSerializer(UserReadSerializer):
"instance_support_message_display_date",
"funkwhale_support_message_display_date",
"summary",
"tokens",
]
def get_quota_status(self, o):
......@@ -237,6 +240,13 @@ class MeSerializer(UserReadSerializer):
return
return common_serializers.ContentSerializer(o.actor.summary_obj).data
def get_tokens(self, o):
return {
"listen": users_authentication.generate_scoped_token(
user_id=o.pk, user_secret=o.secret_key, scopes=["read:libraries"]
)
}
class PasswordResetSerializer(PRS):
def get_email_options(self):
......
......@@ -15,6 +15,7 @@ from funkwhale_api.federation import api_serializers as federation_api_serialize
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.federation import tasks as federation_tasks
from funkwhale_api.music import licenses, models, serializers, tasks, views
from funkwhale_api.users import authentication as users_authentication
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
......@@ -1488,3 +1489,15 @@ def test_other_user_cannot_delete_track(factories, logged_in_api_client):
assert response.status_code == 404
track.refresh_from_db()
def test_listen_to_track_with_scoped_token(factories, api_client):
user = factories["users.User"]()
token = users_authentication.generate_scoped_token(
user_id=user.pk, user_secret=user.secret_key, scopes=["read:libraries"]
)
upload = factories["music.Upload"](playable=True)
url = reverse("api:v1:listen-detail", kwargs={"uuid": upload.track.uuid})
response = api_client.get(url, {"token": token})
assert response.status_code == 200
......@@ -62,7 +62,7 @@ def test_scope_permission_anonymous_policy(
view = mocker.Mock(
required_scope="libraries", anonymous_policy=policy, anonymous_scopes=set()
)
request = mocker.Mock(method="GET", user=anonymous_user, actor=None)
request = mocker.Mock(method="GET", user=anonymous_user, actor=None, scopes=None)
p = permissions.ScopePermission()
......@@ -76,7 +76,7 @@ def test_scope_permission_dict_no_required(mocker, anonymous_user):
action="read",
anonymous_scopes=set(),
)
request = mocker.Mock(method="GET", user=anonymous_user, actor=None)
request = mocker.Mock(method="GET", user=anonymous_user, actor=None, scopes=None)
p = permissions.ScopePermission()
......@@ -97,7 +97,7 @@ def test_scope_permission_user(
):
user = factories["users.User"]()
should_allow = mocker.patch.object(permissions, "should_allow")
request = mocker.Mock(method=method, user=user, actor=None)
request = mocker.Mock(method=method, user=user, actor=None, scopes=None)
view = mocker.Mock(
required_scope=required_scope, anonymous_policy=False, action=action
)
......@@ -131,10 +131,27 @@ def test_scope_permission_token(mocker, factories):
)
def test_scope_permission_request_scopes(mocker, factories):
should_allow = mocker.patch.object(permissions, "should_allow")
request = mocker.Mock(method="POST", scopes=["write:profile", "read:playlists"])
view = mocker.Mock(required_scope="profile", anonymous_policy=False)
p = permissions.ScopePermission()
assert p.has_permission(request, view) == should_allow.return_value
should_allow.assert_called_once_with(
required_scope="write:profile",
request_scopes={"write:profile", "read:playlists"},
)
def test_scope_permission_actor(mocker, factories, anonymous_user):
should_allow = mocker.patch.object(permissions, "should_allow")
request = mocker.Mock(
method="POST", actor=factories["federation.Actor"](), user=anonymous_user
method="POST",
actor=factories["federation.Actor"](),
user=anonymous_user,
scopes=None,
)
view = mocker.Mock(required_scope="profile", anonymous_policy=False)
p = permissions.ScopePermission()
......@@ -151,7 +168,7 @@ def test_scope_permission_token_anonymous_user_auth_required(
):
preferences["common__api_authentication_required"] = True
should_allow = mocker.patch.object(permissions, "should_allow")
request = mocker.Mock(method="POST", user=anonymous_user, actor=None)
request = mocker.Mock(method="POST", user=anonymous_user, actor=None, scopes=None)
view = mocker.Mock(required_scope="profile", anonymous_policy=False)
p = permissions.ScopePermission()
......@@ -166,7 +183,7 @@ def test_scope_permission_token_anonymous_user_auth_not_required(
):
preferences["common__api_authentication_required"] = False
should_allow = mocker.patch.object(permissions, "should_allow")
request = mocker.Mock(method="POST", user=anonymous_user, actor=None)
request = mocker.Mock(method="POST", user=anonymous_user, actor=None, scopes=None)
view = mocker.Mock(
required_scope="profile", anonymous_policy="setting", anonymous_scopes=set()
)
......
import pytest
from django.core import signing
from funkwhale_api.users import authentication
def test_generate_scoped_token(mocker):
dumps = mocker.patch("django.core.signing.dumps")
result = authentication.generate_scoped_token(
user_id=42, user_secret="hello", scopes=["read"],
)
assert result == dumps.return_value
dumps.assert_called_once_with(
{"scopes": ["read"], "user_secret": "hello", "user_id": 42},
salt="scoped_tokens",
)
def test_authenticate_scoped_token(mocker, factories, settings):
loads = mocker.spy(signing, "loads")
user = factories["users.User"]()
token = signing.dumps(
{"user_id": user.pk, "user_secret": str(user.secret_key), "scopes": ["read"]},
salt="scoped_tokens",
)
logged_user, scopes = authentication.authenticate_scoped_token(token)
assert scopes == ["read"]
assert logged_user == user
loads.assert_called_once_with(
token, salt="scoped_tokens", max_age=settings.SCOPED_TOKENS_MAX_AGE
)
def test_authenticate_scoped_token_bad_signature():
with pytest.raises(authentication.exceptions.AuthenticationFailed):
authentication.authenticate_scoped_token("hello")
def test_authenticate_scoped_token_bad_secret_key(factories):
user = factories["users.User"]()
token = authentication.generate_scoped_token(
user_id=user.pk, user_secret="invalid", scopes=["read"]
)
with pytest.raises(authentication.exceptions.AuthenticationFailed):
authentication.authenticate_scoped_token(token)
def test_scope_token_authentication(fake_request, factories, mocker):
user = factories["users.User"]()
actor = user.create_actor()
authenticate_scoped_token = mocker.spy(authentication, "authenticate_scoped_token")
token = authentication.generate_scoped_token(
user_id=user.pk, user_secret=user.secret_key, scopes=["read"]
)
request = fake_request.get("/", {"token": token})
auth = authentication.ScopedTokenAuthentication()
assert auth.authenticate(request) == (user, None)
assert request.scopes == ["read"]
assert request.actor == actor
authenticate_scoped_token.assert_called_once_with(token)
def test_scope_token_invalid(fake_request, factories):
token = "test"
request = fake_request.get("/", {"token": token})
auth = authentication.ScopedTokenAuthentication()
with pytest.raises(authentication.exceptions.AuthenticationFailed):
auth.authenticate(request)
def test_scope_token_missing(fake_request, factories):
request = fake_request.get("/")
auth = authentication.ScopedTokenAuthentication()
assert auth.authenticate(request) is None
......@@ -42,3 +42,18 @@ def test_registration_serializer_validates_password_properly(data, expected_erro
with pytest.raises(serializers.serializers.ValidationError, match=expected_error):
serializer.is_valid(raise_exception=True)
def test_me_serializer_includes_tokens(factories, mocker):
user = factories["users.User"]()
generate_scoped_token = mocker.patch(
"funkwhale_api.users.authentication.generate_scoped_token"
)
expected = {"listen": generate_scoped_token.return_value}
serializer = serializers.MeSerializer(user)
assert serializer.data["tokens"] == expected
generate_scoped_token.assert_called_once_with(
user_id=user.pk, user_secret=user.secret_key, scopes=["read:libraries"]
)
......@@ -20,7 +20,6 @@
"fomantic-ui-css": "^2.8.3",
"howler": "^2.0.14",
"js-logger": "^1.4.1",
"jwt-decode": "^2.2.0",
"lodash": "^4.17.10",
"moment": "^2.22.2",
"qs": "^6.7.0",
......
......@@ -114,6 +114,10 @@ export default {
}
await this.fetchNodeInfo()
this.$store.dispatch('auth/check')
setInterval(() => {
// used to refresh profile every now and then (important for refreshing scoped tokens)
self.$store.dispatch('auth/check')
}, 1000 * 60 * 60 * 8)
this.$store.dispatch('instance/fetchSettings')
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'inbox.item_added',
......
......@@ -428,8 +428,17 @@ export default {
// so authentication can be checked by the backend
// because for audio files we cannot use the regular Authentication
// header
let param = "jwt"
let value = this.$store.state.auth.token
if (this.$store.state.auth.scopedTokens && this.$store.state.auth.scopedTokens.listen) {
// used scoped tokens instead of JWT to reduce the attack surface if the token
// is leaked
param = "token"
value = this.$store.state.auth.scopedTokens.listen
}
console.log('HELLO', param, value, this.$store.state.auth.scopedTokens)
sources.forEach(e => {
e.url = url.updateQueryString(e.url, 'jwt', this.$store.state.auth.token)
e.url = url.updateQueryString(e.url, param, value)
})
}
return sources
......
......@@ -229,10 +229,18 @@ export default {
this.upload.listen_url
)
if (this.$store.state.auth.authenticated) {
let param = "jwt"
let value = this.$store.state.auth.token
if (this.$store.state.auth.scopedTokens && this.$store.state.auth.scopedTokens.listen) {
// used scoped tokens instead of JWT to reduce the attack surface if the token
// is leaked
param = "token"
value = this.$store.state.auth.scopedTokens.listen
}
u = url.updateQueryString(
u,
"jwt",
encodeURI(this.$store.state.auth.token)
param,
encodeURI(value)
)
}
return u
......
import Vue from 'vue'
import axios from 'axios'
import jwtDecode from 'jwt-decode'
import logger from '@/logging'
import router from '@/router'
import lodash from '@/lodash'
function getDefaultScopedTokens () {
return {
listen: null,
}
}
export default {
namespaced: true,
state: {
......@@ -18,7 +22,7 @@ export default {
},
profile: null,
token: '',
tokenData: {}
scopedTokens: getDefaultScopedTokens()
},
getters: {
header: state => {
......@@ -34,7 +38,7 @@ export default {
state.username = ''
state.fullUsername = ''
state.token = ''
state.tokenData = {}
state.scopedTokens = getDefaultScopedTokens()
state.availablePermissions = {
federation: false,
settings: false,
......@@ -51,8 +55,8 @@ export default {
state.username = null
state.fullUsername = null
state.token = null
state.tokenData = null
state.profile = null
state.scopedTokens = getDefaultScopedTokens()
state.availablePermissions = {}
}
},
......@@ -69,11 +73,9 @@ export default {
},
token: (state, value) => {
state.token = value
if (value) {
state.tokenData = jwtDecode(value)
} else {
state.tokenData = {}
}
},
scopedTokens: (state, value) => {
state.scopedTokens = {...value}
},
permission: (state, {key, status}) => {
state.availablePermissions[key] = status
......@@ -159,6 +161,9 @@ export default {
commit("profile", data)
commit("username", data.username)
commit("fullUsername", data.full_username)
if (data.tokens) {
commit("scopedTokens", data.tokens)
}
Object.keys(data.permissions).forEach(function(key) {
// this makes it easier to check for permissions in templates
commit("permission", {
......
......@@ -38,7 +38,6 @@ describe('store/auth', () => {
const state = {
username: 'dummy',
token: 'dummy',
tokenData: 'dummy',
profile: 'dummy',
availablePermissions: 'dummy'
}
......@@ -46,7 +45,6 @@ describe('store/auth', () => {
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({})
})
......@@ -54,24 +52,12 @@ describe('store/auth', () => {
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: {} }
......
......@@ -5839,11 +5839,6 @@ just-extend@^4.0.2:
resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.0.2.tgz#f3f47f7dfca0f989c55410a7ebc8854b07108afc"
integrity sha512-FrLwOgm+iXrPV+5zDU6Jqu4gCRXbWEQg2O3SKONsWE4w7AXFRkryS53bpWdaL9cNol+AmR3AEYz6kn+o0fCPnw==
jwt-decode@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-2.2.0.tgz#7d86bd56679f58ce6a84704a657dd392bba81a79"
integrity sha1-fYa9VmefWM5qhHBKZX3TkruoGnk=
killable@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892"
......
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