diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 3d4db7260..69ff813f1 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7] + python-version: ["3.10"] steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v3 @@ -53,7 +53,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7] + python-version: ["3.10"] steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v3 diff --git a/k8s/Chart.yaml b/k8s/Chart.yaml index e995e1d2e..aa7fe32d2 100644 --- a/k8s/Chart.yaml +++ b/k8s/Chart.yaml @@ -12,7 +12,7 @@ version: 0.8.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. -appVersion: 0.8.0 +appVersion: 0.11.0 # Chart dependencies dependencies: diff --git a/lifemonitor/app.py b/lifemonitor/app.py index edb8ceb13..08d220fdb 100644 --- a/lifemonitor/app.py +++ b/lifemonitor/app.py @@ -25,14 +25,15 @@ from flask import Flask, jsonify, redirect, render_template, request, url_for from flask_cors import CORS from flask_migrate import Migrate -from lifemonitor import redis import lifemonitor.config as config +from lifemonitor import redis from lifemonitor.auth.services import current_user from lifemonitor.integrations import init_integrations from lifemonitor.metrics import init_metrics from lifemonitor.routes import register_routes from lifemonitor.tasks import init_task_queues +from lifemonitor.utils import get_domain from . import commands from .cache import init_cache @@ -101,7 +102,7 @@ def health(): @app.route("/openapi.html") def openapi(): - return redirect('/static/apidocs.html') + return redirect('/static/specs/apidocs.html', code=302) @app.before_request def set_request_start_time(): @@ -110,6 +111,14 @@ def set_request_start_time(): @app.after_request def log_response(response): logger = logging.getLogger("response") + # logger.debug("Current user: %s", current_user) + # logger.debug("request: %s %s %s %s %s %s", + # request.remote_addr, request.method, request.path, + # request.scheme, request.full_path, request.referrer, + # ) + # for h in request.headers: + # logger.debug("header: %s %s", h, request.headers.get(h, None)) + # log the request processing_time = (time.time() * 1000.0 - request.start_time * 1000.0) logger.info( "resp: %s %s %s %s %s %s %s %s %0.3fms", @@ -123,6 +132,7 @@ def log_response(response): request.user_agent, processing_time ) + # return the response return response return app @@ -158,3 +168,5 @@ def initialize_app(app: Flask, app_context, prom_registry=None, load_jobs: bool init_metrics(app, prom_registry) # register commands commands.register_commands(app) + # register the domain filter with Jinja + app.jinja_env.filters['domain'] = get_domain diff --git a/lifemonitor/auth/__init__.py b/lifemonitor/auth/__init__.py index 385ed74a3..c6b66cfd1 100644 --- a/lifemonitor/auth/__init__.py +++ b/lifemonitor/auth/__init__.py @@ -22,7 +22,7 @@ import lifemonitor.auth.oauth2 as oauth2 -from .controllers import blueprint as auth_blueprint +from .controllers import blueprint as auth_blueprint, CustomSessionInterface from .models import EventType, Notification, User, UserNotification from .services import (NotAuthorizedException, authorized, current_registry, current_user, login_manager, login_registry, login_user, @@ -37,6 +37,7 @@ def register_api(app, specs_dir): oauth2.client.register_api(app, specs_dir, "auth.merge") oauth2.server.register_api(app, specs_dir) app.register_blueprint(auth_blueprint) + app.session_interface = CustomSessionInterface() login_manager.init_app(app) diff --git a/lifemonitor/auth/controllers.py b/lifemonitor/auth/controllers.py index dfa49a652..9dfef441d 100644 --- a/lifemonitor/auth/controllers.py +++ b/lifemonitor/auth/controllers.py @@ -24,6 +24,7 @@ import flask from flask import (current_app, flash, redirect, render_template, request, session, url_for) +from flask.sessions import SecureCookieSessionInterface from flask_login import login_required, login_user, logout_user from lifemonitor.cache import Timeout, cached, clear_cache @@ -162,7 +163,7 @@ def get_registry_user(user_id): @blueprint.route("/", methods=("GET",)) def index(): - return redirect(url_for('auth.profile', back=request.args.get('back', False))) + return redirect(url_for('auth.profile', back=request.args.get('back', None))) @blueprint.route("/profile", methods=("GET",)) @@ -177,7 +178,8 @@ def profile(form=None, passwordForm=None, currentView=None, logger.debug("Pushing back param to session") else: logger.debug("Getting back param from session") - back_param = back_param or session.get('lm_back_param', False) + back_param = back_param or session.get('lm_back_param', None) + session['lm_back_param'] = back_param logger.debug("detected back param: %s", back_param) from lifemonitor.api.models.registries.forms import RegistrySettingsForm from lifemonitor.integrations.github.forms import GithubSettingsForm @@ -212,7 +214,7 @@ def register(): login_user(user) flash("Account created", category="success") clear_cache() - return redirect(url_for("auth.index")) + return redirect(url_for("auth.profile")) return render_template("auth/register.j2", form=form, action=url_for('auth.register'), providers=get_providers(), is_service_available=is_service_alive) @@ -228,7 +230,6 @@ def identity_not_found(): form = RegisterForm() user = identity.user # workaround to force clean DB session - from lifemonitor.db import db db.session.rollback() return render_template("auth/identity_not_found.j2", form=form, action=url_for('auth.register_identity') if flask.session.get('sign_in', False) else url_for('auth.register'), @@ -242,7 +243,7 @@ def register_identity(): logger.debug("Current provider identity: %r", identity) if not identity: flash("Unable to register the user") - flask.abort(400) + return redirect(url_for("auth.register")) logger.debug("Provider identity on session: %r", identity) logger.debug("User Info: %r", identity.user_info) user = identity.user @@ -254,7 +255,7 @@ def register_identity(): flash("Account created", category="success") clear_cache() return redirect(url_for("auth.index")) - return render_template("auth/register.j2", form=form, action=url_for('auth.register'), + return render_template("auth/register.j2", form=form, action=url_for('auth.register_identity'), identity=identity, user=user, providers=get_providers()) @@ -272,6 +273,7 @@ def login(): session.pop('_flashes', None) flash("You have logged in", category="success") return redirect(NextRouteRegistry.pop(url_for("auth.profile"))) + flask.session['lm_back_param'] = flask.request.args.get('back', None) return render_template("auth/login.j2", form=form, providers=get_providers(), is_service_available=is_service_alive) @@ -281,9 +283,10 @@ def login(): def logout(): logout_user() session.pop('_flashes', None) + back_param = session.pop('lm_back_param', None) flash("You have logged out", category="success") NextRouteRegistry.clear() - next_route = request.args.get('next', '/') + next_route = request.args.get('next', '/logout' if back_param else '/') logger.debug("Next route after logout: %r", next_route) return redirect(next_route) @@ -606,3 +609,16 @@ def delete_generic_code_flow_client(): flash("App removed!", category="success") clear_cache() return redirect(url_for('auth.profile', currentView='oauth2ClientsTab')) + + +class CustomSessionInterface(SecureCookieSessionInterface): + """Prevent creating session from API requests.""" + + def save_session(self, *args, **kwargs): + + if flask.g.get('login_via_request'): + logger.debug("Prevent creating session from API requests") + return + # if login_via_request is not set, then create a new session + logger.debug("Saving session") + return super(CustomSessionInterface, self).save_session(*args, **kwargs) diff --git a/lifemonitor/auth/forms.py b/lifemonitor/auth/forms.py index 18794a9dd..46df3418f 100644 --- a/lifemonitor/auth/forms.py +++ b/lifemonitor/auth/forms.py @@ -84,7 +84,8 @@ def create_user(self, identity=None): db.session.commit() return user except IntegrityError as e: - logger.debug(e) + if logger.isEnabledFor(logging.DEBUG): + logger.exception(e) self.username.errors.append("This username is already taken") db.session.rollback() return None diff --git a/lifemonitor/auth/oauth2/client/controllers.py b/lifemonitor/auth/oauth2/client/controllers.py index 9ba70b8fb..3734f456d 100644 --- a/lifemonitor/auth/oauth2/client/controllers.py +++ b/lifemonitor/auth/oauth2/client/controllers.py @@ -135,7 +135,7 @@ def logout(name: str): # Determine the right next hop next_url = NextRouteRegistry.pop() return redirect(next_url, code=307) if next_url \ - else RequestHelper.response() or redirect('/', code=302) + else RequestHelper.response() or redirect('/account', code=302) return blueprint @@ -261,7 +261,7 @@ def handle_authorize(self, provider: FlaskRemoteApp, token, user_info: OAuthUser next_url = NextRouteRegistry.pop() flash(f"Logged with your \"{identity.provider.name}\" identity.", category="success") return redirect(next_url, code=307) if next_url \ - else RequestHelper.response() or redirect('/', code=302) + else RequestHelper.response() or redirect('/account', code=302) @staticmethod def validate_identity_token(identity: OAuthIdentity): diff --git a/lifemonitor/auth/services.py b/lifemonitor/auth/services.py index 753dd9cd8..3ed1cee34 100644 --- a/lifemonitor/auth/services.py +++ b/lifemonitor/auth/services.py @@ -24,11 +24,12 @@ from functools import wraps import flask_login -from flask import g, request, url_for +from flask import current_app, g, request, url_for +from werkzeug.local import LocalProxy + from lifemonitor.auth.models import Anonymous, ApiKey, User from lifemonitor.exceptions import LifeMonitorException from lifemonitor.lang import messages -from werkzeug.local import LocalProxy # Config a module level logger logger = logging.getLogger(__name__) @@ -66,8 +67,24 @@ def load_user_from_header(_req): return None -def login_user(user): - flask_login.login_user(user) +@flask_login.user_loaded_from_request.connect +def user_loaded_from_request(app, user=None): + logger.debug("User loaded from request: %s", user) + g.login_via_request = True + + +def login_user(user, remember=False, duration=None, force=False, fresh=True): + logger.debug("User logged in: %s", user) + logger.debug("g.get('login_via_request'): %r", g.get('login_via_request')) + + # signal if API key is provided or Token is in the request header + if request.headers.get('ApiKey', None) or request.headers.get('Authorization', None): + flask_login.user_loaded_from_request.send(current_app._get_current_object(), user=user) + logger.debug("g.get('login_via_request'): %r", g.get('login_via_request')) + else: + logger.debug("Not logged in via request") + + flask_login.login_user(user, remember=remember, duration=duration, force=force, fresh=fresh) def logout_user(): @@ -158,3 +175,23 @@ def check_api_key(api_key, required_scopes): login_user(api_key.user) # return the user_id return {'uid': api_key.user.id} + + +def check_cookie(cookie, required_scopes): + logger.debug("Checking the cookie: %r; scopes required: %r", cookie, required_scopes) + logger.debug("Current user: %r", current_user) + + # check is an ApiKey is present in the request + if request.headers.get('ApiKey', None): + return check_api_key(request.headers.get('ApiKey', None), required_scopes) + + # check is an Authorization header is present in the request + if request.headers.get('Authorization', None): + from lifemonitor.auth.oauth2.server.services import get_token_scopes + auth_header = request.headers.get('Authorization', None) + if auth_header.startswith('Bearer '): + token = auth_header.replace('Bearer ', '') + return get_token_scopes(token) + + # if the cookie is present, return the user_id + return {'uid': current_user.id} diff --git a/lifemonitor/config.py b/lifemonitor/config.py index 8bde89049..e2469f9f9 100644 --- a/lifemonitor/config.py +++ b/lifemonitor/config.py @@ -128,6 +128,8 @@ class BaseConfig: ENABLE_REGISTRY_INTEGRATION = False # Service Availability Timeout SERVICE_AVAILABILITY_TIMEOUT = 1 + # Cookie Settings + SESSION_COOKIE_NAME = 'lifemonitor_session' class DevelopmentConfig(BaseConfig): diff --git a/lifemonitor/static/api.yaml b/lifemonitor/static/api.yaml deleted file mode 120000 index a97f32b16..000000000 --- a/lifemonitor/static/api.yaml +++ /dev/null @@ -1 +0,0 @@ -../../specs/api.yaml \ No newline at end of file diff --git a/lifemonitor/static/specs/api.yaml b/lifemonitor/static/specs/api.yaml new file mode 120000 index 000000000..500e0b8fa --- /dev/null +++ b/lifemonitor/static/specs/api.yaml @@ -0,0 +1 @@ +../../../specs/api.yaml \ No newline at end of file diff --git a/lifemonitor/static/apidocs.html b/lifemonitor/static/specs/apidocs.html similarity index 97% rename from lifemonitor/static/apidocs.html rename to lifemonitor/static/specs/apidocs.html index 94bce3236..e65f27322 100644 --- a/lifemonitor/static/apidocs.html +++ b/lifemonitor/static/specs/apidocs.html @@ -42,6 +42,7 @@ primary-color="#1f8787" theme="light" schema-description-expanded="false" + fetch-credentials="omit" >