diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189def..b4a877219 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -11,6 +11,31 @@ CREATE TABLE IF NOT EXISTS users ( ALTER TABLE users ADD COLUMN IF NOT EXISTS preferred_currency VARCHAR(10) NOT NULL DEFAULT 'INR'; +CREATE TABLE IF NOT EXISTS login_events ( + id SERIAL PRIMARY KEY, + user_id INT REFERENCES users(id) ON DELETE CASCADE, + email VARCHAR(255) NOT NULL, + ip_address VARCHAR(64) NOT NULL, + user_agent VARCHAR(500) NOT NULL, + success BOOLEAN NOT NULL DEFAULT FALSE, + is_suspicious BOOLEAN NOT NULL DEFAULT FALSE, + suspicion_reasons VARCHAR(500), + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_login_events_user_created ON login_events(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_login_events_user_success ON login_events(user_id, success, created_at DESC); + +CREATE TABLE IF NOT EXISTS login_alerts ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + login_event_id INT NOT NULL REFERENCES login_events(id) ON DELETE CASCADE, + alert_type VARCHAR(80) NOT NULL, + message VARCHAR(500) NOT NULL, + acknowledged BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_login_alerts_user_created ON login_alerts(user_id, created_at DESC); + CREATE TABLE IF NOT EXISTS categories ( id SERIAL PRIMARY KEY, user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d448104..9f52a17fb 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -19,6 +19,32 @@ class User(db.Model): created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) +class LoginEvent(db.Model): + __tablename__ = "login_events" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + email = db.Column(db.String(255), nullable=False) + ip_address = db.Column(db.String(64), nullable=False) + user_agent = db.Column(db.String(500), nullable=False) + success = db.Column(db.Boolean, default=False, nullable=False) + is_suspicious = db.Column(db.Boolean, default=False, nullable=False) + suspicion_reasons = db.Column(db.String(500), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +class LoginAlert(db.Model): + __tablename__ = "login_alerts" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + login_event_id = db.Column( + db.Integer, db.ForeignKey("login_events.id"), nullable=False + ) + alert_type = db.Column(db.String(80), nullable=False) + message = db.Column(db.String(500), nullable=False) + acknowledged = db.Column(db.Boolean, default=False, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + class Category(db.Model): __tablename__ = "categories" id = db.Column(db.Integer, primary_key=True) diff --git a/packages/backend/app/routes/auth.py b/packages/backend/app/routes/auth.py index 05a39377d..729264d50 100644 --- a/packages/backend/app/routes/auth.py +++ b/packages/backend/app/routes/auth.py @@ -1,3 +1,4 @@ +from datetime import datetime, timedelta from flask import Blueprint, request, jsonify from werkzeug.security import generate_password_hash, check_password_hash from flask_jwt_extended import ( @@ -9,7 +10,7 @@ get_jwt_identity, ) from ..extensions import db, redis_client -from ..models import User +from ..models import LoginAlert, LoginEvent, User import logging import time @@ -57,8 +58,10 @@ def login(): password = data.get("password") user = db.session.query(User).filter_by(email=email).first() if not user or not check_password_hash(user.password_hash, password): + _record_login_event(user, email or "", success=False) logger.warning("Login failed for email=%s", email) return jsonify(error="invalid credentials"), 401 + _record_login_event(user, email or user.email, success=True) access = create_access_token(identity=str(user.id)) refresh = create_refresh_token(identity=str(user.id)) _store_refresh_session(refresh, str(user.id)) @@ -66,6 +69,59 @@ def login(): return jsonify(access_token=access, refresh_token=refresh) +@bp.get("/login-history") +@jwt_required() +def login_history(): + uid = int(get_jwt_identity()) + events = ( + db.session.query(LoginEvent) + .filter_by(user_id=uid) + .order_by(LoginEvent.created_at.desc()) + .limit(25) + .all() + ) + return jsonify( + events=[ + { + "id": event.id, + "ip_address": event.ip_address, + "user_agent": event.user_agent, + "success": event.success, + "is_suspicious": event.is_suspicious, + "suspicion_reasons": _split_reasons(event.suspicion_reasons), + "created_at": event.created_at.isoformat(), + } + for event in events + ] + ) + + +@bp.get("/alerts") +@jwt_required() +def alerts(): + uid = int(get_jwt_identity()) + rows = ( + db.session.query(LoginAlert) + .filter_by(user_id=uid) + .order_by(LoginAlert.created_at.desc()) + .limit(25) + .all() + ) + return jsonify(alerts=[_alert_payload(row) for row in rows]) + + +@bp.post("/alerts//acknowledge") +@jwt_required() +def acknowledge_alert(alert_id: int): + uid = int(get_jwt_identity()) + alert = db.session.get(LoginAlert, alert_id) + if not alert or alert.user_id != uid: + return jsonify(error="not found"), 404 + alert.acknowledged = True + db.session.commit() + return jsonify(_alert_payload(alert)) + + @bp.get("/me") @jwt_required() def me(): @@ -137,3 +193,114 @@ def _store_refresh_session(refresh_token: str, uid: str): return ttl = max(int(exp - time.time()), 1) redis_client.setex(_refresh_key(jti), ttl, uid) + + +def _record_login_event(user: User | None, email: str, success: bool) -> LoginEvent: + ip_address = _request_ip() + user_agent = (request.headers.get("User-Agent") or "unknown")[:500] + reasons = _detect_login_anomalies(user, ip_address, user_agent, success) + event = LoginEvent( + user_id=user.id if user else None, + email=email[:255], + ip_address=ip_address, + user_agent=user_agent, + success=success, + is_suspicious=bool(reasons), + suspicion_reasons=",".join(reasons) if reasons else None, + ) + db.session.add(event) + db.session.flush() + if user and reasons: + db.session.add( + LoginAlert( + user_id=user.id, + login_event_id=event.id, + alert_type="suspicious_login", + message=_alert_message(reasons), + ) + ) + db.session.commit() + return event + + +def _detect_login_anomalies( + user: User | None, ip_address: str, user_agent: str, success: bool +) -> list[str]: + if not user: + return [] + + reasons: list[str] = [] + if success: + prior_success = ( + db.session.query(LoginEvent) + .filter_by(user_id=user.id, success=True) + .first() + ) + if prior_success: + seen_ip = ( + db.session.query(LoginEvent.id) + .filter_by(user_id=user.id, success=True, ip_address=ip_address) + .first() + ) + if not seen_ip: + reasons.append("new_ip") + seen_device = ( + db.session.query(LoginEvent.id) + .filter_by(user_id=user.id, success=True, user_agent=user_agent) + .first() + ) + if not seen_device: + reasons.append("new_device") + current_hour = datetime.utcnow().hour + if current_hour in {1, 2, 3, 4, 5}: + reasons.append("unusual_hour") + return reasons + + window_start = datetime.utcnow() - timedelta(minutes=15) + failed_count = ( + db.session.query(LoginEvent) + .filter( + LoginEvent.user_id == user.id, + LoginEvent.success.is_(False), + LoginEvent.created_at >= window_start, + ) + .count() + ) + if failed_count >= 4: + reasons.append("failed_login_burst") + return reasons + + +def _request_ip() -> str: + forwarded_for = request.headers.get("X-Forwarded-For", "") + if forwarded_for: + return forwarded_for.split(",")[0].strip()[:64] + return (request.remote_addr or "unknown")[:64] + + +def _split_reasons(value: str | None) -> list[str]: + if not value: + return [] + return [item for item in value.split(",") if item] + + +def _alert_message(reasons: list[str]) -> str: + labels = { + "new_ip": "a new IP address", + "new_device": "a new device or browser", + "unusual_hour": "an unusual login time", + "failed_login_burst": "multiple failed login attempts", + } + readable = [labels.get(reason, reason) for reason in reasons] + return "Suspicious login activity detected: " + ", ".join(readable) + + +def _alert_payload(alert: LoginAlert) -> dict: + return { + "id": alert.id, + "login_event_id": alert.login_event_id, + "alert_type": alert.alert_type, + "message": alert.message, + "acknowledged": alert.acknowledged, + "created_at": alert.created_at.isoformat(), + } diff --git a/packages/backend/tests/conftest.py b/packages/backend/tests/conftest.py index a7315b8c9..88f7c6a90 100644 --- a/packages/backend/tests/conftest.py +++ b/packages/backend/tests/conftest.py @@ -1,12 +1,39 @@ import os +import fnmatch import pytest from app import create_app from app.config import Settings from app.extensions import db -from app.extensions import redis_client from app import models # noqa: F401 - ensure models are registered +class FakeRedis: + def __init__(self): + self.store = {} + + def setex(self, key, _ttl, value): + self.store[key] = value + + def set(self, key, value): + self.store[key] = value + + def get(self, key): + return self.store.get(key) + + def delete(self, *keys): + for key in keys: + self.store.pop(key, None) + + def scan(self, cursor=0, match=None, count=100): + keys = sorted(self.store) + if match: + keys = [key for key in keys if fnmatch.fnmatch(key, match)] + return 0, keys[:count] + + def flushdb(self): + self.store.clear() + + class TestSettings(Settings): # Override defaults for tests database_url: str = "sqlite+pysqlite:///:memory:" @@ -30,9 +57,15 @@ def app_fixture(): ) app = create_app(settings) app.config.update(TESTING=True) + fake_redis = FakeRedis() + import app.routes.auth as auth_routes + import app.services.cache as cache_service + + auth_routes.redis_client = fake_redis + cache_service.redis_client = fake_redis _setup_db(app) try: - redis_client.flushdb() + fake_redis.flushdb() except Exception: pass yield app @@ -40,7 +73,7 @@ def app_fixture(): db.session.remove() db.drop_all() try: - redis_client.flushdb() + fake_redis.flushdb() except Exception: pass diff --git a/packages/backend/tests/test_auth.py b/packages/backend/tests/test_auth.py index 7b22b0e3a..873d1df33 100644 --- a/packages/backend/tests/test_auth.py +++ b/packages/backend/tests/test_auth.py @@ -66,3 +66,87 @@ def test_auth_me_and_update_preferred_currency(client): r = client.patch("/auth/me", json={"preferred_currency": "ZZZ"}, headers=auth) assert r.status_code == 400 + + +def test_login_history_records_successful_login(client): + email = "history@test.com" + password = "secret123" + r = client.post("/auth/register", json={"email": email, "password": password}) + assert r.status_code == 201 + + r = client.post( + "/auth/login", + json={"email": email, "password": password}, + headers={"User-Agent": "FinMindTest/1.0"}, + environ_base={"REMOTE_ADDR": "10.0.0.1"}, + ) + assert r.status_code == 200 + access = r.get_json()["access_token"] + + r = client.get("/auth/login-history", headers={"Authorization": f"Bearer {access}"}) + assert r.status_code == 200 + events = r.get_json()["events"] + assert events[0]["success"] is True + assert events[0]["ip_address"] == "10.0.0.1" + assert events[0]["user_agent"] == "FinMindTest/1.0" + + +def test_new_ip_and_device_login_creates_alert(client): + email = "alert@test.com" + password = "secret123" + r = client.post("/auth/register", json={"email": email, "password": password}) + assert r.status_code == 201 + + r = client.post( + "/auth/login", + json={"email": email, "password": password}, + headers={"User-Agent": "KnownDevice/1.0"}, + environ_base={"REMOTE_ADDR": "10.0.0.1"}, + ) + assert r.status_code == 200 + + r = client.post( + "/auth/login", + json={"email": email, "password": password}, + headers={"User-Agent": "NewDevice/2.0"}, + environ_base={"REMOTE_ADDR": "10.0.0.2"}, + ) + assert r.status_code == 200 + access = r.get_json()["access_token"] + + r = client.get("/auth/login-history", headers={"Authorization": f"Bearer {access}"}) + latest = r.get_json()["events"][0] + assert latest["is_suspicious"] is True + assert "new_ip" in latest["suspicion_reasons"] + assert "new_device" in latest["suspicion_reasons"] + + r = client.get("/auth/alerts", headers={"Authorization": f"Bearer {access}"}) + alerts = r.get_json()["alerts"] + assert alerts[0]["alert_type"] == "suspicious_login" + assert alerts[0]["acknowledged"] is False + + r = client.post( + f"/auth/alerts/{alerts[0]['id']}/acknowledge", + headers={"Authorization": f"Bearer {access}"}, + ) + assert r.status_code == 200 + assert r.get_json()["acknowledged"] is True + + +def test_failed_login_burst_creates_alert_for_known_user(client): + email = "burst@test.com" + password = "secret123" + r = client.post("/auth/register", json={"email": email, "password": password}) + assert r.status_code == 201 + + for _ in range(5): + r = client.post("/auth/login", json={"email": email, "password": "wrong"}) + assert r.status_code == 401 + + r = client.post("/auth/login", json={"email": email, "password": password}) + assert r.status_code == 200 + access = r.get_json()["access_token"] + + r = client.get("/auth/alerts", headers={"Authorization": f"Bearer {access}"}) + messages = [alert["message"] for alert in r.get_json()["alerts"]] + assert any("multiple failed login attempts" in message for message in messages)