diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..c437d80 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,32 @@ +############################################################# +# Variables for configuring GitHub OAuth App used for logging +############################################################# +# Create GitHub App on GitHub: +# - https://github.com/settings/apps/new +# - Set `GitHub App name` to something like `diffkemp-automation-login` +# - Set `Homepage URL` to something like `http://127.0.0.1:5000/` or your domain +# - Set `Callback URL` to `http://127.0.0.1:5000/auth/auth` or `domain/auth/auth` +# - Deactivate Webhook +# - No need to set any permissions +# - Create the GitHub App +# - No need to generate a private key as we will not install the app + +# Client ID of the created GitHub App +GITHUB_LOGIN_CLIENT_ID= + +# Client secret for creating access token +# Generate on the created GitHub App and paste it here +GITHUB_LOGIN_SECRET=... + +# Comma separated list of GitHub logins +# for users which are allowed to modify data +GITHUB_LOGIN_ALLOWED_USERS=login1,login2 + +#### +# +#### + +# Secret key for sessions +# https://flask.palletsprojects.com/en/stable/quickstart/#sessions +# Generate it with `python -c 'import secrets; print(secrets.token_hex())'` +APP_SESSION_SECRET_KEY=... diff --git a/backend/.gitignore b/backend/.gitignore index 01ebd17..ba56bcd 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,3 +1,6 @@ +.env + +# Python specific .venv/ *.pyc diff --git a/backend/README.md b/backend/README.md index a7bb12d..ce1beb2 100644 --- a/backend/README.md +++ b/backend/README.md @@ -27,19 +27,27 @@ Backend server which manages: pip install -e .[dev] ``` -3. Run initial comparison: +3. Prepare DB: + + ```bash + alembic upgrade head + ``` + +4. Run initial comparison: ```bash diffkemp-automation-compare ``` -4. After initial comparison is finished, setup automatic checking of new versions: +5. After initial comparison is finished, setup automatic checking of new versions: ```bash backend/create_cron_job.sh ``` -5. Running web server +6. Prepare `.env` file, create it based on `.env.example` + +7. Running web server ```bash diffkemp-automation-dev-web @@ -59,3 +67,6 @@ mypy . - The container is specified in `comparison_container/Dockerfile`. - The container contains files from `comparison_container/files/` in `/runner/` directory, for more details see `comparison_container/files/README.md`. +- For DB is used [SQLite](https://sqlite.org/), [SqlAlchemy](https://www.sqlalchemy.org/) + is used for ORM. For DB scheme migration is used [Alembic](https://alembic.sqlalchemy.org/en/latest/). +- For logging in is used [GitHub App](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/building-a-login-with-github-button-with-a-github-app). diff --git a/backend/automation/web/__init__.py b/backend/automation/web/__init__.py index 95cd7e4..d650d2c 100644 --- a/backend/automation/web/__init__.py +++ b/backend/automation/web/__init__.py @@ -4,16 +4,20 @@ :author: Lukas Petr """ import logging +import os import sys import gunicorn.app.wsgiapp as wsgiapp +from dotenv import load_dotenv from flask import Flask, redirect, url_for from flask.typing import ResponseReturnValue def create_app() -> Flask: + load_dotenv() app = Flask(__name__) + from .auth import auth_bp from .commits import commits_bp from .versions import versions_bp from .viewer import view_results_bp @@ -21,11 +25,17 @@ def create_app() -> Flask: app.register_blueprint(versions_bp) app.register_blueprint(commits_bp) app.register_blueprint(view_results_bp) + app.register_blueprint(auth_bp) @app.route("/") def index() -> ResponseReturnValue: return redirect(url_for("versions_bp.versions_list")) + session_secret_key = os.getenv("APP_SESSION_SECRET_KEY") + if not session_secret_key: + raise ValueError("APP_SESSION_SECRET_KEY must be set in .env") + app.secret_key = session_secret_key + return app diff --git a/backend/automation/web/auth.py b/backend/automation/web/auth.py new file mode 100644 index 0000000..0672fe6 --- /dev/null +++ b/backend/automation/web/auth.py @@ -0,0 +1,114 @@ +"""Module solving authentication of users.""" +import logging +import os +from urllib.parse import urlparse + +import requests +from flask import Blueprint, flash, redirect, request, session +from werkzeug.wrappers import Response + +auth_bp = Blueprint("auth_bp", __name__, url_prefix="/auth") +logger = logging.getLogger(__package__) + + +def is_safe_redirect(target: str) -> bool: + """Check if redirect URL is safe (same domain as request).""" + if not target: + return False + + # Parse the target URL + parsed = urlparse(target) + + # Allow relative URLs + if not parsed.scheme and not parsed.netloc: + return target.startswith("/") and not target.startswith("//") + + # For absolute URLs, check if domain matches current request + request_host = urlparse(request.url).netloc + return parsed.netloc == request_host + + +@auth_bp.route("/login") +def login() -> Response: + """For user log in via GitHub App OAuth.""" + # Setting next for redirecting back to the original page after user is + # logged in. + next = request.args.get("next", "/") + session["oauth_next"] = next + + if "GITHUB_LOGIN_CLIENT_ID" not in os.environ: + raise ValueError("Missing GITHUB_LOGIN_CLIENT_ID var") + client_id = os.getenv("GITHUB_LOGIN_CLIENT_ID", "") + github_oauth_url = ( + "https://github.com/login/oauth/authorize" + f"?client_id={client_id}" + ) + return redirect(github_oauth_url) + + +@auth_bp.route("/auth") +def auth() -> Response: + """Called back from GitHub OAuth.""" + code = request.args.get("code") + if not code: + flash("Authentication failed: No code received", "danger") + logger.error("Authentication failed: No code received") + return redirect("/") + secret = os.getenv("GITHUB_LOGIN_SECRET") + client_id = os.getenv("GITHUB_LOGIN_CLIENT_ID") + allowed_users = os.getenv("GITHUB_LOGIN_ALLOWED_USERS") + + # Get access token + try: + res = requests.post( + "https://github.com/login/oauth/access_token" + f"?client_id={client_id}&client_secret={secret}&code={code}", + headers={"Accept": "application/json"} + ) + res.raise_for_status() + data = res.json() + except Exception as e: + flash("GitHub authentication failed", "danger") + logger.error(f"Failed to authenticate: {e}") + return redirect("/") + + access_token = data["access_token"] + + # Get user information + try: + res = requests.get( + "https://api.github.com/user", + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {access_token}", + }) + res.raise_for_status() + data = res.json() + except Exception as e: + flash("Unable to get user information from GitHub", "danger") + logger.error(f"Unable to get user information from GitHub: {e}") + return redirect("/") + + login = data["login"] + if allowed_users and login in allowed_users.split(","): + session["user"] = login + else: + flash("You are not on list of allowed users!!!", "danger") + logger.error(f"Attempt to log in from {login}") + + # Redirecting back to the original page + next = session.pop("oauth_next", "/") + if not is_safe_redirect(next): + next = "/" + return redirect(next) + + +@auth_bp.route("/logout") +def logout() -> Response: + """Logs out the user.""" + session.pop("user", None) + + next = request.args.get("next", "/") + if not is_safe_redirect(next): + next = "/" + return redirect(next) diff --git a/backend/automation/web/templates/layout.html b/backend/automation/web/templates/layout.html index 8551cf1..ec71617 100644 --- a/backend/automation/web/templates/layout.html +++ b/backend/automation/web/templates/layout.html @@ -44,24 +44,53 @@ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %}
-
diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6b0abb6..e843bc7 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "gunicorn==23.0.0", "sqlalchemy==2.0.45", "alembic==1.17.2", + "python-dotenv==1.2.1" ] [tool.poetry]