Skip to content

Commit

Permalink
Merge pull request #65 from thisissoon/feature/allow-known-clients
Browse files Browse the repository at this point in the history
Feature: Allow Known Clients
  • Loading branch information
krak3n committed Aug 3, 2015
2 parents 96f0412 + 5477aaf commit 00eb960
Show file tree
Hide file tree
Showing 10 changed files with 409 additions and 10 deletions.
51 changes: 51 additions & 0 deletions fm/auth.py
@@ -0,0 +1,51 @@
#!/usr/bin/env python
# encoding: utf-8

"""
fm.auth
=======
Authentication decorators
"""

# Standard Libs
from functools import wraps

# First Party Libs
from fm import clients, session
from fm.http import Unauthorized


def authenticated(function):
""" This decorator handles allowing access to tesources based on session
or known clients.
"""

@wraps(function)
def wrapper(*args, **kwargs):
if not is_authenticated_request():
return Unauthorized()
return function(*args, **kwargs)

return wrapper


def is_authenticated_request():
""" Retruns if the request is valid
Returns
-------
bool
If the requets is valid
"""

# Known client?
if clients.valid_request():
return True

# Is a user session?
user = session.user_from_session()
if user is not None:
return True

return False
130 changes: 130 additions & 0 deletions fm/clients.py
@@ -0,0 +1,130 @@
#!/usr/bin/env python
# encoding: utf-8

"""
fm.clients
==========
Allow known external clients that are not users access to the API, these could
be the Player or Volume control systems for example which are physical boxes
that run outside of the network perimeter.
Clients should encode their request body with their private key using HMAC, this
should then be sent with the Authorization Header with the username being the
Clients ID and the password the signature.
Example
-------
GET / HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Authorization: Basic Y2xpZW50OmZ5S1dEaG5PalNuVnlGWm1mRUV6d1pNNS92NG5XZWJjYTMzZUNZdUxYOVE9
Connection: keep-alive
Clients should use SHA256 to encode their requests and send a Base 64 encoded
version of the HMAC digest.
"""

# Standard Libs
import base64
import hashlib
import hmac
from functools import wraps

# Third Party Libs
from flask import current_app, request

# First Party Libs
from fm.http import Unauthorized


def know_client_only_required(function):
""" Decorator which requires that the view is accessible by only
known external clients.
"""

@wraps(function)
def wrapper(*args, **kwargs):
if not valid_request():
return Unauthorized()
return function(*args, **kwargs)

return wrapper


def valid_request():
""" Validates an incoming request from and ensures it comes from a trusted
source.
Returns
-------
bool
If the request is from a trust client
"""

try:
auth_type, auth_creds = request.headers.get('Authorization', '').split()
except ValueError:
return False

try:
creds = base64.b64decode(auth_creds)
except TypeError:
return False

try:
cid, sig = creds.split(':')
except ValueError:
return False

key = get_private_key(cid)
if key is None:
return False

return validate_signature(key, sig)


def get_private_key(client_id):
""" Gets the private key for the client from Flask application
configuration.
Example
-------
>>> EXTERNAL_CLIENTS = {
>>> 'Client-ID': 'PrivateKey'
>>> }
Returns
-------
str or None
The clients private key or None if the client is not found
"""

clients = current_app.config.get('EXTERNAL_CLIENTS', {})
key = clients.get(client_id, None)

return key


def validate_signature(key, expected):
""" Validates the HMAC signature sent with the request, encoding the raw
request body and comparing the signatures.
Arguments
---------
key : str
The client private key
expected : str
The signature expected
Returns
-------
bool
If the signatures match
"""

# Generates a hex of the request digest based on the secret key
sig = base64.b64encode(hmac.new(key, request.data, hashlib.sha256).digest())

# Return if they match or not
return sig == expected
7 changes: 7 additions & 0 deletions fm/config/default.py
Expand Up @@ -84,3 +84,10 @@
SPOTIFY_CLIENT_ID = os.environ.get('SPOTIFY_CLIENT_ID', None)
SPOTIFY_CLIENT_SECRET = os.environ.get('SPOTIFY_CLIENT_SECRET', None)
SPOTIFY_REDIRECT_URI = os.environ.get('SPOTIFY_REDIRECT_URI', 'postmessage')

# Known External Clients

EXTERNAL_CLIENTS = {
'Soundwave': os.environ.get('SOUNDWAVE_PRIV_KEY', None),
'Shockwave': os.environ.get('SHOCKWAVE_PRIV_KEY', None),
}
2 changes: 1 addition & 1 deletion fm/session.py
Expand Up @@ -30,7 +30,7 @@
current_user = LocalProxy(lambda: user_from_session())


def authenticated(function):
def session_only_required(function):
""" Decorator which requires that the view is accessable only to users
with a valid session.
"""
Expand Down
4 changes: 2 additions & 2 deletions fm/views/oauth2.py
Expand Up @@ -23,7 +23,7 @@
from fm.models.user import User
from fm.oauth2.google import GoogleOAuth2Exception, authenticate_oauth_code
from fm.oauth2.spotify import SpotifyOAuth2Exception
from fm.session import authenticated, current_user, make_session
from fm.session import current_user, make_session, session_only_required


class GoogleTestClientView(MethodView):
Expand Down Expand Up @@ -117,7 +117,7 @@ def post(self):

class SpotifyConnectView(MethodView):

@authenticated
@session_only_required
def get(self):
try:
code = request.args['code']
Expand Down
3 changes: 2 additions & 1 deletion fm/views/player.py
Expand Up @@ -27,6 +27,7 @@

# First Party Libs
from fm import http
from fm.auth import authenticated
from fm.ext import config, db, redis
from fm.logic import stats
from fm.logic.player import Queue, Random
Expand All @@ -39,7 +40,7 @@
TrackSerializer
)
from fm.serializers.user import UserSerializer
from fm.session import authenticated, current_user
from fm.session import current_user
from fm.tasks.queue import add, add_album


Expand Down
4 changes: 2 additions & 2 deletions fm/views/user.py
Expand Up @@ -25,7 +25,7 @@
from fm.models.user import User
from fm.serializers.spotify import ArtistSerializer
from fm.serializers.user import UserSerializer
from fm.session import authenticated, current_user
from fm.session import current_user, session_only_required
from fm.thirdparty.spotify import (
PlaylistSerializer,
SpotifyApi,
Expand All @@ -37,7 +37,7 @@ class UserAuthenticatedView(MethodView):
""" Authenticated User Resource - returns the currently authenticated user.
"""

@authenticated
@session_only_required
def get(self):
""" Returns the currently authenticated user
"""
Expand Down
55 changes: 55 additions & 0 deletions tests/test_auth.py
@@ -0,0 +1,55 @@
#!/usr/bin/env python
# encoding: utf-8

"""
tests.test_auth
===============
Unittests for the fm.auth module.
"""

# Third Party Libs
import mock

# First Party Libs
from fm.auth import authenticated, is_authenticated_request
from fm.http import Unauthorized


class TestAuthenticatedDecorator(object):

@authenticated
def i_am_protected(self):
return True

@mock.patch('fm.auth.is_authenticated_request')
def test_returns_unauthorized(self, _is_authenticated_request):
_is_authenticated_request.return_value = False

response = self.i_am_protected()

assert type(response) == Unauthorized

@mock.patch('fm.auth.is_authenticated_request')
def test_returns_resource(self, _is_authenticated_request):
_is_authenticated_request.return_value = True

assert self.i_am_protected()


class TestIsAuthenticatedRequest(object):

def test_returns_false_by_default(self):
assert is_authenticated_request() == False

@mock.patch('fm.auth.session.user_from_session')
def test_valid_session(self, _user_from_session):
_user_from_session.return_value = True

assert is_authenticated_request()

@mock.patch('fm.auth.clients.valid_request')
def test_valid_client(self, _valid_request):
_valid_request.return_value = True

assert is_authenticated_request()

0 comments on commit 00eb960

Please sign in to comment.