Skip to content

Commit

Permalink
Added HTTP basic authentication support
Browse files Browse the repository at this point in the history
  • Loading branch information
ereOn committed Feb 5, 2015
1 parent daf39e0 commit 3e08e29
Show file tree
Hide file tree
Showing 10 changed files with 225 additions and 30 deletions.
2 changes: 2 additions & 0 deletions pyfreelan/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Entry points for pyfreelan.
"""

import os
import logging

from twisted.internet import reactor
Expand All @@ -19,6 +20,7 @@ def server_main():
#TODO: Parse the command line arguments.
configuration = {
'listen_on': '0.0.0.0:12000',
'secret_key': os.urandom(24),
}
callbacks = {}

Expand Down
2 changes: 2 additions & 0 deletions pyfreelan/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,5 @@ def associate_http_server():

hostname, port = parse_endpoint(configuration['listen_on'])
reactor.listenTCP(port, self.site, interface=hostname)

self.app.secret_key = self.configuration['secret_key']
18 changes: 11 additions & 7 deletions pyfreelan/server/application/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,25 @@
g,
make_response,
)
from .exceptions import UnexpectedFormat

APP = Flask('pyfreelan')



from flask.ext.login import login_required

from .security import LOGIN_MANAGER
from .exceptions import (
HTTPException,
UnexpectedFormat,
)

APP = Flask('pyfreelan')
LOGIN_MANAGER.init_app(APP)


@APP.errorhandler(UnexpectedFormat)
@APP.errorhandler(HTTPException)
def handle_unexpected_format(error):
return error.to_response()


@APP.route('/')
@login_required
def index():
return jsonify(
{
Expand All @@ -39,6 +42,7 @@ def index():


@APP.route('/request_certificate/', methods={'POST'})
@login_required
def request_certificate():
try:
der_certificate = g.http_server.callbacks['sign_certificate_request'](
Expand Down
56 changes: 44 additions & 12 deletions pyfreelan/server/application/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,57 @@
"""

from flask import jsonify
from werkzeug.datastructures import WWWAuthenticate


class UnexpectedFormat(Exception):
class HTTPException(Exception):
"""
The request received data in an unexpected format and does not like it very
much.
The base class for all HTTP exceptions.
"""
status_code = 406
status_code = 500
message = None
headers = {}

def __init__(self, message=None, status_code=None, headers=None):
super(HTTPException, self).__init__()

if message:
self.message = message

def __init__(self, message):
super(UnexpectedFormat, self).__init__()
self.message = message
if status_code:
self.status_code = status_code

def to_dict(self):
return {
'message': self.message,
}
if headers:
self.headers = headers.copy()

def to_response(self):
response = jsonify(self.to_dict())
response = jsonify(message=self.message)
response.status_code = self.status_code
for key, value in self.headers.iteritems():
response.headers[key] = value
return response


def get_basic_auth(realm=None):
basic_auth = WWWAuthenticate()
basic_auth.set_basic(realm=realm or 'Authentication required')
return basic_auth.to_header()


class AuthenticationRequired(HTTPException):
"""
Authentication is required to access this resource.
"""
status_code = 401
message = 'Authentication required.'
headers = {
'WWW-Authenticate': get_basic_auth(),
}


class UnexpectedFormat(HTTPException):
"""
The request received data in an unexpected format and does not like it very
much.
"""
status_code = 406
39 changes: 39 additions & 0 deletions pyfreelan/server/application/security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""
Security-related functions.
"""

from flask import g
from flask.ext.login import (
LoginManager,
login_user,
)

from .user import User
from .exceptions import AuthenticationRequired

LOGIN_MANAGER = LoginManager()


@LOGIN_MANAGER.user_loader
def load_user(username):
return User(username=username)


@LOGIN_MANAGER.unauthorized_handler
def unauthorized():
raise AuthenticationRequired


@LOGIN_MANAGER.request_loader
def load_user_from_request(request):
if request.authorization:
username = request.authorization.username
password = request.authorization.password

if g.http_server.callbacks['authenticate_user'](username, password):
user = User(
username=request.authorization.username,
)
login_user(user)

return user
23 changes: 23 additions & 0 deletions pyfreelan/server/application/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""
A user class, as required by flask_login.
"""

from flask.ext.login import UserMixin

class User(UserMixin):
"""
Represents a user.
"""

def __init__(self, username):
"""
Initialize a user.
:param username: The username.
"""
super(User, self).__init__()

self.username = username

def get_id(self):
return unicode(self.username)
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
install_requires=[
'Twisted>=15.0.0, < 16.0.0',
'Flask == 0.10.1',
'Flask-Login==0.2.11',
'py2-ipaddress == 2.0',
],
entry_points={
Expand Down
39 changes: 39 additions & 0 deletions tests/test_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""
Test the exceptions.
"""

import json

from unittest import TestCase

from pyfreelan.server.application import APP
from pyfreelan.server.application.exceptions import HTTPException


class ExceptionTests(TestCase):
def setUp(self):
self.app = APP

def test_http_exception_translates_to_response(self):
message = 'Some error'
status_code = 404
headers = {'a': '1', 'b': '2'}

ex = HTTPException(
message=message,
status_code=status_code,
headers=headers,
)

self.assertEqual(message, ex.message)
self.assertEqual(status_code, ex.status_code)
self.assertEqual(headers, ex.headers)

with self.app.test_request_context():
response = ex.to_response()

self.assertEqual({'message': message}, json.loads(response.data))
self.assertEqual(status_code, response.status_code)

for key, value in headers.iteritems():
self.assertEqual(value, response.headers.get(key))
2 changes: 2 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def test_http_server_parses_configuration(self):
reactor = mock.MagicMock()
configuration = {
'listen_on': '0.0.0.0:1234',
'secret_key': 'secret',
}
callbacks = {
'sign_certificate_request': None,
Expand All @@ -63,6 +64,7 @@ def test_http_server_registers_into_the_application_context(self):
reactor = mock.MagicMock()
configuration = {
'listen_on': '0.0.0.0:1234',
'secret_key': 'secret',
}
callbacks = {}

Expand Down
73 changes: 62 additions & 11 deletions tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from unittest import TestCase
from contextlib import contextmanager
from base64 import b64encode
from flask import g

from pyfreelan.server.application import APP
Expand All @@ -31,16 +32,60 @@ def register_callback(self, callback, name=None):
with mock.patch.dict(self.http_server.callbacks, **{name: callback}):
yield

@contextmanager
def enable_credentials(self, result):
username = 'user1'
password = 'password'

def authenticate_user(*args, **kwargs):
return result

with self.register_callback(authenticate_user):
yield self.get_credentials(username, password)

def get_credentials(self, username, password):
return {
'Authorization': 'Basic {}'.format(
b64encode('{username}:{password}'.format(
username=username,
password=password,
)),
),
}

def test_index(self):
indexes = {
'index',
'request_certificate',
}
response = self.client.get('/')

with self.enable_credentials(True) as credentials:
response = self.client.get('/', headers=credentials)

self.assertEqual(200, response.status_code)
self.assertEqual('application/json', response.content_type)
self.assertEqual(indexes, set(json.loads(response.data)))

def test_index_with_existing_session(self):
with self.enable_credentials(True) as credentials:
response = self.client.get('/', headers=credentials)
self.assertEqual(200, response.status_code)

response = self.client.get('/')
self.assertEqual(200, response.status_code)

def test_index_without_credentials(self):
response = self.client.get('/')
self.assertEqual(401, response.status_code)
self.assertEqual('application/json', response.content_type)

def test_index_with_invalid_credentials(self):
with self.enable_credentials(False) as credentials:
response = self.client.get('/', headers=credentials)

self.assertEqual(401, response.status_code)
self.assertEqual('application/json', response.content_type)

def test_request_certificate(self):
der_certificate_request = 'der_certificate_request'
der_certificate = 'der_certificate'
Expand All @@ -49,11 +94,14 @@ def sign_certificate_request(der_certificate_request):
if der_certificate_request:
return der_certificate

with self.register_callback(sign_certificate_request):
response = self.client.post(
'/request_certificate/',
data=der_certificate_request,
)
with self.enable_credentials(True) as credentials:
with self.register_callback(sign_certificate_request):
response = self.client.post(
'/request_certificate/',
data=der_certificate_request,
headers=credentials,
)

self.assertEqual(200, response.status_code)
self.assertEqual('application/x-x509-cert', response.content_type)
self.assertEqual(der_certificate, response.data)
Expand All @@ -64,11 +112,14 @@ def test_request_certificate_failure_returns_406(self):
def sign_certificate_request(der_certificate_request):
raise ValueError

with self.register_callback(sign_certificate_request):
response = self.client.post(
'/request_certificate/',
data=der_certificate_request,
)
with self.enable_credentials(True) as credentials:
with self.register_callback(sign_certificate_request):
response = self.client.post(
'/request_certificate/',
data=der_certificate_request,
headers=credentials,
)

self.assertEqual(406, response.status_code)
self.assertEqual('application/json', response.content_type)
self.assertIn('message', set(json.loads(response.data)))

0 comments on commit 3e08e29

Please sign in to comment.