Skip to content

Commit d363d80

Browse files
committed
CWS: Allow impersonating a user using an admin token
1 parent c51234a commit d363d80

5 files changed

Lines changed: 82 additions & 44 deletions

File tree

cms/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ def __init__(self):
146146
# necessary to change it.
147147
# [1] http://freedesktop.org/wiki/Software/shared-mime-info
148148
self.shared_mime_info_prefix = "/usr"
149+
self.contest_admin_token = None
149150

150151
# AdminWebServer.
151152
self.admin_listen_address = ""

cms/server/contest/authentication.py

Lines changed: 71 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ def validate_login(
7171
username: str,
7272
password: str,
7373
ip_address: AnyIPAddress,
74+
admin_token: str = ""
7475
) -> tuple[Participation | None, bytes | None]:
7576
"""Authenticate a user logging in, with username and password.
7677
@@ -91,6 +92,7 @@ def validate_login(
9192
username: the username the user provided.
9293
password: the password the user provided.
9394
ip_address: the IP address the request came from.
95+
admin_token: administrator's token used to impersonate a user
9496
9597
return: if the user couldn't
9698
be authenticated then return None, otherwise return the
@@ -103,7 +105,7 @@ def log_failed_attempt(msg, *args):
103105
"%r, on contest %s, at %s: " + msg, ip_address,
104106
username, contest.name, timestamp, *args)
105107

106-
if not contest.allow_password_authentication:
108+
if not contest.allow_password_authentication and admin_token == "":
107109
log_failed_attempt("password authentication not allowed")
108110
return None, None
109111

@@ -120,6 +122,20 @@ def log_failed_attempt(msg, *args):
120122
log_failed_attempt("user not registered to contest")
121123
return None, None
122124

125+
if admin_token != "":
126+
if (config.contest_admin_token is not None
127+
and admin_token != config.contest_admin_token):
128+
log_failed_attempt("invalid admin token")
129+
return None, None
130+
131+
logger.info("Successful impersonated login from IP address %s, as user %r, on "
132+
"contest %s, at %s", ip_address, username, contest.name,
133+
timestamp)
134+
135+
return (participation,
136+
json.dumps([username, "", make_timestamp(timestamp), True])
137+
.encode("utf-8"))
138+
123139
correct_password = get_password(participation)
124140

125141
try:
@@ -151,7 +167,7 @@ def log_failed_attempt(msg, *args):
151167
# If hashing is used, the cookie stores the hashed password so that
152168
# the expensive bcrypt call doesn't need to be done at every request.
153169
return (participation,
154-
json.dumps([username, correct_password, make_timestamp(timestamp)])
170+
json.dumps([username, correct_password, make_timestamp(timestamp), False])
155171
.encode("utf-8"))
156172

157173

@@ -166,7 +182,7 @@ def authenticate_request(
166182
cookie: bytes | None,
167183
authorization_header: bytes | None,
168184
ip_address: AnyIPAddress,
169-
) -> tuple[Participation | None, bytes | None]:
185+
) -> tuple[Participation | None, bytes | None, bool]:
170186
"""Authenticate a user returning to the site, with a cookie.
171187
172188
Given the information the user's browser provided (the cookie) and
@@ -200,16 +216,17 @@ def authenticate_request(
200216
timestamp: the date and the time of the request.
201217
cookie: the cookie the user's browser provided in the
202218
request (if any).
219+
authorization_header: the value of X-CMS-Authorization header (if any).
203220
ip_address: the IP address the request
204221
came from.
205222
206-
return: if the user
207-
couldn't be authenticated then return None, otherwise return
208-
the participation that they wanted to authenticate as; if a
209-
cookie has to be set return it as well, otherwise return None.
223+
return: a tuple consisting of participation (None if authentication failed),
224+
a cookie that has to be set (or None), and a boolean flag indicating
225+
whether the admin token was used to impersonate a user.
210226
211227
"""
212228
participation: Participation | None = None
229+
impersonated = False
213230

214231
if contest.ip_autologin:
215232
try:
@@ -219,34 +236,37 @@ def authenticate_request(
219236
if participation is not None:
220237
cookie = None
221238
except AmbiguousIPAddress:
222-
return None, None
239+
return None, None, False
223240

224-
if participation is None \
225-
and contest.allow_password_authentication:
226-
participation, cookie = _authenticate_request_from_cookie_or_authorization_header(
227-
sql_session, contest, timestamp, authorization_header if authorization_header is not None else cookie)
241+
if participation is None:
242+
participation, cookie, impersonated = (
243+
_authenticate_request_from_cookie_or_authorization_header(
244+
sql_session, contest, timestamp,
245+
authorization_header if authorization_header is not None else cookie))
228246

229247
if participation is None:
230-
return None, None
248+
return None, None, False
231249

232250
# Check if user is using the right IP (or is on the right subnet).
233-
if contest.ip_restriction and participation.ip is not None \
234-
and not any(ip_address in network for network in participation.ip):
251+
if (contest.ip_restriction and participation.ip is not None
252+
and not impersonated
253+
and not any(ip_address in network for network in participation.ip)):
235254
logger.info(
236255
"Unsuccessful authentication from IP address %s, on contest %s, "
237256
"as %s, at %s: unauthorized IP address",
238257
ip_address, contest.name, participation.user.username, timestamp)
239-
return None, None
258+
return None, None, False
240259

241260
# Check that the user is not hidden if hidden users are blocked.
242-
if contest.block_hidden_participations and participation.hidden:
261+
if (contest.block_hidden_participations and participation.hidden
262+
and not impersonated):
243263
logger.info(
244264
"Unsuccessful authentication from IP address %s, on contest %s, "
245265
"as %s, at %s: participation is hidden and unauthorized",
246266
ip_address, contest.name, participation.user.username, timestamp)
247-
return None, None
267+
return None, None, False
248268

249-
return participation, cookie
269+
return participation, cookie, impersonated
250270

251271

252272
def _authenticate_request_by_ip_address(
@@ -311,7 +331,7 @@ def _authenticate_request_by_ip_address(
311331

312332
def _authenticate_request_from_cookie_or_authorization_header(
313333
sql_session: Session, contest: Contest, timestamp: datetime, cookie: bytes | None
314-
) -> tuple[Participation | None, bytes | None]:
334+
) -> tuple[Participation | None, bytes | None, bool]:
315335
"""Return the current participation based on the cookie.
316336
317337
If a participation can be extracted, the cookie is refreshed.
@@ -323,26 +343,31 @@ def _authenticate_request_from_cookie_or_authorization_header(
323343
cookie: the contents of the cookie (or authorization header)
324344
provided in the request (if any).
325345
326-
return: the participation
327-
extracted from the cookie and the cookie to set/refresh, or
328-
None in case of errors.
346+
return: a triple of the participation extracted from the cookie (or None),
347+
the cookie to set/refresh (or None), and a boolean flag indicating
348+
impersonation of the user by the administrator.
329349
330350
"""
331351
if cookie is None:
332352
logger.info("Unsuccessful cookie authentication: no cookie provided")
333-
return None, None
353+
return None, None, False
334354

335355
# Parse cookie.
336356
try:
337-
cookie = json.loads(cookie.decode("utf-8"))
357+
cookie: typing.Any = json.loads(cookie.decode("utf-8"))
338358
username: str = cookie[0]
339359
password: str = cookie[1]
340360
last_update = make_datetime(cookie[2])
361+
impersonated: bool = cookie[3]
341362
except Exception as e:
342363
# Cookies are stored securely and thus cannot be tampered with:
343364
# this is either a programming or a configuration error.
344365
logger.warning("Invalid cookie (%s): %s", e, cookie)
345-
return None, None
366+
return None, None, False
367+
368+
# Reject if password authentication is disabled and it's not an impersonation cookie/header.
369+
if not contest.allow_password_authentication and not impersonated:
370+
return None, None, False
346371

347372
def log_failed_attempt(msg, *args):
348373
logger.info("Unsuccessful cookie authentication as %r, returning from "
@@ -353,7 +378,7 @@ def log_failed_attempt(msg, *args):
353378
if timestamp - last_update > timedelta(seconds=config.cookie_duration):
354379
log_failed_attempt("cookie expired (lasts %d seconds)",
355380
config.cookie_duration)
356-
return None, None
381+
return None, None, False
357382

358383
# Load participation from DB and make sure it exists.
359384
participation: Participation | None = (
@@ -366,22 +391,28 @@ def log_failed_attempt(msg, *args):
366391
)
367392
if participation is None:
368393
log_failed_attempt("user not registered to contest")
369-
return None, None
394+
return None, None, False
370395

371-
correct_password = get_password(participation)
372-
373-
# We compare hashed password because it would be too expensive to
374-
# re-hash the user-provided plaintext password at every request.
375-
if password != correct_password:
376-
log_failed_attempt("wrong password")
377-
return None, None
396+
if impersonated:
397+
correct_password = ""
398+
logger.info("Successful impersonation of user %r, on contest %s, "
399+
"returning from %s, at %s", username, contest.name, last_update,
400+
timestamp)
401+
else:
402+
# We compare hashed password because it would be too expensive to
403+
# re-hash the user-provided plaintext password at every request.
404+
correct_password = get_password(participation)
405+
if password != correct_password:
406+
log_failed_attempt("wrong password")
407+
return None, None, False
378408

379-
logger.info("Successful cookie authentication as user %r, on contest %s, "
380-
"returning from %s, at %s", username, contest.name, last_update,
381-
timestamp)
409+
logger.info("Successful cookie authentication as user %r, on contest %s, "
410+
"returning from %s, at %s", username, contest.name, last_update,
411+
timestamp)
382412

383413
# We store the hashed password (if hashing is used) so that the
384414
# expensive bcrypt hashing doesn't need to be done at every request.
385415
return (participation,
386-
json.dumps([username, correct_password, make_timestamp(timestamp)])
387-
.encode("utf-8"))
416+
json.dumps([username, correct_password, make_timestamp(timestamp), impersonated])
417+
.encode("utf-8"),
418+
impersonated)

cms/server/contest/handlers/api.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ def post(self):
7777
current_user = self.get_current_user()
7878

7979
username = self.get_argument("username", "")
80+
password = self.get_argument("password", "")
81+
admin_token = self.get_argument("admin_token", "")
8082

8183
if current_user is not None:
8284
if username != "" and current_user.user.username != username:
@@ -90,8 +92,6 @@ def post(self):
9092

9193
return
9294

93-
password = self.get_argument("password", "")
94-
9595
try:
9696
ip_address = ipaddress.ip_address(self.request.remote_ip)
9797
except ValueError:
@@ -101,7 +101,7 @@ def post(self):
101101

102102
participation, login_data = validate_login(
103103
self.sql_session, self.contest, self.timestamp, username, password,
104-
ip_address)
104+
ip_address, admin_token=admin_token)
105105

106106
if participation is None:
107107
self.json({"error": "Login failed"}, 403)

cms/server/contest/handlers/contest.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ def __init__(self, *args, **kwargs):
7979
super().__init__(*args, **kwargs)
8080
self.contest_url: Url = None
8181
self.contest: Contest
82+
self.impersonated_by_admin = False
8283

8384
def prepare(self):
8485
self.choose_contest()
@@ -173,7 +174,7 @@ def get_current_user(self) -> Participation | None:
173174
self.request.remote_ip)
174175
return None
175176

176-
participation, cookie = authenticate_request(
177+
participation, cookie, impersonated = authenticate_request(
177178
self.sql_session, self.contest,
178179
self.timestamp, cookie,
179180
authorization_header,
@@ -185,6 +186,7 @@ def get_current_user(self) -> Participation | None:
185186
self.set_secure_cookie(
186187
cookie_name, cookie, expires_days=None, max_age=config.cookie_duration)
187188

189+
self.impersonated_by_admin = impersonated
188190
return participation
189191

190192
def render_params(self):

config/cms.sample.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ max_input_length = 5000000
9191
# example for C++ add 'cpp/index.html', for Java 'java/index.html'.
9292
docs_path = "/usr/share/cms/docs"
9393

94+
# An authentication token that can be used by the administrator
95+
# to impersonate an arbitrary user and bypass submit restrictions.
96+
# contest_admin_token = "CHANGE-ME"
97+
9498
##################
9599
# AdminWebServer #
96100
##################

0 commit comments

Comments
 (0)