Skip to content

Commit e7f1885

Browse files
author
Ahmon Dancy
committed
spiderpig: CAS auth integration
Update SpiderPig front and backend code to require a CAS3 login workflow, along with the exiting OTP 2FA. * Tests added for auth-related functions of `scap/spiderpig/api.py`. Change-Id: I988b41cfd6d01ffcad1d674a11feded979081586
1 parent a81f1ed commit e7f1885

19 files changed

+1249
-255
lines changed

local-dev/Dockerfile

+9
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ RUN groupadd -g ${GID} -o deployment
1313
RUN useradd -m -s /bin/bash -u ${UID} -g deployment deployer01
1414

1515
USER deployer01
16+
WORKDIR /home/deployer01
1617

1718
#########
1819
# SETUP #
@@ -29,3 +30,11 @@ ENTRYPOINT ["/entrypoint.setup"]
2930
FROM base AS deploy
3031
COPY entrypoint.deploy /
3132
ENTRYPOINT ["/entrypoint.deploy"]
33+
34+
#######
35+
# CAS #
36+
#######
37+
38+
FROM base AS cas
39+
COPY cas_server.py /home/deployer01
40+
ENTRYPOINT ["fastapi", "run", "--host", "0.0.0.0", "--port", "8002", "cas_server.py"]

local-dev/cas_server.py

+256
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
from typing import Annotated, Optional
2+
from fastapi import FastAPI, Form, Request
3+
from fastapi.responses import HTMLResponse, RedirectResponse
4+
import json
5+
import os
6+
import time
7+
import urllib.parse
8+
9+
from starlette.middleware.sessions import SessionMiddleware
10+
11+
12+
# This is a fake CAS server which handles authentication for a few
13+
# hard-coded test accounts used during SpiderPig development.
14+
15+
USER_DB = {
16+
"deployer01": {
17+
"username": "deployer01",
18+
"password": "scap100",
19+
"attributes": {
20+
"uid": "deployer01",
21+
"memberOf": [
22+
"cn=deployers,ou=groups,dc=example",
23+
"cn=admins,ou=groups,dc=example",
24+
],
25+
},
26+
},
27+
"deployer02": {
28+
"username": "deployer02",
29+
"password": "scap102",
30+
"attributes": {
31+
"uid": "deployer02",
32+
"memberOf": [
33+
"cn=deployers,ou=groups,dc=example",
34+
],
35+
},
36+
},
37+
}
38+
39+
SERVICE_DB = {"http://localhost:8000/": {"name": "SpiderPig local development"}}
40+
41+
42+
def get_service_info(service: str) -> Optional[dict]:
43+
for url_prefix, info in SERVICE_DB.items():
44+
if service.startswith(url_prefix):
45+
return info
46+
47+
return None
48+
49+
50+
TICKETS_FILENAME = "/workspace/cas-tickets"
51+
TICKET_TTL = 60 # Seconds
52+
53+
54+
def write_ticket_db(db: dict):
55+
tmpname = f"{TICKETS_FILENAME}.tmp"
56+
57+
with open(tmpname, "w") as f:
58+
json.dump(db, f)
59+
60+
os.rename(tmpname, TICKETS_FILENAME)
61+
62+
63+
def read_ticket_db() -> dict:
64+
db = {}
65+
66+
if os.path.exists(TICKETS_FILENAME):
67+
with open(TICKETS_FILENAME) as f:
68+
db = json.load(f)
69+
70+
# Discard any expired tickets
71+
now = time.time()
72+
for ticket, info in db.items():
73+
if now >= info["expires_at"]:
74+
del db[ticket]
75+
76+
return db
77+
78+
79+
def generate_cas_ticket(username) -> str:
80+
ticket = os.urandom(32).hex()
81+
82+
db = read_ticket_db()
83+
84+
now = time.time()
85+
db[ticket] = {
86+
"username": username,
87+
"issued_at": now,
88+
"expires_at": now + TICKET_TTL,
89+
}
90+
91+
write_ticket_db(db)
92+
93+
return ticket
94+
95+
96+
def validate_ticket(ticket: str):
97+
db = read_ticket_db()
98+
info = db.get(ticket)
99+
if not info:
100+
return None, None
101+
102+
username = info["username"]
103+
attributes = USER_DB[username]["attributes"]
104+
105+
# Remove the ticket
106+
del db[ticket]
107+
write_ticket_db(db)
108+
109+
return username, attributes
110+
111+
112+
def cas_successful_auth_response(service: Optional[str], username: str):
113+
if service:
114+
parsed = urllib.parse.urlsplit(service)
115+
d = urllib.parse.parse_qs(parsed.query)
116+
d["ticket"] = generate_cas_ticket(username)
117+
parsed = parsed._replace(query=urllib.parse.urlencode(d, doseq=True))
118+
# a 302 redirect is used to ensure that the client
119+
# uses a GET request on the request URL even though they just
120+
# POSTed to /cas/login.
121+
return RedirectResponse(parsed.geturl(), status_code=302)
122+
123+
return HTMLResponse("Single sign-on completed.")
124+
125+
126+
def render_cas_login_form(service=None, complaint=None):
127+
resp = ""
128+
129+
if service:
130+
info = get_service_info(service)
131+
name = info["name"]
132+
resp += f"{name} login"
133+
else:
134+
resp += "General SSO requested"
135+
resp += "<br>"
136+
137+
if complaint:
138+
resp += f'<p style="color: red;">{complaint}</p>'
139+
140+
resp += """
141+
<form method="POST">
142+
Username: <input name="username"/><br>
143+
Password: <input name="password" type="password"/><br>
144+
<input type="submit"/>
145+
</form>
146+
147+
"""
148+
149+
return HTMLResponse(resp)
150+
151+
152+
app = FastAPI()
153+
154+
155+
@app.get("/cas/login")
156+
async def cas_login(request: Request, service: Optional[str] = None):
157+
if service and not get_service_info(service):
158+
return HTMLResponse(
159+
f"Unwilling to handle authentication for service {service}", status_code=403
160+
)
161+
162+
username = request.session.get("SSO")
163+
if username:
164+
# The user has already completed SSO.
165+
return cas_successful_auth_response(service, username)
166+
167+
return render_cas_login_form(service=service)
168+
169+
170+
@app.post("/cas/login")
171+
async def cas_login_post(
172+
request: Request,
173+
username: Annotated[str, Form()],
174+
password: Annotated[str, Form()],
175+
service: Optional[str] = None,
176+
):
177+
if service and not get_service_info(service):
178+
return HTMLResponse(
179+
f"Unwilling to handle authentication for service {service}", status_code=403
180+
)
181+
182+
user_info = USER_DB.get(username)
183+
if not user_info or password != user_info["password"]:
184+
return render_cas_login_form(
185+
complaint="Invalid username/password", service=service
186+
)
187+
188+
# Successful authentication
189+
request.session["SSO"] = username
190+
191+
return cas_successful_auth_response(service, username)
192+
193+
194+
@app.get("/cas/logout")
195+
async def cas_logout(request: Request):
196+
request.session["SSO"] = None
197+
198+
return HTMLResponse(
199+
'You have successfully logged out of SSO. Click <a href="/cas/login">here</a> to sign back in'
200+
)
201+
202+
203+
@app.get("/cas/p3/serviceValidate")
204+
async def cas_validate(request: Request, ticket: str, service: str):
205+
if not get_service_info(service):
206+
return HTMLResponse(
207+
f"Unwilling to handle authentication for service {service}", status_code=403
208+
)
209+
210+
username, attributes = validate_ticket(ticket)
211+
if not username:
212+
return HTMLResponse(
213+
f"""<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
214+
<cas:authenticationFailure code="INVALID_TICKET">
215+
Ticket {ticket} not recognized
216+
</cas:authenticationFailure>
217+
</cas:serviceResponse>"""
218+
)
219+
220+
resp = f"""<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
221+
<cas:authenticationSuccess>
222+
<cas:user>{username}</cas:user>
223+
<cas:attributes>
224+
"""
225+
226+
for key, values in attributes.items():
227+
if not isinstance(values, list):
228+
values = [values]
229+
for value in values:
230+
resp += f"<cas:{key}>{value}</cas:{key}>\n"
231+
232+
resp += """
233+
</cas:attributes>
234+
</cas:authenticationSuccess>
235+
</cas:serviceResponse>
236+
"""
237+
238+
return HTMLResponse(resp)
239+
240+
241+
def get_session_key():
242+
filename = "/workspace/cas-session-key"
243+
244+
if os.path.exists(filename):
245+
with open(filename) as f:
246+
return f.read()
247+
248+
key = os.urandom(32).hex()
249+
250+
with open(filename, "w") as f:
251+
f.write(key)
252+
253+
return key
254+
255+
256+
app.add_middleware(SessionMiddleware, secret_key=get_session_key())

local-dev/docker-compose.yml

+21
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,27 @@ services:
4141
read_only: true
4242
ports:
4343
- 8000:8000
44+
cas:
45+
build:
46+
context: .
47+
target: cas
48+
init: true
49+
hostname: cas
50+
depends_on:
51+
setup:
52+
condition: service_completed_successfully
53+
volumes:
54+
- workspace:/workspace
55+
- type: bind
56+
source: ..
57+
target: /scap-source
58+
read_only: true
59+
ports:
60+
- 8002:8002
61+
networks:
62+
default:
63+
aliases:
64+
- cas.local.wmftest.net
4465

4566
volumes:
4667
workspace:

local-dev/scap.cfg

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
[global]
22
local_dev_mode: True
33
stage_dir: /workspace/mediawiki-staging
4+
spiderpig_auth_server: http://cas.local.wmftest.net:8002/cas

requirements.txt

+3-1
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,7 @@ python-multipart
3333

3434
pyotp
3535
pyjwt
36-
36+
python-cas
37+
itsdangerous
38+
lxml
3739
six

scap/cli.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -650,8 +650,8 @@ def spiderpig_dbfile(self):
650650
def spiderpig_joblogdir(self):
651651
return os.path.join(self.spiderpig_dir(), "jobs")
652652

653-
def spiderpig_jwt_secret_file(self):
654-
return os.path.join(self.spiderpig_dir(), "spiderpig-jwt.key")
653+
def spiderpig_session_secret_file(self):
654+
return os.path.join(self.spiderpig_dir(), "session.key")
655655

656656
def timed(self, fn, *args, **kwargs):
657657
"""

scap/config.py

+3
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,9 @@
148148
"notify_patch_failures": (bool, False),
149149
"patch_bot_phorge_name": (str, "SecurityPatchBot"),
150150
"patch_bot_phorge_token": (str, None),
151+
# SpiderPig settings
152+
"spiderpig_auth_server": (str, None),
153+
"spiderpig_admin_group": (str, None),
151154
}
152155

153156

0 commit comments

Comments
 (0)