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
36 changes: 36 additions & 0 deletions app/src/api/alerts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { api } from './client';

export interface LoginAlert {
id: number;
alert_type: string;
severity: string;
message: string;
read: boolean;
created_at: string;
}

export interface AlertsResponse {
alerts: LoginAlert[];
}

export interface UnreadCountResponse {
unread_count: number;
}

export async function getAlerts(unread = false, limit = 50): Promise<AlertsResponse> {
const params = new URLSearchParams();
if (unread) params.set('unread', 'true');
params.set('limit', String(limit));
return api<AlertsResponse>(`/alerts/?${params.toString()}`);
}

export async function getUnreadCount(): Promise<UnreadCountResponse> {
return api<UnreadCountResponse>('/alerts/unread-count');
}

export async function markAlertsRead(alertIds?: number[]): Promise<{ marked_read: number }> {
return api('/alerts/read', {
method: 'POST',
body: alertIds ? { alert_ids: alertIds } : {},
});
}
174 changes: 174 additions & 0 deletions app/src/components/SecurityAlerts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { useEffect, useState } from 'react';
import { getAlerts, markAlertsRead, LoginAlert } from '@/api/alerts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Shield, ShieldAlert, ShieldCheck, ShieldQuestion, Check } from 'lucide-react';
import { toast } from 'sonner';

const SEVERITY_COLORS: Record<string, string> = {
high: 'bg-red-100 text-red-800 border-red-200',
medium: 'bg-yellow-100 text-yellow-800 border-yellow-200',
low: 'bg-blue-100 text-blue-800 border-blue-200',
};

const ALERT_ICONS: Record<string, typeof Shield> = {
brute_force: ShieldAlert,
credential_stuffing: ShieldAlert,
new_ip: ShieldQuestion,
new_device: ShieldQuestion,
};

function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'Just now';
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
const diffDays = Math.floor(diffHr / 24);
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}

function AlertItem({
alert,
onMarkRead,
}: {
alert: LoginAlert;
onMarkRead: (id: number) => void;
}) {
const Icon = ALERT_ICONS[alert.alert_type] || Shield;
return (
<div
className={`flex items-start gap-3 p-4 rounded-lg border transition-colors ${
alert.read ? 'bg-muted/30 opacity-60' : 'bg-card'
}`}
>
<div className="mt-0.5">
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline" className={SEVERITY_COLORS[alert.severity] || ''}>
{alert.severity}
</Badge>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(alert.created_at)}
</span>
{!alert.read && (
<span className="h-2 w-2 rounded-full bg-blue-500 flex-shrink-0" />
)}
</div>
<p className="text-sm">{alert.message}</p>
</div>
{!alert.read && (
<Button
variant="ghost"
size="sm"
onClick={() => onMarkRead(alert.id)}
className="flex-shrink-0"
>
<Check className="h-4 w-4" />
</Button>
)}
</div>
);
}

export default function SecurityAlerts() {
const [alerts, setAlerts] = useState<LoginAlert[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<'all' | 'unread'>('all');

const fetchAlerts = async () => {
try {
const data = await getAlerts(filter === 'unread');
setAlerts(data.alerts);
} catch {
toast.error('Failed to load security alerts');
} finally {
setLoading(false);
}
};

useEffect(() => {
fetchAlerts();
}, [filter]);

const handleMarkRead = async (id: number) => {
try {
await markAlertsRead([id]);
setAlerts((prev) =>
prev.map((a) => (a.id === id ? { ...a, read: true } : a))
);
} catch {
toast.error('Failed to mark alert as read');
}
};

const handleMarkAllRead = async () => {
try {
await markAlertsRead();
setAlerts((prev) => prev.map((a) => ({ ...a, read: true })));
toast.success('All alerts marked as read');
} catch {
toast.error('Failed to mark alerts as read');
}
};

const unreadCount = alerts.filter((a) => !a.read).length;

return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5" />
<CardTitle className="text-lg">Security Alerts</CardTitle>
{unreadCount > 0 && (
<Badge variant="secondary">{unreadCount} unread</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant={filter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('all')}
>
All
</Button>
<Button
variant={filter === 'unread' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('unread')}
>
Unread
</Button>
{unreadCount > 0 && (
<Button variant="ghost" size="sm" onClick={handleMarkAllRead}>
Mark all read
</Button>
)}
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="text-center py-8 text-muted-foreground">Loading...</div>
) : alerts.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<ShieldCheck className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No security alerts</p>
</div>
) : (
<div className="space-y-3">
{alerts.map((alert) => (
<AlertItem key={alert.id} alert={alert} onMarkRead={handleMarkRead} />
))}
</div>
)}
</CardContent>
</Card>
);
}
41 changes: 41 additions & 0 deletions packages/backend/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,44 @@ def _ensure_schema_compatibility(app: Flask) -> None:
conn.rollback()
finally:
conn.close()

# Create login anomaly detection tables if they don't exist
conn = db.engine.raw_connection()
try:
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS login_attempts (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE SET NULL,
email VARCHAR(255) NOT NULL,
ip_address VARCHAR(45),
user_agent VARCHAR(500),
success BOOLEAN NOT NULL DEFAULT FALSE,
failure_reason VARCHAR(100),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_login_attempts_user_id ON login_attempts(user_id);
CREATE INDEX IF NOT EXISTS idx_login_attempts_email_success ON login_attempts(email, success);
CREATE INDEX IF NOT EXISTS idx_login_attempts_created_at ON login_attempts(created_at DESC);

CREATE TABLE IF NOT EXISTS login_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,
metadata_json TEXT,
read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_login_alerts_user_read ON login_alerts(user_id, read);
CREATE INDEX IF NOT EXISTS idx_login_alerts_created_at ON login_alerts(created_at DESC);
"""
)
conn.commit()
except Exception:
app.logger.exception("Schema compatibility patch failed for login anomaly tables")
conn.rollback()
finally:
conn.close()
28 changes: 28 additions & 0 deletions packages/backend/app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,31 @@ CREATE TABLE IF NOT EXISTS audit_logs (
action VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Login anomaly detection tables
CREATE TABLE IF NOT EXISTS login_attempts (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE SET NULL,
email VARCHAR(255) NOT NULL,
ip_address VARCHAR(45),
user_agent VARCHAR(500),
success BOOLEAN NOT NULL DEFAULT FALSE,
failure_reason VARCHAR(100),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_login_attempts_user_id ON login_attempts(user_id);
CREATE INDEX IF NOT EXISTS idx_login_attempts_email_success ON login_attempts(email, success);
CREATE INDEX IF NOT EXISTS idx_login_attempts_created_at ON login_attempts(created_at DESC);

CREATE TABLE IF NOT EXISTS login_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,
metadata_json TEXT,
read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_login_alerts_user_read ON login_alerts(user_id, read);
CREATE INDEX IF NOT EXISTS idx_login_alerts_created_at ON login_alerts(created_at DESC);
28 changes: 28 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,31 @@ class AuditLog(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
action = db.Column(db.String(100), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class LoginAttempt(db.Model):
"""Tracks every login attempt for anomaly detection."""

__tablename__ = "login_attempts"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
email = db.Column(db.String(255), 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=False)
failure_reason = db.Column(db.String(100), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class LoginAlert(db.Model):
"""Stores anomaly alerts triggered by suspicious login behavior."""

__tablename__ = "login_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), nullable=False, default="medium")
message = db.Column(db.String(500), nullable=False)
metadata_json = db.Column(db.Text, nullable=True)
read = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
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 .alerts import bp as alerts_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(alerts_bp, url_prefix="/alerts")
50 changes: 50 additions & 0 deletions packages/backend/app/routes/alerts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Login security alert management endpoints."""

from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from ..services.login_anomaly import get_user_alerts, mark_alerts_read

bp = Blueprint("alerts", __name__)


@bp.get("/")
@jwt_required()
def list_alerts():
"""List login security alerts for the current user."""
uid = int(get_jwt_identity())
unread_only = request.args.get("unread", "false").lower() == "true"
limit = min(int(request.args.get("limit", 50)), 100)
alerts = get_user_alerts(uid, unread_only=unread_only, limit=limit)
return jsonify(
alerts=[
{
"id": a.id,
"alert_type": a.alert_type,
"severity": a.severity,
"message": a.message,
"read": a.read,
"created_at": a.created_at.isoformat(),
}
for a in alerts
]
)


@bp.post("/read")
@jwt_required()
def mark_read():
"""Mark alerts as read. Body: {\"alert_ids\": [1,2,3]} or omit for all."""
uid = int(get_jwt_identity())
data = request.get_json() or {}
alert_ids = data.get("alert_ids")
count = mark_alerts_read(uid, alert_ids)
return jsonify(marked_read=count)


@bp.get("/unread-count")
@jwt_required()
def unread_count():
"""Get count of unread alerts."""
uid = int(get_jwt_identity())
alerts = get_user_alerts(uid, unread_only=True)
return jsonify(unread_count=len(alerts))
Loading