From baad613db44238fcc938f273a10941ed59d54a91 Mon Sep 17 00:00:00 2001 From: tungdd2710 Date: Fri, 22 May 2026 22:14:16 +0700 Subject: [PATCH] feat: add login anomaly alerts --- README.md | 3 +- app/src/__tests__/SignIn.test.tsx | 32 +++++ app/src/api/auth.ts | 34 ++++- app/src/pages/SignIn.tsx | 18 ++- packages/backend/app/__init__.py | 61 ++++++++- packages/backend/app/db/schema.sql | 28 +++++ packages/backend/app/models.py | 25 ++++ packages/backend/app/openapi.yaml | 76 ++++++++++++ packages/backend/app/routes/auth.py | 186 +++++++++++++++++++++++++++- packages/backend/tests/conftest.py | 36 +++++- packages/backend/tests/test_auth.py | 62 ++++++++++ 11 files changed, 547 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 49592bffc..a37833120 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ See `backend/app/db/schema.sql`. Key tables: ## API Endpoints OpenAPI: `backend/app/openapi.yaml` -- Auth: `/auth/register`, `/auth/login`, `/auth/refresh` +- Auth: `/auth/register`, `/auth/login`, `/auth/refresh`, `/auth/security-alerts` - Expenses: CRUD `/expenses` - Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay` - Reminders: CRUD `/reminders`, trigger `/reminders/run` @@ -185,6 +185,7 @@ finmind/ ## Security & Scalability - JWT access/refresh, secure cookies OR Authorization header. +- Login anomaly detection records hashed network/device fingerprints, creates security alerts for new networks or devices, and exposes unread alerts through `/auth/security-alerts`. - RBAC-ready via roles on `users.role`. - N+1 avoided via SQLAlchemy eager loading. - Redis caching for hot paths to cut DB load. diff --git a/app/src/__tests__/SignIn.test.tsx b/app/src/__tests__/SignIn.test.tsx index a07b7b5ae..cd99d3ce2 100644 --- a/app/src/__tests__/SignIn.test.tsx +++ b/app/src/__tests__/SignIn.test.tsx @@ -90,6 +90,38 @@ describe('SignIn page', () => { expect(navigateMock).toHaveBeenCalled(); }); + it('shows security warning toast when login response is suspicious', async () => { + (login as jest.Mock).mockResolvedValue({ + access_token: 'a', + refresh_token: 'r', + security_alert: { + suspicious: true, + reasons: ['new_ip'], + message: 'New login from an unrecognized network.', + }, + }); + + render( + + + + ); + + await userEvent.type(screen.getByLabelText(/email/i), 'demo@finmind.local'); + await userEvent.type(screen.getByLabelText(/password/i), 'DemoPass123!'); + await userEvent.click(screen.getByRole('button', { name: /sign in to your account/i })); + + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: 'destructive', + title: 'Suspicious login detected', + }) + ) + ); + expect(navigateMock).toHaveBeenCalled(); + }); + it('shows error toast on failed login', async () => { (login as jest.Mock).mockRejectedValue(new Error('invalid credentials')); diff --git a/app/src/api/auth.ts b/app/src/api/auth.ts index 79001053a..bcd4687fc 100644 --- a/app/src/api/auth.ts +++ b/app/src/api/auth.ts @@ -1,6 +1,18 @@ import { api } from './client'; -export type LoginResponse = { access_token: string; refresh_token?: string }; +export type LoginSecurityAlert = { + suspicious: boolean; + reasons: string[]; + alert_id?: number; + message?: string; + severity?: string; +}; + +export type LoginResponse = { + access_token: string; + refresh_token?: string; + security_alert?: LoginSecurityAlert; +}; export async function login(email: string, password: string): Promise { return api('/auth/login', { method: 'POST', body: { email, password } }); @@ -35,3 +47,23 @@ export async function updateMe(payload: { }): Promise { return api('/auth/me', { method: 'PATCH', body: payload }); } + +export type SecurityAlert = { + id: number; + type: string; + severity: string; + message: string; + details: Record; + read: boolean; + read_at: string | null; + created_at: string | null; +}; + +export async function listSecurityAlerts(unreadOnly = false): Promise { + const query = unreadOnly ? '?unread_only=true' : ''; + return api('/auth/security-alerts' + query); +} + +export async function markSecurityAlertRead(alertId: number): Promise { + return api('/auth/security-alerts/' + alertId + '/read', { method: 'PATCH' }); +} diff --git a/app/src/pages/SignIn.tsx b/app/src/pages/SignIn.tsx index 80be9129a..55bcea553 100644 --- a/app/src/pages/SignIn.tsx +++ b/app/src/pages/SignIn.tsx @@ -128,10 +128,20 @@ export function SignIn() { } catch { // Keep existing local currency if profile fetch fails. } - toast({ - title: 'Welcome back 👋', - description: 'You have successfully signed in.', - }); + if (res.security_alert?.suspicious) { + toast({ + variant: 'destructive', + title: 'Suspicious login detected', + description: + res.security_alert.message || + 'We noticed a login from a new device or network.', + }); + } else { + toast({ + title: 'Welcome back 👋', + description: 'You have successfully signed in.', + }); + } nav(from, { replace: true }); } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Invalid email or password'; diff --git a/packages/backend/app/__init__.py b/packages/backend/app/__init__.py index cdf76b45f..8dfcc7992 100644 --- a/packages/backend/app/__init__.py +++ b/packages/backend/app/__init__.py @@ -110,10 +110,69 @@ def _ensure_schema_compatibility(app: Flask) -> None: NOT NULL DEFAULT 'INR' """ ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS login_events ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + ip_hash VARCHAR(64) NOT NULL, + user_agent_hash VARCHAR(64) NOT NULL, + user_agent VARCHAR(255) NOT NULL, + suspicious BOOLEAN NOT NULL DEFAULT FALSE, + reason VARCHAR(255), + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + """ + ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_login_events_user_created + ON login_events(user_id, created_at DESC) + """ + ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_login_events_user_ip + ON login_events(user_id, ip_hash) + """ + ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_login_events_user_agent + ON login_events(user_id, user_agent_hash) + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS security_alerts ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + alert_type VARCHAR(50) NOT NULL, + severity VARCHAR(20) NOT NULL DEFAULT 'medium', + message VARCHAR(500) NOT NULL, + details JSONB NOT NULL DEFAULT '{}'::jsonb, + read BOOLEAN NOT NULL DEFAULT FALSE, + read_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + """ + ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_security_alerts_user_created + ON security_alerts(user_id, created_at DESC) + """ + ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_security_alerts_user_unread + ON security_alerts(user_id, read) + """ + ) conn.commit() except Exception: app.logger.exception( - "Schema compatibility patch failed for users.preferred_currency" + "Schema compatibility patch failed for auth security tables" ) conn.rollback() finally: diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189def..b88815f10 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -11,6 +11,34 @@ 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 NOT NULL REFERENCES users(id) ON DELETE CASCADE, + ip_hash VARCHAR(64) NOT NULL, + user_agent_hash VARCHAR(64) NOT NULL, + user_agent VARCHAR(255) NOT NULL, + suspicious BOOLEAN NOT NULL DEFAULT FALSE, + reason VARCHAR(255), + 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_ip ON login_events(user_id, ip_hash); +CREATE INDEX IF NOT EXISTS idx_login_events_user_agent ON login_events(user_id, user_agent_hash); + +CREATE TABLE IF NOT EXISTS security_alerts ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + alert_type VARCHAR(50) NOT NULL, + severity VARCHAR(20) NOT NULL DEFAULT 'medium', + message VARCHAR(500) NOT NULL, + details JSONB NOT NULL DEFAULT '{}'::jsonb, + read BOOLEAN NOT NULL DEFAULT FALSE, + read_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_security_alerts_user_created ON security_alerts(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_security_alerts_user_unread ON security_alerts(user_id, read); + 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..bc429f595 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -19,6 +19,31 @@ 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=False) + ip_hash = db.Column(db.String(64), nullable=False) + user_agent_hash = db.Column(db.String(64), nullable=False) + user_agent = db.Column(db.String(255), nullable=False) + suspicious = db.Column(db.Boolean, default=False, nullable=False) + reason = db.Column(db.String(255), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +class SecurityAlert(db.Model): + __tablename__ = "security_alerts" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + alert_type = db.Column(db.String(50), nullable=False) + severity = db.Column(db.String(20), default="medium", nullable=False) + message = db.Column(db.String(500), nullable=False) + details = db.Column(db.JSON, default=dict, nullable=False) + read = db.Column(db.Boolean, default=False, nullable=False) + read_at = db.Column(db.DateTime, nullable=True) + 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/openapi.yaml b/packages/backend/app/openapi.yaml index 3f8ec3f0f..638ae3995 100644 --- a/packages/backend/app/openapi.yaml +++ b/packages/backend/app/openapi.yaml @@ -65,6 +65,9 @@ paths: example: access_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... refresh_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + security_alert: + suspicious: false + reasons: [] '401': description: Invalid credentials content: @@ -94,6 +97,54 @@ paths: schema: { $ref: '#/components/schemas/Error' } example: { error: "Missing Authorization Header" } + /auth/security-alerts: + get: + summary: List login security alerts + tags: [Auth] + security: [{ bearerAuth: [] }] + parameters: + - in: query + name: unread_only + required: false + schema: { type: boolean } + responses: + '200': + description: Recent login anomaly alerts + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SecurityAlert' + '401': + description: Unauthorized + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /auth/security-alerts/{alertId}/read: + patch: + summary: Mark a security alert as read + tags: [Auth] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: alertId + required: true + schema: { type: integer } + responses: + '200': + description: Updated alert + content: + application/json: + schema: + $ref: '#/components/schemas/SecurityAlert' + '404': + description: Alert not found + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + /categories: get: summary: List categories @@ -498,6 +549,31 @@ components: properties: access_token: { type: string } refresh_token: { type: string } + security_alert: + $ref: '#/components/schemas/LoginSecurityAlert' + LoginSecurityAlert: + type: object + properties: + suspicious: { type: boolean } + reasons: + type: array + items: { type: string, enum: [new_ip, new_device] } + alert_id: { type: integer, nullable: true } + message: { type: string, nullable: true } + severity: { type: string, nullable: true } + SecurityAlert: + type: object + properties: + id: { type: integer } + type: { type: string } + severity: { type: string } + message: { type: string } + details: + type: object + additionalProperties: true + read: { type: boolean } + read_at: { type: string, format: date-time, nullable: true } + created_at: { type: string, format: date-time, nullable: true } Category: type: object properties: diff --git a/packages/backend/app/routes/auth.py b/packages/backend/app/routes/auth.py index 05a39377d..ec148c9e9 100644 --- a/packages/backend/app/routes/auth.py +++ b/packages/backend/app/routes/auth.py @@ -1,4 +1,9 @@ -from flask import Blueprint, request, jsonify +from datetime import datetime +import hashlib +import logging +import time + +from flask import Blueprint, current_app, request, jsonify from werkzeug.security import generate_password_hash, check_password_hash from flask_jwt_extended import ( create_access_token, @@ -9,9 +14,7 @@ get_jwt_identity, ) from ..extensions import db, redis_client -from ..models import User -import logging -import time +from ..models import LoginEvent, SecurityAlert, User bp = Blueprint("auth", __name__) logger = logging.getLogger("finmind.auth") @@ -62,8 +65,17 @@ def login(): access = create_access_token(identity=str(user.id)) refresh = create_refresh_token(identity=str(user.id)) _store_refresh_session(refresh, str(user.id)) - logger.info("Login success user_id=%s", user.id) - return jsonify(access_token=access, refresh_token=refresh) + security_alert = _record_login_event(user) + logger.info( + "Login success user_id=%s suspicious=%s", + user.id, + security_alert["suspicious"], + ) + return jsonify( + access_token=access, + refresh_token=refresh, + security_alert=security_alert, + ) @bp.get("/me") @@ -101,6 +113,35 @@ def update_me(): ) +@bp.get("/security-alerts") +@jwt_required() +def security_alerts(): + uid = int(get_jwt_identity()) + unread_only = str(request.args.get("unread_only", "")).lower() in { + "1", + "true", + "yes", + } + query = db.session.query(SecurityAlert).filter_by(user_id=uid) + if unread_only: + query = query.filter_by(read=False) + alerts = query.order_by(SecurityAlert.created_at.desc()).limit(50).all() + return jsonify([_serialize_security_alert(alert) for alert in alerts]) + + +@bp.patch("/security-alerts//read") +@jwt_required() +def mark_security_alert_read(alert_id: int): + uid = int(get_jwt_identity()) + alert = db.session.get(SecurityAlert, alert_id) + if not alert or alert.user_id != uid: + return jsonify(error="not found"), 404 + alert.read = True + alert.read_at = datetime.utcnow() + db.session.commit() + return jsonify(_serialize_security_alert(alert)) + + @bp.post("/refresh") @jwt_required(refresh=True) def refresh(): @@ -125,6 +166,139 @@ def logout(): return jsonify(message="logged out"), 200 +def _record_login_event(user: User) -> dict: + ip_address = _client_ip() + user_agent = (request.headers.get("User-Agent") or "unknown").strip() or "unknown" + ip_hash = _fingerprint(ip_address) + user_agent_hash = _fingerprint(user_agent) + prior_events = ( + db.session.query(LoginEvent) + .filter_by(user_id=user.id) + .order_by(LoginEvent.created_at.desc()) + .limit(20) + .all() + ) + suspicious, reasons = _detect_login_anomaly( + prior_events, + ip_hash=ip_hash, + user_agent_hash=user_agent_hash, + ) + message = _login_alert_message(reasons) + + event = LoginEvent( + user_id=user.id, + ip_hash=ip_hash, + user_agent_hash=user_agent_hash, + user_agent=user_agent[:255], + suspicious=suspicious, + reason=",".join(reasons) if reasons else None, + ) + db.session.add(event) + + alert = None + if suspicious: + alert = SecurityAlert( + user_id=user.id, + alert_type="login_anomaly", + severity="high" if len(reasons) > 1 else "medium", + message=message, + details={ + "reasons": reasons, + "ip": _mask_ip(ip_address), + "user_agent": user_agent[:255], + }, + ) + db.session.add(alert) + + try: + db.session.commit() + except Exception: + db.session.rollback() + logger.exception("Failed to record login security event user_id=%s", user.id) + return {"suspicious": False, "reasons": []} + + payload = { + "suspicious": suspicious, + "reasons": reasons, + } + if alert: + payload.update( + { + "alert_id": alert.id, + "message": alert.message, + "severity": alert.severity, + } + ) + return payload + + +def _detect_login_anomaly( + prior_events: list[LoginEvent], + *, + ip_hash: str, + user_agent_hash: str, +) -> tuple[bool, list[str]]: + if not prior_events: + return False, [] + + reasons: list[str] = [] + known_ips = {event.ip_hash for event in prior_events if event.ip_hash} + known_user_agents = { + event.user_agent_hash for event in prior_events if event.user_agent_hash + } + + if ip_hash not in known_ips: + reasons.append("new_ip") + if user_agent_hash not in known_user_agents: + reasons.append("new_device") + + return bool(reasons), reasons + + +def _login_alert_message(reasons: list[str]) -> str: + if "new_ip" in reasons and "new_device" in reasons: + return "New login from an unrecognized network and device." + if "new_ip" in reasons: + return "New login from an unrecognized network." + if "new_device" in reasons: + return "New login from an unrecognized device." + return "Login recorded." + + +def _client_ip() -> str: + forwarded_for = request.headers.get("X-Forwarded-For", "") + if forwarded_for: + return forwarded_for.split(",", 1)[0].strip() or "unknown" + return request.headers.get("X-Real-IP") or request.remote_addr or "unknown" + + +def _fingerprint(value: str) -> str: + secret = current_app.config.get("JWT_SECRET_KEY", "") + return hashlib.sha256(f"{secret}:{value}".encode("utf-8")).hexdigest() + + +def _mask_ip(ip_address: str) -> str: + parts = ip_address.split(".") + if len(parts) == 4 and all(part.isdigit() for part in parts): + return ".".join([parts[0], parts[1], parts[2], "x"]) + if ":" in ip_address: + return ":".join(ip_address.split(":")[:4]) + "::" + return "unknown" + + +def _serialize_security_alert(alert: SecurityAlert) -> dict: + return { + "id": alert.id, + "type": alert.alert_type, + "severity": alert.severity, + "message": alert.message, + "details": alert.details or {}, + "read": alert.read, + "read_at": alert.read_at.isoformat() if alert.read_at else None, + "created_at": alert.created_at.isoformat() if alert.created_at else None, + } + + def _refresh_key(jti: str) -> str: return f"auth:refresh:{jti}" diff --git a/packages/backend/tests/conftest.py b/packages/backend/tests/conftest.py index a7315b8c9..714a18966 100644 --- a/packages/backend/tests/conftest.py +++ b/packages/backend/tests/conftest.py @@ -3,8 +3,38 @@ 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 +from app import extensions +from app.routes import auth as auth_routes +from app.services import cache as cache_service + + +class InMemoryRedis: + def __init__(self): + self._data: dict[str, str] = {} + + def setex(self, key: str, _ttl: int, value: str): + self._data[key] = value + + def set(self, key: str, value: str): + self._data[key] = value + + def get(self, key: str): + return self._data.get(key) + + def delete(self, *keys: str): + for key in keys: + self._data.pop(key, None) + + def scan(self, cursor: int = 0, match: str | None = None, count: int = 100): + del count + if match is None: + return 0, list(self._data.keys()) + prefix = match.rstrip("*") + return 0, [key for key in self._data if key.startswith(prefix)] + + def flushdb(self): + self._data.clear() class TestSettings(Settings): @@ -23,6 +53,10 @@ def _setup_db(app): def app_fixture(): # Ensure a clean env for tests os.environ.setdefault("FLASK_ENV", "testing") + redis_client = InMemoryRedis() + extensions.redis_client = redis_client + auth_routes.redis_client = redis_client + cache_service.redis_client = redis_client settings = TestSettings( database_url="sqlite+pysqlite:///:memory:", redis_url="redis://localhost:6379/15", diff --git a/packages/backend/tests/test_auth.py b/packages/backend/tests/test_auth.py index 7b22b0e3a..7a00656b9 100644 --- a/packages/backend/tests/test_auth.py +++ b/packages/backend/tests/test_auth.py @@ -66,3 +66,65 @@ 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_anomaly_alert_created_for_new_network(client): + email = "anomaly@test.com" + password = "secret123" + r = client.post("/auth/register", json={"email": email, "password": password}) + assert r.status_code == 201 + + first = client.post( + "/auth/login", + json={"email": email, "password": password}, + headers={"User-Agent": "FinMindTest/1.0"}, + environ_base={"REMOTE_ADDR": "203.0.113.10"}, + ) + assert first.status_code == 200 + first_data = first.get_json() + assert first_data["security_alert"]["suspicious"] is False + + second = client.post( + "/auth/login", + json={"email": email, "password": password}, + headers={"User-Agent": "FinMindTest/1.0"}, + environ_base={"REMOTE_ADDR": "203.0.113.10"}, + ) + assert second.status_code == 200 + assert second.get_json()["security_alert"]["suspicious"] is False + + suspicious = client.post( + "/auth/login", + json={"email": email, "password": password}, + headers={ + "User-Agent": "FinMindTest/1.0", + "X-Forwarded-For": "198.51.100.20", + }, + ) + assert suspicious.status_code == 200 + suspicious_data = suspicious.get_json() + assert suspicious_data["security_alert"]["suspicious"] is True + assert suspicious_data["security_alert"]["reasons"] == ["new_ip"] + + access = suspicious_data["access_token"] + auth = {"Authorization": f"Bearer {access}"} + alerts = client.get("/auth/security-alerts", headers=auth) + assert alerts.status_code == 200 + alerts_data = alerts.get_json() + assert len(alerts_data) == 1 + assert alerts_data[0]["type"] == "login_anomaly" + assert alerts_data[0]["read"] is False + assert alerts_data[0]["details"]["ip"] == "198.51.100.x" + + unread = client.get("/auth/security-alerts?unread_only=true", headers=auth) + assert unread.status_code == 200 + assert len(unread.get_json()) == 1 + + alert_id = alerts_data[0]["id"] + marked = client.patch(f"/auth/security-alerts/{alert_id}/read", headers=auth) + assert marked.status_code == 200 + assert marked.get_json()["read"] is True + + unread = client.get("/auth/security-alerts?unread_only=true", headers=auth) + assert unread.status_code == 200 + assert unread.get_json() == []