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
|
build
|
||||||
result
|
result
|
||||||
nixos.qcow2
|
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
|
* frontend for hydra
|
||||||
* Web Platform to mange users
|
* 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:
|
Auth Methods:
|
||||||
-------------
|
-------------
|
||||||
|
* U2F (TODO)
|
||||||
|
* TOTP
|
||||||
* Password
|
* Password
|
||||||
* Passkey
|
* WebAuth (TODO)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -31,20 +34,7 @@ Tested Services
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Development
|
Oauth2 Settings:
|
||||||
===========
|
----------------
|
||||||
|
|
||||||
|
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,7 +12,6 @@ var asn1 = require('node-forge/lib/asn1');
|
||||||
var pkcs12 = require('node-forge/lib/pkcs12');
|
var pkcs12 = require('node-forge/lib/pkcs12');
|
||||||
var util = require('node-forge/lib/util');
|
var util = require('node-forge/lib/util');
|
||||||
import SimpleFormSubmit from "simple-form-submit";
|
import SimpleFormSubmit from "simple-form-submit";
|
||||||
import {startRegistration, startAuthentication} from '@simplewebauthn/browser';
|
|
||||||
|
|
||||||
const $ = document.querySelector.bind(document);
|
const $ = document.querySelector.bind(document);
|
||||||
const $$ = document.querySelectorAll.bind(document);
|
const $$ = document.querySelectorAll.bind(document);
|
||||||
|
@ -101,17 +100,44 @@ window.auth = {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.auth_passkey = {
|
window.totp = {
|
||||||
sign_up: async function(options) {
|
init_list: function(){
|
||||||
const resp = await startRegistration(options);
|
|
||||||
return resp;
|
|
||||||
},
|
},
|
||||||
sign_in: async function(options) {
|
init_new: function() {
|
||||||
const resp = await startAuthentication(options);
|
//create new TOTP secret, create qrcode and ask for token.
|
||||||
return resp;
|
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= {
|
window.password_change= {
|
||||||
init: function(){
|
init: function(){
|
||||||
var form = $('form');
|
var form = $('form');
|
||||||
|
@ -149,3 +175,77 @@ window.oauth2_token = {
|
||||||
return false;
|
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"
|
"systems": "systems"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1701680307,
|
"lastModified": 1694529238,
|
||||||
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
|
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "flake-utils",
|
"repo": "flake-utils",
|
||||||
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
|
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
@ -52,16 +52,16 @@
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1703200384,
|
"lastModified": 1697059129,
|
||||||
"narHash": "sha256-q5j06XOsy0qHOarsYPfZYJPWbTbc8sryRxianlEPJN0=",
|
"narHash": "sha256-9NJcFF9CEYPvHJ5ckE8kvINvI84SZZ87PvqMbH6pro0=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "0b3d618173114c64ab666f557504d6982665d328",
|
"rev": "5e4c2ada4fcd54b99d56d7bd62f384511a7e2593",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"id": "nixpkgs",
|
"id": "nixpkgs",
|
||||||
"ref": "nixos-23.11",
|
"ref": "nixos-unstable",
|
||||||
"type": "indirect"
|
"type": "indirect"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -99,11 +99,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1702764954,
|
"lastModified": 1696700871,
|
||||||
"narHash": "sha256-+1z/0NJ/8c0d6Um1y9wpVO8CPXHd9/psOJF9GqFS/38=",
|
"narHash": "sha256-9VFEJEfnfnCS1+kLznxd+OiDYdMnLP00+XR53iPfnK4=",
|
||||||
"ref": "refs/heads/master",
|
"ref": "refs/heads/master",
|
||||||
"rev": "dcea3067863899ee23950670e7fed2a4feccc20e",
|
"rev": "a25f5792a256beaed2a9f944fccdea8ea7a8d44b",
|
||||||
"revCount": 13,
|
"revCount": 6,
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "ssh://git@git.o-g.at/nixpkg/tuxpkgs.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";
|
description = "Lenticular cloud interface";
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "nixpkgs/nixos-23.11";
|
nixpkgs.url = "nixpkgs/nixos-unstable";
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
flake-compat = { # for shell.nix
|
flake-compat = { # for shell.nix
|
||||||
url = "github:edolstra/flake-compat";
|
url = "github:edolstra/flake-compat";
|
||||||
|
@ -22,16 +22,14 @@
|
||||||
pkgs = nixpkgs.legacyPackages.${system}.extend (import ./overlay.nix);
|
pkgs = nixpkgs.legacyPackages.${system}.extend (import ./overlay.nix);
|
||||||
in rec {
|
in rec {
|
||||||
formatter = pkgs.nixpkgs-fmt;
|
formatter = pkgs.nixpkgs-fmt;
|
||||||
devShells.default = pkgs.mkShell {packages = with pkgs; [
|
devShells.default = pkgs.mkShell {packages = [
|
||||||
(python3.withPackages (ps: (
|
(pkgs.python3.withPackages (ps: (
|
||||||
lenticular-cloud.propagatedBuildInputs ++
|
pkgs.lenticular-cloud.propagatedBuildInputs ++
|
||||||
lenticular-cloud.testBuildInputs
|
pkgs.lenticular-cloud.testBuildInputs
|
||||||
)))
|
)))
|
||||||
nodejs
|
|
||||||
];};
|
];};
|
||||||
|
|
||||||
packages.default = pkgs.lenticular-cloud;
|
packages.default = pkgs.lenticular-cloud;
|
||||||
packages.frontend = pkgs.lenticular-cloud-frontend;
|
|
||||||
|
|
||||||
checks = {
|
checks = {
|
||||||
package = packages.default;
|
package = packages.default;
|
||||||
|
@ -48,77 +46,20 @@
|
||||||
self.nixosModules.default
|
self.nixosModules.default
|
||||||
tuxpkgs.nixosModules.ory-hydra
|
tuxpkgs.nixosModules.ory-hydra
|
||||||
"${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix"
|
"${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix"
|
||||||
({lib, ...}:{
|
({...}:{
|
||||||
security.acme.acceptTerms = true;
|
security.acme.acceptTerms = true;
|
||||||
security.acme.defaults.email = "acme@example.com";
|
security.acme.defaults.email = "acme@example.com";
|
||||||
services.lenticular-cloud = {
|
services.lenticular-cloud = {
|
||||||
enable = true;
|
enable = true;
|
||||||
domain = "example.com";
|
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 = {
|
services.ory-hydra = {
|
||||||
enable = true;
|
enable = true;
|
||||||
admin_domain = "admin-hydra.local";
|
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";
|
services.getty.autologinUser = "root";
|
||||||
services.nginx.virtualHosts = {
|
virtualisation.qemu.options = ["-vga none"];
|
||||||
"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 import current_app
|
||||||
from flask_wtf import FlaskForm
|
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
|
from hmac import compare_digest as compare_hash
|
||||||
import crypt
|
import crypt
|
||||||
from .model import User
|
from .model import User
|
||||||
|
@ -47,8 +47,38 @@ class PasswordAuthProvider(AuthProvider):
|
||||||
return compare_hash(crypt.crypt(password, user.password_hashed),user.password_hashed)
|
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 = [
|
AUTH_PROVIDER_LIST = [
|
||||||
PasswordAuthProvider
|
PasswordAuthProvider,
|
||||||
|
TotpAuthProvider
|
||||||
]
|
]
|
||||||
|
|
||||||
#print(LdapAuthProvider.get_name())
|
#print(LdapAuthProvider.get_name())
|
||||||
|
|
|
@ -6,7 +6,6 @@ from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
from flask_migrate import upgrade
|
from flask_migrate import upgrade
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
@ -20,10 +19,6 @@ def entry_point() -> None:
|
||||||
|
|
||||||
parser_user = subparsers.add_parser('user')
|
parser_user = subparsers.add_parser('user')
|
||||||
parser_user.set_defaults(func=cli_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 = subparsers.add_parser('signup')
|
||||||
parser_signup.add_argument('--signup_id', type=str)
|
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}`')
|
print(f'{user.id} - Enabled: {user.enabled} - Name:`{user.username}`')
|
||||||
pass
|
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:
|
def cli_signup(args) -> None:
|
||||||
|
|
||||||
if args.signup_id is not 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}>')
|
print(f'<Signup id={user.id}, username={user.username}>')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def cli_run(app: Flask, args) -> None:
|
def cli_run(app: Flask, args) -> None:
|
||||||
print("running in debug mode")
|
print("running in debug mode")
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
|
@ -12,22 +12,17 @@ SQLALCHEMY_TRACK_MODIFICATIONS = false
|
||||||
|
|
||||||
PKI_PATH = "../data/pki"
|
PKI_PATH = "../data/pki"
|
||||||
DOMAIN = 'example.com'
|
DOMAIN = 'example.com'
|
||||||
PUBLIC_URL = 'http://localhost:5000'
|
|
||||||
#SERVER_NAME = f'account.{ DOMAIN }:9090'
|
#SERVER_NAME = f'account.{ DOMAIN }:9090'
|
||||||
|
|
||||||
HYDRA_REQUEST_TIMEOUT_SECONDS = 3
|
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_USER = 'lenticluar_cloud'
|
||||||
HYDRA_ADMIN_PASSWORD = 'notSecure'
|
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'
|
SUBJECT_PREFIX = 'something random'
|
||||||
|
|
||||||
OAUTH_ID = 'identiy_provider'
|
OAUTH_ID = 'identiy_provider'
|
||||||
OAUTH_SECRET = 'thisIsNotSecure'
|
|
||||||
|
|
||||||
ADMINS = [
|
|
||||||
'tuxcoder'
|
|
||||||
]
|
|
||||||
|
|
||||||
[LENTICULAR_CLOUD_SERVICES.jabber]
|
[LENTICULAR_CLOUD_SERVICES.jabber]
|
||||||
app_token = true
|
app_token = true
|
||||||
|
|
|
@ -20,6 +20,22 @@ class PasswordForm(FlaskForm):
|
||||||
password = PasswordField(gettext('Password'))
|
password = PasswordField(gettext('Password'))
|
||||||
submit = SubmitField(gettext('Authorize'))
|
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):
|
class ConsentForm(FlaskForm):
|
||||||
# scopes = SelectMultipleField(gettext('scopes'))
|
# scopes = SelectMultipleField(gettext('scopes'))
|
||||||
# audiences = SelectMultipleField(gettext('audiences'))
|
# audiences = SelectMultipleField(gettext('audiences'))
|
||||||
|
|
|
@ -22,6 +22,17 @@ class ClientCertForm(FlaskForm):
|
||||||
])
|
])
|
||||||
submit = SubmitField(gettext('Submit'))
|
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):
|
class AppTokenForm(FlaskForm):
|
||||||
name = StringField(gettext('name'), validators=[DataRequired(),Length(min=1, max=255) ])
|
name = StringField(gettext('name'), validators=[DataRequired(),Length(min=1, max=255) ])
|
||||||
scopes = StringField(gettext('scopes'), 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):
|
class AppTokenDeleteForm(FlaskForm):
|
||||||
submit = SubmitField(gettext('Delete'))
|
submit = SubmitField(gettext('Delete'))
|
||||||
|
|
||||||
class PasskeyRegisterForm(FlaskForm):
|
class WebauthnRegisterForm(FlaskForm):
|
||||||
"""Passkey register form"""
|
"""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})
|
submit = SubmitField('Register', render_kw={'disabled': True})
|
||||||
|
|
||||||
class PasswordChangeForm(FlaskForm):
|
class PasswordChangeForm(FlaskForm):
|
||||||
|
|
|
@ -23,8 +23,7 @@ class HydraService:
|
||||||
self.set_hydra_client(Client(base_url=app.config['HYDRA_ADMIN_URL']))
|
self.set_hydra_client(Client(base_url=app.config['HYDRA_ADMIN_URL']))
|
||||||
|
|
||||||
client_name = app.config['OAUTH_ID']
|
client_name = app.config['OAUTH_ID']
|
||||||
client_secret = app.config['OAUTH_SECRET']
|
client_secret = token_hex(16)
|
||||||
public_url = app.config['PUBLIC_URL']
|
|
||||||
|
|
||||||
clients = list_o_auth_2_clients.sync_detailed(_client=self.hydra_client).parsed
|
clients = list_o_auth_2_clients.sync_detailed(_client=self.hydra_client).parsed
|
||||||
if clients is None:
|
if clients is None:
|
||||||
|
@ -36,26 +35,25 @@ class HydraService:
|
||||||
break
|
break
|
||||||
|
|
||||||
if client is None:
|
if client is None:
|
||||||
client_req = OAuth20Client(
|
domain = app.config['DOMAIN']
|
||||||
|
client = OAuth20Client(
|
||||||
client_name="identiy_provider",
|
client_name="identiy_provider",
|
||||||
# client_id=client_id,
|
# client_id=client_id,
|
||||||
client_secret=client_secret,
|
client_secret=client_secret,
|
||||||
response_types=["code", "id_token"],
|
response_types=["code", "id_token"],
|
||||||
scope="openid profile manage",
|
scope="openid profile manage",
|
||||||
grant_types=["authorization_code", "refresh_token"],
|
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",
|
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:
|
if ret is None:
|
||||||
raise RuntimeError("could not create account")
|
raise RuntimeError("could not crate account")
|
||||||
client = ret
|
|
||||||
else:
|
else:
|
||||||
client.client_secret = client_secret
|
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:
|
if ret is None:
|
||||||
raise RuntimeError("could not update account")
|
raise RuntimeError("could not crate account")
|
||||||
if type(client.client_id) is not str:
|
if type(client.client_id) is not str:
|
||||||
raise RuntimeError("could not parse client_id from ory-hydra")
|
raise RuntimeError("could not parse client_id from ory-hydra")
|
||||||
self.client_id = client.client_id
|
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)
|
enabled: Mapped[bool] = mapped_column(db.Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
app_tokens: Mapped[List['AppToken']] = relationship('AppToken', back_populates='user')
|
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:
|
def __init__(self, **kwargs) -> None:
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
@ -178,8 +185,7 @@ class User(BaseModel, ModelUpdatedMixin):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def groups(self) -> list['Group']:
|
def groups(self) -> list['Group']:
|
||||||
admins = current_app.config['ADMINS']
|
if self.username == 'tuxcoder':
|
||||||
if self.username in admins:
|
|
||||||
return [Group(name='admin')]
|
return [Group(name='admin')]
|
||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
|
@ -222,20 +228,33 @@ class AppToken(BaseModel, ModelUpdatedMixin):
|
||||||
token = ''.join(secrets.choice(alphabet) for i in range(12))
|
token = ''.join(secrets.choice(alphabet) for i in range(12))
|
||||||
return AppToken(scopes=scopes, token=token, user=user, name=name)
|
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
|
class WebauthnCredential(BaseModel, ModelUpdatedMixin): # pylint: disable=too-few-public-methods
|
||||||
"""Passkey credential model"""
|
"""Webauthn credential model"""
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
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_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)
|
user_handle: Mapped[str] = mapped_column(db.String(64), nullable=False)
|
||||||
credential_public_key: Mapped[bytes] = mapped_column(db.LargeBinary, nullable=False)
|
credential_data: Mapped[bytes] = mapped_column(db.LargeBinary, nullable=False)
|
||||||
name: Mapped[str] = mapped_column(db.String(250), 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)
|
registered: Mapped[datetime] = mapped_column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
sign_count: Mapped[int] = mapped_column(db.Integer, nullable=False, default=0)
|
|
||||||
|
|
||||||
user = db.relationship('User', back_populates='passkey_credentials')
|
# user = db.relationship('User', back_populates='webauthn_credentials')
|
||||||
|
|
||||||
|
|
||||||
class Group(BaseModel, ModelUpdatedMixin):
|
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 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 %}
|
{% 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">
|
{{ render_form(form) }}
|
||||||
<a href="{{ url_for('.sign_up') }}" class="btn btn-secondary">Sign Up</a>
|
|
||||||
</div>
|
<a href="{{ url_for('.sign_up') }}" class="btn btn-primary">Sign Up</a>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
@ -213,9 +213,9 @@
|
||||||
action_text - text of submit button
|
action_text - text of submit button
|
||||||
class_ - sets a class for form
|
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__ }}">
|
<input name="form" type="hidden" value="{{ form.__class__.__name__ }}">
|
||||||
{{ _render_form(form) }}
|
{{ _render_form(form) }}
|
||||||
{% if not form.submit %}
|
{% if not form.submit %}
|
||||||
|
|
|
@ -17,9 +17,9 @@
|
||||||
<div class="sidebar-sticky active">
|
<div class="sidebar-sticky active">
|
||||||
{#<a href="/"><img alt="logo" class="container-fluid" src="/static/images/dog_main_small.png"></a>#}
|
{#<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.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.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.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.password_change') }}">{{ gettext('Password Change') }}</a></li>
|
||||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.logout') }}">{{ gettext('Logout') }}</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
|
from urllib.parse import urlencode, parse_qs
|
||||||
import crypt
|
|
||||||
from datetime import datetime, timedelta
|
import flask
|
||||||
import jwt
|
from flask import Blueprint, redirect, flash, current_app, session
|
||||||
from flask import request, url_for, jsonify, Blueprint, redirect, current_app, session
|
|
||||||
from flask.templating import render_template
|
from flask.templating import render_template
|
||||||
|
from flask_babel import gettext
|
||||||
from flask.typing import ResponseReturnValue
|
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 ..form.auth import ConsentForm, LoginForm, RegistrationForm
|
||||||
from ..auth_providers import AUTH_PROVIDER_LIST
|
from ..auth_providers import AUTH_PROVIDER_LIST
|
||||||
from ..hydra import hydra_service
|
from ..hydra import hydra_service
|
||||||
|
from ..wrapped_fido2_server import WrappedFido2Server
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
auth_views = Blueprint('auth', __name__, url_prefix='/auth')
|
auth_views = Blueprint('auth', __name__, url_prefix='/auth')
|
||||||
|
webauthn = WrappedFido2Server()
|
||||||
|
|
||||||
|
|
||||||
@auth_views.route('/consent', methods=['GET', 'POST'])
|
@auth_views.route('/consent', methods=['GET', 'POST'])
|
||||||
|
@ -50,14 +54,8 @@ async def consent() -> ResponseReturnValue:
|
||||||
requested_audiences = consent_request.requested_access_token_audience
|
requested_audiences = consent_request.requested_access_token_audience
|
||||||
|
|
||||||
if form.validate_on_submit() or consent_request.skip:
|
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:
|
if user is None:
|
||||||
logger.error("user not found, even if it should exist")
|
|
||||||
return 'internal error', 500
|
return 'internal error', 500
|
||||||
access_token = {
|
access_token = {
|
||||||
'name': str(user.username),
|
'name': str(user.username),
|
||||||
|
@ -98,9 +96,6 @@ async def consent() -> ResponseReturnValue:
|
||||||
|
|
||||||
@auth_views.route('/login', methods=['GET', 'POST'])
|
@auth_views.route('/login', methods=['GET', 'POST'])
|
||||||
async def login() -> ResponseReturnValue:
|
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')
|
login_challenge = request.args.get('login_challenge')
|
||||||
if login_challenge is None:
|
if login_challenge is None:
|
||||||
return 'login_challenge missing', 400
|
return 'login_challenge missing', 400
|
||||||
|
@ -109,21 +104,6 @@ async def login() -> ResponseReturnValue:
|
||||||
logger.exception("could not fetch login request")
|
logger.exception("could not fetch login request")
|
||||||
return redirect(url_for('frontend.index'))
|
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:
|
if login_request.skip:
|
||||||
resp = await accept_o_auth_2_login_request.asyncio(_client=hydra_service.hydra_client,
|
resp = await accept_o_auth_2_login_request.asyncio(_client=hydra_service.hydra_client,
|
||||||
login_challenge=login_challenge,
|
login_challenge=login_challenge,
|
||||||
|
@ -142,13 +122,7 @@ async def login() -> ResponseReturnValue:
|
||||||
session['auth_providers'] = []
|
session['auth_providers'] = []
|
||||||
return redirect(
|
return redirect(
|
||||||
url_for('auth.login_auth', login_challenge=login_challenge))
|
url_for('auth.login_auth', login_challenge=login_challenge))
|
||||||
return render_template(
|
return render_template('auth/login.html.j2', form=form)
|
||||||
'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'])
|
@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)
|
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()
|
user = User.query.filter(User.id == session.get('webauthn_login_user_id')).one() #type: User
|
||||||
token = jwt.decode(data['token'], secret_key, algorithms=['HS256'])
|
form = ButtonForm()
|
||||||
challenge = urlsafe_b64decode(token['challenge'])
|
if user and form.validate_on_submit():
|
||||||
credential = data['credential']
|
pkcro, state = webauthn.authenticate_begin(webauthn_credentials(user))
|
||||||
credential_id = urlsafe_b64decode(credential['id'])
|
session['webauthn_login_state'] = state
|
||||||
|
return Response(b64encode(cbor.encode(pkcro)).decode('utf-8'), mimetype='text/plain')
|
||||||
|
|
||||||
login_challenge = data['login_challenge']
|
return '', HTTPStatus.BAD_REQUEST
|
||||||
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")
|
@auth_views.route("/logout")
|
||||||
async def logout() -> ResponseReturnValue:
|
async def logout() -> ResponseReturnValue:
|
||||||
|
|
|
@ -1,35 +1,35 @@
|
||||||
|
|
||||||
from authlib.integrations.base_client.errors import MissingTokenError, InvalidTokenError
|
from authlib.integrations.base_client.errors import MissingTokenError, InvalidTokenError
|
||||||
from base64 import b64encode, b64decode
|
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 current_app
|
||||||
from flask import jsonify, session
|
from flask import jsonify, session, flash
|
||||||
from flask import render_template, url_for
|
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 http import HTTPStatus
|
||||||
from werkzeug.utils import redirect
|
from werkzeug.utils import redirect
|
||||||
import logging
|
import logging
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from base64 import b64decode
|
||||||
from flask.typing import ResponseReturnValue
|
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.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 ory_hydra_client.models import GenericError
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlencode, parse_qs
|
||||||
from typing import Optional, Any
|
from random import SystemRandom
|
||||||
import jwt
|
import string
|
||||||
from datetime import datetime, timedelta
|
from collections.abc import Iterable
|
||||||
import webauthn
|
from typing import Optional, Mapping, Iterator, List, Any
|
||||||
from webauthn.helpers.structs import (
|
|
||||||
AuthenticatorSelectionCriteria,
|
|
||||||
PublicKeyCredentialDescriptor,
|
|
||||||
ResidentKeyRequirement,
|
|
||||||
UserVerificationRequirement,
|
|
||||||
)
|
|
||||||
|
|
||||||
from ..model import db, User, AppToken, PasskeyCredential
|
from ..model import db, User, SecurityUser, Totp, AppToken, WebauthnCredential
|
||||||
from ..form.frontend import ClientCertForm, PasswordChangeForm, \
|
from ..form.frontend import ClientCertForm, TOTPForm, \
|
||||||
AppTokenForm, AppTokenDeleteForm, PasskeyRegisterForm
|
TOTPDeleteForm, PasswordChangeForm, WebauthnRegisterForm, \
|
||||||
|
AppTokenForm, AppTokenDeleteForm
|
||||||
from ..form.base import ButtonForm
|
from ..form.base import ButtonForm
|
||||||
from ..auth_providers import PasswordAuthProvider
|
from ..auth_providers import PasswordAuthProvider
|
||||||
|
from .auth import webauthn
|
||||||
from .oauth2 import redirect_login, oauth2
|
from .oauth2 import redirect_login, oauth2
|
||||||
from ..hydra import hydra_service
|
from ..hydra import hydra_service
|
||||||
from ..pki import pki
|
from ..pki import pki
|
||||||
|
@ -187,109 +187,132 @@ def app_token_delete(app_token_name: str) -> ResponseReturnValue:
|
||||||
|
|
||||||
return redirect(url_for('frontend.app_token'))
|
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"""
|
"""list registered credentials for current user"""
|
||||||
|
|
||||||
credentials = PasskeyCredential.query.all()
|
creds = WebauthnCredential.query.all() # type: Iterable[WebauthnCredential]
|
||||||
return render_template('frontend/passkey_list.html.j2', credentials=credentials, button_form=ButtonForm())
|
return render_template('frontend/webauthn_list.html', creds=creds, button_form=ButtonForm())
|
||||||
|
|
||||||
|
|
||||||
|
@frontend_views.route('/webauthn/delete/<webauthn_id>', methods=['POST'])
|
||||||
@frontend_views.route('/passkey/new', methods=['GET'])
|
def webauthn_delete_route(webauthn_id: str) -> ResponseReturnValue:
|
||||||
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"""
|
"""delete registered credential"""
|
||||||
|
|
||||||
form = ButtonForm()
|
form = ButtonForm()
|
||||||
if form.validate_on_submit():
|
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.delete(cred)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return redirect(url_for('.passkey'))
|
return redirect(url_for('app.webauthn_list_route'))
|
||||||
|
|
||||||
return '', HTTPStatus.BAD_REQUEST
|
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')
|
@frontend_views.route('/password_change')
|
||||||
def password_change() -> ResponseReturnValue:
|
def password_change() -> ResponseReturnValue:
|
||||||
form = PasswordChangeForm()
|
form = PasswordChangeForm()
|
||||||
|
|
|
@ -1,13 +1,12 @@
|
||||||
from authlib.integrations.flask_client import OAuth
|
from authlib.integrations.flask_client import OAuth
|
||||||
from authlib.integrations.base_client.errors import MismatchingStateError, OAuthError
|
from authlib.integrations.base_client.errors import MismatchingStateError
|
||||||
from flask import Flask, Blueprint, current_app, session, request, redirect, url_for
|
from flask import Flask, Blueprint, Response, session, request, redirect, url_for
|
||||||
from flask_login import login_user, logout_user
|
from flask_login import login_user, logout_user, current_user
|
||||||
from flask.typing import ResponseReturnValue
|
from flask.typing import ResponseReturnValue
|
||||||
from flask_login import LoginManager
|
from flask_login import LoginManager
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from werkzeug.wrappers.response import Response as WerkzeugResponse
|
from werkzeug.wrappers.response import Response as WerkzeugResponse
|
||||||
import logging
|
import logging
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from ..model import User, SecurityUser
|
from ..model import User, SecurityUser
|
||||||
from ..hydra import hydra_service
|
from ..hydra import hydra_service
|
||||||
|
@ -29,8 +28,7 @@ login_manager = LoginManager()
|
||||||
def redirect_login() -> ResponseReturnValue:
|
def redirect_login() -> ResponseReturnValue:
|
||||||
logout_user()
|
logout_user()
|
||||||
session['next_url'] = request.path
|
session['next_url'] = request.path
|
||||||
public_url = current_app.config['PUBLIC_URL']
|
redirect_uri = url_for('oauth2.authorized', _external=True)
|
||||||
redirect_uri = public_url + url_for('oauth2.authorized')
|
|
||||||
response = oauth2.custom.authorize_redirect(redirect_uri)
|
response = oauth2.custom.authorize_redirect(redirect_uri)
|
||||||
if not isinstance(response, WerkzeugResponse):
|
if not isinstance(response, WerkzeugResponse):
|
||||||
raise RuntimeError("invalid redirect")
|
raise RuntimeError("invalid redirect")
|
||||||
|
@ -44,14 +42,11 @@ def authorized() -> ResponseReturnValue:
|
||||||
except MismatchingStateError:
|
except MismatchingStateError:
|
||||||
logger.warning("MismatchingStateError redirect user")
|
logger.warning("MismatchingStateError redirect user")
|
||||||
return redirect(url_for('oauth2.login'))
|
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:
|
if token is None:
|
||||||
return 'bad request', 400
|
return 'bad request', 400
|
||||||
session['token'] = token
|
session['token'] = token
|
||||||
userinfo = oauth2.custom.get('/userinfo').json()
|
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:
|
if user is None:
|
||||||
return "user not found", 404
|
return "user not found", 404
|
||||||
logger.info(f"user `{user.username}` successfully logged in")
|
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, ... }:
|
{ config, pkgs, lib, ... }:
|
||||||
let
|
let
|
||||||
cfg = config.services.lenticular-cloud;
|
cfg = config.services.lenticular-cloud;
|
||||||
|
username = "lenticular_cloud";
|
||||||
|
data_folder = "/var/lib/${username}";
|
||||||
python = pkgs.python3;
|
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
|
in
|
||||||
{
|
{
|
||||||
options = with lib.options; {
|
options = with lib.options; {
|
||||||
|
@ -15,62 +13,22 @@ in
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
example = "example.com";
|
example = "example.com";
|
||||||
};
|
};
|
||||||
username = mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
description = mdDoc "user to run the service";
|
|
||||||
default = "lenticular_cloud";
|
|
||||||
};
|
|
||||||
service_domain = mkOption {
|
service_domain = mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
example = "account.example.com";
|
example = "account.example.com";
|
||||||
};
|
};
|
||||||
settings = mkOption {
|
settings = mkOption {
|
||||||
description = mdDoc ''
|
type = lib.types.attrs;
|
||||||
Lenticular cloud settings
|
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}";
|
||||||
|
};
|
||||||
|
|
||||||
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}";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -82,38 +40,35 @@ in
|
||||||
];
|
];
|
||||||
|
|
||||||
users = {
|
users = {
|
||||||
groups."${cfg.username}" = {
|
groups."${username}" = {
|
||||||
};
|
};
|
||||||
users."${cfg.username}" = {
|
users."${username}" = {
|
||||||
createHome = true;
|
createHome = true;
|
||||||
home = "/var/lib/${cfg.username}";
|
home = data_folder;
|
||||||
description = "web server";
|
description = "web server";
|
||||||
extraGroups = [
|
extraGroups = [
|
||||||
# "ory-hydra"
|
# "ory-hydra"
|
||||||
];
|
];
|
||||||
group = cfg.username;
|
group = username;
|
||||||
isSystemUser = true;
|
isSystemUser = true;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
services.postgresql = {
|
services.postgresql = {
|
||||||
enable = true;
|
enable = true;
|
||||||
ensureDatabases = [ cfg.username ];
|
ensureDatabases = [ username ];
|
||||||
ensureUsers = [
|
ensureUsers = [
|
||||||
{
|
{
|
||||||
name = cfg.username;
|
name = username;
|
||||||
ensureDBOwnership = true;
|
ensurePermissions = {
|
||||||
|
"DATABASE ${username}" = "All PRIVILEGES";
|
||||||
|
};
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
};
|
identMap = ''
|
||||||
|
# ArbitraryMapName systemUser DBUser
|
||||||
services.ory-hydra.settings = {
|
superuser_map ${username} ${username}
|
||||||
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;
|
services.nginx.enable = true;
|
||||||
|
@ -123,10 +78,10 @@ in
|
||||||
serverName = cfg.service_domain;
|
serverName = cfg.service_domain;
|
||||||
locations."/" = {
|
locations."/" = {
|
||||||
recommendedProxySettings = true;
|
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 = {
|
systemd.services.lenticular-cloud = {
|
||||||
description = "lenticular account";
|
description = "lenticular account";
|
||||||
|
@ -136,30 +91,30 @@ in
|
||||||
enable = cfg.enable;
|
enable = cfg.enable;
|
||||||
|
|
||||||
environment = let
|
environment = let
|
||||||
config_file = format.generate "lenticular-cloud.json" cfg.settings;
|
python_path = with python.pkgs; makePythonPath [ pkgs.lenticular-cloud gevent setuptools ];
|
||||||
in {
|
in {
|
||||||
# CONFIG_FILE = "/etc/lenticular_cloud/production.conf";
|
# 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 = ''
|
preStart = ''
|
||||||
if [[ ! -e "${config_oauth_secret}" ]]; then
|
#cat > ${data_folder}/foobar.conf <<EOF
|
||||||
SECRET_KEY=`${pkgs.openssl}/bin/openssl rand --hex 16`
|
#SECRET_KEY=""
|
||||||
echo 'OAUTH_SECRET="$${SECRET_KEY}"' > ${config_oauth_secret}
|
#EOF
|
||||||
echo "oauth secreted generated"
|
|
||||||
fi
|
|
||||||
${pkgs.lenticular-cloud}/bin/lenticular_cloud-cli db_upgrade
|
${pkgs.lenticular-cloud}/bin/lenticular_cloud-cli db_upgrade
|
||||||
'';
|
'';
|
||||||
|
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "simple";
|
Type = "simple";
|
||||||
WorkingDirectory = cfg.settings.DATA_FOLDER;
|
WorkingDirectory = data_folder;
|
||||||
User = cfg.username;
|
User = username;
|
||||||
ExecStart = ''${python_env}/bin/gunicorn lenticular_cloud.wsgi --name lenticular_cloud \
|
ExecStart = ''${python.pkgs.gunicorn}/bin/gunicorn lenticular_cloud.wsgi --name lenticular_cloud \
|
||||||
--workers 2 --log-level=info \
|
--workers 1 --log-level=info \
|
||||||
--bind=unix:/run/${cfg.username}/web.sock \
|
--bind=unix:/run/${username}/web.sock \
|
||||||
-k gevent'';
|
-k gevent'';
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RuntimeDirectory = cfg.username;
|
RuntimeDirectory = username;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
55
overlay.nix
55
overlay.nix
|
@ -1,23 +1,19 @@
|
||||||
final: prev:
|
final: prev:
|
||||||
let
|
let
|
||||||
pkgs = final.pkgs;
|
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 {
|
in {
|
||||||
python3 = prev.python3.override {
|
python3 = prev.python3.override {
|
||||||
packageOverrides = final: prev: with final; {
|
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 {
|
urlobject = buildPythonPackage rec {
|
||||||
pname = "URLObject";
|
pname = "URLObject";
|
||||||
version = "2.4.3";
|
version = "2.4.3";
|
||||||
|
@ -78,16 +74,28 @@ in {
|
||||||
httpx
|
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: {
|
flask = prev.flask.overridePythonAttrs (old: {
|
||||||
propagatedBuildInputs = old.propagatedBuildInputs ++ flask.optional-dependencies.async;
|
propagatedBuildInputs = old.propagatedBuildInputs ++ flask.optional-dependencies.async;
|
||||||
});
|
});
|
||||||
lenticular-cloud = buildPythonPackage {
|
lenticular-cloud = buildPythonPackage {
|
||||||
pname = "lenticular_cloud";
|
pname = "lenticular_cloud";
|
||||||
version = version;
|
version = "0.3";
|
||||||
src = ./.;
|
src = ./.;
|
||||||
postPatch = ''
|
|
||||||
cp -r ${frontend}/static ./lenticular_cloud/
|
|
||||||
'';
|
|
||||||
propagatedBuildInputs = [
|
propagatedBuildInputs = [
|
||||||
flask
|
flask
|
||||||
flask-restful
|
flask-restful
|
||||||
|
@ -97,18 +105,17 @@ in {
|
||||||
flask_login
|
flask_login
|
||||||
requests
|
requests
|
||||||
requests_oauthlib
|
requests_oauthlib
|
||||||
# ldap3 # only needed for old upgrade
|
ldap3
|
||||||
#ldap3-orm
|
#ldap3-orm
|
||||||
pyotp
|
pyotp
|
||||||
cryptography
|
cryptography
|
||||||
blinker
|
blinker
|
||||||
authlib # as oauth client lib
|
authlib # as oauth client lib
|
||||||
|
fido2 # for webauthn
|
||||||
flask_migrate # db migrations
|
flask_migrate # db migrations
|
||||||
flask-dance
|
flask-dance
|
||||||
ory-hydra-client
|
ory-hydra-client
|
||||||
toml
|
toml
|
||||||
webauthn pyopenssl
|
|
||||||
pyjwt
|
|
||||||
|
|
||||||
pkgs.nodejs
|
pkgs.nodejs
|
||||||
#node-env
|
#node-env
|
||||||
|
@ -128,6 +135,11 @@ in {
|
||||||
mypy
|
mypy
|
||||||
|
|
||||||
];
|
];
|
||||||
|
# passthru = {
|
||||||
|
# inherit python;
|
||||||
|
# pythonPath = python.pkgs.makePythonPath propagatedBuildInputs;
|
||||||
|
# };
|
||||||
|
|
||||||
|
|
||||||
doCheck = false;
|
doCheck = false;
|
||||||
checkInputs = [
|
checkInputs = [
|
||||||
|
@ -137,5 +149,4 @@ in {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
lenticular-cloud = final.python3.pkgs.lenticular-cloud;
|
lenticular-cloud = final.python3.pkgs.lenticular-cloud;
|
||||||
lenticular-cloud-frontend = frontend;
|
|
||||||
}
|
}
|
3524
package-lock.json
generated
3524
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",
|
"version": "2.0.0",
|
||||||
"description": "Lenticular Cloud ================",
|
"description": "Lenticular Cloud ================",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "webpack-cli --mode production",
|
"build": "webpack-cli"
|
||||||
"watch": "webpack-cli --mode development -w"
|
|
||||||
},
|
},
|
||||||
"author": "TuxCoder",
|
"author": "TuxCoder",
|
||||||
"license": "GPLv3",
|
"license": "GPLv3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.1.1",
|
"@fortawesome/fontawesome-free": "^6.1.1",
|
||||||
"@simplewebauthn/browser": "^8.3.4",
|
|
||||||
"bootstrap": "^4.6.1",
|
"bootstrap": "^4.6.1",
|
||||||
"cbor-web": "*",
|
"cbor-web": "*",
|
||||||
"css-loader": "^6.7.1",
|
"css-loader": "^6.7.1",
|
||||||
|
@ -24,12 +22,13 @@
|
||||||
"qrcode-svg": "~1.1.0",
|
"qrcode-svg": "~1.1.0",
|
||||||
"sass": "^1.52.1",
|
"sass": "^1.52.1",
|
||||||
"sass-loader": "^13.0.0",
|
"sass-loader": "^13.0.0",
|
||||||
"simple-form-submit": "*",
|
|
||||||
"style-loader": "*",
|
"style-loader": "*",
|
||||||
"terser-webpack-plugin": "*",
|
"terser-webpack-plugin": "*",
|
||||||
"ts-loader": "^9.3.0",
|
"ts-loader": "^9.3.0",
|
||||||
"url-loader": "*",
|
"url-loader": "*",
|
||||||
"webpack": "^5.72.1",
|
"webpack": "^5.72.1",
|
||||||
"webpack-cli": "*"
|
"webpack-cli": "*",
|
||||||
}
|
"simple-form-submit": "*"
|
||||||
|
},
|
||||||
|
"devDependencies": {}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue