web, auth: work in progress: jwt generation
This commit is contained in:
		
							parent
							
								
									e71544c09f
								
							
						
					
					
						commit
						5fec210f2f
					
				
					 7 changed files with 26 additions and 206 deletions
				
			
		| 
						 | 
					@ -60,6 +60,10 @@
 | 
				
			||||||
  (make-instance cls :head (list org (name cred)) 
 | 
					  (make-instance cls :head (list org (name cred)) 
 | 
				
			||||||
                     :data (list :credentials cred)))
 | 
					                     :data (list :credentials cred)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					(defgeneric principal-id (prc)
 | 
				
			||||||
 | 
					  (:method ((prc principal))
 | 
				
			||||||
 | 
					    (head-value :name prc)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
;;;; login entry point
 | 
					;;;; login entry point
 | 
				
			||||||
 | 
					
 | 
				
			||||||
(defun login (inp)
 | 
					(defun login (inp)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -33,10 +33,10 @@
 | 
				
			||||||
      (t:show-result))))
 | 
					      (t:show-result))))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
(deftest test-login ()
 | 
					(deftest test-login ()
 | 
				
			||||||
  (let ((cred '(:name "admin" :password "secret"))
 | 
					  (let (cred pr1)
 | 
				
			||||||
        pr1)
 | 
					    (setf cred '(:name "admin" :password "secret"))
 | 
				
			||||||
    (== (auth:login cred) nil)
 | 
					    (== (auth:login cred) nil)
 | 
				
			||||||
    (setf (getf cred :password) "sc0pes")
 | 
					    (setf cred '(:name "admin" :password "sc0pes"))
 | 
				
			||||||
    (setf pr1 (auth:login cred))
 | 
					    (setf pr1 (auth:login cred))
 | 
				
			||||||
    (== (shape:head-value pr1 :name) :admin)
 | 
					    (== (shape:head-value pr1 :name) :admin)
 | 
				
			||||||
    ))
 | 
					    ))
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -7,7 +7,7 @@
 | 
				
			||||||
  :homepage "https://www.cyberconcepts.org"
 | 
					  :homepage "https://www.cyberconcepts.org"
 | 
				
			||||||
  :description "Core packages of the scopes project."
 | 
					  :description "Core packages of the scopes project."
 | 
				
			||||||
  :depends-on (:alexandria :cl-dotenv :com.inuoe.jzon 
 | 
					  :depends-on (:alexandria :cl-dotenv :com.inuoe.jzon 
 | 
				
			||||||
               :local-time :log4cl :str)
 | 
					               :flexi-streams :ironclad :local-time :log4cl :qbase64 :str)
 | 
				
			||||||
  :components ((:file "config" :depends-on ("util"))
 | 
					  :components ((:file "config" :depends-on ("util"))
 | 
				
			||||||
               (:file "core/core" 
 | 
					               (:file "core/core" 
 | 
				
			||||||
                      :depends-on ("core/message" "config" 
 | 
					                      :depends-on ("core/message" "config" 
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -12,8 +12,9 @@
 | 
				
			||||||
  :components ((:file "frontend/cs-hx" :depends-on ("web/dom" "web/response"))
 | 
					  :components ((:file "frontend/cs-hx" :depends-on ("web/dom" "web/response"))
 | 
				
			||||||
               (:file "web/client")
 | 
					               (:file "web/client")
 | 
				
			||||||
               (:file "web/dom")
 | 
					               (:file "web/dom")
 | 
				
			||||||
 | 
					               (:file "web/jwt")
 | 
				
			||||||
               (:file "web/response" :depends-on ("web/dom"))
 | 
					               (:file "web/response" :depends-on ("web/dom"))
 | 
				
			||||||
               (:file "web/server" :depends-on ("web/response")))
 | 
					               (:file "web/server" :depends-on ("web/jwt" "web/response")))
 | 
				
			||||||
  :long-description "scopes/web: Web server and web/API/REST client."
 | 
					  :long-description "scopes/web: Web server and web/API/REST client."
 | 
				
			||||||
  :in-order-to ((test-op (test-op "scopes-web/test"))))
 | 
					  :in-order-to ((test-op (test-op "scopes-web/test"))))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										21
									
								
								scratch.lisp
									
										
									
									
									
								
							
							
						
						
									
										21
									
								
								scratch.lisp
									
										
									
									
									
								
							| 
						 | 
					@ -16,24 +16,3 @@
 | 
				
			||||||
(defun iter-current (it)
 | 
					(defun iter-current (it)
 | 
				
			||||||
  (funcall it #'(lambda (p) (car (svref p 0)))))
 | 
					  (funcall it #'(lambda (p) (car (svref p 0)))))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#+ecl
 | 
					 | 
				
			||||||
(defun classes ()
 | 
					 | 
				
			||||||
  (let ((r nil)) 
 | 
					 | 
				
			||||||
    (maphash #'(lambda (k v) 
 | 
					 | 
				
			||||||
      (setf r (cons k r))) si:*class-name-hash-table*) 
 | 
					 | 
				
			||||||
    (sort r #'(lambda (x y) 
 | 
					 | 
				
			||||||
      (string<= (package-name (symbol-package x)) 
 | 
					 | 
				
			||||||
                (package-name (symbol-package y)))))))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
(defun x-make-var (value)
 | 
					 | 
				
			||||||
  #'(lambda (nv)
 | 
					 | 
				
			||||||
      (if (null nv)
 | 
					 | 
				
			||||||
        value
 | 
					 | 
				
			||||||
        (setf value nv))))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
(defun x-get-var (vf)
 | 
					 | 
				
			||||||
  (funcall vf nil))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
(defun x-put-var (vf value)
 | 
					 | 
				
			||||||
  (funcall vf value))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										13
									
								
								util.lisp
									
										
									
									
									
								
							
							
						
						
									
										13
									
								
								util.lisp
									
										
									
									
									
								
							| 
						 | 
					@ -2,14 +2,20 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
(defpackage :scopes/util
 | 
					(defpackage :scopes/util
 | 
				
			||||||
  (:use :common-lisp)
 | 
					  (:use :common-lisp)
 | 
				
			||||||
 | 
					  (:local-nicknames (:b64 :qbase64))
 | 
				
			||||||
 | 
					  #+sbcl (:import-from :sb-ext #:add-package-local-nickname)
 | 
				
			||||||
  (:export #:lg #:lgd #:lgi
 | 
					  (:export #:lg #:lgd #:lgi
 | 
				
			||||||
 | 
					           #:create-secret
 | 
				
			||||||
           #:rfill #:rtrim #:loop-plist #:filter-plist #:plist-pairs #:plist-equal
 | 
					           #:rfill #:rtrim #:loop-plist #:filter-plist #:plist-pairs #:plist-equal
 | 
				
			||||||
           #:flatten-str #:to-keyword #:keyword-to-string #:to-integer #:to-string
 | 
					           #:flatten-str #:to-keyword #:keyword-to-string #:to-integer #:to-string
 | 
				
			||||||
           #:absolute-dir #:check-dir #:ensure-dir #:home-path #:path-from-string
 | 
					           #:absolute-dir #:check-dir #:ensure-dir #:home-path #:path-from-string
 | 
				
			||||||
           #:relative-path #:runtime-path #:system-path))
 | 
					           #:relative-path #:runtime-path #:system-path
 | 
				
			||||||
 | 
					           #:add-package-local-nickname))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
(in-package :scopes/util)
 | 
					(in-package :scopes/util)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					;;;; logging shortcuts
 | 
				
			||||||
 | 
					
 | 
				
			||||||
(defmacro lg (level &rest vars)
 | 
					(defmacro lg (level &rest vars)
 | 
				
			||||||
  (let ((lm (find-symbol (string level) :log))
 | 
					  (let ((lm (find-symbol (string level) :log))
 | 
				
			||||||
        (fm (format nil "~{~(~a~): ~~S ~}" vars)))
 | 
					        (fm (format nil "~{~(~a~): ~~S ~}" vars)))
 | 
				
			||||||
| 
						 | 
					@ -18,6 +24,11 @@
 | 
				
			||||||
(defmacro lgd (&rest vars) `(lg :debug ,@vars))
 | 
					(defmacro lgd (&rest vars) `(lg :debug ,@vars))
 | 
				
			||||||
(defmacro lgi (&rest vars) `(lg :info ,@vars))
 | 
					(defmacro lgi (&rest vars) `(lg :info ,@vars))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					;;;; secrets, digests, and other crypto stuff
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					(defun create-secret ()
 | 
				
			||||||
 | 
					  (b64:encode-bytes (ironclad:random-data 16)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
;;;; lists and loops
 | 
					;;;; lists and loops
 | 
				
			||||||
 | 
					
 | 
				
			||||||
(defun rfill (ll ls)
 | 
					(defun rfill (ll ls)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										181
									
								
								web/jwt.lisp
									
										
									
									
									
								
							
							
						
						
									
										181
									
								
								web/jwt.lisp
									
										
									
									
									
								
							| 
						 | 
					@ -1,187 +1,12 @@
 | 
				
			||||||
;;;; cl-scopes/web/jwt - JWT creation and validation
 | 
					;;;; cl-scopes/web/jwt - JWT creation and validation
 | 
				
			||||||
;;;; based on: cljwt
 | 
					;;;; inspired by: cljwt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
(defpackage :scopes/web/jwt
 | 
					(defpackage :scopes/web/jwt
 | 
				
			||||||
  (:use :common-lisp)
 | 
					  (:use :common-lisp)
 | 
				
			||||||
  (:local-nicknames (:alx :alexandria)
 | 
					  (:local-nicknames (:alx :alexandria)
 | 
				
			||||||
                    (:b64 :cl-base64)
 | 
					                    (:b64 :qbase64)
 | 
				
			||||||
                    (:fxs :flexi-streams)
 | 
					                    (:fxs :flexi-streams)
 | 
				
			||||||
                    (:ic :ironclad)
 | 
					                    (:ic :ironclad)
 | 
				
			||||||
                    (:jzon :com.inuoe.jzon))
 | 
					                    (:jzon :com.inuoe.jzon))
 | 
				
			||||||
  (:export #:issue #:decode 
 | 
					  (:export #:issue #:decode))
 | 
				
			||||||
           #:unsecured-token #:invalid-hmac
 | 
					 | 
				
			||||||
           #:unsupported-algorithm #:invalid-time #:expired #:not-yet-valid))
 | 
					 | 
				
			||||||
 
 | 
					 
 | 
				
			||||||
(in-package :scopes/web/jwt)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
(defconstant *unix-time-base* (encode-universal-time 0 0 0 1 1 1970 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
(defmacro bind-hash-tables (bindings &body body)
 | 
					 | 
				
			||||||
  `(let ,(loop for binding in bindings collect
 | 
					 | 
				
			||||||
              (list (car binding)
 | 
					 | 
				
			||||||
                    `(etypecase ,(cadr binding)
 | 
					 | 
				
			||||||
                       (hash-table ,(cadr binding))
 | 
					 | 
				
			||||||
                       (list (plist-hash-table ,(cadr binding)
 | 
					 | 
				
			||||||
                                               :test #'equal)))))
 | 
					 | 
				
			||||||
     ,@body))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
(defmacro add-claims (hash &rest claims)
 | 
					 | 
				
			||||||
  `(progn ,@(loop for (key value) on claims by #'cddr collect
 | 
					 | 
				
			||||||
                 `(when ,value
 | 
					 | 
				
			||||||
                    (setf (gethash ,key ,hash) ,value)))))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
(defun to-unix-time (time)
 | 
					 | 
				
			||||||
  (when time (- time *unix-time-base*)))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
(defun from-unix-time (time)
 | 
					 | 
				
			||||||
  (when time (+ time *unix-time-base*)))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
(defun base64-encode (input)
 | 
					 | 
				
			||||||
  "Takes a string or octets, returns an unpadded URI-encoded Base64 string."
 | 
					 | 
				
			||||||
  (etypecase input
 | 
					 | 
				
			||||||
    (string (base64-encode (fxs:string-to-octets input :external-format :utf-8)))
 | 
					 | 
				
			||||||
    ((simple-array (unsigned-byte 8))
 | 
					 | 
				
			||||||
     (with-output-to-string (out)
 | 
					 | 
				
			||||||
       (with-input-from-string (in (b64:usb8-array-to-base64-string input :uri t))
 | 
					 | 
				
			||||||
         (loop for character = (read-char in nil)
 | 
					 | 
				
			||||||
               while character do
 | 
					 | 
				
			||||||
                 ;; CL-BASE64 always uses padding, which must be removed.
 | 
					 | 
				
			||||||
                 (unless (eq character #\.)
 | 
					 | 
				
			||||||
                   (write-char character out))))))))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
(defun base64-decode (base-64-string)
 | 
					 | 
				
			||||||
  "Takes a base64-uri string and return an array of octets"
 | 
					 | 
				
			||||||
  (b64:base64-string-to-usb8-array
 | 
					 | 
				
			||||||
   ;; Re-pad the string, or CL-BASE64 will get confused
 | 
					 | 
				
			||||||
   (concatenate 'string
 | 
					 | 
				
			||||||
                base-64-string
 | 
					 | 
				
			||||||
                (make-array (rem (length base-64-string) 4)
 | 
					 | 
				
			||||||
                            :element-type 'character
 | 
					 | 
				
			||||||
                            :initial-element #\.))
 | 
					 | 
				
			||||||
   :uri t))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
(defun issue (claims &key algorithm secret issuer subject audience
 | 
					 | 
				
			||||||
                       expiration not-before issued-at id more-header)
 | 
					 | 
				
			||||||
  "Encodes and returns a JSON Web Token. Times are in universal-time,
 | 
					 | 
				
			||||||
number of seconds from 1900-01-01 00:00:00"
 | 
					 | 
				
			||||||
  (bind-hash-tables ((claimset claims)
 | 
					 | 
				
			||||||
                     (header more-header))
 | 
					 | 
				
			||||||
    ;; Add registered claims to the claims hash table
 | 
					 | 
				
			||||||
    (add-claims claimset
 | 
					 | 
				
			||||||
                "iss" issuer
 | 
					 | 
				
			||||||
                "sub" subject
 | 
					 | 
				
			||||||
                "aud" audience
 | 
					 | 
				
			||||||
                "exp" (to-unix-time expiration)
 | 
					 | 
				
			||||||
                "nbf" (to-unix-time not-before)
 | 
					 | 
				
			||||||
                "iat" (to-unix-time issued-at)
 | 
					 | 
				
			||||||
                "jti" id)
 | 
					 | 
				
			||||||
    ;; Add type and algorithm to the header hash table
 | 
					 | 
				
			||||||
    (add-claims header
 | 
					 | 
				
			||||||
                "typ" "JWT"
 | 
					 | 
				
			||||||
                "alg" (ecase algorithm
 | 
					 | 
				
			||||||
                        (:none "none")
 | 
					 | 
				
			||||||
                        (:hs256 "HS256")))
 | 
					 | 
				
			||||||
    ;; Prepare JSON
 | 
					 | 
				
			||||||
    (let ((header-string (base64-encode
 | 
					 | 
				
			||||||
                          (with-output-to-string (s)
 | 
					 | 
				
			||||||
                            (jzon:stringify header s))))
 | 
					 | 
				
			||||||
          (claims-string (base64-encode
 | 
					 | 
				
			||||||
                          (with-output-to-string (s)
 | 
					 | 
				
			||||||
                            (jzon:stringify claimset s)))))
 | 
					 | 
				
			||||||
      ;; Assemble and, if applicable, sign the JWT
 | 
					 | 
				
			||||||
      (format nil "~A.~A.~@[~A~]"
 | 
					 | 
				
			||||||
              header-string
 | 
					 | 
				
			||||||
              claims-string
 | 
					 | 
				
			||||||
              (when (eq algorithm :hs256)
 | 
					 | 
				
			||||||
                (HS256-digest header-string
 | 
					 | 
				
			||||||
                                 claims-string
 | 
					 | 
				
			||||||
                                 secret))))))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
(defun HS256-digest (header-string claims-string secret)
 | 
					 | 
				
			||||||
  "Takes header and claims in Base64, secret as a string or octets,
 | 
					 | 
				
			||||||
returns the digest, in Base64"
 | 
					 | 
				
			||||||
  (base64-encode
 | 
					 | 
				
			||||||
   (ic:hmac-digest
 | 
					 | 
				
			||||||
    (ic:update-hmac
 | 
					 | 
				
			||||||
     (ic:make-hmac (etypecase secret
 | 
					 | 
				
			||||||
                  ((simple-array (unsigned-byte 8))
 | 
					 | 
				
			||||||
                   secret)
 | 
					 | 
				
			||||||
                  (string
 | 
					 | 
				
			||||||
                   (string-to-octets secret
 | 
					 | 
				
			||||||
                                     :external-format :utf-8)))
 | 
					 | 
				
			||||||
                'ironclad:SHA256)
 | 
					 | 
				
			||||||
     (concatenate '(vector (unsigned-byte 8))
 | 
					 | 
				
			||||||
                  (string-to-octets
 | 
					 | 
				
			||||||
                   header-string)
 | 
					 | 
				
			||||||
                  #(46) ; ASCII period (.)
 | 
					 | 
				
			||||||
                  (string-to-octets
 | 
					 | 
				
			||||||
                   claims-string))))))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
(defun compare-HS256-digest (header-string claims-string
 | 
					 | 
				
			||||||
                             secret reported-digest)
 | 
					 | 
				
			||||||
  "Takes header and claims in Base64, secret as a string or octets, and a digest in Base64 to compare with. Signals an error if there is a mismatch."
 | 
					 | 
				
			||||||
  (let ((computed-digest
 | 
					 | 
				
			||||||
         (HS256-digest header-string
 | 
					 | 
				
			||||||
                       claims-string
 | 
					 | 
				
			||||||
                       secret)))
 | 
					 | 
				
			||||||
    (unless (equalp computed-digest
 | 
					 | 
				
			||||||
                   reported-digest)
 | 
					 | 
				
			||||||
      (cerror "Continue anyway" 'invalid-hmac
 | 
					 | 
				
			||||||
             :reported-digest reported-digest
 | 
					 | 
				
			||||||
             :computed-digest computed-digest))))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
(defun decode (jwt-string &key secret fail-if-unsecured)
 | 
					 | 
				
			||||||
  "Decodes and verifies a JSON Web Token. Returns two hash tables,
 | 
					 | 
				
			||||||
token claims and token header"
 | 
					 | 
				
			||||||
  (destructuring-bind (header-string claims-string digest-string)
 | 
					 | 
				
			||||||
                      (str:split #\. jwt-string)
 | 
					 | 
				
			||||||
    (let* ((header-hash (jzon:parse (octets-to-string (base64-decode header-string)
 | 
					 | 
				
			||||||
                                                      :external-format :utf-8)))
 | 
					 | 
				
			||||||
           (claims-hash (jzon:parse (octets-to-string (base64-decode claims-string)
 | 
					 | 
				
			||||||
                                                      :external-format :utf-8)))
 | 
					 | 
				
			||||||
           (algorithm (gethash "alg" header-hash)))
 | 
					 | 
				
			||||||
      ;; Verify HMAC
 | 
					 | 
				
			||||||
      (cond ((equal algorithm "HS256")
 | 
					 | 
				
			||||||
             (compare-HS256-digest header-string
 | 
					 | 
				
			||||||
                                   claims-string
 | 
					 | 
				
			||||||
                                   secret
 | 
					 | 
				
			||||||
                                   digest-string))
 | 
					 | 
				
			||||||
            ((and (or (null algorithm) (equal algorithm "none")) fail-if-unsecured)
 | 
					 | 
				
			||||||
             (cerror "Continue anyway" 'unsecured-token))
 | 
					 | 
				
			||||||
            (t (cerror "Continue anyway" 'unsupported-algorithm
 | 
					 | 
				
			||||||
                       :algorithm algorithm)))
 | 
					 | 
				
			||||||
      ;; Verify timestamps
 | 
					 | 
				
			||||||
      (let ((expires (from-unix-time (gethash "exp" claims-hash)))
 | 
					 | 
				
			||||||
            (not-before (from-unix-time (gethash "nbf" claims-hash)))
 | 
					 | 
				
			||||||
            (current-time (get-universal-time)))
 | 
					 | 
				
			||||||
        (when (and expires (> current-time expires))
 | 
					 | 
				
			||||||
          (cerror "Continue anyway" 'expired :delta (- current-time expires)))
 | 
					 | 
				
			||||||
        (when (and not-before (< current-time not-before))
 | 
					 | 
				
			||||||
          (cerror "Continue anyway" 'not-yet-valid :delta (- current-time not-before))))
 | 
					 | 
				
			||||||
      ;; Return hashes
 | 
					 | 
				
			||||||
      (values claims-hash header-hash))))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
;;; Conditions
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
(define-condition unsecured-token (error) ())
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
(define-condition invalid-hmac (error) ())
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
(define-condition unsupported-algorithm (error)
 | 
					 | 
				
			||||||
  ((algorithm :initarg :algorithm :reader algorithm))
 | 
					 | 
				
			||||||
  (:report (lambda (condition stream)
 | 
					 | 
				
			||||||
             (format stream "Algorithm \"~A\" not supported"
 | 
					 | 
				
			||||||
                     (algorithm condition)))))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
(define-condition invalid-time (error)
 | 
					 | 
				
			||||||
  ((delta :initarg :delta :reader time-delta))
 | 
					 | 
				
			||||||
  (:report (lambda (condition stream)
 | 
					 | 
				
			||||||
             (format stream "Token ~A. ~D seconds off."
 | 
					 | 
				
			||||||
                     (typecase condition
 | 
					 | 
				
			||||||
                       (expired "has expired")
 | 
					 | 
				
			||||||
                       (not-yet-valid "is not yet valid"))
 | 
					 | 
				
			||||||
                     (time-delta condition)))))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
(define-condition expired (invalid-time) ())
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
(define-condition not-yet-valid (invalid-time) ())
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		
		Reference in a new issue