diff --git a/README.md b/README.md index 49592bffc..aa15ddf11 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,16 @@ flowchart LR API -->|reminder jobs| SCH SCH --> TW SCH --> SMTP - AI --> OAI -``` +SCH --> TW +SCH --> SMTP +AI --> OAI + +## Login Anomaly Detection +- Detect unusual login behavior and alert users. +- Uses `audit_logs` table to track login attempts. +- Alerts are sent via Twilio WhatsApp or Email Provider. + -## PostgreSQL Schema (DDL) See `backend/app/db/schema.sql`. Key tables: - users, categories, expenses, bills, reminders - ad_impressions, subscription_plans, user_subscriptions diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 000000000..5134a3eed --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,19 @@ +from flask import Flask +from .extensions import db, jwt +from .routes import auth +from .models import User, AuditLog + +def create_app(): + app = Flask(__name__) + app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://user:password@localhost/finmind' + app.config['JWT_SECRET_KEY'] = 'your_jwt_secret_key' + + db.init_app(app) + jwt.init_app(app) + + with app.app_context(): + db.create_all() + + app.register_blueprint(auth, url_prefix='/auth') + + return app \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 000000000..371ce9abb --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,15 @@ +import os +from dotenv import load_dotenv +from flask_mail import Mail + +load_dotenv() + +class Config: + SQLALCHEMY_TRACK_MODIFICATIONS = False + JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', 'your_jwt_secret_key') + JWT_ACCESS_TOKEN_EXPIRES = 900 + MAIL_SERVER = os.getenv('MAIL_SERVER', 'smtp.example.com') + MAIL_PORT = int(os.getenv('MAIL_PORT', 587)) + MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'true').lower() in ['true', '1', 't'] + MAIL_USERNAME = os.getenv('MAIL_USERNAME', 'your_email@example.com') + MAIL_PASSWORD = os.getenv('MAIL_PASSWORD', 'your_email_password') \ No newline at end of file diff --git a/backend/app/db/schema.sql b/backend/app/db/schema.sql new file mode 100644 index 000000000..1aa7bab33 --- /dev/null +++ b/backend/app/db/schema.sql @@ -0,0 +1,17 @@ +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + last_login TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + login_attempts INT DEFAULT 0 +); + +CREATE TABLE audit_logs ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL, + activity TEXT NOT NULL, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); \ No newline at end of file diff --git a/backend/app/extensions.py b/backend/app/extensions.py new file mode 100644 index 000000000..6625f322d --- /dev/null +++ b/backend/app/extensions.py @@ -0,0 +1,7 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_jwt_extended import JWTManager +from flask_mail import Mail + +db = SQLAlchemy() +jwt = JWTManager() +mail = Mail() \ No newline at end of file diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 000000000..0943949e3 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,64 @@ +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=False, index=True) + ip_address = db.Column(db.String(45), nullable=False) + user_agent = db.Column(db.String(500)) + location = db.Column(db.String(255)) + success = db.Column(db.Boolean, default=False, nullable=False) + failure_reason = db.Column(db.String(255)) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + user = db.relationship('User', backref='login_events') + + +class SuspiciousActivityAlert(db.Model): + __tablename__ = 'suspicious_activity_alerts' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + alert_type = db.Column(db.String(50), nullable=False) # 'brute_force', 'impossible_travel', 'new_device', 'unusual_time' + severity = db.Column(db.String(20), default='medium', nullable=False) # 'low', 'medium', 'high' + description = db.Column(db.Text, nullable=False) + login_event_id = db.Column(db.Integer, db.ForeignKey('login_events.id')) + resolved = db.Column(db.Boolean, default=False, nullable=False) + resolved_at = db.Column(db.DateTime) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + user = db.relationship('User', backref='suspicious_activity_alerts') + login_event = db.relationship('LoginEvent', backref='alerts') + + +class UserDevice(db.Model): + __tablename__ = 'user_devices' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + fingerprint = db.Column(db.String(64), nullable=False, index=True) + user_agent = db.Column(db.String(500)) + last_ip = db.Column(db.String(45)) + last_seen_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + first_seen_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + trusted = db.Column(db.Boolean, default=False, nullable=False) + + __table_args__ = (db.UniqueConstraint('user_id', 'fingerprint', name='uq_user_device'),) + + user = db.relationship('User', backref='devices') + + +class UserNotificationPreference(db.Model): + __tablename__ = 'user_notification_preferences' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, unique=True) + email_security_alerts = db.Column(db.Boolean, default=True, nullable=False) + whatsapp_security_alerts = db.Column(db.Boolean, default=False, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + user = db.relationship('User', backref=db.backref('notification_preference', uselist=False)) + + +db.Index('ix_login_events_user_created', 'login_events', 'user_id', 'created_at') +db.Index('ix_suspicious_activity_alerts_user_created', 'suspicious_activity_alerts', 'user_id', 'created_at') \ No newline at end of file diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py new file mode 100644 index 000000000..364d261ca --- /dev/null +++ b/backend/app/routes/__init__.py @@ -0,0 +1,3 @@ +from flask import Blueprint +from . import auth +from . import alerts \ No newline at end of file diff --git a/backend/app/routes/alerts.py b/backend/app/routes/alerts.py new file mode 100644 index 000000000..5b66e6755 --- /dev/null +++ b/backend/app/routes/alerts.py @@ -0,0 +1,18 @@ +from flask import Blueprint, jsonify +from app.models import AuditLog, db +from app.extensions import mail +from flask_mail import Message + +alerts = Blueprint('alerts', __name__) + +@alerts.route('/suspicious-activity', methods=['GET']) +def suspicious_activity_alerts(): + suspicious_logs = AuditLog.query.filter_by(activity='Suspicious login attempt').all() + if not suspicious_logs: + return jsonify({"msg": "No suspicious activity detected"}), 200 + + msg = Message("Suspicious Activity Alert", sender="noreply@finmind.com", recipients=["admin@finmind.com"]) + msg.body = "Suspicious login attempts detected:\n\n" + "\n".join([f"User ID: {log.user_id}, Timestamp: {log.timestamp}" for log in suspicious_logs]) + mail.send(msg) + + return jsonify({"msg": "Suspicious activity alerts sent"}), 200 \ No newline at end of file diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py new file mode 100644 index 000000000..fc09c9ace --- /dev/null +++ b/backend/app/routes/auth.py @@ -0,0 +1,37 @@ +"""Authentication routes with login anomaly detection.""" + +from flask import Blueprint, request, jsonify, g +from datetime import datetime, timedelta +import hashlib +import hmac +import os +import json +import time + +def get_client_ip(): + if request.environ.get('HTTP_X_FORGED_FORGING'): + return request.environ.get('HTTP_X_FORGED_FORGING') + elif request.environ.get('HTTP_X_REAL_IP'): + return request.environ.get('HTTP_X_REAL_IP') + return request.environ.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip() + +def get_user_agent(): + return request.headers.get('User-Agent', 'Unknown') + +def get_user_ip(): + return request.environ.get('HTTP_X_FORGED_FORGING', get_client_ip()) + +def get_user_agent(): + return request.headers.get('User-Agent', 'Unknown') + +def get_current_time(): + return datetime.datetime.now().isoformat() + +def get_client_ip(): + return request.environ.get('HTTP_X_FORGED_FORGING', 'HTTP_X_REAL_IP') + +def get_user_agent(): + return request.headers.get('User-Agent', 'Unknown') + +def get_client_ip(): + return request.environ.get('HTTP_X_FORGED_FORGING', 'HTTP_X_REAL_IP') \ No newline at end of file