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