From 686c9efc2cb95ac16178d5278a169981ea4511e2 Mon Sep 17 00:00:00 2001 From: xeals Date: Sat, 3 Sep 2022 14:33:52 +1000 Subject: [PATCH] app: initial version with templating --- frontpage/app.py | 56 +++++++++++++++++++++++ frontpage/config.py | 85 +++++++++++++++++++++++++++++++++++ frontpage/routes.py | 40 +++++++++++++++++ frontpage/templates/home.html | 34 ++++++++++++++ 4 files changed, 215 insertions(+) create mode 100644 frontpage/app.py create mode 100644 frontpage/config.py create mode 100644 frontpage/routes.py create mode 100644 frontpage/templates/home.html diff --git a/frontpage/app.py b/frontpage/app.py new file mode 100644 index 0000000..285c18c --- /dev/null +++ b/frontpage/app.py @@ -0,0 +1,56 @@ +"""Main entrypoint.""" +import argparse +import secrets + +from flask import Flask +from flask_pyoidc import OIDCAuthentication +from flask_pyoidc.provider_configuration import ClientMetadata, ProviderConfiguration + +from frontpage import config, routes +from frontpage.config import OidcConfig + +app = Flask(__name__) + + +def _init_oidc(cfg: OidcConfig) -> OIDCAuthentication: + if cfg.client_secret_file is not None: + with open(cfg.client_secret_file, encoding="UTF-8") as f: + secret = f.read() + else: + secret = cfg.client_secret + print(secret) + client = ClientMetadata(client_id=cfg.client_id, client_secret=secret) + + # Ensure we have the minimum set of scopes for things to work. + scopes = {"openid"} + scopes.update(cfg.scopes) + + auth_params = {"scope": list(scopes)} + provider = ProviderConfiguration( + issuer=cfg.issuer, client_metadata=client, auth_request_params=auth_params + ) + + auth = OIDCAuthentication({"default": provider}, app) + return auth + + +def main(): + """ + Entrypoint. + """ + parser = argparse.ArgumentParser() + parser.add_argument( + "-c", "--config", metavar="FILE", help="configuration file", required=True + ) + args = parser.parse_args() + + cfg = config.load(args.config) + auth = _init_oidc(cfg.oidc) + + app.register_blueprint(routes.register(auth, "default")) + + app.config.update({"DEBUG": cfg.app.debug, "SECRET_KEY": secrets.token_hex()}) + app.config["user_config"] = cfg + + # Reloader doesn't work on NixOS + app.run(use_reloader=False) diff --git a/frontpage/config.py b/frontpage/config.py new file mode 100644 index 0000000..397c917 --- /dev/null +++ b/frontpage/config.py @@ -0,0 +1,85 @@ +"""Configuration classes and utilities.""" +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional + +import toml +import dacite + +from flask import current_app + + +@dataclass +class CoreConfig: + """Core application configuration.""" + + debug: bool = False + + +@dataclass +class OidcConfig: + """OIDC client configuration.""" + + issuer: str + client_id: str + client_secret: Optional[str] = None + client_secret_file: Optional[str] = None + scopes: List[str] = field(default_factory=list) + + +@dataclass +class AppConfig: + """App link configuration.""" + + url: str + name: Optional[str] = None + image: Optional[str] = None + description: Optional[str] = None + groups: List[str] = field(default_factory=list) + + +@dataclass +class Config: + """Top-level configuration.""" + + app: CoreConfig + oidc: OidcConfig + apps: Dict[str, AppConfig] = field(default_factory=dict) + + +class ConfigError(Exception): + pass + + +def _validate(config: Config) -> None: + oidc = config.oidc + if oidc.client_secret is None and oidc.client_secret_file is None: + raise ConfigError( + "exactly one of oidc.client_secret or oidc.client_secret_file is required" + ) + if oidc.client_secret is not None and oidc.client_secret_file is not None: + raise ConfigError( + "exactly one of oidc.client_secret or oidc.client_secret_file is required" + ) + + +def load(file: str) -> Config: + """ + Load the configuration from a `file` path containing a TOML configuration. + + :param file: a file path to a TOML config + :return: the configuration object + """ + path = Path(file) + cfg = dacite.from_dict(data_class=Config, data=toml.load(path)) + _validate(cfg) + return cfg + + +def current_config() -> Config: + """ + Load the configuration from the current Flask app. + + :return: the configuration object + """ + return current_app.config["user_config"] diff --git a/frontpage/routes.py b/frontpage/routes.py new file mode 100644 index 0000000..42fce10 --- /dev/null +++ b/frontpage/routes.py @@ -0,0 +1,40 @@ +"""Route configuration.""" +from typing import Any, Iterable, List + +import flask + +from flask import Blueprint, current_app, render_template +from flask_pyoidc import OIDCAuthentication +from flask_pyoidc.user_session import UserSession + +from frontpage.config import AppConfig, current_config + + +def _allowed(items_from: Iterable[Any], items_in: Iterable[Any]) -> bool: + if len(items_from) == 0: + return True + for item in items_from: + if item in items_in: + return True + return False + + +def register(auth: OIDCAuthentication, auth_provider: str) -> Blueprint: + routes = Blueprint("routes", __name__) + + @routes.route("/") + @auth.oidc_auth(auth_provider) + def home(): + """ + Renders the home route. + """ + user_session = UserSession(flask.session) + groups: List[str] = user_session.userinfo["groups"] + + apps: AppConfig = current_config().apps + allowed_apps = { + ident: a for ident, a in apps.items() if _allowed(a.groups, groups) + } + return render_template("home.html", apps=allowed_apps, groups=groups) + + return routes diff --git a/frontpage/templates/home.html b/frontpage/templates/home.html new file mode 100644 index 0000000..abb0496 --- /dev/null +++ b/frontpage/templates/home.html @@ -0,0 +1,34 @@ + + + + + Front page + + + + +
+
+ {% for ident, cfg in apps.items() %} +
+
+ + {{ cfg.name or ident }} + +
+ {% if cfg.image %} + + {% endif %} + {% if cfg.description %} +

{{ cfg.description }}

+ {% endif %} +
+ {% endfor %} +
+
+ +