Skip to content

Commit d63c9cd

Browse files
authored
Merge pull request #23 from LifeguardSystem/basic-auth
implementation of basic authentication
2 parents 2534add + 1dbd384 commit d63c9cd

File tree

8 files changed

+245
-3
lines changed

8 files changed

+245
-3
lines changed

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,18 @@ To see all settings avaiable run command:
104104

105105
`lifeguard -d`
106106

107+
108+
## Authentication
109+
110+
### Builtin Methods
111+
112+
#### Basic Authentication
113+
114+
Set users in lifeguard context like in the example:
115+
116+
```
117+
# in lifeguard_settings.py
118+
def setup(lifeguard_context):
119+
lifeguard_context.auth_method = BASIC_AUTH_METHOD
120+
lifeguard_context.users = [{"username": "test", "password": "pass"}]
121+
```

lifeguard/auth.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""
2+
Auth methods
3+
"""
4+
import base64
5+
6+
from flask import Response as FlaskResponse
7+
from flask import request as flask_request
8+
9+
from lifeguard.context import LIFEGUARD_CONTEXT
10+
11+
BASIC_AUTH_METHOD = "basic_auth"
12+
13+
14+
def basic_auth_login(authorization_header):
15+
"""
16+
Basic auth method
17+
"""
18+
encoded_uname_pass = authorization_header.split()[-1]
19+
username, password = base64.b64decode(encoded_uname_pass).decode("utf-8").split(":")
20+
return LIFEGUARD_CONTEXT.valid_user(username, password)
21+
22+
23+
def basic_auth_login_required_implementation(args, kwargs, function):
24+
"""
25+
Baic Auth Login Implementation
26+
"""
27+
authorization_header = flask_request.headers.get("Authorization")
28+
if authorization_header and basic_auth_login(authorization_header):
29+
return function(*args, **kwargs)
30+
response = FlaskResponse()
31+
response.headers["WWW-Authenticate"] = "Basic"
32+
return response, 401
33+
34+
35+
AUTHENTICATION_METHODS = {BASIC_AUTH_METHOD: basic_auth_login_required_implementation}

lifeguard/context.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1+
"""
2+
Lifeguard Context Implementation
3+
"""
4+
5+
16
class LifeguardContext:
27
"""
38
Lifeguard Context
49
"""
510

611
def __init__(self):
12+
self._users = []
13+
self._auth_method = None
714
self._only_settings = False
815
self._alert_email_template = """From: [[SMTP_USER]]
916
To: [[RECEIVERS]]
@@ -51,5 +58,43 @@ def alert_email_template(self, value):
5158
"""
5259
self._alert_email_template = value
5360

61+
@property
62+
def auth_method(self):
63+
"""
64+
Getter for auth method
65+
"""
66+
return self._auth_method
67+
68+
@auth_method.setter
69+
def auth_method(self, value):
70+
"""
71+
Setter for auth method
72+
"""
73+
self._auth_method = value
74+
75+
@property
76+
def users(self):
77+
"""
78+
Getter for users
79+
"""
80+
return self._users
81+
82+
@users.setter
83+
def users(self, value):
84+
"""
85+
Setter for users
86+
"""
87+
self._users = value
88+
89+
def valid_user(self, username, password):
90+
"""
91+
Check if user is valid
92+
"""
93+
return [
94+
user
95+
for user in self.users
96+
if user["username"] == username and user["password"] == password
97+
]
98+
5499

55100
LIFEGUARD_CONTEXT = LifeguardContext()

lifeguard/controllers.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from flask import Response as FlaskResponse
99
from flask import request as flask_request
1010

11+
from lifeguard.auth import AUTHENTICATION_METHODS
12+
from lifeguard.context import LIFEGUARD_CONTEXT
1113
from lifeguard.logger import lifeguard_logger as logger
1214
from lifeguard.settings import LIFEGUARD_DIRECTORY
1315

@@ -109,10 +111,27 @@ def data(self, value):
109111
self._data = value
110112

111113

114+
def login_required(function):
115+
"""
116+
Decorator for login
117+
"""
118+
119+
@wraps(function)
120+
def wrapped(*args, **kwargs):
121+
122+
if LIFEGUARD_CONTEXT.auth_method in AUTHENTICATION_METHODS:
123+
return AUTHENTICATION_METHODS[LIFEGUARD_CONTEXT.auth_method](
124+
args, kwargs, function
125+
)
126+
return function(*args, **kwargs)
127+
128+
return wrapped
129+
130+
112131
def register_custom_controller(path, function, options):
113132
endpoint = options.pop("endpoint", function.__name__)
114133
custom_controllers.add_url_rule(
115-
path, endpoint, configure_controller(function), **options
134+
path, endpoint, configure_controller(login_required(function)), **options
116135
)
117136

118137

lifeguard/server.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from flask import Flask, make_response
66

7-
from lifeguard.controllers import custom_controllers
7+
from lifeguard.controllers import custom_controllers, login_required
88
from lifeguard.logger import lifeguard_logger as logger
99
from lifeguard.repositories import ValidationRepository
1010
from lifeguard.validations import VALIDATIONS, ValidationResponseEncoder
@@ -19,13 +19,15 @@ def make_json_response(content):
1919

2020

2121
@APP.route("/lifeguard/validations/<validation>", methods=["GET"])
22+
@login_required
2223
def get_validation(validation):
2324
repository = ValidationRepository()
2425
result = repository.fetch_last_validation_result(validation)
2526
return make_json_response(ValidationResponseEncoder().encode(result))
2627

2728

2829
@APP.route("/lifeguard/validations/<validation>/execute", methods=["POST"])
30+
@login_required
2931
def execute_validation(validation):
3032
try:
3133
result = VALIDATIONS[validation]["ref"]()

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
setup(
44
name="lifeguard",
5-
version="0.0.21",
5+
version="0.0.22",
66
url="https://github.com/LifeguardSystem/lifeguard",
77
author="Diego Rubin",
88
author_email="[email protected]",

tests/test_auth.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import unittest
2+
from unittest.mock import MagicMock, patch
3+
4+
from lifeguard.auth import (
5+
basic_auth_login,
6+
basic_auth_login_required_implementation,
7+
AUTHENTICATION_METHODS,
8+
)
9+
from lifeguard.context import LifeguardContext
10+
11+
lifeguard_context = LifeguardContext()
12+
lifeguard_context.users = [{"username": "test", "password": "pass"}]
13+
14+
15+
class TestAuth(unittest.TestCase):
16+
@patch("lifeguard.auth.LIFEGUARD_CONTEXT", lifeguard_context)
17+
def test_auth_valid_user(self):
18+
header = "Basic dGVzdDpwYXNz"
19+
20+
self.assertListEqual(
21+
basic_auth_login(header), [{"username": "test", "password": "pass"}]
22+
)
23+
24+
@patch("lifeguard.auth.LIFEGUARD_CONTEXT", lifeguard_context)
25+
def test_auth_header(self):
26+
header = "Basic YTpi dGVzdDpwYXNz"
27+
28+
self.assertListEqual(
29+
basic_auth_login(header), [{"username": "test", "password": "pass"}]
30+
)
31+
32+
def test_auth_invalid_user(self):
33+
header = "Basic YTpi"
34+
35+
self.assertFalse(basic_auth_login(header), [])
36+
37+
@patch("lifeguard.auth.FlaskResponse")
38+
@patch("lifeguard.auth.flask_request", spec={})
39+
@patch("lifeguard.auth.basic_auth_login")
40+
def test_basic_auth_login_required_implementation_call_function(
41+
self, mock_login, mock_request, _mock_response
42+
):
43+
args = MagicMock(name="args")
44+
kwargs = MagicMock(name="kwargs")
45+
function = MagicMock(name="function")
46+
47+
mock_request.headers = MagicMock(name="headers")
48+
mock_request.headers.get.return_value = "returned_get"
49+
50+
mock_login.return_value = True
51+
52+
function.return_value = False
53+
54+
self.assertFalse(
55+
basic_auth_login_required_implementation(args, kwargs, function)
56+
)
57+
58+
function.assert_called()
59+
mock_request.headers.get.assert_called_with("Authorization")
60+
mock_login.assert_called_with("returned_get")
61+
62+
@patch("lifeguard.auth.FlaskResponse")
63+
@patch("lifeguard.auth.flask_request", spec={})
64+
@patch("lifeguard.auth.basic_auth_login")
65+
def test_basic_auth_login_required_implementation_return_401(
66+
self, mock_login, mock_request, mock_response
67+
):
68+
args = MagicMock(name="args")
69+
kwargs = MagicMock(name="kwargs")
70+
function = MagicMock(name="function")
71+
72+
mock_request.headers = MagicMock(name="headers")
73+
mock_request.headers.get.return_value = "returned_get"
74+
75+
mock_login.return_value = False
76+
77+
response = MagicMock(name="response")
78+
response.headers = {}
79+
mock_response.return_value = response
80+
81+
self.assertEqual(
82+
basic_auth_login_required_implementation(args, kwargs, function),
83+
(response, 401),
84+
)
85+
86+
self.assertDictEqual(response.headers, {"WWW-Authenticate": "Basic"})
87+
88+
function.assert_not_called()
89+
mock_request.headers.get.assert_called_with("Authorization")
90+
mock_login.assert_called_with("returned_get")
91+
92+
def test_authentication_methods(self):
93+
self.assertEqual(
94+
AUTHENTICATION_METHODS["basic_auth"],
95+
basic_auth_login_required_implementation,
96+
)

tests/test_controllers.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@
88
configure_controller,
99
render_template,
1010
Request,
11+
login_required,
1112
)
1213
from tests.fixtures.controllers.hello_controller import hello
1314

1415
mock_flask_request = MagicMock(name="flask_request")
1516

17+
MOCK_BASIC_AUTH = MagicMock(name="basic_auth")
18+
MOCK_AUTHENTICATION_METHODS = {"basic_auth": MOCK_BASIC_AUTH}
19+
1620

1721
class TestControllers(unittest.TestCase):
1822
@patch("lifeguard.controllers.LIFEGUARD_DIRECTORY", "tests/fixtures")
@@ -146,3 +150,29 @@ def test_request_proxy(self):
146150
self.assertEqual("method", request.method)
147151
self.assertEqual("args", request.args)
148152
self.assertEqual("values", request.values)
153+
154+
@patch("lifeguard.controllers.AUTHENTICATION_METHODS", MOCK_AUTHENTICATION_METHODS)
155+
@patch("lifeguard.controllers.LIFEGUARD_CONTEXT")
156+
def test_login_required_wrap_controller(self, mock_lifeguard_context):
157+
158+
mock_lifeguard_context.auth_method = "basic_auth"
159+
MOCK_AUTHENTICATION_METHODS["basic_auth"].return_value = False
160+
161+
a_function = MagicMock(name="a_function")
162+
wrapped = login_required(a_function)
163+
164+
self.assertFalse(wrapped())
165+
MOCK_AUTHENTICATION_METHODS["basic_auth"].assert_called_with((), {}, a_function)
166+
167+
@patch("lifeguard.controllers.AUTHENTICATION_METHODS", MOCK_AUTHENTICATION_METHODS)
168+
@patch("lifeguard.controllers.LIFEGUARD_CONTEXT")
169+
def test_login_required_not_wrap_controller(self, mock_lifeguard_context):
170+
171+
mock_lifeguard_context.auth_method = None
172+
173+
a_function = MagicMock(name="a_function")
174+
a_function.return_value = 1
175+
wrapped = login_required(a_function)
176+
177+
self.assertEqual(wrapped(), 1)
178+
MOCK_AUTHENTICATION_METHODS["basic_auth"].assert_not_called()

0 commit comments

Comments
 (0)