Skip to content

Commit b64b07e

Browse files
committed
Merge branch 'file-auth-backend'
2 parents 6841e7d + b01f484 commit b64b07e

File tree

7 files changed

+110
-7
lines changed

7 files changed

+110
-7
lines changed

code_submitter/auth.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import base64
22
import logging
3+
import secrets
34
import binascii
4-
from typing import cast, List, Tuple, Optional, Sequence
5+
from typing import cast, Dict, List, Tuple, Union, Optional, Sequence
6+
from pathlib import Path
57
from typing_extensions import TypedDict
68

9+
import yaml
710
import httpx
811
from starlette.requests import HTTPConnection
912
from starlette.responses import Response
@@ -17,6 +20,8 @@
1720

1821
logger = logging.getLogger(__name__)
1922

23+
BLUESHIRT_SCOPE = 'blueshirt'
24+
2025

2126
class User(SimpleUser):
2227
def __init__(self, username: str, team: Optional[str]) -> None:
@@ -154,7 +159,7 @@ def get_scopes(self, info: NemesisUserInfo) -> List[str]:
154159
scopes = ['authenticated']
155160

156161
if info['is_blueshirt']:
157-
scopes.append('blueshirt')
162+
scopes.append(BLUESHIRT_SCOPE)
158163

159164
return scopes
160165

@@ -199,3 +204,49 @@ def __init__(self, data: List[NemesisUserInfo] = DEFAULT) -> None:
199204

200205
async def load_user(self, username: str, password: str) -> NemesisUserInfo:
201206
return self.data[username]
207+
208+
209+
class FileBackend(BasicAuthBackend):
210+
"""
211+
Authentication backend which stores credentials in a YAML file.
212+
213+
Credentials are stored in the format `TLA: password`.
214+
215+
Note: Passwords are stored in plaintext.
216+
This is acceptable for some use-cases, for example where the
217+
data being uploaded is not sensitive or because someone having
218+
access to the credentials file almost certainly implies they have
219+
access to the upload storage anyway. However this may not be
220+
the case for all use-cases and you should evaluate the risks
221+
yourself before using this backend. You have been warned!
222+
"""
223+
224+
UNKNOWN_USER_MESSAGE = "Username or password is incorrect"
225+
BLUESHIRT_TEAM = "SRX"
226+
227+
def __init__(self, *, path: Union[str, Path]) -> None:
228+
with open(path) as f:
229+
self.credentials = cast(Dict[str, str], yaml.safe_load(f))
230+
231+
def get_scopes(self, username: str) -> List[str]:
232+
scopes = ['authenticated']
233+
234+
if username == self.BLUESHIRT_TEAM:
235+
scopes.append(BLUESHIRT_SCOPE)
236+
237+
return scopes
238+
239+
async def validate(self, username: str, password: str) -> ValidationResult:
240+
known_password = self.credentials.get(username)
241+
242+
if known_password is None:
243+
raise AuthenticationError(self.UNKNOWN_USER_MESSAGE)
244+
245+
if not secrets.compare_digest(password.encode(), known_password.encode()):
246+
raise AuthenticationError(self.UNKNOWN_USER_MESSAGE)
247+
248+
scopes = self.get_scopes(username)
249+
250+
if BLUESHIRT_SCOPE in scopes:
251+
return scopes, User("SR", None)
252+
return scopes, User(f"Team {username}", username)

code_submitter/server.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from starlette.middleware.authentication import AuthenticationMiddleware
1616

1717
from . import auth, utils, config
18-
from .auth import User
18+
from .auth import User, BLUESHIRT_SCOPE
1919
from .tables import Archive, ChoiceHistory
2020

2121
database = databases.Database(config.DATABASE_URL, force_rollback=config.TESTING)
@@ -53,6 +53,7 @@ async def homepage(request: Request) -> Response:
5353
'request': request,
5454
'chosen': chosen,
5555
'uploads': uploads,
56+
'BLUESHIRT_SCOPE': BLUESHIRT_SCOPE,
5657
})
5758

5859

@@ -121,7 +122,7 @@ async def upload(request: Request) -> Response:
121122
)
122123

123124

124-
@requires(['authenticated', 'blueshirt'])
125+
@requires(['authenticated', BLUESHIRT_SCOPE])
125126
async def download_submissions(request: Request) -> Response:
126127
buffer = io.BytesIO()
127128
with zipfile.ZipFile(buffer, mode='w') as zf:

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+
pyyaml

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ markupsafe==1.1.1 # via jinja2, mako
2626
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
29+
pyyaml==5.3.1 # via -r requirements.in
2930
rfc3986==1.4.0 # via httpx
3031
six==1.15.0 # via python-dateutil, python-multipart
3132
sniffio==1.1.0 # via httpcore, httpx

templates/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
<body>
3232
<div class="container">
3333
<h1>Virtual Competition Code Submission</h1>
34-
{% if 'blueshirt' in request.auth.scopes %}
34+
{% if BLUESHIRT_SCOPE in request.auth.scopes %}
3535
<div class="row">
3636
<div class="col-sm-6">
3737
<a href="{{ url_for('download_submissions') }}">

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: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1+
from pathlib import Path
2+
13
import test_utils
24
from starlette.requests import Request
35
from starlette.responses import Response, JSONResponse
46
from starlette.applications import Starlette
57
from starlette.authentication import AuthenticationError
68

7-
from code_submitter.auth import NemesisBackend, NemesisUserInfo
9+
from code_submitter.auth import (
10+
FileBackend,
11+
NemesisBackend,
12+
BLUESHIRT_SCOPE,
13+
NemesisUserInfo,
14+
)
815

916

1017
class NemesisAuthTests(test_utils.AsyncTestCase):
@@ -89,7 +96,47 @@ async def endpoint(request: Request) -> Response:
8996
self.assertEqual('user', user.username, "Wrong username for user")
9097

9198
self.assertEqual(
92-
['authenticated', 'blueshirt'],
99+
['authenticated', BLUESHIRT_SCOPE],
100+
scopes,
101+
"Wrong scopes for user",
102+
)
103+
104+
105+
class FileAuthTests(test_utils.AsyncTestCase):
106+
def setUp(self) -> None:
107+
super().setUp()
108+
109+
self.backend = FileBackend(
110+
path=Path(__file__).parent / 'fixtures' / 'auth-file.yml',
111+
)
112+
113+
def test_ok(self) -> None:
114+
scopes, user = self.await_(self.backend.validate('ABC', 'password1'))
115+
self.assertEqual(['authenticated'], scopes, "Wrong scopes for user")
116+
117+
self.assertEqual('Team ABC', user.username, "Wrong username for user")
118+
self.assertEqual('ABC', user.team, "Wrong team for user")
119+
120+
def test_unknown_user(self) -> None:
121+
with self.assertRaises(AuthenticationError) as e:
122+
self.await_(self.backend.validate('DEF', 'password1'))
123+
124+
self.assertEqual(e.exception.args[0], FileBackend.UNKNOWN_USER_MESSAGE)
125+
126+
def test_incorrect_password(self) -> None:
127+
with self.assertRaises(AuthenticationError) as e:
128+
self.await_(self.backend.validate('ABC', 'password2'))
129+
130+
self.assertEqual(e.exception.args[0], FileBackend.UNKNOWN_USER_MESSAGE)
131+
132+
def test_blueshirt(self) -> None:
133+
scopes, user = self.await_(self.backend.validate('SRX', 'bees'))
134+
135+
self.assertIsNone(user.team, "Wrong team for user")
136+
self.assertEqual('SR', user.username, "Wrong username for user")
137+
138+
self.assertEqual(
139+
['authenticated', BLUESHIRT_SCOPE],
93140
scopes,
94141
"Wrong scopes for user",
95142
)

0 commit comments

Comments
 (0)