Compare commits

...

19 Commits

Author SHA1 Message Date
tuxcoder 368f2396ce remove totp, cleanup, bugfixes 2023-12-25 19:44:38 +01:00
tuxcoder bd7d8e4398 fix postfix compatiblity
sql is only a standard, ...
2023-12-25 18:57:27 +01:00
tuxcoder 0a1da35d84 implement basic passkey login flow 2023-12-25 18:55:20 +01:00
tuxcoder 926afee5c5 cleanup javascript build code
build static files allways from source
2023-12-25 17:52:02 +01:00
tuxcoder f858a1a78c add basic passkey management 2023-12-25 17:28:09 +01:00
tuxcoder 5759cb1e4f flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/d65bceaee0fb1e64363f7871bc43dc1c6ecad99f' (2023-12-20)
  → 'github:NixOS/nixpkgs/0b3d618173114c64ab666f557504d6982665d328' (2023-12-21)
2023-12-25 12:24:53 +01:00
tuxcoder c43d59db99 update npm packages 2023-12-24 14:46:20 +01:00
tuxcoder aaf91cb580 [hydra] also set redirect url on setup 2023-12-24 11:10:49 +01:00
tuxcoder 2d2766ac30 better python handling in nix module 2023-12-24 11:10:19 +01:00
tuxcoder ddbba31fe6 add config for admins 2023-12-24 11:09:41 +01:00
tuxcoder 04846aac0e better ory-hydra error handling 2023-12-23 02:41:39 +01:00
tuxcoder 632158b566 better oauth secret management 2023-12-23 02:41:26 +01:00
tuxcoder 85d04478d1 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/cf28ee258fd5f9a52de6b9865cdb93a1f96d09b7' (2023-12-12)
  → 'github:NixOS/nixpkgs/d65bceaee0fb1e64363f7871bc43dc1c6ecad99f' (2023-12-20)
2023-12-23 00:52:53 +01:00
tuxcoder 5ab1a0f39c add function to delete user 2023-12-22 23:09:37 +01:00
tuxcoder 3775c8eace parse uuid before usage 2023-12-17 17:10:41 +01:00
tuxcoder 4b1de43d43 fix nixos modules 2023-12-17 15:31:19 +01:00
tuxcoder 5a26d53106 remove old package overrides
this packages are now up to date in the stable channel 23.11
2023-12-17 15:21:50 +01:00
tuxcoder fbbe6e2c87 refactor nixos dev setup 2023-12-17 14:47:38 +01:00
tuxcoder 0494fb336f flake.lock: Update
Flake lock file updates:

• Updated input 'flake-utils':
    'github:numtide/flake-utils/ff7b65b44d01cf9ba6a71320833626af21126384' (2023-09-12)
  → 'github:numtide/flake-utils/4022d587cbbfd70fe950c1e2083a02621806a725' (2023-12-04)
• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/5e4c2ada4fcd54b99d56d7bd62f384511a7e2593' (2023-10-11)
  → 'github:NixOS/nixpkgs/cf28ee258fd5f9a52de6b9865cdb93a1f96d09b7' (2023-12-12)
• Updated input 'tuxpkgs':
    'git+ssh://git@git.o-g.at/nixpkg/tuxpkgs.git?ref=refs/heads/master&rev=a25f5792a256beaed2a9f944fccdea8ea7a8d44b' (2023-10-07)
  → 'git+ssh://git@git.o-g.at/nixpkg/tuxpkgs.git?ref=refs/heads/master&rev=b77ada84c29fc587b24b4ca838a0280272e654da' (2023-12-10)
2023-12-14 23:00:16 +01:00
51 changed files with 1556 additions and 3458 deletions

1
.gitignore vendored
View File

@ -13,3 +13,4 @@ node_modules
build
result
nixos.qcow2
/lenticular_cloud/static

View File

@ -2,7 +2,7 @@ Lenticular Cloud
================
Simple user Manager in LDAP
Simple user Manager proudly made in ~~LDAP~~ SQL
@ -11,15 +11,12 @@ Features
* frontend for hydra
* Web Platform to mange users
* client certs
* ldap backend, can be used by other services
* fake ldap backend, can be used by other services
Auth Methods:
-------------
* U2F (TODO)
* TOTP
* Password
* WebAuth (TODO)
* Passkey
@ -34,7 +31,20 @@ Tested Services
Oauth2 Settings:
----------------
Development
===========
callback url: `${domain}/
requirements:
* nix package manager
get dev enviroment with `nix develop`
run javascript part with `npm run watch`
run python stuff with `python cli.py run`
run tests with `nix flake check`

View File

@ -12,6 +12,7 @@ var asn1 = require('node-forge/lib/asn1');
var pkcs12 = require('node-forge/lib/pkcs12');
var util = require('node-forge/lib/util');
import SimpleFormSubmit from "simple-form-submit";
import {startRegistration, startAuthentication} from '@simplewebauthn/browser';
const $ = document.querySelector.bind(document);
const $$ = document.querySelectorAll.bind(document);
@ -100,44 +101,17 @@ window.auth = {
}
};
window.totp = {
init_list: function(){
window.auth_passkey = {
sign_up: async function(options) {
const resp = await startRegistration(options);
return resp;
},
init_new: function() {
//create new TOTP secret, create qrcode and ask for token.
var form = $('form');
var secret = randBase32();
var input_secret = form.querySelector('#secret')
if(input_secret.value == '') {
input_secret.value = secret;
}
form.querySelector('#name').onchange=window.totp.generate_qrcode;
form.querySelector('#name').onkeyup=window.totp.generate_qrcode;
window.totp.generate_qrcode();
sign_in: async function(options) {
const resp = await startAuthentication(options);
return resp;
},
generate_qrcode: function(){
var form = $('form');
var secret = form.querySelector('#secret').value;
var name = form.querySelector('#name').value;
var issuer = 'Lenticular%20Cloud';
var svg_container = $('#svg-container')
var svg = new QRCode(`otpauth://totp/${issuer}:${name}?secret=${secret}&issuer=${issuer}`).svg();
var svg_xml =new DOMParser().parseFromString(svg,'text/xml')
if(svg_container.childNodes.length > 0) {
svg_container.childNodes[0].replaceWith(svg_xml.childNodes[0])
} else {
svg_container.appendChild(svg_xml.childNodes[0]);
}
// .innerHtml=svg;
}
}
window.fido2 = {
init: function() {
}
}
window.password_change= {
init: function(){
var form = $('form');
@ -175,77 +149,3 @@ window.oauth2_token = {
return false;
}
}
window.client_cert = {
init_list: function() {
// do fancy cert stats stuff
},
init_new: function() {
// create localy key or import public key
var form = $('form#gen-key-form');
},
generate_private_key: function() {
var form = $('form#gen-key-form');
var key_size = form.querySelector('#key-size').value;
var valid_time = form.querySelector('input[name=valid_time]').value;
$('button#generate-key').style['display'] = 'none';
pki.rsa.generateKeyPair({bits: key_size, workers: 2}, function(err, keypair) {
console.log(keypair);
//returns the exported key to a hidden form
var form_sign_key = $('#gen-key-sign form');
form_sign_key.querySelector('textarea[name=publickey]').value = pki.publicKeyToPem(keypair.publicKey);
form_sign_key.querySelector('input[name=valid_time]').value = valid_time;
SimpleFormSubmit.submitForm(form_sign_key.action, form_sign_key)
.then(response => {
response.json().then( json_data => {
if (json_data.errors) {
var msg ='<ul>';
for( var field in json_data.repsonse) {
msg += `<li>${field}: ${data.errors[field]}</li>`;
}
msg += '</ul>';
new Dialog('Password change Error', `Error Happend: ${msg}`).show()
} else {
// get certificate
var data = response.data;
var certs = [
pki.certificateFromPem(data.cert),
pki.certificateFromPem(data.ca_cert)
];
var password = form.querySelector('#cert-password').value;
var p12Asn1;
if (password == '') {
p12Asn1 = pkcs12.toPkcs12Asn1(keypair.privateKey, certs, null, {algorithm: '3des'}); // without password
} else {
p12Asn1 = pkcs12.toPkcs12Asn1(keypair.privateKey, certs, password, {algorithm: '3des'}); // without password
}
var p12Der = asn1.toDer(p12Asn1).getBytes();
var p12b64 = util.encode64(p12Der);
var button = $('#save-button');
button.href= "data:application/x-pkcs12;base64," + p12b64
button.style['display'] ='block';
//new Dialog('Password changed', 'Password changed successfully!').show();
}
});
});
});
},
revoke_certificate: function(href, id){
var dialog = new ConfirmDialog('Revoke client certificate', `Are you sure to revoke the certificate with the fingerprint ${id}?`);
dialog.show().then(()=>{
fetch(href, {
method: 'DELETE'
});
});
return false;
}
};

View File

@ -21,11 +21,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1694529238,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
"lastModified": 1701680307,
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
"type": "github"
},
"original": {
@ -52,16 +52,16 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1697059129,
"narHash": "sha256-9NJcFF9CEYPvHJ5ckE8kvINvI84SZZ87PvqMbH6pro0=",
"lastModified": 1703200384,
"narHash": "sha256-q5j06XOsy0qHOarsYPfZYJPWbTbc8sryRxianlEPJN0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "5e4c2ada4fcd54b99d56d7bd62f384511a7e2593",
"rev": "0b3d618173114c64ab666f557504d6982665d328",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "nixos-unstable",
"ref": "nixos-23.11",
"type": "indirect"
}
},
@ -99,11 +99,11 @@
]
},
"locked": {
"lastModified": 1696700871,
"narHash": "sha256-9VFEJEfnfnCS1+kLznxd+OiDYdMnLP00+XR53iPfnK4=",
"lastModified": 1702764954,
"narHash": "sha256-+1z/0NJ/8c0d6Um1y9wpVO8CPXHd9/psOJF9GqFS/38=",
"ref": "refs/heads/master",
"rev": "a25f5792a256beaed2a9f944fccdea8ea7a8d44b",
"revCount": 6,
"rev": "dcea3067863899ee23950670e7fed2a4feccc20e",
"revCount": 13,
"type": "git",
"url": "ssh://git@git.o-g.at/nixpkg/tuxpkgs.git"
},

View File

@ -1,7 +1,7 @@
{
description = "Lenticular cloud interface";
inputs = {
nixpkgs.url = "nixpkgs/nixos-unstable";
nixpkgs.url = "nixpkgs/nixos-23.11";
flake-utils.url = "github:numtide/flake-utils";
flake-compat = { # for shell.nix
url = "github:edolstra/flake-compat";
@ -22,14 +22,16 @@
pkgs = nixpkgs.legacyPackages.${system}.extend (import ./overlay.nix);
in rec {
formatter = pkgs.nixpkgs-fmt;
devShells.default = pkgs.mkShell {packages = [
(pkgs.python3.withPackages (ps: (
pkgs.lenticular-cloud.propagatedBuildInputs ++
pkgs.lenticular-cloud.testBuildInputs
devShells.default = pkgs.mkShell {packages = with pkgs; [
(python3.withPackages (ps: (
lenticular-cloud.propagatedBuildInputs ++
lenticular-cloud.testBuildInputs
)))
nodejs
];};
packages.default = pkgs.lenticular-cloud;
packages.frontend = pkgs.lenticular-cloud-frontend;
checks = {
package = packages.default;
@ -46,20 +48,77 @@
self.nixosModules.default
tuxpkgs.nixosModules.ory-hydra
"${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix"
({...}:{
({lib, ...}:{
security.acme.acceptTerms = true;
security.acme.defaults.email = "acme@example.com";
services.lenticular-cloud = {
enable = true;
domain = "example.com";
service_domain = "account.example.com";
settings = {
HYDRA_ADMIN_URL = "http://127.0.0.1:8081";
HYDRA_PUBLIC_URL = "http://127.0.0.1:8082";
PUBLIC_URL = "http://127.0.0.1:5000";
ADMINS = [ "tuxcoder" ];
};
};
services.ory-hydra = {
enable = true;
admin_domain = "admin-hydra.local";
public_domain = "public-hydra.local";
extra_args = ["--dev"];
settings = {
urls.self = {
issuer = "http://127.0.0.1:8082";
public = "http://127.0.0.1:8082";
admin = "http://127.0.0.1:8081";
};
};
};
networking.hosts = {"::1" = [ "admin-hydra.local" ]; };
networking.hosts = {
"::1" = [ "admin-hydra.local" "public-hydra.local" "account.example.com" ];
};
networking.firewall.enable = false;
services.getty.autologinUser = "root";
virtualisation.qemu.options = ["-vga none"];
services.nginx.virtualHosts = {
"admin-hydra.local" = {
addSSL = lib.mkForce false;
enableACME = lib.mkForce false;
listen = [{
addr = "0.0.0.0";
port = 8081;
}];
locations."/" = {
extraConfig = ''
allow all;
'';
};
};
"public-hydra.local" = {
addSSL = lib.mkForce false;
enableACME = lib.mkForce false;
listen = [{
addr = "0.0.0.0";
port = 8082;
}];
};
};
virtualisation = {
forwardPorts = [ {
from = "host";
host.port = 8080;
guest.port = 80;
} {
from = "host";
host.port = 8081;
guest.port = 8081;
} {
from = "host";
host.port = 8082;
guest.port = 8082;
} ];
qemu.options = [ "-vga none" "-nographic" ];
};
})
];
};

View File

@ -1,6 +1,6 @@
from flask import current_app
from flask_wtf import FlaskForm
from .form.auth import PasswordForm, TotpForm, Fido2Form
from .form.auth import PasswordForm
from hmac import compare_digest as compare_hash
import crypt
from .model import User
@ -47,38 +47,8 @@ class PasswordAuthProvider(AuthProvider):
return compare_hash(crypt.crypt(password, user.password_hashed),user.password_hashed)
class U2FAuthProvider(AuthProvider):
@staticmethod
def get_from() -> FlaskForm:
return Fido2Form(prefix='fido2')
class WebAuthProvider(AuthProvider):
pass
class TotpAuthProvider(AuthProvider):
@staticmethod
def get_form():
return TotpForm(prefix='totp')
@staticmethod
def check_auth(user: User, form: FlaskForm) -> bool:
data = form.data['totp']
if data is not None:
#print(f'data totp: {data}')
if len(user.totps) == 0: # migration, TODO remove
return True
for totp in user.totps:
if totp.verify(data):
return True
return False
AUTH_PROVIDER_LIST = [
PasswordAuthProvider,
TotpAuthProvider
PasswordAuthProvider
]
#print(LdapAuthProvider.get_name())

View File

@ -6,6 +6,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix
from flask_migrate import upgrade
from pathlib import Path
from flask import Flask
from uuid import UUID
import logging
import os
@ -19,6 +20,10 @@ def entry_point() -> None:
parser_user = subparsers.add_parser('user')
parser_user.set_defaults(func=cli_user)
subparsers_user = parser_user.add_subparsers()
parser_user_delete = subparsers_user.add_parser('delete')
parser_user_delete.add_argument('--id', type=str)
parser_user_delete.set_defaults(func=cli_user_delete)
parser_signup = subparsers.add_parser('signup')
parser_signup.add_argument('--signup_id', type=str)
@ -61,6 +66,16 @@ def cli_user(args) -> None:
print(f'{user.id} - Enabled: {user.enabled} - Name:`{user.username}`')
pass
def cli_user_delete(args) -> None:
user = User.query.get(UUID(args.id))
if user is None:
print("user not found")
return
db.session.delete(user)
db.session.commit()
print(f"user {user.username} - {user.id} deleted")
pass
def cli_signup(args) -> None:
if args.signup_id is not None:
@ -78,6 +93,7 @@ def cli_signup(args) -> None:
print(f'<Signup id={user.id}, username={user.username}>')
def cli_run(app: Flask, args) -> None:
print("running in debug mode")
logging.basicConfig(level=logging.DEBUG)

View File

@ -12,17 +12,22 @@ SQLALCHEMY_TRACK_MODIFICATIONS = false
PKI_PATH = "../data/pki"
DOMAIN = 'example.com'
PUBLIC_URL = 'http://localhost:5000'
#SERVER_NAME = f'account.{ DOMAIN }:9090'
HYDRA_REQUEST_TIMEOUT_SECONDS = 3
HYDRA_ADMIN_URL = 'http://127.0.0.1:4445'
HYDRA_ADMIN_URL = 'http://127.0.0.1:8081'
HYDRA_ADMIN_USER = 'lenticluar_cloud'
HYDRA_ADMIN_PASSWORD = 'notSecure'
HYDRA_PUBLIC_URL = 'http://127.0.0.1:4444'
HYDRA_PUBLIC_URL = 'http://127.0.0.1:8082'
SUBJECT_PREFIX = 'something random'
OAUTH_ID = 'identiy_provider'
OAUTH_SECRET = 'thisIsNotSecure'
ADMINS = [
'tuxcoder'
]
[LENTICULAR_CLOUD_SERVICES.jabber]
app_token = true

View File

@ -20,22 +20,6 @@ class PasswordForm(FlaskForm):
password = PasswordField(gettext('Password'))
submit = SubmitField(gettext('Authorize'))
class TotpForm(FlaskForm):
totp = StringField(gettext('2FA Token'))
submit = SubmitField(gettext('Authorize'))
class WebauthnLoginForm(FlaskForm):
"""webauthn login form"""
assertion = HiddenField('Assertion', [InputRequired()])
class Fido2Form(FlaskForm):
fido2 = StringField(gettext('Fido2'), default="Javascript Required")
submit = SubmitField(gettext('Authorize'))
class ConsentForm(FlaskForm):
# scopes = SelectMultipleField(gettext('scopes'))
# audiences = SelectMultipleField(gettext('audiences'))

View File

@ -22,17 +22,6 @@ class ClientCertForm(FlaskForm):
])
submit = SubmitField(gettext('Submit'))
class TOTPForm(FlaskForm):
secret = HiddenField(gettext('totp-Secret'))
token = StringField(gettext('totp-verify token'))
name = StringField(gettext('name'))
submit = SubmitField(gettext('Activate'))
class TOTPDeleteForm(FlaskForm):
submit = SubmitField(gettext('Delete'))
class AppTokenForm(FlaskForm):
name = StringField(gettext('name'), validators=[DataRequired(),Length(min=1, max=255) ])
scopes = StringField(gettext('scopes'), validators=[DataRequired(),Length(min=1, max=255) ])
@ -41,11 +30,10 @@ class AppTokenForm(FlaskForm):
class AppTokenDeleteForm(FlaskForm):
submit = SubmitField(gettext('Delete'))
class WebauthnRegisterForm(FlaskForm):
"""webauthn register token form"""
class PasskeyRegisterForm(FlaskForm):
"""Passkey register form"""
attestation = HiddenField('Attestation', [InputRequired()])
name = StringField('Name', [Length(max=250)])
name = StringField('Name', [Length(max=50)])
submit = SubmitField('Register', render_kw={'disabled': True})
class PasswordChangeForm(FlaskForm):

View File

@ -23,7 +23,8 @@ class HydraService:
self.set_hydra_client(Client(base_url=app.config['HYDRA_ADMIN_URL']))
client_name = app.config['OAUTH_ID']
client_secret = token_hex(16)
client_secret = app.config['OAUTH_SECRET']
public_url = app.config['PUBLIC_URL']
clients = list_o_auth_2_clients.sync_detailed(_client=self.hydra_client).parsed
if clients is None:
@ -35,25 +36,26 @@ class HydraService:
break
if client is None:
domain = app.config['DOMAIN']
client = OAuth20Client(
client_req = 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" ],
redirect_uris=[ f"{public_url}/oauth/authorized" ],
token_endpoint_auth_method="client_secret_basic",
)
ret = create_o_auth_2_client.sync(json_body=client, _client=self.hydra_client)
ret = create_o_auth_2_client.sync(json_body=client_req, _client=self.hydra_client)
if ret is None:
raise RuntimeError("could not crate account")
raise RuntimeError("could not create account")
client = ret
else:
client.client_secret = client_secret
ret = set_o_auth_2_client.sync(id=client.client_id,json_body=client, _client=self.hydra_client)
client.redirect_uris = [ f"{public_url}/oauth/authorized" ]
ret = set_o_auth_2_client.sync(id=client.client_id, json_body=client, _client=self.hydra_client)
if ret is None:
raise RuntimeError("could not crate account")
raise RuntimeError("could not update 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

View File

@ -0,0 +1,40 @@
"""passkey
Revision ID: b5448df204eb
Revises: a74320a5d7a1
Create Date: 2023-12-25 00:13:01.703575
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b5448df204eb'
down_revision = 'a74320a5d7a1'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('passkey_credential',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('credential_id', sa.LargeBinary(), nullable=False),
sa.Column('credential_public_key', sa.LargeBinary(), nullable=False),
sa.Column('name', sa.String(length=250), nullable=False),
sa.Column('last_used', sa.DateTime(), nullable=True),
sa.Column('sign_count', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('modified_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('passkey_credential')
# ### end Alembic commands ###

View File

@ -163,15 +163,8 @@ class User(BaseModel, ModelUpdatedMixin):
enabled: Mapped[bool] = mapped_column(db.Boolean, nullable=False, default=False)
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)
passkey_credentials: Mapped[List['PasskeyCredential']] = relationship('PasskeyCredential', back_populates='user', cascade='delete,delete-orphan', passive_deletes=True)
@property
def totps(self) -> List['Totp']:
return []
@property
def webauthn_credentials(self) -> List['WebauthnCredential']:
return []
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
@ -185,7 +178,8 @@ class User(BaseModel, ModelUpdatedMixin):
@property
def groups(self) -> list['Group']:
if self.username == 'tuxcoder':
admins = current_app.config['ADMINS']
if self.username in admins:
return [Group(name='admin')]
else:
return []
@ -228,33 +222,20 @@ class AppToken(BaseModel, ModelUpdatedMixin):
token = ''.join(secrets.choice(alphabet) for i in range(12))
return AppToken(scopes=scopes, token=token, user=user, name=name)
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: Mapped[uuid.UUID] = mapped_column(
db.Uuid,
db.ForeignKey(User.id), nullable=False)
# 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, ModelUpdatedMixin): # pylint: disable=too-few-public-methods
"""Webauthn credential model"""
class PasskeyCredential(BaseModel, ModelUpdatedMixin): # pylint: disable=too-few-public-methods
"""Passkey credential model"""
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)
credential_id: Mapped[bytes] = mapped_column(db.LargeBinary, nullable=False)
credential_public_key: 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)
last_used: Mapped[Optional[datetime]] = mapped_column(db.DateTime, nullable=True, default=None)
sign_count: Mapped[int] = mapped_column(db.Integer, nullable=False, default=0)
# user = db.relationship('User', back_populates='webauthn_credentials')
user = db.relationship('User', back_populates='passkey_credentials')
class Group(BaseModel, ModelUpdatedMixin):

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,79 +0,0 @@
/*!
* Bootstrap v4.6.1 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
/*!
* Sizzle CSS Selector Engine v2.3.6
* https://sizzlejs.com/
*
* Copyright JS Foundation and other contributors
* Released under the MIT license
* https://js.foundation/
*
* Date: 2021-02-16
*/
/*!
* jQuery Form Plugin
* version: 4.3.0
* Requires jQuery v1.7.2 or later
* Project repository: https://github.com/jquery-form/form
* Copyright 2017 Kevin Morris
* Copyright 2006 M. Alsup
* Dual licensed under the LGPL-2.1+ or MIT licenses
* https://github.com/jquery-form/form#license
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*/
/*!
* jQuery JavaScript Library v3.6.0
* https://jquery.com/
*
* Includes Sizzle.js
* https://sizzlejs.com/
*
* Copyright OpenJS Foundation and other contributors
* Released under the MIT license
* https://jquery.org/license
*
* Date: 2021-03-02T17:08Z
*/
/*! For license information please see cbor.js.LICENSE.txt */
/**!
* @fileOverview Kickass library to create and place poppers near their reference elements.
* @version 1.16.1
* @license
* Copyright (c) 2016 Federico Zivolo and contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/

File diff suppressed because one or more lines are too long

View File

@ -2,12 +2,53 @@
{% block title %}{{ gettext('Login') }}{% endblock %}
{% block content %}
{% block script %}
<script>
const options_req = {{ options }};
const token = "{{ token }}";
const login_challenge = "{{ login_challenge }}";
{{ render_form(form) }}
async function login() {
const credential = await auth_passkey.sign_in(options_req);
const response = await fetch("{{ url_for('.passkey_verify') }}", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
token,
credential,
login_challenge,
}),
})
return await response.json()
}
<a href="{{ url_for('.sign_up') }}" class="btn btn-primary">Sign Up</a>
let form = document.getElementById('webauthn_register_form');
form.onsubmit = ev => {
ev.preventDefault()
login().then( response => {
document.location = response.redirect;
})
};
</script>
{% endblock %}
{% block content %}
<div class="row">
{{ render_form(form) }}
</div>
<div class="row">
<form id="webauthn_register_form">
<button class="btn btn-primary">Login wiht Passkey</button>
</form>
</div>
<div class="row">
<a href="{{ url_for('.sign_up') }}" class="btn btn-secondary">Sign Up</a>
</div>
{% endblock %}

View File

@ -213,9 +213,9 @@
action_text - text of submit button
class_ - sets a class for form
#}
{% macro render_form(form, action_url='', action_text='Submit', class_='', btn_class='btn btn-primary', method='post', onsubmit='') -%}
{% macro render_form(form, action_url='', id='', action_text='Submit', class_='', btn_class='btn btn-primary', method='post', onsubmit='') -%}
<form method="{{ method }}" {% if action_url %}action="{{ action_url }}" {% endif %}{% if onsubmit %}onsubmit="{{ onsubmit }}" {% endif %}role="form" class="{{ class_ }}">
<form method="{{ method }}" {% if id %}id="{{ id }}" {% endif %}{% if action_url %}action="{{ action_url }}" {% endif %}{% if onsubmit %}onsubmit="{{ onsubmit }}" {% endif %}role="form" class="{{ class_ }}">
<input name="form" type="hidden" value="{{ form.__class__.__name__ }}">
{{ _render_form(form) }}
{% if not form.submit %}

View File

@ -17,9 +17,9 @@
<div class="sidebar-sticky active">
{#<a href="/"><img alt="logo" class="container-fluid" src="/static/images/dog_main_small.png"></a>#}
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.index') }}">{{ gettext('Account') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.client_cert') }}">{{ gettext('Client Cert') }}</a></li>
{#<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.client_cert') }}">{{ gettext('Client Cert') }}</a></li>#}
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.passkey') }}">{{ gettext('Passkey') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.app_token') }}">{{ gettext('App Tokens') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.totp') }}">{{ gettext('2FA - TOTP') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.oauth2_tokens') }}">{{ gettext('Oauth2 Tokens') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.password_change') }}">{{ gettext('Password Change') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.logout') }}">{{ gettext('Logout') }}</a></li>

View File

@ -0,0 +1,32 @@
{% extends 'frontend/base.html.j2' %}
{% block content %}
<div class="users">
<h1>Passkey Credentials list</h1>
<table class="table">
<thead>
<tr>
<th>name</th>
<th>id</th>
<th>last used</th>
<th>created at</th>
<th>actions</th>
</tr>
</thead>
<tbody>
{% for credential in credentials %}
<tr>
<td>{{ credential.name }}</td>
<td>{{ credential.credential_id[0:8].hex() }}...</td>
<td>{{ credential.last_used }}</td>
<td>{{ credential.created_at }}...</td>
<td>
{{ render_form(button_form, action_url=url_for('.passkey_delete', id=credential.id), action_text='delete', btn_class='btn btn-danger') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<a class="btn btn-primary" href="{{ url_for('.passkey_new')}}">Add new Passkey</a>
</div>
{% endblock %}

View File

@ -0,0 +1,54 @@
{#- This file is part of sner4 project governed by MIT license, see the LICENSE.txt file. -#}
{% extends 'frontend/base.html.j2' %}
{% block script %}
<script>
let options_req = {{ options }};
let token = "{{ token }}";
let form = document.getElementById('webauthn_register_form');
async function register() {
let credential = await auth_passkey.sign_up(options_req);
let name = form.querySelector('#name').value;
let response = await fetch("{{ url_for('.passkey_new_process') }}", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
token,
credential,
name
}),
})
}
form.onsubmit = ev => {
ev.preventDefault()
register().then( result => {
document.location = "{{ url_for('.passkey') }}";
})
};
</script>
{% endblock %}
{% block content %}
<div class="profile">
<h1>Register new Passkey credential</h1>
<div>
To register new credential:
<ol>
<li>Insert/connect authenticator and verify user presence.</li>
<li>Set name for the new credential.</li>
<li>Submit the registration.</li>
</ol>
</div>
{{ render_form(form, id="webauthn_register_form") }}
</div>
{% endblock %}

View File

@ -1,35 +0,0 @@
{% extends 'frontend/base.html.j2' %}
{% block title %}{{ gettext('2FA - TOTP') }}{% endblock %}
{% block content %}
<table class="table">
<thead>
<tr>
<th>name</th>
<th>created_at</th>
<th>action<th>
</tr>
</thead>
<tbody>
{% for totp in current_user.totps %}
<tr>
<td>{{ totp.name }}</td>
<td>{{ totp.created_at }}</td>
<td>{{ render_form(delete_form, action_url=url_for('frontend.totp_delete', totp_name=totp.name)) }}</td>
{% endfor %}
</table>
<a class="btn btn-default" href="{{ url_for('frontend.totp_new') }}">
New TOTP
</a>
{% endblock %}
{% block script_js %}
totp.init_list();
{% endblock %}

View File

@ -1,20 +0,0 @@
{% extends 'frontend/base.html.j2' %}
{% block title %}{{ gettext('2FA - TOTP - New') }}{% endblock %}
{% block content %}
{{ render_form(form) }}
<div id="svg-container">
</div>
{% endblock %}
{% block script_js %}
totp.init_new();
{% endblock %}

View File

@ -1,30 +0,0 @@
{% extends 'frontend/base.html.j2' %}
{% block content %}
<div class="users">
<h1>WebauthnCredentials list</h1>
<table class="table">
<thead>
<tr>
<th>user.username</th>
<th>user_handle</th>
<th>credential_data</th>
<th>name</th>
<th>_actions</th>
</tr>
</thead>
<tbody>
{% for cred in creds %}
<tr>
<td>{{ cred.user.username }}</td>
<td>{{ cred.user_handle }}</td>
<td>{{ cred.credential_data[0:40] }}...</td>
<td>{{ cred.name }}</td>
<td>{{ render_form(button_form, action_url=url_for('app.webauthn_delete_route', webauthn_id=cred.id)) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -1,140 +0,0 @@
{#- This file is part of sner4 project governed by MIT license, see the LICENSE.txt file. -#}
{% extends 'frontend/base.html.j2' %}
{% block script %}
<script>
/**
* decode base64 data to ArrayBuffer
*
* @param {string} data data to decode
* @return {ArrayBuffer} decoded data
*/
function base64_to_array_buffer(data) {
return Uint8Array.from(atob(data), c => c.charCodeAt(0)).buffer;
}
/**
* request publicKeyCredentialCreationOptions for webauthn from server
*
* @return {Promise<Object>} A promise that resolves with publicKeyCredentialCreationOptions for navigator.credentials.create()
*/
function get_pkcco() {
return fetch("{{ url_for('frontend.webauthn_pkcco_route')}}", {method:'post', headers: {'Content-Type': 'application/json'}})
.then(function(resp) {
return resp.text();
})
.then(function(data){
var pkcco = CBOR.decode(base64_to_array_buffer(data));
console.debug('credentials.create options:', pkcco);
var publicKey = {
// The challenge is produced by the server; see the Security Considerations
challenge: new Uint8Array([21,31,105 /* 29 more random bytes generated by the server */]),
// Relying Party:
rp: {
name: "Lenticular Cloud - domain TODO"
},
// User:
user: {
id: Uint8Array.from(window.atob("MIIBkzCCATigAwIBAjCCAZMwggE4oAMCAQIwggGTMII="), c=>c.charCodeAt(0)),
name: "{user.domain}",
displayName: "{user.name}",
},
// This Relying Party will accept either an ES256 or RS256 credential, but
// prefers an ES256 credential.
pubKeyCredParams: [
{
type: "public-key",
alg: -7 // "ES256" as registered in the IANA COSE Algorithms registry
},
{
type: "public-key",
alg: -257 // Value registered by this specification for "RS256"
}
],
authenticatorSelection: {
// Try to use UV if possible. This is also the default.
userVerification: "preferred"
},
timeout: 360000, // 6 minutes
excludeCredentials: [
// Dont re-register any authenticator that has one of these credentials
//{"id": Uint8Array.from(window.atob("E/e1dhZc++mIsz4f9hb6NifAzJpF1V4mEtRlIPBiWdY="), c=>c.charCodeAt(0)), "type": "public-key"}
],
// Make excludeCredentials check backwards compatible with credentials registered with U2F
extensions: {"appidExclude": "https://acme.example.com"}
};
return { "publicKey": publicKey };
})
.catch(function(error) { console.log('cant get pkcco ',error)});
}
/**
* pack attestation
*
* @param {object} attestation attestation response for the credential to register
*/
function pack_attestation(attestation) {
console.debug('new credential attestation:', attestation);
var attestation_data = {
'clientDataJSON': new Uint8Array(attestation.response.clientDataJSON),
'attestationObject': new Uint8Array(attestation.response.attestationObject)
};
//var form = $('#webauthn_register_form')[0];
var form = document.querySelector('form')
var base64 = btoa(new Uint8Array(CBOR.encode(attestation_data)).reduce((data, byte) => data + String.fromCharCode(byte), ''));
form.attestation.value = base64;
form.submit.disabled = false;
//form.querySelecotr('p[name="attestation_data_status"]').innerHTML = '<span style="color: green;">Prepared</span>';
}
console.log(window.PublicKeyCredential ? 'WebAuthn supported' : 'WebAuthn NOT supported');
get_pkcco()
.then(pkcco => navigator.credentials.create(pkcco))
.then(attestation_response => pack_attestation(attestation_response))
.catch(function(error) {
//toastr.error('Registration data preparation failed.');
console.log(error.message);
});
</script>
{% endblock %}
{% block content %}
<div class="profile">
<h1>Register new Webauthn credential</h1>
<div>
To register new credential:
<ol>
<li>Insert/connect authenticator and verify user presence.</li>
<li>Optionaly set comment for the new credential.</li>
<li>Submit the registration.</li>
</ol>
</div>
{{ render_form(form) }}
{#
<form id="webauthn_register_form" class="form-horizontal" method="post">
{{ form.csrf_token }}
<div class="form-group">
<label class="col-sm-2 control-label">Registration data</label>
<div class="col-sm-10"><p class="form-control-static" name="attestation_data_status"><span style="color: orange;">To be prepared</span></p></div>
</div>
{{ b_wtf.bootstrap_field(form.attestation, horizontal=True) }}
{{ b_wtf.bootstrap_field(form.name, horizontal=True) }}
{{ b_wtf.bootstrap_field(form.submit, horizontal=True) }}
</form>
#}
</div>
{% endblock %}

View File

@ -1,39 +1,35 @@
from urllib.parse import urlencode, parse_qs
import flask
from flask import Blueprint, redirect, flash, current_app, session
from flask.templating import render_template
from flask_babel import gettext
from flask.typing import ResponseReturnValue
from flask import request, url_for, jsonify
from flask_login import login_required, login_user, logout_user, current_user
import logging
from urllib.parse import urlparse
from base64 import b64decode, b64encode
import http
from base64 import b64encode, b64decode, urlsafe_b64decode
import crypt
from datetime import datetime
from datetime import datetime, timedelta
import jwt
from flask import request, url_for, jsonify, Blueprint, redirect, current_app, session
from flask.templating import render_template
from flask.typing import ResponseReturnValue
import logging
import json
from ory_hydra_client.api.o_auth_2 import get_o_auth_2_consent_request, accept_o_auth_2_consent_request, accept_o_auth_2_login_request, get_o_auth_2_login_request, accept_o_auth_2_login_request, accept_o_auth_2_logout_request, get_o_auth_2_login_request
from ory_hydra_client import models as ory_hydra_m
from ory_hydra_client.models import TheRequestPayloadUsedToAcceptALoginOrConsentRequest, TheRequestPayloadUsedToAcceptAConsentRequest, GenericError
from ory_hydra_client.api.o_auth_2 import get_o_auth_2_consent_request, accept_o_auth_2_consent_request, accept_o_auth_2_login_request, get_o_auth_2_login_request, accept_o_auth_2_login_request, accept_o_auth_2_logout_request, get_o_auth_2_login_request
from ory_hydra_client.models import TheRequestPayloadUsedToAcceptAConsentRequest, GenericError
from typing import Optional
from uuid import uuid4
from urllib.parse import urlparse
from uuid import uuid4, UUID
import webauthn
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
from ..model import db, User, SecurityUser
from ..model import db, User, PasskeyCredential
from ..form.auth import ConsentForm, LoginForm, RegistrationForm
from ..auth_providers import AUTH_PROVIDER_LIST
from ..hydra import hydra_service
from ..wrapped_fido2_server import WrappedFido2Server
logger = logging.getLogger(__name__)
auth_views = Blueprint('auth', __name__, url_prefix='/auth')
webauthn = WrappedFido2Server()
@auth_views.route('/consent', methods=['GET', 'POST'])
@ -54,8 +50,14 @@ async def consent() -> ResponseReturnValue:
requested_audiences = consent_request.requested_access_token_audience
if form.validate_on_submit() or consent_request.skip:
user = User.query.get(consent_request.subject) # type: Optional[User]
if type(consent_request.subject) != str:
logger.error("not set subject `consent_request.subject`")
return 'internal error', 500
uid = UUID(consent_request.subject)
user = User.query.get(uid)
if user is None:
logger.error("user not found, even if it should exist")
return 'internal error', 500
access_token = {
'name': str(user.username),
@ -96,6 +98,9 @@ async def consent() -> ResponseReturnValue:
@auth_views.route('/login', methods=['GET', 'POST'])
async def login() -> ResponseReturnValue:
secret_key = current_app.config['SECRET_KEY']
public_url = urlparse(current_app.config['PUBLIC_URL'])
login_challenge = request.args.get('login_challenge')
if login_challenge is None:
return 'login_challenge missing', 400
@ -104,6 +109,21 @@ async def login() -> ResponseReturnValue:
logger.exception("could not fetch login request")
return redirect(url_for('frontend.index'))
## passkey
options = webauthn.generate_authentication_options(
rp_id = public_url.hostname,
user_verification = UserVerificationRequirement.REQUIRED,
challenge=webauthn.helpers.generate_challenge(32)
)
token = jwt.encode({
'challenge': b64encode(options.challenge).decode(),
'iat': datetime.utcnow() - timedelta(minutes=1),
'nbf': datetime.utcnow(),
'exp': datetime.utcnow() + timedelta(minutes=15),
}, secret_key, algorithm="HS256"
)
##
if login_request.skip:
resp = await accept_o_auth_2_login_request.asyncio(_client=hydra_service.hydra_client,
login_challenge=login_challenge,
@ -122,7 +142,13 @@ async def login() -> ResponseReturnValue:
session['auth_providers'] = []
return redirect(
url_for('auth.login_auth', login_challenge=login_challenge))
return render_template('auth/login.html.j2', form=form)
return render_template(
'auth/login.html.j2',
form=form,
options=webauthn.options_to_json(options),
token=token,
login_challenge=login_challenge,
)
@auth_views.route('/login/auth', methods=['GET', 'POST'])
@ -169,21 +195,54 @@ async def login_auth() -> ResponseReturnValue:
return render_template('auth/login_auth.html.j2', forms=auth_forms)
@auth_views.route('/passkey/verify', methods=['POST'])
async def passkey_verify() -> ResponseReturnValue:
secret_key = current_app.config['SECRET_KEY']
public_url = current_app.config['PUBLIC_URL']
@auth_views.route('/webauthn/pkcro', methods=['POST'])
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()
if user and form.validate_on_submit():
pkcro, state = webauthn.authenticate_begin(webauthn_credentials(user))
session['webauthn_login_state'] = state
return Response(b64encode(cbor.encode(pkcro)).decode('utf-8'), mimetype='text/plain')
data = request.get_json()
token = jwt.decode(data['token'], secret_key, algorithms=['HS256'])
challenge = urlsafe_b64decode(token['challenge'])
credential = data['credential']
credential_id = urlsafe_b64decode(credential['id'])
return '', HTTPStatus.BAD_REQUEST
login_challenge = data['login_challenge']
if login_challenge is None:
return 'missing login_challenge, bad request', 400
login_request = await get_o_auth_2_login_request.asyncio(_client=hydra_service.hydra_client, login_challenge=login_challenge)
if login_request is None:
return redirect(url_for('frontend.index'))
passkey = PasskeyCredential.query.filter(PasskeyCredential.credential_id == credential_id).first_or_404()
result = webauthn.verify_authentication_response(
credential = credential,
expected_rp_id = "localhost",
expected_challenge = challenge,
expected_origin = [ public_url ],
credential_public_key = passkey.credential_public_key,
credential_current_sign_count = passkey.sign_count,
)
logger.error(f"DEBUG: {passkey}")
logger.error(f"DEBUG: {result}")
passkey.sign_count = result.new_sign_count
passkey.last_used = datetime.utcnow()
user = passkey.user
user.last_login = datetime.now()
subject = str(user.id)
resp = await accept_o_auth_2_login_request.asyncio(_client=hydra_service.hydra_client,
login_challenge=login_challenge, json_body=ory_hydra_m.HandledLoginRequestIsTheRequestPayloadUsedToAcceptALoginRequest(
subject=subject,
remember=True,
))
if resp is None or isinstance( resp, GenericError):
return 'internal error, could not forward request', 503
db.session.commit()
return jsonify({'redirect': resp.redirect_to})
@auth_views.route("/logout")
async def logout() -> ResponseReturnValue:

View File

@ -1,35 +1,35 @@
from authlib.integrations.base_client.errors import MissingTokenError, InvalidTokenError
from base64 import b64encode, b64decode
from fido2 import cbor
from fido2.webauthn import CollectedClientData, AttestationObject, AttestedCredentialData, AuthenticatorData, PublicKeyCredentialUserEntity
from flask import Blueprint, Response, redirect, request
from flask import Blueprint, redirect, request
from flask import current_app
from flask import jsonify, session, flash
from flask import jsonify, session
from flask import render_template, url_for
from flask_login import login_user, logout_user, current_user
from flask_login import logout_user, current_user
from http import HTTPStatus
from werkzeug.utils import redirect
import logging
from datetime import timedelta
from base64 import b64decode
from flask.typing import ResponseReturnValue
from oauthlib.oauth2.rfc6749.errors import TokenExpiredError
from ory_hydra_client.api.o_auth_2 import list_o_auth_2_consent_sessions, revoke_o_auth_2_consent_sessions
from ory_hydra_client.models import GenericError
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, Any
from urllib.parse import urlparse
from typing import Optional, Any
import jwt
from datetime import datetime, timedelta
import webauthn
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
from ..model import db, User, SecurityUser, Totp, AppToken, WebauthnCredential
from ..form.frontend import ClientCertForm, TOTPForm, \
TOTPDeleteForm, PasswordChangeForm, WebauthnRegisterForm, \
AppTokenForm, AppTokenDeleteForm
from ..model import db, User, AppToken, PasskeyCredential
from ..form.frontend import ClientCertForm, PasswordChangeForm, \
AppTokenForm, AppTokenDeleteForm, PasskeyRegisterForm
from ..form.base import ButtonForm
from ..auth_providers import PasswordAuthProvider
from .auth import webauthn
from .oauth2 import redirect_login, oauth2
from ..hydra import hydra_service
from ..pki import pki
@ -187,132 +187,109 @@ def app_token_delete(app_token_name: str) -> ResponseReturnValue:
return redirect(url_for('frontend.app_token'))
@frontend_views.route('/totp')
def totp() -> ResponseReturnValue:
delete_form = TOTPDeleteForm()
return render_template('frontend/totp.html.j2', delete_form=delete_form)
## Passkey
@frontend_views.route('/totp/new', methods=['GET', 'POST'])
def totp_new() -> ResponseReturnValue:
form = TOTPForm()
if form.validate_on_submit():
totp = Totp(name=form.data['name'], secret=form.data['secret'], user=get_current_user())
if totp.verify(form.data['token']):
get_current_user().totps.append(totp)
db.session.commit()
return jsonify({
'status': 'ok'})
else:
return jsonify({
'status': 'error',
'errors': [
'TOTP Token invalid'
]})
return render_template('frontend/totp_new.html.j2', form=form)
@frontend_views.route('/totp/<totp_name>/delete', methods=['GET', 'POST'])
def totp_delete(totp_name) -> ResponseReturnValue:
totp = Totp.query.filter(Totp.name == totp_name).first() # type: Optional[Totp]
db.session.delete(totp)
db.session.commit()
return jsonify({
'status': 'ok'})
@frontend_views.route('/webauthn/list', methods=['GET'])
def webauthn_list_route() -> ResponseReturnValue:
@frontend_views.route('/passkey/list', methods=['GET'])
def passkey() -> ResponseReturnValue:
"""list registered credentials for current user"""
creds = WebauthnCredential.query.all() # type: Iterable[WebauthnCredential]
return render_template('frontend/webauthn_list.html', creds=creds, button_form=ButtonForm())
credentials = PasskeyCredential.query.all()
return render_template('frontend/passkey_list.html.j2', credentials=credentials, button_form=ButtonForm())
@frontend_views.route('/webauthn/delete/<webauthn_id>', methods=['POST'])
def webauthn_delete_route(webauthn_id: str) -> ResponseReturnValue:
@frontend_views.route('/passkey/new', methods=['GET'])
def passkey_new() -> ResponseReturnValue:
"""register credential for current user"""
public_url = urlparse(current_app.config['PUBLIC_URL'])
user = get_current_user() # type: User
form = PasskeyRegisterForm()
options = webauthn.generate_registration_options(
rp_name="Lenticluar Cloud",
rp_id=public_url.hostname,
user_id=str(user.id),
user_name=user.username,
authenticator_selection=AuthenticatorSelectionCriteria(
user_verification=UserVerificationRequirement.REQUIRED,
resident_key=ResidentKeyRequirement.REQUIRED,
),
exclude_credentials = list(map(lambda x: PublicKeyCredentialDescriptor(id=x.credential_id), user.passkey_credentials))
)
secret_key = current_app.config['SECRET_KEY']
token = jwt.encode({
'challenge': b64encode(options.challenge).decode(),
'iat': datetime.utcnow() - timedelta(minutes=1),
'nbf': datetime.utcnow(),
'exp': datetime.utcnow() + timedelta(minutes=15),
}, secret_key, algorithm="HS256"
)
return render_template(
'frontend/passkey_new.html.j2',
form=form,
options=webauthn.options_to_json(options),
token=token,
)
@frontend_views.route('/passkey/new', methods=['POST'])
def passkey_new_process() -> ResponseReturnValue:
secret_key = current_app.config['SECRET_KEY']
public_url = urlparse(current_app.config['PUBLIC_URL'])
user = get_current_user()
data = request.get_json()
try:
token = jwt.decode(
data['token'], secret_key, algorithms=['HS256'],
options = {
'require': ["challenge", "exp", "iat", "nbf"],
})
except jwt.exceptions.MissingRequiredClaimError:
return jsonify({'message': "invalid token"}), 400
challenge = b64decode(token['challenge'])
credential = data['credential']
name = data['name']
result = webauthn.verify_registration_response(
credential = credential,
expected_rp_id = public_url.hostname,
expected_challenge = challenge,
expected_origin = [ public_url.geturl() ],
)
if not result.user_verified:
return jsonify({ "message": "invalid auth" }), 403
db.session.add(PasskeyCredential(
id=None,
user_id=user.id,
credential_id=result.credential_id,
credential_public_key=result.credential_public_key,
name=name,
))
db.session.commit()
logger.info(f"add new passkey for user {user.username}")
return jsonify({})
@frontend_views.route('/passkey/delete/<id>', methods=['POST'])
def passkey_delete(id: str) -> ResponseReturnValue:
"""delete registered credential"""
form = ButtonForm()
if form.validate_on_submit():
cred = WebauthnCredential.query.filter(WebauthnCredential.id == webauthn_id).one() # type: WebauthnCredential
cred = PasskeyCredential.query.filter(PasskeyCredential.id == id).first_or_404()
db.session.delete(cred)
db.session.commit()
return redirect(url_for('app.webauthn_list_route'))
return redirect(url_for('.passkey'))
return '', HTTPStatus.BAD_REQUEST
def webauthn_credentials(user: User) -> list[AttestedCredentialData]:
"""get and decode all credentials for given user"""
def decode(creds: List[WebauthnCredential]) -> Iterator[AttestedCredentialData]:
for cred in creds:
data = cbor.decode(cred.credential_data)
if isinstance(data, Mapping):
yield AttestedCredentialData.create(**data)
return list(decode(user.webauthn_credentials))
def random_string(length=32) -> str:
"""generates random string"""
return ''.join([SystemRandom().choice(string.ascii_letters + string.digits) for i in range(length)])
@frontend_views.route('/webauthn/pkcco', methods=['POST'])
def webauthn_pkcco_route() -> ResponseReturnValue:
"""get publicKeyCredentialCreationOptions"""
user = User.query.get(get_current_user().id) #type: Optional[User]
if user is None:
return 'internal error', 500
user_handle = random_string()
exclude_credentials = webauthn_credentials(user)
pkcco, state = webauthn.register_begin(
user=PublicKeyCredentialUserEntity(id=user_handle.encode('utf-8'), name=user.username, display_name=user.username),
credentials=exclude_credentials
)
session['webauthn_register_user_handle'] = user_handle
session['webauthn_register_state'] = state
return Response(b64encode(cbor.encode(pkcco)).decode('utf-8'), mimetype='text/plain')
@frontend_views.route('/webauthn/register', methods=['GET', 'POST'])
def webauthn_register_route() -> ResponseReturnValue:
"""register credential for current user"""
user = get_current_user() # type: User
form = WebauthnRegisterForm()
if form.validate_on_submit():
try:
attestation = cbor.decode(b64decode(form.attestation.data))
if not isinstance(attestation, Mapping) or 'clientDataJSON' not in attestation or 'attestationObject' not in attestation:
return 'invalid attestion data', 400
auth_data = webauthn.register_complete(
session.pop('webauthn_register_state'),
CollectedClientData(attestation['clientDataJSON']),
AttestationObject(attestation['attestationObject']))
db.session.add(WebauthnCredential(
user=user,
user_handle=session.pop('webauthn_register_user_handle'),
credential_data=cbor.encode(auth_data.credential_data.__dict__),
name=form.name.data))
db.session.commit()
return redirect(url_for('app.webauthn_list_route'))
except (KeyError, ValueError) as e:
logger.exception(e)
flash('Error during registration.', 'error')
return render_template('frontend/webauthn_register.html', form=form)
@frontend_views.route('/password_change')
def password_change() -> ResponseReturnValue:
form = PasswordChangeForm()

View File

@ -1,12 +1,13 @@
from authlib.integrations.flask_client import OAuth
from authlib.integrations.base_client.errors import MismatchingStateError
from flask import Flask, Blueprint, Response, session, request, redirect, url_for
from flask_login import login_user, logout_user, current_user
from authlib.integrations.base_client.errors import MismatchingStateError, OAuthError
from flask import Flask, Blueprint, current_app, session, request, redirect, url_for
from flask_login import login_user, logout_user
from flask.typing import ResponseReturnValue
from flask_login import LoginManager
from typing import Optional
from werkzeug.wrappers.response import Response as WerkzeugResponse
import logging
from uuid import UUID
from ..model import User, SecurityUser
from ..hydra import hydra_service
@ -28,7 +29,8 @@ login_manager = LoginManager()
def redirect_login() -> ResponseReturnValue:
logout_user()
session['next_url'] = request.path
redirect_uri = url_for('oauth2.authorized', _external=True)
public_url = current_app.config['PUBLIC_URL']
redirect_uri = public_url + url_for('oauth2.authorized')
response = oauth2.custom.authorize_redirect(redirect_uri)
if not isinstance(response, WerkzeugResponse):
raise RuntimeError("invalid redirect")
@ -42,11 +44,14 @@ def authorized() -> ResponseReturnValue:
except MismatchingStateError:
logger.warning("MismatchingStateError redirect user")
return redirect(url_for('oauth2.login'))
except OAuthError as e:
logger.warning(f"OAuthError redirect user {e}")
return redirect(url_for('oauth2.login'))
if token is None:
return 'bad request', 400
session['token'] = token
userinfo = oauth2.custom.get('/userinfo').json()
user = User.query.get(str(userinfo["sub"])) # type: Optional[User]
user = User.query.get(UUID(userinfo["sub"])) # type: Optional[User]
if user is None:
return "user not found", 404
logger.info(f"user `{user.username}` successfully logged in")

View File

@ -1,21 +0,0 @@
# This file is part of sner4 project governed by MIT license, see the LICENSE.txt file.
# source: https://github.com/bodik/flask-webauthn-example/blob/master/fwe/wrapped_fido2_server.py
"""
yubico fido2 server wrapped for flask factory pattern delayed configuration
"""
from socket import getfqdn
from fido2.server import Fido2Server, PublicKeyCredentialRpEntity
class WrappedFido2Server(Fido2Server):
"""yubico fido2 server wrapped for flask factory pattern delayed configuration"""
def __init__(self):
"""initialize with default rp name"""
super().__init__(PublicKeyCredentialRpEntity(getfqdn(), 'name'))
def init_app(self, app) -> None:
"""reinitialize on factory pattern config request"""
super().__init__(PublicKeyCredentialRpEntity(app.config['SERVER_NAME'] or getfqdn(), 'name'))

View File

@ -1,9 +1,11 @@
{ config, pkgs, lib, ... }:
let
cfg = config.services.lenticular-cloud;
username = "lenticular_cloud";
data_folder = "/var/lib/${username}";
python = pkgs.python3;
format = pkgs.formats.json {};
types = lib.types;
config_oauth_secret = "${cfg.settings.DATA_FOLDER}/lenticular_oauth_secret.toml";
python_env = python.withPackages (ps: with ps; [ lenticular-cloud gevent setuptools ]);
in
{
options = with lib.options; {
@ -13,22 +15,62 @@ in
type = lib.types.str;
example = "example.com";
};
username = mkOption {
type = lib.types.str;
description = mdDoc "user to run the service";
default = "lenticular_cloud";
};
service_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}";
};
description = mdDoc ''
Lenticular cloud settings
'';
default = { };
type = types.submodule {
freeformType = format.type;
options = {
DOMAIN = mkOption {
type = types.str;
description = mdDoc "Top level Domain of the service";
default = cfg.domain;
};
PUBLIC_URL = mkOption {
type = types.str;
description = mdDoc "public service url";
default = "https://${cfg.service_domain}";
};
ADMINS = mkOption {
type = types.listOf types.str;
description = mdDoc "list of admin users";
example = [ "tuxcoder" ];
};
DATA_FOLDER = mkOption {
type = types.str;
default = "/var/lib/${cfg.username}";
};
PKI_PATH = mkOption {
type = types.str;
default = "${cfg.settings.DATA_FOLDER}/pki";
};
SQLALCHEMY_DATABASE_URI = mkOption {
type = types.str;
default = "postgresql://${cfg.username}@/${cfg.username}?host=/run/postgresql";
};
HYDRA_ADMIN_URL = mkOption {
type = types.str;
default = "https://${config.services.ory-hydra.admin_domain}";
};
HYDRA_PUBLIC_URL = mkOption {
type = types.str;
default = "https://${config.services.ory-hydra.public_domain}";
};
};
};
};
};
};
@ -40,35 +82,38 @@ in
];
users = {
groups."${username}" = {
groups."${cfg.username}" = {
};
users."${username}" = {
users."${cfg.username}" = {
createHome = true;
home = data_folder;
home = "/var/lib/${cfg.username}";
description = "web server";
extraGroups = [
# "ory-hydra"
];
group = username;
group = cfg.username;
isSystemUser = true;
};
};
services.postgresql = {
enable = true;
ensureDatabases = [ username ];
ensureDatabases = [ cfg.username ];
ensureUsers = [
{
name = username;
ensurePermissions = {
"DATABASE ${username}" = "All PRIVILEGES";
};
name = cfg.username;
ensureDBOwnership = true;
}
];
identMap = ''
# ArbitraryMapName systemUser DBUser
superuser_map ${username} ${username}
'';
};
services.ory-hydra.settings = {
urls = {
login = "${cfg.settings.PUBLIC_URL}/auth/login";
logout = "${cfg.settings.PUBLIC_URL}/auth/logout";
consent = "${cfg.settings.PUBLIC_URL}/auth/consent";
error = "${cfg.settings.PUBLIC_URL}/auth/error";
};
};
services.nginx.enable = true;
@ -78,10 +123,10 @@ in
serverName = cfg.service_domain;
locations."/" = {
recommendedProxySettings = true;
proxyPass = "http://unix:/run/${username}/web.sock";
proxyPass = "http://unix:/run/${cfg.username}/web.sock";
};
};
users.users.nginx.extraGroups = [ username ];
users.users.nginx.extraGroups = [ cfg.username ];
systemd.services.lenticular-cloud = {
description = "lenticular account";
@ -91,30 +136,30 @@ in
enable = cfg.enable;
environment = let
python_path = with python.pkgs; makePythonPath [ pkgs.lenticular-cloud gevent setuptools ];
config_file = format.generate "lenticular-cloud.json" cfg.settings;
in {
# 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}";
CONFIG_FILE = "${config_file}:${config_oauth_secret}";
};
preStart = ''
#cat > ${data_folder}/foobar.conf <<EOF
#SECRET_KEY=""
#EOF
if [[ ! -e "${config_oauth_secret}" ]]; then
SECRET_KEY=`${pkgs.openssl}/bin/openssl rand --hex 16`
echo 'OAUTH_SECRET="$${SECRET_KEY}"' > ${config_oauth_secret}
echo "oauth secreted generated"
fi
${pkgs.lenticular-cloud}/bin/lenticular_cloud-cli db_upgrade
'';
serviceConfig = {
Type = "simple";
WorkingDirectory = data_folder;
User = username;
ExecStart = ''${python.pkgs.gunicorn}/bin/gunicorn lenticular_cloud.wsgi --name lenticular_cloud \
--workers 1 --log-level=info \
--bind=unix:/run/${username}/web.sock \
WorkingDirectory = cfg.settings.DATA_FOLDER;
User = cfg.username;
ExecStart = ''${python_env}/bin/gunicorn lenticular_cloud.wsgi --name lenticular_cloud \
--workers 2 --log-level=info \
--bind=unix:/run/${cfg.username}/web.sock \
-k gevent'';
Restart = "on-failure";
RuntimeDirectory = username;
RuntimeDirectory = cfg.username;
};
};

View File

@ -1,19 +1,23 @@
final: prev:
let
pkgs = final.pkgs;
version = "2.4";
frontend = pkgs.buildNpmPackage {
pname = "lenticular_cloud_js";
version = version;
src = ./.;
npmDepsHash = "sha256-L0EZHY1WN0zlnlUVm6d/EJIlC3Z/lod5d8dPNMsuw50=";
installPhase = ''
npm run build
mkdir -p $out
cp -r lenticular_cloud/static $out/
'';
};
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";
@ -74,28 +78,16 @@ in {
httpx
];
};
flask-sqlalchemy = prev.flask-sqlalchemy.overridePythonAttrs (old: rec {
version = "3.1.1";
# version = "3.0.3";
src = fetchPypi {
pname = "flask_sqlalchemy";
inherit version;
sha256 = "e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312";
};
propagatedBuildInputs = old.propagatedBuildInputs ++ [
flit-core sqlalchemy
];
nativeCheckInputs = old.nativeCheckInputs ++ [
typing-extensions
];
});
flask = prev.flask.overridePythonAttrs (old: {
propagatedBuildInputs = old.propagatedBuildInputs ++ flask.optional-dependencies.async;
});
lenticular-cloud = buildPythonPackage {
pname = "lenticular_cloud";
version = "0.3";
version = version;
src = ./.;
postPatch = ''
cp -r ${frontend}/static ./lenticular_cloud/
'';
propagatedBuildInputs = [
flask
flask-restful
@ -105,17 +97,18 @@ in {
flask_login
requests
requests_oauthlib
ldap3
# ldap3 # only needed for old upgrade
#ldap3-orm
pyotp
cryptography
blinker
authlib # as oauth client lib
fido2 # for webauthn
flask_migrate # db migrations
flask-dance
ory-hydra-client
toml
webauthn pyopenssl
pyjwt
pkgs.nodejs
#node-env
@ -135,11 +128,6 @@ in {
mypy
];
# passthru = {
# inherit python;
# pythonPath = python.pkgs.makePythonPath propagatedBuildInputs;
# };
doCheck = false;
checkInputs = [
@ -149,4 +137,5 @@ in {
};
};
lenticular-cloud = final.python3.pkgs.lenticular-cloud;
lenticular-cloud-frontend = frontend;
}

3532
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,12 +3,14 @@
"version": "2.0.0",
"description": "Lenticular Cloud ================",
"scripts": {
"build": "webpack-cli"
"build": "webpack-cli --mode production",
"watch": "webpack-cli --mode development -w"
},
"author": "TuxCoder",
"license": "GPLv3",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.1.1",
"@simplewebauthn/browser": "^8.3.4",
"bootstrap": "^4.6.1",
"cbor-web": "*",
"css-loader": "^6.7.1",
@ -22,13 +24,12 @@
"qrcode-svg": "~1.1.0",
"sass": "^1.52.1",
"sass-loader": "^13.0.0",
"simple-form-submit": "*",
"style-loader": "*",
"terser-webpack-plugin": "*",
"ts-loader": "^9.3.0",
"url-loader": "*",
"webpack": "^5.72.1",
"webpack-cli": "*",
"simple-form-submit": "*"
},
"devDependencies": {}
"webpack-cli": "*"
}
}