Skip to content
Open
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
508 changes: 508 additions & 0 deletions docs/login-anomaly.md

Large diffs are not rendered by default.

75 changes: 75 additions & 0 deletions packages/backend/app/db/migrations/001_login_anomaly.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
-- Migration: Login Anomaly Detection Tables
-- Creates login_events and security_alerts tables for tracking
-- suspicious login activities and generating security alerts.

-- Login Events Table
-- Tracks all login-related events including successful logins,
-- failed attempts, and suspicious activities.
CREATE TABLE IF NOT EXISTS login_events (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE SET NULL,
event_type VARCHAR(30) NOT NULL,
ip_address VARCHAR(45),
user_agent VARCHAR(500),
device_fingerprint VARCHAR(128),
location_country VARCHAR(100),
location_city VARCHAR(100),
risk_score NUMERIC(3,2) NOT NULL DEFAULT 0.0,
risk_factors TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Index for quick lookup of user's recent login events
CREATE INDEX IF NOT EXISTS idx_login_events_user_created
ON login_events(user_id, created_at DESC);

-- Index for IP-based queries (detecting same IP across accounts)
CREATE INDEX IF NOT EXISTS idx_login_events_ip
ON login_events(ip_address, created_at DESC);

-- Index for event type filtering
CREATE INDEX IF NOT EXISTS idx_login_events_type
ON login_events(event_type, created_at DESC);

-- Security Alerts Table
-- Stores security alerts generated from suspicious activities
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',
status VARCHAR(20) NOT NULL DEFAULT 'active',
title VARCHAR(200) NOT NULL,
description TEXT,
ip_address VARCHAR(45),
user_agent VARCHAR(500),
login_event_id INT REFERENCES login_events(id) ON DELETE SET NULL,
acknowledged_at TIMESTAMP,
acknowledged_by INT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Index for user's alerts lookup
CREATE INDEX IF NOT EXISTS idx_security_alerts_user_status
ON security_alerts(user_id, status, created_at DESC);

-- Index for alert type filtering
CREATE INDEX IF NOT EXISTS idx_security_alerts_type
ON security_alerts(alert_type, created_at DESC);

-- Index for severity filtering
CREATE INDEX IF NOT EXISTS idx_security_alerts_severity
ON security_alerts(severity, created_at DESC);

-- Extend audit_logs table with additional columns for login events
ALTER TABLE audit_logs
ADD COLUMN IF NOT EXISTS ip_address VARCHAR(45);

ALTER TABLE audit_logs
ADD COLUMN IF NOT EXISTS details TEXT;

-- Add comment for documentation
COMMENT ON TABLE login_events IS 'Tracks all login events for anomaly detection';
COMMENT ON TABLE security_alerts IS 'Security alerts generated from suspicious activities';
COMMENT ON COLUMN login_events.risk_score IS 'Risk score from 0.0 (safe) to 1.0 (high risk)';
COMMENT ON COLUMN login_events.risk_factors IS 'JSON array of detected risk factors';
77 changes: 77 additions & 0 deletions packages/backend/app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,80 @@ CREATE TABLE IF NOT EXISTS audit_logs (
action VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Extend audit_logs for login events
ALTER TABLE audit_logs
ADD COLUMN IF NOT EXISTS ip_address VARCHAR(45);

ALTER TABLE audit_logs
ADD COLUMN IF NOT EXISTS details TEXT;

-- Login Events Table for anomaly detection
CREATE TABLE IF NOT EXISTS login_events (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE SET NULL,
event_type VARCHAR(30) NOT NULL,
ip_address VARCHAR(45),
user_agent VARCHAR(500),
device_fingerprint VARCHAR(128),
location_country VARCHAR(100),
location_city VARCHAR(100),
risk_score NUMERIC(3,2) NOT NULL DEFAULT 0.0,
risk_factors TEXT,
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_ip
ON login_events(ip_address, created_at DESC);

CREATE INDEX IF NOT EXISTS idx_login_events_type
ON login_events(event_type, created_at DESC);

-- Security Alerts Table
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',
status VARCHAR(20) NOT NULL DEFAULT 'active',
title VARCHAR(200) NOT NULL,
description TEXT,
ip_address VARCHAR(45),
user_agent VARCHAR(500),
login_event_id INT REFERENCES login_events(id) ON DELETE SET NULL,
acknowledged_at TIMESTAMP,
acknowledged_by INT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_security_alerts_user_status
ON security_alerts(user_id, status, created_at DESC);

CREATE INDEX IF NOT EXISTS idx_security_alerts_type
ON security_alerts(alert_type, created_at DESC);

CREATE INDEX IF NOT EXISTS idx_security_alerts_severity
ON security_alerts(severity, created_at DESC);

-- Trusted Devices Table
CREATE TABLE IF NOT EXISTS trusted_devices (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_fingerprint VARCHAR(128) NOT NULL,
device_name VARCHAR(100),
user_agent VARCHAR(500),
ip_address VARCHAR(45),
last_used_at TIMESTAMP NOT NULL DEFAULT NOW(),
trusted_at TIMESTAMP NOT NULL DEFAULT NOW(),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_trusted_devices_user
ON trusted_devices(user_id, is_active, last_used_at DESC);

CREATE INDEX IF NOT EXISTS idx_trusted_devices_fingerprint
ON trusted_devices(device_fingerprint, user_id);
119 changes: 119 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,123 @@ class AuditLog(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
action = db.Column(db.String(100), nullable=False)
ip_address = db.Column(db.String(45), nullable=True)
details = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class LoginEventType(str, Enum):
LOGIN_SUCCESS = "login_success"
LOGIN_FAILED = "login_failed"
LOGOUT = "logout"
BRUTE_FORCE_BLOCKED = "brute_force_blocked"
SUSPICIOUS_LOGIN = "suspicious_login"


class LoginEvent(db.Model):
"""Track all login-related events for anomaly detection."""
__tablename__ = "login_events"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
event_type = db.Column(db.String(30), nullable=False)
ip_address = db.Column(db.String(45), nullable=True) # IPv6 max length
user_agent = db.Column(db.String(500), nullable=True)
device_fingerprint = db.Column(db.String(128), nullable=True)
location_country = db.Column(db.String(100), nullable=True)
location_city = db.Column(db.String(100), nullable=True)
risk_score = db.Column(db.Numeric(3, 2), default=0.0, nullable=False)
risk_factors = db.Column(db.Text, nullable=True) # JSON array of risk factors
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)

def to_dict(self):
import json
return {
"id": self.id,
"user_id": self.user_id,
"event_type": self.event_type,
"ip_address": self.ip_address,
"user_agent": self.user_agent,
"device_fingerprint": self.device_fingerprint,
"location_country": self.location_country,
"location_city": self.location_city,
"risk_score": float(self.risk_score) if self.risk_score else 0.0,
"risk_factors": json.loads(self.risk_factors) if self.risk_factors else [],
"created_at": self.created_at.isoformat() if self.created_at else None,
}


class AlertSeverity(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"


class AlertStatus(str, Enum):
ACTIVE = "active"
ACKNOWLEDGED = "acknowledged"
RESOLVED = "resolved"


class SecurityAlert(db.Model):
"""Security alerts generated from suspicious activities."""
__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=AlertSeverity.MEDIUM.value, nullable=False)
status = db.Column(db.String(20), default=AlertStatus.ACTIVE.value, nullable=False)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
ip_address = db.Column(db.String(45), nullable=True)
user_agent = db.Column(db.String(500), nullable=True)
login_event_id = db.Column(db.Integer, db.ForeignKey("login_events.id"), nullable=True)
acknowledged_at = db.Column(db.DateTime, nullable=True)
acknowledged_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)

def to_dict(self):
return {
"id": self.id,
"user_id": self.user_id,
"alert_type": self.alert_type,
"severity": self.severity,
"status": self.status,
"title": self.title,
"description": self.description,
"ip_address": self.ip_address,
"user_agent": self.user_agent,
"login_event_id": self.login_event_id,
"acknowledged_at": self.acknowledged_at.isoformat() if self.acknowledged_at else None,
"acknowledged_by": self.acknowledged_by,
"created_at": self.created_at.isoformat() if self.created_at else None,
}


class TrustedDevice(db.Model):
"""Manage trusted devices for users."""
__tablename__ = "trusted_devices"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
device_fingerprint = db.Column(db.String(128), nullable=False)
device_name = db.Column(db.String(100), nullable=True)
user_agent = db.Column(db.String(500), nullable=True)
ip_address = db.Column(db.String(45), nullable=True)
last_used_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
trusted_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)

def to_dict(self):
return {
"id": self.id,
"user_id": self.user_id,
"device_fingerprint": self.device_fingerprint[:16] + "..." if self.device_fingerprint else None,
"device_name": self.device_name,
"user_agent": self.user_agent,
"ip_address": self.ip_address,
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None,
"trusted_at": self.trusted_at.isoformat() if self.trusted_at else None,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
2 changes: 2 additions & 0 deletions packages/backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .categories import bp as categories_bp
from .docs import bp as docs_bp
from .dashboard import bp as dashboard_bp
from .security import bp as security_bp


def register_routes(app: Flask):
Expand All @@ -18,3 +19,4 @@ def register_routes(app: Flask):
app.register_blueprint(categories_bp, url_prefix="/categories")
app.register_blueprint(docs_bp, url_prefix="/docs")
app.register_blueprint(dashboard_bp, url_prefix="/dashboard")
app.register_blueprint(security_bp, url_prefix="/security")
60 changes: 58 additions & 2 deletions packages/backend/app/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
)
from ..extensions import db, redis_client
from ..models import User
from ..services.login_anomaly import (
process_login,
check_brute_force,
get_client_ip,
LoginEventType,
)
import logging
import time

Expand Down Expand Up @@ -55,15 +61,65 @@ def login():
data = request.get_json() or {}
email = data.get("email")
password = data.get("password")

# Get client info for anomaly detection
ip_address = get_client_ip()

# Check brute force before processing
if check_brute_force(ip_address):
logger.warning("Login blocked due to brute force: email=%s, ip=%s", email, ip_address)
return jsonify(error="too many failed attempts, please try again later"), 429

user = db.session.query(User).filter_by(email=email).first()
if not user or not check_password_hash(user.password_hash, password):

# Determine if login is successful
is_successful = user is not None and check_password_hash(user.password_hash, password)

# Process login with anomaly detection
login_event, security_alert, should_block = process_login(
user=user,
is_successful=is_successful,
ip_address=ip_address
)

if should_block:
logger.warning("Login blocked after brute force detection: email=%s", email)
return jsonify(error="account temporarily locked due to suspicious activity"), 429

if not is_successful:
logger.warning("Login failed for email=%s", email)
return jsonify(error="invalid credentials"), 401

# Successful login - create tokens
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)

# Build response with security info
response_data = {
"access_token": access,
"refresh_token": refresh,
}

# Include security alert if generated
if security_alert:
response_data["security_alert"] = {
"type": security_alert.alert_type,
"severity": security_alert.severity,
"message": security_alert.title,
}
logger.info("Security alert generated for user_id=%s: %s", user.id, security_alert.title)

# Include risk info if elevated
if login_event and float(login_event.risk_score) > 0.3:
response_data["security_notice"] = {
"risk_score": float(login_event.risk_score),
"message": "Login from new device or location detected"
}

return jsonify(response_data)


@bp.get("/me")
Expand Down
Loading