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)
|
||||||
|
|
183
web/jwt.lisp
183
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