CC2 R6: middleware-wide JWT, avatar demo mode, mock mailer, login rate limit
#1 JWT middleware extended: - Was: /api/admin/* only - Now: any POST/PUT/PATCH/DELETE under /api/* requires Bearer JWT - Whitelist (still anonymous): /api/auth/login, /refresh, /forgot-password, /password/reset, /reset-password, /setup-password, /google; /api/gdpr/consent; any path ending /avatar - 14 mutating endpoints verified to return 401 without token #2 Avatar upload demo mode (routers/clan_panel_router.py): - Anonymous → returns {demo_mode:true, slika_url:null, message:'Demo mode — slika nije spremljena. Prijavite se za pravu pohranu.'}, no FS write, no DB write - Authenticated (valid JWT, allowed role) → real save as before - Auth check now uses auth.auth_v2.decode_token (proper secret + revocation) instead of the broken local _resolve_role #3 Mock mailer (auth/mailer.py): - send_email writes RFC 822 .eml to /tmp/pgz_mailbox + appends to INDEX.jsonl - send_password_reset, send_invite helpers with HR text + HTML alt - Real SMTP active when PGZ_SMTP_HOST is set (env-driven, off by default) - forgot-password and admin invite both call mailer; audit logs mail status #5 Rate limiting on /api/auth/login: - Per-user: 5 wrong attempts → 5-minute DB-backed lockout (was 5 → 15 min). Configurable via PGZ_LOGIN_LOCK_THRESHOLD/MINUTES. - Per-IP: 10 fails / 5-min sliding window in-memory → HTTP 429 Configurable via PGZ_LOGIN_IP_THRESHOLD/WINDOW_SEC. Successful login clears the IP counter. - Failed attempts respond '(N/5) — račun je zaključan na 5 minuta' - New audit actions: login.ratelimit.ip; login.fail meta now includes fails count, locked, lock_minutes #4 Live test report: 46/46 across 6 demo users — login, JWT gate on 14 mutating endpoints, public path whitelist, demo-mode avatar + real save, forgot-password e-mail to mailbox, no-leak unknown email, 5-fail lockout, 423 during lockout, audit coverage.
This commit is contained in:
@@ -69,13 +69,78 @@ def _log(msg: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _heartbeat() -> None:
|
||||
def _redis():
|
||||
try:
|
||||
import redis
|
||||
r = redis.Redis(host=os.environ.get('REDIS_HOST', 'localhost'),
|
||||
port=int(os.environ.get('REDIS_PORT', '6379')),
|
||||
password=os.environ.get('REDIS_PASS', None))
|
||||
except Exception:
|
||||
return None
|
||||
host = os.environ.get('REDIS_HOST', 'localhost')
|
||||
port = int(os.environ.get('REDIS_PORT', '6379'))
|
||||
pwd = (os.environ.get('REDIS_PASS') or '').strip().strip("'").strip('"') or None
|
||||
for p in (pwd, None):
|
||||
try:
|
||||
r = redis.Redis(host=host, port=port, password=p,
|
||||
decode_responses=True, socket_connect_timeout=2)
|
||||
r.ping()
|
||||
return r
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _heartbeat(meta: dict | None = None) -> None:
|
||||
r = _redis()
|
||||
if not r: return
|
||||
try:
|
||||
r.set('cc:pgz-enricher:heartbeat', str(int(time.time())))
|
||||
if meta is not None:
|
||||
r.set('cc:pgz-enricher:last_cycle', json.dumps(meta, default=str))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _is_paused() -> bool:
|
||||
r = _redis()
|
||||
if not r: return False
|
||||
try:
|
||||
return (r.get('cc:pgz-enricher:pause') or '0') == '1'
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _consume_run_now() -> bool:
|
||||
r = _redis()
|
||||
if not r: return False
|
||||
try:
|
||||
v = r.get('cc:pgz-enricher:run_now')
|
||||
if v == '1':
|
||||
r.set('cc:pgz-enricher:run_now', '0')
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _refresh_confidence() -> None:
|
||||
"""Read live confidence override from redis (set by /worker/confidence)."""
|
||||
global CONFIDENCE_MIN
|
||||
r = _redis()
|
||||
if not r: return
|
||||
try:
|
||||
v = r.get('cc:pgz-enricher:confidence')
|
||||
if v:
|
||||
CONFIDENCE_MIN = float(v)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _bump_fields_24h(n: int) -> None:
|
||||
if n <= 0: return
|
||||
r = _redis()
|
||||
if not r: return
|
||||
try:
|
||||
r.incrby('cc:pgz-enricher:fields_24h', n)
|
||||
r.expire('cc:pgz-enricher:fields_24h', 86400)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -264,8 +329,10 @@ def _process(kind: str, eid: int) -> tuple[int, list[str]]:
|
||||
|
||||
|
||||
def _cycle() -> dict:
|
||||
_refresh_confidence()
|
||||
started = time.time()
|
||||
out = {'sportas': 0, 'klub': 0, 'savez': 0, 'fields_total': 0}
|
||||
out = {'sportas': 0, 'klub': 0, 'savez': 0, 'fields_total': 0,
|
||||
'started_at': datetime.now(timezone.utc).isoformat()}
|
||||
fields_total = 0
|
||||
for kind, picker, limit in (
|
||||
('sportas', _pick_sportas, 50),
|
||||
@@ -278,26 +345,45 @@ def _cycle() -> dict:
|
||||
for eid in ids:
|
||||
if DRY:
|
||||
continue
|
||||
if _is_paused():
|
||||
_log("paused → break out of cycle")
|
||||
break
|
||||
n, fields = _process(kind, eid)
|
||||
out[kind] += 1
|
||||
fields_total += n
|
||||
if n: _bump_fields_24h(n)
|
||||
time.sleep(1.5) # gentle pacing
|
||||
_heartbeat()
|
||||
out['fields_total'] = fields_total
|
||||
out['elapsed_s'] = round(time.time() - started, 1)
|
||||
out['ended_at'] = datetime.now(timezone.utc).isoformat()
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_log(f"enrichment_worker starting | API_BASE={API_BASE} | sleep={SLEEP_S}s | dry={DRY}")
|
||||
while True:
|
||||
if _is_paused():
|
||||
_log("paused (cc:pgz-enricher:pause=1) — sleeping 30s")
|
||||
_heartbeat({'paused': True})
|
||||
time.sleep(30)
|
||||
continue
|
||||
try:
|
||||
stats = _cycle()
|
||||
_log(f"cycle done: {json.dumps(stats)}")
|
||||
_heartbeat(stats)
|
||||
except Exception as e:
|
||||
_log(f"cycle FAILED: {type(e).__name__}: {e}")
|
||||
_heartbeat()
|
||||
time.sleep(SLEEP_S)
|
||||
_heartbeat({'error': str(e)[:200]})
|
||||
# Sleep in 5-second slices so /worker/run-now and /pause respond fast.
|
||||
elapsed = 0
|
||||
while elapsed < SLEEP_S:
|
||||
if _consume_run_now():
|
||||
_log("run-now signal received → starting next cycle early")
|
||||
break
|
||||
if _is_paused():
|
||||
break
|
||||
time.sleep(5); elapsed += 5
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in New Issue
Block a user