Compare commits
19 commits
6e76cf74da
...
368f2396ce
Author | SHA1 | Date | |
---|---|---|---|
tuxcoder | 368f2396ce | ||
tuxcoder | bd7d8e4398 | ||
tuxcoder | 0a1da35d84 | ||
tuxcoder | 926afee5c5 | ||
tuxcoder | f858a1a78c | ||
tuxcoder | 5759cb1e4f | ||
tuxcoder | c43d59db99 | ||
tuxcoder | aaf91cb580 | ||
tuxcoder | 2d2766ac30 | ||
tuxcoder | ddbba31fe6 | ||
tuxcoder | 04846aac0e | ||
tuxcoder | 632158b566 | ||
tuxcoder | 85d04478d1 | ||
tuxcoder | 5ab1a0f39c | ||
tuxcoder | 3775c8eace | ||
tuxcoder | 4b1de43d43 | ||
tuxcoder | 5a26d53106 | ||
tuxcoder | fbbe6e2c87 | ||
tuxcoder | 0494fb336f |
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -13,3 +13,4 @@ node_modules
|
|||
build
|
||||
result
|
||||
nixos.qcow2
|
||||
/lenticular_cloud/static
|
||||
|
|
28
README.md
28
README.md
|
@ -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`
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
22
flake.lock
22
flake.lock
|
@ -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"
|
||||
},
|
||||
|
|
75
flake.nix
75
flake.nix
|
@ -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" ];
|
||||
};
|
||||
})
|
||||
];
|
||||
};
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
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
|
||||
|
|
40
lenticular_cloud/migrations/versions/b5448df204eb_passkey.py
Normal file
40
lenticular_cloud/migrations/versions/b5448df204eb_passkey.py
Normal 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 ###
|
|
@ -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):
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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
|
@ -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
|
@ -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 %}
|
||||
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
32
lenticular_cloud/template/frontend/passkey_list.html.j2
Normal file
32
lenticular_cloud/template/frontend/passkey_list.html.j2
Normal 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 %}
|
54
lenticular_cloud/template/frontend/passkey_new.html.j2
Normal file
54
lenticular_cloud/template/frontend/passkey_new.html.j2
Normal 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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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: [
|
||||
// Don’t 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 %}
|
|
@ -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:
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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'))
|
125
module.nix
125
module.nix
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
55
overlay.nix
55
overlay.nix
|
@ -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;
|
||||
}
|
3528
package-lock.json
generated
3528
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
@ -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": "*"
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue