Compare commits
	
		
			No commits in common. "368f2396ce0ae7e2b09705bf73b559e0df12f000" and "6e76cf74da2b99e75e663efbf8790fcf21274195" have entirely different histories.
		
	
	
		
			368f2396ce
			...
			6e76cf74da
		
	
		
					 51 changed files with 3460 additions and 1558 deletions
				
			
		
							
								
								
									
										1
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -13,4 +13,3 @@ node_modules
 | 
			
		|||
build
 | 
			
		||||
result
 | 
			
		||||
nixos.qcow2
 | 
			
		||||
/lenticular_cloud/static
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										28
									
								
								README.md
									
										
									
									
									
								
							
							
						
						
									
										28
									
								
								README.md
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -2,7 +2,7 @@ Lenticular Cloud
 | 
			
		|||
================
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Simple user Manager proudly made in ~~LDAP~~ SQL
 | 
			
		||||
Simple user Manager in LDAP
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -11,12 +11,15 @@ Features
 | 
			
		|||
 | 
			
		||||
* frontend for hydra
 | 
			
		||||
* Web Platform to mange users
 | 
			
		||||
* fake ldap backend, can be used by other services
 | 
			
		||||
* client certs
 | 
			
		||||
* ldap backend, can be used by other services
 | 
			
		||||
 | 
			
		||||
Auth Methods:
 | 
			
		||||
-------------
 | 
			
		||||
 * U2F (TODO)
 | 
			
		||||
 * TOTP
 | 
			
		||||
 * Password
 | 
			
		||||
 * Passkey
 | 
			
		||||
 * WebAuth (TODO)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -31,20 +34,7 @@ Tested Services
 | 
			
		|||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Development
 | 
			
		||||
===========
 | 
			
		||||
Oauth2 Settings:
 | 
			
		||||
----------------
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
requirements:
 | 
			
		||||
 * nix package manager
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
get dev enviroment with `nix develop`
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
run javascript part with `npm run watch`
 | 
			
		||||
 | 
			
		||||
run python stuff with `python cli.py run`
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
run tests with `nix flake check`
 | 
			
		||||
callback url: `${domain}/
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,7 +12,6 @@ var asn1 = require('node-forge/lib/asn1');
 | 
			
		|||
var pkcs12 = require('node-forge/lib/pkcs12');
 | 
			
		||||
var util = require('node-forge/lib/util');
 | 
			
		||||
import SimpleFormSubmit from "simple-form-submit";
 | 
			
		||||
import {startRegistration, startAuthentication} from '@simplewebauthn/browser';
 | 
			
		||||
 | 
			
		||||
const $ = document.querySelector.bind(document);
 | 
			
		||||
const $$ = document.querySelectorAll.bind(document);
 | 
			
		||||
| 
						 | 
				
			
			@ -101,17 +100,44 @@ window.auth = {
 | 
			
		|||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
window.auth_passkey = {
 | 
			
		||||
	sign_up: async function(options) {
 | 
			
		||||
		const resp = await startRegistration(options);
 | 
			
		||||
		return resp;
 | 
			
		||||
window.totp = {
 | 
			
		||||
	init_list: function(){
 | 
			
		||||
	},
 | 
			
		||||
	sign_in: async function(options) {
 | 
			
		||||
		const resp = await startAuthentication(options);
 | 
			
		||||
		return resp;
 | 
			
		||||
	init_new: function() {
 | 
			
		||||
		//create new TOTP secret, create qrcode and ask for token.
 | 
			
		||||
		var form = $('form');
 | 
			
		||||
		var secret = randBase32();
 | 
			
		||||
		var input_secret = form.querySelector('#secret')
 | 
			
		||||
		if(input_secret.value == '') {
 | 
			
		||||
			input_secret.value = secret;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		form.querySelector('#name').onchange=window.totp.generate_qrcode;
 | 
			
		||||
		form.querySelector('#name').onkeyup=window.totp.generate_qrcode;
 | 
			
		||||
		window.totp.generate_qrcode();
 | 
			
		||||
	},
 | 
			
		||||
	generate_qrcode: function(){
 | 
			
		||||
		var form = $('form');
 | 
			
		||||
		var secret = form.querySelector('#secret').value;
 | 
			
		||||
		var name = form.querySelector('#name').value;
 | 
			
		||||
		var issuer = 'Lenticular%20Cloud';
 | 
			
		||||
		var svg_container = $('#svg-container')
 | 
			
		||||
		var svg = new QRCode(`otpauth://totp/${issuer}:${name}?secret=${secret}&issuer=${issuer}`).svg();
 | 
			
		||||
		var svg_xml =new DOMParser().parseFromString(svg,'text/xml')
 | 
			
		||||
		if(svg_container.childNodes.length > 0) {
 | 
			
		||||
			svg_container.childNodes[0].replaceWith(svg_xml.childNodes[0])
 | 
			
		||||
		} else {
 | 
			
		||||
			svg_container.appendChild(svg_xml.childNodes[0]);
 | 
			
		||||
		}
 | 
			
		||||
		//	.innerHtml=svg;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
window.fido2 = {
 | 
			
		||||
	init: function() {
 | 
			
		||||
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
window.password_change= {
 | 
			
		||||
	init: function(){
 | 
			
		||||
		var form = $('form');
 | 
			
		||||
| 
						 | 
				
			
			@ -149,3 +175,77 @@ window.oauth2_token = {
 | 
			
		|||
		return false;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
window.client_cert = {
 | 
			
		||||
	init_list: function() {
 | 
			
		||||
		// do fancy cert stats stuff
 | 
			
		||||
	},
 | 
			
		||||
	init_new: function() {
 | 
			
		||||
		// create localy key or import public key
 | 
			
		||||
 | 
			
		||||
		var form = $('form#gen-key-form');
 | 
			
		||||
 | 
			
		||||
	},
 | 
			
		||||
	generate_private_key: function() {
 | 
			
		||||
		var form = $('form#gen-key-form');
 | 
			
		||||
		var key_size = form.querySelector('#key-size').value;
 | 
			
		||||
		var valid_time = form.querySelector('input[name=valid_time]').value;
 | 
			
		||||
		$('button#generate-key').style['display'] = 'none';
 | 
			
		||||
		pki.rsa.generateKeyPair({bits: key_size, workers: 2}, function(err, keypair) {
 | 
			
		||||
			console.log(keypair);
 | 
			
		||||
 | 
			
		||||
			//returns the exported key to a hidden form
 | 
			
		||||
			var form_sign_key = $('#gen-key-sign form');
 | 
			
		||||
			form_sign_key.querySelector('textarea[name=publickey]').value = pki.publicKeyToPem(keypair.publicKey);
 | 
			
		||||
			form_sign_key.querySelector('input[name=valid_time]').value = valid_time;
 | 
			
		||||
 | 
			
		||||
			SimpleFormSubmit.submitForm(form_sign_key.action, form_sign_key)
 | 
			
		||||
				.then(response => {
 | 
			
		||||
					response.json().then( json_data => {
 | 
			
		||||
						if (json_data.errors) {
 | 
			
		||||
							var msg ='<ul>';
 | 
			
		||||
							for( var field in json_data.repsonse) {
 | 
			
		||||
								msg += `<li>${field}: ${data.errors[field]}</li>`;
 | 
			
		||||
							}
 | 
			
		||||
							msg += '</ul>';
 | 
			
		||||
							new Dialog('Password change Error', `Error Happend: ${msg}`).show()
 | 
			
		||||
						} else {
 | 
			
		||||
							// get certificate
 | 
			
		||||
							var data = response.data;
 | 
			
		||||
							var certs = [
 | 
			
		||||
								pki.certificateFromPem(data.cert),
 | 
			
		||||
								pki.certificateFromPem(data.ca_cert)
 | 
			
		||||
							];
 | 
			
		||||
							var password = form.querySelector('#cert-password').value;
 | 
			
		||||
							var p12Asn1;
 | 
			
		||||
							if (password == '') {
 | 
			
		||||
								p12Asn1 = pkcs12.toPkcs12Asn1(keypair.privateKey, certs, null, {algorithm: '3des'}); // without password
 | 
			
		||||
							} else {
 | 
			
		||||
								p12Asn1 = pkcs12.toPkcs12Asn1(keypair.privateKey, certs, password, {algorithm: '3des'}); // without password
 | 
			
		||||
							}
 | 
			
		||||
							var p12Der = asn1.toDer(p12Asn1).getBytes();
 | 
			
		||||
							var p12b64 = util.encode64(p12Der);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							var button = $('#save-button');
 | 
			
		||||
							button.href= "data:application/x-pkcs12;base64," + p12b64
 | 
			
		||||
							button.style['display'] ='block';
 | 
			
		||||
							//new Dialog('Password changed', 'Password changed successfully!').show();
 | 
			
		||||
						}
 | 
			
		||||
					});
 | 
			
		||||
				});
 | 
			
		||||
		});
 | 
			
		||||
	},
 | 
			
		||||
	revoke_certificate: function(href, id){
 | 
			
		||||
		var dialog = new ConfirmDialog('Revoke client certificate', `Are you sure to revoke the certificate with the fingerprint ${id}?`);
 | 
			
		||||
		dialog.show().then(()=>{
 | 
			
		||||
			fetch(href, {
 | 
			
		||||
				method: 'DELETE'
 | 
			
		||||
			});
 | 
			
		||||
		});
 | 
			
		||||
		return false;
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										22
									
								
								flake.lock
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										22
									
								
								flake.lock
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -21,11 +21,11 @@
 | 
			
		|||
        "systems": "systems"
 | 
			
		||||
      },
 | 
			
		||||
      "locked": {
 | 
			
		||||
        "lastModified": 1701680307,
 | 
			
		||||
        "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
 | 
			
		||||
        "lastModified": 1694529238,
 | 
			
		||||
        "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
 | 
			
		||||
        "owner": "numtide",
 | 
			
		||||
        "repo": "flake-utils",
 | 
			
		||||
        "rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
 | 
			
		||||
        "rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
 | 
			
		||||
        "type": "github"
 | 
			
		||||
      },
 | 
			
		||||
      "original": {
 | 
			
		||||
| 
						 | 
				
			
			@ -52,16 +52,16 @@
 | 
			
		|||
    },
 | 
			
		||||
    "nixpkgs": {
 | 
			
		||||
      "locked": {
 | 
			
		||||
        "lastModified": 1703200384,
 | 
			
		||||
        "narHash": "sha256-q5j06XOsy0qHOarsYPfZYJPWbTbc8sryRxianlEPJN0=",
 | 
			
		||||
        "lastModified": 1697059129,
 | 
			
		||||
        "narHash": "sha256-9NJcFF9CEYPvHJ5ckE8kvINvI84SZZ87PvqMbH6pro0=",
 | 
			
		||||
        "owner": "NixOS",
 | 
			
		||||
        "repo": "nixpkgs",
 | 
			
		||||
        "rev": "0b3d618173114c64ab666f557504d6982665d328",
 | 
			
		||||
        "rev": "5e4c2ada4fcd54b99d56d7bd62f384511a7e2593",
 | 
			
		||||
        "type": "github"
 | 
			
		||||
      },
 | 
			
		||||
      "original": {
 | 
			
		||||
        "id": "nixpkgs",
 | 
			
		||||
        "ref": "nixos-23.11",
 | 
			
		||||
        "ref": "nixos-unstable",
 | 
			
		||||
        "type": "indirect"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
| 
						 | 
				
			
			@ -99,11 +99,11 @@
 | 
			
		|||
        ]
 | 
			
		||||
      },
 | 
			
		||||
      "locked": {
 | 
			
		||||
        "lastModified": 1702764954,
 | 
			
		||||
        "narHash": "sha256-+1z/0NJ/8c0d6Um1y9wpVO8CPXHd9/psOJF9GqFS/38=",
 | 
			
		||||
        "lastModified": 1696700871,
 | 
			
		||||
        "narHash": "sha256-9VFEJEfnfnCS1+kLznxd+OiDYdMnLP00+XR53iPfnK4=",
 | 
			
		||||
        "ref": "refs/heads/master",
 | 
			
		||||
        "rev": "dcea3067863899ee23950670e7fed2a4feccc20e",
 | 
			
		||||
        "revCount": 13,
 | 
			
		||||
        "rev": "a25f5792a256beaed2a9f944fccdea8ea7a8d44b",
 | 
			
		||||
        "revCount": 6,
 | 
			
		||||
        "type": "git",
 | 
			
		||||
        "url": "ssh://git@git.o-g.at/nixpkg/tuxpkgs.git"
 | 
			
		||||
      },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										75
									
								
								flake.nix
									
										
									
									
									
								
							
							
						
						
									
										75
									
								
								flake.nix
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
{
 | 
			
		||||
  description = "Lenticular cloud interface";
 | 
			
		||||
  inputs = {
 | 
			
		||||
    nixpkgs.url = "nixpkgs/nixos-23.11";
 | 
			
		||||
    nixpkgs.url = "nixpkgs/nixos-unstable";
 | 
			
		||||
    flake-utils.url = "github:numtide/flake-utils";
 | 
			
		||||
    flake-compat = { # for shell.nix
 | 
			
		||||
      url = "github:edolstra/flake-compat";
 | 
			
		||||
| 
						 | 
				
			
			@ -22,16 +22,14 @@
 | 
			
		|||
      pkgs = nixpkgs.legacyPackages.${system}.extend (import ./overlay.nix);
 | 
			
		||||
    in rec {
 | 
			
		||||
      formatter = pkgs.nixpkgs-fmt;
 | 
			
		||||
      devShells.default = pkgs.mkShell {packages = with pkgs; [
 | 
			
		||||
         (python3.withPackages (ps: (
 | 
			
		||||
          lenticular-cloud.propagatedBuildInputs ++
 | 
			
		||||
          lenticular-cloud.testBuildInputs
 | 
			
		||||
      devShells.default = pkgs.mkShell {packages = [
 | 
			
		||||
         (pkgs.python3.withPackages (ps: (
 | 
			
		||||
          pkgs.lenticular-cloud.propagatedBuildInputs ++
 | 
			
		||||
          pkgs.lenticular-cloud.testBuildInputs
 | 
			
		||||
        )))
 | 
			
		||||
        nodejs
 | 
			
		||||
      ];};
 | 
			
		||||
 | 
			
		||||
      packages.default = pkgs.lenticular-cloud;
 | 
			
		||||
      packages.frontend = pkgs.lenticular-cloud-frontend;
 | 
			
		||||
 | 
			
		||||
      checks = {
 | 
			
		||||
        package = packages.default;
 | 
			
		||||
| 
						 | 
				
			
			@ -48,77 +46,20 @@
 | 
			
		|||
          self.nixosModules.default
 | 
			
		||||
          tuxpkgs.nixosModules.ory-hydra
 | 
			
		||||
          "${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix"
 | 
			
		||||
          ({lib, ...}:{
 | 
			
		||||
          ({...}:{
 | 
			
		||||
            security.acme.acceptTerms = true;
 | 
			
		||||
            security.acme.defaults.email = "acme@example.com";
 | 
			
		||||
            services.lenticular-cloud = {
 | 
			
		||||
              enable = true;
 | 
			
		||||
              domain = "example.com";
 | 
			
		||||
              service_domain = "account.example.com";
 | 
			
		||||
              settings = {
 | 
			
		||||
                HYDRA_ADMIN_URL = "http://127.0.0.1:8081";
 | 
			
		||||
                HYDRA_PUBLIC_URL = "http://127.0.0.1:8082";
 | 
			
		||||
                PUBLIC_URL = "http://127.0.0.1:5000";
 | 
			
		||||
                ADMINS = [ "tuxcoder" ];
 | 
			
		||||
              };
 | 
			
		||||
            };
 | 
			
		||||
            services.ory-hydra = {
 | 
			
		||||
              enable = true;
 | 
			
		||||
              admin_domain = "admin-hydra.local";
 | 
			
		||||
              public_domain = "public-hydra.local";
 | 
			
		||||
              extra_args = ["--dev"];
 | 
			
		||||
              settings = {
 | 
			
		||||
                urls.self = {
 | 
			
		||||
                  issuer = "http://127.0.0.1:8082";
 | 
			
		||||
                  public = "http://127.0.0.1:8082";
 | 
			
		||||
                  admin = "http://127.0.0.1:8081";
 | 
			
		||||
                };
 | 
			
		||||
              };
 | 
			
		||||
            };
 | 
			
		||||
            networking.hosts = {
 | 
			
		||||
              "::1" = [ "admin-hydra.local" "public-hydra.local" "account.example.com" ];
 | 
			
		||||
            };
 | 
			
		||||
            networking.firewall.enable = false;
 | 
			
		||||
            networking.hosts = {"::1" = [ "admin-hydra.local" ]; };
 | 
			
		||||
            services.getty.autologinUser = "root";
 | 
			
		||||
            services.nginx.virtualHosts = {
 | 
			
		||||
              "admin-hydra.local" = {
 | 
			
		||||
                addSSL = lib.mkForce false;
 | 
			
		||||
                enableACME = lib.mkForce false;
 | 
			
		||||
                listen = [{
 | 
			
		||||
                  addr = "0.0.0.0";
 | 
			
		||||
                  port = 8081;
 | 
			
		||||
                }];
 | 
			
		||||
                locations."/" = {
 | 
			
		||||
                  extraConfig = ''
 | 
			
		||||
                    allow all;
 | 
			
		||||
                  '';
 | 
			
		||||
                };
 | 
			
		||||
              };
 | 
			
		||||
              "public-hydra.local" = {
 | 
			
		||||
                addSSL = lib.mkForce false;
 | 
			
		||||
                enableACME = lib.mkForce false;
 | 
			
		||||
                listen = [{
 | 
			
		||||
                  addr = "0.0.0.0";
 | 
			
		||||
                  port = 8082;
 | 
			
		||||
                }];
 | 
			
		||||
              };
 | 
			
		||||
            };
 | 
			
		||||
            virtualisation = {
 | 
			
		||||
              forwardPorts = [ {
 | 
			
		||||
                from = "host";
 | 
			
		||||
                host.port = 8080;
 | 
			
		||||
                guest.port = 80;
 | 
			
		||||
              } {
 | 
			
		||||
                from = "host";
 | 
			
		||||
                host.port = 8081;
 | 
			
		||||
                guest.port = 8081;
 | 
			
		||||
              } {
 | 
			
		||||
                from = "host";
 | 
			
		||||
                host.port = 8082;
 | 
			
		||||
                guest.port = 8082;
 | 
			
		||||
              } ];
 | 
			
		||||
              qemu.options = [ "-vga none" "-nographic" ];
 | 
			
		||||
            };
 | 
			
		||||
            virtualisation.qemu.options = ["-vga none"];
 | 
			
		||||
          })
 | 
			
		||||
        ];
 | 
			
		||||
      };
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,6 @@
 | 
			
		|||
from flask import current_app
 | 
			
		||||
from flask_wtf import FlaskForm
 | 
			
		||||
from .form.auth import PasswordForm
 | 
			
		||||
from .form.auth import PasswordForm, TotpForm, Fido2Form
 | 
			
		||||
from hmac import compare_digest as compare_hash
 | 
			
		||||
import crypt
 | 
			
		||||
from .model import User
 | 
			
		||||
| 
						 | 
				
			
			@ -47,8 +47,38 @@ class PasswordAuthProvider(AuthProvider):
 | 
			
		|||
        return compare_hash(crypt.crypt(password, user.password_hashed),user.password_hashed) 
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class U2FAuthProvider(AuthProvider):
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def get_from() -> FlaskForm:
 | 
			
		||||
        return Fido2Form(prefix='fido2')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class WebAuthProvider(AuthProvider):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TotpAuthProvider(AuthProvider):
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def get_form():
 | 
			
		||||
        return TotpForm(prefix='totp')
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def check_auth(user: User, form: FlaskForm) -> bool:
 | 
			
		||||
        data = form.data['totp']
 | 
			
		||||
        if data is not None:
 | 
			
		||||
            #print(f'data totp: {data}')
 | 
			
		||||
            if len(user.totps) == 0:  # migration, TODO remove
 | 
			
		||||
                return True
 | 
			
		||||
            for totp in user.totps:
 | 
			
		||||
                if totp.verify(data):
 | 
			
		||||
                    return True
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
AUTH_PROVIDER_LIST = [
 | 
			
		||||
    PasswordAuthProvider
 | 
			
		||||
    PasswordAuthProvider,
 | 
			
		||||
    TotpAuthProvider
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
#print(LdapAuthProvider.get_name())
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,7 +6,6 @@ from werkzeug.middleware.proxy_fix import ProxyFix
 | 
			
		|||
from flask_migrate import upgrade
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from flask import Flask
 | 
			
		||||
from uuid import UUID
 | 
			
		||||
 | 
			
		||||
import logging
 | 
			
		||||
import os
 | 
			
		||||
| 
						 | 
				
			
			@ -20,10 +19,6 @@ def entry_point() -> None:
 | 
			
		|||
 | 
			
		||||
    parser_user = subparsers.add_parser('user')
 | 
			
		||||
    parser_user.set_defaults(func=cli_user)
 | 
			
		||||
    subparsers_user = parser_user.add_subparsers()
 | 
			
		||||
    parser_user_delete = subparsers_user.add_parser('delete')
 | 
			
		||||
    parser_user_delete.add_argument('--id', type=str)
 | 
			
		||||
    parser_user_delete.set_defaults(func=cli_user_delete)
 | 
			
		||||
 | 
			
		||||
    parser_signup = subparsers.add_parser('signup')
 | 
			
		||||
    parser_signup.add_argument('--signup_id', type=str)
 | 
			
		||||
| 
						 | 
				
			
			@ -66,16 +61,6 @@ def cli_user(args) -> None:
 | 
			
		|||
        print(f'{user.id} - Enabled: {user.enabled} - Name:`{user.username}`')
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
def cli_user_delete(args) -> None:
 | 
			
		||||
    user = User.query.get(UUID(args.id))
 | 
			
		||||
    if user is None:
 | 
			
		||||
        print("user not found")
 | 
			
		||||
        return
 | 
			
		||||
    db.session.delete(user)
 | 
			
		||||
    db.session.commit()
 | 
			
		||||
    print(f"user {user.username} - {user.id} deleted")
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
def cli_signup(args) -> None:
 | 
			
		||||
 | 
			
		||||
    if args.signup_id is not None:
 | 
			
		||||
| 
						 | 
				
			
			@ -93,7 +78,6 @@ def cli_signup(args) -> None:
 | 
			
		|||
            print(f'<Signup id={user.id}, username={user.username}>')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def cli_run(app: Flask, args) -> None:
 | 
			
		||||
    print("running in debug mode")
 | 
			
		||||
    logging.basicConfig(level=logging.DEBUG)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,22 +12,17 @@ SQLALCHEMY_TRACK_MODIFICATIONS = false
 | 
			
		|||
 | 
			
		||||
PKI_PATH = "../data/pki"
 | 
			
		||||
DOMAIN = 'example.com'
 | 
			
		||||
PUBLIC_URL = 'http://localhost:5000'
 | 
			
		||||
#SERVER_NAME = f'account.{ DOMAIN }:9090'
 | 
			
		||||
 | 
			
		||||
HYDRA_REQUEST_TIMEOUT_SECONDS = 3
 | 
			
		||||
HYDRA_ADMIN_URL = 'http://127.0.0.1:8081'
 | 
			
		||||
HYDRA_ADMIN_URL = 'http://127.0.0.1:4445'
 | 
			
		||||
HYDRA_ADMIN_USER = 'lenticluar_cloud'
 | 
			
		||||
HYDRA_ADMIN_PASSWORD = 'notSecure'
 | 
			
		||||
HYDRA_PUBLIC_URL = 'http://127.0.0.1:8082'
 | 
			
		||||
HYDRA_PUBLIC_URL = 'http://127.0.0.1:4444'
 | 
			
		||||
SUBJECT_PREFIX = 'something random'
 | 
			
		||||
 | 
			
		||||
OAUTH_ID = 'identiy_provider'
 | 
			
		||||
OAUTH_SECRET = 'thisIsNotSecure'
 | 
			
		||||
 | 
			
		||||
ADMINS = [
 | 
			
		||||
    'tuxcoder'
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[LENTICULAR_CLOUD_SERVICES.jabber]
 | 
			
		||||
app_token = true
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -20,6 +20,22 @@ class PasswordForm(FlaskForm):
 | 
			
		|||
    password = PasswordField(gettext('Password'))
 | 
			
		||||
    submit = SubmitField(gettext('Authorize'))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TotpForm(FlaskForm):
 | 
			
		||||
    totp = StringField(gettext('2FA Token'))
 | 
			
		||||
    submit = SubmitField(gettext('Authorize'))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class WebauthnLoginForm(FlaskForm):
 | 
			
		||||
    """webauthn login form"""
 | 
			
		||||
 | 
			
		||||
    assertion = HiddenField('Assertion', [InputRequired()])
 | 
			
		||||
 | 
			
		||||
class Fido2Form(FlaskForm):
 | 
			
		||||
    fido2 = StringField(gettext('Fido2'), default="Javascript Required")
 | 
			
		||||
    submit = SubmitField(gettext('Authorize'))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ConsentForm(FlaskForm):
 | 
			
		||||
#   scopes = SelectMultipleField(gettext('scopes'))
 | 
			
		||||
#   audiences = SelectMultipleField(gettext('audiences'))
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -22,6 +22,17 @@ class ClientCertForm(FlaskForm):
 | 
			
		|||
            ])
 | 
			
		||||
    submit = SubmitField(gettext('Submit'))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TOTPForm(FlaskForm):
 | 
			
		||||
    secret = HiddenField(gettext('totp-Secret'))
 | 
			
		||||
    token = StringField(gettext('totp-verify token'))
 | 
			
		||||
    name = StringField(gettext('name'))
 | 
			
		||||
    submit = SubmitField(gettext('Activate'))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TOTPDeleteForm(FlaskForm):
 | 
			
		||||
    submit = SubmitField(gettext('Delete'))
 | 
			
		||||
 | 
			
		||||
class AppTokenForm(FlaskForm):
 | 
			
		||||
    name = StringField(gettext('name'), validators=[DataRequired(),Length(min=1, max=255) ])
 | 
			
		||||
    scopes = StringField(gettext('scopes'), validators=[DataRequired(),Length(min=1, max=255) ])
 | 
			
		||||
| 
						 | 
				
			
			@ -30,10 +41,11 @@ class AppTokenForm(FlaskForm):
 | 
			
		|||
class AppTokenDeleteForm(FlaskForm):
 | 
			
		||||
    submit = SubmitField(gettext('Delete'))
 | 
			
		||||
 | 
			
		||||
class PasskeyRegisterForm(FlaskForm):
 | 
			
		||||
    """Passkey register form"""
 | 
			
		||||
class WebauthnRegisterForm(FlaskForm):
 | 
			
		||||
    """webauthn register token form"""
 | 
			
		||||
 | 
			
		||||
    name = StringField('Name', [Length(max=50)])
 | 
			
		||||
    attestation = HiddenField('Attestation', [InputRequired()])
 | 
			
		||||
    name = StringField('Name', [Length(max=250)])
 | 
			
		||||
    submit = SubmitField('Register', render_kw={'disabled': True})
 | 
			
		||||
 | 
			
		||||
class PasswordChangeForm(FlaskForm):
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,8 +23,7 @@ class HydraService:
 | 
			
		|||
        self.set_hydra_client(Client(base_url=app.config['HYDRA_ADMIN_URL']))
 | 
			
		||||
 | 
			
		||||
        client_name = app.config['OAUTH_ID']
 | 
			
		||||
        client_secret = app.config['OAUTH_SECRET']
 | 
			
		||||
        public_url = app.config['PUBLIC_URL']
 | 
			
		||||
        client_secret = token_hex(16)
 | 
			
		||||
 | 
			
		||||
        clients = list_o_auth_2_clients.sync_detailed(_client=self.hydra_client).parsed
 | 
			
		||||
        if clients is None:
 | 
			
		||||
| 
						 | 
				
			
			@ -36,26 +35,25 @@ class HydraService:
 | 
			
		|||
                break
 | 
			
		||||
 | 
			
		||||
        if client is None:
 | 
			
		||||
            client_req = OAuth20Client(
 | 
			
		||||
            domain = app.config['DOMAIN']
 | 
			
		||||
            client = OAuth20Client(
 | 
			
		||||
                client_name="identiy_provider",
 | 
			
		||||
                # client_id=client_id,
 | 
			
		||||
                client_secret=client_secret,
 | 
			
		||||
                response_types=["code", "id_token"],
 | 
			
		||||
                scope="openid profile manage",
 | 
			
		||||
                grant_types=["authorization_code", "refresh_token"],
 | 
			
		||||
                redirect_uris=[ f"{public_url}/oauth/authorized" ],
 | 
			
		||||
                redirect_uris=[ f"https://{domain}/oauth/authorized" ],
 | 
			
		||||
                token_endpoint_auth_method="client_secret_basic",
 | 
			
		||||
            )
 | 
			
		||||
            ret = create_o_auth_2_client.sync(json_body=client_req, _client=self.hydra_client)
 | 
			
		||||
            ret = create_o_auth_2_client.sync(json_body=client, _client=self.hydra_client)
 | 
			
		||||
            if ret is None:
 | 
			
		||||
                raise RuntimeError("could not create account")
 | 
			
		||||
            client = ret
 | 
			
		||||
                raise RuntimeError("could not crate account")
 | 
			
		||||
        else:
 | 
			
		||||
            client.client_secret = client_secret
 | 
			
		||||
            client.redirect_uris = [ f"{public_url}/oauth/authorized" ]
 | 
			
		||||
            ret = set_o_auth_2_client.sync(id=client.client_id, json_body=client, _client=self.hydra_client)
 | 
			
		||||
            ret = set_o_auth_2_client.sync(id=client.client_id,json_body=client, _client=self.hydra_client)
 | 
			
		||||
            if ret is None:
 | 
			
		||||
                raise RuntimeError("could not update account")
 | 
			
		||||
                raise RuntimeError("could not crate account")
 | 
			
		||||
        if type(client.client_id) is not str:
 | 
			
		||||
            raise RuntimeError("could not parse client_id from ory-hydra")
 | 
			
		||||
        self.client_id = client.client_id
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,40 +0,0 @@
 | 
			
		|||
"""passkey
 | 
			
		||||
 | 
			
		||||
Revision ID: b5448df204eb
 | 
			
		||||
Revises: a74320a5d7a1
 | 
			
		||||
Create Date: 2023-12-25 00:13:01.703575
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from alembic import op
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# revision identifiers, used by Alembic.
 | 
			
		||||
revision = 'b5448df204eb'
 | 
			
		||||
down_revision = 'a74320a5d7a1'
 | 
			
		||||
branch_labels = None
 | 
			
		||||
depends_on = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def upgrade():
 | 
			
		||||
    # ### commands auto generated by Alembic - please adjust! ###
 | 
			
		||||
    op.create_table('passkey_credential',
 | 
			
		||||
    sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
 | 
			
		||||
    sa.Column('user_id', sa.Uuid(), nullable=False),
 | 
			
		||||
    sa.Column('credential_id', sa.LargeBinary(), nullable=False),
 | 
			
		||||
    sa.Column('credential_public_key', sa.LargeBinary(), nullable=False),
 | 
			
		||||
    sa.Column('name', sa.String(length=250), nullable=False),
 | 
			
		||||
    sa.Column('last_used', sa.DateTime(), nullable=True),
 | 
			
		||||
    sa.Column('sign_count', sa.Integer(), nullable=False),
 | 
			
		||||
    sa.Column('created_at', sa.DateTime(), nullable=False),
 | 
			
		||||
    sa.Column('modified_at', sa.DateTime(), nullable=False),
 | 
			
		||||
    sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
 | 
			
		||||
    sa.PrimaryKeyConstraint('id')
 | 
			
		||||
    )
 | 
			
		||||
    # ### end Alembic commands ###
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def downgrade():
 | 
			
		||||
    # ### commands auto generated by Alembic - please adjust! ###
 | 
			
		||||
    op.drop_table('passkey_credential')
 | 
			
		||||
    # ### end Alembic commands ###
 | 
			
		||||
| 
						 | 
				
			
			@ -163,8 +163,15 @@ class User(BaseModel, ModelUpdatedMixin):
 | 
			
		|||
    enabled: Mapped[bool] = mapped_column(db.Boolean, nullable=False, default=False)
 | 
			
		||||
 | 
			
		||||
    app_tokens: Mapped[List['AppToken']] = relationship('AppToken', back_populates='user')
 | 
			
		||||
    passkey_credentials: Mapped[List['PasskeyCredential']] = relationship('PasskeyCredential', back_populates='user', cascade='delete,delete-orphan', passive_deletes=True)
 | 
			
		||||
    # totps: Mapped[List['Totp']] = relationship('Totp', back_populates='user', default_factory=list)
 | 
			
		||||
    # webauthn_credentials: Mapped[List['WebauthnCredential']] = relationship('WebauthnCredential', back_populates='user', cascade='delete,delete-orphan', passive_deletes=True, default_factory=list)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def totps(self) -> List['Totp']:
 | 
			
		||||
        return []
 | 
			
		||||
    @property
 | 
			
		||||
    def webauthn_credentials(self) -> List['WebauthnCredential']:
 | 
			
		||||
        return []
 | 
			
		||||
 | 
			
		||||
    def __init__(self, **kwargs) -> None:
 | 
			
		||||
        super().__init__(**kwargs)
 | 
			
		||||
| 
						 | 
				
			
			@ -178,8 +185,7 @@ class User(BaseModel, ModelUpdatedMixin):
 | 
			
		|||
 | 
			
		||||
    @property
 | 
			
		||||
    def groups(self) -> list['Group']:
 | 
			
		||||
        admins = current_app.config['ADMINS']
 | 
			
		||||
        if self.username in admins:
 | 
			
		||||
        if self.username == 'tuxcoder':
 | 
			
		||||
            return [Group(name='admin')]
 | 
			
		||||
        else:
 | 
			
		||||
            return []
 | 
			
		||||
| 
						 | 
				
			
			@ -222,20 +228,33 @@ class AppToken(BaseModel, ModelUpdatedMixin):
 | 
			
		|||
        token = ''.join(secrets.choice(alphabet) for i in range(12))
 | 
			
		||||
        return AppToken(scopes=scopes, token=token, user=user, name=name)
 | 
			
		||||
 | 
			
		||||
class Totp(BaseModel, ModelUpdatedMixin):
 | 
			
		||||
    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
 | 
			
		||||
    secret: Mapped[str] = mapped_column(db.String, nullable=False)
 | 
			
		||||
    name: Mapped[str] = mapped_column(db.String, nullable=False)
 | 
			
		||||
 | 
			
		||||
    user_id: Mapped[uuid.UUID] = mapped_column(
 | 
			
		||||
            db.Uuid,
 | 
			
		||||
            db.ForeignKey(User.id), nullable=False)
 | 
			
		||||
    # user: Mapped[User] = relationship(User, back_populates="totp")
 | 
			
		||||
    last_used: Mapped[Optional[datetime]] = mapped_column(db.DateTime, nullable=True, default=None)
 | 
			
		||||
 | 
			
		||||
    def verify(self, token: str) -> bool:
 | 
			
		||||
        totp = pyotp.TOTP(self.secret)
 | 
			
		||||
        return totp.verify(token)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PasskeyCredential(BaseModel, ModelUpdatedMixin):  # pylint: disable=too-few-public-methods
 | 
			
		||||
    """Passkey credential model"""
 | 
			
		||||
class WebauthnCredential(BaseModel, ModelUpdatedMixin):  # pylint: disable=too-few-public-methods
 | 
			
		||||
    """Webauthn credential model"""
 | 
			
		||||
 | 
			
		||||
    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
 | 
			
		||||
    user_id: Mapped[uuid.UUID] = mapped_column(db.Uuid, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
 | 
			
		||||
    credential_id: Mapped[bytes] = mapped_column(db.LargeBinary, nullable=False)
 | 
			
		||||
    credential_public_key: Mapped[bytes] = mapped_column(db.LargeBinary, nullable=False)
 | 
			
		||||
    user_handle: Mapped[str] = mapped_column(db.String(64), nullable=False)
 | 
			
		||||
    credential_data: Mapped[bytes] = mapped_column(db.LargeBinary, nullable=False)
 | 
			
		||||
    name: Mapped[str] = mapped_column(db.String(250), nullable=False)
 | 
			
		||||
    last_used: Mapped[Optional[datetime]] = mapped_column(db.DateTime, nullable=True, default=None)
 | 
			
		||||
    sign_count: Mapped[int] = mapped_column(db.Integer, nullable=False, default=0)
 | 
			
		||||
    registered: Mapped[datetime] = mapped_column(db.DateTime, default=datetime.utcnow, nullable=False)
 | 
			
		||||
 | 
			
		||||
    user = db.relationship('User', back_populates='passkey_credentials')
 | 
			
		||||
    # user = db.relationship('User', back_populates='webauthn_credentials')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Group(BaseModel, ModelUpdatedMixin):
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										
											BIN
										
									
								
								lenticular_cloud/static/0a1b701f5563c2288281.ttf
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								lenticular_cloud/static/0a1b701f5563c2288281.ttf
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								lenticular_cloud/static/0caf4c6cf244a90efcc5.woff2
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								lenticular_cloud/static/0caf4c6cf244a90efcc5.woff2
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								lenticular_cloud/static/0d03b1bbd1d62c1e1284.ttf
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								lenticular_cloud/static/0d03b1bbd1d62c1e1284.ttf
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								lenticular_cloud/static/15d98c18221c8bcb2334.ttf
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								lenticular_cloud/static/15d98c18221c8bcb2334.ttf
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								lenticular_cloud/static/3d503f89ccaf1b224aa5.woff2
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								lenticular_cloud/static/3d503f89ccaf1b224aa5.woff2
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								lenticular_cloud/static/4896d4b04430cc3dfb06.woff2
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								lenticular_cloud/static/4896d4b04430cc3dfb06.woff2
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								lenticular_cloud/static/4baccb548138840fa33a.ttf
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								lenticular_cloud/static/4baccb548138840fa33a.ttf
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								lenticular_cloud/static/52eba2c567c521b8d58a.ttf
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								lenticular_cloud/static/52eba2c567c521b8d58a.ttf
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								lenticular_cloud/static/59edf72a325ac2048d60.woff2
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								lenticular_cloud/static/59edf72a325ac2048d60.woff2
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								lenticular_cloud/static/79da213423ac0def2058.ttf
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								lenticular_cloud/static/79da213423ac0def2058.ttf
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								lenticular_cloud/static/afac89562a5301459069.woff2
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								lenticular_cloud/static/afac89562a5301459069.woff2
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								lenticular_cloud/static/b823fc0dbb5a5f0c21bb.ttf
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								lenticular_cloud/static/b823fc0dbb5a5f0c21bb.ttf
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								lenticular_cloud/static/d79c2ec96ab9ff1161a2.woff2
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								lenticular_cloud/static/d79c2ec96ab9ff1161a2.woff2
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								lenticular_cloud/static/e615bbcb258550973c16.ttf
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								lenticular_cloud/static/e615bbcb258550973c16.ttf
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								lenticular_cloud/static/ebb7a127d2d8ee6f1832.woff2
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								lenticular_cloud/static/ebb7a127d2d8ee6f1832.woff2
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								lenticular_cloud/static/f77bcc98bb7468c8b15a.woff2
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								lenticular_cloud/static/f77bcc98bb7468c8b15a.woff2
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										13
									
								
								lenticular_cloud/static/main.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								lenticular_cloud/static/main.css
									
										
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										1
									
								
								lenticular_cloud/static/main.css.map
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								lenticular_cloud/static/main.css.map
									
										
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										3
									
								
								lenticular_cloud/static/main.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								lenticular_cloud/static/main.js
									
										
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										79
									
								
								lenticular_cloud/static/main.js.LICENSE.txt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								lenticular_cloud/static/main.js.LICENSE.txt
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,79 @@
 | 
			
		|||
/*!
 | 
			
		||||
  * Bootstrap v4.6.1 (https://getbootstrap.com/)
 | 
			
		||||
  * Copyright 2011-2021 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
 | 
			
		||||
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
 | 
			
		||||
  */
 | 
			
		||||
 | 
			
		||||
/*!
 | 
			
		||||
 * Sizzle CSS Selector Engine v2.3.6
 | 
			
		||||
 * https://sizzlejs.com/
 | 
			
		||||
 *
 | 
			
		||||
 * Copyright JS Foundation and other contributors
 | 
			
		||||
 * Released under the MIT license
 | 
			
		||||
 * https://js.foundation/
 | 
			
		||||
 *
 | 
			
		||||
 * Date: 2021-02-16
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/*!
 | 
			
		||||
 * jQuery Form Plugin
 | 
			
		||||
 * version: 4.3.0
 | 
			
		||||
 * Requires jQuery v1.7.2 or later
 | 
			
		||||
 * Project repository: https://github.com/jquery-form/form
 | 
			
		||||
 | 
			
		||||
 * Copyright 2017 Kevin Morris
 | 
			
		||||
 * Copyright 2006 M. Alsup
 | 
			
		||||
 | 
			
		||||
 * Dual licensed under the LGPL-2.1+ or MIT licenses
 | 
			
		||||
 * https://github.com/jquery-form/form#license
 | 
			
		||||
 | 
			
		||||
 * This library is free software; you can redistribute it and/or
 | 
			
		||||
 * modify it under the terms of the GNU Lesser General Public
 | 
			
		||||
 * License as published by the Free Software Foundation; either
 | 
			
		||||
 * version 2.1 of the License, or (at your option) any later version.
 | 
			
		||||
 * This library is distributed in the hope that it will be useful,
 | 
			
		||||
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
			
		||||
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 | 
			
		||||
 * Lesser General Public License for more details.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/*!
 | 
			
		||||
 * jQuery JavaScript Library v3.6.0
 | 
			
		||||
 * https://jquery.com/
 | 
			
		||||
 *
 | 
			
		||||
 * Includes Sizzle.js
 | 
			
		||||
 * https://sizzlejs.com/
 | 
			
		||||
 *
 | 
			
		||||
 * Copyright OpenJS Foundation and other contributors
 | 
			
		||||
 * Released under the MIT license
 | 
			
		||||
 * https://jquery.org/license
 | 
			
		||||
 *
 | 
			
		||||
 * Date: 2021-03-02T17:08Z
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/*! For license information please see cbor.js.LICENSE.txt */
 | 
			
		||||
 | 
			
		||||
/**!
 | 
			
		||||
 * @fileOverview Kickass library to create and place poppers near their reference elements.
 | 
			
		||||
 * @version 1.16.1
 | 
			
		||||
 * @license
 | 
			
		||||
 * Copyright (c) 2016 Federico Zivolo and contributors
 | 
			
		||||
 *
 | 
			
		||||
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 | 
			
		||||
 * of this software and associated documentation files (the "Software"), to deal
 | 
			
		||||
 * in the Software without restriction, including without limitation the rights
 | 
			
		||||
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 | 
			
		||||
 * copies of the Software, and to permit persons to whom the Software is
 | 
			
		||||
 * furnished to do so, subject to the following conditions:
 | 
			
		||||
 *
 | 
			
		||||
 * The above copyright notice and this permission notice shall be included in all
 | 
			
		||||
 * copies or substantial portions of the Software.
 | 
			
		||||
 *
 | 
			
		||||
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 | 
			
		||||
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 | 
			
		||||
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 | 
			
		||||
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 | 
			
		||||
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 | 
			
		||||
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 | 
			
		||||
 * SOFTWARE.
 | 
			
		||||
 */
 | 
			
		||||
							
								
								
									
										1
									
								
								lenticular_cloud/static/main.js.map
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								lenticular_cloud/static/main.js.map
									
										
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| 
						 | 
				
			
			@ -2,53 +2,12 @@
 | 
			
		|||
 | 
			
		||||
{% block title %}{{ gettext('Login') }}{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
{% block script %}
 | 
			
		||||
<script>
 | 
			
		||||
	const options_req = {{ options }};
 | 
			
		||||
	const token = "{{ token }}";
 | 
			
		||||
    const login_challenge = "{{ login_challenge }}";
 | 
			
		||||
 | 
			
		||||
	async function login() {
 | 
			
		||||
		const credential = await auth_passkey.sign_in(options_req);
 | 
			
		||||
		const response = await fetch("{{ url_for('.passkey_verify') }}", {
 | 
			
		||||
			method: "POST",
 | 
			
		||||
			headers: {
 | 
			
		||||
				"Content-Type": "application/json",
 | 
			
		||||
			},
 | 
			
		||||
			body: JSON.stringify({
 | 
			
		||||
				token,
 | 
			
		||||
				credential,
 | 
			
		||||
                login_challenge,
 | 
			
		||||
			}),
 | 
			
		||||
		})
 | 
			
		||||
        return await response.json()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	let form = document.getElementById('webauthn_register_form');
 | 
			
		||||
 | 
			
		||||
	form.onsubmit = ev => {
 | 
			
		||||
		ev.preventDefault()
 | 
			
		||||
		login().then( response => {
 | 
			
		||||
            document.location = response.redirect;
 | 
			
		||||
		})
 | 
			
		||||
	};
 | 
			
		||||
</script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="row">
 | 
			
		||||
    {{ render_form(form) }}
 | 
			
		||||
</div>
 | 
			
		||||
<div class="row">
 | 
			
		||||
    <form id="webauthn_register_form">
 | 
			
		||||
        <button class="btn btn-primary">Login wiht Passkey</button>
 | 
			
		||||
    </form>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div class="row">
 | 
			
		||||
    <a href="{{ url_for('.sign_up') }}" class="btn btn-secondary">Sign Up</a>
 | 
			
		||||
</div>
 | 
			
		||||
{{ render_form(form) }}
 | 
			
		||||
 | 
			
		||||
<a href="{{ url_for('.sign_up') }}" class="btn btn-primary">Sign Up</a>
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -213,9 +213,9 @@
 | 
			
		|||
        action_text - text of submit button
 | 
			
		||||
        class_ - sets a class for form
 | 
			
		||||
    #}
 | 
			
		||||
{% macro render_form(form, action_url='', id='', action_text='Submit', class_='', btn_class='btn btn-primary', method='post', onsubmit='') -%}
 | 
			
		||||
{% macro render_form(form, action_url='', action_text='Submit', class_='', btn_class='btn btn-primary', method='post', onsubmit='') -%}
 | 
			
		||||
 | 
			
		||||
	<form method="{{ method }}" {% if id %}id="{{ id }}" {% endif %}{% if action_url %}action="{{ action_url }}" {% endif %}{% if onsubmit %}onsubmit="{{ onsubmit }}" {% endif %}role="form" class="{{ class_ }}">
 | 
			
		||||
	<form method="{{ method }}" {% if action_url %}action="{{ action_url }}" {% endif %}{% if onsubmit %}onsubmit="{{ onsubmit }}" {% endif %}role="form" class="{{ class_ }}">
 | 
			
		||||
        <input name="form" type="hidden" value="{{ form.__class__.__name__ }}">
 | 
			
		||||
        {{ _render_form(form) }}
 | 
			
		||||
        {% if not form.submit %}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,9 +17,9 @@
 | 
			
		|||
			<div class="sidebar-sticky active">
 | 
			
		||||
				{#<a href="/"><img alt="logo" class="container-fluid" src="/static/images/dog_main_small.png"></a>#}
 | 
			
		||||
				<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.index') }}">{{ gettext('Account') }}</a></li>
 | 
			
		||||
				{#<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.client_cert') }}">{{ gettext('Client Cert') }}</a></li>#}
 | 
			
		||||
				<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.passkey') }}">{{ gettext('Passkey') }}</a></li>
 | 
			
		||||
				<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.client_cert') }}">{{ gettext('Client Cert') }}</a></li>
 | 
			
		||||
				<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.app_token') }}">{{ gettext('App Tokens') }}</a></li>
 | 
			
		||||
				<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.totp') }}">{{ gettext('2FA - TOTP') }}</a></li>
 | 
			
		||||
				<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.oauth2_tokens') }}">{{ gettext('Oauth2 Tokens') }}</a></li>
 | 
			
		||||
				<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.password_change') }}">{{ gettext('Password Change') }}</a></li>
 | 
			
		||||
				<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.logout') }}">{{ gettext('Logout') }}</a></li>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,32 +0,0 @@
 | 
			
		|||
{% extends 'frontend/base.html.j2' %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
<div class="users">
 | 
			
		||||
	<h1>Passkey Credentials list</h1>
 | 
			
		||||
 | 
			
		||||
	<table class="table">
 | 
			
		||||
		<thead>
 | 
			
		||||
			<tr>
 | 
			
		||||
				<th>name</th>
 | 
			
		||||
				<th>id</th>
 | 
			
		||||
				<th>last used</th>
 | 
			
		||||
				<th>created at</th>
 | 
			
		||||
				<th>actions</th>
 | 
			
		||||
			</tr>
 | 
			
		||||
		</thead>
 | 
			
		||||
		<tbody>
 | 
			
		||||
		{% for credential in credentials %}
 | 
			
		||||
			<tr>
 | 
			
		||||
				<td>{{ credential.name }}</td>
 | 
			
		||||
				<td>{{ credential.credential_id[0:8].hex() }}...</td>
 | 
			
		||||
				<td>{{ credential.last_used }}</td>
 | 
			
		||||
				<td>{{ credential.created_at }}...</td>
 | 
			
		||||
        	<td>
 | 
			
		||||
				{{ render_form(button_form, action_url=url_for('.passkey_delete', id=credential.id), action_text='delete', btn_class='btn btn-danger') }}</td>
 | 
			
		||||
			</tr>
 | 
			
		||||
		{% endfor %}
 | 
			
		||||
		</tbody>
 | 
			
		||||
	</table>
 | 
			
		||||
	<a class="btn btn-primary" href="{{ url_for('.passkey_new')}}">Add new Passkey</a>
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,54 +0,0 @@
 | 
			
		|||
{#- This file is part of sner4 project governed by MIT license, see the LICENSE.txt file. -#}
 | 
			
		||||
{% extends 'frontend/base.html.j2' %}
 | 
			
		||||
 | 
			
		||||
{% block script %}
 | 
			
		||||
<script>
 | 
			
		||||
	let options_req = {{ options }};
 | 
			
		||||
	let token = "{{ token }}";
 | 
			
		||||
 | 
			
		||||
	let form = document.getElementById('webauthn_register_form');
 | 
			
		||||
 | 
			
		||||
	async function register() {
 | 
			
		||||
		let credential = await auth_passkey.sign_up(options_req);
 | 
			
		||||
		let name = form.querySelector('#name').value;
 | 
			
		||||
 | 
			
		||||
		let response = await fetch("{{ url_for('.passkey_new_process') }}", {
 | 
			
		||||
			method: "POST",
 | 
			
		||||
			headers: {
 | 
			
		||||
				"Content-Type": "application/json",
 | 
			
		||||
			},
 | 
			
		||||
			body: JSON.stringify({
 | 
			
		||||
				token,
 | 
			
		||||
				credential,
 | 
			
		||||
				name
 | 
			
		||||
			}),
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	form.onsubmit = ev => {
 | 
			
		||||
		ev.preventDefault()
 | 
			
		||||
		register().then( result => {
 | 
			
		||||
			document.location = "{{ url_for('.passkey') }}";
 | 
			
		||||
		})
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
</script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
<div class="profile">
 | 
			
		||||
	<h1>Register new Passkey credential</h1>
 | 
			
		||||
 | 
			
		||||
	<div>
 | 
			
		||||
		To register new credential:
 | 
			
		||||
		<ol>
 | 
			
		||||
			<li>Insert/connect authenticator and verify user presence.</li>
 | 
			
		||||
			<li>Set name for the new credential.</li>
 | 
			
		||||
			<li>Submit the registration.</li>
 | 
			
		||||
		</ol>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
  {{ render_form(form, id="webauthn_register_form") }}
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										35
									
								
								lenticular_cloud/template/frontend/totp.html.j2
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								lenticular_cloud/template/frontend/totp.html.j2
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,35 @@
 | 
			
		|||
{% extends 'frontend/base.html.j2' %}
 | 
			
		||||
 | 
			
		||||
{% block title %}{{ gettext('2FA - TOTP') }}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<table class="table">
 | 
			
		||||
	<thead>
 | 
			
		||||
		<tr>
 | 
			
		||||
			<th>name</th>
 | 
			
		||||
			<th>created_at</th>
 | 
			
		||||
			<th>action<th>
 | 
			
		||||
		</tr>
 | 
			
		||||
	</thead>
 | 
			
		||||
	<tbody>
 | 
			
		||||
		{% for totp in current_user.totps %}
 | 
			
		||||
		<tr>
 | 
			
		||||
			<td>{{ totp.name }}</td>
 | 
			
		||||
			<td>{{ totp.created_at }}</td>
 | 
			
		||||
			<td>{{ render_form(delete_form, action_url=url_for('frontend.totp_delete', totp_name=totp.name)) }}</td>
 | 
			
		||||
		{% endfor %}
 | 
			
		||||
</table>
 | 
			
		||||
<a class="btn btn-default" href="{{ url_for('frontend.totp_new') }}">
 | 
			
		||||
	New TOTP
 | 
			
		||||
</a>
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
{% block script_js %}
 | 
			
		||||
 | 
			
		||||
totp.init_list();
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										20
									
								
								lenticular_cloud/template/frontend/totp_new.html.j2
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								lenticular_cloud/template/frontend/totp_new.html.j2
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,20 @@
 | 
			
		|||
{% extends 'frontend/base.html.j2' %}
 | 
			
		||||
 | 
			
		||||
{% block title %}{{ gettext('2FA - TOTP - New') }}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
{{ render_form(form) }}
 | 
			
		||||
 | 
			
		||||
<div id="svg-container">
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
{% block script_js %}
 | 
			
		||||
 | 
			
		||||
totp.init_new();
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										30
									
								
								lenticular_cloud/template/frontend/webauthn_list.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								lenticular_cloud/template/frontend/webauthn_list.html
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,30 @@
 | 
			
		|||
{% extends 'frontend/base.html.j2' %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
<div class="users">
 | 
			
		||||
	<h1>WebauthnCredentials list</h1>
 | 
			
		||||
 | 
			
		||||
	<table class="table">
 | 
			
		||||
		<thead>
 | 
			
		||||
			<tr>
 | 
			
		||||
				<th>user.username</th>
 | 
			
		||||
				<th>user_handle</th>
 | 
			
		||||
				<th>credential_data</th>
 | 
			
		||||
				<th>name</th>
 | 
			
		||||
				<th>_actions</th>
 | 
			
		||||
			</tr>
 | 
			
		||||
		</thead>
 | 
			
		||||
		<tbody>
 | 
			
		||||
		{% for cred in creds %}
 | 
			
		||||
			<tr>
 | 
			
		||||
				<td>{{ cred.user.username }}</td>
 | 
			
		||||
				<td>{{ cred.user_handle }}</td>
 | 
			
		||||
				<td>{{ cred.credential_data[0:40] }}...</td>
 | 
			
		||||
				<td>{{ cred.name }}</td>
 | 
			
		||||
        <td>{{ render_form(button_form, action_url=url_for('app.webauthn_delete_route', webauthn_id=cred.id)) }}</td>
 | 
			
		||||
			</tr>
 | 
			
		||||
		{% endfor %}
 | 
			
		||||
		</tbody>
 | 
			
		||||
	</table>
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
							
								
								
									
										140
									
								
								lenticular_cloud/template/frontend/webauthn_register.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										140
									
								
								lenticular_cloud/template/frontend/webauthn_register.html
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,140 @@
 | 
			
		|||
{#- This file is part of sner4 project governed by MIT license, see the LICENSE.txt file. -#}
 | 
			
		||||
{% extends 'frontend/base.html.j2' %}
 | 
			
		||||
 | 
			
		||||
{% block script %}
 | 
			
		||||
<script>
 | 
			
		||||
  /**
 | 
			
		||||
		 * decode base64 data to ArrayBuffer
 | 
			
		||||
		 *
 | 
			
		||||
		 * @param  {string}      data data to decode
 | 
			
		||||
		 * @return {ArrayBuffer}      decoded data
 | 
			
		||||
		 */
 | 
			
		||||
		function base64_to_array_buffer(data) {
 | 
			
		||||
			return Uint8Array.from(atob(data), c => c.charCodeAt(0)).buffer;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * request publicKeyCredentialCreationOptions for webauthn from server
 | 
			
		||||
	 *
 | 
			
		||||
	 * @return {Promise<Object>} A promise that resolves with publicKeyCredentialCreationOptions for navigator.credentials.create()
 | 
			
		||||
	 */
 | 
			
		||||
	function get_pkcco() {
 | 
			
		||||
		return fetch("{{ url_for('frontend.webauthn_pkcco_route')}}", {method:'post',  headers: {'Content-Type': 'application/json'}})
 | 
			
		||||
      .then(function(resp) {
 | 
			
		||||
        return resp.text();
 | 
			
		||||
      })
 | 
			
		||||
      .then(function(data){
 | 
			
		||||
        var pkcco = CBOR.decode(base64_to_array_buffer(data));
 | 
			
		||||
        console.debug('credentials.create options:', pkcco);
 | 
			
		||||
        
 | 
			
		||||
var publicKey = {
 | 
			
		||||
  // The challenge is produced by the server; see the Security Considerations
 | 
			
		||||
  challenge: new Uint8Array([21,31,105 /* 29 more random bytes generated by the server */]),
 | 
			
		||||
 | 
			
		||||
  // Relying Party:
 | 
			
		||||
  rp: {
 | 
			
		||||
    name: "Lenticular Cloud - domain TODO"
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // User:
 | 
			
		||||
  user: {
 | 
			
		||||
    id: Uint8Array.from(window.atob("MIIBkzCCATigAwIBAjCCAZMwggE4oAMCAQIwggGTMII="), c=>c.charCodeAt(0)),
 | 
			
		||||
    name: "{user.domain}",
 | 
			
		||||
    displayName: "{user.name}",
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // This Relying Party will accept either an ES256 or RS256 credential, but
 | 
			
		||||
  // prefers an ES256 credential.
 | 
			
		||||
  pubKeyCredParams: [
 | 
			
		||||
    {
 | 
			
		||||
      type: "public-key",
 | 
			
		||||
      alg: -7 // "ES256" as registered in the IANA COSE Algorithms registry
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      type: "public-key",
 | 
			
		||||
      alg: -257 // Value registered by this specification for "RS256"
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
 | 
			
		||||
  authenticatorSelection: {
 | 
			
		||||
    // Try to use UV if possible. This is also the default.
 | 
			
		||||
    userVerification: "preferred"
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  timeout: 360000,  // 6 minutes
 | 
			
		||||
  excludeCredentials: [
 | 
			
		||||
    // Don’t re-register any authenticator that has one of these credentials
 | 
			
		||||
    //{"id": Uint8Array.from(window.atob("E/e1dhZc++mIsz4f9hb6NifAzJpF1V4mEtRlIPBiWdY="), c=>c.charCodeAt(0)), "type": "public-key"}
 | 
			
		||||
  ],
 | 
			
		||||
 | 
			
		||||
  // Make excludeCredentials check backwards compatible with credentials registered with U2F
 | 
			
		||||
  extensions: {"appidExclude": "https://acme.example.com"}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
        
 | 
			
		||||
return { "publicKey": publicKey };
 | 
			
		||||
      })
 | 
			
		||||
      .catch(function(error) { console.log('cant get pkcco ',error)});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * pack attestation
 | 
			
		||||
	 *
 | 
			
		||||
	 * @param {object} attestation attestation response for the credential to register
 | 
			
		||||
	 */
 | 
			
		||||
	function pack_attestation(attestation) {
 | 
			
		||||
		console.debug('new credential attestation:', attestation);
 | 
			
		||||
 | 
			
		||||
		var attestation_data = {
 | 
			
		||||
			'clientDataJSON': new Uint8Array(attestation.response.clientDataJSON),
 | 
			
		||||
			'attestationObject': new Uint8Array(attestation.response.attestationObject)
 | 
			
		||||
		};
 | 
			
		||||
    //var form = $('#webauthn_register_form')[0];
 | 
			
		||||
    var form = document.querySelector('form')
 | 
			
		||||
    var base64 = btoa(new Uint8Array(CBOR.encode(attestation_data)).reduce((data, byte) => data + String.fromCharCode(byte), ''));
 | 
			
		||||
		form.attestation.value = base64;
 | 
			
		||||
		form.submit.disabled = false;
 | 
			
		||||
    //form.querySelecotr('p[name="attestation_data_status"]').innerHTML = '<span style="color: green;">Prepared</span>';
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
  console.log(window.PublicKeyCredential ? 'WebAuthn supported' : 'WebAuthn NOT supported');
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  get_pkcco()
 | 
			
		||||
  .then(pkcco => navigator.credentials.create(pkcco))
 | 
			
		||||
  .then(attestation_response => pack_attestation(attestation_response))
 | 
			
		||||
  .catch(function(error) {
 | 
			
		||||
    //toastr.error('Registration data preparation failed.');
 | 
			
		||||
    console.log(error.message);
 | 
			
		||||
  });
 | 
			
		||||
</script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
<div class="profile">
 | 
			
		||||
	<h1>Register new Webauthn credential</h1>
 | 
			
		||||
 | 
			
		||||
	<div>
 | 
			
		||||
		To register new credential:
 | 
			
		||||
		<ol>
 | 
			
		||||
			<li>Insert/connect authenticator and verify user presence.</li>
 | 
			
		||||
			<li>Optionaly set comment for the new credential.</li>
 | 
			
		||||
			<li>Submit the registration.</li>
 | 
			
		||||
		</ol>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
  {{ render_form(form) }}
 | 
			
		||||
  {#
 | 
			
		||||
	<form id="webauthn_register_form" class="form-horizontal" method="post">
 | 
			
		||||
		{{ form.csrf_token }}
 | 
			
		||||
		<div class="form-group">
 | 
			
		||||
			<label class="col-sm-2 control-label">Registration data</label>
 | 
			
		||||
			<div class="col-sm-10"><p class="form-control-static" name="attestation_data_status"><span style="color: orange;">To be prepared</span></p></div>
 | 
			
		||||
		</div>
 | 
			
		||||
		{{ b_wtf.bootstrap_field(form.attestation, horizontal=True) }}
 | 
			
		||||
		{{ b_wtf.bootstrap_field(form.name, horizontal=True) }}
 | 
			
		||||
		{{ b_wtf.bootstrap_field(form.submit, horizontal=True) }}
 | 
			
		||||
  </form>
 | 
			
		||||
  #}
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,35 +1,39 @@
 | 
			
		|||
 | 
			
		||||
from base64 import b64encode, b64decode, urlsafe_b64decode
 | 
			
		||||
import crypt
 | 
			
		||||
from datetime import datetime, timedelta
 | 
			
		||||
import jwt
 | 
			
		||||
from flask import request, url_for, jsonify, Blueprint, redirect, current_app, session
 | 
			
		||||
from urllib.parse import urlencode, parse_qs
 | 
			
		||||
 | 
			
		||||
import flask
 | 
			
		||||
from flask import Blueprint, redirect, flash, current_app, session
 | 
			
		||||
from flask.templating import render_template
 | 
			
		||||
from flask_babel import gettext
 | 
			
		||||
from flask.typing import ResponseReturnValue
 | 
			
		||||
import logging
 | 
			
		||||
from ory_hydra_client import models as ory_hydra_m
 | 
			
		||||
from ory_hydra_client.api.o_auth_2 import get_o_auth_2_consent_request, accept_o_auth_2_consent_request, accept_o_auth_2_login_request, get_o_auth_2_login_request, accept_o_auth_2_login_request, accept_o_auth_2_logout_request, get_o_auth_2_login_request
 | 
			
		||||
from ory_hydra_client.models import TheRequestPayloadUsedToAcceptAConsentRequest, GenericError
 | 
			
		||||
from typing import Optional
 | 
			
		||||
from urllib.parse import urlparse
 | 
			
		||||
from uuid import uuid4, UUID
 | 
			
		||||
import webauthn
 | 
			
		||||
from webauthn.helpers.structs import (
 | 
			
		||||
    AuthenticatorSelectionCriteria,
 | 
			
		||||
    PublicKeyCredentialDescriptor,
 | 
			
		||||
    ResidentKeyRequirement,
 | 
			
		||||
    UserVerificationRequirement,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
from ..model import db, User, PasskeyCredential
 | 
			
		||||
from flask import request, url_for, jsonify
 | 
			
		||||
from flask_login import login_required, login_user, logout_user, current_user
 | 
			
		||||
import logging
 | 
			
		||||
from urllib.parse import urlparse
 | 
			
		||||
from base64 import b64decode, b64encode
 | 
			
		||||
import http
 | 
			
		||||
import crypt
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
import logging
 | 
			
		||||
import json
 | 
			
		||||
from ory_hydra_client.api.o_auth_2 import get_o_auth_2_consent_request, accept_o_auth_2_consent_request, accept_o_auth_2_login_request, get_o_auth_2_login_request, accept_o_auth_2_login_request, accept_o_auth_2_logout_request, get_o_auth_2_login_request
 | 
			
		||||
from ory_hydra_client import models as ory_hydra_m
 | 
			
		||||
from ory_hydra_client.models import TheRequestPayloadUsedToAcceptALoginOrConsentRequest, TheRequestPayloadUsedToAcceptAConsentRequest, GenericError
 | 
			
		||||
from typing import Optional
 | 
			
		||||
from uuid import uuid4
 | 
			
		||||
 | 
			
		||||
from ..model import db, User, SecurityUser
 | 
			
		||||
from ..form.auth import ConsentForm, LoginForm, RegistrationForm
 | 
			
		||||
from ..auth_providers import AUTH_PROVIDER_LIST
 | 
			
		||||
from ..hydra import hydra_service
 | 
			
		||||
from ..wrapped_fido2_server import WrappedFido2Server
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
auth_views = Blueprint('auth', __name__, url_prefix='/auth')
 | 
			
		||||
webauthn = WrappedFido2Server()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@auth_views.route('/consent', methods=['GET', 'POST'])
 | 
			
		||||
| 
						 | 
				
			
			@ -50,14 +54,8 @@ async def consent() -> ResponseReturnValue:
 | 
			
		|||
    requested_audiences = consent_request.requested_access_token_audience
 | 
			
		||||
 | 
			
		||||
    if form.validate_on_submit() or consent_request.skip:
 | 
			
		||||
 | 
			
		||||
        if type(consent_request.subject) != str:
 | 
			
		||||
            logger.error("not set subject `consent_request.subject`")
 | 
			
		||||
            return 'internal error', 500
 | 
			
		||||
        uid = UUID(consent_request.subject)
 | 
			
		||||
        user = User.query.get(uid)
 | 
			
		||||
        user = User.query.get(consent_request.subject) # type: Optional[User]
 | 
			
		||||
        if user is None:
 | 
			
		||||
            logger.error("user not found, even if it should exist")
 | 
			
		||||
            return 'internal error', 500
 | 
			
		||||
        access_token = {
 | 
			
		||||
            'name': str(user.username),
 | 
			
		||||
| 
						 | 
				
			
			@ -98,9 +96,6 @@ async def consent() -> ResponseReturnValue:
 | 
			
		|||
 | 
			
		||||
@auth_views.route('/login', methods=['GET', 'POST'])
 | 
			
		||||
async def login() -> ResponseReturnValue:
 | 
			
		||||
    secret_key = current_app.config['SECRET_KEY']
 | 
			
		||||
    public_url = urlparse(current_app.config['PUBLIC_URL'])
 | 
			
		||||
    
 | 
			
		||||
    login_challenge = request.args.get('login_challenge')
 | 
			
		||||
    if login_challenge is None:
 | 
			
		||||
        return 'login_challenge missing', 400
 | 
			
		||||
| 
						 | 
				
			
			@ -109,21 +104,6 @@ async def login() -> ResponseReturnValue:
 | 
			
		|||
        logger.exception("could not fetch login request")
 | 
			
		||||
        return redirect(url_for('frontend.index'))
 | 
			
		||||
 | 
			
		||||
    ## passkey
 | 
			
		||||
    options = webauthn.generate_authentication_options(
 | 
			
		||||
        rp_id = public_url.hostname,
 | 
			
		||||
        user_verification = UserVerificationRequirement.REQUIRED,
 | 
			
		||||
        challenge=webauthn.helpers.generate_challenge(32)
 | 
			
		||||
    )
 | 
			
		||||
    token = jwt.encode({
 | 
			
		||||
            'challenge': b64encode(options.challenge).decode(),
 | 
			
		||||
            'iat': datetime.utcnow() - timedelta(minutes=1),
 | 
			
		||||
            'nbf': datetime.utcnow(),
 | 
			
		||||
            'exp': datetime.utcnow() + timedelta(minutes=15),
 | 
			
		||||
        }, secret_key, algorithm="HS256"
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    ## 
 | 
			
		||||
    if login_request.skip:
 | 
			
		||||
        resp = await accept_o_auth_2_login_request.asyncio(_client=hydra_service.hydra_client,
 | 
			
		||||
            login_challenge=login_challenge,
 | 
			
		||||
| 
						 | 
				
			
			@ -142,13 +122,7 @@ async def login() -> ResponseReturnValue:
 | 
			
		|||
        session['auth_providers'] = []
 | 
			
		||||
        return redirect(
 | 
			
		||||
                url_for('auth.login_auth', login_challenge=login_challenge))
 | 
			
		||||
    return render_template(
 | 
			
		||||
        'auth/login.html.j2',
 | 
			
		||||
        form=form,
 | 
			
		||||
        options=webauthn.options_to_json(options),
 | 
			
		||||
        token=token,
 | 
			
		||||
        login_challenge=login_challenge,
 | 
			
		||||
    )
 | 
			
		||||
    return render_template('auth/login.html.j2', form=form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@auth_views.route('/login/auth', methods=['GET', 'POST'])
 | 
			
		||||
| 
						 | 
				
			
			@ -195,54 +169,21 @@ async def login_auth() -> ResponseReturnValue:
 | 
			
		|||
    return render_template('auth/login_auth.html.j2', forms=auth_forms)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@auth_views.route('/passkey/verify', methods=['POST'])
 | 
			
		||||
async def passkey_verify() -> ResponseReturnValue:
 | 
			
		||||
    secret_key = current_app.config['SECRET_KEY']
 | 
			
		||||
    public_url = current_app.config['PUBLIC_URL']
 | 
			
		||||
 | 
			
		||||
@auth_views.route('/webauthn/pkcro', methods=['POST'])
 | 
			
		||||
def webauthn_pkcro_route() -> ResponseReturnValue:
 | 
			
		||||
    """login webauthn pkcro route"""
 | 
			
		||||
    return '', 404
 | 
			
		||||
 | 
			
		||||
    data = request.get_json()
 | 
			
		||||
    token = jwt.decode(data['token'], secret_key, algorithms=['HS256'])
 | 
			
		||||
    challenge = urlsafe_b64decode(token['challenge'])
 | 
			
		||||
    credential = data['credential']
 | 
			
		||||
    credential_id = urlsafe_b64decode(credential['id'])
 | 
			
		||||
    user = User.query.filter(User.id == session.get('webauthn_login_user_id')).one() #type: User
 | 
			
		||||
    form = ButtonForm()
 | 
			
		||||
    if user and form.validate_on_submit():
 | 
			
		||||
        pkcro, state = webauthn.authenticate_begin(webauthn_credentials(user))
 | 
			
		||||
        session['webauthn_login_state'] = state
 | 
			
		||||
        return Response(b64encode(cbor.encode(pkcro)).decode('utf-8'), mimetype='text/plain')
 | 
			
		||||
 | 
			
		||||
    login_challenge = data['login_challenge']
 | 
			
		||||
    if login_challenge is None:
 | 
			
		||||
        return 'missing login_challenge, bad request', 400
 | 
			
		||||
    login_request = await get_o_auth_2_login_request.asyncio(_client=hydra_service.hydra_client, login_challenge=login_challenge)
 | 
			
		||||
    if login_request is None:
 | 
			
		||||
        return redirect(url_for('frontend.index'))
 | 
			
		||||
    return '', HTTPStatus.BAD_REQUEST
 | 
			
		||||
 | 
			
		||||
    passkey = PasskeyCredential.query.filter(PasskeyCredential.credential_id == credential_id).first_or_404()
 | 
			
		||||
    
 | 
			
		||||
    result = webauthn.verify_authentication_response(
 | 
			
		||||
        credential = credential,
 | 
			
		||||
        expected_rp_id = "localhost",
 | 
			
		||||
        expected_challenge = challenge,
 | 
			
		||||
        expected_origin = [ public_url ],
 | 
			
		||||
        credential_public_key = passkey.credential_public_key,
 | 
			
		||||
        credential_current_sign_count = passkey.sign_count,
 | 
			
		||||
    )
 | 
			
		||||
    logger.error(f"DEBUG: {passkey}")
 | 
			
		||||
    logger.error(f"DEBUG: {result}")
 | 
			
		||||
 | 
			
		||||
    passkey.sign_count = result.new_sign_count
 | 
			
		||||
    passkey.last_used = datetime.utcnow()
 | 
			
		||||
    user = passkey.user
 | 
			
		||||
    user.last_login = datetime.now()
 | 
			
		||||
 | 
			
		||||
    subject = str(user.id)
 | 
			
		||||
    resp = await accept_o_auth_2_login_request.asyncio(_client=hydra_service.hydra_client,
 | 
			
		||||
        login_challenge=login_challenge, json_body=ory_hydra_m.HandledLoginRequestIsTheRequestPayloadUsedToAcceptALoginRequest(
 | 
			
		||||
            subject=subject,
 | 
			
		||||
            remember=True,
 | 
			
		||||
        ))
 | 
			
		||||
    if resp is None or isinstance( resp, GenericError):
 | 
			
		||||
        return 'internal error, could not forward request', 503
 | 
			
		||||
 | 
			
		||||
    db.session.commit()
 | 
			
		||||
    return jsonify({'redirect': resp.redirect_to})
 | 
			
		||||
 | 
			
		||||
@auth_views.route("/logout")
 | 
			
		||||
async def logout() -> ResponseReturnValue:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,35 +1,35 @@
 | 
			
		|||
 | 
			
		||||
from authlib.integrations.base_client.errors import MissingTokenError, InvalidTokenError
 | 
			
		||||
from base64 import b64encode, b64decode
 | 
			
		||||
from flask import Blueprint, redirect, request
 | 
			
		||||
from fido2 import cbor
 | 
			
		||||
from fido2.webauthn import CollectedClientData, AttestationObject, AttestedCredentialData, AuthenticatorData, PublicKeyCredentialUserEntity
 | 
			
		||||
from flask import Blueprint, Response, redirect, request
 | 
			
		||||
from flask import current_app
 | 
			
		||||
from flask import jsonify, session
 | 
			
		||||
from flask import jsonify, session, flash
 | 
			
		||||
from flask import render_template, url_for
 | 
			
		||||
from flask_login import logout_user, current_user
 | 
			
		||||
from flask_login import login_user, logout_user, current_user
 | 
			
		||||
from http import HTTPStatus
 | 
			
		||||
from werkzeug.utils import redirect
 | 
			
		||||
import logging
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
from base64 import b64decode
 | 
			
		||||
from flask.typing import ResponseReturnValue 
 | 
			
		||||
from oauthlib.oauth2.rfc6749.errors import TokenExpiredError
 | 
			
		||||
from ory_hydra_client.api.o_auth_2 import list_o_auth_2_consent_sessions, revoke_o_auth_2_consent_sessions
 | 
			
		||||
from ory_hydra_client.models import GenericError
 | 
			
		||||
from urllib.parse import urlparse
 | 
			
		||||
from typing import Optional, Any
 | 
			
		||||
import jwt
 | 
			
		||||
from datetime import datetime, timedelta
 | 
			
		||||
import webauthn
 | 
			
		||||
from webauthn.helpers.structs import (
 | 
			
		||||
    AuthenticatorSelectionCriteria,
 | 
			
		||||
    PublicKeyCredentialDescriptor,
 | 
			
		||||
    ResidentKeyRequirement,
 | 
			
		||||
    UserVerificationRequirement,
 | 
			
		||||
)
 | 
			
		||||
from urllib.parse import urlencode, parse_qs
 | 
			
		||||
from random import SystemRandom
 | 
			
		||||
import string
 | 
			
		||||
from collections.abc import Iterable
 | 
			
		||||
from typing import Optional, Mapping, Iterator, List, Any
 | 
			
		||||
 | 
			
		||||
from ..model import db, User, AppToken, PasskeyCredential
 | 
			
		||||
from ..form.frontend import ClientCertForm, PasswordChangeForm, \
 | 
			
		||||
    AppTokenForm, AppTokenDeleteForm, PasskeyRegisterForm
 | 
			
		||||
from ..model import db, User, SecurityUser, Totp, AppToken, WebauthnCredential
 | 
			
		||||
from ..form.frontend import ClientCertForm, TOTPForm, \
 | 
			
		||||
    TOTPDeleteForm, PasswordChangeForm, WebauthnRegisterForm, \
 | 
			
		||||
    AppTokenForm, AppTokenDeleteForm
 | 
			
		||||
from ..form.base import  ButtonForm
 | 
			
		||||
from ..auth_providers import PasswordAuthProvider
 | 
			
		||||
from .auth import webauthn
 | 
			
		||||
from .oauth2 import redirect_login, oauth2
 | 
			
		||||
from ..hydra import hydra_service
 | 
			
		||||
from ..pki import pki
 | 
			
		||||
| 
						 | 
				
			
			@ -187,109 +187,132 @@ def app_token_delete(app_token_name: str) -> ResponseReturnValue:
 | 
			
		|||
 | 
			
		||||
    return redirect(url_for('frontend.app_token'))
 | 
			
		||||
 | 
			
		||||
## Passkey
 | 
			
		||||
@frontend_views.route('/totp')
 | 
			
		||||
def totp() -> ResponseReturnValue:
 | 
			
		||||
    delete_form = TOTPDeleteForm()
 | 
			
		||||
    return render_template('frontend/totp.html.j2', delete_form=delete_form)
 | 
			
		||||
 | 
			
		||||
@frontend_views.route('/passkey/list', methods=['GET'])
 | 
			
		||||
def passkey() -> ResponseReturnValue:
 | 
			
		||||
 | 
			
		||||
@frontend_views.route('/totp/new', methods=['GET', 'POST'])
 | 
			
		||||
def totp_new() -> ResponseReturnValue:
 | 
			
		||||
    form = TOTPForm()
 | 
			
		||||
 | 
			
		||||
    if form.validate_on_submit():
 | 
			
		||||
        totp = Totp(name=form.data['name'], secret=form.data['secret'], user=get_current_user())
 | 
			
		||||
        if totp.verify(form.data['token']):
 | 
			
		||||
            get_current_user().totps.append(totp)
 | 
			
		||||
            db.session.commit()
 | 
			
		||||
            return jsonify({
 | 
			
		||||
                    'status': 'ok'})
 | 
			
		||||
        else:
 | 
			
		||||
            return jsonify({
 | 
			
		||||
                'status': 'error',
 | 
			
		||||
                'errors': [
 | 
			
		||||
                    'TOTP Token invalid'
 | 
			
		||||
                    ]})
 | 
			
		||||
    return render_template('frontend/totp_new.html.j2', form=form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@frontend_views.route('/totp/<totp_name>/delete', methods=['GET', 'POST'])
 | 
			
		||||
def totp_delete(totp_name) -> ResponseReturnValue:
 | 
			
		||||
    totp = Totp.query.filter(Totp.name == totp_name).first() # type: Optional[Totp]
 | 
			
		||||
    db.session.delete(totp)
 | 
			
		||||
    db.session.commit()
 | 
			
		||||
 | 
			
		||||
    return jsonify({
 | 
			
		||||
            'status': 'ok'})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@frontend_views.route('/webauthn/list', methods=['GET'])
 | 
			
		||||
def webauthn_list_route() -> ResponseReturnValue:
 | 
			
		||||
    """list registered credentials for current user"""
 | 
			
		||||
 | 
			
		||||
    credentials = PasskeyCredential.query.all()
 | 
			
		||||
    return render_template('frontend/passkey_list.html.j2', credentials=credentials, button_form=ButtonForm())
 | 
			
		||||
    creds = WebauthnCredential.query.all() # type: Iterable[WebauthnCredential] 
 | 
			
		||||
    return render_template('frontend/webauthn_list.html', creds=creds, button_form=ButtonForm())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@frontend_views.route('/passkey/new', methods=['GET'])
 | 
			
		||||
def passkey_new() -> ResponseReturnValue:
 | 
			
		||||
    """register credential for current user"""
 | 
			
		||||
    public_url = urlparse(current_app.config['PUBLIC_URL'])
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    user = get_current_user() # type: User 
 | 
			
		||||
    form = PasskeyRegisterForm()
 | 
			
		||||
 | 
			
		||||
    options = webauthn.generate_registration_options(
 | 
			
		||||
        rp_name="Lenticluar Cloud",
 | 
			
		||||
        rp_id=public_url.hostname,
 | 
			
		||||
        user_id=str(user.id),
 | 
			
		||||
        user_name=user.username,
 | 
			
		||||
        authenticator_selection=AuthenticatorSelectionCriteria(
 | 
			
		||||
            user_verification=UserVerificationRequirement.REQUIRED,
 | 
			
		||||
            resident_key=ResidentKeyRequirement.REQUIRED,
 | 
			
		||||
        ),
 | 
			
		||||
        exclude_credentials = list(map(lambda x: PublicKeyCredentialDescriptor(id=x.credential_id), user.passkey_credentials))
 | 
			
		||||
    )
 | 
			
		||||
    secret_key = current_app.config['SECRET_KEY']
 | 
			
		||||
    token = jwt.encode({
 | 
			
		||||
            'challenge': b64encode(options.challenge).decode(),
 | 
			
		||||
            'iat': datetime.utcnow() - timedelta(minutes=1),
 | 
			
		||||
            'nbf': datetime.utcnow(),
 | 
			
		||||
            'exp': datetime.utcnow() + timedelta(minutes=15),
 | 
			
		||||
        }, secret_key, algorithm="HS256"
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    return render_template(
 | 
			
		||||
        'frontend/passkey_new.html.j2',
 | 
			
		||||
        form=form,
 | 
			
		||||
        options=webauthn.options_to_json(options),
 | 
			
		||||
        token=token,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
@frontend_views.route('/passkey/new', methods=['POST'])
 | 
			
		||||
def passkey_new_process() -> ResponseReturnValue:
 | 
			
		||||
    secret_key = current_app.config['SECRET_KEY']
 | 
			
		||||
    public_url = urlparse(current_app.config['PUBLIC_URL'])
 | 
			
		||||
    user = get_current_user()
 | 
			
		||||
 | 
			
		||||
    data = request.get_json()
 | 
			
		||||
    try:
 | 
			
		||||
        token = jwt.decode(
 | 
			
		||||
            data['token'], secret_key, algorithms=['HS256'], 
 | 
			
		||||
            options = {
 | 
			
		||||
                'require': ["challenge", "exp", "iat", "nbf"],
 | 
			
		||||
                
 | 
			
		||||
            })
 | 
			
		||||
    except jwt.exceptions.MissingRequiredClaimError:
 | 
			
		||||
        return jsonify({'message': "invalid token"}), 400
 | 
			
		||||
    challenge = b64decode(token['challenge'])
 | 
			
		||||
    credential = data['credential']
 | 
			
		||||
    name = data['name']
 | 
			
		||||
    
 | 
			
		||||
    result = webauthn.verify_registration_response(
 | 
			
		||||
        credential = credential,
 | 
			
		||||
        expected_rp_id = public_url.hostname,
 | 
			
		||||
        expected_challenge = challenge,
 | 
			
		||||
        expected_origin = [ public_url.geturl() ],
 | 
			
		||||
    )
 | 
			
		||||
    if not result.user_verified:
 | 
			
		||||
        return jsonify({ "message": "invalid auth" }), 403
 | 
			
		||||
 | 
			
		||||
    db.session.add(PasskeyCredential(
 | 
			
		||||
        id=None,
 | 
			
		||||
        user_id=user.id,
 | 
			
		||||
        credential_id=result.credential_id,
 | 
			
		||||
        credential_public_key=result.credential_public_key,
 | 
			
		||||
        name=name,
 | 
			
		||||
    ))
 | 
			
		||||
    db.session.commit()
 | 
			
		||||
    logger.info(f"add new passkey for user {user.username}")
 | 
			
		||||
 | 
			
		||||
    return jsonify({})
 | 
			
		||||
 | 
			
		||||
@frontend_views.route('/passkey/delete/<id>', methods=['POST'])
 | 
			
		||||
def passkey_delete(id: str) -> ResponseReturnValue:
 | 
			
		||||
@frontend_views.route('/webauthn/delete/<webauthn_id>', methods=['POST'])
 | 
			
		||||
def webauthn_delete_route(webauthn_id: str) -> ResponseReturnValue:
 | 
			
		||||
    """delete registered credential"""
 | 
			
		||||
 | 
			
		||||
    form = ButtonForm()
 | 
			
		||||
    if form.validate_on_submit():
 | 
			
		||||
        cred = PasskeyCredential.query.filter(PasskeyCredential.id == id).first_or_404()
 | 
			
		||||
        cred = WebauthnCredential.query.filter(WebauthnCredential.id == webauthn_id).one() # type: WebauthnCredential
 | 
			
		||||
        db.session.delete(cred)
 | 
			
		||||
        db.session.commit()
 | 
			
		||||
        return redirect(url_for('.passkey'))
 | 
			
		||||
        return redirect(url_for('app.webauthn_list_route'))
 | 
			
		||||
 | 
			
		||||
    return '', HTTPStatus.BAD_REQUEST
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def webauthn_credentials(user: User) -> list[AttestedCredentialData]:
 | 
			
		||||
    """get and decode all credentials for given user"""
 | 
			
		||||
 | 
			
		||||
    def decode(creds: List[WebauthnCredential]) -> Iterator[AttestedCredentialData]:
 | 
			
		||||
        for cred in creds:
 | 
			
		||||
            data = cbor.decode(cred.credential_data)
 | 
			
		||||
            if isinstance(data, Mapping):
 | 
			
		||||
                yield AttestedCredentialData.create(**data)
 | 
			
		||||
 | 
			
		||||
    return list(decode(user.webauthn_credentials))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def random_string(length=32) -> str:
 | 
			
		||||
    """generates random string"""
 | 
			
		||||
    return ''.join([SystemRandom().choice(string.ascii_letters + string.digits) for i in range(length)])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@frontend_views.route('/webauthn/pkcco', methods=['POST'])
 | 
			
		||||
def webauthn_pkcco_route() -> ResponseReturnValue:
 | 
			
		||||
    """get publicKeyCredentialCreationOptions"""
 | 
			
		||||
 | 
			
		||||
    user = User.query.get(get_current_user().id) #type: Optional[User]
 | 
			
		||||
    if user is None:
 | 
			
		||||
        return 'internal error', 500
 | 
			
		||||
    user_handle = random_string()
 | 
			
		||||
    exclude_credentials = webauthn_credentials(user)
 | 
			
		||||
    pkcco, state = webauthn.register_begin(
 | 
			
		||||
        user=PublicKeyCredentialUserEntity(id=user_handle.encode('utf-8'), name=user.username, display_name=user.username),
 | 
			
		||||
        credentials=exclude_credentials
 | 
			
		||||
    )
 | 
			
		||||
    session['webauthn_register_user_handle'] = user_handle
 | 
			
		||||
    session['webauthn_register_state'] = state
 | 
			
		||||
    return Response(b64encode(cbor.encode(pkcco)).decode('utf-8'), mimetype='text/plain')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@frontend_views.route('/webauthn/register', methods=['GET', 'POST'])
 | 
			
		||||
def webauthn_register_route() -> ResponseReturnValue:
 | 
			
		||||
    """register credential for current user"""
 | 
			
		||||
 | 
			
		||||
    user = get_current_user() # type: User 
 | 
			
		||||
    form = WebauthnRegisterForm()
 | 
			
		||||
    if form.validate_on_submit():
 | 
			
		||||
        try:
 | 
			
		||||
            attestation = cbor.decode(b64decode(form.attestation.data))
 | 
			
		||||
            if not isinstance(attestation, Mapping) or 'clientDataJSON' not in attestation or 'attestationObject' not in attestation:
 | 
			
		||||
                return 'invalid attestion data', 400
 | 
			
		||||
            auth_data = webauthn.register_complete(
 | 
			
		||||
                session.pop('webauthn_register_state'),
 | 
			
		||||
                CollectedClientData(attestation['clientDataJSON']),
 | 
			
		||||
                AttestationObject(attestation['attestationObject']))
 | 
			
		||||
 | 
			
		||||
            db.session.add(WebauthnCredential(
 | 
			
		||||
                user=user,
 | 
			
		||||
                user_handle=session.pop('webauthn_register_user_handle'),
 | 
			
		||||
                credential_data=cbor.encode(auth_data.credential_data.__dict__),
 | 
			
		||||
                name=form.name.data))
 | 
			
		||||
            db.session.commit()
 | 
			
		||||
 | 
			
		||||
            return redirect(url_for('app.webauthn_list_route'))
 | 
			
		||||
        except (KeyError, ValueError) as e:
 | 
			
		||||
            logger.exception(e)
 | 
			
		||||
            flash('Error during registration.', 'error')
 | 
			
		||||
 | 
			
		||||
    return render_template('frontend/webauthn_register.html', form=form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@frontend_views.route('/password_change')
 | 
			
		||||
def password_change() -> ResponseReturnValue:
 | 
			
		||||
    form = PasswordChangeForm()
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,13 +1,12 @@
 | 
			
		|||
from authlib.integrations.flask_client import OAuth
 | 
			
		||||
from authlib.integrations.base_client.errors import MismatchingStateError, OAuthError
 | 
			
		||||
from flask import Flask, Blueprint, current_app, session, request, redirect, url_for
 | 
			
		||||
from flask_login import login_user, logout_user
 | 
			
		||||
from authlib.integrations.base_client.errors import MismatchingStateError
 | 
			
		||||
from flask import Flask, Blueprint, Response, session, request, redirect, url_for
 | 
			
		||||
from flask_login import login_user, logout_user, current_user
 | 
			
		||||
from flask.typing import ResponseReturnValue
 | 
			
		||||
from flask_login import LoginManager
 | 
			
		||||
from typing import Optional
 | 
			
		||||
from werkzeug.wrappers.response import Response as WerkzeugResponse
 | 
			
		||||
import logging
 | 
			
		||||
from uuid import UUID
 | 
			
		||||
 | 
			
		||||
from ..model import User, SecurityUser
 | 
			
		||||
from ..hydra import hydra_service
 | 
			
		||||
| 
						 | 
				
			
			@ -29,8 +28,7 @@ login_manager = LoginManager()
 | 
			
		|||
def redirect_login() -> ResponseReturnValue:
 | 
			
		||||
    logout_user()
 | 
			
		||||
    session['next_url'] = request.path
 | 
			
		||||
    public_url = current_app.config['PUBLIC_URL']
 | 
			
		||||
    redirect_uri = public_url + url_for('oauth2.authorized')
 | 
			
		||||
    redirect_uri = url_for('oauth2.authorized', _external=True)
 | 
			
		||||
    response = oauth2.custom.authorize_redirect(redirect_uri)
 | 
			
		||||
    if not isinstance(response, WerkzeugResponse):
 | 
			
		||||
        raise RuntimeError("invalid redirect")
 | 
			
		||||
| 
						 | 
				
			
			@ -44,14 +42,11 @@ def authorized() -> ResponseReturnValue:
 | 
			
		|||
    except MismatchingStateError:
 | 
			
		||||
        logger.warning("MismatchingStateError redirect user")
 | 
			
		||||
        return redirect(url_for('oauth2.login'))
 | 
			
		||||
    except OAuthError as e:
 | 
			
		||||
        logger.warning(f"OAuthError redirect user {e}")
 | 
			
		||||
        return redirect(url_for('oauth2.login'))
 | 
			
		||||
    if token is None:
 | 
			
		||||
        return 'bad request', 400
 | 
			
		||||
    session['token'] = token
 | 
			
		||||
    userinfo = oauth2.custom.get('/userinfo').json()
 | 
			
		||||
    user = User.query.get(UUID(userinfo["sub"])) # type: Optional[User]
 | 
			
		||||
    user = User.query.get(str(userinfo["sub"])) # type: Optional[User]
 | 
			
		||||
    if user is None:
 | 
			
		||||
        return "user not found", 404
 | 
			
		||||
    logger.info(f"user `{user.username}` successfully logged in")
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										21
									
								
								lenticular_cloud/wrapped_fido2_server.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								lenticular_cloud/wrapped_fido2_server.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,21 @@
 | 
			
		|||
# This file is part of sner4 project governed by MIT license, see the LICENSE.txt file.
 | 
			
		||||
# source: https://github.com/bodik/flask-webauthn-example/blob/master/fwe/wrapped_fido2_server.py
 | 
			
		||||
"""
 | 
			
		||||
yubico fido2 server wrapped for flask factory pattern delayed configuration
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
from socket import getfqdn
 | 
			
		||||
 | 
			
		||||
from fido2.server import Fido2Server, PublicKeyCredentialRpEntity
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class WrappedFido2Server(Fido2Server):
 | 
			
		||||
    """yubico fido2 server wrapped for flask factory pattern delayed configuration"""
 | 
			
		||||
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        """initialize with default rp name"""
 | 
			
		||||
        super().__init__(PublicKeyCredentialRpEntity(getfqdn(), 'name'))
 | 
			
		||||
 | 
			
		||||
    def init_app(self, app) -> None:
 | 
			
		||||
        """reinitialize on factory pattern config request"""
 | 
			
		||||
        super().__init__(PublicKeyCredentialRpEntity(app.config['SERVER_NAME'] or getfqdn(), 'name'))
 | 
			
		||||
							
								
								
									
										125
									
								
								module.nix
									
										
									
									
									
								
							
							
						
						
									
										125
									
								
								module.nix
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -1,11 +1,9 @@
 | 
			
		|||
{ config, pkgs, lib, ... }:
 | 
			
		||||
let
 | 
			
		||||
  cfg = config.services.lenticular-cloud;
 | 
			
		||||
  username = "lenticular_cloud";
 | 
			
		||||
  data_folder = "/var/lib/${username}";
 | 
			
		||||
  python = pkgs.python3;
 | 
			
		||||
  format = pkgs.formats.json {};
 | 
			
		||||
  types = lib.types;
 | 
			
		||||
  config_oauth_secret = "${cfg.settings.DATA_FOLDER}/lenticular_oauth_secret.toml";
 | 
			
		||||
  python_env = python.withPackages (ps: with ps; [ lenticular-cloud gevent setuptools ]);
 | 
			
		||||
in
 | 
			
		||||
{
 | 
			
		||||
  options = with lib.options; {
 | 
			
		||||
| 
						 | 
				
			
			@ -15,62 +13,22 @@ in
 | 
			
		|||
        type = lib.types.str;
 | 
			
		||||
        example = "example.com";
 | 
			
		||||
      };
 | 
			
		||||
      username = mkOption {
 | 
			
		||||
        type = lib.types.str;
 | 
			
		||||
        description = mdDoc "user to run the service";
 | 
			
		||||
        default = "lenticular_cloud";
 | 
			
		||||
      };
 | 
			
		||||
      service_domain = mkOption {
 | 
			
		||||
        type = lib.types.str;
 | 
			
		||||
        example = "account.example.com";
 | 
			
		||||
      };
 | 
			
		||||
      settings = mkOption {
 | 
			
		||||
        description = mdDoc ''
 | 
			
		||||
          Lenticular cloud settings
 | 
			
		||||
        '';
 | 
			
		||||
 | 
			
		||||
        default = { };
 | 
			
		||||
 | 
			
		||||
        type  = types.submodule {
 | 
			
		||||
          freeformType = format.type;
 | 
			
		||||
          options = {
 | 
			
		||||
            DOMAIN = mkOption {
 | 
			
		||||
              type = types.str;
 | 
			
		||||
              description = mdDoc "Top level Domain of the service";
 | 
			
		||||
              default = cfg.domain;
 | 
			
		||||
            };
 | 
			
		||||
            PUBLIC_URL = mkOption {
 | 
			
		||||
              type = types.str;
 | 
			
		||||
              description = mdDoc "public service url";
 | 
			
		||||
              default = "https://${cfg.service_domain}";
 | 
			
		||||
            };
 | 
			
		||||
            ADMINS = mkOption {
 | 
			
		||||
              type = types.listOf types.str;
 | 
			
		||||
              description = mdDoc "list of admin users";
 | 
			
		||||
              example = [ "tuxcoder" ];
 | 
			
		||||
            };
 | 
			
		||||
            DATA_FOLDER = mkOption {
 | 
			
		||||
              type = types.str;
 | 
			
		||||
              default = "/var/lib/${cfg.username}";
 | 
			
		||||
            };
 | 
			
		||||
            PKI_PATH = mkOption {
 | 
			
		||||
              type = types.str;
 | 
			
		||||
              default = "${cfg.settings.DATA_FOLDER}/pki";
 | 
			
		||||
            };
 | 
			
		||||
            SQLALCHEMY_DATABASE_URI = mkOption {
 | 
			
		||||
              type = types.str;
 | 
			
		||||
              default = "postgresql://${cfg.username}@/${cfg.username}?host=/run/postgresql";
 | 
			
		||||
            }; 
 | 
			
		||||
            HYDRA_ADMIN_URL = mkOption {
 | 
			
		||||
              type = types.str;
 | 
			
		||||
              default = "https://${config.services.ory-hydra.admin_domain}";
 | 
			
		||||
            }; 
 | 
			
		||||
            HYDRA_PUBLIC_URL = mkOption {
 | 
			
		||||
              type = types.str;
 | 
			
		||||
              default = "https://${config.services.ory-hydra.public_domain}";
 | 
			
		||||
            }; 
 | 
			
		||||
          };
 | 
			
		||||
        type = lib.types.attrs;
 | 
			
		||||
        default = rec {
 | 
			
		||||
          DOMAIN = cfg.domain;
 | 
			
		||||
          DATA_FOLDER = data_folder;
 | 
			
		||||
          PKI_PATH = "${DATA_FOLDER}/pki";
 | 
			
		||||
          # SQLALCHEMY_DATABASE_URI = "sqlite:////${DATA_FOLDER}/db.sqlite";
 | 
			
		||||
          SQLALCHEMY_DATABASE_URI = "postgresql://${username}@/${username}?host=/run/postgresql";
 | 
			
		||||
          HYDRA_ADMIN_URL=  "https://${config.services.ory-hydra.admin_domain}";
 | 
			
		||||
          HYDRA_PUBLIC_URL=  "https://${config.services.ory-hydra.public_domain}";
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
      };
 | 
			
		||||
    };
 | 
			
		||||
  };
 | 
			
		||||
| 
						 | 
				
			
			@ -82,38 +40,35 @@ in
 | 
			
		|||
    ];
 | 
			
		||||
    
 | 
			
		||||
    users = {
 | 
			
		||||
      groups."${cfg.username}" = {
 | 
			
		||||
      groups."${username}" = {
 | 
			
		||||
      };
 | 
			
		||||
      users."${cfg.username}" = {
 | 
			
		||||
      users."${username}" = {
 | 
			
		||||
        createHome = true;
 | 
			
		||||
        home = "/var/lib/${cfg.username}";
 | 
			
		||||
        home = data_folder;
 | 
			
		||||
        description = "web server";
 | 
			
		||||
        extraGroups = [
 | 
			
		||||
          # "ory-hydra"
 | 
			
		||||
        ];
 | 
			
		||||
        group = cfg.username;
 | 
			
		||||
        group = username;
 | 
			
		||||
        isSystemUser = true;
 | 
			
		||||
      };
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    services.postgresql = {
 | 
			
		||||
      enable = true;
 | 
			
		||||
      ensureDatabases = [ cfg.username ];
 | 
			
		||||
      ensureDatabases = [ username ];
 | 
			
		||||
      ensureUsers = [
 | 
			
		||||
        {
 | 
			
		||||
          name = cfg.username;
 | 
			
		||||
          ensureDBOwnership = true;
 | 
			
		||||
          name = username;
 | 
			
		||||
          ensurePermissions = {
 | 
			
		||||
            "DATABASE ${username}" = "All PRIVILEGES";
 | 
			
		||||
          };
 | 
			
		||||
        }
 | 
			
		||||
      ];
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    services.ory-hydra.settings = {
 | 
			
		||||
      urls = {
 | 
			
		||||
        login = "${cfg.settings.PUBLIC_URL}/auth/login";
 | 
			
		||||
        logout = "${cfg.settings.PUBLIC_URL}/auth/logout";
 | 
			
		||||
        consent = "${cfg.settings.PUBLIC_URL}/auth/consent";
 | 
			
		||||
        error = "${cfg.settings.PUBLIC_URL}/auth/error";
 | 
			
		||||
      };
 | 
			
		||||
      identMap = ''
 | 
			
		||||
        # ArbitraryMapName systemUser DBUser
 | 
			
		||||
        superuser_map   ${username}   ${username}
 | 
			
		||||
      '';
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    services.nginx.enable = true;
 | 
			
		||||
| 
						 | 
				
			
			@ -123,10 +78,10 @@ in
 | 
			
		|||
      serverName = cfg.service_domain;
 | 
			
		||||
      locations."/" = {
 | 
			
		||||
        recommendedProxySettings = true;
 | 
			
		||||
        proxyPass = "http://unix:/run/${cfg.username}/web.sock";
 | 
			
		||||
        proxyPass = "http://unix:/run/${username}/web.sock";
 | 
			
		||||
      };
 | 
			
		||||
    };
 | 
			
		||||
    users.users.nginx.extraGroups = [ cfg.username ];
 | 
			
		||||
    users.users.nginx.extraGroups = [ username ];
 | 
			
		||||
 | 
			
		||||
    systemd.services.lenticular-cloud = {
 | 
			
		||||
      description = "lenticular account";
 | 
			
		||||
| 
						 | 
				
			
			@ -136,30 +91,30 @@ in
 | 
			
		|||
      enable = cfg.enable;
 | 
			
		||||
 | 
			
		||||
      environment = let
 | 
			
		||||
        config_file = format.generate "lenticular-cloud.json" cfg.settings;
 | 
			
		||||
        python_path = with python.pkgs; makePythonPath [ pkgs.lenticular-cloud gevent setuptools ];
 | 
			
		||||
      in {
 | 
			
		||||
        # CONFIG_FILE = "/etc/lenticular_cloud/production.conf";
 | 
			
		||||
        CONFIG_FILE = "${config_file}:${config_oauth_secret}";
 | 
			
		||||
        CONFIG_FILE = pkgs.writeText "lenticular-cloud.json" (builtins.toJSON cfg.settings);
 | 
			
		||||
        PYTHONPATH = "${python_path}";
 | 
			
		||||
        # PYTHONPATH =  "${lenticular-pkg.pythonPath}:${lenticular-pkg}/lib/python3.10/site-packages:${python_path}";
 | 
			
		||||
      };
 | 
			
		||||
      preStart = ''
 | 
			
		||||
        if [[ ! -e "${config_oauth_secret}" ]]; then
 | 
			
		||||
          SECRET_KEY=`${pkgs.openssl}/bin/openssl rand --hex 16`
 | 
			
		||||
          echo 'OAUTH_SECRET="$${SECRET_KEY}"' > ${config_oauth_secret}
 | 
			
		||||
          echo "oauth secreted generated"
 | 
			
		||||
        fi
 | 
			
		||||
        #cat > ${data_folder}/foobar.conf <<EOF
 | 
			
		||||
        #SECRET_KEY=""
 | 
			
		||||
        #EOF
 | 
			
		||||
        ${pkgs.lenticular-cloud}/bin/lenticular_cloud-cli db_upgrade
 | 
			
		||||
      '';
 | 
			
		||||
 | 
			
		||||
      serviceConfig = {
 | 
			
		||||
        Type = "simple";
 | 
			
		||||
        WorkingDirectory = cfg.settings.DATA_FOLDER;
 | 
			
		||||
        User = cfg.username;
 | 
			
		||||
        ExecStart = ''${python_env}/bin/gunicorn lenticular_cloud.wsgi --name lenticular_cloud \
 | 
			
		||||
              --workers 2 --log-level=info \
 | 
			
		||||
              --bind=unix:/run/${cfg.username}/web.sock \
 | 
			
		||||
        WorkingDirectory = data_folder;
 | 
			
		||||
        User = username;
 | 
			
		||||
        ExecStart = ''${python.pkgs.gunicorn}/bin/gunicorn lenticular_cloud.wsgi --name lenticular_cloud \
 | 
			
		||||
              --workers 1 --log-level=info \
 | 
			
		||||
              --bind=unix:/run/${username}/web.sock \
 | 
			
		||||
              -k gevent'';
 | 
			
		||||
        Restart = "on-failure";
 | 
			
		||||
        RuntimeDirectory = cfg.username;
 | 
			
		||||
        RuntimeDirectory = username;
 | 
			
		||||
      };
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										55
									
								
								overlay.nix
									
										
									
									
									
								
							
							
						
						
									
										55
									
								
								overlay.nix
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -1,23 +1,19 @@
 | 
			
		|||
final: prev:
 | 
			
		||||
let
 | 
			
		||||
  pkgs = final.pkgs;
 | 
			
		||||
  version = "2.4";
 | 
			
		||||
 | 
			
		||||
  frontend = pkgs.buildNpmPackage {
 | 
			
		||||
    pname = "lenticular_cloud_js";
 | 
			
		||||
    version = version;
 | 
			
		||||
    src = ./.;
 | 
			
		||||
    npmDepsHash = "sha256-L0EZHY1WN0zlnlUVm6d/EJIlC3Z/lod5d8dPNMsuw50=";
 | 
			
		||||
 | 
			
		||||
    installPhase = ''
 | 
			
		||||
      npm run build
 | 
			
		||||
      mkdir -p $out
 | 
			
		||||
      cp -r lenticular_cloud/static $out/
 | 
			
		||||
    '';
 | 
			
		||||
  };
 | 
			
		||||
in {
 | 
			
		||||
  python3 = prev.python3.override {
 | 
			
		||||
    packageOverrides = final: prev: with final; {
 | 
			
		||||
      sqlalchemy = prev.sqlalchemy.overridePythonAttrs (old: rec {
 | 
			
		||||
        version = "2.0.19";
 | 
			
		||||
        src = pkgs.fetchFromGitHub {
 | 
			
		||||
          owner = "sqlalchemy";
 | 
			
		||||
          repo = "sqlalchemy";
 | 
			
		||||
          rev = "refs/tags/rel_${lib.replaceStrings [ "." ] [ "_" ] version}";
 | 
			
		||||
          hash = "sha256-97q04wQVtlV2b6VJHxvnQ9ep76T5umn1KI3hXh6a8kU=";
 | 
			
		||||
        };
 | 
			
		||||
        disabledTestPaths = old.disabledTestPaths ++ [ "test/typing" ];
 | 
			
		||||
      });
 | 
			
		||||
      urlobject = buildPythonPackage rec {
 | 
			
		||||
          pname = "URLObject";
 | 
			
		||||
          version = "2.4.3";
 | 
			
		||||
| 
						 | 
				
			
			@ -78,16 +74,28 @@ in {
 | 
			
		|||
          httpx
 | 
			
		||||
        ];
 | 
			
		||||
      };
 | 
			
		||||
      flask-sqlalchemy = prev.flask-sqlalchemy.overridePythonAttrs (old: rec {
 | 
			
		||||
        version = "3.1.1";
 | 
			
		||||
        # version = "3.0.3";
 | 
			
		||||
        src = fetchPypi {
 | 
			
		||||
          pname = "flask_sqlalchemy";
 | 
			
		||||
          inherit version;
 | 
			
		||||
          sha256 = "e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312";
 | 
			
		||||
        };
 | 
			
		||||
        propagatedBuildInputs = old.propagatedBuildInputs ++ [
 | 
			
		||||
          flit-core sqlalchemy
 | 
			
		||||
        ];
 | 
			
		||||
        nativeCheckInputs = old.nativeCheckInputs ++ [
 | 
			
		||||
          typing-extensions
 | 
			
		||||
        ];
 | 
			
		||||
      });
 | 
			
		||||
      flask = prev.flask.overridePythonAttrs (old: {
 | 
			
		||||
        propagatedBuildInputs = old.propagatedBuildInputs ++ flask.optional-dependencies.async;
 | 
			
		||||
      });
 | 
			
		||||
      lenticular-cloud = buildPythonPackage {
 | 
			
		||||
        pname = "lenticular_cloud";
 | 
			
		||||
        version = version;
 | 
			
		||||
        version = "0.3";
 | 
			
		||||
        src = ./.;
 | 
			
		||||
        postPatch = ''
 | 
			
		||||
          cp -r ${frontend}/static ./lenticular_cloud/
 | 
			
		||||
        '';
 | 
			
		||||
        propagatedBuildInputs = [
 | 
			
		||||
          flask
 | 
			
		||||
          flask-restful
 | 
			
		||||
| 
						 | 
				
			
			@ -97,18 +105,17 @@ in {
 | 
			
		|||
          flask_login
 | 
			
		||||
          requests
 | 
			
		||||
          requests_oauthlib
 | 
			
		||||
          # ldap3 # only needed for old upgrade
 | 
			
		||||
          ldap3
 | 
			
		||||
          #ldap3-orm
 | 
			
		||||
          pyotp
 | 
			
		||||
          cryptography
 | 
			
		||||
          blinker
 | 
			
		||||
          authlib # as oauth client lib
 | 
			
		||||
          fido2 # for webauthn
 | 
			
		||||
          flask_migrate # db migrations
 | 
			
		||||
          flask-dance
 | 
			
		||||
          ory-hydra-client
 | 
			
		||||
          toml
 | 
			
		||||
          webauthn pyopenssl
 | 
			
		||||
          pyjwt
 | 
			
		||||
 | 
			
		||||
          pkgs.nodejs
 | 
			
		||||
          #node-env
 | 
			
		||||
| 
						 | 
				
			
			@ -128,6 +135,11 @@ in {
 | 
			
		|||
          mypy
 | 
			
		||||
 | 
			
		||||
        ];
 | 
			
		||||
        # passthru = {
 | 
			
		||||
        #   inherit python;
 | 
			
		||||
        #   pythonPath = python.pkgs.makePythonPath propagatedBuildInputs;
 | 
			
		||||
        # };
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        doCheck = false;
 | 
			
		||||
        checkInputs = [
 | 
			
		||||
| 
						 | 
				
			
			@ -137,5 +149,4 @@ in {
 | 
			
		|||
    };
 | 
			
		||||
  };
 | 
			
		||||
  lenticular-cloud = final.python3.pkgs.lenticular-cloud;
 | 
			
		||||
  lenticular-cloud-frontend = frontend;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										3530
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										3530
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										11
									
								
								package.json
									
										
									
									
									
								
							
							
						
						
									
										11
									
								
								package.json
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -3,14 +3,12 @@
 | 
			
		|||
  "version": "2.0.0",
 | 
			
		||||
  "description": "Lenticular Cloud ================",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "build": "webpack-cli --mode production",
 | 
			
		||||
    "watch": "webpack-cli --mode development -w"
 | 
			
		||||
    "build": "webpack-cli"
 | 
			
		||||
  },
 | 
			
		||||
  "author": "TuxCoder",
 | 
			
		||||
  "license": "GPLv3",
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@fortawesome/fontawesome-free": "^6.1.1",
 | 
			
		||||
    "@simplewebauthn/browser": "^8.3.4",
 | 
			
		||||
    "bootstrap": "^4.6.1",
 | 
			
		||||
    "cbor-web": "*",
 | 
			
		||||
    "css-loader": "^6.7.1",
 | 
			
		||||
| 
						 | 
				
			
			@ -24,12 +22,13 @@
 | 
			
		|||
    "qrcode-svg": "~1.1.0",
 | 
			
		||||
    "sass": "^1.52.1",
 | 
			
		||||
    "sass-loader": "^13.0.0",
 | 
			
		||||
    "simple-form-submit": "*",
 | 
			
		||||
    "style-loader": "*",
 | 
			
		||||
    "terser-webpack-plugin": "*",
 | 
			
		||||
    "ts-loader": "^9.3.0",
 | 
			
		||||
    "url-loader": "*",
 | 
			
		||||
    "webpack": "^5.72.1",
 | 
			
		||||
    "webpack-cli": "*"
 | 
			
		||||
  }
 | 
			
		||||
    "webpack-cli": "*",
 | 
			
		||||
    "simple-form-submit": "*"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue