Skip to content

Commit a0d411c

Browse files
committed
Add authentication backend based on YAML file
1 parent 1ce2d86 commit a0d411c

File tree

5 files changed

+96
-5
lines changed

5 files changed

+96
-5
lines changed

code_submitter/auth.py

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import base64
2-
import logging
3-
import binascii
4-
from typing import cast, List, Tuple, Optional, Sequence
51
from typing_extensions import TypedDict
62

73
import httpx
4+
import base64
5+
import logging
6+
import secrets
7+
import binascii
8+
from typing import cast, Dict, List, Tuple, Optional, Sequence
9+
from ruamel.yaml import YAML
810
from starlette.requests import HTTPConnection
911
from starlette.responses import Response
1012
from starlette.applications import Starlette
@@ -199,3 +201,45 @@ def __init__(self, data: List[NemesisUserInfo] = DEFAULT) -> None:
199201

200202
async def load_user(self, username: str, password: str) -> NemesisUserInfo:
201203
return self.data[username]
204+
205+
206+
class FileBackend(BasicAuthBackend):
207+
"""
208+
Authentication backend which stores credentials in a YAML file.
209+
210+
Credentials are stored in the format `TLA: password`.
211+
"""
212+
213+
UNKNOWN_USER_MESSAGE = "Username or password is incorrect"
214+
BLUESHIRT_TEAM = "SRX"
215+
216+
def __init__(
217+
self,
218+
*,
219+
path: str,
220+
) -> None:
221+
with open(path) as f:
222+
self.credentials = cast(Dict[str, str], YAML(typ="safe").load(f))
223+
224+
def get_scopes(self, username: str) -> List[str]:
225+
scopes = ['authenticated']
226+
227+
if username == self.BLUESHIRT_TEAM:
228+
scopes.append('blueshirt')
229+
230+
return scopes
231+
232+
async def validate(self, username: str, password: str) -> ValidationResult:
233+
known_password = self.credentials.get(username)
234+
235+
if known_password is None:
236+
raise AuthenticationError(self.UNKNOWN_USER_MESSAGE)
237+
238+
if not secrets.compare_digest(password.encode(), known_password.encode()):
239+
raise AuthenticationError(self.UNKNOWN_USER_MESSAGE)
240+
241+
scopes = self.get_scopes(username)
242+
243+
if 'blueshirt' in scopes:
244+
return scopes, User("SR", None)
245+
return scopes, User(f"Team {username}", username)

requirements.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ databases[sqlite]
55
sqlalchemy
66
alembic
77
httpx
8+
ruamel.yaml

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ python-dateutil==2.8.1 # via alembic
2727
python-editor==1.0.4 # via alembic
2828
python-multipart==0.0.5 # via -r requirements.in
2929
rfc3986==1.4.0 # via httpx
30+
ruamel.yaml.clib==0.2.2 # via ruamel.yaml
31+
ruamel.yaml==0.16.12 # via -r requirements.in
3032
six==1.15.0 # via python-dateutil, python-multipart
3133
sniffio==1.1.0 # via httpcore, httpx
3234
sqlalchemy==1.3.18 # via -r requirements.in, alembic, databases

tests/fixtures/auth-file.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ABC: password1
2+
SRX: bees

tests/tests_auth.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import os
12
import test_utils
23
from starlette.requests import Request
3-
from code_submitter.auth import NemesisBackend, NemesisUserInfo
44
from starlette.responses import Response, JSONResponse
55
from starlette.applications import Starlette
66
from starlette.authentication import AuthenticationError
77

8+
from code_submitter.auth import FileBackend, NemesisBackend, NemesisUserInfo
9+
810

911
class NemesisAuthTests(test_utils.AsyncTestCase):
1012
def setUp(self) -> None:
@@ -92,3 +94,43 @@ async def endpoint(request: Request) -> Response:
9294
scopes,
9395
"Wrong scopes for user",
9496
)
97+
98+
99+
class FileAuthTests(test_utils.AsyncTestCase):
100+
def setUp(self) -> None:
101+
super().setUp()
102+
103+
my_dir = os.path.abspath(os.path.dirname(__file__))
104+
105+
self.backend = FileBackend(path=os.path.join(my_dir, "fixtures", "auth-file.yml"))
106+
107+
def test_ok(self) -> None:
108+
scopes, user = self.await_(self.backend.validate('ABC', 'password1'))
109+
self.assertEqual(['authenticated'], scopes, "Wrong scopes for user")
110+
111+
self.assertEqual('Team ABC', user.username, "Wrong username for user")
112+
self.assertEqual('ABC', user.team, "Wrong team for user")
113+
114+
def test_unknown_user(self) -> None:
115+
with self.assertRaises(AuthenticationError) as e:
116+
self.await_(self.backend.validate('DEF', 'password1'))
117+
118+
self.assertEqual(e.exception.args[0], FileBackend.UNKNOWN_USER_MESSAGE)
119+
120+
def test_incorrect_password(self) -> None:
121+
with self.assertRaises(AuthenticationError) as e:
122+
self.await_(self.backend.validate('ABC', 'password2'))
123+
124+
self.assertEqual(e.exception.args[0], FileBackend.UNKNOWN_USER_MESSAGE)
125+
126+
def test_blueshirt(self) -> None:
127+
scopes, user = self.await_(self.backend.validate('SRX', 'bees'))
128+
129+
self.assertIsNone(user.team, "Wrong team for user")
130+
self.assertEqual('SR', user.username, "Wrong username for user")
131+
132+
self.assertEqual(
133+
['authenticated', 'blueshirt'],
134+
scopes,
135+
"Wrong scopes for user",
136+
)

0 commit comments

Comments
 (0)