diff --git a/.gitignore b/.gitignore index db5b905..c5ce2aa 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ node_modules /dist build result +nixos.qcow2 diff --git a/MANIFEST.in b/MANIFEST.in index 50e47fa..ae6aa0c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,5 @@ recursive-include lenticular_cloud/template * recursive-include lenticular_cloud/static ** recursive-include lenticular_cloud/migrations ** -include lenticular_cloud/*.cfg +include lenticular_cloud/*.toml diff --git a/flake.lock b/flake.lock index c7614dc..896297c 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "flake-compat": { "flake": false, "locked": { - "lastModified": 1673956053, - "narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=", + "lastModified": 1696426674, + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", "owner": "edolstra", "repo": "flake-compat", - "rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9", + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", "type": "github" }, "original": { @@ -52,17 +52,17 @@ }, "nixpkgs": { "locked": { - "lastModified": 1696022471, - "narHash": "sha256-3U5nqHQ9JFUoY4GJ89ErqzRmkgAhPYjbPn4vP+CpltM=", + "lastModified": 1696374741, + "narHash": "sha256-gt8B3G0ryizT9HSB4cCO8QoxdbsHnrQH+/BdKxOwqF0=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "336d12bc4cdb0980f5ff450d7bc599bdb4ed5e74", + "rev": "8a4c17493e5c39769f79117937c79e1c88de6729", "type": "github" }, "original": { - "owner": "NixOS", - "repo": "nixpkgs", - "type": "github" + "id": "nixpkgs", + "ref": "nixos-23.05", + "type": "indirect" } }, "root": { @@ -70,7 +70,8 @@ "flake-compat": "flake-compat", "flake-utils": "flake-utils", "nix-node-package": "nix-node-package", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "tuxpkgs": "tuxpkgs" } }, "systems": { @@ -87,6 +88,29 @@ "repo": "default", "type": "github" } + }, + "tuxpkgs": { + "inputs": { + "flake-utils": [ + "flake-utils" + ], + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1696700871, + "narHash": "sha256-9VFEJEfnfnCS1+kLznxd+OiDYdMnLP00+XR53iPfnK4=", + "ref": "refs/heads/master", + "rev": "a25f5792a256beaed2a9f944fccdea8ea7a8d44b", + "revCount": 6, + "type": "git", + "url": "ssh://git@git.o-g.at/nixpkg/tuxpkgs.git" + }, + "original": { + "type": "git", + "url": "ssh://git@git.o-g.at/nixpkg/tuxpkgs.git" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index c21abcd..40e7774 100644 --- a/flake.nix +++ b/flake.nix @@ -1,7 +1,7 @@ { description = "Lenticular cloud interface"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs"; + nixpkgs.url = "nixpkgs/nixos-23.05"; flake-utils.url = "github:numtide/flake-utils"; flake-compat = { # for shell.nix url = "github:edolstra/flake-compat"; @@ -11,16 +11,23 @@ url = "github:mkg20001/nix-node-package"; flake = false; }; + tuxpkgs = { + url = "git+ssh://git@git.o-g.at/nixpkg/tuxpkgs.git"; + inputs.nixpkgs.follows = "nixpkgs"; + inputs.flake-utils.follows = "flake-utils"; + }; }; - outputs = { self, nixpkgs, nix-node-package, flake-utils, ... }: + outputs = { self, nixpkgs, nix-node-package, flake-utils, tuxpkgs, ... }: flake-utils.lib.eachDefaultSystem (system: let pkgs = nixpkgs.legacyPackages.${system}.extend (import ./overlay.nix); in rec { formatter = pkgs.nixpkgs-fmt; - devShells.default = pkgs.python3.withPackages (ps: ( - pkgs.lenticular-cloud.propagatedBuildInputs ++ - pkgs.lenticular-cloud.testBuildInputs - )); + devShells.default = pkgs.mkShell {packages = [ + (pkgs.python3.withPackages (ps: ( + pkgs.lenticular-cloud.propagatedBuildInputs ++ + pkgs.lenticular-cloud.testBuildInputs + ))) + ];}; packages.default = pkgs.lenticular-cloud; @@ -37,7 +44,18 @@ system = "x86_64-linux"; modules = [ self.nixosModules.default + tuxpkgs.nixosModules.ory-hydra "${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix" + ({...}:{ + services.lenticular-cloud.enable = true; + services.ory-hydra = { + enable = true; + admin_domain = "admin-hydra.local"; + }; + networking.hosts = {"::1" = [ "admin-hydra.local" ]; }; + services.getty.autologinUser = "root"; + virtualisation.qemu.options = ["-vga none"]; + }) ]; }; }; diff --git a/lenticular_cloud/app.py b/lenticular_cloud/app.py index adfbe6d..9378683 100644 --- a/lenticular_cloud/app.py +++ b/lenticular_cloud/app.py @@ -1,11 +1,14 @@ from flask.app import Flask -from flask import g, redirect, request -from flask.helpers import url_for +from flask import g +from flask.json.provider import DefaultJSONProvider import time import subprocess from lenticular_cloud.lenticular_services import lenticular_services -from ory_hydra_client import Client import os +import toml +import json +import logging +from uuid import UUID from pathlib import Path @@ -15,6 +18,7 @@ from .translations import init_babel from .model import db, migrate from .views import auth_views, frontend_views, init_login_manager, api_views, pki_views, admin_views, oauth2_views +logger = logging.getLogger(__name__) def get_git_hash(): try: @@ -23,13 +27,33 @@ def get_git_hash(): return '' -def create_app() -> Flask: + +class CustomJSONEncoder(DefaultJSONProvider): + def default(self, obj): + if isinstance(obj, UUID): + # if the obj is uuid, we simply return the value of uuid + return obj.hex + return super().default(obj) + + +def create_app_raw(config_files: list[Path]) -> Flask: name = "lenticular_cloud" app = Flask(name, template_folder='template') - app.config.from_pyfile('application.cfg') - active_cfg = os.getenv('CONFIG_FILE', 'production.cfg') - app.config.from_pyfile(active_cfg) - + app.json_provider_class = CustomJSONEncoder + + # config + app.config.from_file('config_development.toml', toml.load) + for config_file in config_files: + active_cfg = str(config_file.absolute()) + if active_cfg.endswith(".toml"): + logger.info(f"load toml config file from {active_cfg}") + app.config.from_file(active_cfg, toml.load) + elif active_cfg.endswith(".json"): + logger.info(f"load json config file from {active_cfg}") + app.config.from_file(active_cfg, json.load) + else: + logger.info(f"load pyfile config file from {active_cfg}") + app.config.from_pyfile(active_cfg) app.jinja_env.globals['GIT_HASH'] = get_git_hash() db.init_app(app) @@ -45,9 +69,9 @@ def create_app() -> Flask: # host=app.config['HYDRA_ADMIN_URL'], # username=app.config['HYDRA_ADMIN_USER'], # password=app.config['HYDRA_ADMIN_PASSWORD']) - hydra_service.set_hydra_client(Client(base_url=app.config['HYDRA_ADMIN_URL'])) + hydra_service.init_app(app) - init_login_manager(app) + init_login_manager(app) # has to be after hydra_service app.register_blueprint(auth_views) app.register_blueprint(frontend_views) app.register_blueprint(api_views) @@ -66,3 +90,12 @@ def create_app() -> Flask: return app + +def create_app() -> Flask: + evn_var = os.getenv('CONFIG_FILE', None) + if isinstance(evn_var, str): + active_cfgs = list(map(Path, evn_var.split(':'))) + else: + active_cfgs = [ Path() / 'production.toml' ] + + return create_app_raw(active_cfgs) \ No newline at end of file diff --git a/lenticular_cloud/cli.py b/lenticular_cloud/cli.py index 8caeca2..d796ba2 100644 --- a/lenticular_cloud/cli.py +++ b/lenticular_cloud/cli.py @@ -1,4 +1,5 @@ import argparse +from typing import Optional from .model import db, User from .app import create_app from werkzeug.middleware.proxy_fix import ProxyFix @@ -64,7 +65,7 @@ def cli_signup(args) -> None: if args.signup_id is not None: user = User.query.get(args.signup_id) - if user == None: + if user is None: print("user not found") return user.enabled = True @@ -87,7 +88,7 @@ def cli_run(app: Flask, args) -> None: def cli_db_upgrade(args) -> None: app = create_app() migration_dir = Path(app.root_path) / 'migrations' - upgrade( str(migration_dir) ) + upgrade( str(migration_dir), revision='head' ) if __name__ == "__main__": diff --git a/lenticular_cloud/application.cfg b/lenticular_cloud/config_development.toml similarity index 52% rename from lenticular_cloud/application.cfg rename to lenticular_cloud/config_development.toml index 0e1b0ec..ac3d6f1 100644 --- a/lenticular_cloud/application.cfg +++ b/lenticular_cloud/config_development.toml @@ -6,16 +6,11 @@ PREFERRED_URL_SCHEME = 'https' DATA_FOLDER = "../data" SCRIPT_LOCATION="lenticular_cloud:migrations" -SQLALCHEMY_DATABASE_URI = f'sqlite:///{DATA_FOLDER}/db.sqlite' -SQLALCHEMY_TRACK_MODIFICATIONS=False - -LDAP_URL = 'ldaps://ldap.example.org' -LDAP_BASE_DN = 'dc=example,dc=com' -LDAP_BIND_DN = 'cn=admin,' + LDAP_BASE_DN -LDAP_BIND_PW = '123456' +SQLALCHEMY_DATABASE_URI = 'sqlite:///../data/db.sqlite' +SQLALCHEMY_TRACK_MODIFICATIONS = false -PKI_PATH = f'{DATA_FOLDER}/pki' +PKI_PATH = "../data/pki" DOMAIN = 'example.com' #SERVER_NAME = f'account.{ DOMAIN }:9090' @@ -30,17 +25,24 @@ OAUTH_ID = 'identiy_provider' OAUTH_SECRET = 'ThisIsNotSafe' +[LENTICULAR_CLOUD_SERVICES.jabber] +app_token = true +# client_cert= true -LENTICULAR_CLOUD_SERVICES = { - 'jabber': { - 'client_cert': True, - 'pki_config':{ - 'email': '{username}@jabber.{domain}' - }, - 'app_token': True - }, - 'mail-cardav': { - 'client_cert': False, - 'app_token': True - } -} + +[LENTICULAR_CLOUD_SERVICES.mail-cardav] +app_token = true + +# LENTICULAR_CLOUD_SERVICES = { +# 'jabber': { +# 'client_cert': True, +# 'pki_config':{ +# 'email': '{username}@jabber.{domain}' +# }, +# 'app_token': True +# }, +# 'mail-cardav': { +# 'client_cert': False, +# 'app_token': True +# } +# } diff --git a/lenticular_cloud/hydra.py b/lenticular_cloud/hydra.py index 9c4d401..155d0bb 100644 --- a/lenticular_cloud/hydra.py +++ b/lenticular_cloud/hydra.py @@ -1,14 +1,56 @@ +from flask import Flask from ory_hydra_client import Client from typing import Optional - +from ory_hydra_client.api.o_auth_2 import list_o_auth_2_clients, create_o_auth_2_client +from ory_hydra_client.models.o_auth_20_client import OAuth20Client class HydraService: - def __init__(self): + def __init__(self) -> None: self._hydra_client: Optional[Client] = None self._oauth_client: Optional[Client] = None + self.client_id = '' + self.client_secret = '' + + def init_app(self, app: Flask) -> None: + + self.set_hydra_client(Client(base_url=app.config['HYDRA_ADMIN_URL'])) + + client_name = app.config['OAUTH_ID'] + client_secret = app.config['OAUTH_SECRET'] + + clients = list_o_auth_2_clients.sync_detailed(_client=self.hydra_client).parsed + if clients is None: + raise RuntimeError("could not get clients list") + client: Optional[OAuth20Client] = None + for c in clients: + if c.client_name == client_name: + client = c + break + + if client is None: + domain = app.config['DOMAIN'] + client = OAuth20Client( + client_name="identiy_provider", + # client_id=client_id, + client_secret=client_secret, + response_types=["code", "id_token"], + scope="openid profile manage", + grant_types=["authorization_code", "refresh_token"], + redirect_uris=[ f"https://{domain}/oauth/authorized" ], + token_endpoint_auth_method="client_secret_basic", + ) + ret = create_o_auth_2_client.sync(json_body=client, _client=self.hydra_client) + if ret is None: + raise RuntimeError("could not crate account") + if type(client.client_id) is not str: + raise RuntimeError("could not parse client_id from ory-hydra") + self.client_id = client.client_id + self.client_secret = client_secret + + @property def hydra_client(self) -> Client: if self._hydra_client is None: diff --git a/lenticular_cloud/migrations/versions/a74320a5d7a1_init.py b/lenticular_cloud/migrations/versions/a74320a5d7a1_init.py new file mode 100644 index 0000000..06eea90 --- /dev/null +++ b/lenticular_cloud/migrations/versions/a74320a5d7a1_init.py @@ -0,0 +1,63 @@ +"""init + +Revision ID: a74320a5d7a1 +Revises: +Create Date: 2023-10-01 20:15:53.795636 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a74320a5d7a1' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('group', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('modified_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('user', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('username', sa.String(), nullable=False), + sa.Column('password_hashed', sa.String(), nullable=False), + sa.Column('alternative_email', sa.String(), nullable=True), + sa.Column('last_login', sa.DateTime(), nullable=True), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('modified_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_table('app_token', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('service_name', sa.String(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('token', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('last_used', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('modified_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('webauthn_credential') + op.drop_table('totp') + op.drop_table('app_token') + op.drop_table('user') + op.drop_table('group') + # ### end Alembic commands ### diff --git a/lenticular_cloud/migrations/versions/0518a8625b50_remove_ldap_add_rest_to_db.py b/lenticular_cloud/migrations/versions/old/0518a8625b50_remove_ldap_add_rest_to_db.py similarity index 57% rename from lenticular_cloud/migrations/versions/0518a8625b50_remove_ldap_add_rest_to_db.py rename to lenticular_cloud/migrations/versions/old/0518a8625b50_remove_ldap_add_rest_to_db.py index 993e456..2ae3512 100644 --- a/lenticular_cloud/migrations/versions/0518a8625b50_remove_ldap_add_rest_to_db.py +++ b/lenticular_cloud/migrations/versions/old/0518a8625b50_remove_ldap_add_rest_to_db.py @@ -20,18 +20,6 @@ depends_on = None def upgrade(): - from ldap3_orm import AttrDef, EntryBase as _EntryBase, ObjectDef, EntryType - from ldap3_orm import Reader - from ldap3 import Connection, Server, ALL - - app = current_app - server = Server(app.config['LDAP_URL'], get_info=ALL) - ldap_conn = Connection(server, app.config['LDAP_BIND_DN'], app.config['LDAP_BIND_PW'], auto_bind=True) # TODO auto_bind read docu - base_dn = app.config['LDAP_BASE_DN'] - object_def = ObjectDef(["inetOrgPerson"], ldap_conn) - user_base_dn = f"ou=users,{base_dn}" - - # ### commands auto generated by Alembic - please adjust! ### op.create_table('app_token', sa.Column('id', sa.Integer(), nullable=False), @@ -50,21 +38,36 @@ def upgrade(): op.add_column('user', sa.Column('password_hashed', sa.String(), server_default="", nullable=False)) op.add_column('user', sa.Column('enabled', sa.Boolean(), server_default="false", nullable=True)) # ### end Alembic commands ### + try: + from ldap3_orm import AttrDef, EntryBase as _EntryBase, ObjectDef, EntryType + from ldap3_orm import Reader + from ldap3 import Connection, Server, ALL - op.execute(User.__table__.update().values({'enabled': True})) - conn = op.get_bind() - users = conn.execute(User.__table__.select()) + app = current_app + server = Server(app.config['LDAP_URL'], get_info=ALL) + ldap_conn = Connection(server, app.config['LDAP_BIND_DN'], app.config['LDAP_BIND_PW'], auto_bind=True) # TODO auto_bind read docu + base_dn = app.config['LDAP_BASE_DN'] + object_def = ObjectDef(["inetOrgPerson"], ldap_conn) + user_base_dn = f"ou=users,{base_dn}" - for user in users: - print(f"migrating user {user.username}") - reader = Reader(ldap_conn, object_def, user_base_dn, f'(uid={user.username})') - result = reader.search() - if len(result) == 0: - print(f"WARNING: could not migrate user {user.username}") - continue - ldap_object = result[0] - password_hashed = ldap_object.userPassword[0].decode().replace('{CRYPT}','') - op.execute(User.__table__.update().values({'password_hashed': password_hashed}).where(User.id == user.id)) + + + op.execute(User.__table__.update().values({'enabled': True})) + conn = op.get_bind() + users = conn.execute(User.__table__.select()) + + for user in users: + print(f"migrating user {user.username}") + reader = Reader(ldap_conn, object_def, user_base_dn, f'(uid={user.username})') + result = reader.search() + if len(result) == 0: + print(f"WARNING: could not migrate user {user.username}") + continue + ldap_object = result[0] + password_hashed = ldap_object.userPassword[0].decode().replace('{CRYPT}','') + op.execute(User.__table__.update().values({'password_hashed': password_hashed}).where(User.id == user.id)) + except ModuleNotFoundError: + print("ignore import warning") diff --git a/lenticular_cloud/migrations/versions/0f217e90cd07_fix_app_token.py b/lenticular_cloud/migrations/versions/old/0f217e90cd07_fix_app_token.py similarity index 85% rename from lenticular_cloud/migrations/versions/0f217e90cd07_fix_app_token.py rename to lenticular_cloud/migrations/versions/old/0f217e90cd07_fix_app_token.py index 66e489b..669b925 100644 --- a/lenticular_cloud/migrations/versions/0f217e90cd07_fix_app_token.py +++ b/lenticular_cloud/migrations/versions/old/0f217e90cd07_fix_app_token.py @@ -18,10 +18,10 @@ depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.add_column('app_token', sa.Column('user_id', sa.String(length=36), nullable=False)) - op.add_column('app_token', sa.Column('last_used', sa.DateTime(), nullable=True)) + with op.batch_alter_table('app_token') as batch_op: + batch_op.add_column(sa.Column('user_id', sa.Uuid, nullable=False)) + batch_op.add_column(sa.Column('last_used', sa.DateTime(), nullable=True)) op.create_foreign_key(None, 'app_token', 'user', ['user_id'], ['id']) - op.add_column('totp', sa.Column('last_used', sa.DateTime(), nullable=True)) tmp_table = sa.Table('_alembic_tmp_user', sa.MetaData()) op.execute(sa.schema.DropTable(tmp_table, if_exists=True)) with op.batch_alter_table('user') as batch_op: diff --git a/lenticular_cloud/migrations/versions/52a21983d2a8_add_webauthn.py b/lenticular_cloud/migrations/versions/old/52a21983d2a8_add_webauthn.py similarity index 94% rename from lenticular_cloud/migrations/versions/52a21983d2a8_add_webauthn.py rename to lenticular_cloud/migrations/versions/old/52a21983d2a8_add_webauthn.py index 63106a2..62356bb 100644 --- a/lenticular_cloud/migrations/versions/52a21983d2a8_add_webauthn.py +++ b/lenticular_cloud/migrations/versions/old/52a21983d2a8_add_webauthn.py @@ -20,7 +20,7 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('webauthn_credential', sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), sa.Column('user_handle', sa.String(length=64), nullable=False), sa.Column('credential_data', sa.LargeBinary(), nullable=False), sa.Column('name', sa.String(length=250), nullable=True), diff --git a/lenticular_cloud/migrations/versions/ff2f2e871dfc_init.py b/lenticular_cloud/migrations/versions/old/ff2f2e871dfc_init.py similarity index 100% rename from lenticular_cloud/migrations/versions/ff2f2e871dfc_init.py rename to lenticular_cloud/migrations/versions/old/ff2f2e871dfc_init.py diff --git a/lenticular_cloud/model.py b/lenticular_cloud/model.py index 28e46d4..ecd628d 100644 --- a/lenticular_cloud/model.py +++ b/lenticular_cloud/model.py @@ -11,6 +11,7 @@ import logging import crypt import secrets import string +from sqlalchemy import null from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass, Mapped, mapped_column, relationship, declarative_base from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy.model import Model, DefaultMeta @@ -38,9 +39,10 @@ if TYPE_CHECKING: pass else: BaseModel: Type[_FSAModel] = db.Model + class ModelUpdatedMixin: - created_at: Mapped[datetime] = mapped_column(db.DateTime, default=datetime.now()) - last_update: Mapped[datetime] = mapped_column(db.DateTime, default=datetime.now(), onupdate=datetime.now) + created_at: Mapped[datetime] = mapped_column(db.DateTime, default=datetime.now(), nullable=False) + modified_at: Mapped[datetime] = mapped_column(db.DateTime, default=datetime.now(), onupdate=datetime.now, nullable=False) class SecurityUser(UserMixin): @@ -151,28 +153,27 @@ def generate_uuid(): return str(uuid.uuid4()) -class User(BaseModel): - id = db.Column( - db.String(length=36), primary_key=True, default=generate_uuid) - username = db.Column( - db.String, unique=True, nullable=False) - password_hashed = db.Column( - db.String, nullable=False) - alternative_email = db.Column( - db.String, nullable=True) - created_at = db.Column(db.DateTime, nullable=False, - default=datetime.now) - modified_at = db.Column(db.DateTime, nullable=False, - default=datetime.now, onupdate=datetime.now) - last_login = db.Column(db.DateTime, nullable=True) +class User(BaseModel, ModelUpdatedMixin): + id: Mapped[uuid.UUID] = mapped_column(db.Uuid, primary_key=True, default=uuid.uuid4) + username: Mapped[str] = mapped_column(db.String, unique=True, nullable=False) + password_hashed: Mapped[str] = mapped_column(db.String, nullable=False) + alternative_email: Mapped[Optional[str]] = mapped_column( db.String, nullable=True) + last_login: Mapped[Optional[datetime]] = mapped_column(db.DateTime, nullable=True) - enabled = db.Column(db.Boolean, nullable=False, default=False) + enabled: Mapped[bool] = mapped_column(db.Boolean, nullable=False, default=False) - app_tokens = db.relationship('AppToken', back_populates='user') - totps = db.relationship('Totp', back_populates='user') - webauthn_credentials = db.relationship('WebauthnCredential', back_populates='user', cascade='delete,delete-orphan', passive_deletes=True) + app_tokens: Mapped[List['AppToken']] = relationship('AppToken', back_populates='user') + # totps: Mapped[List['Totp']] = relationship('Totp', back_populates='user', default_factory=list) + # webauthn_credentials: Mapped[List['WebauthnCredential']] = relationship('WebauthnCredential', back_populates='user', cascade='delete,delete-orphan', passive_deletes=True, default_factory=list) - def __init__(self, **kwargs): + @property + def totps(self) -> List['Totp']: + return [] + @property + def webauthn_credentials(self) -> List['WebauthnCredential']: + return [] + + def __init__(self, **kwargs) -> None: super().__init__(**kwargs) @property @@ -207,56 +208,53 @@ class User(BaseModel): return None -class AppToken(BaseModel): - id = db.Column(db.Integer, primary_key=True) - service_name = db.Column(db.String, nullable=False) - user_id = db.Column( - db.String(length=36), +class AppToken(BaseModel, ModelUpdatedMixin): + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + service_name: Mapped[str] = mapped_column(nullable=False) + user_id: Mapped[uuid.UUID] = mapped_column( + db.Uuid, db.ForeignKey(User.id), nullable=False) - user = db.relationship(User) - token = db.Column(db.String, nullable=False) - name = db.Column(db.String, nullable=False) - last_used = db.Column(db.DateTime, nullable=True) + user: Mapped[User] = relationship(User, back_populates="app_tokens") + token: Mapped[str] = mapped_column(nullable=False) + name: Mapped[str] = mapped_column(nullable=False) + last_used: Mapped[Optional[datetime]] = mapped_column(db.DateTime, nullable=True, default=None) @staticmethod - def new(service: Service): - app_token = AppToken() - app_token.service_name = service.name + def new(user: User, service: Service, name: str): alphabet = string.ascii_letters + string.digits - app_token.token = ''.join(secrets.choice(alphabet) for i in range(12)) - return app_token + token = ''.join(secrets.choice(alphabet) for i in range(12)) + return AppToken(service_name=service.name, token=token, user=user, name=name) -class Totp(BaseModel): - id = db.Column(db.Integer, primary_key=True) - secret = db.Column(db.String, nullable=False) - name = db.Column(db.String, nullable=False) - created_at = db.Column(db.DateTime, default=datetime.now, nullable=False) - last_used = db.Column(db.DateTime, nullable=True) +class Totp(BaseModel, ModelUpdatedMixin): + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + secret: Mapped[str] = mapped_column(db.String, nullable=False) + name: Mapped[str] = mapped_column(db.String, nullable=False) - user_id = db.Column( - db.String(length=36), + user_id: Mapped[uuid.UUID] = mapped_column( + db.Uuid, db.ForeignKey(User.id), nullable=False) - user = db.relationship(User) + # user: Mapped[User] = relationship(User, back_populates="totp") + last_used: Mapped[Optional[datetime]] = mapped_column(db.DateTime, nullable=True, default=None) def verify(self, token: str) -> bool: totp = pyotp.TOTP(self.secret) return totp.verify(token) -class WebauthnCredential(BaseModel): # pylint: disable=too-few-public-methods +class WebauthnCredential(BaseModel, ModelUpdatedMixin): # pylint: disable=too-few-public-methods """Webauthn credential model""" - id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.String(length=36), db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) - user_handle = db.Column(db.String(64), nullable=False) - credential_data = db.Column(db.LargeBinary, nullable=False) - name = db.Column(db.String(250)) - registered = db.Column(db.DateTime, default=datetime.utcnow) + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[uuid.UUID] = mapped_column(db.Uuid, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) + user_handle: Mapped[str] = mapped_column(db.String(64), nullable=False) + credential_data: Mapped[bytes] = mapped_column(db.LargeBinary, nullable=False) + name: Mapped[str] = mapped_column(db.String(250), nullable=False) + registered: Mapped[datetime] = mapped_column(db.DateTime, default=datetime.utcnow, nullable=False) - user = db.relationship('User', back_populates='webauthn_credentials') + # user = db.relationship('User', back_populates='webauthn_credentials') -class Group(BaseModel): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(), nullable=False, unique=True) +class Group(BaseModel, ModelUpdatedMixin): + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(db.String(), nullable=False, unique=True) diff --git a/lenticular_cloud/template/auth/consent.html.j2 b/lenticular_cloud/template/auth/consent.html.j2 index 4190c6c..18ae7a3 100644 --- a/lenticular_cloud/template/auth/consent.html.j2 +++ b/lenticular_cloud/template/auth/consent.html.j2 @@ -5,7 +5,7 @@ {% block content %}
- The application "{{ client.client_id }}" requested the following scopes: {{ requested_scope }} + The application "{{ client.client_name }}" (id: {{ client.client_id }}) requested the following scopes: {{ requested_scope }}
Allow this app to access that data?
diff --git a/lenticular_cloud/views/admin.py b/lenticular_cloud/views/admin.py index da71066..37ff970 100644 --- a/lenticular_cloud/views/admin.py +++ b/lenticular_cloud/views/admin.py @@ -46,7 +46,7 @@ async def index() -> ResponseReturnValue: @admin_views.route('/user', methods=['GET']) -async def users(): +async def users() -> ResponseReturnValue: users = User.query.all() # type: Iterable[User] return render_template('admin/users.html.j2', users=users) diff --git a/lenticular_cloud/views/auth.py b/lenticular_cloud/views/auth.py index a153edb..40eaa0d 100644 --- a/lenticular_cloud/views/auth.py +++ b/lenticular_cloud/views/auth.py @@ -21,6 +21,7 @@ from ory_hydra_client.api.o_auth_2 import get_o_auth_2_consent_request, accept_o from ory_hydra_client import models as ory_hydra_m from ory_hydra_client.models import TheRequestPayloadUsedToAcceptALoginOrConsentRequest, TheRequestPayloadUsedToAcceptAConsentRequest, GenericError from typing import Optional +from uuid import uuid4 from ..model import db, User, SecurityUser from ..form.auth import ConsentForm, LoginForm, RegistrationForm @@ -136,7 +137,7 @@ async def login_auth() -> ResponseReturnValue: if 'username' not in session: return redirect(url_for('auth.login')) auth_forms = {} - user = User.query.filter_by(username=session['username']).first() # Optional[User] + user = User.query.filter_by(username=session['username']).first_or_404() for auth_provider in AUTH_PROVIDER_LIST: form = auth_provider.get_form() if auth_provider.get_name() not in session['auth_providers'] and\ @@ -154,7 +155,7 @@ async def login_auth() -> ResponseReturnValue: # db.session.add(db_user) # db.session.commit() - subject = user.id + subject = str(user.id) user.last_login = datetime.now() db.session.commit() resp = await accept_o_auth_2_login_request.asyncio(_client=hydra_service.hydra_client, @@ -170,8 +171,9 @@ async def login_auth() -> ResponseReturnValue: @auth_views.route('/webauthn/pkcro', methods=['POST']) -def webauthn_pkcro_route(): +def webauthn_pkcro_route() -> ResponseReturnValue: """login webauthn pkcro route""" + return '', 404 user = User.query.filter(User.id == session.get('webauthn_login_user_id')).one() #type: User form = ButtonForm() @@ -213,6 +215,7 @@ def sign_up_submit(): form = RegistrationForm() if form.validate_on_submit(): user = User() + user.id = uuid4() user.username = form.data['username'] user.password_hashed = crypt.crypt(form.data['password']) user.alternative_email = form.data['alternative_email'] diff --git a/lenticular_cloud/views/frontend.py b/lenticular_cloud/views/frontend.py index 9fc53e5..80b5272 100644 --- a/lenticular_cloud/views/frontend.py +++ b/lenticular_cloud/views/frontend.py @@ -21,7 +21,7 @@ from urllib.parse import urlencode, parse_qs from random import SystemRandom import string from collections.abc import Iterable -from typing import Optional, Mapping, Iterator, List +from typing import Optional, Mapping, Iterator, List, Any from ..model import db, User, SecurityUser, Totp, AppToken, WebauthnCredential from ..form.frontend import ClientCertForm, TOTPForm, \ @@ -38,11 +38,16 @@ from ..lenticular_services import lenticular_services frontend_views = Blueprint('frontend', __name__, url_prefix='') logger = logging.getLogger(__name__) +def get_current_user() -> User: + user_any: Any = current_user + user: User = user_any + return user + def before_request() -> Optional[ResponseReturnValue]: try: resp = oauth2.custom.get('/userinfo') - if not current_user.is_authenticated or resp.status_code != 200: + if not get_current_user().is_authenticated or resp.status_code != 200: logger.info('user not logged in redirect') return redirect_login() except MissingTokenError: @@ -79,7 +84,7 @@ def client_cert() -> ResponseReturnValue: client_certs = {} for service in lenticular_services.values(): client_certs[str(service.name)] = \ - pki.get_client_certs(current_user, service) + pki.get_client_certs(get_current_user(), service) return render_template( 'frontend/client_cert.html.j2', @@ -91,7 +96,7 @@ def client_cert() -> ResponseReturnValue: def get_client_cert(service_name, serial_number) -> ResponseReturnValue: service = lenticular_services[service_name] cert = pki.get_client_cert( - current_user, service, serial_number) + get_current_user(), service, serial_number) return jsonify({ 'data': { 'pem': cert.pem()} @@ -103,7 +108,7 @@ def get_client_cert(service_name, serial_number) -> ResponseReturnValue: def revoke_client_cert(service_name, serial_number) -> ResponseReturnValue: service = lenticular_services[service_name] cert = pki.get_client_cert( - current_user, service, serial_number) + get_current_user(), service, serial_number) pki.revoke_certificate(cert) return jsonify({}) @@ -119,7 +124,7 @@ def client_cert_new(service_name) -> ResponseReturnValue: if form.validate_on_submit(): valid_time = int(form.data['valid_time']) * timedelta(1, 0, 0) cert = pki.signing_publickey( - current_user, + get_current_user(), service, form.data['publickey'], valid_time=valid_time) @@ -156,13 +161,15 @@ def app_token_new(service_name: str) -> ResponseReturnValue: form = AppTokenForm() if form.validate_on_submit(): - app_token = AppToken.new(service) + user_any = get_current_user() # type: Any + user = user_any # type: User + app_token = AppToken.new(user, service, "") form.populate_obj(app_token) # check for duplicate names - for user_app_token in current_user.app_tokens: + for user_app_token in user.app_tokens: if user_app_token.name == app_token.name: return 'name already exist', 400 - current_user.app_tokens.append(app_token) + user.app_tokens.append(app_token) db.session.commit() return render_template('frontend/app_token_new_show.html.j2', service=service, app_token=app_token) @@ -180,7 +187,7 @@ def app_token_delete(service_name: str, app_token_name: str) -> ResponseReturnVa service = lenticular_services[service_name] if form.validate_on_submit(): - app_token = current_user.get_token(service, app_token_name) + app_token = get_current_user().get_token(service, app_token_name) if app_token is None: return 'not found', 404 db.session.delete(app_token) @@ -199,9 +206,9 @@ def totp_new() -> ResponseReturnValue: form = TOTPForm() if form.validate_on_submit(): - totp = Totp(name=form.data['name'], secret=form.data['secret']) + totp = Totp(name=form.data['name'], secret=form.data['secret'], user=get_current_user()) if totp.verify(form.data['token']): - current_user.totps.append(totp) + get_current_user().totps.append(totp) db.session.commit() return jsonify({ 'status': 'ok'}) @@ -269,7 +276,7 @@ def random_string(length=32) -> str: def webauthn_pkcco_route() -> ResponseReturnValue: """get publicKeyCredentialCreationOptions""" - user = User.query.get(current_user.id) #type: Optional[User] + user = User.query.get(get_current_user().id) #type: Optional[User] if user is None: return 'internal error', 500 user_handle = random_string() @@ -287,7 +294,7 @@ def webauthn_pkcco_route() -> ResponseReturnValue: def webauthn_register_route() -> ResponseReturnValue: """register credential for current user""" - user = current_user # type: User + user = get_current_user() # type: User form = WebauthnRegisterForm() if form.validate_on_submit(): try: @@ -300,7 +307,7 @@ def webauthn_register_route() -> ResponseReturnValue: AttestationObject(attestation['attestationObject'])) db.session.add(WebauthnCredential( - user_id=user.id, + user=user, user_handle=session.pop('webauthn_register_user_handle'), credential_data=cbor.encode(auth_data.credential_data.__dict__), name=form.name.data)) @@ -327,12 +334,12 @@ def password_change_post() -> ResponseReturnValue: password_old = str(form.data['password_old']) password_new = str(form.data['password_new']) if not PasswordAuthProvider.check_auth_internal( - current_user, password_old): + get_current_user(), password_old): return jsonify( {'errors': {'password_old': 'Old Password is invalid'}}) - current_user.change_password(password_new) - logger.info(f"user {current_user.username} changed password") + get_current_user().change_password(password_new) + logger.info(f"user {get_current_user().username} changed password") db.session.commit() return jsonify({}) return jsonify({'errors': form.errors}) diff --git a/lenticular_cloud/views/oauth2.py b/lenticular_cloud/views/oauth2.py index 696cb67..c8643c3 100644 --- a/lenticular_cloud/views/oauth2.py +++ b/lenticular_cloud/views/oauth2.py @@ -9,6 +9,7 @@ from werkzeug.wrappers.response import Response as WerkzeugResponse import logging from ..model import User, SecurityUser +from ..hydra import hydra_service logger = logging.getLogger(__name__) @@ -90,7 +91,7 @@ def init_login_manager(app: Flask) -> None: oauth2.register( name="custom", - client_id=app.config['OAUTH_ID'], + client_id=hydra_service.client_id, client_secret=app.config['OAUTH_SECRET'], server_metadata_url=f'{base_url}/.well-known/openid-configuration', access_token_url=f"{base_url}/oauth2/token", diff --git a/module.nix b/module.nix index 1e27f03..a22c198 100644 --- a/module.nix +++ b/module.nix @@ -1,12 +1,31 @@ { config, pkgs, lib, ... }: let cfg = config.services.lenticular-cloud; + username = "lenticular_cloud"; + data_folder = "/var/lib/${username}"; python = pkgs.python3; in { options = with lib.options; { services.lenticular-cloud ={ enable = mkEnableOption "lenticluar service enable"; + domain = mkOption { + type = lib.types.str; + example = "account.example.com"; + }; + settings = mkOption { + type = lib.types.attrs; + default = rec { + DOMAIN = cfg.domain; + DATA_FOLDER = data_folder; + PKI_PATH = "${DATA_FOLDER}/pki"; + # SQLALCHEMY_DATABASE_URI = "sqlite:////${DATA_FOLDER}/db.sqlite"; + SQLALCHEMY_DATABASE_URI = "postgresql://${username}@/${username}?host=/run/postgresql"; + HYDRA_ADMIN_URL= "https://${config.services.ory-hydra.admin_domain}"; + HYDRA_PUBLIC_URL= "https://${config.services.ory-hydra.public_domain}"; + }; + + }; }; }; config = { @@ -17,51 +36,81 @@ in ]; users = { - groups.lenticular = { + groups."${username}" = { }; - users.lenticular = { + users."${username}" = { createHome = true; - home = "/var/lib/lenticular"; + home = data_folder; description = "web server"; extraGroups = [ + # "ory-hydra" ]; - group = "lenticular"; + group = username; isSystemUser = true; }; }; + services.postgresql = { + enable = true; + ensureDatabases = [ username ]; + ensureUsers = [ + { + name = username; + ensurePermissions = { + "DATABASE ${username}" = "All PRIVILEGES"; + }; + } + ]; + identMap = '' + # ArbitraryMapName systemUser DBUser + superuser_map ${username} ${username} + ''; + }; + + services.nginx.enable = true; + services.nginx.virtualHosts."${cfg.domain}" = { + addSSL = true; + enableACME = true; + serverName = cfg.domain; + locations."/" = { + recommendedProxySettings = true; + proxyPass = "http://unix:/run/${username}/web.sock"; + }; + }; + users.users.nginx.extraGroups = [ username ]; + systemd.services.lenticular-cloud = { description = "lenticular account"; after = [ "network.target" ]; wantedBy = [ "multi-user.target" ]; + requires = [ "ory-hydra.service" ]; enable = cfg.enable; environment = let - python_path = with python.pkgs; makePythonPath [ lenticular-cloud gevent psycopg2]; + python_path = with python.pkgs; makePythonPath [ pkgs.lenticular-cloud gevent ]; in { - CONFIG_FILE = "/etc/lenticular_cloud/production.conf"; + # CONFIG_FILE = "/etc/lenticular_cloud/production.conf"; + CONFIG_FILE = pkgs.writeText "lenticular-cloud.json" (builtins.toJSON cfg.settings); PYTHONPATH = "${python_path}"; # PYTHONPATH = "${lenticular-pkg.pythonPath}:${lenticular-pkg}/lib/python3.10/site-packages:${python_path}"; }; + preStart = '' + #cat > ${data_folder}/foobar.conf <