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() == []