diff --git a/.env.example b/.env.example index 7016ace..e403593 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,18 @@ DATABASE_URL=sqlite:///app.db # Application Settings DEBUG=True +# Mail Configuration (for location alerts and notifications) +MAIL_SERVER=smtp.gmail.com +MAIL_PORT=587 +MAIL_USE_TLS=true +MAIL_USERNAME=your-email@gmail.com +MAIL_PASSWORD=your-app-password +MAIL_DEFAULT_SENDER=noreply@flask2fa.com + +# Location Security Settings +MAX_LOGIN_ATTEMPTS=5 +SUSPICIOUS_LOGIN_THRESHOLD_KM=100 + # Security Headers (Production only) # SESSION_COOKIE_SECURE=True # SESSION_COOKIE_HTTPONLY=True diff --git a/app/__init__.py b/app/__init__.py index f84490c..c521c7b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -3,6 +3,7 @@ from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate from flask_login import LoginManager from flask_wtf.csrf import CSRFProtect +from flask_mail import Mail from config import config # Initialize extensions @@ -10,6 +11,7 @@ db = SQLAlchemy() migrate = Migrate() login_manager = LoginManager() csrf = CSRFProtect() +mail = Mail() def create_app(config_name='default'): @@ -23,19 +25,22 @@ def create_app(config_name='default'): """ app = Flask(__name__) app.config.from_object(config[config_name]) - - # Initialize extensions with app + # Initialize extensions with app db.init_app(app) migrate.init_app(app, db) csrf.init_app(app) - - # Configure Flask-Login for security + mail.init_app(app) + # Configure Flask-Login for security login_manager.init_app(app) login_manager.login_view = 'auth.login' login_manager.login_message = 'Please log in to access this page.' login_manager.login_message_category = 'info' login_manager.session_protection = 'strong' # Enhanced session protection + # Initialize mail service + from app.utils.mail import mail_service + mail_service.init_app(app, mail) + @login_manager.user_loader def load_user(user_id): """ diff --git a/app/auth/routes.py b/app/auth/routes.py index 2c02104..675c9bf 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -1,10 +1,13 @@ from flask import render_template, redirect, url_for, flash, request, session from flask_login import login_user, logout_user, current_user, login_required from urllib.parse import urlparse +from datetime import datetime, timedelta from app import db from app.auth import bp from app.auth.forms import RegistrationForm, LoginForm, TwoFactorForm -from app.models import User +from app.models import User, LoginLocation, LocationApprovalToken +from app.utils.location import location_service +from app.utils.mail import mail_service import logging # Configure logging for security events @@ -155,6 +158,9 @@ def verify_otp(): if not user.is_2fa_enabled: user.enable_2fa() + # Track login location + success = track_login_location(user) + # Update last login timestamp user.last_login = db.func.current_timestamp() db.session.commit() @@ -166,11 +172,16 @@ def verify_otp(): # Complete login process login_user(user, remember=remember_me) + # Show appropriate message based on location tracking + if success: + flash('Login successful!', 'success') + else: + flash('Login successful! Check your email for location verification.', 'info') + # Log successful login logger.info(f'Successful login for user: {user.username}') - flash('Login successful!', 'success') - # Redirect to originally requested page or dashboard + # Redirect to originally requested page or dashboard next_page = request.args.get('next') if not next_page or urlparse(next_page).netloc != '': next_page = url_for('main.index') @@ -221,3 +232,162 @@ def disable_2fa(): flash('Failed to disable two-factor authentication.', 'error') return redirect(url_for('main.profile')) + + +def track_login_location(user): + """ + Track user login location and handle suspicious login detection. + + Args: + user: User object + + Returns: + bool: True if location is trusted, False if suspicious (email sent) + """ + try: + # Get current IP and location data + ip_address = location_service.get_client_ip() + user_agent = location_service.get_user_agent() + location_data = location_service.get_location_from_ip(ip_address) + + # Check if location is suspicious + is_suspicious, last_location = location_service.is_suspicious_location( + user.id, location_data + ) + + # Create login location record + login_location = LoginLocation( + user_id=user.id, + ip_address=ip_address, + country=location_data.get('country'), + region=location_data.get('region'), + city=location_data.get('city'), + latitude=location_data.get('latitude'), + longitude=location_data.get('longitude'), + user_agent=user_agent, + is_approved=not is_suspicious, + is_suspicious=is_suspicious + ) + + db.session.add(login_location) + db.session.commit() + + # Handle suspicious login + if is_suspicious: + # Create approval token + token = LocationApprovalToken.generate_token() + approval_token = LocationApprovalToken( + user_id=user.id, + location_id=login_location.id, + token=token, + expires_at=datetime.utcnow() + timedelta(hours=24) + ) + + db.session.add(approval_token) + db.session.commit() + + # Prepare location data for email + email_location_data = { + 'city': location_data.get('city'), + 'region': location_data.get('region'), + 'country': location_data.get('country'), + 'ip_address': ip_address, + 'distance_km': last_location.get('distance_km') if last_location else None + } + + # Send suspicious login alert email + mail_service.send_suspicious_login_alert( + user.email, + user.username, + email_location_data, + token + ) + + logger.warning(f"Suspicious login detected for user {user.username} from {location_data.get('city', 'Unknown')}") + return False + + logger.info(f"Trusted location login for user {user.username} from {location_data.get('city', 'Unknown')}") + return True + + except Exception as e: + logger.error(f"Error tracking login location for user {user.username}: {str(e)}") + # On error, allow login but log the issue + return True + + +@bp.route('/approve-location/') +def approve_location(token): + """ + Approve a suspicious login location via email link. + + Args: + token: Location approval token from email + """ + try: + # Find and validate token + approval_token = LocationApprovalToken.query.filter_by(token=token).first() + + if not approval_token: + flash('Invalid or expired approval link.', 'error') + return redirect(url_for('main.index')) + + if not approval_token.is_valid(): + flash('This approval link has expired or already been used.', 'error') + return redirect(url_for('main.index')) + + # Mark location as approved + location = approval_token.location + location.is_approved = True + location.is_suspicious = False + location.approved_at = datetime.utcnow() + + # Mark token as used + approval_token.is_used = True + approval_token.used_at = datetime.utcnow() + + db.session.commit() + + # Send confirmation email + user = approval_token.user + location_data = { + 'city': location.city, + 'region': location.region, + 'country': location.country + } + + mail_service.send_location_approved_notification( + user.email, + user.username, + location_data + ) + + logger.info(f"Location approved for user {user.username}: {location.location_display}") + flash('Location approved successfully! This location is now trusted.', 'success') + + except Exception as e: + logger.error(f"Error approving location with token {token}: {str(e)}") + flash('An error occurred while approving the location.', 'error') + + return redirect(url_for('main.index')) + + +@bp.route('/login-history') +@login_required +def login_history(): + """ + Display user's login history and location data. + """ + try: + # Get user's login locations ordered by most recent + locations = LoginLocation.query.filter_by( + user_id=current_user.id + ).order_by(LoginLocation.login_time.desc()).limit(50).all() + + return render_template('auth/login_history.html', + locations=locations, + title='Login History') + + except Exception as e: + logger.error(f"Error loading login history for user {current_user.username}: {str(e)}") + flash('Error loading login history.', 'error') + return redirect(url_for('main.dashboard')) diff --git a/app/models.py b/app/models.py index 0a497e0..19a3e84 100644 --- a/app/models.py +++ b/app/models.py @@ -145,3 +145,82 @@ def validate_password_hash(target, value, oldvalue, initiator): """Ensure password hash is never stored as plaintext.""" if value and not value.startswith('pbkdf2:sha256'): raise ValueError("Password must be hashed before storage") + + +class LoginLocation(db.Model): + """ + Model to track user login locations for security monitoring. + + Security features: + - IP address tracking + - Geolocation data storage + - Login attempt tracking + - Suspicious activity detection + """ + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + ip_address = db.Column(db.String(45), nullable=False) # IPv6 support + country = db.Column(db.String(100)) + region = db.Column(db.String(100)) + city = db.Column(db.String(100)) + latitude = db.Column(db.Float) + longitude = db.Column(db.Float) + user_agent = db.Column(db.Text) + is_approved = db.Column(db.Boolean, default=False, nullable=False) + is_suspicious = db.Column(db.Boolean, default=False, nullable=False) + login_time = db.Column(db.DateTime, default=db.func.current_timestamp()) + approved_at = db.Column(db.DateTime) + + # Relationship + user = db.relationship('User', backref=db.backref('login_locations', lazy=True, + order_by='LoginLocation.login_time.desc()')) + + def __repr__(self): + return f'' + + @property + def location_display(self): + """Human-readable location string.""" + parts = [] + if self.city: + parts.append(self.city) + if self.region and self.region != self.city: + parts.append(self.region) + if self.country: + parts.append(self.country) + return ', '.join(parts) if parts else 'Unknown Location' + + @property + def coordinates(self): + """Return coordinates as tuple if available.""" + if self.latitude is not None and self.longitude is not None: + return (self.latitude, self.longitude) + return None + + +class LocationApprovalToken(db.Model): + """ + Model for secure location approval tokens sent via email. + + Security features: + - Cryptographically secure token generation + - Token expiration + - One-time use tokens + """ + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + location_id = db.Column(db.Integer, db.ForeignKey('login_location.id'), nullable=False) + token = db.Column(db.String(255), nullable=False, unique=True) + created_at = db.Column(db.DateTime, default=db.func.current_timestamp()) + expires_at = db.Column(db.DateTime, nullable=False) + used_at = db.Column(db.DateTime) + is_used = db.Column(db.Boolean, default=False, nullable=False) + + # Relationships + user = db.relationship('User', backref=db.backref('location_tokens', lazy=True)) + location = db.relationship('LoginLocation', backref=db.backref('approval_tokens', lazy=True)) + + def __repr__(self): + return f'' diff --git a/app/templates/auth/login_history.html b/app/templates/auth/login_history.html new file mode 100644 index 0000000..3c2f6c1 --- /dev/null +++ b/app/templates/auth/login_history.html @@ -0,0 +1,180 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+

+ Login History +

+
+
+

+ Review your recent login activity. If you see any suspicious activity, + please change your password immediately. +

+ + {% if locations %} +
+ + + + + + + + + + + + {% for location in locations %} + + + + + + + + {% endfor %} + +
Date & Time Location IP Address Device Status
+ {{ location.login_time.strftime('%Y-%m-%d') }}
+ {{ location.login_time.strftime('%H:%M:%S UTC') }} +
+
+ + {{ location.location_display }} +
+ {% if location.coordinates %} + + {{ "%.4f"|format(location.latitude) }}, {{ "%.4f"|format(location.longitude) }} + + {% endif %} +
+ {{ location.ip_address }} + + {% if location.user_agent %} + + {% if 'Chrome' in location.user_agent %} + Chrome + {% elif 'Firefox' in location.user_agent %} + Firefox + {% elif 'Safari' in location.user_agent %} + Safari + {% elif 'Edge' in location.user_agent %} + Edge + {% else %} + Unknown + {% endif %} + + {% if 'Mobile' in location.user_agent or 'Android' in location.user_agent or 'iPhone' in location.user_agent %} + + {% else %} + + {% endif %} + + {% else %} + Unknown Device + {% endif %} + + {% if location.is_approved %} + + Trusted + + {% if location.approved_at %} +
+ Approved: {{ location.approved_at.strftime('%Y-%m-%d %H:%M') }} + + {% endif %} + {% elif location.is_suspicious %} + + Suspicious + +
Pending approval + {% else %} + + Unknown + + {% endif %} +
+
+ + + {% if locations|length == 50 %} +
+ + Showing the most recent 50 login attempts. Older entries are automatically archived. +
+ {% endif %} + + {% else %} +
+ +
No Login History
+

This appears to be your first login, or login tracking was recently enabled.

+
+ {% endif %} + + +
+
+
+
+
+
+ Security Features +
+
    +
  • Automatic location tracking
  • +
  • Suspicious login detection
  • +
  • Email alerts for new locations
  • +
  • Two-factor authentication
  • +
+
+
+
+
+
+
+
+ Security Tips +
+
    +
  • Review this page regularly
  • +
  • Report suspicious activity
  • +
  • Use strong, unique passwords
  • +
  • Keep your devices secure
  • +
+
+
+
+
+
+ + + +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 893b04d..c66b4bf 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -75,9 +75,13 @@