-
Notifications
You must be signed in to change notification settings - Fork 46
feat: Login anomaly detection & suspicious activity alerts #278
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | | ||
|
|
||
| ### 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
|
||
|
|
||
| CREATE TABLE IF NOT EXISTS audit_logs ( | ||
| id SERIAL PRIMARY KEY, | ||
| user_id INT REFERENCES users(id) ON DELETE SET NULL, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| from __future__ import annotations | ||
| import json | ||
| import logging | ||
| import os | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||
|
|
||||||||||
|
|
@@ -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
|
||||||||||
|
|
||||||||||
| 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") | ||||||||||
|
|
@@ -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) | ||||||||||
|
||||||||||
| 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
AI
Mar 1, 2026
There was a problem hiding this comment.
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.
| "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
AI
Mar 1, 2026
There was a problem hiding this comment.
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.
| """Return unacknowledged security alerts for the authenticated user.""" | |
| """Return recent security alerts for the authenticated user.""" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| from __future__ import annotations | ||
| import json | ||
| from urllib import request | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| from __future__ import annotations | ||
| import csv | ||
| import io | ||
| import json | ||
|
|
||
There was a problem hiding this comment.
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.