Skip to content
Merged
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
12 changes: 7 additions & 5 deletions .github/workflows/build-flatpak.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
name: Build Flatpak

on:
push:
branches: [ deploy ]
pull_request:
branches: [ deploy ]
workflow_dispatch:
inputs:
disabled:
description: 'This workflow is disabled'

permissions:
actions: write
Expand Down Expand Up @@ -53,8 +52,11 @@ jobs:
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
python -m pip install briefcase
python -m pip install "briefcase>=0.4.1"

- name: Copy PNG icon for Flatpak
run: cp app/static/logo.png standalone/src/castle/resources/logo.png

- name: Create Flatpak package
working-directory: ./standalone
run: |
Expand Down
12 changes: 7 additions & 5 deletions .github/workflows/build-linux.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
name: Build Linux App

on:
push:
branches: [ deploy ]
pull_request:
branches: [ deploy ]
workflow_dispatch:
inputs:
disabled:
description: 'This workflow is disabled'

permissions:
contents: write
Expand Down Expand Up @@ -37,7 +36,10 @@ jobs:
desktop-file-utils

- name: Install Briefcase
run: python3 -m pip install --break-system-packages briefcase
run: python3 -m pip install --break-system-packages "briefcase>=0.4.1"

- name: Copy PNG icon for Flatpak
run: cp app/static/logo.png standalone/src/castle/resources/logo.png

- name: Build DEB package
working-directory: ./standalone
Expand Down
14 changes: 7 additions & 7 deletions app/app.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import os
import logging
import os
import traceback
from time import strftime

from dotenv import load_dotenv
from flask import (Flask, make_response, render_template,
send_from_directory, request, flash, redirect, url_for)
from flask import (Flask, make_response, redirect, render_template, request,
send_from_directory, url_for)
from flask_cors import CORS
from flask_login import LoginManager, current_user
from flask_pymongo import PyMongo
from flask_wtf.csrf import CSRFProtect
from flask_cors import CORS

from app.auth.auth_utils import UserManager
from app.utils import limiter, get_mongodb_instance, close_mongodb_connection
from app.utils import close_mongodb_connection, get_mongodb_instance, limiter

csrf = CSRFProtect()
mongo = PyMongo()
Expand All @@ -25,7 +25,7 @@
logger = logging.getLogger(__name__)

def create_app():
app = Flask(__name__, static_folder="static", template_folder="templates")
app: Flask = Flask(__name__, static_folder="static", template_folder="templates")

# Load config
load_dotenv()
Expand Down Expand Up @@ -104,9 +104,9 @@ def load_user(user_id):

# Import blueprints inside create_app to avoid circular imports
from app.auth.routes import auth_bp
from app.notifications.routes import notifications_bp
from app.scout.routes import scouting_bp
from app.team.routes import team_bp
from app.notifications.routes import notifications_bp

app.register_blueprint(auth_bp, url_prefix="/auth")
app.register_blueprint(scouting_bp, url_prefix="/")
Expand Down
90 changes: 77 additions & 13 deletions app/auth/auth_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

import logging
from datetime import datetime, timezone
from typing import Optional

from bson import ObjectId
from flask_login import current_user
from werkzeug.security import generate_password_hash

from app.models import User
from app.utils import DatabaseManager, allowed_file, with_mongodb_retry, get_database_connection, get_gridfs
from app.utils import (DatabaseManager, allowed_file, get_gridfs,
with_mongodb_retry)

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
Expand All @@ -27,7 +30,7 @@ def __init__(self, mongo_uri=None):
super().__init__(mongo_uri)
self._ensure_collections()

def _ensure_collections(self):
def _ensure_collections(self) -> None:
"""Ensure required collections exist"""
if "users" not in self.db.list_collection_names():
self.db.create_collection("users")
Expand All @@ -40,7 +43,7 @@ async def create_user(
username,
password,
team_number=None
):
) -> tuple[bool, str]:
"""Create a new user with retry mechanism"""

try:
Expand Down Expand Up @@ -78,7 +81,7 @@ async def create_user(
return False, "An internal error has occurred."

@with_mongodb_retry(retries=3, delay=2)
async def authenticate_user(self, login, password):
async def authenticate_user(self, login: str, password: str) -> tuple[bool, User | None]:
"""Authenticate user with retry mechanism"""

try:
Expand All @@ -101,7 +104,7 @@ async def authenticate_user(self, login, password):
return False, None

@with_mongodb_retry(retries=3, delay=2)
def get_user_by_id(self, user_id):
def get_user_by_id(self, user_id: str) -> User | None:
"""Retrieve user by ID with retry mechanism"""

try:
Expand All @@ -114,7 +117,7 @@ def get_user_by_id(self, user_id):
return None

@with_mongodb_retry(retries=3, delay=2)
async def update_user_profile(self, user_id, updates):
async def update_user_profile(self, user_id: str, updates: dict) -> tuple[bool, str]:
"""Update user profile information"""

try:
Expand Down Expand Up @@ -146,7 +149,7 @@ async def update_user_profile(self, user_id, updates):
logger.error(f"Error updating profile: {str(e)}")
return False, "An internal error has occurred."

def get_user_profile(self, username):
def get_user_profile(self, username: str) -> User | None:
"""Get user profile by username"""

try:
Expand All @@ -157,7 +160,7 @@ def get_user_profile(self, username):
return None

@with_mongodb_retry(retries=3, delay=2)
async def update_profile_picture(self, user_id, file_id):
async def update_profile_picture(self, user_id: str, file_id: str) -> tuple[bool, str]:
"""Update user's profile picture and clean up old one"""

try:
Expand Down Expand Up @@ -188,7 +191,7 @@ async def update_profile_picture(self, user_id, file_id):
logger.error(f"Error updating profile picture: {str(e)}")
return False, "An internal error has occurred."

def get_profile_picture(self, user_id):
def get_profile_picture(self, user_id: str) -> str | None:
"""Get user's profile picture ID"""

try:
Expand All @@ -200,7 +203,7 @@ def get_profile_picture(self, user_id):
return None

@with_mongodb_retry(retries=3, delay=2)
async def delete_user(self, user_id):
async def delete_user(self, user_id: str) -> tuple[bool, str]:
"""Delete a user account and all associated data"""

try:
Expand Down Expand Up @@ -230,7 +233,68 @@ async def delete_user(self, user_id):
return False, "An internal error has occurred."

@with_mongodb_retry(retries=3, delay=2)
async def update_user_settings(self, user_id, form_data, profile_picture=None):
async def change_password(self, user_id: str, current_password: str, new_password: str) -> tuple[bool, str]:
"""Change user password after verifying current password"""

try:
# Get user data
user_data = self.db.users.find_one({"_id": ObjectId(user_id)})
if not user_data:
return False, "User not found"

# Check for account lockout due to too many failed attempts
failed_attempts = user_data.get('failed_password_change_attempts', 0)
last_failed = user_data.get('last_failed_password_change')

if failed_attempts >= 5 and last_failed:
# Lock for 15 minutes after 5 failed attempts
time_since = (datetime.now(timezone.utc) - last_failed).total_seconds()
if time_since < 900: # 15 minutes
mins_left = int((900 - time_since) / 60) + 1
return False, f"Too many failed attempts. Try again in {mins_left} minutes"

# Create user object to verify current password
user = User.create_from_db(user_data)
if not user.check_password(current_password):
# Track failed attempt
self.db.users.update_one(
{"_id": ObjectId(user_id)},
{
"$set": {"last_failed_password_change": datetime.now(timezone.utc)},
"$inc": {"failed_password_change_attempts": 1}
}
)
return False, "Current password is incorrect"

# Check new password strength
password_valid, message = await check_password_strength(new_password)
if not password_valid:
return False, message

# Update password and reset failed attempts
result = self.db.users.update_one(
{"_id": ObjectId(user_id)},
{
"$set": {
"password_hash": generate_password_hash(new_password),
"password_changed_at": datetime.now(timezone.utc),
"failed_password_change_attempts": 0
},
"$unset": {"last_failed_password_change": ""}
}
)

if result.modified_count > 0:
logger.info(f"Password changed for user: {user.username}")
return True, "Password changed successfully"
return False, "Failed to update password"

except Exception as e:
logger.error(f"Error changing password: {str(e)}")
return False, "An internal error has occurred."

@with_mongodb_retry(retries=3, delay=2)
async def update_user_settings(self, user_id: str, form_data: dict, profile_picture=None) -> tuple[bool, Optional[str]]:
"""Update user settings including profile picture"""

try:
Expand All @@ -241,7 +305,7 @@ async def update_user_settings(self, user_id, form_data, profile_picture=None):
if new_username != current_user.username:
# Check if username is taken
if self.db.users.find_one({"username": new_username}):
return False
return False, "Username is already taken"
updates['username'] = new_username

# Handle description update
Expand All @@ -262,7 +326,7 @@ async def update_user_settings(self, user_id, form_data, profile_picture=None):

if updates:
success, message = await self.update_user_profile(user_id, updates)
return success
return success, message

return True, "Profile updated successfully"
except Exception as e:
Expand Down
53 changes: 50 additions & 3 deletions app/auth/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
from werkzeug.utils import secure_filename

from app.auth.auth_utils import UserManager
from app.utils import (async_route, handle_route_errors, is_safe_url, limiter,
send_gridfs_file, get_gridfs)
from app.utils import (async_route, get_gridfs, handle_route_errors,
is_safe_url, limiter, send_gridfs_file)

ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}

Expand Down Expand Up @@ -249,7 +249,6 @@ def profile_picture(user_id):

return send_gridfs_file(
user.profile_picture_id,
user_manager.db,
"static/images/default_profile.png"
)

Expand Down Expand Up @@ -282,6 +281,54 @@ async def check_username():
}), 500


@auth_bp.route("/change_password", methods=["POST"])
@limiter.limit("5 per minute")
@login_required
@async_route
async def change_password():
"""Change user password"""
try:
if request.headers.get('X-Requested-With') != 'XMLHttpRequest':
return jsonify({"success": False, "message": "Invalid request"}), 403

data = request.get_json()
current_password = data.get('current_password', '').strip()
new_password = data.get('new_password', '').strip()
confirm_password = data.get('confirm_password', '').strip()

# Validate input
if not all([current_password, new_password, confirm_password]):
return jsonify({"success": False, "message": "All fields are required"}), 400

if new_password != confirm_password:
return jsonify({"success": False, "message": "New passwords do not match"}), 400

# Additional security: Prevent reusing current password
if current_password == new_password:
return jsonify({"success": False, "message": "New password must be different from current password"}), 400

# Change password
success, message = await user_manager.change_password(
current_user.get_id(),
current_password,
new_password
)

if success:
current_app.logger.info(f"Password changed for user {current_user.username}")
# Log out user to invalidate session
logout_user()
return jsonify({"success": True, "message": "Password changed successfully. Please log in again.", "redirect": url_for("auth.login")})
else:
# Don't reveal whether username exists or password was wrong (timing attack mitigation)
current_app.logger.warning(f"Failed password change attempt for user {current_user.username}")
return jsonify({"success": False, "message": message}), 400

except Exception as e:
current_app.logger.error(f"Error changing password for user {current_user.username}: {str(e)}", exc_info=True)
return jsonify({"success": False, "message": "An internal error has occurred."}), 500


@auth_bp.route("/delete_account", methods=["POST"])
@login_required
@async_route
Expand Down
Loading
Loading