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
25 changes: 25 additions & 0 deletions packages/backend/app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,31 @@ 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 REFERENCES users(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
ip_address VARCHAR(64) NOT NULL,
user_agent VARCHAR(500) NOT NULL,
success BOOLEAN NOT NULL DEFAULT FALSE,
is_suspicious BOOLEAN NOT NULL DEFAULT FALSE,
suspicion_reasons VARCHAR(500),
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_success ON login_events(user_id, success, 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,
login_event_id INT NOT NULL REFERENCES login_events(id) ON DELETE CASCADE,
alert_type VARCHAR(80) NOT NULL,
message VARCHAR(500) NOT NULL,
acknowledged BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_login_alerts_user_created ON login_alerts(user_id, created_at DESC);

CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
Expand Down
26 changes: 26 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,32 @@ 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=True)
email = db.Column(db.String(255), nullable=False)
ip_address = db.Column(db.String(64), nullable=False)
user_agent = db.Column(db.String(500), nullable=False)
success = db.Column(db.Boolean, default=False, nullable=False)
is_suspicious = db.Column(db.Boolean, default=False, nullable=False)
suspicion_reasons = db.Column(db.String(500), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class LoginAlert(db.Model):
__tablename__ = "login_alerts"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
login_event_id = db.Column(
db.Integer, db.ForeignKey("login_events.id"), nullable=False
)
alert_type = db.Column(db.String(80), nullable=False)
message = db.Column(db.String(500), nullable=False)
acknowledged = db.Column(db.Boolean, default=False, nullable=False)
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)
Expand Down
169 changes: 168 additions & 1 deletion packages/backend/app/routes/auth.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import datetime, timedelta
from flask import Blueprint, request, jsonify
from werkzeug.security import generate_password_hash, check_password_hash
from flask_jwt_extended import (
Expand All @@ -9,7 +10,7 @@
get_jwt_identity,
)
from ..extensions import db, redis_client
from ..models import User
from ..models import LoginAlert, LoginEvent, User
import logging
import time

Expand Down Expand Up @@ -57,15 +58,70 @@ def login():
password = data.get("password")
user = db.session.query(User).filter_by(email=email).first()
if not user or not check_password_hash(user.password_hash, password):
_record_login_event(user, email or "", success=False)
logger.warning("Login failed for email=%s", email)
return jsonify(error="invalid credentials"), 401
_record_login_event(user, email or user.email, 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)


@bp.get("/login-history")
@jwt_required()
def login_history():
uid = int(get_jwt_identity())
events = (
db.session.query(LoginEvent)
.filter_by(user_id=uid)
.order_by(LoginEvent.created_at.desc())
.limit(25)
.all()
)
return jsonify(
events=[
{
"id": event.id,
"ip_address": event.ip_address,
"user_agent": event.user_agent,
"success": event.success,
"is_suspicious": event.is_suspicious,
"suspicion_reasons": _split_reasons(event.suspicion_reasons),
"created_at": event.created_at.isoformat(),
}
for event in events
]
)


@bp.get("/alerts")
@jwt_required()
def alerts():
uid = int(get_jwt_identity())
rows = (
db.session.query(LoginAlert)
.filter_by(user_id=uid)
.order_by(LoginAlert.created_at.desc())
.limit(25)
.all()
)
return jsonify(alerts=[_alert_payload(row) for row in rows])


@bp.post("/alerts/<int:alert_id>/acknowledge")
@jwt_required()
def acknowledge_alert(alert_id: int):
uid = int(get_jwt_identity())
alert = db.session.get(LoginAlert, alert_id)
if not alert or alert.user_id != uid:
return jsonify(error="not found"), 404
alert.acknowledged = True
db.session.commit()
return jsonify(_alert_payload(alert))


@bp.get("/me")
@jwt_required()
def me():
Expand Down Expand Up @@ -137,3 +193,114 @@ def _store_refresh_session(refresh_token: str, uid: str):
return
ttl = max(int(exp - time.time()), 1)
redis_client.setex(_refresh_key(jti), ttl, uid)


def _record_login_event(user: User | None, email: str, success: bool) -> LoginEvent:
ip_address = _request_ip()
user_agent = (request.headers.get("User-Agent") or "unknown")[:500]
reasons = _detect_login_anomalies(user, ip_address, user_agent, success)
event = LoginEvent(
user_id=user.id if user else None,
email=email[:255],
ip_address=ip_address,
user_agent=user_agent,
success=success,
is_suspicious=bool(reasons),
suspicion_reasons=",".join(reasons) if reasons else None,
)
db.session.add(event)
db.session.flush()
if user and reasons:
db.session.add(
LoginAlert(
user_id=user.id,
login_event_id=event.id,
alert_type="suspicious_login",
message=_alert_message(reasons),
)
)
db.session.commit()
return event


def _detect_login_anomalies(
user: User | None, ip_address: str, user_agent: str, success: bool
) -> list[str]:
if not user:
return []

reasons: list[str] = []
if success:
prior_success = (
db.session.query(LoginEvent)
.filter_by(user_id=user.id, success=True)
.first()
)
if prior_success:
seen_ip = (
db.session.query(LoginEvent.id)
.filter_by(user_id=user.id, success=True, ip_address=ip_address)
.first()
)
if not seen_ip:
reasons.append("new_ip")
seen_device = (
db.session.query(LoginEvent.id)
.filter_by(user_id=user.id, success=True, user_agent=user_agent)
.first()
)
if not seen_device:
reasons.append("new_device")
current_hour = datetime.utcnow().hour
if current_hour in {1, 2, 3, 4, 5}:
reasons.append("unusual_hour")
return reasons

window_start = datetime.utcnow() - timedelta(minutes=15)
failed_count = (
db.session.query(LoginEvent)
.filter(
LoginEvent.user_id == user.id,
LoginEvent.success.is_(False),
LoginEvent.created_at >= window_start,
)
.count()
)
if failed_count >= 4:
reasons.append("failed_login_burst")
return reasons


def _request_ip() -> str:
forwarded_for = request.headers.get("X-Forwarded-For", "")
if forwarded_for:
return forwarded_for.split(",")[0].strip()[:64]
return (request.remote_addr or "unknown")[:64]


def _split_reasons(value: str | None) -> list[str]:
if not value:
return []
return [item for item in value.split(",") if item]


def _alert_message(reasons: list[str]) -> str:
labels = {
"new_ip": "a new IP address",
"new_device": "a new device or browser",
"unusual_hour": "an unusual login time",
"failed_login_burst": "multiple failed login attempts",
}
readable = [labels.get(reason, reason) for reason in reasons]
return "Suspicious login activity detected: " + ", ".join(readable)


def _alert_payload(alert: LoginAlert) -> dict:
return {
"id": alert.id,
"login_event_id": alert.login_event_id,
"alert_type": alert.alert_type,
"message": alert.message,
"acknowledged": alert.acknowledged,
"created_at": alert.created_at.isoformat(),
}
39 changes: 36 additions & 3 deletions packages/backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,39 @@
import os
import fnmatch
import pytest
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


class FakeRedis:
def __init__(self):
self.store = {}

def setex(self, key, _ttl, value):
self.store[key] = value

def set(self, key, value):
self.store[key] = value

def get(self, key):
return self.store.get(key)

def delete(self, *keys):
for key in keys:
self.store.pop(key, None)

def scan(self, cursor=0, match=None, count=100):
keys = sorted(self.store)
if match:
keys = [key for key in keys if fnmatch.fnmatch(key, match)]
return 0, keys[:count]

def flushdb(self):
self.store.clear()


class TestSettings(Settings):
# Override defaults for tests
database_url: str = "sqlite+pysqlite:///:memory:"
Expand All @@ -30,17 +57,23 @@ def app_fixture():
)
app = create_app(settings)
app.config.update(TESTING=True)
fake_redis = FakeRedis()
import app.routes.auth as auth_routes
import app.services.cache as cache_service

auth_routes.redis_client = fake_redis
cache_service.redis_client = fake_redis
_setup_db(app)
try:
redis_client.flushdb()
fake_redis.flushdb()
except Exception:
pass
yield app
with app.app_context():
db.session.remove()
db.drop_all()
try:
redis_client.flushdb()
fake_redis.flushdb()
except Exception:
pass

Expand Down
Loading