Compare commits
No commits in common. "368f2396ce0ae7e2b09705bf73b559e0df12f000" and "6e76cf74da2b99e75e663efbf8790fcf21274195" have entirely different histories.
368f2396ce
...
6e76cf74da
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -13,4 +13,3 @@ node_modules
|
|||
build
|
||||
result
|
||||
nixos.qcow2
|
||||
/lenticular_cloud/static
|
||||
|
|
28
README.md
28
README.md
|
@ -2,7 +2,7 @@ Lenticular Cloud
|
|||
================
|
||||
|
||||
|
||||
Simple user Manager proudly made in ~~LDAP~~ SQL
|
||||
Simple user Manager in LDAP
|
||||
|
||||
|
||||
|
||||
|
@ -11,12 +11,15 @@ Features
|
|||
|
||||
* frontend for hydra
|
||||
* Web Platform to mange users
|
||||
* fake ldap backend, can be used by other services
|
||||
* client certs
|
||||
* ldap backend, can be used by other services
|
||||
|
||||
Auth Methods:
|
||||
-------------
|
||||
* U2F (TODO)
|
||||
* TOTP
|
||||
* Password
|
||||
* Passkey
|
||||
* WebAuth (TODO)
|
||||
|
||||
|
||||
|
||||
|
@ -31,20 +34,7 @@ Tested Services
|
|||
|
||||
|
||||
|
||||
Development
|
||||
===========
|
||||
Oauth2 Settings:
|
||||
----------------
|
||||
|
||||
|
||||
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`
|
||||
callback url: `${domain}/
|
||||
|
|
|
@ -12,7 +12,6 @@ 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);
|
||||
|
@ -101,17 +100,44 @@ window.auth = {
|
|||
}
|
||||
};
|
||||
|
||||
window.auth_passkey = {
|
||||
sign_up: async function(options) {
|
||||
const resp = await startRegistration(options);
|
||||
return resp;
|
||||
window.totp = {
|
||||
init_list: function(){
|
||||
},
|
||||
sign_in: async function(options) {
|
||||
const resp = await startAuthentication(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();
|
||||
},
|
||||
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');
|
||||
|
@ -149,3 +175,77 @@ 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": 1701680307,
|
||||
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
|
||||
"lastModified": 1694529238,
|
||||
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
|
||||
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
@ -52,16 +52,16 @@
|
|||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1703200384,
|
||||
"narHash": "sha256-q5j06XOsy0qHOarsYPfZYJPWbTbc8sryRxianlEPJN0=",
|
||||
"lastModified": 1697059129,
|
||||
"narHash": "sha256-9NJcFF9CEYPvHJ5ckE8kvINvI84SZZ87PvqMbH6pro0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0b3d618173114c64ab666f557504d6982665d328",
|
||||
"rev": "5e4c2ada4fcd54b99d56d7bd62f384511a7e2593",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"id": "nixpkgs",
|
||||
"ref": "nixos-23.11",
|
||||
"ref": "nixos-unstable",
|
||||
"type": "indirect"
|
||||
}
|
||||
},
|
||||
|
@ -99,11 +99,11 @@
|
|||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1702764954,
|
||||
"narHash": "sha256-+1z/0NJ/8c0d6Um1y9wpVO8CPXHd9/psOJF9GqFS/38=",
|
||||
"lastModified": 1696700871,
|
||||
"narHash": "sha256-9VFEJEfnfnCS1+kLznxd+OiDYdMnLP00+XR53iPfnK4=",
|
||||
"ref": "refs/heads/master",
|
||||
"rev": "dcea3067863899ee23950670e7fed2a4feccc20e",
|
||||
"revCount": 13,
|
||||
"rev": "a25f5792a256beaed2a9f944fccdea8ea7a8d44b",
|
||||
"revCount": 6,
|
||||
"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-23.11";
|
||||
nixpkgs.url = "nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
flake-compat = { # for shell.nix
|
||||
url = "github:edolstra/flake-compat";
|
||||
|
@ -22,16 +22,14 @@
|
|||
pkgs = nixpkgs.legacyPackages.${system}.extend (import ./overlay.nix);
|
||||
in rec {
|
||||
formatter = pkgs.nixpkgs-fmt;
|
||||
devShells.default = pkgs.mkShell {packages = with pkgs; [
|
||||
(python3.withPackages (ps: (
|
||||
lenticular-cloud.propagatedBuildInputs ++
|
||||
lenticular-cloud.testBuildInputs
|
||||
devShells.default = pkgs.mkShell {packages = [
|
||||
(pkgs.python3.withPackages (ps: (
|
||||
pkgs.lenticular-cloud.propagatedBuildInputs ++
|
||||
pkgs.lenticular-cloud.testBuildInputs
|
||||
)))
|
||||
nodejs
|
||||
];};
|
||||
|
||||
packages.default = pkgs.lenticular-cloud;
|
||||
packages.frontend = pkgs.lenticular-cloud-frontend;
|
||||
|
||||
checks = {
|
||||
package = packages.default;
|
||||
|
@ -48,77 +46,20 @@
|
|||
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" "public-hydra.local" "account.example.com" ];
|
||||
};
|
||||
networking.firewall.enable = false;
|
||||
networking.hosts = {"::1" = [ "admin-hydra.local" ]; };
|
||||
services.getty.autologinUser = "root";
|
||||
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" ];
|
||||
};
|
||||
virtualisation.qemu.options = ["-vga none"];
|
||||
})
|
||||
];
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from flask import current_app
|
||||
from flask_wtf import FlaskForm
|
||||
from .form.auth import PasswordForm
|
||||
from .form.auth import PasswordForm, TotpForm, Fido2Form
|
||||
from hmac import compare_digest as compare_hash
|
||||
import crypt
|
||||
from .model import User
|
||||
|
@ -47,8 +47,38 @@ 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
|
||||
PasswordAuthProvider,
|
||||
TotpAuthProvider
|
||||
]
|
||||
|
||||
#print(LdapAuthProvider.get_name())
|
||||
|
|
|
@ -6,7 +6,6 @@ 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
|
||||
|
@ -20,10 +19,6 @@ 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)
|
||||
|
@ -66,16 +61,6 @@ 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:
|
||||
|
@ -93,7 +78,6 @@ 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,22 +12,17 @@ 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:8081'
|
||||
HYDRA_ADMIN_URL = 'http://127.0.0.1:4445'
|
||||
HYDRA_ADMIN_USER = 'lenticluar_cloud'
|
||||
HYDRA_ADMIN_PASSWORD = 'notSecure'
|
||||
HYDRA_PUBLIC_URL = 'http://127.0.0.1:8082'
|
||||
HYDRA_PUBLIC_URL = 'http://127.0.0.1:4444'
|
||||
SUBJECT_PREFIX = 'something random'
|
||||
|
||||
OAUTH_ID = 'identiy_provider'
|
||||
OAUTH_SECRET = 'thisIsNotSecure'
|
||||
|
||||
ADMINS = [
|
||||
'tuxcoder'
|
||||
]
|
||||
|
||||
[LENTICULAR_CLOUD_SERVICES.jabber]
|
||||
app_token = true
|
||||
|
|
|
@ -20,6 +20,22 @@ 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,6 +22,17 @@ 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) ])
|
||||
|
@ -30,10 +41,11 @@ class AppTokenForm(FlaskForm):
|
|||
class AppTokenDeleteForm(FlaskForm):
|
||||
submit = SubmitField(gettext('Delete'))
|
||||
|
||||
class PasskeyRegisterForm(FlaskForm):
|
||||
"""Passkey register form"""
|
||||
class WebauthnRegisterForm(FlaskForm):
|
||||
"""webauthn register token form"""
|
||||
|
||||
name = StringField('Name', [Length(max=50)])
|
||||
attestation = HiddenField('Attestation', [InputRequired()])
|
||||
name = StringField('Name', [Length(max=250)])
|
||||
submit = SubmitField('Register', render_kw={'disabled': True})
|
||||
|
||||
class PasswordChangeForm(FlaskForm):
|
||||
|
|
|
@ -23,8 +23,7 @@ class HydraService:
|
|||
self.set_hydra_client(Client(base_url=app.config['HYDRA_ADMIN_URL']))
|
||||
|
||||
client_name = app.config['OAUTH_ID']
|
||||
client_secret = app.config['OAUTH_SECRET']
|
||||
public_url = app.config['PUBLIC_URL']
|
||||
client_secret = token_hex(16)
|
||||
|
||||
clients = list_o_auth_2_clients.sync_detailed(_client=self.hydra_client).parsed
|
||||
if clients is None:
|
||||
|
@ -36,26 +35,25 @@ class HydraService:
|
|||
break
|
||||
|
||||
if client is None:
|
||||
client_req = OAuth20Client(
|
||||
domain = app.config['DOMAIN']
|
||||
client = OAuth20Client(
|
||||
client_name="identiy_provider",
|
||||
# client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
response_types=["code", "id_token"],
|
||||
scope="openid profile manage",
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
redirect_uris=[ f"{public_url}/oauth/authorized" ],
|
||||
redirect_uris=[ f"https://{domain}/oauth/authorized" ],
|
||||
token_endpoint_auth_method="client_secret_basic",
|
||||
)
|
||||
ret = create_o_auth_2_client.sync(json_body=client_req, _client=self.hydra_client)
|
||||
ret = create_o_auth_2_client.sync(json_body=client, _client=self.hydra_client)
|
||||
if ret is None:
|
||||
raise RuntimeError("could not create account")
|
||||
client = ret
|
||||
raise RuntimeError("could not crate account")
|
||||
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)
|
||||
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 update account")
|
||||
raise RuntimeError("could not crate account")
|
||||
if type(client.client_id) is not str:
|
||||
raise RuntimeError("could not parse client_id from ory-hydra")
|
||||
self.client_id = client.client_id
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
"""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,8 +163,15 @@ 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')
|
||||
passkey_credentials: Mapped[List['PasskeyCredential']] = relationship('PasskeyCredential', back_populates='user', cascade='delete,delete-orphan', passive_deletes=True)
|
||||
# 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)
|
||||
|
||||
@property
|
||||
def totps(self) -> List['Totp']:
|
||||
return []
|
||||
@property
|
||||
def webauthn_credentials(self) -> List['WebauthnCredential']:
|
||||
return []
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
|
@ -178,8 +185,7 @@ class User(BaseModel, ModelUpdatedMixin):
|
|||
|
||||
@property
|
||||
def groups(self) -> list['Group']:
|
||||
admins = current_app.config['ADMINS']
|
||||
if self.username in admins:
|
||||
if self.username == 'tuxcoder':
|
||||
return [Group(name='admin')]
|
||||
else:
|
||||
return []
|
||||
|
@ -222,20 +228,33 @@ 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 PasskeyCredential(BaseModel, ModelUpdatedMixin): # pylint: disable=too-few-public-methods
|
||||
"""Passkey credential model"""
|
||||
class WebauthnCredential(BaseModel, ModelUpdatedMixin): # pylint: disable=too-few-public-methods
|
||||
"""Webauthn 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)
|
||||
credential_id: Mapped[bytes] = mapped_column(db.LargeBinary, nullable=False)
|
||||
credential_public_key: Mapped[bytes] = mapped_column(db.LargeBinary, nullable=False)
|
||||
user_handle: Mapped[str] = mapped_column(db.String(64), nullable=False)
|
||||
credential_data: Mapped[bytes] = mapped_column(db.LargeBinary, nullable=False)
|
||||
name: Mapped[str] = mapped_column(db.String(250), nullable=False)
|
||||
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)
|
||||
registered: Mapped[datetime] = mapped_column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
user = db.relationship('User', back_populates='passkey_credentials')
|
||||
# user = db.relationship('User', back_populates='webauthn_credentials')
|
||||
|
||||
|
||||
class Group(BaseModel, ModelUpdatedMixin):
|
||||
|
|
BIN
lenticular_cloud/static/0a1b701f5563c2288281.ttf
Normal file
BIN
lenticular_cloud/static/0a1b701f5563c2288281.ttf
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/0caf4c6cf244a90efcc5.woff2
Normal file
BIN
lenticular_cloud/static/0caf4c6cf244a90efcc5.woff2
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/0d03b1bbd1d62c1e1284.ttf
Normal file
BIN
lenticular_cloud/static/0d03b1bbd1d62c1e1284.ttf
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/15d98c18221c8bcb2334.ttf
Normal file
BIN
lenticular_cloud/static/15d98c18221c8bcb2334.ttf
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/3d503f89ccaf1b224aa5.woff2
Normal file
BIN
lenticular_cloud/static/3d503f89ccaf1b224aa5.woff2
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/4896d4b04430cc3dfb06.woff2
Normal file
BIN
lenticular_cloud/static/4896d4b04430cc3dfb06.woff2
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/4baccb548138840fa33a.ttf
Normal file
BIN
lenticular_cloud/static/4baccb548138840fa33a.ttf
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/52eba2c567c521b8d58a.ttf
Normal file
BIN
lenticular_cloud/static/52eba2c567c521b8d58a.ttf
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/59edf72a325ac2048d60.woff2
Normal file
BIN
lenticular_cloud/static/59edf72a325ac2048d60.woff2
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/79da213423ac0def2058.ttf
Normal file
BIN
lenticular_cloud/static/79da213423ac0def2058.ttf
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/afac89562a5301459069.woff2
Normal file
BIN
lenticular_cloud/static/afac89562a5301459069.woff2
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/b823fc0dbb5a5f0c21bb.ttf
Normal file
BIN
lenticular_cloud/static/b823fc0dbb5a5f0c21bb.ttf
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/d79c2ec96ab9ff1161a2.woff2
Normal file
BIN
lenticular_cloud/static/d79c2ec96ab9ff1161a2.woff2
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/e615bbcb258550973c16.ttf
Normal file
BIN
lenticular_cloud/static/e615bbcb258550973c16.ttf
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/ebb7a127d2d8ee6f1832.woff2
Normal file
BIN
lenticular_cloud/static/ebb7a127d2d8ee6f1832.woff2
Normal file
Binary file not shown.
BIN
lenticular_cloud/static/f77bcc98bb7468c8b15a.woff2
Normal file
BIN
lenticular_cloud/static/f77bcc98bb7468c8b15a.woff2
Normal file
Binary file not shown.
13
lenticular_cloud/static/main.css
Normal file
13
lenticular_cloud/static/main.css
Normal file
File diff suppressed because one or more lines are too long
1
lenticular_cloud/static/main.css.map
Normal file
1
lenticular_cloud/static/main.css.map
Normal file
File diff suppressed because one or more lines are too long
3
lenticular_cloud/static/main.js
Normal file
3
lenticular_cloud/static/main.js
Normal file
File diff suppressed because one or more lines are too long
79
lenticular_cloud/static/main.js.LICENSE.txt
Normal file
79
lenticular_cloud/static/main.js.LICENSE.txt
Normal file
|
@ -0,0 +1,79 @@
|
|||
/*!
|
||||
* 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.
|
||||
*/
|
1
lenticular_cloud/static/main.js.map
Normal file
1
lenticular_cloud/static/main.js.map
Normal file
File diff suppressed because one or more lines are too long
|
@ -2,53 +2,12 @@
|
|||
|
||||
{% block title %}{{ gettext('Login') }}{% endblock %}
|
||||
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
const options_req = {{ options }};
|
||||
const token = "{{ token }}";
|
||||
const login_challenge = "{{ login_challenge }}";
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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>
|
||||
{{ render_form(form) }}
|
||||
|
||||
<a href="{{ url_for('.sign_up') }}" class="btn btn-primary">Sign Up</a>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
@ -213,9 +213,9 @@
|
|||
action_text - text of submit button
|
||||
class_ - sets a class for form
|
||||
#}
|
||||
{% macro render_form(form, action_url='', id='', action_text='Submit', class_='', btn_class='btn btn-primary', method='post', onsubmit='') -%}
|
||||
{% macro render_form(form, action_url='', action_text='Submit', class_='', btn_class='btn btn-primary', method='post', onsubmit='') -%}
|
||||
|
||||
<form method="{{ method }}" {% if id %}id="{{ id }}" {% endif %}{% if action_url %}action="{{ action_url }}" {% endif %}{% if onsubmit %}onsubmit="{{ onsubmit }}" {% endif %}role="form" class="{{ class_ }}">
|
||||
<form method="{{ method }}" {% 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.passkey') }}">{{ gettext('Passkey') }}</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.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>
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
{% 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 %}
|
|
@ -1,54 +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>
|
||||
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 %}
|
35
lenticular_cloud/template/frontend/totp.html.j2
Normal file
35
lenticular_cloud/template/frontend/totp.html.j2
Normal file
|
@ -0,0 +1,35 @@
|
|||
{% 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 %}
|
20
lenticular_cloud/template/frontend/totp_new.html.j2
Normal file
20
lenticular_cloud/template/frontend/totp_new.html.j2
Normal file
|
@ -0,0 +1,20 @@
|
|||
{% 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 %}
|
30
lenticular_cloud/template/frontend/webauthn_list.html
Normal file
30
lenticular_cloud/template/frontend/webauthn_list.html
Normal file
|
@ -0,0 +1,30 @@
|
|||
{% 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 %}
|
140
lenticular_cloud/template/frontend/webauthn_register.html
Normal file
140
lenticular_cloud/template/frontend/webauthn_register.html
Normal file
|
@ -0,0 +1,140 @@
|
|||
{#- 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,35 +1,39 @@
|
|||
|
||||
from base64 import b64encode, b64decode, urlsafe_b64decode
|
||||
import crypt
|
||||
from datetime import datetime, timedelta
|
||||
import jwt
|
||||
from flask import request, url_for, jsonify, Blueprint, redirect, current_app, session
|
||||
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
|
||||
import logging
|
||||
from ory_hydra_client import models as ory_hydra_m
|
||||
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 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, PasskeyCredential
|
||||
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
|
||||
import crypt
|
||||
from datetime import datetime
|
||||
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 typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from ..model import db, User, SecurityUser
|
||||
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'])
|
||||
|
@ -50,14 +54,8 @@ async def consent() -> ResponseReturnValue:
|
|||
requested_audiences = consent_request.requested_access_token_audience
|
||||
|
||||
if form.validate_on_submit() or consent_request.skip:
|
||||
|
||||
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)
|
||||
user = User.query.get(consent_request.subject) # type: Optional[User]
|
||||
if user is None:
|
||||
logger.error("user not found, even if it should exist")
|
||||
return 'internal error', 500
|
||||
access_token = {
|
||||
'name': str(user.username),
|
||||
|
@ -98,9 +96,6 @@ 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
|
||||
|
@ -109,21 +104,6 @@ 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,
|
||||
|
@ -142,13 +122,7 @@ 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,
|
||||
options=webauthn.options_to_json(options),
|
||||
token=token,
|
||||
login_challenge=login_challenge,
|
||||
)
|
||||
return render_template('auth/login.html.j2', form=form)
|
||||
|
||||
|
||||
@auth_views.route('/login/auth', methods=['GET', 'POST'])
|
||||
|
@ -195,54 +169,21 @@ 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
|
||||
|
||||
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'])
|
||||
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')
|
||||
|
||||
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'))
|
||||
return '', HTTPStatus.BAD_REQUEST
|
||||
|
||||
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 flask import Blueprint, redirect, request
|
||||
from fido2 import cbor
|
||||
from fido2.webauthn import CollectedClientData, AttestationObject, AttestedCredentialData, AuthenticatorData, PublicKeyCredentialUserEntity
|
||||
from flask import Blueprint, Response, redirect, request
|
||||
from flask import current_app
|
||||
from flask import jsonify, session
|
||||
from flask import jsonify, session, flash
|
||||
from flask import render_template, url_for
|
||||
from flask_login import logout_user, current_user
|
||||
from flask_login import login_user, 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 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 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 ..model import db, User, AppToken, PasskeyCredential
|
||||
from ..form.frontend import ClientCertForm, PasswordChangeForm, \
|
||||
AppTokenForm, AppTokenDeleteForm, PasskeyRegisterForm
|
||||
from ..model import db, User, SecurityUser, Totp, AppToken, WebauthnCredential
|
||||
from ..form.frontend import ClientCertForm, TOTPForm, \
|
||||
TOTPDeleteForm, PasswordChangeForm, WebauthnRegisterForm, \
|
||||
AppTokenForm, AppTokenDeleteForm
|
||||
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,109 +187,132 @@ def app_token_delete(app_token_name: str) -> ResponseReturnValue:
|
|||
|
||||
return redirect(url_for('frontend.app_token'))
|
||||
|
||||
## Passkey
|
||||
@frontend_views.route('/totp')
|
||||
def totp() -> ResponseReturnValue:
|
||||
delete_form = TOTPDeleteForm()
|
||||
return render_template('frontend/totp.html.j2', delete_form=delete_form)
|
||||
|
||||
@frontend_views.route('/passkey/list', methods=['GET'])
|
||||
def passkey() -> ResponseReturnValue:
|
||||
|
||||
@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:
|
||||
"""list registered credentials for current user"""
|
||||
|
||||
credentials = PasskeyCredential.query.all()
|
||||
return render_template('frontend/passkey_list.html.j2', credentials=credentials, button_form=ButtonForm())
|
||||
creds = WebauthnCredential.query.all() # type: Iterable[WebauthnCredential]
|
||||
return render_template('frontend/webauthn_list.html', creds=creds, button_form=ButtonForm())
|
||||
|
||||
|
||||
|
||||
@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:
|
||||
@frontend_views.route('/webauthn/delete/<webauthn_id>', methods=['POST'])
|
||||
def webauthn_delete_route(webauthn_id: str) -> ResponseReturnValue:
|
||||
"""delete registered credential"""
|
||||
|
||||
form = ButtonForm()
|
||||
if form.validate_on_submit():
|
||||
cred = PasskeyCredential.query.filter(PasskeyCredential.id == id).first_or_404()
|
||||
cred = WebauthnCredential.query.filter(WebauthnCredential.id == webauthn_id).one() # type: WebauthnCredential
|
||||
db.session.delete(cred)
|
||||
db.session.commit()
|
||||
return redirect(url_for('.passkey'))
|
||||
return redirect(url_for('app.webauthn_list_route'))
|
||||
|
||||
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,13 +1,12 @@
|
|||
from authlib.integrations.flask_client import OAuth
|
||||
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 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 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
|
||||
|
@ -29,8 +28,7 @@ login_manager = LoginManager()
|
|||
def redirect_login() -> ResponseReturnValue:
|
||||
logout_user()
|
||||
session['next_url'] = request.path
|
||||
public_url = current_app.config['PUBLIC_URL']
|
||||
redirect_uri = public_url + url_for('oauth2.authorized')
|
||||
redirect_uri = url_for('oauth2.authorized', _external=True)
|
||||
response = oauth2.custom.authorize_redirect(redirect_uri)
|
||||
if not isinstance(response, WerkzeugResponse):
|
||||
raise RuntimeError("invalid redirect")
|
||||
|
@ -44,14 +42,11 @@ 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(UUID(userinfo["sub"])) # type: Optional[User]
|
||||
user = User.query.get(str(userinfo["sub"])) # type: Optional[User]
|
||||
if user is None:
|
||||
return "user not found", 404
|
||||
logger.info(f"user `{user.username}` successfully logged in")
|
||||
|
|
21
lenticular_cloud/wrapped_fido2_server.py
Normal file
21
lenticular_cloud/wrapped_fido2_server.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
# 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,11 +1,9 @@
|
|||
{ 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; {
|
||||
|
@ -15,62 +13,22 @@ 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 {
|
||||
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}";
|
||||
};
|
||||
};
|
||||
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}";
|
||||
};
|
||||
|
||||
};
|
||||
};
|
||||
};
|
||||
|
@ -82,38 +40,35 @@ in
|
|||
];
|
||||
|
||||
users = {
|
||||
groups."${cfg.username}" = {
|
||||
groups."${username}" = {
|
||||
};
|
||||
users."${cfg.username}" = {
|
||||
users."${username}" = {
|
||||
createHome = true;
|
||||
home = "/var/lib/${cfg.username}";
|
||||
home = data_folder;
|
||||
description = "web server";
|
||||
extraGroups = [
|
||||
# "ory-hydra"
|
||||
];
|
||||
group = cfg.username;
|
||||
group = username;
|
||||
isSystemUser = true;
|
||||
};
|
||||
};
|
||||
|
||||
services.postgresql = {
|
||||
enable = true;
|
||||
ensureDatabases = [ cfg.username ];
|
||||
ensureDatabases = [ username ];
|
||||
ensureUsers = [
|
||||
{
|
||||
name = cfg.username;
|
||||
ensureDBOwnership = true;
|
||||
name = username;
|
||||
ensurePermissions = {
|
||||
"DATABASE ${username}" = "All PRIVILEGES";
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
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";
|
||||
};
|
||||
identMap = ''
|
||||
# ArbitraryMapName systemUser DBUser
|
||||
superuser_map ${username} ${username}
|
||||
'';
|
||||
};
|
||||
|
||||
services.nginx.enable = true;
|
||||
|
@ -123,10 +78,10 @@ in
|
|||
serverName = cfg.service_domain;
|
||||
locations."/" = {
|
||||
recommendedProxySettings = true;
|
||||
proxyPass = "http://unix:/run/${cfg.username}/web.sock";
|
||||
proxyPass = "http://unix:/run/${username}/web.sock";
|
||||
};
|
||||
};
|
||||
users.users.nginx.extraGroups = [ cfg.username ];
|
||||
users.users.nginx.extraGroups = [ username ];
|
||||
|
||||
systemd.services.lenticular-cloud = {
|
||||
description = "lenticular account";
|
||||
|
@ -136,30 +91,30 @@ in
|
|||
enable = cfg.enable;
|
||||
|
||||
environment = let
|
||||
config_file = format.generate "lenticular-cloud.json" cfg.settings;
|
||||
python_path = with python.pkgs; makePythonPath [ pkgs.lenticular-cloud gevent setuptools ];
|
||||
in {
|
||||
# CONFIG_FILE = "/etc/lenticular_cloud/production.conf";
|
||||
CONFIG_FILE = "${config_file}:${config_oauth_secret}";
|
||||
CONFIG_FILE = pkgs.writeText "lenticular-cloud.json" (builtins.toJSON cfg.settings);
|
||||
PYTHONPATH = "${python_path}";
|
||||
# PYTHONPATH = "${lenticular-pkg.pythonPath}:${lenticular-pkg}/lib/python3.10/site-packages:${python_path}";
|
||||
};
|
||||
preStart = ''
|
||||
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
|
||||
#cat > ${data_folder}/foobar.conf <<EOF
|
||||
#SECRET_KEY=""
|
||||
#EOF
|
||||
${pkgs.lenticular-cloud}/bin/lenticular_cloud-cli db_upgrade
|
||||
'';
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
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 \
|
||||
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 \
|
||||
-k gevent'';
|
||||
Restart = "on-failure";
|
||||
RuntimeDirectory = cfg.username;
|
||||
RuntimeDirectory = username;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
55
overlay.nix
55
overlay.nix
|
@ -1,23 +1,19 @@
|
|||
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";
|
||||
|
@ -78,16 +74,28 @@ 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 = version;
|
||||
version = "0.3";
|
||||
src = ./.;
|
||||
postPatch = ''
|
||||
cp -r ${frontend}/static ./lenticular_cloud/
|
||||
'';
|
||||
propagatedBuildInputs = [
|
||||
flask
|
||||
flask-restful
|
||||
|
@ -97,18 +105,17 @@ in {
|
|||
flask_login
|
||||
requests
|
||||
requests_oauthlib
|
||||
# ldap3 # only needed for old upgrade
|
||||
ldap3
|
||||
#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
|
||||
|
@ -128,6 +135,11 @@ in {
|
|||
mypy
|
||||
|
||||
];
|
||||
# passthru = {
|
||||
# inherit python;
|
||||
# pythonPath = python.pkgs.makePythonPath propagatedBuildInputs;
|
||||
# };
|
||||
|
||||
|
||||
doCheck = false;
|
||||
checkInputs = [
|
||||
|
@ -137,5 +149,4 @@ in {
|
|||
};
|
||||
};
|
||||
lenticular-cloud = final.python3.pkgs.lenticular-cloud;
|
||||
lenticular-cloud-frontend = frontend;
|
||||
}
|
3530
package-lock.json
generated
3530
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
@ -3,14 +3,12 @@
|
|||
"version": "2.0.0",
|
||||
"description": "Lenticular Cloud ================",
|
||||
"scripts": {
|
||||
"build": "webpack-cli --mode production",
|
||||
"watch": "webpack-cli --mode development -w"
|
||||
"build": "webpack-cli"
|
||||
},
|
||||
"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",
|
||||
|
@ -24,12 +22,13 @@
|
|||
"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": "*"
|
||||
}
|
||||
"webpack-cli": "*",
|
||||
"simple-form-submit": "*"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue