Skip to content

Commit e644794

Browse files
committed
Implement a nemesis auth backend
Fixes #1.
1 parent 54bda2f commit e644794

File tree

3 files changed

+162
-1
lines changed

3 files changed

+162
-1
lines changed

code_submitter/auth.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
import base64
2+
import logging
23
import binascii
3-
from typing import Tuple, Optional, Sequence
4+
from typing import cast, List, Tuple, Optional, Sequence
5+
from typing_extensions import TypedDict
46

7+
import httpx
58
from starlette.requests import HTTPConnection
69
from starlette.responses import Response
10+
from starlette.applications import Starlette
711
from starlette.authentication import (
812
SimpleUser,
913
AuthCredentials,
1014
AuthenticationError,
1115
AuthenticationBackend,
1216
)
1317

18+
logger = logging.getLogger(__name__)
19+
1420

1521
class User(SimpleUser):
1622
def __init__(self, username: str, team: Optional[str]) -> None:
@@ -70,3 +76,81 @@ async def validate(self, username: str, password: str) -> ValidationResult:
7076
if not password:
7177
raise AuthenticationError("Must provide a password")
7278
return ['authenticated'], User(username, self.team)
79+
80+
81+
NemesisUserInfo = TypedDict('NemesisUserInfo', {
82+
'username': str,
83+
'first_name': str,
84+
'last_name': str,
85+
'teams': List[str],
86+
'is_blueshirt': bool,
87+
'is_student': bool,
88+
'is_team_leader': bool,
89+
})
90+
91+
92+
class NemesisBackend(BasicAuthBackend):
93+
def __init__(self, _target: Optional[Starlette] = None, *, url: str) -> None:
94+
# Munge types to cope with httpx not supporting strict_optional but
95+
# actually being fine with given `None`. Note we expect only to pass
96+
# this value in tests, so need to cope with it being `None` most of the
97+
# time anyway.
98+
app = cast(Starlette, _target)
99+
self.client = httpx.AsyncClient(base_url=url, app=app)
100+
101+
async def load_user(self, username: str, password: str) -> NemesisUserInfo:
102+
async with self.client as client:
103+
respone = await client.get(
104+
'user/{}'.format(username),
105+
auth=(username, password),
106+
)
107+
108+
try:
109+
respone.raise_for_status()
110+
except httpx.HTTPError as e:
111+
if e.response.status_code != 403:
112+
logger.exception(
113+
"Failed to contact nemesis while trying to authenticate %r",
114+
username,
115+
)
116+
raise AuthenticationError(e) from e
117+
118+
return cast(NemesisUserInfo, respone.json())
119+
120+
def strip_team(self, team: str) -> str:
121+
# All teams from nemesis *should* start with this prefix...
122+
if team.startswith('team-'):
123+
return team[len('team-'):]
124+
return team
125+
126+
def get_team(self, info: NemesisUserInfo) -> Optional[str]:
127+
teams = [self.strip_team(x) for x in info['teams']]
128+
129+
if not teams:
130+
if info['is_student']:
131+
logger.warning("Competitor %r has no teams!", info['username'])
132+
return None
133+
134+
team = teams[0]
135+
136+
if len(teams) > 1:
137+
logger.warning(
138+
"User %r is in more than one team (%r), using %r",
139+
info['username'],
140+
teams,
141+
team,
142+
)
143+
144+
return team
145+
146+
async def validate(self, username: str, password: str) -> ValidationResult:
147+
if not username:
148+
raise AuthenticationError("Must provide a username")
149+
if not password:
150+
raise AuthenticationError("Must provide a password")
151+
152+
info = await self.load_user(username, password)
153+
154+
team = self.get_team(info)
155+
156+
return ['authenticated'], User(username, team)

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,6 @@
4343
'databases[sqlite]',
4444
'sqlalchemy',
4545
'alembic',
46+
'httpx',
4647
],
4748
)

tests/tests_auth.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import test_utils
2+
from starlette.requests import Request
3+
from code_submitter.auth import NemesisBackend, NemesisUserInfo
4+
from starlette.responses import Response, JSONResponse
5+
from starlette.applications import Starlette
6+
from starlette.authentication import AuthenticationError
7+
8+
9+
class NemesisAuthTests(test_utils.AsyncTestCase):
10+
def setUp(self) -> None:
11+
super().setUp()
12+
13+
self.info = NemesisUserInfo({
14+
'username': 'user',
15+
'first_name': 'Dave',
16+
'last_name': 'McDave',
17+
'teams': ['team-ABC'],
18+
'is_blueshirt': False,
19+
'is_student': True,
20+
'is_team_leader': False,
21+
})
22+
self.fake_nemesis = Starlette()
23+
self.backend = NemesisBackend(self.fake_nemesis, url='http://nowhere/')
24+
25+
def test_not_authenticated(self) -> None:
26+
@self.fake_nemesis.route('/user/user')
27+
async def endpoint(request: Request) -> Response:
28+
return JSONResponse(
29+
{'authentication_errors': [
30+
'NO_USERNAME',
31+
'NO_PASSWORD',
32+
'WRONG_PASSWORD',
33+
]},
34+
status_code=403,
35+
)
36+
37+
with self.assertRaises(AuthenticationError):
38+
self.await_(self.backend.validate('user', 'pass'))
39+
40+
def test_ok(self) -> None:
41+
@self.fake_nemesis.route('/user/user')
42+
async def endpoint(request: Request) -> Response:
43+
return JSONResponse(self.info)
44+
45+
scopes, user = self.await_(self.backend.validate('user', 'pass'))
46+
47+
self.assertEqual(['authenticated'], scopes, "Wrong scopes for user")
48+
49+
self.assertEqual('user', user.username, "Wrong username for user")
50+
self.assertEqual('ABC', user.team, "Wrong team for user")
51+
52+
def test_no_team(self) -> None:
53+
@self.fake_nemesis.route('/user/user')
54+
async def endpoint(request: Request) -> Response:
55+
self.info['teams'] = []
56+
return JSONResponse(self.info)
57+
58+
scopes, user = self.await_(self.backend.validate('user', 'pass'))
59+
60+
self.assertIsNone(user.team, "Wrong team for user")
61+
self.assertEqual('user', user.username, "Wrong username for user")
62+
63+
self.assertEqual(['authenticated'], scopes, "Wrong scopes for user")
64+
65+
def test_multiple_teams(self) -> None:
66+
@self.fake_nemesis.route('/user/user')
67+
async def endpoint(request: Request) -> Response:
68+
self.info['teams'] = ['team-DEF', 'team-ABC']
69+
return JSONResponse(self.info)
70+
71+
scopes, user = self.await_(self.backend.validate('user', 'pass'))
72+
73+
self.assertEqual('DEF', user.team, "Wrong team for user")
74+
self.assertEqual('user', user.username, "Wrong username for user")
75+
76+
self.assertEqual(['authenticated'], scopes, "Wrong scopes for user")

0 commit comments

Comments
 (0)