From d87d19afb3d4aa61c1dc77325c49e04d63c6cc7d Mon Sep 17 00:00:00 2001 From: yuriBean Date: Wed, 4 Jun 2025 09:46:51 +0500 Subject: [PATCH 1/7] =?UTF-8?q?Add=20OIDC=20login=20with=20Authlib=20(Goog?= =?UTF-8?q?le,=20GitHub,=20Microsoft)=20=E2=80=93=20closes=20#355?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 15 +++++++++ gramps_webapi/app.py | 63 +++++++++++++++++++++++++++++++++----- gramps_webapi/auth/oidc.py | 56 +++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 .env.example create mode 100644 gramps_webapi/auth/oidc.py diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..d71942da --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Google +GOOGLE_CLIENT_ID=your_google_client_id +GOOGLE_CLIENT_SECRET=your_google_client_secret + +# GitHub +GITHUB_CLIENT_ID=your_github_client_id +GITHUB_CLIENT_SECRET=your_github_client_secret + +# Microsoft +MICROSOFT_CLIENT_ID=your_microsoft_client_id +MICROSOFT_CLIENT_SECRET=your_microsoft_client_secret + +# General +SECRET_KEY=supersecret +BASE_URL=http://localhost:5000 diff --git a/gramps_webapi/app.py b/gramps_webapi/app.py index 2041c2a1..40ad6476 100644 --- a/gramps_webapi/app.py +++ b/gramps_webapi/app.py @@ -23,8 +23,10 @@ import os import warnings from typing import Any, Dict, Optional +from auth.oidc import configure_oauth, oidc_bp +from dotenv import load_dotenv -from flask import Flask, abort, g, send_from_directory +from flask import Flask, abort, g, send_from_directory, session from flask_compress import Compress from flask_cors import CORS from flask_jwt_extended import JWTManager @@ -32,7 +34,7 @@ from gramps.gen.config import set as setconfig from .api import api_blueprint -from .api.cache import request_cache, thumbnail_cache +from .api.cache import thumbnail_cache from .api.ratelimiter import limiter from .api.search.embeddings import load_model from .api.util import close_db @@ -42,6 +44,7 @@ from .dbmanager import WebDbManager from .util.celery import create_celery +load_dotenv() def deprecated_config_from_env(app): """Add deprecated config from environment variables. @@ -76,10 +79,10 @@ def deprecated_config_from_env(app): return app -def create_app(config: Optional[Dict[str, Any]] = None, config_from_env: bool = True): +def create_app(config: Optional[Dict[str, Any]] = None): """Flask application factory.""" app = Flask(__name__) - + app.secret_key = os.getenv("SECRET_KEY") app.logger.setLevel(logging.INFO) # load default config @@ -93,8 +96,7 @@ def create_app(config: Optional[Dict[str, Any]] = None, config_from_env: bool = deprecated_config_from_env(app) # use prefixed environment variables if exist - if config_from_env: - app.config.from_prefixed_env(prefix="GRAMPSWEB") + app.config.from_prefixed_env(prefix="GRAMPSWEB") # update config from dictionary if present if config: @@ -143,7 +145,9 @@ def create_app(config: Optional[Dict[str, Any]] = None, config_from_env: bool = app.config["SQLALCHEMY_DATABASE_URI"] = app.config["USER_DB_URI"] user_db.init_app(app) - request_cache.init_app(app, config=app.config["REQUEST_CACHE_CONFIG"]) + configure_oauth(app) + app.register_blueprint(oidc_bp) + thumbnail_cache.init_app(app, config=app.config["THUMBNAIL_CACHE_CONFIG"]) # enable CORS for /api/... resources @@ -207,4 +211,47 @@ def close_user_db_connection(exception) -> None: def ready(): return {"status": "ready"}, 200 - return app + @oidc_bp.route("/callback/") + def authorize(provider): + client = oauth.create_client(provider) + token = client.authorize_access_token() + oidc_user = client.parse_id_token(token) if provider == "google" else client.get('user').json() + + # Get email from OIDC user info + email = oidc_user.get('email') + if not email: + return {"error": "No email provided by OIDC provider"}, 400 + + # Check if user exists + query = user_db.session.query(User) + user = query.filter_by(email=email).scalar() + + if not user: + # Create new user with default role + try: + user = User( + id=uuid.uuid4(), + name=email.split('@')[0], # Use part before @ as username + email=email, + fullname=oidc_user.get('name', ''), + role=0, # Default role + pwhash='', # No password for OIDC users + ) + user_db.session.add(user) + user_db.session.commit() + except IntegrityError: + return {"error": "User creation failed"}, 400 + + # Set up session + session['user_id'] = str(user.id) + session['user_name'] = user.name + session['user_role'] = user.role + + return {"status": "logged_in", "provider": provider, "user": { + "name": user.name, + "email": user.email, + "full_name": user.fullname, + "role": user.role + }} + + return app \ No newline at end of file diff --git a/gramps_webapi/auth/oidc.py b/gramps_webapi/auth/oidc.py new file mode 100644 index 00000000..93eea565 --- /dev/null +++ b/gramps_webapi/auth/oidc.py @@ -0,0 +1,56 @@ +import os +from authlib.integrations.flask_client import OAuth +from flask import Blueprint, redirect, url_for, session, request +from dotenv import load_dotenv + +load_dotenv() + +oauth = OAuth() + +oidc_bp = Blueprint("oidc", __name__, url_prefix="/auth") + +def configure_oauth(app): + oauth.init_app(app) + + oauth.register( + name='google', + client_id=os.getenv("GOOGLE_CLIENT_ID"), + client_secret=os.getenv("GOOGLE_CLIENT_SECRET"), + access_token_url='https://oauth2.googleapis.com/token', + authorize_url='https://accounts.google.com/o/oauth2/auth', + api_base_url='https://www.googleapis.com/oauth2/v1/', + client_kwargs={'scope': 'openid email profile'}, + ) + + oauth.register( + name='github', + client_id=os.getenv("GITHUB_CLIENT_ID"), + client_secret=os.getenv("GITHUB_CLIENT_SECRET"), + access_token_url='https://github.com/login/oauth/access_token', + authorize_url='https://github.com/login/oauth/authorize', + api_base_url='https://api.github.com/', + client_kwargs={'scope': 'read:user user:email'}, + ) + + oauth.register( + name='microsoft', + client_id=os.getenv("MICROSOFT_CLIENT_ID"), + client_secret=os.getenv("MICROSOFT_CLIENT_SECRET"), + access_token_url='https://login.microsoftonline.com/common/oauth2/v2.0/token', + authorize_url='https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + api_base_url='https://graph.microsoft.com/v1.0/', + client_kwargs={'scope': 'openid email profile'}, + ) + +@oidc_bp.route("/login/") +def login(provider): + redirect_uri = url_for("oidc.authorize", provider=provider, _external=True) + return oauth.create_client(provider).authorize_redirect(redirect_uri) + +@oidc_bp.route("/callback/") +def authorize(provider): + client = oauth.create_client(provider) + token = client.authorize_access_token() + user = client.parse_id_token(token) if provider == "google" else client.get('user').json() + session['user'] = user + return {"status": "logged_in", "provider": provider, "user": user} From bdf27b13383ea453881603a73eb3fccd2a885cb0 Mon Sep 17 00:00:00 2001 From: yuriBean Date: Wed, 4 Jun 2025 18:19:31 +0500 Subject: [PATCH 2/7] fixed impot --- gramps_webapi/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gramps_webapi/app.py b/gramps_webapi/app.py index 40ad6476..f1267261 100644 --- a/gramps_webapi/app.py +++ b/gramps_webapi/app.py @@ -23,7 +23,7 @@ import os import warnings from typing import Any, Dict, Optional -from auth.oidc import configure_oauth, oidc_bp +from gramps_webapi.auth.oidc import configure_oauth, oidc_bp from dotenv import load_dotenv from flask import Flask, abort, g, send_from_directory, session From e874d4ff05f6c691e4f2a7d682bf65abd4fe39be Mon Sep 17 00:00:00 2001 From: yuriBean Date: Fri, 6 Jun 2025 15:49:59 +0500 Subject: [PATCH 3/7] Added authlib dependency to requirements --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index ea5aeadf..80c00984 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,3 +8,4 @@ pre-commit celery[pytest] moto[s3]<5.0.0 PyYAML +authlib From 410014b86d50c6f36331c764a53eba3c473e6b64 Mon Sep 17 00:00:00 2001 From: yuriBean Date: Fri, 6 Jun 2025 16:26:04 +0500 Subject: [PATCH 4/7] Integrate OIDC login with Authlib (Google, GitHub, Microsoft) with existing system --- gramps_webapi/auth/oidc.py | 59 +++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/gramps_webapi/auth/oidc.py b/gramps_webapi/auth/oidc.py index 93eea565..b2ff52d1 100644 --- a/gramps_webapi/auth/oidc.py +++ b/gramps_webapi/auth/oidc.py @@ -1,7 +1,12 @@ import os from authlib.integrations.flask_client import OAuth -from flask import Blueprint, redirect, url_for, session, request +from flask import Blueprint, redirect, url_for, request, current_app +from flask_jwt_extended import create_access_token, create_refresh_token from dotenv import load_dotenv +from sqlalchemy.exc import IntegrityError +import uuid + +from . import user_db, User load_dotenv() @@ -44,13 +49,59 @@ def configure_oauth(app): @oidc_bp.route("/login/") def login(provider): + """Start the OAuth/OIDC login flow.""" redirect_uri = url_for("oidc.authorize", provider=provider, _external=True) return oauth.create_client(provider).authorize_redirect(redirect_uri) @oidc_bp.route("/callback/") def authorize(provider): + """Handle the OAuth/OIDC callback and create/link user account.""" client = oauth.create_client(provider) token = client.authorize_access_token() - user = client.parse_id_token(token) if provider == "google" else client.get('user').json() - session['user'] = user - return {"status": "logged_in", "provider": provider, "user": user} + + # Get user info from provider + if provider == "google": + user_info = client.parse_id_token(token) + else: + user_info = client.get('user').json() + + # Get email from OIDC user info + email = user_info.get('email') + if not email: + return {"error": "No email provided by OIDC provider"}, 400 + + # Check if user exists + query = user_db.session.query(User) + user = query.filter_by(email=email).scalar() + + if not user: + # Create new user with default role + try: + user = User( + id=uuid.uuid4(), + name=email.split('@')[0], + email=email, + fullname=user_info.get('name', ''), + role=0, + pwhash='', + ) + user_db.session.add(user) + user_db.session.commit() + except IntegrityError: + return {"error": "User creation failed"}, 400 + + # Create JWT tokens + access_token = create_access_token(identity=str(user.id)) + refresh_token = create_refresh_token(identity=str(user.id)) + + # Return tokens and user info + return { + "access_token": access_token, + "refresh_token": refresh_token, + "user": { + "name": user.name, + "email": user.email, + "full_name": user.fullname, + "role": user.role + } + } From cd0a3d6274b789cf4ccdce83339764e56a7db871 Mon Sep 17 00:00:00 2001 From: yuriBean Date: Mon, 9 Jun 2025 10:46:20 +0500 Subject: [PATCH 5/7] Integrate OIDC login and allow users to authenticate to the REST API --- gramps_webapi/auth/oidc.py | 50 +++++++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/gramps_webapi/auth/oidc.py b/gramps_webapi/auth/oidc.py index b2ff52d1..f9e2d1c7 100644 --- a/gramps_webapi/auth/oidc.py +++ b/gramps_webapi/auth/oidc.py @@ -1,12 +1,15 @@ import os from authlib.integrations.flask_client import OAuth -from flask import Blueprint, redirect, url_for, request, current_app +from flask import Blueprint, redirect, url_for, request, current_app, jsonify from flask_jwt_extended import create_access_token, create_refresh_token from dotenv import load_dotenv from sqlalchemy.exc import IntegrityError import uuid from . import user_db, User +from .const import ROLE_OWNER +from ..api.util import get_tree_id +from ..api.auth import get_permissions load_dotenv() @@ -50,7 +53,8 @@ def configure_oauth(app): @oidc_bp.route("/login/") def login(provider): """Start the OAuth/OIDC login flow.""" - redirect_uri = url_for("oidc.authorize", provider=provider, _external=True) + # Get the redirect URL from the request or use a default + redirect_uri = request.args.get('redirect_uri', url_for("oidc.authorize", provider=provider, _external=True)) return oauth.create_client(provider).authorize_redirect(redirect_uri) @oidc_bp.route("/callback/") @@ -68,7 +72,7 @@ def authorize(provider): # Get email from OIDC user info email = user_info.get('email') if not email: - return {"error": "No email provided by OIDC provider"}, 400 + return jsonify({"error": "No email provided by OIDC provider"}), 400 # Check if user exists query = user_db.session.query(User) @@ -79,29 +83,53 @@ def authorize(provider): try: user = User( id=uuid.uuid4(), - name=email.split('@')[0], + name=email.split('@')[0], email=email, fullname=user_info.get('name', ''), - role=0, + role=ROLE_OWNER, # Give owner role to new users pwhash='', ) user_db.session.add(user) user_db.session.commit() except IntegrityError: - return {"error": "User creation failed"}, 400 + return jsonify({"error": "User creation failed"}), 400 - # Create JWT tokens - access_token = create_access_token(identity=str(user.id)) + # Get user's tree and permissions + tree_id = get_tree_id(str(user.id)) + permissions = get_permissions(username=user.name, tree=tree_id) + + # Create JWT tokens with proper claims + access_token = create_access_token( + identity=str(user.id), + additional_claims={ + "permissions": list(permissions), + "tree": tree_id + } + ) refresh_token = create_refresh_token(identity=str(user.id)) - # Return tokens and user info - return { + # Get the frontend redirect URL from the state parameter + state = request.args.get('state', '') + frontend_redirect = state if state else current_app.config.get('FRONTEND_URL', '/') + + # Return tokens in standard OAuth2 format + response = { "access_token": access_token, "refresh_token": refresh_token, + "token_type": "Bearer", + "expires_in": current_app.config.get('JWT_ACCESS_TOKEN_EXPIRES', 900), # 15 minutes default "user": { "name": user.name, "email": user.email, "full_name": user.fullname, - "role": user.role + "role": user.role, + "tree": tree_id } } + + # If this is an API request (has Accept: application/json header) + if request.headers.get('Accept') == 'application/json': + return jsonify(response) + + # For browser requests, redirect to frontend with tokens + return redirect(f"{frontend_redirect}?access_token={access_token}&refresh_token={refresh_token}") From 2ae7b44012baade6c49cbba6ea8b53c1afda698c Mon Sep 17 00:00:00 2001 From: yuriBean Date: Wed, 11 Jun 2025 23:17:12 +0500 Subject: [PATCH 6/7] WIP: OIDC integration draft with Authlib --- gramps_webapi/app.py | 182 +++++++++++-------------------------- gramps_webapi/auth/oidc.py | 160 ++++++++++++++++++-------------- gramps_webapi/config.py | 25 +++++ requirements-dev.txt | 2 +- 4 files changed, 169 insertions(+), 200 deletions(-) diff --git a/gramps_webapi/app.py b/gramps_webapi/app.py index f1267261..d5c7866e 100644 --- a/gramps_webapi/app.py +++ b/gramps_webapi/app.py @@ -1,192 +1,156 @@ -# -# Gramps Web API - A RESTful API for the Gramps genealogy program -# -# Copyright (C) 2020-2022 David Straub -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation; either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -# - """Flask web app providing a REST API to a Gramps family tree.""" + import logging import os import warnings from typing import Any, Dict, Optional -from gramps_webapi.auth.oidc import configure_oauth, oidc_bp -from dotenv import load_dotenv -from flask import Flask, abort, g, send_from_directory, session + +from flask import Flask, abort, g, send_from_directory from flask_compress import Compress from flask_cors import CORS from flask_jwt_extended import JWTManager from gramps.gen.config import config as gramps_config from gramps.gen.config import set as setconfig + from .api import api_blueprint -from .api.cache import thumbnail_cache +from .api.cache import thumbnail_cache, request_cache from .api.ratelimiter import limiter from .api.search.embeddings import load_model from .api.util import close_db from .auth import user_db +from .auth.oidc import configure_oauth, oidc_bp from .config import DefaultConfig, DefaultConfigJWT from .const import API_PREFIX, ENV_CONFIG_FILE, TREE_MULTI from .dbmanager import WebDbManager from .util.celery import create_celery -load_dotenv() def deprecated_config_from_env(app): - """Add deprecated config from environment variables. - - This function will be removed eventually! - """ + """Add deprecated config from environment variables (legacy support).""" options = [ - "TREE", - "SECRET_KEY", - "USER_DB_URI", - "POSTGRES_USER", - "POSTGRES_PASSWORD", - "MEDIA_BASE_DIR", - "SEARCH_INDEX_DIR", - "EMAIL_HOST", - "EMAIL_PORT", - "EMAIL_HOST_USER", - "EMAIL_HOST_PASSWORD", - "DEFAULT_FROM_EMAIL", - "BASE_URL", - "STATIC_PATH", + "TREE", "SECRET_KEY", "USER_DB_URI", "POSTGRES_USER", "POSTGRES_PASSWORD", + "MEDIA_BASE_DIR", "SEARCH_INDEX_DIR", "EMAIL_HOST", "EMAIL_PORT", + "EMAIL_HOST_USER", "EMAIL_HOST_PASSWORD", "DEFAULT_FROM_EMAIL", + "BASE_URL", "STATIC_PATH", ] for option in options: value = os.getenv(option) if value: app.config[option] = value warnings.warn( - f"Setting the `{option}` config option via the `{option}` environment" - " variable is deprecated and will stop working in the future." - f" Please use `GRAMPSWEB_{option}` instead." + f"Setting `{option}` via the environment is deprecated. " + f"Use `GRAMPSWEB_{option}` instead." ) return app -def create_app(config: Optional[Dict[str, Any]] = None): +def create_app(config: Optional[Dict[str, Any]] = None, config_from_env: bool = True): """Flask application factory.""" app = Flask(__name__) - app.secret_key = os.getenv("SECRET_KEY") app.logger.setLevel(logging.INFO) - # load default config + app.config.from_object(DefaultConfig) - # overwrite with user config file + if os.getenv(ENV_CONFIG_FILE): app.config.from_envvar(ENV_CONFIG_FILE) - # use unprefixed environment variables if exist - deprecated! + deprecated_config_from_env(app) - # use prefixed environment variables if exist - app.config.from_prefixed_env(prefix="GRAMPSWEB") - # update config from dictionary if present + if config_from_env: + app.config.from_prefixed_env(prefix="GRAMPSWEB") + + if config: app.config.update(**config) - # fail if required config option is missing + required_options = ["TREE", "SECRET_KEY", "USER_DB_URI"] for option in required_options: if not app.config.get(option): raise ValueError(f"{option} must be specified") - # environment variable to set the Gramps database path. - # Needed for backwards compatibility from Gramps 6.0 onwards + if db_path := os.getenv("GRAMPS_DATABASE_PATH"): setconfig("database.path", db_path) + if app.config.get("LOG_LEVEL"): app.logger.setLevel(app.config["LOG_LEVEL"]) + if app.config["TREE"] != TREE_MULTI: - # create database if missing (only in single-tree mode) WebDbManager( name=app.config["TREE"], create_if_missing=True, ignore_lock=app.config["IGNORE_DB_LOCK"], ) + if app.config["TREE"] == TREE_MULTI and not app.config["MEDIA_PREFIX_TREE"]: - warnings.warn( - "You have enabled multi-tree support, but `MEDIA_PREFIX_TREE` is " - "set to `False`. This is strongly discouraged as it exposes media " - "files to users belonging to different trees!" - ) + warnings.warn("Multi-tree mode is enabled but MEDIA_PREFIX_TREE is False.") + if app.config["TREE"] == TREE_MULTI and app.config["NEW_DB_BACKEND"] != "sqlite": - # needed in case a new postgres tree is to be created gramps_config.set("database.host", app.config["POSTGRES_HOST"]) gramps_config.set("database.port", str(app.config["POSTGRES_PORT"])) - # load JWT default settings - app.config.from_object(DefaultConfigJWT) - # instantiate JWT manager + app.config.from_object(DefaultConfigJWT) JWTManager(app) + app.config["SQLALCHEMY_DATABASE_URI"] = app.config["USER_DB_URI"] user_db.init_app(app) + + request_cache.init_app(app, config=app.config["REQUEST_CACHE_CONFIG"]) + + configure_oauth(app) app.register_blueprint(oidc_bp) - + + thumbnail_cache.init_app(app, config=app.config["THUMBNAIL_CACHE_CONFIG"]) - # enable CORS for /api/... resources + if app.config.get("CORS_ORIGINS"): - CORS( - app, - resources={f"{API_PREFIX}/*": {"origins": app.config["CORS_ORIGINS"]}}, - ) + CORS(app, resources={f"{API_PREFIX}/*": {"origins": app.config["CORS_ORIGINS"]}}) + - # enable gzip compression Compress(app) + static_path = app.config.get("STATIC_PATH") - # routes for static hosting (e.g. SPA frontend) + @app.route("/", methods=["GET", "POST"]) def send_index(): return send_from_directory(static_path, "index.html") + @app.route("/", methods=["GET", "POST"]) def send_static(path): if path.startswith(API_PREFIX[1:]): - # we don't want any erroneous API calls to end up here! abort(404) if path and os.path.exists(os.path.join(static_path, path)): return send_from_directory(static_path, path) - else: - return send_from_directory(static_path, "index.html") + return send_from_directory(static_path, "index.html") + - # register the API blueprint app.register_blueprint(api_blueprint) limiter.init_app(app) - - # instantiate celery create_celery(app) + @app.teardown_appcontext def close_db_connection(exception) -> None: - """Close the Gramps database after every request.""" db = g.pop("db", None) if db: close_db(db) @@ -194,64 +158,24 @@ def close_db_connection(exception) -> None: if db_write: close_db(db_write) + @app.teardown_request def close_user_db_connection(exception) -> None: - """Close the user database after every request.""" if exception: - user_db.session.rollback() # pylint: disable=no-member - user_db.session.close() # pylint: disable=no-member - user_db.session.remove() # pylint: disable=no-member + user_db.session.rollback() + user_db.session.close() + user_db.session.remove() + if app.config.get("VECTOR_EMBEDDING_MODEL"): app.config["_INITIALIZED_VECTOR_EMBEDDING_MODEL"] = load_model( app.config["VECTOR_EMBEDDING_MODEL"] ) + @app.route("/ready", methods=["GET"]) def ready(): return {"status": "ready"}, 200 - @oidc_bp.route("/callback/") - def authorize(provider): - client = oauth.create_client(provider) - token = client.authorize_access_token() - oidc_user = client.parse_id_token(token) if provider == "google" else client.get('user').json() - - # Get email from OIDC user info - email = oidc_user.get('email') - if not email: - return {"error": "No email provided by OIDC provider"}, 400 - - # Check if user exists - query = user_db.session.query(User) - user = query.filter_by(email=email).scalar() - - if not user: - # Create new user with default role - try: - user = User( - id=uuid.uuid4(), - name=email.split('@')[0], # Use part before @ as username - email=email, - fullname=oidc_user.get('name', ''), - role=0, # Default role - pwhash='', # No password for OIDC users - ) - user_db.session.add(user) - user_db.session.commit() - except IntegrityError: - return {"error": "User creation failed"}, 400 - - # Set up session - session['user_id'] = str(user.id) - session['user_name'] = user.name - session['user_role'] = user.role - - return {"status": "logged_in", "provider": provider, "user": { - "name": user.name, - "email": user.email, - "full_name": user.fullname, - "role": user.role - }} - - return app \ No newline at end of file + + return app diff --git a/gramps_webapi/auth/oidc.py b/gramps_webapi/auth/oidc.py index f9e2d1c7..39e06bbf 100644 --- a/gramps_webapi/auth/oidc.py +++ b/gramps_webapi/auth/oidc.py @@ -1,104 +1,125 @@ -import os +import uuid from authlib.integrations.flask_client import OAuth from flask import Blueprint, redirect, url_for, request, current_app, jsonify from flask_jwt_extended import create_access_token, create_refresh_token -from dotenv import load_dotenv from sqlalchemy.exc import IntegrityError -import uuid + from . import user_db, User -from .const import ROLE_OWNER +from .const import ROLE_USER from ..api.util import get_tree_id from ..api.auth import get_permissions -load_dotenv() oauth = OAuth() - oidc_bp = Blueprint("oidc", __name__, url_prefix="/auth") + def configure_oauth(app): + if not app.config.get("OAUTH_ENABLED", False): + return + + oauth.init_app(app) - oauth.register( - name='google', - client_id=os.getenv("GOOGLE_CLIENT_ID"), - client_secret=os.getenv("GOOGLE_CLIENT_SECRET"), - access_token_url='https://oauth2.googleapis.com/token', - authorize_url='https://accounts.google.com/o/oauth2/auth', - api_base_url='https://www.googleapis.com/oauth2/v1/', - client_kwargs={'scope': 'openid email profile'}, - ) - oauth.register( - name='github', - client_id=os.getenv("GITHUB_CLIENT_ID"), - client_secret=os.getenv("GITHUB_CLIENT_SECRET"), - access_token_url='https://github.com/login/oauth/access_token', - authorize_url='https://github.com/login/oauth/authorize', - api_base_url='https://api.github.com/', - client_kwargs={'scope': 'read:user user:email'}, - ) + if app.config.get("OAUTH_GOOGLE_CLIENT_ID") and app.config.get("OAUTH_GOOGLE_CLIENT_SECRET"): + oauth.register( + name="google", + client_id=app.config["OAUTH_GOOGLE_CLIENT_ID"], + client_secret=app.config["OAUTH_GOOGLE_CLIENT_SECRET"], + access_token_url="https://oauth2.googleapis.com/token", + authorize_url="https://accounts.google.com/o/oauth2/auth", + api_base_url="https://www.googleapis.com/oauth2/v1/", + client_kwargs={"scope": "openid email profile"}, + ) + + + if app.config.get("OAUTH_GITHUB_CLIENT_ID") and app.config.get("OAUTH_GITHUB_CLIENT_SECRET"): + oauth.register( + name="github", + client_id=app.config["OAUTH_GITHUB_CLIENT_ID"], + client_secret=app.config["OAUTH_GITHUB_CLIENT_SECRET"], + access_token_url="https://github.com/login/oauth/access_token", + authorize_url="https://github.com/login/oauth/authorize", + api_base_url="https://api.github.com/", + client_kwargs={"scope": "read:user user:email"}, + ) + + + if app.config.get("OAUTH_MICROSOFT_CLIENT_ID") and app.config.get("OAUTH_MICROSOFT_CLIENT_SECRET"): + oauth.register( + name="microsoft", + client_id=app.config["OAUTH_MICROSOFT_CLIENT_ID"], + client_secret=app.config["OAUTH_MICROSOFT_CLIENT_SECRET"], + access_token_url="https://login.microsoftonline.com/common/oauth2/v2.0/token", + authorize_url="https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + api_base_url="https://graph.microsoft.com/v1.0/", + client_kwargs={"scope": "openid email profile"}, + ) - oauth.register( - name='microsoft', - client_id=os.getenv("MICROSOFT_CLIENT_ID"), - client_secret=os.getenv("MICROSOFT_CLIENT_SECRET"), - access_token_url='https://login.microsoftonline.com/common/oauth2/v2.0/token', - authorize_url='https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - api_base_url='https://graph.microsoft.com/v1.0/', - client_kwargs={'scope': 'openid email profile'}, - ) @oidc_bp.route("/login/") def login(provider): - """Start the OAuth/OIDC login flow.""" - # Get the redirect URL from the request or use a default - redirect_uri = request.args.get('redirect_uri', url_for("oidc.authorize", provider=provider, _external=True)) + if not current_app.config.get("OAUTH_ENABLED", False): + return jsonify({"error": "OAuth is not enabled"}), 403 + + + redirect_uri = request.args.get("redirect_uri", url_for("oidc.authorize", provider=provider, _external=True)) return oauth.create_client(provider).authorize_redirect(redirect_uri) + @oidc_bp.route("/callback/") def authorize(provider): - """Handle the OAuth/OIDC callback and create/link user account.""" + if not current_app.config.get("OAUTH_ENABLED", False): + return jsonify({"error": "OAuth is not enabled"}), 403 + + client = oauth.create_client(provider) token = client.authorize_access_token() - - # Get user info from provider + + if provider == "google": user_info = client.parse_id_token(token) else: - user_info = client.get('user').json() - - # Get email from OIDC user info - email = user_info.get('email') + user_info = client.get("user").json() + + + email = user_info.get("email") if not email: - return jsonify({"error": "No email provided by OIDC provider"}), 400 - - # Check if user exists - query = user_db.session.query(User) - user = query.filter_by(email=email).scalar() - + return jsonify({"error": "No email provided"}), 400 + + + user = user_db.session.query(User).filter_by(email=email).first() + + if not user: - # Create new user with default role + tree_id = get_tree_id("default") # You can replace this logic as needed + + + if not current_app.config.get("ALLOW_OIDC_REGISTRATION", True): + return jsonify({"error": "User registration is disabled"}), 403 + + try: user = User( id=uuid.uuid4(), - name=email.split('@')[0], + name=email.split("@")[0], email=email, - fullname=user_info.get('name', ''), - role=ROLE_OWNER, # Give owner role to new users - pwhash='', + fullname=user_info.get("name", ""), + role=ROLE_USER, + pwhash="", ) user_db.session.add(user) user_db.session.commit() except IntegrityError: return jsonify({"error": "User creation failed"}), 400 - - # Get user's tree and permissions + + tree_id = get_tree_id(str(user.id)) permissions = get_permissions(username=user.name, tree=tree_id) - - # Create JWT tokens with proper claims + + access_token = create_access_token( identity=str(user.id), additional_claims={ @@ -107,17 +128,16 @@ def authorize(provider): } ) refresh_token = create_refresh_token(identity=str(user.id)) - - # Get the frontend redirect URL from the state parameter - state = request.args.get('state', '') - frontend_redirect = state if state else current_app.config.get('FRONTEND_URL', '/') - - # Return tokens in standard OAuth2 format + + + frontend_redirect = request.args.get("state") or current_app.config.get("FRONTEND_URL", "/") + + response = { "access_token": access_token, "refresh_token": refresh_token, "token_type": "Bearer", - "expires_in": current_app.config.get('JWT_ACCESS_TOKEN_EXPIRES', 900), # 15 minutes default + "expires_in": current_app.config.get("JWT_ACCESS_TOKEN_EXPIRES", 900), "user": { "name": user.name, "email": user.email, @@ -126,10 +146,10 @@ def authorize(provider): "tree": tree_id } } - - # If this is an API request (has Accept: application/json header) - if request.headers.get('Accept') == 'application/json': + + + if request.headers.get("Accept") == "application/json": return jsonify(response) - - # For browser requests, redirect to frontend with tokens + + return redirect(f"{frontend_redirect}?access_token={access_token}&refresh_token={refresh_token}") diff --git a/gramps_webapi/config.py b/gramps_webapi/config.py index fd64a0ab..fc9b0c28 100644 --- a/gramps_webapi/config.py +++ b/gramps_webapi/config.py @@ -17,16 +17,21 @@ # along with this program. If not, see . # + """Default configuration settings.""" + import datetime from pathlib import Path from typing import Dict + + class DefaultConfig(object): """Default configuration object.""" + PROPAGATE_EXCEPTIONS = True SEARCH_INDEX_DIR = "indexdir" # deprecated! SEARCH_INDEX_DB_URI = "" @@ -51,6 +56,12 @@ class DefaultConfig(object): "CACHE_THRESHOLD": 1000, "CACHE_DEFAULT_TIMEOUT": 0, } + PERSISTENT_CACHE_CONFIG = { + "CACHE_TYPE": "FileSystemCache", + "CACHE_DIR": str(Path.cwd() / "persistent_cache"), + "CACHE_THRESHOLD": 0, + "CACHE_DEFAULT_TIMEOUT": 0, + } POSTGRES_USER = None POSTGRES_PASSWORD = None POSTGRES_HOST = "localhost" @@ -69,11 +80,25 @@ class DefaultConfig(object): LLM_MODEL = "" LLM_MAX_CONTEXT_LENGTH = 50000 VECTOR_EMBEDDING_MODEL = "" + DISABLE_TELEMETRY = False + + + # OAuth configuration + OAUTH_GOOGLE_CLIENT_ID = "" + OAUTH_GOOGLE_CLIENT_SECRET = "" + OAUTH_GITHUB_CLIENT_ID = "" + OAUTH_GITHUB_CLIENT_SECRET = "" + OAUTH_MICROSOFT_CLIENT_ID = "" + OAUTH_MICROSOFT_CLIENT_SECRET = "" + OAUTH_ENABLED = False # Master switch for OAuth functionality + + class DefaultConfigJWT(object): """Default configuration for JWT auth.""" + JWT_TOKEN_LOCATION = ["headers", "query_string"] JWT_ACCESS_TOKEN_EXPIRES = datetime.timedelta(minutes=15) JWT_REFRESH_TOKEN_EXPIRES = False diff --git a/requirements-dev.txt b/requirements-dev.txt index 80c00984..25aec3dd 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,4 +8,4 @@ pre-commit celery[pytest] moto[s3]<5.0.0 PyYAML -authlib +authlib \ No newline at end of file From a93999d00cc66739bf70ada231ffb282705d292f Mon Sep 17 00:00:00 2001 From: yuriBean Date: Thu, 12 Jun 2025 11:40:26 +0500 Subject: [PATCH 7/7] Harmonize OIDC registration with main user registration checks and error handling --- gramps_webapi/app.py | 81 +++++++++++++++++++++++++++----------- gramps_webapi/auth/oidc.py | 61 ++++++++++++++-------------- pyproject.toml | 1 + requirements-dev.txt | 3 +- 4 files changed, 92 insertions(+), 54 deletions(-) diff --git a/gramps_webapi/app.py b/gramps_webapi/app.py index d5c7866e..86107e4b 100644 --- a/gramps_webapi/app.py +++ b/gramps_webapi/app.py @@ -1,4 +1,21 @@ -"""Flask web app providing a REST API to a Gramps family tree.""" +# +# Gramps Web API - A RESTful API for the Gramps genealogy program +# +# Copyright (C) 2020-2022 David Straub +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# import logging @@ -29,7 +46,10 @@ def deprecated_config_from_env(app): - """Add deprecated config from environment variables (legacy support).""" + """Add deprecated config from environment variables. + + This function will be removed eventually! + """ options = [ "TREE", "SECRET_KEY", "USER_DB_URI", "POSTGRES_USER", "POSTGRES_PASSWORD", "MEDIA_BASE_DIR", "SEARCH_INDEX_DIR", "EMAIL_HOST", "EMAIL_PORT", @@ -41,8 +61,9 @@ def deprecated_config_from_env(app): if value: app.config[option] = value warnings.warn( - f"Setting `{option}` via the environment is deprecated. " - f"Use `GRAMPSWEB_{option}` instead." + f"Setting the `{option}` config option via the `{option}` environment" + " variable is deprecated and will stop working in the future." + f" Please use `GRAMPSWEB_{option}` instead." ) return app @@ -52,31 +73,32 @@ def create_app(config: Optional[Dict[str, Any]] = None, config_from_env: bool = app = Flask(__name__) app.logger.setLevel(logging.INFO) - + # load default config app.config.from_object(DefaultConfig) - + # overwrite with user config file if os.getenv(ENV_CONFIG_FILE): app.config.from_envvar(ENV_CONFIG_FILE) - + # use unprefixed environment variables if exist - deprecated! deprecated_config_from_env(app) - + # use prefixed environment variables if exist if config_from_env: app.config.from_prefixed_env(prefix="GRAMPSWEB") - + # update config from dictionary if present if config: app.config.update(**config) - + # fail if required config option is missing required_options = ["TREE", "SECRET_KEY", "USER_DB_URI"] for option in required_options: if not app.config.get(option): raise ValueError(f"{option} must be specified") - + # environment variable to set the Gramps database path. + # Needed for backwards compatibility from Gramps 6.0 onwards if db_path := os.getenv("GRAMPS_DATABASE_PATH"): setconfig("database.path", db_path) @@ -86,6 +108,7 @@ def create_app(config: Optional[Dict[str, Any]] = None, config_from_env: bool = if app.config["TREE"] != TREE_MULTI: + # create database if missing (only in single-tree mode) WebDbManager( name=app.config["TREE"], create_if_missing=True, @@ -94,14 +117,19 @@ def create_app(config: Optional[Dict[str, Any]] = None, config_from_env: bool = if app.config["TREE"] == TREE_MULTI and not app.config["MEDIA_PREFIX_TREE"]: - warnings.warn("Multi-tree mode is enabled but MEDIA_PREFIX_TREE is False.") + warnings.warn( + "You have enabled multi-tree support, but `MEDIA_PREFIX_TREE` is " + "set to `False`. This is strongly discouraged as it exposes media " + "files to users belonging to different trees!" + ) if app.config["TREE"] == TREE_MULTI and app.config["NEW_DB_BACKEND"] != "sqlite": + # needed in case a new postgres tree is to be created gramps_config.set("database.host", app.config["POSTGRES_HOST"]) gramps_config.set("database.port", str(app.config["POSTGRES_PORT"])) - + # load JWT default settings app.config.from_object(DefaultConfigJWT) JWTManager(app) @@ -119,17 +147,20 @@ def create_app(config: Optional[Dict[str, Any]] = None, config_from_env: bool = thumbnail_cache.init_app(app, config=app.config["THUMBNAIL_CACHE_CONFIG"]) - +# enable CORS for /api/... resources if app.config.get("CORS_ORIGINS"): - CORS(app, resources={f"{API_PREFIX}/*": {"origins": app.config["CORS_ORIGINS"]}}) - + CORS( + app, + resources={f"{API_PREFIX}/*": {"origins": app.config["CORS_ORIGINS"]}}, + ) + # enable gzip compression Compress(app) static_path = app.config.get("STATIC_PATH") - + # routes for static hosting (e.g. SPA frontend) @app.route("/", methods=["GET", "POST"]) def send_index(): return send_from_directory(static_path, "index.html") @@ -138,19 +169,24 @@ def send_index(): @app.route("/", methods=["GET", "POST"]) def send_static(path): if path.startswith(API_PREFIX[1:]): + # we don't want any erroneous API calls to end up here! abort(404) if path and os.path.exists(os.path.join(static_path, path)): return send_from_directory(static_path, path) - return send_from_directory(static_path, "index.html") - + else: + return send_from_directory(static_path, "index.html") + # register the API blueprint app.register_blueprint(api_blueprint) limiter.init_app(app) + + # instantiate celery create_celery(app) @app.teardown_appcontext def close_db_connection(exception) -> None: + """Close the Gramps database after every request.""" db = g.pop("db", None) if db: close_db(db) @@ -161,10 +197,11 @@ def close_db_connection(exception) -> None: @app.teardown_request def close_user_db_connection(exception) -> None: + """Close the user database after every request.""" if exception: - user_db.session.rollback() - user_db.session.close() - user_db.session.remove() + user_db.session.rollback() # pylint: disable=no-member + user_db.session.close() # pylint: disable=no-member + user_db.session.remove() # pylint: disable=no-member if app.config.get("VECTOR_EMBEDDING_MODEL"): diff --git a/gramps_webapi/auth/oidc.py b/gramps_webapi/auth/oidc.py index 39e06bbf..1a4db580 100644 --- a/gramps_webapi/auth/oidc.py +++ b/gramps_webapi/auth/oidc.py @@ -5,10 +5,11 @@ from sqlalchemy.exc import IntegrityError -from . import user_db, User +from . import user_db, User, add_user from .const import ROLE_USER -from ..api.util import get_tree_id +from ..api.util import get_tree_id, abort_with_message, tree_exists from ..api.auth import get_permissions +from ..const import TREE_MULTI oauth = OAuth() @@ -62,7 +63,7 @@ def configure_oauth(app): @oidc_bp.route("/login/") def login(provider): if not current_app.config.get("OAUTH_ENABLED", False): - return jsonify({"error": "OAuth is not enabled"}), 403 + abort_with_message(403, "OAuth is not enabled") redirect_uri = request.args.get("redirect_uri", url_for("oidc.authorize", provider=provider, _external=True)) @@ -72,54 +73,58 @@ def login(provider): @oidc_bp.route("/callback/") def authorize(provider): if not current_app.config.get("OAUTH_ENABLED", False): - return jsonify({"error": "OAuth is not enabled"}), 403 - + abort_with_message(403, "OAuth is not enabled") client = oauth.create_client(provider) token = client.authorize_access_token() - if provider == "google": user_info = client.parse_id_token(token) else: user_info = client.get("user").json() - email = user_info.get("email") if not email: - return jsonify({"error": "No email provided"}), 400 - + abort_with_message(400, "No email provided") user = user_db.session.query(User).filter_by(email=email).first() - - if not user: - tree_id = get_tree_id("default") # You can replace this logic as needed - - + # Enforce OIDC registration enable/disable if not current_app.config.get("ALLOW_OIDC_REGISTRATION", True): - return jsonify({"error": "User registration is disabled"}), 403 + abort_with_message(403, "User registration is disabled") + # Determine the tree to use + if current_app.config["TREE"] == TREE_MULTI: + # In multi-tree, OIDC must know which tree to assign! + abort_with_message(422, "tree is required for OIDC registration in multi-tree mode") + tree_id = current_app.config["TREE"] + if not tree_exists(tree_id): + abort_with_message(422, "Tree does not exist") try: - user = User( - id=uuid.uuid4(), - name=email.split("@")[0], + username = email.split("@", 1)[0] + dummy_password = uuid.uuid4().hex # Satisfy non-empty password check + add_user( + name=username, + password=dummy_password, email=email, fullname=user_info.get("name", ""), role=ROLE_USER, - pwhash="", + tree=tree_id ) - user_db.session.add(user) - user_db.session.commit() - except IntegrityError: - return jsonify({"error": "User creation failed"}), 400 - - + user = user_db.session.query(User).filter_by(email=email).first() + if not user: + abort_with_message(500, "Failed to create user") + except ValueError as exc: + # Consistent error codes: 409 for conflict, 400 otherwise + msg = str(exc) + code = 409 if "exists" in msg.lower() else 400 + abort_with_message(code, msg) + + # Now continue as usual tree_id = get_tree_id(str(user.id)) permissions = get_permissions(username=user.name, tree=tree_id) - access_token = create_access_token( identity=str(user.id), additional_claims={ @@ -129,10 +134,8 @@ def authorize(provider): ) refresh_token = create_refresh_token(identity=str(user.id)) - frontend_redirect = request.args.get("state") or current_app.config.get("FRONTEND_URL", "/") - response = { "access_token": access_token, "refresh_token": refresh_token, @@ -147,9 +150,7 @@ def authorize(provider): } } - if request.headers.get("Accept") == "application/json": return jsonify(response) - return redirect(f"{frontend_redirect}?access_token={access_token}&refresh_token={refresh_token}") diff --git a/pyproject.toml b/pyproject.toml index 38ad8801..1785e881 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ "gramps-ql>=0.4.0", "object-ql>=0.1.3", "sifts>=0.8.3", + "authlib", ] [project.optional-dependencies] diff --git a/requirements-dev.txt b/requirements-dev.txt index 25aec3dd..169f3b47 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,5 +7,4 @@ pydocstyle pre-commit celery[pytest] moto[s3]<5.0.0 -PyYAML -authlib \ No newline at end of file +PyYAML \ No newline at end of file