Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions docs/login-anomaly-detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Login Anomaly Detection & Suspicious Activity Alerts

## Overview

FinMind now tracks login activity and automatically detects suspicious patterns, alerting users when anomalies are found.

## Features

### Login History Tracking
Every login attempt (successful or failed) is recorded with:
- IP address (from `X-Forwarded-For` header or direct connection)
- User agent string (device/browser fingerprint)
- Timestamp
- Success/failure status
- Detected anomaly flags

### Anomaly Detection
The system checks for four types of anomalies on each successful login:

| Anomaly | Flag | Description |
|---------|------|-------------|
| New IP Address | `new_ip` | Login from an IP not previously seen for this user |
| New Device | `new_device` | Login from a user agent not previously seen |
| Unusual Time | `unusual_time` | Login during UTC 01:00–05:00 |
| Brute Force | `multiple_failed_attempts` | 5+ failed login attempts within 30 minutes |
Comment on lines +20 to +25
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The anomaly table in this markdown uses leading double pipes (||), which renders as an extra empty column in GitHub-flavored markdown. Use single leading pipes (e.g., | Anomaly | Flag | Description |) for correct table formatting.

Copilot uses AI. Check for mistakes.

### Alerting
When anomalies are detected:
1. **In-app alerts** are created in the `security_alerts` table
2. **Email notifications** are sent (if SMTP is configured)
3. **Login response** includes `security_warnings` array

Duplicate alerts of the same type are suppressed within a 1-hour window.

## API Endpoints

All endpoints require JWT authentication (Bearer token).

### `GET /auth/login-history`
Returns paginated login history for the authenticated user.

**Query Parameters:**
- `page` (int, default: 1)
- `per_page` (int, default: 20, max: 100)

**Response:**
```json
{
"total": 42,
"page": 1,
"per_page": 20,
"items": [
{
"id": 1,
"ip_address": "192.168.1.1",
"user_agent": "Mozilla/5.0 ...",
"success": true,
"anomaly_flags": ["new_ip"],
"created_at": "2024-01-15T10:30:00Z"
}
]
}
```

### `GET /auth/security-alerts`
Returns security alerts (up to 50 most recent).

**Response:**
```json
{
"items": [
{
"id": 1,
"alert_type": "new_ip",
"message": "Login from a new IP address: 203.0.113.42",
"acknowledged": false,
"created_at": "2024-01-15T10:30:00Z"
}
]
}
```

### `POST /auth/security-alerts/<id>/acknowledge`
Mark a security alert as acknowledged. Returns 404 if the alert doesn't belong to the authenticated user.

### `POST /auth/login` (updated)
The login response now includes `security_warnings` when anomalies are detected:
```json
{
"access_token": "...",
"refresh_token": "...",
"security_warnings": ["new_ip", "new_device"]
}
```

## Database Tables

### `login_history`
| Column | Type | Description |
|--------|------|-------------|
| id | SERIAL | Primary key |
| user_id | INT | Foreign key to users |
| ip_address | VARCHAR(45) | Client IP (supports IPv6) |
| user_agent | VARCHAR(500) | Browser/client identifier |
| success | BOOLEAN | Whether login succeeded |
| anomaly_flags | TEXT | JSON array of detected anomaly flags |
| created_at | TIMESTAMP | When the login occurred |

### `security_alerts`
| Column | Type | Description |
|--------|------|-------------|
| id | SERIAL | Primary key |
| user_id | INT | Foreign key to users |
| alert_type | VARCHAR(50) | Anomaly type identifier |
| message | VARCHAR(500) | Human-readable alert message |
| metadata_json | TEXT | JSON with IP and user agent details |
| acknowledged | BOOLEAN | Whether user dismissed the alert |
| created_at | TIMESTAMP | When the alert was created |

## Configuration

No additional configuration is required. Email alerts use the existing SMTP settings (`SMTP_URL`, `EMAIL_FROM`).

## Thresholds

Default thresholds (configurable in `app/services/login_anomaly.py`):
- **Failed attempt threshold:** 5 attempts
- **Failed attempt window:** 30 minutes
- **Unusual hour range:** UTC 01:00–05:00
- **Alert dedup window:** 1 hour
1 change: 1 addition & 0 deletions packages/backend/app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from __future__ import annotations
from flask import Flask, jsonify
from .config import Settings
from .extensions import db, jwt
Expand Down
1 change: 1 addition & 0 deletions packages/backend/app/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from __future__ import annotations
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

Expand Down
22 changes: 22 additions & 0 deletions packages/backend/app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,28 @@ CREATE TABLE IF NOT EXISTS user_subscriptions (
started_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS login_history (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ip_address VARCHAR(45),
user_agent VARCHAR(500),
success BOOLEAN NOT NULL DEFAULT TRUE,
anomaly_flags TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_login_history_user ON login_history(user_id, created_at DESC);

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,
message VARCHAR(500) NOT NULL,
metadata_json TEXT,
acknowledged BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_security_alerts_user ON security_alerts(user_id, acknowledged, created_at DESC);
Comment on lines +129 to +140
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The login_history index only covers (user_id, created_at), but anomaly checks query on (user_id, ip_address, success), (user_id, user_agent, success), and brute-force counts query on (user_id, success, created_at). Without supporting composite indexes these will degrade to scans as the tables grow. Consider adding composite indexes aligned to those query patterns.

Copilot uses AI. Check for mistakes.

CREATE TABLE IF NOT EXISTS audit_logs (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE SET NULL,
Expand Down
22 changes: 22 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,28 @@ class UserSubscription(db.Model):
started_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class LoginHistory(db.Model):
__tablename__ = "login_history"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
ip_address = db.Column(db.String(45), nullable=True)
user_agent = db.Column(db.String(500), nullable=True)
success = db.Column(db.Boolean, nullable=False, default=True)
anomaly_flags = db.Column(db.Text, nullable=True) # JSON list of flags
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)
message = db.Column(db.String(500), nullable=False)
metadata_json = db.Column(db.Text, nullable=True)
acknowledged = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class AuditLog(db.Model):
__tablename__ = "audit_logs"
id = db.Column(db.Integer, primary_key=True)
Expand Down
1 change: 1 addition & 0 deletions packages/backend/app/observability.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from __future__ import annotations
import json
import logging
import os
Expand Down
97 changes: 95 additions & 2 deletions packages/backend/app/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
get_jwt_identity,
)
from ..extensions import db, redis_client
from ..models import User
from ..models import User, LoginHistory, SecurityAlert
from ..services.login_anomaly import record_login, record_failed_login
import json
import logging
import time

Expand Down Expand Up @@ -55,15 +57,28 @@ def login():
data = request.get_json() or {}
email = data.get("email")
password = data.get("password")
ip_address = request.headers.get("X-Forwarded-For", request.remote_addr)
user_agent = request.headers.get("User-Agent")
Comment on lines +60 to +61
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

X-Forwarded-For can contain a comma-separated chain and is user-controlled unless you only trust it behind a known proxy. Storing it directly risks spoofing and can exceed the VARCHAR(45) ip_address column, causing login to 500. Parse the first IP (trim whitespace), and consider only honoring X-Forwarded-For when a trusted proxy config is enabled; otherwise fall back to request.remote_addr.

Copilot uses AI. Check for mistakes.

user = db.session.query(User).filter_by(email=email).first()
if not user or not check_password_hash(user.password_hash, password):
logger.warning("Login failed for email=%s", email)
record_failed_login(email, ip_address, user_agent)
return jsonify(error="invalid credentials"), 401

# Record successful login & run anomaly detection
login_entry = record_login(user.id, ip_address, user_agent, 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))
logger.info("Login success user_id=%s", user.id)
return jsonify(access_token=access, refresh_token=refresh)

response_data = {"access_token": access, "refresh_token": refresh}
if login_entry.anomaly_flags:
response_data["security_warnings"] = json.loads(login_entry.anomaly_flags)

return jsonify(response_data)


@bp.get("/me")
Expand Down Expand Up @@ -125,6 +140,84 @@ def logout():
return jsonify(message="logged out"), 200


@bp.get("/login-history")
@jwt_required()
def login_history():
"""Return paginated login history for the authenticated user."""
uid = int(get_jwt_identity())
page = request.args.get("page", 1, type=int)
per_page = min(request.args.get("per_page", 20, type=int), 100)
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pagination parameters aren’t clamped to sensible minimums. page can be 0/negative and per_page can be 0/negative, which produces negative offsets/limits and may error or behave unexpectedly. Clamp page >= 1 and 1 <= per_page <= 100 (and return 400 on invalid values, consistent with other routes).

Suggested change
per_page = min(request.args.get("per_page", 20, type=int), 100)
per_page = request.args.get("per_page", 20, type=int)
if page < 1 or per_page < 1 or per_page > 100:
return jsonify(error="invalid pagination parameters"), 400

Copilot uses AI. Check for mistakes.
offset = (page - 1) * per_page

total = db.session.query(LoginHistory).filter_by(user_id=uid).count()
entries = (
db.session.query(LoginHistory)
.filter_by(user_id=uid)
.order_by(LoginHistory.created_at.desc())
.offset(offset)
.limit(per_page)
.all()
)

return jsonify(
total=total,
page=page,
per_page=per_page,
items=[
{
"id": e.id,
"ip_address": e.ip_address,
"user_agent": e.user_agent,
"success": e.success,
"anomaly_flags": json.loads(e.anomaly_flags) if e.anomaly_flags else [],
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This anomaly_flags line is likely over the repo’s flake8 max-line-length = 88 limit (see .flake8). Wrap it across multiple lines to avoid CI lint failures.

Suggested change
"anomaly_flags": json.loads(e.anomaly_flags) if e.anomaly_flags else [],
"anomaly_flags": (
json.loads(e.anomaly_flags) if e.anomaly_flags else []
),

Copilot uses AI. Check for mistakes.
"created_at": e.created_at.isoformat() + "Z",
}
for e in entries
],
)


@bp.get("/security-alerts")
@jwt_required()
def security_alerts():
"""Return unacknowledged security alerts for the authenticated user."""
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring says this endpoint returns unacknowledged alerts, but the query returns all alerts for the user (no acknowledged=False filter), and tests appear to rely on acknowledged alerts remaining visible. Either filter to unacknowledged alerts or update the docstring/contract to match the actual behavior.

Suggested change
"""Return unacknowledged security alerts for the authenticated user."""
"""Return recent security alerts for the authenticated user."""

Copilot uses AI. Check for mistakes.
uid = int(get_jwt_identity())
alerts = (
db.session.query(SecurityAlert)
.filter_by(user_id=uid)
.order_by(SecurityAlert.created_at.desc())
.limit(50)
.all()
)
return jsonify(
items=[
{
"id": a.id,
"alert_type": a.alert_type,
"message": a.message,
"acknowledged": a.acknowledged,
"created_at": a.created_at.isoformat() + "Z",
}
for a in alerts
]
)


@bp.post("/security-alerts/<int:alert_id>/acknowledge")
@jwt_required()
def acknowledge_alert(alert_id):
"""Mark a security alert as acknowledged."""
uid = int(get_jwt_identity())
alert = db.session.query(SecurityAlert).filter_by(
id=alert_id, user_id=uid
).first()
if not alert:
return jsonify(error="not found"), 404
alert.acknowledged = True
db.session.commit()
return jsonify(message="acknowledged")


def _refresh_key(jti: str) -> str:
return f"auth:refresh:{jti}"

Expand Down
1 change: 1 addition & 0 deletions packages/backend/app/routes/expenses.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from __future__ import annotations
import calendar
from datetime import date, timedelta
from decimal import Decimal, InvalidOperation
Expand Down
1 change: 1 addition & 0 deletions packages/backend/app/services/ai.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from __future__ import annotations
import json
from urllib import request

Expand Down
1 change: 1 addition & 0 deletions packages/backend/app/services/cache.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from __future__ import annotations
import json
from typing import Iterable
from ..extensions import redis_client
Expand Down
1 change: 1 addition & 0 deletions packages/backend/app/services/expense_import.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from __future__ import annotations
import csv
import io
import json
Expand Down
Loading
Loading