Skip to content

Commit 0f44f49

Browse files
authored
Merge pull request #6 from adab-tech/claude/globalopportunities-deploy-yukhn1
Add login rate-limiting, manual opportunity entry, and a public submission address
2 parents f6f0b9b + e3b7621 commit 0f44f49

10 files changed

Lines changed: 337 additions & 8 deletions

File tree

backend/app/routes/admin_auth.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,25 @@
1717

1818
from app.config import settings
1919
from app.security import create_session_token, verify_password, verify_session_token
20+
from app.services.rate_limit import LockedOutError, LoginAttemptLimiter
2021

2122
router = APIRouter(prefix="/admin", tags=["Admin Auth"])
2223

2324
SESSION_COOKIE_NAME = "of_admin_session"
2425
_SESSION_MAX_AGE_SECONDS = 12 * 60 * 60
2526

27+
# Keyed by submitted email, not caller IP — there's exactly one valid
28+
# admin account (see module docstring), so this directly protects it
29+
# regardless of how many source addresses an attacker spreads across.
30+
_LOGIN_MAX_ATTEMPTS = 5
31+
_LOGIN_WINDOW_SECONDS = 15 * 60
32+
_LOGIN_LOCKOUT_SECONDS = 15 * 60
33+
_login_limiter = LoginAttemptLimiter(
34+
max_attempts=_LOGIN_MAX_ATTEMPTS,
35+
window_seconds=_LOGIN_WINDOW_SECONDS,
36+
lockout_seconds=_LOGIN_LOCKOUT_SECONDS,
37+
)
38+
2639

2740
class LoginRequest(BaseModel):
2841
email: str
@@ -42,14 +55,26 @@ def login(request: LoginRequest, response: Response):
4255
"SESSION_SECRET_KEY unset).",
4356
)
4457

58+
login_key = request.email.strip().lower()
59+
try:
60+
_login_limiter.check(login_key)
61+
except LockedOutError:
62+
wait = _login_limiter.seconds_remaining(login_key)
63+
raise HTTPException(
64+
status_code=429,
65+
detail=f"Too many failed attempts. Try again in {wait}s.",
66+
) from None
67+
4568
# Always run verify_password, even on an email mismatch, so a wrong
4669
# email doesn't return faster than a wrong password and leak which
4770
# one was wrong via response timing.
48-
email_matches = hmac.compare_digest(request.email.strip().lower(), settings.ADMIN_EMAIL.strip().lower())
71+
email_matches = hmac.compare_digest(login_key, settings.ADMIN_EMAIL.strip().lower())
4972
password_matches = verify_password(request.password, settings.ADMIN_PASSWORD_HASH)
5073
if not (email_matches and password_matches):
74+
_login_limiter.record_failure(login_key)
5175
raise HTTPException(status_code=401, detail="Invalid email or password.")
5276

77+
_login_limiter.record_success(login_key)
5378
token = create_session_token(settings.SESSION_SECRET_KEY)
5479
response.set_cookie(
5580
key=SESSION_COOKIE_NAME,

backend/app/routes/admin_listings.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@
1414
from app.database import get_db
1515
from app.models import Opportunity
1616
from app.routes.admin_auth import require_admin_session
17-
from app.schemas import AdminOpportunityUpdate, OpportunityResponse, PaginatedOpportunities
17+
from app.schemas import (
18+
AdminOpportunityCreate,
19+
AdminOpportunityUpdate,
20+
OpportunityResponse,
21+
PaginatedOpportunities,
22+
)
1823
from app.scrapers.dedup import normalize_title
1924
from app.scrapers.url_utils import clean_url
2025

@@ -74,6 +79,41 @@ def list_all(
7479
)
7580

7681

82+
@router.post("/", response_model=OpportunityResponse, status_code=201)
83+
def create(request: AdminOpportunityCreate, db: Session = Depends(get_db)):
84+
"""Manually add a listing. Unlike every automated ingest path, this
85+
one is trusted by construction (an admin typed it in), so it skips
86+
the moderation queue entirely and goes live immediately.
87+
"""
88+
cleaned_url = clean_url(request.url)
89+
if cleaned_url is None:
90+
raise HTTPException(status_code=400, detail="Invalid URL: must be a plain http:// or https:// link.")
91+
92+
if db.query(Opportunity).filter(Opportunity.url == cleaned_url).first():
93+
raise HTTPException(status_code=409, detail="An opportunity with this URL already exists.")
94+
95+
opp = Opportunity(
96+
title=request.title.strip(),
97+
title_normalized=normalize_title(request.title),
98+
description=request.description,
99+
summary=request.summary,
100+
opportunity_type=request.opportunity_type.lower(),
101+
field=request.field,
102+
location=request.location,
103+
deadline=request.deadline,
104+
deadline_at=request.deadline_at,
105+
url=cleaned_url,
106+
source_name=request.source_name or "Manual entry",
107+
tags=request.tags,
108+
is_active=True,
109+
review_status="approved",
110+
)
111+
db.add(opp)
112+
db.commit()
113+
db.refresh(opp)
114+
return opp
115+
116+
77117
@router.patch("/{opportunity_id}", response_model=OpportunityResponse)
78118
def update(opportunity_id: int, request: AdminOpportunityUpdate, db: Session = Depends(get_db)):
79119
opp = _get_or_404(db, opportunity_id)

backend/app/schemas.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,26 @@ class BulkModerationResponse(BaseModel):
115115
ids: list[int]
116116

117117

118+
class AdminOpportunityCreate(BaseModel):
119+
"""A manually-added listing — trusted by definition (an admin typed
120+
it in directly), so it skips the moderation queue and goes live
121+
immediately as review_status="approved", unlike the low-trust
122+
open web-search discovery path (see routes/moderation.py).
123+
"""
124+
125+
title: str = Field(min_length=1, max_length=500)
126+
opportunity_type: str
127+
url: str
128+
description: str | None = None
129+
summary: str | None = None
130+
field: str | None = None
131+
location: str | None = None
132+
deadline: str | None = None
133+
deadline_at: date | None = None
134+
source_name: str | None = None
135+
tags: str | None = None
136+
137+
118138
class AdminOpportunityUpdate(BaseModel):
119139
"""Partial update for the admin listing-management table — every
120140
field optional, only the ones the admin actually changed are sent.

backend/app/services/rate_limit.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,61 @@ def check(self, key: str) -> None:
4242
if last is not None and now - last < self._seconds:
4343
raise RateLimitedError()
4444
self._last_seen[key] = now
45+
46+
47+
class LockedOutError(Exception):
48+
"""Raised when `key` is currently locked out after too many failures."""
49+
50+
51+
class LoginAttemptLimiter:
52+
"""Locks a key out after too many failed attempts in a row.
53+
54+
Keyed by the submitted email rather than caller IP: this app has
55+
exactly one valid admin account (single-admin by design — see
56+
routes/admin_auth.py), so locking out repeated failures against
57+
that one email directly protects the real target regardless of how
58+
many source IPs an attacker spreads requests across. A wrong email
59+
can never succeed anyway, so tracking those separately costs
60+
nothing.
61+
"""
62+
63+
def __init__(self, max_attempts: int, window_seconds: float, lockout_seconds: float):
64+
self._max_attempts = max_attempts
65+
self._window = window_seconds
66+
self._lockout = lockout_seconds
67+
self._lock = threading.Lock()
68+
self._failures: dict[str, tuple[int, float]] = {} # key -> (count, first_failure_at)
69+
self._locked_until: dict[str, float] = {}
70+
71+
def check(self, key: str) -> None:
72+
"""Raise LockedOutError if `key` is currently locked out."""
73+
now = time.monotonic()
74+
with self._lock:
75+
locked_until = self._locked_until.get(key)
76+
if locked_until is not None and now < locked_until:
77+
raise LockedOutError()
78+
79+
def record_failure(self, key: str) -> None:
80+
now = time.monotonic()
81+
with self._lock:
82+
count, first_failure_at = self._failures.get(key, (0, now))
83+
if now - first_failure_at > self._window:
84+
count, first_failure_at = 0, now
85+
count += 1
86+
if count >= self._max_attempts:
87+
self._locked_until[key] = now + self._lockout
88+
self._failures.pop(key, None)
89+
else:
90+
self._failures[key] = (count, first_failure_at)
91+
92+
def record_success(self, key: str) -> None:
93+
with self._lock:
94+
self._failures.pop(key, None)
95+
self._locked_until.pop(key, None)
96+
97+
def seconds_remaining(self, key: str) -> int:
98+
with self._lock:
99+
locked_until = self._locked_until.get(key)
100+
if locked_until is None:
101+
return 0
102+
return max(0, int(locked_until - time.monotonic()))

backend/tests/test_admin_auth.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,11 @@ def test_expired_token_fails(self):
6060
class TestLoginEndpoint:
6161
def setup_method(self):
6262
from app.config import settings
63+
from app.routes import admin_auth
6364

6465
self._prior = (settings.ADMIN_EMAIL, settings.ADMIN_PASSWORD_HASH, settings.SESSION_SECRET_KEY)
66+
admin_auth._login_limiter._failures.clear()
67+
admin_auth._login_limiter._locked_until.clear()
6568

6669
def teardown_method(self):
6770
from app.config import settings
@@ -118,6 +121,84 @@ def test_login_email_check_is_case_insensitive(self, monkeypatch):
118121
assert response.status_code == 200
119122

120123

124+
class TestLoginLockout:
125+
"""No lockout existed before this — an attacker could brute-force
126+
the single admin password with unlimited attempts. See
127+
app/services/rate_limit.py::LoginAttemptLimiter.
128+
"""
129+
130+
def setup_method(self):
131+
from app.config import settings
132+
from app.routes import admin_auth
133+
134+
self.admin_auth = admin_auth
135+
self._prior = (settings.ADMIN_EMAIL, settings.ADMIN_PASSWORD_HASH, settings.SESSION_SECRET_KEY)
136+
admin_auth._login_limiter._failures.clear()
137+
admin_auth._login_limiter._locked_until.clear()
138+
settings.ADMIN_EMAIL = "lockout-test@example.org"
139+
settings.ADMIN_PASSWORD_HASH = self._hash("a-strong-password-123")
140+
settings.SESSION_SECRET_KEY = "test-secret"
141+
142+
def teardown_method(self):
143+
from app.config import settings
144+
145+
settings.ADMIN_EMAIL, settings.ADMIN_PASSWORD_HASH, settings.SESSION_SECRET_KEY = self._prior
146+
self.admin_auth._login_limiter._failures.clear()
147+
self.admin_auth._login_limiter._locked_until.clear()
148+
149+
@staticmethod
150+
def _hash(password: str) -> str:
151+
from app.security import hash_password
152+
153+
return hash_password(password)
154+
155+
def _bad_login(self):
156+
return client.post(
157+
"/api/v1/admin/login",
158+
json={"email": "lockout-test@example.org", "password": "wrong-password"},
159+
)
160+
161+
def test_locks_out_after_max_attempts(self):
162+
for _ in range(self.admin_auth._LOGIN_MAX_ATTEMPTS):
163+
response = self._bad_login()
164+
assert response.status_code == 401
165+
# One more, still within the window, now locked out.
166+
locked = self._bad_login()
167+
assert locked.status_code == 429
168+
169+
def test_locked_out_rejects_even_the_correct_password(self):
170+
for _ in range(self.admin_auth._LOGIN_MAX_ATTEMPTS):
171+
self._bad_login()
172+
response = client.post(
173+
"/api/v1/admin/login",
174+
json={"email": "lockout-test@example.org", "password": "a-strong-password-123"},
175+
)
176+
assert response.status_code == 429
177+
178+
def test_successful_login_resets_the_failure_count(self):
179+
for _ in range(self.admin_auth._LOGIN_MAX_ATTEMPTS - 1):
180+
self._bad_login()
181+
success = client.post(
182+
"/api/v1/admin/login",
183+
json={"email": "lockout-test@example.org", "password": "a-strong-password-123"},
184+
)
185+
assert success.status_code == 200
186+
# Failure count should be cleared, not still one shy of lockout.
187+
response = self._bad_login()
188+
assert response.status_code == 401
189+
190+
def test_different_email_is_not_affected_by_another_lockout(self):
191+
for _ in range(self.admin_auth._LOGIN_MAX_ATTEMPTS):
192+
self._bad_login()
193+
# A wrong-but-different email must not be caught by the
194+
# lockout recorded for "lockout-test@example.org".
195+
response = client.post(
196+
"/api/v1/admin/login",
197+
json={"email": "someone-else@example.org", "password": "whatever"},
198+
)
199+
assert response.status_code == 401
200+
201+
121202
class TestSessionEndpoint:
122203
def setup_method(self):
123204
from app.config import settings

backend/tests/test_admin_listings.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,73 @@ def test_search_filters_by_title(self):
9494
assert response.status_code == 200
9595
assert response.json()["total"] == 1
9696

97+
def test_create_adds_an_approved_active_listing(self):
98+
response = client.post(
99+
"/api/v1/admin/opportunities/",
100+
json={
101+
"title": "Manually Added Fellowship",
102+
"opportunity_type": "fellowship",
103+
"url": f"{_TEST_URL_PREFIX}manual-1",
104+
"field": "Public Health",
105+
"location": "Kenya",
106+
},
107+
cookies=self._cookies(),
108+
)
109+
assert response.status_code == 201
110+
body = response.json()
111+
assert body["is_active"] is True
112+
assert body["review_status"] == "approved"
113+
assert body["title"] == "Manually Added Fellowship"
114+
assert body["source_name"] == "Manual entry"
115+
116+
row = self.db.query(Opportunity).filter(Opportunity.id == body["id"]).first()
117+
assert row is not None
118+
assert row.title_normalized == "manually added fellowship"
119+
120+
def test_create_rejects_javascript_url(self):
121+
response = client.post(
122+
"/api/v1/admin/opportunities/",
123+
json={
124+
"title": "Bad URL Listing",
125+
"opportunity_type": "grant",
126+
"url": "javascript:alert(1)",
127+
},
128+
cookies=self._cookies(),
129+
)
130+
assert response.status_code == 400
131+
132+
def test_create_rejects_duplicate_url(self):
133+
_make_row(self.db, "dup", url=f"{_TEST_URL_PREFIX}dup")
134+
response = client.post(
135+
"/api/v1/admin/opportunities/",
136+
json={
137+
"title": "Different Title, Same URL",
138+
"opportunity_type": "grant",
139+
"url": f"{_TEST_URL_PREFIX}dup",
140+
},
141+
cookies=self._cookies(),
142+
)
143+
assert response.status_code == 409
144+
145+
def test_create_requires_title_and_type(self):
146+
response = client.post(
147+
"/api/v1/admin/opportunities/",
148+
json={"url": f"{_TEST_URL_PREFIX}missing-fields"},
149+
cookies=self._cookies(),
150+
)
151+
assert response.status_code == 422
152+
153+
def test_create_requires_session(self):
154+
response = client.post(
155+
"/api/v1/admin/opportunities/",
156+
json={
157+
"title": "No Session",
158+
"opportunity_type": "job",
159+
"url": f"{_TEST_URL_PREFIX}no-session",
160+
},
161+
)
162+
assert response.status_code == 401
163+
97164
def test_update_edits_fields(self):
98165
opp = _make_row(self.db, "edit-me", title="Original Title")
99166
response = client.patch(

frontend/admin.html

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ <h2 style="margin-bottom:0;">All listings</h2>
202202
<option value="other">Other</option>
203203
</select>
204204
<button type="button" class="btn-secondary" id="listingsRefresh">Refresh</button>
205+
<button type="button" class="btn-primary" id="listingsAddBtn">+ Add opportunity</button>
205206
</div>
206207
<div class="mod-list" id="listingsList"></div>
207208
<p class="mod-empty" id="listingsEmpty" style="display:none;">No listings match this search.</p>
@@ -214,7 +215,7 @@ <h2 style="margin-bottom:0;">All listings</h2>
214215
<!-- Edit listing modal -->
215216
<div id="editModal" class="modal-overlay" style="display:none">
216217
<div class="modal-box edit-modal-box">
217-
<h3>Edit listing</h3>
218+
<h3 id="editModalTitle">Edit listing</h3>
218219
<form id="editForm">
219220
<input type="hidden" id="editId" />
220221
<div class="edit-form-grid">
@@ -274,7 +275,7 @@ <h3>Edit listing</h3>
274275
</div>
275276
<div style="display:flex; gap:10px; justify-content:flex-end;">
276277
<button type="button" class="btn-secondary" id="closeEditModal">Cancel</button>
277-
<button type="submit" class="btn-primary">Save changes</button>
278+
<button type="submit" class="btn-primary" id="editSubmitBtn">Save changes</button>
278279
</div>
279280
</form>
280281
<p id="editError" style="color:#b91c1c; display:none; font-size:.85rem; margin-top:10px;"></p>

0 commit comments

Comments
 (0)