add nix flake
make a restart
This commit is contained in:
tuxcoder 2023-10-09 21:58:44 +02:00
parent 536668d8b9
commit eee18c1785
24 changed files with 509 additions and 231 deletions

1
.gitignore vendored
View file

@ -12,3 +12,4 @@ node_modules
/dist
build
result
nixos.qcow2

View file

@ -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

View file

@ -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",

View file

@ -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"];
})
];
};
};

View file

@ -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)

View file

@ -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__":

View file

@ -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
# }
# }

View file

@ -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:

View file

@ -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 ###

View file

@ -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")

View file

@ -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:

View file

@ -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),

View file

@ -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)

View file

@ -5,7 +5,7 @@
{% block content %}
<p>
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 }}
</p>
<p> Allow this app to access that data?</p>

View file

@ -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)

View file

@ -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']

View file

@ -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})

View file

@ -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",

View file

@ -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 <<EOF
#SECRET_KEY=""
#EOF
${pkgs.lenticular-cloud}/bin/lenticular_cloud-cli db_upgrade
'';
serviceConfig = {
Type = "simple";
WorkingDirectory = /var/lib/lenticular;
#User="lenticular"; #done by gunicorn
ExecStartPre = pkgs.writeScript "lenticular-cloud-server-init" ''
#!/bin/sh
#cat > /var/lib/lenticular/foobar.conf <<EOF
#SECRET_KEY=""
#EOF
${pkgs.lenticular-cloud}/bin/lenticular_cloud-cli db_upgrade
'';
WorkingDirectory = data_folder;
User = username;
ExecStart = ''${python.pkgs.gunicorn}/bin/gunicorn lenticular_cloud.wsgi --name lenticular_cloud \
-u lenticular \
-g lenticular \
--workers 3 --log-level=info \
--bind=unix:/run/lenticular.sock \
--bind=unix:/run/${username}/web.sock \
-k gevent'';
Restart = "on-failure";
RuntimeDirectory = username;
};
};

View file

@ -4,6 +4,16 @@ let
in {
python3 = prev.python3.override {
packageOverrides = final: prev: with final; {
sqlalchemy = prev.sqlalchemy.overridePythonAttrs (old: rec {
version = "2.0.19";
src = pkgs.fetchFromGitHub {
owner = "sqlalchemy";
repo = "sqlalchemy";
rev = "refs/tags/rel_${lib.replaceStrings [ "." ] [ "_" ] version}";
hash = "sha256-97q04wQVtlV2b6VJHxvnQ9ep76T5umn1KI3hXh6a8kU=";
};
disabledTestPaths = old.disabledTestPaths ++ [ "test/typing" ];
});
urlobject = buildPythonPackage rec {
pname = "URLObject";
version = "2.4.3";
@ -23,7 +33,7 @@ in {
inherit pname version;
sha256 = "a37dec5c3a21f13966178285d5c10691cd72203dcef8a01db802fef6287e716d";
};
doCheck = false;
doCheck = true;
propagatedBuildInputs = [
requests
oauthlib
@ -79,58 +89,64 @@ in {
typing-extensions
];
});
flask = prev.flask.overridePythonAttrs (old: {
propagatedBuildInputs = old.propagatedBuildInputs ++ flask.optional-dependencies.async;
});
lenticular-cloud = buildPythonPackage {
pname = "lenticular_cloud";
version = "0.3";
src = ./.;
propagatedBuildInputs = [
flask
flask-restful
flask_sqlalchemy
flask_wtf
flask-babel
flask_login
requests
requests_oauthlib
ldap3
#ldap3-orm
pyotp
cryptography
blinker
authlib # as oauth client lib
fido2 # for webauthn
flask_migrate # db migrations
flask-dance
ory-hydra-client
toml
pkgs.nodejs
#node-env
gunicorn
psycopg2
];
testBuildInputs = [
pytest
pytest-mypy
flask_testing
tox
types-dateutil
types-toml
nose
mypy
];
# passthru = {
# inherit python;
# pythonPath = python.pkgs.makePythonPath propagatedBuildInputs;
# };
doCheck = false;
checkInputs = [
pytest
];
};
};
};
lenticular-cloud = with final.python3.pkgs; buildPythonApplication {
pname = "lenticular_cloud";
version = "0.2";
src = ./.;
propagatedBuildInputs = [
flask
flask-restful
flask_sqlalchemy
flask_wtf
flask-babel
flask_login
requests
requests_oauthlib
ldap3
#ldap3-orm
pyotp
cryptography
blinker
authlib # as oauth client lib
fido2 # for webauthn
flask_migrate # db migrations
flask-dance
ory-hydra-client
pkgs.nodejs
#node-env
gunicorn
];
testBuildInputs = [
pytest
pytest-mypy
flask_testing
tox
types-dateutil
nose
mypy
];
# passthru = {
# inherit python;
# pythonPath = python.pkgs.makePythonPath propagatedBuildInputs;
# };
doCheck = false;
checkInputs = [
pytest
];
};
lenticular-cloud = final.python3.pkgs.lenticular-cloud;
}

0
production.toml Normal file
View file

0
tests/__init__.py Normal file
View file

17
tests/test_json.py Normal file
View file

@ -0,0 +1,17 @@
import unittest
from flask import jsonify
from uuid import uuid4
from lenticular_cloud.app import create_app
from lenticular_cloud.model import User
class TestBasicJsonFunction(unittest.TestCase):
def test_encode(self):
app = create_app()
uuid = uuid4()
with app.app_context():
text = jsonify(uuid)
print(text)