fix: remove stale self-signups after 14 days

parent 9ed0846b
...@@ -887,6 +887,41 @@ class DatabaseManager: ...@@ -887,6 +887,41 @@ class DatabaseManager:
''', (username, email, password_hash, role, created_by, 1 if email_verified else 0, display_name or username)) ''', (username, email, password_hash, role, created_by, 1 if email_verified else 0, display_name or username))
conn.commit() conn.commit()
return cursor.lastrowid return cursor.lastrowid
def delete_stale_unverified_signup_users(self, inactivity_days: int = 14) -> int:
"""
Delete self-registered users who never logged in within the grace period.
Args:
inactivity_days: Number of days after registration before deletion.
Returns:
Number of deleted users.
"""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
if self.db_type == 'sqlite':
cutoff_expr = f"datetime('now', '-' || {placeholder} || ' days')"
else:
cutoff_expr = f"DATE_SUB(NOW(), INTERVAL {placeholder} DAY)"
cursor.execute(f'''
SELECT id
FROM users
WHERE role = 'user'
AND created_by IS NULL
AND last_login IS NULL
AND email_verified = 0
AND created_at <= {cutoff_expr}
''', (inactivity_days,))
user_ids = [row[0] for row in cursor.fetchall()]
for user_id in user_ids:
self.delete_user(user_id)
return len(user_ids)
def get_user_by_email(self, email: str) -> Optional[Dict]: def get_user_by_email(self, email: str) -> Optional[Dict]:
""" """
......
...@@ -31,6 +31,15 @@ def init(config, templates, server_config): ...@@ -31,6 +31,15 @@ def init(config, templates, server_config):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def cleanup_stale_signup_users() -> int:
"""Delete self-registered users who never logged in within 14 days."""
db = DatabaseRegistry.get_config_database()
deleted_count = db.delete_stale_unverified_signup_users(inactivity_days=14)
if deleted_count:
logger.info(f"Deleted {deleted_count} stale self-registered user(s) with no login activity")
return deleted_count
@router.get("/dashboard/profile-pic") @router.get("/dashboard/profile-pic")
async def dashboard_profile_pic(request: Request): async def dashboard_profile_pic(request: Request):
"""Serve the logged-in user's profile picture from the database.""" """Serve the logged-in user's profile picture from the database."""
...@@ -130,6 +139,8 @@ async def dashboard_login(request: Request, username: str = Form(...), password: ...@@ -130,6 +139,8 @@ async def dashboard_login(request: Request, username: str = Form(...), password:
"""Handle dashboard login""" """Handle dashboard login"""
client_ip = request.client.host if request.client else "unknown" client_ip = request.client.host if request.client else "unknown"
cleanup_stale_signup_users()
if _login_rate_limit_check(client_ip, username): if _login_rate_limit_check(client_ip, username):
return RedirectResponse( return RedirectResponse(
url=url_for(request, "/dashboard/login") + "?error=Too+many+failed+attempts.+Please+wait+and+try+again.", url=url_for(request, "/dashboard/login") + "?error=Too+many+failed+attempts.+Please+wait+and+try+again.",
......
...@@ -349,6 +349,14 @@ async def _run_startup() -> None: ...@@ -349,6 +349,14 @@ async def _run_startup() -> None:
# Initialize routers with config/templates # Initialize routers with config/templates
_init_all_routers() _init_all_routers()
try:
from aisbf.routes.auth import cleanup_stale_signup_users
deleted_stale_users = cleanup_stale_signup_users()
if deleted_stale_users:
logger.info(f"Startup cleanup removed {deleted_stale_users} stale self-registered user(s)")
except Exception as e:
logger.error(f"Failed to clean up stale self-registered users: {e}")
# Background tasks # Background tasks
from aisbf.app.model_cache import prefetch_global_provider_models, refresh_model_cache from aisbf.app.model_cache import prefetch_global_provider_models, refresh_model_cache
asyncio.create_task(prefetch_global_provider_models(config)) asyncio.create_task(prefetch_global_provider_models(config))
......
from datetime import datetime, timedelta
import sys
from pathlib import Path
from uuid import uuid4
from fastapi.testclient import TestClient
from itsdangerous import TimestampSigner
from base64 import b64encode
import json
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from aisbf.database import DatabaseRegistry
from main import app
def _find_session_secret() -> str:
for middleware in app.user_middleware:
kwargs = getattr(middleware, "kwargs", {})
secret_key = kwargs.get("secret_key")
if secret_key:
return secret_key
raise AssertionError("Session middleware secret key not found")
def _set_session_cookie(client: TestClient, data: dict) -> None:
signer = TimestampSigner(_find_session_secret())
serialized = b64encode(json.dumps(data).encode("utf-8"))
signed = signer.sign(serialized).decode("utf-8")
client.cookies.set("session", signed)
def _login_as_admin(client: TestClient) -> None:
_set_session_cookie(
client,
{
"logged_in": True,
"username": "admin",
"role": "admin",
"user_id": None,
"expires_at": 4102444800,
},
)
def test_login_cleanup_removes_self_registered_users_without_login_after_14_days():
client = TestClient(app)
db = DatabaseRegistry.get_config_database()
token = uuid4().hex
stale_user_id = db.create_user(
username=f"stale-signup-{token}",
email=f"stale-signup-{token}@example.com",
password_hash="hash",
role="user",
email_verified=False,
)
active_user_id = db.create_user(
username=f"active-signup-{token}",
email=f"active-signup-{token}@example.com",
password_hash="hash",
role="user",
email_verified=False,
)
with db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if db.db_type == 'sqlite' else '%s'
cursor.execute(
f"UPDATE users SET created_at = {placeholder}, last_login = NULL, email_verified = 0, created_by = NULL WHERE id = {placeholder}",
((datetime.now() - timedelta(days=15)).isoformat(sep=" "), stale_user_id),
)
cursor.execute(
f"UPDATE users SET created_at = {placeholder}, last_login = NULL, email_verified = 0, created_by = NULL WHERE id = {placeholder}",
((datetime.now() - timedelta(days=13)).isoformat(sep=" "), active_user_id),
)
conn.commit()
response = client.post(
"/dashboard/login",
data={"username": "definitely-not-a-user", "password": "wrong-password"},
follow_redirects=False,
)
assert response.status_code == 303
assert db.get_user_by_id(stale_user_id) is None
assert db.get_user_by_id(active_user_id) is not None
def test_admin_created_users_are_not_removed_by_signup_cleanup():
db = DatabaseRegistry.get_config_database()
token = uuid4().hex
invited_user_id = db.create_user(
username=f"invited-stale-{token}",
email=f"invited-stale-{token}@example.com",
password_hash="hash",
role="user",
created_by="admin",
email_verified=False,
)
with db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if db.db_type == 'sqlite' else '%s'
cursor.execute(
f"UPDATE users SET created_at = {placeholder}, last_login = NULL, email_verified = 0, created_by = {placeholder} WHERE id = {placeholder}",
((datetime.now() - timedelta(days=30)).isoformat(sep=" "), "admin", invited_user_id),
)
conn.commit()
response = db.delete_stale_unverified_signup_users(inactivity_days=14)
assert response == 0
assert db.get_user_by_id(invited_user_id) is not None
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment