nmunro.github.io

Common Lisp and other programming related things from NMunro

View on GitHub
31 July 2025

Ningle Tutorial 9: Authentication System

by NMunro

Contents

Introduction

Welcome back to this tutorial series, in this chapter we are going to build an authentication system and I ain’t gonna lie to you, it’s something of a monster of a chapter, we will be extending our settings, writting middleware code and injecting settings into apps at the point they are mounted, so buckle up, it’ll be a wild ride.

Learning Outcomes

We will be developing an authentication app that:

Allows users to register

This will render a form that uses csrf protection, when a user fills in the form if the username or email address they have entered is already in use by another user, an error will be signalled, if they have entered two different passwords into the password and password-verify another error will be signalled. Assuming no errors are signalled, a user and token object will be created, a unique url based on the username and token will be displayed to the terminal (later to be send via email), and the browser is redirected to another route. It is important to note that tokens will only be valid for one hour.

Verify new accounts prior to logging in

This is the second step in the user registration process, for the moment we will use the url printed in the terminal from the previous step (but remember this will be emailed to users later), when the url is requested, if there is a user that is already logged in, they will be redirected away from the url. If there is a matching token but it is expired, a new token will be issued (deleting the old one in the process), as before, a new url will be printed to the terminal. If there is no token, an error page will be displayed. Finally, if a token exists, it is valid, and there’s no logged in user, we can proceed with activating the user. This will delete the token, set up permissions for the user, activate and save the user and redirect the browser to the login route.

Allows user login with restricted views

This will render a form to users to log in with as with our register form it will be protected with a csrf token, if a user is already logged in, it will redirect them away from this route, if there is a csrf token error this will be signalled, likewise errors will be signalled for users that don’t exist (or have not yet been activated via the verification process described above), or the password is invalid for the given user. If however there are no errors the user is logged in and redirected to a new url. As part of this, a route /profile will be set up that will only be accessible to users that are logged in.

Allow users to request a secure password reset

Users forget their password, it happens, we need to facilitate a way to reset their password. This will be a two step process, as always we will have our form contain a csrf token, so it might be that this controller signals an error, but assuming this hasn’t happened. If there’s a user, and a token, but the token hasn’t expired, this suggests that a previous attempt was made, so an error should be sent back informing the user that they must either complete the reset, or wait for the token to expire.

If there is a user and a token that has expired, the old token will be deleted and a new one issued, the new url will then be displayed on the terminal (as always with these links they will be emailed in the future) and the browser will redirect.

If there is only a user and no token, this means that the reset process is being started for the first time and a token will be issued, the url printed to the terminal and the browser redirected.

Finally if there is no user found, an error will be displayed in the controller.

Allow users to reset password

Once the request to reset the password has been processed, the password should be reset, this controller will render the password reset form, if the user is logged in the browser should be redirected away from this url.

If there is no reset token, or it has expired an error should be rendered in the browser.

If there is a valid reset token, the form can be rendered to accept a new password, upon form submisison, as with all forms a csrf token protects the form and this can be signalled, likewise if two different passwords are entered, this will signal an error.

When the user, token, and passwords match the new user password is set and the user object is saved, the token is deleted and the browser is redirected, however if, for some reason, the user isn’t valid, an error will be displayed in the browser.

Allow users to logout

This will clear the active user from the session and redirect to the login page.

Building the Authentication App

Initial Clean Up

Before we begin in earnest we should remove a route setup in the last chapter that ultimately doesn’t belong in authentication, it more accurately belongs in user management, which we will explore in a futute chapter.

Find the controller for deleting users and delete it:

    (setf (ningle:route *app* "/delete")
        (lambda (params)
            (djula:render-template* "auth/delete.html" nil :title "Delete")))

Also find and remove the following templates:

It was anticipated that that these may have been needed, but in the process of developing the solution, they weren’t actually needed.

Forms

The easiest place to start is with our forms, our forms control what data we want to send back and forth and how to validate it, so these offer a good high level view at what we will be doing. We previously wrote a form in the ningle-tutorial-app for registering users, we will move that form from the tutorial app and into the authentication app (ningle-auth) we created last time and we will create a few other forms too. As before, we used the cl-forms package, and so these forms should be familiar from Part 4, but specifically we have the following four forms:

register

Our register form concerns itself with allowing users to sign up to our application, it has the following fields:

  1. Username used to log in (we could have used emails, but I wanted to demonstrate a few things)
  2. An email address (we will use this in a later tutorial to email information we produce during this tutorial)
  3. A password field
  4. A confirm password field (to help ensure the password typed was free of typos)
  5. A submit button
1
2
3
4
5
6
(cl-forms:defform register (:id "register" :csrf-protection t :csrf-field-name "csrftoken")
    ((email           :email    :value "" :constraints (list (clavier:valid-email)))
     (username        :string   :value "" :constraints *username-validator*)
     (password        :password :value "" :constraints *password-validator*)
     (password-verify :password :value "" :constraints *password-validator*)
     (submit          :submit   :label "Register")))

The fields have constraints on them as one might expect, as we do want to validate our forms! When this form is rendered a GET request will display this form and a POST request will process the data the form submitted.

login

Our login form concerns itself with allowing registered users to log into our application, this is as simple as a username and a password, we do not necessarily need to validate these they will only be comparing objects in the database not creating new objects.

1
2
3
4
(cl-forms:defform login (:id "login" :csrf-protection t :csrf-field-name "csrftoken")
    ((username :string   :value "")
    (password :password :value "")
    (submit   :submit   :value "Login")))

reset-password

Our reset-password form concerns itself with allowing registered users to begin the process of securely changing their password if they cannot login. We do not want just anyone to be able to reset a users password, so we will need a form that will take an email address and send a link the user can follow to actually change the password.

1
2
3
(cl-forms:defform reset-password (:id "password-reset" :csrf-protection 5 :csrf-field-name "csrftoken")
    ((email  :string :value "")
    (submit :submit :value "Reset")))

new-password

Our new-password form concerns itself with completing the process of securely changing the password of registered users that have begun the process if they cannot login. It is assumed that this form is served by a url that the user has received via email and requires matching usernames and secure tokens that an attacker couldn’t guess, also these tokens expire within 1 hour and are deleted after a single use, so cannot be reused and its unlikely they could be cracked within the 1 hour window in which they are valid.

It is important to note that the email, and token fields will be of the type hidden, we don’t want the user to fill these in directly, but we certainly want to validate them along with all the other items in the form. When the form is initially rendered, these will need to be populated by us.

1
2
3
4
5
6
(cl-forms:defform new-password (:id "new-password" :csrf-protection 5 :csrf-field-name "csrftoken")
    ((email           :hidden   :value "" :constraints (list (clavier:valid-email)))
     (token           :hidden   :value "" :constraints *token-validator*)
     (password        :password :value "" :constraints *password-validator*)
     (password-verify :password :value "" :constraints *password-validator*)
     (submit          :submit   :value "Reset")))

Full Listing

In the ningle-auth application create src/forms.lisp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
(defpackage ningle-auth/forms
  (:use :cl)
  (:export #:register
           #:login
           #:reset-password
           #:new-password
           #:email
           #:username
           #:token
           #:password
           #:password-verify))

(in-package ningle-auth/forms)

(defparameter *username-validator* (list (clavier:not-blank)
                                         (clavier:is-a-string)))

(defparameter *password-validator* (list (clavier:not-blank)
                                         (clavier:is-a-string)
                                         (clavier:len :min 8)))

(defparameter *token-validator* (list (clavier:not-blank)
                                      (clavier:is-a-string)
                                      (clavier:len :min 64 :max 64)))

(cl-forms:defform register (:id "register" :csrf-protection t :csrf-field-name "csrftoken")
  ((email           :email    :value "" :constraints (list (clavier:valid-email)))
   (username        :string   :value "" :constraints *username-validator*)
   (password        :password :value "" :constraints *password-validator*)
   (password-verify :password :value "" :constraints *password-validator*)
   (submit          :submit   :label "Register")))

(cl-forms:defform login (:id "login" :csrf-protection t :csrf-field-name "csrftoken")
  ((username :string   :value "")
   (password :password :value "")
   (submit   :submit   :value "Login")))

(cl-forms:defform reset-password (:id "password-reset" :csrf-protection 5 :csrf-field-name "csrftoken")
  ((email  :string :value "")
   (submit :submit :value "Reset")))

(cl-forms:defform new-password (:id "new-password" :csrf-protection 5 :csrf-field-name "csrftoken")
  ((email           :hidden   :value "" :constraints (list (clavier:valid-email)))
   (token           :hidden   :value "" :constraints *token-validator*)
   (password        :password :value "" :constraints *password-validator*)
   (password-verify :password :value "" :constraints *password-validator*)
   (submit          :submit   :value "Reset")))

Models

With our forms defined, we can go back and write our models, we will look at each model in isolation, any methods, and then see the complete listing, so we can then see what we need to export after having looked at the basic functionality.

User Model

Our user model will use the mito-auth mixin to provide an interface with which we can use hashed and salted passwords, we will have a text column (:varchar 255) for our email and username fields, and an integer field that will represent if the user is “active” or not (if they have completed the registration steps). Since we are using the mito-auth mixin we have a number of fields hidden here and the details aren’t too important except to know that there’s a password-hash that will contain the salted and hashed password, mito-auth does the heavy lifting for us here.

1
2
3
4
5
(deftable user (mito-auth:has-secure-password)
  ((email    :col-type (:varchar 255) :initarg  :email    :accessor email)
   (username :col-type (:varchar 255) :initarg  :username :accessor username)
   (active   :col-type :integer       :initform 0         :accessor active))
  (:unique-keys email username))

From the last line, we can see that both email and username should be unique.

Role Model

The role model is quite simple and concerns itself with, as its name might suggest, roles, these are simply names and descriptions. When we come to writing our migrations, we will create admin and user roles and their permissions.

1
2
3
4
(deftable role ()
  ((name        :col-type (:varchar 255)  :initarg :name        :accessor name)
   (description :col-type (:varchar 2048) :initarg :description :accessor description))
  (:unique-keys name))

We make the name unique here as we really don’t want two roles with the same name.

Permission Model

In order to grant user roles, we need a permission model, this will link a user to a role. As we build the application having a permission table allows us to grant or revoke permissions easily.

1
2
3
4
(deftable permission ()
  ((user :col-type user :references (user id))
   (role :col-type role :references (role id)))
  (:unique-keys (user role)))

Where we previously defined unique fields, here we define a unique constraint where the same value can repeat in this table multiple times, and the same role can appear in this table multiple times, but the same role with the same user cannot appear more than once. In effect a user can only ever be assigned a given role once.

Token Model

Our token model will concern itself with various tokens, in our authentication system there is only two an email-verification token and a password-reset token.

1
2
3
4
5
6
7
(deftable token ()
  ((user       :col-type user          :references (user id))
   (purpose    :col-type :string       :initarg :purpose    :accessor token-purpose)
   (token      :col-type (:varchar 64) :initarg :token      :accessor token-value)
   (salt       :col-type :binary       :accessor token-salt)
   (expires-at :col-type :timestamp    :accessor token-expires-at))
  (:unique-keys (user-id purpose)))

As in our permission model, we have a constraint where a user can only ever have one type of token, there’s something to note, that while our field is called user and we can use that in code, the actual name in the database is user_id. Just like our user model, we will use salts and hashes to create unique and secure tokens.

Token Methods

While not all of our models require methods, some do, staring with our token model we have to check if a token has expired, so we will write a method that simply returns t or nil depending on if the token has indeed expired, or not.

The type of the expiration date may change depending on when it is serialized, so we use a typecase here to handle the different types it may be.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(defgeneric is-expired-p (token)
  (:documentation "Determines if a token has expired"))

(defmethod is-expired-p ((token token))
  (let ((expiry (token-expires-at token)))
    (typecase expiry
      (local-time:timestamp
       (> (get-universal-time) (local-time:timestamp-to-universal expiry)))

      (integer
       (> (get-universal-time) expiry))

      (t
       (error "Unknown type for token-expires-at: ~S" (type-of expiry))))))

Since we have specific token types, we want to ensure that invalid values cannot be passed into the objects, so here we write our own implementations of the initialize-instance method using :before and :after to ensure that if an invalid token type is passed in we signal an error, but also, if no salt or expires-at value was provided, a default is created, for security.

1
2
3
4
5
6
7
8
9
10
(defmethod initialize-instance :before ((token token) &rest initargs &key purpose &allow-other-keys)
  (unless (member purpose +token-purposes+ :test #'string=)
    (error "Invalid token purpose: ~A. Allowed: ~A" purpose +token-purposes+)))

(defmethod initialize-instance :after ((token token) &rest initargs &key &allow-other-keys)
  (unless (slot-boundp token 'salt)
    (setf (token-salt token) (ironclad:make-random-salt 16)))

  (unless (slot-boundp token 'expires-at)
    (setf (token-expires-at token) (+ (get-universal-time) 3600))))

User Methods

Finally the methods for our user object, we will start by defining a method to activate our user object (which will be used when a user completes the account verification step), all this does is set the active slot on the user object to 1, please note that due to separation of concerns and the principle of the least surprise setting the active flat does not save the user object.

1
2
3
4
5
(defgeneric activate (user)
  (:documentation "Set the active slot of a user to 1"))

(defmethod activate ((user user))
  (setf (active user) 1))

As we have mentioned, we must create tokens, and tokens are linked to a user, so it makes sense to have a method that dispatches on a user model for creating a token, calling generate-token with a user and a valid token type will create and return the token.

1
2
3
4
5
6
7
8
9
10
11
12
(defgeneric generate-token (user purpose &key expires-in)
  (:documentation "Generates a token for a user"))

(defmethod generate-token ((user user) purpose &key (expires-in 3600))
    (unless (member purpose +token-purposes+ :test #'string=)
      (error "Invalid token purpose: ~A. Allowed: ~A" purpose +token-purposes+))

    (let* ((salt (ironclad:make-random-salt 16))
           (expires-at (truncate (+ (get-universal-time) expires-in)))
           (base-string (format nil "~A~A~A" (username user) expires-at salt))
           (hash (ironclad:byte-array-to-hex-string (ironclad:digest-sequence :sha256 (babel:string-to-octets base-string)))))
        (create-dao 'token :user user :purpose purpose :token hash :salt salt :expires-at expires-at)))

Token Types

We have discussed the two token types, they’re simple strings, but we define them in our package and include them in a list so that if we add more it’s easy to check membership of +token-purposes+.

1
2
3
(defparameter +email-verification+ "email-verification")
(defparameter +password-reset+ "password-reset")
(defparameter +token-purposes+ (list +email-verification+ +password-reset+))

Package Structure

Unusually, we are looking at the package structure and exports now at the end, but we didn’t know what would be exported until we wrote it!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
(defpackage ningle-auth/models
  (:use :cl :mito)
  (:import-from :mito-auth
                :password-hash)
  (:export #:user
           #:id
           #:created-at
           #:updated-at
           #:email
           #:username
           #:password-hash
           #:role
           #:permission
           #:token
           #:token-value
           #:generate-token
           #:is-expired-p
           #:activate
           #:+email-verification+
           #:+password-reset+
           #:+token-purposes+))

(in-package ningle-auth/models)

Full Listing

In the ningle-auth application create src/models.lisp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
(defpackage ningle-auth/models
  (:use :cl :mito)
  (:import-from :mito-auth
                :password-hash)
  (:export #:user
           #:id
           #:created-at
           #:updated-at
           #:email
           #:username
           #:password-hash
           #:role
           #:permission
           #:token
           #:token-value
           #:generate-token
           #:is-expired-p
           #:activate
           #:+email-verification+
           #:+password-reset+
           #:+token-purposes+))

(in-package ningle-auth/models)

(defparameter +email-verification+ "email-verification")
(defparameter +password-reset+ "password-reset")
(defparameter +token-purposes+ (list +email-verification+ +password-reset+))

(deftable user (mito-auth:has-secure-password)
  ((email    :col-type (:varchar 255) :initarg  :email    :accessor email)
   (username :col-type (:varchar 255) :initarg  :username :accessor username)
   (active   :col-type :integer       :initform 0         :accessor active))
  (:unique-keys email username))

(deftable role ()
  ((name        :col-type (:varchar 255)  :initarg :name        :accessor name)
   (description :col-type (:varchar 2048) :initarg :description :accessor description))
  (:unique-keys name))

(deftable permission ()
  ((user :col-type user :references (user id))
   (role :col-type role :references (role id)))
  (:unique-keys (user role)))

(deftable token ()
  ((user       :col-type user          :references (user id))
   (purpose    :col-type :string       :initarg :purpose    :accessor token-purpose)
   (token      :col-type (:varchar 64) :initarg :token      :accessor token-value)
   (salt       :col-type :binary       :accessor token-salt)
   (expires-at :col-type :timestamp    :accessor token-expires-at))
  (:unique-keys (user-id purpose)))

(defgeneric activate (user)
  (:documentation "Set the active slot of a user to 1"))

(defmethod activate ((user user))
  (setf (active user) 1))

(defgeneric is-expired-p (token)
  (:documentation "Determines if a token has expired"))

(defmethod is-expired-p ((token token))
  (let ((expiry (token-expires-at token)))
    (typecase expiry
      (local-time:timestamp
       (> (get-universal-time) (local-time:timestamp-to-universal expiry)))

      (integer
       (> (get-universal-time) expiry))

      (t
       (error "Unknown type for token-expires-at: ~S" (type-of expiry))))))

(defmethod initialize-instance :before ((tok token) &rest initargs &key purpose &allow-other-keys)
  (unless (member purpose +token-purposes+ :test #'string=)
    (error "Invalid token purpose: ~A. Allowed: ~A" purpose +token-purposes+)))

(defmethod initialize-instance :after ((token token) &rest initargs &key &allow-other-keys)
  (unless (slot-boundp token 'salt)
    (setf (token-salt token) (ironclad:make-random-salt 16)))

  (unless (slot-boundp token 'expires-at)
    (setf (token-expires-at token) (+ (get-universal-time) 3600))))

(defgeneric generate-token (user purpose &key expires-in)
  (:documentation "Generates a token for a user"))

(defmethod generate-token ((user user) purpose &key (expires-in 3600))
    (unless (member purpose +token-purposes+ :test #'string=)
      (error "Invalid token purpose: ~A. Allowed: ~A" purpose +token-purposes+))

    (let* ((salt (ironclad:make-random-salt 16))
           (expires-at (truncate (+ (get-universal-time) expires-in)))
           (base-string (format nil "~A~A~A" (username user) expires-at salt))
           (hash (ironclad:byte-array-to-hex-string (ironclad:digest-sequence :sha256 (babel:string-to-octets base-string)))))
        (create-dao 'token :user user :purpose purpose :token hash :salt salt :expires-at expires-at)))

Migrations

We know from a previous tutorial that when we are setting up and application of have changed the structures of the models we need to migrate them, we have seen that mito has the ensure-table-exists and migrate-table functions, so we must write a migration file.

Creating tables

As a reminder on how to create the tables for our four models.

1
2
3
4
(mito:ensure-table-exists 'ningle-auth/models:user)
(mito:ensure-table-exists 'ningle-auth/models:role)
(mito:ensure-table-exists 'ningle-auth/models:permission)
(mito:ensure-table-exists 'ningle-auth/models:token)

Migrating tables

Migrating an existing table is similarly easy.

1
2
3
4
(mito:migrate-table 'ningle-auth/models:user)
(mito:migrate-table 'ningle-auth/models:role)
(mito:migrate-table 'ningle-auth/models:permission)
(mito:migrate-table 'ningle-auth/models:token)

Initial object creation

If we have some objects we want to create as part of our migration, in our case creating “user” and “admin” roles, we might want to write something like the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
(defpackage ningle-auth/migrations
  (:use :cl :mito)
  (:export #:migrate))

(in-package :ningle-auth/migrations)

(defun migrate ()
  "Explicitly apply migrations when called."
  (format t "Applying migrations...~%")
  (mito:ensure-table-exists 'ningle-auth/models:user)
  (mito:ensure-table-exists 'ningle-auth/models:role)
  (mito:ensure-table-exists 'ningle-auth/models:permission)
  (mito:ensure-table-exists 'ningle-auth/models:token)
  (mito:migrate-table 'ningle-auth/models:user)
  (mito:migrate-table 'ningle-auth/models:role)
  (mito:migrate-table 'ningle-auth/models:permission)
  (mito:migrate-table 'ningle-auth/models:token)

  (let ((admin-role (find-dao 'ningle-auth/models:role :name "admin")))
    (unless admin-role
      (create-dao 'ningle-auth/models:role :name "admin" :description "Admin")))

  (let ((user-role (find-dao 'ningle-auth/models:role :name "user")))
    (unless user-role
      (create-dao 'ningle-auth/models:role :name "user" :description "User")))

  (format t "Migrations complete.~%"))

You might notice at no point we establish a database connection to run this migration, don’t worry, we will come to that a little bit later, this migration function is assumed to be run inside a context where a database has already been established. This will come in handy if we had many applications that needed to be migrated, each migration wont be connecting and disconnecting, there’s one connection established, and all migrations run inside that connection.

Full Listing

Create src/migrations.lisp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(defpackage ningle-auth/migrations
  (:use :cl :mito)
  (:export #:migrate))

(in-package :ningle-auth/migrations)

(defun migrate ()
  "Explicitly apply migrations when called."
  (format t "Applying migrations...~%")
  (mito:ensure-table-exists 'ningle-auth/models:user)
  (mito:ensure-table-exists 'ningle-auth/models:role)
  (mito:ensure-table-exists 'ningle-auth/models:permission)
  (mito:ensure-table-exists 'ningle-auth/models:token)
  (mito:migrate-table 'ningle-auth/models:user)
  (mito:migrate-table 'ningle-auth/models:role)
  (mito:migrate-table 'ningle-auth/models:permission)
  (mito:migrate-table 'ningle-auth/models:token)
  (create-dao 'ningle-auth/models:role :name "admin" :description "Admin")
  (create-dao 'ningle-auth/models:role :name "user" :description "User")
  (format t "Migrations complete.~%"))

Main

The “main” event, so to speak! Most of our logic will go in here, remember however that our main project will set up the configuration and we will need a way to pass this down into applications it uses. There is a package I created for managing user objects in the http session called cu-sith, we will use that in our application here. We also use envy-ningle which adds some functions around envy to help build middleware etc.

So, before we work on the controllers, ensure you have downloaded cu-sith to your local package registry and once you have, add it to the dependencies in the application asd file, the full dependencies are shown here:

:depends-on (:cl-dotenv
             :clack
             :djula
             :cl-forms
             :cl-forms.djula
             :cl-forms.ningle
             :envy-ningle
             :mito
             :ningle
             :local-time
             :cu-sith)

Once you have your dependencies in place, we can look at what we will initially change from last time. We have already spoken about removing the delete controller, which leaves us with six controllers to write.

Initial Setup

We began our authentication application last time with this beginning:

(defpackage ningle-auth
  (:use :cl)
  (:export #:*app*
           #:start
           #:stop))

(in-package ningle-auth)

(defvar *app* (make-instance 'ningle:app))

(djula:add-template-directory (asdf:system-relative-pathname :ningle-auth "src/templates/"))

We will now begin adding some config, the application cu-sith that we added as a dependency is used to help manage the session, we need to provide it with a way to look up a user object and how to get a list of the permissions assigned to the user.

(cu-sith:setup
    :user-p (lambda (username) (mito:find-dao 'ningle-auth/models:user :username username :active 1))
    :user-roles (lambda (user) (mito:select-dao 'ningle-auth/models:permission (where (:= :user_id (mito:object-id user))))))

We set up two lambda functions:

(lambda (username) (mito:find-dao 'ningle-auth/models:user :username username :active 1))

This one will, given a username (a string) will use the mito orm to look up our user object, finding the object that matches the username and is also active (remember that the active column is used to determine if a user account is valid to use). Any time the application needs to find out if a user is logged in, this lambda function will be called.

(lambda (user) (mito:select-dao 'ningle-auth/models:permission (where (:= :user_id (mito:object-id user)))))

This lambda function is used to get the permissions a logged in user has. We will want to check this regularly as a users permissions may change, and it would be poor security to continue to allow a user to perform an action they no longer had the permission for. It takes a user object, and then returns a list of permission object where the user id matches the user passed in. Cu-sith tries to be un-opinionated and doesn’t assume any structure about the way a user object or permissions are loaded, and in fact, because we define our own models here, cu-sith couldn’t possibly have known what our models are or how to use them, which is why we have to provide these functions.

cu-sith stores these lambda functions and runs them at key points in the application run time. Our authentication system can set these up and our project (ningle-tutorial-project) can make calls to cu-sith and everything will work together.

With this initial setup done, we can look at the individual controllers now!

Register

While we looked at a version of the register controller previously, it has changed to a degree so we shall go through the process of writing this again.

As with any controller, we must bind it to our application, we know from our previous work that we bind a lambda, because we must also render a register form and submit data, the :methods that we ought to support are :GET and :POST:

(setf (ningle:route *app* "/register" :method '(:GET :POST))
    (lambda (params)
    ...))

Since we know we need to render both a :GET response and a :POST response, we can write a simple if expression, however, both branches will need to access the register form object, our :GET branch will simply render it, our :POST branch will read and validate data, we will look at the if branch first before looking at the else branch:

(let ((form (cl-forms:find-form 'register)))
    (if (string= "GET" (lack.request:request-method ningle:*request*))
        (djula:render-template* "ningle-auth/register.html" nil :title "Register" :form form)
        ...))

We first load the form object, and if the http request type is :GET we use djula to render a register template passing in the blank form, however if the http request type is :POST we will want to do a lot more. We will start with a handler-case, run progn which could potentially throw some errors.

(handler-case
    (progn
        ...)
        
    (error (err)
        (djula:render-template* "error.html" nil :title "Error" :error err))
        
    (simple-error (csrf-error)
        (setf (lack.response:response-status ningle:*response*) 403)
        (djula:render-template* "error.html" nil :title "Error" :error csrf-error)))

There could be a csrf-error in which case we want to set the http response code to 403 and render an error template, with some sort of error displayed, however there may be other types of error we don’t have specific error types for, such as the user entered two different passwords (thus they don’t match) or they tried to register an account with a username or email address that already exists. We will in fact those exact situations into the progn!

(progn
    (cl-forms:handle-request form) ; Can throw an error if CSRF fails

    (multiple-value-bind (valid errors)
        (cl-forms:validate-form form)

        (when errors
            (format t "Errors: ~A~%" errors))

        (when valid
            (cl-forms:with-form-field-values (email username password password-verify) form
                (when (mito:select-dao 'ningle-auth/models:user
                                        (where (:or (:= :username username)
                                                    (:= :email email))))
                    (error "Either username or email is already registered"))

                (when (string/= password password-verify)
                    (error "Passwords do not match"))

                (let* ((user (mito:create-dao 'ningle-auth/models:user :email email :username username :password password))
                       (token (ningle-auth/models:generate-token user ningle-auth/models:+email-verification+)))
                    (format t "Reset url: ~A~A/verify?user=~A&token=~A~%"
                        (format nil "http://~A:~A" (lack/request:request-server-name ningle:*request*) (lack/request:request-server-port ningle:*request*))
                        (envy-ningle:get-config :auth-mount-path)
                        (ningle-auth/models:username user)
                        (ningle-auth/models:token-value token))
                    (ingle:redirect "/")))))

We start by handling the request of the form, which can throw a csrf error (handled in the handler-case as described above), but assuming the form is able to pass the security checks we must then validate the form (with the validators we wrote on them). When there are errors we shall simply display them by using format to display them in the running terminal.

If however the form is valid, we can continue to process the form as the data is both secure and valid (although that doesn’t mean we’re ready to accept it yet!) we then want to grab the field values with (cl-forms:with-form-field-values ...) we will grab the email, username, password, and password-verify values from the form.

Using:

(when (mito:select-dao 'ningle-auth/models:user
        (where (:or (:= :username username)
                    (:= :email email))))
    (error "Either username or email is already registered"))```

We check the username and email values to ensure no user object can be found with either of them, if a user can be found we signal an error.

Likewise with the following:

(when (string/= password password-verify)
    (error "Passwords do not match"))

If the password and password-verify do not match, we will signal an error again.

Finally, if none of our error conditions have triggered, we can begin to process the data. The following eight lines, do the heavy lifting for us.

1
2
3
4
5
6
7
8
(let* ((user (mito:create-dao 'ningle-auth/models:user :email email :username username :password password))
       (token (ningle-auth/models:generate-token user ningle-auth/models:+email-verification+)))
    (format t "Reset url: ~A~A/verify?user=~A&token=~A~%"
        (format nil "http://~A:~A" (lack/request:request-server-name ningle:*request*) (lack/request:request-server-port ningle:*request*))
        (envy-ningle:get-config :auth-mount-path)
        (ningle-auth/models:username user)
        (ningle-auth/models:token-value token))
    (ingle:redirect "/"))

Using a let* binding we create a user object (notice that the active flag is NOT set, as we want users to complete a login flow), and a token object (of the type +email-verification+), once both of these objects are created we simply build up the url that a user needs to click to take them to form that will activate the user, while we are printing this out to the terminal right now, it is intended that these will be emailed out. Lines 3-7 build and print this url, and finally, once that is done, the controller redirects the browser to the “/” route.

Full Listing
(setf (ningle:route *app* "/register" :method '(:GET :POST))
    (lambda (params)
        (let ((form (cl-forms:find-form 'register)))
          (if (string= "GET" (lack.request:request-method ningle:*request*))
            (djula:render-template* "ningle-auth/register.html" nil :title "Register" :form form)
            (handler-case
                (progn
                    (cl-forms:handle-request form) ; Can throw an error if CSRF fails
                    (multiple-value-bind (valid errors)
                        (cl-forms:validate-form form)

                      (when errors
                        (format t "Errors: ~A~%" errors))

                      (when valid
                        (cl-forms:with-form-field-values (email username password password-verify) form
                          (when (mito:select-dao 'ningle-auth/models:user
                                 (where (:or (:= :username username)
                                             (:= :email email))))
                            (error "Either username or email is already registered"))

                          (when (string/= password password-verify)
                            (error "Passwords do not match"))

                          (let* ((user (mito:create-dao 'ningle-auth/models:user :email email :username username :password password))
                                 (token (ningle-auth/models:generate-token user ningle-auth/models:+email-verification+)))
                            (format t "Reset url: ~A~A/verify?user=~A&token=~A~%"
                                            (format nil "http://~A:~A" (lack/request:request-server-name ningle:*request*) (lack/request:request-server-port ningle:*request*))
                                            (envy-ningle:get-config :auth-mount-path)
                                            (ningle-auth/models:username user)
                                            (ningle-auth/models:token-value token))
                            (ingle:redirect "/"))))))

                (error (err)
                    (djula:render-template* "error.html" nil :title "Error" :error err))

                (simple-error (csrf-error)
                    (setf (lack.response:response-status ningle:*response*) 403)
                    (djula:render-template* "error.html" nil :title "Error" :error csrf-error)))))))

Verify

To verify our user after initial user registration we must activate the user securely, we start with the usual setup:

(setf (ningle:route *app* "/register" :method '(:GET :POST))
    (lambda (params)
    ...))

Since we are passing a user and token as Query parameters we will immediately extract these in a let* and since we have multiple conditions to check we will use a cond.

(let* ((user (mito:find-dao 'ningle-auth/models:user :username (cdr (assoc "user" params :test #'string=))))
       (token (mito:find-dao 'ningle-auth/models:token :user user :purpose ningle-auth/models:+email-verification+ :token (cdr (assoc "token" params :test #'string=)))))
    (cond
        ...)

There are four conditions to manager inside this cond, the first is to check if the user is logged in, then redirect if they are.

((cu-sith:logged-in-p)
    (ingle:redirect "/"))

The second condition is when there is a token, but it has expired, we will delete the existing token and issue a new one, printing out the new url and rendering the verification template.

((and token (ningle-auth/models:is-expired-p token))
    (mito:delete-dao token)
    (let ((new-token (ningle-auth/models:generate-token user ningle-auth/models:+email-verification+)))
        (format t "Token ~A expired, issuing new token: ~A~A/verify?user=~A&token=~A~%"
            (format nil "http://~A:~A" (lack/request:request-server-name ningle:*request*) (lack/request:request-server-port ningle:*request*))
            (envy-ningle:get-config :auth-mount-path)
            (ningle-auth/models:token-value token)
            (ningle-auth/models:username user)
            (ningle-auth/models:token-value new-token)))
        (djula:render-template* "ningle-auth/verify.html" nil :title "Verify" :token-reissued t))

The third condition is when no token exists, an error message is rendered to the error template.

((not token)
    (format t "Token ~A does not exist~%" (cdr (assoc "token" params :test #'string=)))
    (djula:render-template* "error.html" nil :title "Error" :error "Token not valid"))

Finally, we can activate the user by first deleting the verification token, creating the permissions to be associated with the user account, set the user as active and save them. The browser will then redirect to the "/login" route.

(t
    (mito:delete-dao token)
    (mito:create-dao 'ningle-auth/models:permission :user user :role (mito:find-dao 'ningle-auth/models:role :name "user"))
    (ningle-auth/models:activate user)
    (mito:save-dao user)
    (format t "User ~A activated!~%" (ningle-auth/models:username user))
    (ingle:redirect (concatenate 'string (envy-ningle:get-config :auth-mount-path) "/login")))
Full Listing
(setf (ningle:route *app* "/verify")
    (lambda (params)
      (let* ((user (mito:find-dao 'ningle-auth/models:user :username (cdr (assoc "user" params :test #'string=))))
             (token (mito:find-dao 'ningle-auth/models:token :user user :purpose ningle-auth/models:+email-verification+ :token (cdr (assoc "token" params :test #'string=)))))
        (cond
          ((cu-sith:logged-in-p)
            (ingle:redirect "/"))

          ((and token (ningle-auth/models:is-expired-p token))
            (mito:delete-dao token)
            (let ((new-token (ningle-auth/models:generate-token user ningle-auth/models:+email-verification+)))
                (format t "Token ~A expired, issuing new token: ~A~A/verify?user=~A&token=~A~%"
                    (format nil "http://~A:~A" (lack/request:request-server-name ningle:*request*) (lack/request:request-server-port ningle:*request*))
                    (envy-ningle:get-config :auth-mount-path)
                    (ningle-auth/models:token-value token)
                    (ningle-auth/models:username user)
                    (ningle-auth/models:token-value new-token)))
            (djula:render-template* "ningle-auth/verify.html" nil :title "Verify" :token-reissued t))

          ((not token)
            (format t "Token ~A does not exist~%" (cdr (assoc "token" params :test #'string=)))
            (djula:render-template* "error.html" nil :title "Error" :error "Token not valid"))

          (t
            (mito:delete-dao token)
            (mito:create-dao 'ningle-auth/models:permission :user user :role (mito:find-dao 'ningle-auth/models:role :name "user"))
            (ningle-auth/models:activate user)
            (mito:save-dao user)
            (format t "User ~A activated!~%" (ningle-auth/models:username user))
            (ingle:redirect (concatenate 'string (envy-ningle:get-config :auth-mount-path) "/login")))))))

Login

As always, let’s prepare the controller!

(setf (ningle:route *app* "/login" :method '(:GET :POST))
    (lambda (params)
    ...))

Immediately inside it, we will use let to grab the login form, we will then use a cond to handle the three conditions we described above, we have seen above how to handle the redirect case, so we will just include it now.

(let ((form (cl-forms:find-form 'login)))
    (cond
        ((cu-sith:logged-in-p)
            (ingle:redirect "/"))
        ...))

Now, to render the form for a user to fill in (the GET request), you will notice that we pass in a new parameter url, this is the url that will be used to allow a user to click a “forgotten password” link, but of course since this application can’t know anything about where it is mounted we both have to look up from the envy-ningle package what the mount path is (we will look at the settings towards the end of this chapter when we integrate the app into our project), and pass the the result of concatenate with the mount path and /reset, since we mount this on /auth the result should be /auth/reset.

((string= "GET" (lack.request:request-method ningle:*request*))
    (djula:render-template* "ningle-auth/login.html" nil :form form :url (concatenate 'string (envy-ningle:get-config :auth-mount-path) "/reset")))

Finally, when the form is submitted (the POST request). We will start by using a handler-case (as we have done before) and immediately open a progn and use the cl-forms:handle-request to handle our form. There’s three errors to handle, two come from the cu-sith package, the invalid-user and invalid-password errors, the third is a standard csrf error that we have used before.

(t
    (handler-case
        (progn
            (cl-forms:handle-request form) ; Can throw an error if CSRF fails

            ...)

        (cu-sith:invalid-user (err)
            (djula:render-template* "error.html" nil :title "Error" :error (format nil "~A, have you verified the account?" (cu-sith:msg err))))

        (cu-sith:invalid-password (err)
            (djula:render-template* "error.html" nil :title "Error" :error (cu-sith:msg err)))

        (simple-error (csrf-error)
            (setf (lack.response:response-status ningle:*response*) 403)
            (djula:render-template* "error.html" nil :title "Error" :error csrf-error))))

We can see that if the invalid-user error is signalled, it might be that there is no such user, or that the user is not yet active, either way, the user isn’t permitted to log in, and is invalid, in which case rendering the error template with a relevent message is the most helpful thing to do.

The invalid-password is pretty obvious, the user exists but the password is incorrect, we handle it by rendering the error template.

Finally, as before, if the csrf error is triggered, we use the same handling logic we wrote previously in other controllers.

The rest of the login logic is quite short, within the handler-case and under the call to cl-forms:handle-request we can add the following:

(multiple-value-bind (valid errors)
    (cl-forms:validate-form form)

    (when errors
        (format t "Errors: ~A~%" errors))

    (when valid
        (cl-forms:with-form-field-values (username password) form
            (cu-sith:login :user username :password password)
            (ingle:redirect (envy-ningle:get-config :login-redirect)))))

We bind the valid and errors using the multiple-value-bind (as we have done before), if there are errors print them to the terminal, and if the form is valid we use cl-forms:with-form-field-values (again, similarly to before), capturing the username and password, we use the cu-sith:login function with the username and password, the login function can signal the invalid-user or invalid-password that we wrote handlers for above. So either a user will be logged in and saved to the session and the browser will be redirected to a url looked up from settings (we will look at that later), or an error will be signalled which we handle.

Full Listing
(setf (ningle:route *app* "/login" :method '(:GET :POST))
    (lambda (params)
        (let ((form (cl-forms:find-form 'login)))
          (cond
            ((cu-sith:logged-in-p)
                (ingle:redirect "/"))

            ((string= "GET" (lack.request:request-method ningle:*request*))
                (djula:render-template* "ningle-auth/login.html" nil :form form :url (concatenate 'string (envy-ningle:get-config :auth-mount-path) "/reset")))

            (t
                (handler-case
                    (progn
                        (cl-forms:handle-request form) ; Can throw an error if CSRF fails

                        (multiple-value-bind (valid errors)
                            (cl-forms:validate-form form)

                          (when errors
                            (format t "Errors: ~A~%" errors))

                          (when valid
                            (cl-forms:with-form-field-values (username password) form
                                (cu-sith:login :user username :password password)
                                (ingle:redirect (envy-ningle:get-config :login-redirect))))))

                    (cu-sith:invalid-user (err)
                        (djula:render-template* "error.html" nil :title "Error" :error (format nil "~A, have you verified the account?" (cu-sith:msg err))))

                    (cu-sith:invalid-password (err)
                        (djula:render-template* "error.html" nil :title "Error" :error (cu-sith:msg err)))

                    (simple-error (csrf-error)
                        (setf (lack.response:response-status ningle:*response*) 403)
                        (djula:render-template* "error.html" nil :title "Error" :error csrf-error))))))))

Logout

You may be pleased to know that the logout controller is much, much simpler, all we need to is use cu-sith to log a user out.

(setf (ningle:route *app* "/logout" :method '(:GET :POST))
    (lambda (params)
        (cu-sith:logout)
        (ingle:redirect (envy-ningle:get-config :login-redirect))))

cu-sith:logout doesn’t signal any errors, all it does is remove a user and their permissions from the active session. Our controller then just redirects the browser.

Reset

The password reset process is a fair amount of code, however we have seen a decent amount of it already, certainly concerning the route, the lambda, grabbing a form and setting up a cond and handling redirecting the user if they are already logged in. So we will skip over aspects we have already seen before and setup the controller ready to add in the real logic. Lines 5-6 show the redirect, lines 8-9 show the rendering of the template with the form, and of course we have a handler-case in the cond where our logic goes.

Line 24 is where we will pick up the new material.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
(setf (ningle:route *app* "/reset" :method '(:GET :POST))
    (lambda (params)
        (let ((form (cl-forms:find-form 'reset-password)))
            (cond
              ((cu-sith:logged-in-p)
                (ingle:redirect "/"))

              ((string= "GET" (lack.request:request-method ningle:*request*))
                (djula:render-template* "ningle-auth/reset.html" nil :title "Reset GET" :form form))

              (t
                (handler-case
                    (progn
                        (cl-forms:handle-request form) ; Can throw an error if CSRF fails

                        (multiple-value-bind (valid errors)
                            (cl-forms:validate-form form)

                          (when errors
                            (format t "Errors: ~A~%" errors))

                          (when valid
                            (cl-forms:with-form-field-values (email) form
                                ...))))

                    (simple-error (csrf-error)
                        (setf (lack.response:response-status ningle:*response*) 403)
                        (djula:render-template* "error.html" nil :title "Error" :error csrf-error))))))))

We will start with a let* binding a user and token object, there may not always be a token, but there may be, within the let* we set up a cond with the four conditions we need to be aware of.

Our first check will check if there’s a user, a token, and the token has not expired, and if this condition is met, a warning about an active password reset in progress message is rendered in the error template.

(let* ((user (mito:find-dao 'ningle-auth/models:user :email email))
       (token (mito:find-dao 'ningle-auth/models:token :user user :purpose ningle-auth/models:+password-reset+)))
    (cond
        ((and user token (not (ningle-auth/models:is-expired-p token)))
            (djula:render-template* "error.html" nil :title "Error" :error "There is already a password reset in progress, either continue or wait a while before retrying"))

        ...))

The next check is if there’s a user and a token (implied to have expired since the check above checked the token wasn’t expired), if so, the token will be deleted, a new one created and a new url printed to the terminal, then the browser will be redirected. This follows a similar pattern for validating our user, which is fortunate, as much of this will be familiar.

((and user token)
    (mito:delete-dao token)
    (let ((token (ningle-auth/models:generate-token user ningle-auth/models:+password-reset+)))
        (format t "Reset url: ~A~A/reset/process?user=~A&token=~A~%"
            (format nil "http://~A:~A" (lack/request:request-server-name ningle:*request*) (lack/request:request-server-port ningle:*request*))
            (envy-ningle:get-config :auth-mount-path)
            (ningle-auth/models:username user)
            (ningle-auth/models:token-value token)))
        (ingle:redirect "/"))

If there is only a user object (that is to say, no active token), the logic is similar to the check above, with the exception that there’s no token to delete.

(user
    (let ((token (ningle-auth/models:generate-token user ningle-auth/models:+password-reset+)))
        (format t "Reset url: ~A~A/reset/process?user=~A&token=~A~%"
            (format nil "http://~A:~A" (lack/request:request-server-name ningle:*request*) (lack/request:request-server-port ningle:*request*))
            (envy-ningle:get-config :auth-mount-path)
            (ningle-auth/models:username user)
            (ningle-auth/models:token-value token)))
        (ingle:redirect "/"))

Finally, if no user could be found, we should display an error:

(t
    (djula:render-template* "error.html" nil :title "Error" :error "No user found"))
Full Listing
(setf (ningle:route *app* "/reset" :method '(:GET :POST))
    (lambda (params)
        (let ((form (cl-forms:find-form 'reset-password)))
            (cond
              ((cu-sith:logged-in-p)
                (ingle:redirect "/"))

              ((string= "GET" (lack.request:request-method ningle:*request*))
                (djula:render-template* "ningle-auth/reset.html" nil :title "Reset GET" :form form))

              (t
                (handler-case
                    (progn
                        (cl-forms:handle-request form) ; Can throw an error if CSRF fails

                        (multiple-value-bind (valid errors)
                            (cl-forms:validate-form form)

                          (when errors
                            (format t "Errors: ~A~%" errors))

                          (when valid
                            (cl-forms:with-form-field-values (email) form
                                (let* ((user (mito:find-dao 'ningle-auth/models:user :email email))
                                       (token (mito:find-dao 'ningle-auth/models:token :user user :purpose ningle-auth/models:+password-reset+)))
                                  (cond
                                    ((and user token (not (ningle-auth/models:is-expired-p token)))
                                        (djula:render-template* "error.html" nil :title "Error" :error "There is already a password reset in progress, either continue or wait a while before retrying"))

                                    ((and user token)
                                        (mito:delete-dao token)
                                        (let ((token (ningle-auth/models:generate-token user ningle-auth/models:+password-reset+)))
                                          (format t "Reset url: ~A~A/reset/process?user=~A&token=~A~%"
                                            (format nil "http://~A:~A" (lack/request:request-server-name ningle:*request*) (lack/request:request-server-port ningle:*request*))
                                            (envy-ningle:get-config :auth-mount-path)
                                            (ningle-auth/models:username user)
                                            (ningle-auth/models:token-value token)))
                                        (ingle:redirect "/"))

                                    (user
                                        (let ((token (ningle-auth/models:generate-token user ningle-auth/models:+password-reset+)))
                                          (format t "Reset url: ~A~A/reset/process?user=~A&token=~A~%"
                                            (format nil "http://~A:~A" (lack/request:request-server-name ningle:*request*) (lack/request:request-server-port ningle:*request*))
                                            (envy-ningle:get-config :auth-mount-path)
                                            (ningle-auth/models:username user)
                                            (ningle-auth/models:token-value token)))
                                        (ingle:redirect "/"))

                                    (t
                                     (djula:render-template* "error.html" nil :title "Error" :error "No user found"))))))))

                    (simple-error (csrf-error)
                        (setf (lack.response:response-status ningle:*response*) 403)
                        (djula:render-template* "error.html" nil :title "Error" :error csrf-error))))))))

Reset/Process

Now that a reset url is generated, we need a controller to handle the actual changing of the password, as before we set a route and a handler, but what we will immediately do is grab the form, the user, and the token, with that done we will use a cond to handle the different cases we need to handle. We have seen before that the first condition is to redirect away if there is a logged in user, so it’s included immediately below.

(setf (ningle:route *app* "/reset/process" :method '(:GET :POST))
      (lambda (params)
        (let* ((form (cl-forms:find-form 'new-password))
               (user (mito:find-dao 'ningle-auth/models:user :username (cdr (assoc "user" params :test #'string=))))
               (token (mito:find-dao 'ningle-auth/models:token :user user :purpose ningle-auth/models:+password-reset+ :token (cdr (assoc "token" params :test #'string=)))))
          (cond
            ((cu-sith:logged-in-p)
                (ingle:redirect "/"))

              ...))))

The next condition is if the token is invalid, where invalid is defined as not existing, or having expired. In this instance, the error template will be rendered by djula informing the user that the token is invalid.

((and (string= "GET" (lack.request:request-method ningle:*request*)) (or (not token) (ningle-auth/models:is-expired-p token)))
    (djula:render-template* "error.html" nil :title "Error" :error "Invalid reset token, please try again"))

Now our third condition concerns itself with rendering the form ready for a user to fill in, as discussed in the forms section, the email and token fields need to be populated so that they’re included in the complete POST request body, we then render the form.

((and (string= "GET" (lack.request:request-method ningle:*request*)) token)
    (cl-forms:set-field-value form 'ningle-auth/forms:email (ningle-auth/models:email user))
    (cl-forms:set-field-value form 'ningle-auth/forms:token (ningle-auth/models:token-value token))
    (djula:render-template* "ningle-auth/reset.html" nil :title "Create a new password" :form form))

The final condition is processing the form and is our fall through case or t (as we have seen many times before already). The pattern which has emerged is to have a handler-case with a progn inside it and handle, certainly the csrf token error (if it occurs) and any other errors, in this case we will only need to check that passwords do not match. Again, there’s some boiler plate code we are using, such as cl-forms:handle-request and binding valid and errors and checking for each. Inside our (when valid ...) is where the main logic goes.

(t
    (handler-case
        (progn
            (cl-forms:handle-request form) ; Can throw an error if CSRF fails
            (multiple-value-bind (valid errors)
                (cl-forms:validate-form form)

                (when errors
                    (format t "Errors: ~A~%" errors))
                    
                (when valid
                    ...)))

        (error (err)
            (djula:render-template* "error.html" nil :title "Error" :error err))

        (simple-error (csrf-error)
            (setf (lack.response:response-status ningle:*response*) 403)
            (djula:render-template* "error.html" nil :title "Error" :error csrf-error))))

As with in previous form logic, we need to get the field values from cl-forms, and if the two passwords do not match, an error will be signalled, which we handle in the code above.

(cl-forms:with-form-field-values (email token password password-verify) form
    (when (string/= password password-verify)
        (error "Passwords do not match"))
        
    ...)

If no error is signalled then, we can assume that we are able to go ahead and update the user object. We start by opening a let* block to capture the user and token. If the user exists we will process the update, and if there is no user render a template to inform the browser that there is no such user.

(let* ((user (mito:find-dao 'ningle-auth/models:user :email email))
       (token (mito:find-dao 'ningle-auth/models:token :user user :token token :purpose ningle-auth/models:+password-reset+)))
    (if user
        (progn
            (setf (mito-auth:password user) password)
            (mito:save-dao user)
            (mito:delete-dao token)
            (ingle:redirect (concatenate 'string (envy-ningle:get-config :auth-mount-path) "/login")))
        (djula:render-template* "error.html" nil :title "Error" :error "No user found")))

In the logic for updating the user, the password is set, the user is saved, the token is deleted and the browser is redirected to the login route.

Full Listing

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
(defpackage ningle-auth
  (:use :cl :sxql :ningle-auth/forms)
  (:export #:*app*
           #:start
           #:stop))

(in-package ningle-auth)

(defvar *app* (make-instance 'ningle:app))

(djula:add-template-directory (asdf:system-relative-pathname :ningle-auth "src/templates/"))

(cu-sith:setup
    :user-p (lambda (username) (mito:find-dao 'ningle-auth/models:user :username username :active 1))
    :user-roles (lambda (user) (mito:select-dao 'ningle-auth/models:permission (where (:= :user_id (mito:object-id user))))))

(setf (ningle:route *app* "/register" :method '(:GET :POST))
    (lambda (params)
        (let ((form (cl-forms:find-form 'register)))
          (if (string= "GET" (lack.request:request-method ningle:*request*))
            (djula:render-template* "ningle-auth/register.html" nil :title "Register" :form form)
            (handler-case
                (progn
                    (cl-forms:handle-request form) ; Can throw an error if CSRF fails
                    (multiple-value-bind (valid errors)
                        (cl-forms:validate-form form)

                      (when errors
                        (format t "Errors: ~A~%" errors))

                      (when valid
                        (cl-forms:with-form-field-values (email username password password-verify) form
                          (when (mito:select-dao 'ningle-auth/models:user
                                 (where (:or (:= :username username)
                                             (:= :email email))))
                            (error "Either username or email is already registered"))

                          (when (string/= password password-verify)
                            (error "Passwords do not match"))

                          (let* ((user (mito:create-dao 'ningle-auth/models:user :email email :username username :password password))
                                 (token (ningle-auth/models:generate-token user ningle-auth/models:+email-verification+)))
                            (format t "Reset url: ~A~A/verify?user=~A&token=~A~%"
                                            (format nil "http://~A:~A" (lack/request:request-server-name ningle:*request*) (lack/request:request-server-port ningle:*request*))
                                            (envy-ningle:get-config :auth-mount-path)
                                            (ningle-auth/models:username user)
                                            (ningle-auth/models:token-value token))
                            (ingle:redirect "/"))))))

                (error (err)
                    (djula:render-template* "error.html" nil :title "Error" :error err))

                (simple-error (csrf-error)
                    (setf (lack.response:response-status ningle:*response*) 403)
                    (djula:render-template* "error.html" nil :title "Error" :error csrf-error)))))))

;; Must be logged out
(setf (ningle:route *app* "/login" :method '(:GET :POST))
    (lambda (params)
        (let ((form (cl-forms:find-form 'login)))
          (cond
            ((cu-sith:logged-in-p)
                (ingle:redirect "/"))

            ((string= "GET" (lack.request:request-method ningle:*request*))
                (djula:render-template* "ningle-auth/login.html" nil :form form :url (concatenate 'string (envy-ningle:get-config :auth-mount-path) "/reset")))

            (t
                (handler-case
                    (progn
                        (cl-forms:handle-request form) ; Can throw an error if CSRF fails

                        (multiple-value-bind (valid errors)
                            (cl-forms:validate-form form)

                          (when errors
                            (format t "Errors: ~A~%" errors))

                          (when valid
                            (cl-forms:with-form-field-values (username password) form
                                (cu-sith:login :user username :password password)
                                (ingle:redirect (envy-ningle:get-config :login-redirect))))))

                    (cu-sith:invalid-user (err)
                        (djula:render-template* "error.html" nil :title "Error" :error (format nil "~A, have you verified the account?" (cu-sith:msg err))))

                    (cu-sith:invalid-password (err)
                        (djula:render-template* "error.html" nil :title "Error" :error (cu-sith:msg err)))

                    (simple-error (csrf-error)
                        (setf (lack.response:response-status ningle:*response*) 403)
                        (djula:render-template* "error.html" nil :title "Error" :error csrf-error))))))))

;; Must be logged in
(setf (ningle:route *app* "/logout" :method '(:GET :POST))
    (lambda (params)
        (cu-sith:logout)
        (ingle:redirect (envy-ningle:get-config :login-redirect))))

;; Must be logged out
(setf (ningle:route *app* "/reset" :method '(:GET :POST))
    (lambda (params)
        (let ((form (cl-forms:find-form 'reset-password)))
            (cond
              ((cu-sith:logged-in-p)
                (ingle:redirect "/"))

              ((string= "GET" (lack.request:request-method ningle:*request*))
                (djula:render-template* "ningle-auth/reset.html" nil :title "Reset GET" :form form))

              (t
                (handler-case
                    (progn
                        (cl-forms:handle-request form) ; Can throw an error if CSRF fails

                        (multiple-value-bind (valid errors)
                            (cl-forms:validate-form form)

                          (when errors
                            (format t "Errors: ~A~%" errors))

                          (when valid
                            (cl-forms:with-form-field-values (email) form
                                (let* ((user (mito:find-dao 'ningle-auth/models:user :email email))
                                       (token (mito:find-dao 'ningle-auth/models:token :user user :purpose ningle-auth/models:+password-reset+)))
                                  (cond
                                    ((and user token (not (ningle-auth/models:is-expired-p token)))
                                        (djula:render-template* "error.html" nil :title "Error" :error "There is already a password reset in progress, either continue or wait a while before retrying"))

                                    ((and user token)
                                        (mito:delete-dao token)
                                        (let ((token (ningle-auth/models:generate-token user ningle-auth/models:+password-reset+)))
                                          (format t "Reset url: ~A~A/reset/process?user=~A&token=~A~%"
                                            (format nil "http://~A:~A" (lack/request:request-server-name ningle:*request*) (lack/request:request-server-port ningle:*request*))
                                            (envy-ningle:get-config :auth-mount-path)
                                            (ningle-auth/models:username user)
                                            (ningle-auth/models:token-value token)))
                                        (ingle:redirect "/"))

                                    (user
                                        (let ((token (ningle-auth/models:generate-token user ningle-auth/models:+password-reset+)))
                                          (format t "Reset url: ~A~A/reset/process?user=~A&token=~A~%"
                                            (format nil "http://~A:~A" (lack/request:request-server-name ningle:*request*) (lack/request:request-server-port ningle:*request*))
                                            (envy-ningle:get-config :auth-mount-path)
                                            (ningle-auth/models:username user)
                                            (ningle-auth/models:token-value token)))
                                        (ingle:redirect "/"))

                                    (t
                                     (djula:render-template* "error.html" nil :title "Error" :error "No user found"))))))))

                    (simple-error (csrf-error)
                        (setf (lack.response:response-status ningle:*response*) 403)
                        (djula:render-template* "error.html" nil :title "Error" :error csrf-error))))))))

(setf (ningle:route *app* "/reset/process" :method '(:GET :POST))
      (lambda (params)
        (let* ((form (cl-forms:find-form 'new-password))
               (user (mito:find-dao 'ningle-auth/models:user :username (cdr (assoc "user" params :test #'string=))))
               (token (mito:find-dao 'ningle-auth/models:token :user user :purpose ningle-auth/models:+password-reset+ :token (cdr (assoc "token" params :test #'string=)))))
          (cond
            ((cu-sith:logged-in-p)
                (ingle:redirect "/"))

            ((and (string= "GET" (lack.request:request-method ningle:*request*)) (or (not token) (ningle-auth/models:is-expired-p token)))
                (djula:render-template* "error.html" nil :title "Error" :error "Invalid reset token, please try again"))

            ((and (string= "GET" (lack.request:request-method ningle:*request*)) token)
                (cl-forms:set-field-value form 'ningle-auth/forms:email (ningle-auth/models:email user))
                (cl-forms:set-field-value form 'ningle-auth/forms:token (ningle-auth/models:token-value token))
                (djula:render-template* "ningle-auth/reset.html" nil :title "Create a new password" :form form))

            (t
                (handler-case
                    (progn
                        (cl-forms:handle-request form) ; Can throw an error if CSRF fails
                        (multiple-value-bind (valid errors)
                            (cl-forms:validate-form form)

                          (when errors
                            (format t "Errors: ~A~%" errors))

                          (when valid
                            (cl-forms:with-form-field-values (email token password password-verify) form
                                (when (string/= password password-verify)
                                    (error "Passwords do not match"))

                                (let* ((user (mito:find-dao 'ningle-auth/models:user :email email))
                                       (token (mito:find-dao 'ningle-auth/models:token :user user :token token :purpose ningle-auth/models:+password-reset+)))
                                  (if user
                                      (progn
                                        (setf (mito-auth:password user) password)
                                        (mito:save-dao user)
                                        (mito:delete-dao token)
                                        (ingle:redirect (concatenate 'string (envy-ningle:get-config :auth-mount-path) "/login")))
                                      (djula:render-template* "error.html" nil :title "Error" :error "No user found")))))))

                    (error (err)
                        (djula:render-template* "error.html" nil :title "Error" :error err))

                    (simple-error (csrf-error)
                        (setf (lack.response:response-status ningle:*response*) 403)
                        (djula:render-template* "error.html" nil :title "Error" :error csrf-error))))))))

;; Must not be fully set up
(setf (ningle:route *app* "/verify")
    (lambda (params)
      (let* ((user (mito:find-dao 'ningle-auth/models:user :username (cdr (assoc "user" params :test #'string=))))
             (token (mito:find-dao 'ningle-auth/models:token :user user :purpose ningle-auth/models:+email-verification+ :token (cdr (assoc "token" params :test #'string=)))))
        (cond
          ((cu-sith:logged-in-p)
            (ingle:redirect "/"))

          ((and token (ningle-auth/models:is-expired-p token))
            (mito:delete-dao token)
            (let ((new-token (ningle-auth/models:generate-token user ningle-auth/models:+email-verification+)))
                (format t "Token ~A expired, issuing new token: ~A~A/verify?user=~A&token=~A~%"
                    (format nil "http://~A:~A" (lack/request:request-server-name ningle:*request*) (lack/request:request-server-port ningle:*request*))
                    (envy-ningle:get-config :auth-mount-path)
                    (ningle-auth/models:token-value token)
                    (ningle-auth/models:username user)
                    (ningle-auth/models:token-value new-token)))

            (djula:render-template* "ningle-auth/verify.html" nil :title "Verify" :token-reissued t))

          ((not token)
            (format t "Token ~A does not exist~%" (cdr (assoc "token" params :test #'string=)))
            (djula:render-template* "error.html" nil :title "Error" :error "Token not valid"))

          (t
            (mito:delete-dao token)
            (mito:create-dao 'ningle-auth/models:permission :user user :role (mito:find-dao 'ningle-auth/models:role :name "user"))
            (ningle-auth/models:activate user)
            (mito:save-dao user)
            (format t "User ~A activated!~%" (ningle-auth/models:username user))
            (ingle:redirect (concatenate 'string (envy-ningle:get-config :auth-mount-path) "/login")))))))

(defmethod ningle:not-found ((app ningle:<app>))
    (declare (ignore app))
    (setf (lack.response:response-status ningle:*response*) 404)
    (djula:render-template* "error.html" nil :title "Error" :error "Not Found"))

(defun start (&key (server :woo) (address "127.0.0.1") (port 8000))
    (djula:add-template-directory (asdf:system-relative-pathname :ningle-auth "src/templates/"))
    (djula:set-static-url "/public/")
    (clack:clackup
     (lack.builder:builder (envy-ningle:build-middleware :ningle-auth/config *app*))
     :server server
     :address address
     :port port))

(defun stop (instance)
    (clack:stop instance))

Templates

Our templates haven’t changed dramatically since last time, but there’s some small changes.

register.html

All we do here is render the form that is passed in from our controller.

1
2
3
4
5
6
7
8
9
10
11
12
{% extends "base.html" %}

{% block content %}
<div class="container">
    <div class="row">
        <div class="col-12">
            <h1>Register for an account</h1>
            {% form form %}
        </div>
    </div>
</div>
{% endblock %}

verify.html

In our verify template we pass in (from our controller) if the token had expired, we use the token-reissued variable that may be passed in to inform the user to expect a new email.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{% extends "base.html" %}

{% block content %}
<div class="container">
    <div class="row">
        <div class="col-12">
            <h1>Your account is almost ready!</h1>
            {% if token-reissued %}
                <p>This token has expired and a new one has been issued and sent to the email address used when registering.</p>
            {% else %}
                <p>Please follow the instructions send to the email used when registering to verify your account!</p>
            {% endif %}
        </div>
    </div>
</div>
{% endblock %}

login.html

In our login template we render our login form, but we also display the url passed in, that allows a user to click to the “forgot password” link.

1
2
3
4
5
6
7
8
9
10
11
12
13
{% extends "base.html" %}

{% block content %}
<div class="container">
    <div class="row">
        <div class="col-12">
            <h1>Login</h1>
            {% form form %}
            <h4><a href="{{ url }}">Forgotten Password?</a></h4>
        </div>
    </div>
</div>
{% endblock %}

reset.html

Our reset template simply renders the form passed into it.

1
2
3
4
5
6
7
8
9
10
11
12
{% extends "base.html" %}

{% block content %}
<div class="container">
    <div class="row">
        <div class="col-12">
            <h1>Reset Password</h1>
            {% form form %}
        </div>
    </div>
</div>
{% endblock %}

Integrating the Authentication App

Initial Clean Up

The following files can be deleted as they have been moved into the authentication app:

Updating project.asd file

Due to removing some old files we will need to update the project asd file, it should be stressed that we will also be adding new files too, so you will see some files we haven’t written (yet) in this updated :components section.

1
2
3
4
5
6
:components
    ((:file "contrib")
     (:file "middleware")
     (:file "config")
     (:file "migrations")
     (:file "main"))

contrib.lisp

While we are still building up our ideal project structure, we have some code that depends on ningle-auth (which we have just written) and may end up somewhere else in the project, ningle-auth may become baked into our project structure going forward, at the moment it’s hard to know how best to manage the following code, so I have contrib-uted some helper code. If a better place for it is found, or we decide to formally bundle things together, we can move it, but for now we will just keep the code here.

In this package we will define a create-super-user function (which depends on the ningle-auth models) and a macro (with-db-connection) to enable code to run that needs to be run in the context of a database connection. We will use the with-db-connection macro in other parts of this project.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
(defpackage ningle-tutorial-project/contrib
  (:use :cl :mito)
  (:export #:create-super-user
           #:with-db-connection))

(in-package :ningle-tutorial-project/contrib)

(defmacro with-db-connection (&body body)
    `(multiple-value-bind (backend args) (envy-ningle:extract-middleware-config :ningle-tutorial-project/config :mito)
        (unless backend
            (error "No MITO backend found for config ~A" cfg))

        (unwind-protect
             (progn
               (apply #'mito:connect-toplevel backend args)
               ,@body
               (mito:disconnect-toplevel)))))

(defun create-super-user (&key username email password)
  (with-db-connection
      (let ((user (create-dao 'ningle-auth/models:user :username username :email email :password password :active 1)))
        (create-dao 'ningle-auth/models:permission :user user :role (find-dao 'ningle-auth/models:role :name "admin"))
        user)))

middleware.lisp

Now, this middleware isn’t, strictly speaking, required, but it will demonstrate another piece of managing security. It’s a little bit more complicated than is ideal, but oh well! We have learned, from previous chapters that middleware runs on each request, cu-sith stores the user and roles in the active session, however if the permissions change, we need to update the session. This piece of middleware does this.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(defpackage :ningle-tutorial-project/middleware
  (:use :cl :sxql :ningle-tutorial-project/contrib)
  (:export #:refresh-roles))

(in-package :ningle-tutorial-project/middleware)

(defun refresh-roles (app)
  (lambda (env)
    (with-db-connection
        (handler-case
            (let ((session (getf env :lack.session)))
              (when (and session (hash-table-p session) (> (hash-table-count session) 0))
                (let ((user (gethash :user session)))
                  (when (typep user 'ningle-auth/models:user)
                    (format t "[refreshing-roles]~%")
                    (let ((roles (mito:select-dao 'ningle-auth/models:permission (where (:= :user user)))))
                      (setf (gethash :roles session) roles)
                      (format t "[refreshed-roles for ~A] result: ~A~%" user roles))))))
          (error (e)
              (format *error-output* "Error refreshing roles: ~A~%" e))))
    (funcall app env)))

We learned from part 3 that middleware is a function that accepts an application object (which is a function itself!) and returns a function that accepts an environment. There’s a nuance, however, middleware has to run in a specific order, for example, this middleware depends on using the session object, so the :session middleware must run first, else this will fail because there’s no session set up for us to use!

We use the with-db-connection macro to ensure we have a database connection, and set up a handler-case, we handle this by capturing any error and displaying to the error-output stream a message, however inside the code to be handled we use a let to get the session object, but, just because we have a session object (a hash-table) it doesn’t mean it has any data in it, so we check that the session object is a hash-table and it has at least one key/value pair in it. If there is, we grab the user object from the session (of course there may not be a user!) and check it is of the type of our model, assuming we have a valid user object we then grab the roles the user can perform and set them into the :roles section of the session object.

As mentioned above this will run on each request, so if the user permissions changed, the session will be updated as the user navigates the web application. Of course it would be more performant to use a cache, or redis or something, but for this demonstration, this is a decent enough example of how to get this working.

config.lisp

We have a small amount of tinkering to do to our settings, including setting up the middleware order as described above. Most of our changes are concerned with mounting our authentication app, however, because it has migrations, we have added some settings for use in the next section (migrations).

The tricky thing is, we want to mount our authentication application on a route, but we also want the authentication application to know where it is mounted (so that its internal links and routing are correct), as a result we want to set a parameter that defines the mount point and is both set explicitly as a named setting and used in the :mount middleware section.

Thus the *auth-mount-path* is used to define this mount path, and in the :common settings block it is set as the named :auth-mount-path and later in the |sqlite| section in the :mount line.

Additionally, you can see on line #19, we add in the refresh-roles middleware we defined in the previous section, do remember that order matters and it must be between the :session middleware and the :mito middleware else it wont work!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
(defpackage ningle-tutorial-project/config
  (:use :cl :envy))
(in-package ningle-tutorial-project/config)

(dotenv:load-env (asdf:system-relative-pathname :ningle-tutorial-project ".env"))
(setf (config-env-var) "APP_ENV")

(defparameter *auth-mount-path* "/auth") ;; add this

(defconfig :common
  `(:application-root ,(asdf:component-pathname (asdf:find-system :ningle-tutorial-project))
    :installed-apps (:ningle-auth)      ;; add this
    :auth-mount-path ,*auth-mount-path* ;; add this
    :login-redirect "/"))               ;; add this

(defconfig |sqlite|
  `(:debug T
    :middleware ((:session)
                 ningle-tutorial-project/middleware:refresh-roles ;; add this
                 (:mito (:sqlite3 :database-name ,(uiop:getenv "SQLITE_DB_NAME")))
                 (:mount ,*auth-mount-path* ,ningle-auth:*app*)   ;; add this
                 (:static :root ,(asdf:system-relative-pathname :ningle-tutorial-project "src/static/") :path "/public/"))))

(defconfig |mysql|
  `(:middleware ((:session)
                 (:mito (:mysql
                         :database-name ,(uiop:native-namestring (uiop:parse-unix-namestring (uiop:getenv "MYSQL_DB_NAME")))
                         :username ,(uiop:getenv "MYSQL_USER")
                         :password ,(uiop:getenv "MYSQL_PASSWORD")
                         :host ,(uiop:getenv "MYSQL_ADDRESS")
                         :port ,(parse-integer (uiop:getenv "MYSQL_PORT"))))
                 (:static :root ,(asdf:system-relative-pathname :ningle-tutorial-project "src/static/") :path "/public/"))))

(defconfig |postgresql|
  `(:middleware ((:session)
                 (:mito (:postgres
                         :database-name ,(uiop:native-namestring (uiop:parse-unix-namestring (uiop:getenv "POSTGRES_DB_NAME")))
                         :username ,(uiop:getenv "POSTGRES_USER")
                         :password ,(uiop:getenv "POSTGRES_PASSWORD")
                         :host ,(uiop:getenv "POSTGRES_ADDRESS")
                         :port ,(parse-integer (uiop:getenv "POSTGRES_PORT"))))
                 (:static :root ,(asdf:system-relative-pathname :ningle-tutorial-project "src/static/") :path "/public/"))))

migrations.lisp

Previously we wrote the migrations in such a way that they established their own database connection and ran their migrations, with two apps however, where one defines the settings, it becomes important to ensure that the other does not need to know. As a result we have redesigned the migrations, each application will define a migrate function, and our project will search through a list of known installed apps to find their migrate function, and it will then run these function inside the with-db-connection. We spoke about this briefly when we rewrote the ningle-auth migrations file, and here we are now!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
(defpackage ningle-tutorial-project/migrations
  (:use :cl :ningle-tutorial-project/contrib)
  (:export #:migrate-apps))

(in-package :ningle-tutorial-project/migrations)

(defun migrate-apps (&optional (apps nil))
  "Run migrate function for each app in APPS list. If APPS is nil, migrate all apps listed in *config* :installed-apps."
  (let ((apps (or apps (getf (envy:config :ningle-tutorial-project/config) :installed-apps))))
    (unless apps
      (error "No apps specified and no :installed-apps found in config."))

    (with-db-connection
        (dolist (app apps)
            (let* ((migrations-pkg-name (string-upcase (format nil "~A/MIGRATIONS" (string-upcase (symbol-name app)))))
                   (migrations-pkg (find-package migrations-pkg-name)))
                (unless migrations-pkg
                    (error "Migrations package ~A not found." migrations-pkg-name))

                ;; Set app-specific config before calling migrate
                (let ((migrate-fn (find-symbol "MIGRATE" migrations-pkg))) ;; Name known to project
                    (unless (and migrate-fn (fboundp migrate-fn))
                        (error "Migrate function not found in package ~A." migrations-pkg-name))
                    (funcall migrate-fn)))))))

We start by defining a migrate-apps function, it can either be passed a list of apps, or it will read the :installed-apps setting that we added in config.lisp, if there are no apps an error is signalled, however, if there are, we, once again, use the with-db-connection macro and loop over the list of apps, getting each package name with a migrations suffix, if there’s no such package an error is signalled.

Assuming the migrations package has been found, an attempt it made to find the migrate function within it (this does mean that each app has to have a migrations package with a migrate function), if this function couldn’t be found an error is signalled, however if it could be, the migration function for that application is called.

main.lisp

Since we removed much of the logic we previously had from here, we removed forms.lisp so we will immediately need to remove the import we had in the defpackage, it should now look like this.

(defpackage ningle-tutorial-project
  (:use :cl :sxql)
  (:export #:start
           #:stop))

Additionally there was a register route, this must be completely removed, which leaves us with only four controllers in this file, including the /profile controller we are yet to write! So let’s look at them one by one.

Route: “/”

While this view has not changed much at all, where we previously hard coded the user, we can now pass a real user from the session into our templates.

(setf (ningle:route *app* "/")
      (lambda (params)
        (let ((user  (gethash :user ningle:*session*)) ;; Change this
              (posts (list (list :author (list :username "Bob")  :content "Experimenting with Dylan" :created-at "2025-01-24 @ 13:34")
                           (list :author (list :username "Jane") :content "Wrote in my diary today"  :created-at "2025-01-24 @ 13:23"))))
          (djula:render-template* "main/index.html" nil :title "Home" :user user :posts posts))))

Route: “/profile”

This is our new controller that will only be accessible if the user is logged in. We can see this works by grabbing the user from the session (using a let) and using a simple if to either render the template if there is a user, or set the http response code to 403 and render the “Unauthorized” error.

(setf (ningle:route *app* "/profile")
      (lambda (params)
        (let ((user (gethash :user ningle:*session*)))
            (if user
                (djula:render-template* "main/profile.html" nil :title "Profile" :user user)
                (progn
                    (setf (lack.response:response-status ningle:*response*) 403)
                    (djula:render-template* "error.html" nil :title "Error" :error "Unauthorized"))))))

Route: “/people”

Again, not much has changed here, the only thing we have done is update the code such that the model is now the ningle-auth, and in the final line, we use cu-sith to pass the logged in user into the template, along with a list of the users registered with the system.

(setf (ningle:route *app* "/people")
      (lambda (params)
        (let ((users (mito:retrieve-dao 'ningle-auth/models:user)))
          (djula:render-template* "main/people.html" nil :title "People" :users users :user (cu-sith:logged-in-p)))))

Route: “/people/:person”

A slight change here is, again, to pass the user pulled from the session into the template, but also because we enabled a user to be looked up by username, or email, we have changed the variables, just for clarity.

(setf (ningle:route *app* "/people/:person")
      (lambda (params)
        (let* ((username-or-email (ingle:get-param :person params))
               (person (first (mito:select-dao
                              'ningle-auth/models:user
                              (where (:or (:= :username username-or-email)
                                          (:= :email username-or-email)))))))
          (djula:render-template* "main/person.html" nil :title "Person" :person person :user (cu-sith:logged-in-p)))))

Full Listing

Putting it all together!

(defpackage ningle-tutorial-project
  (:use :cl :sxql)
  (:export #:start
           #:stop))

(in-package ningle-tutorial-project)

(defvar *app* (make-instance 'ningle:app))

(setf (ningle:route *app* "/")
      (lambda (params)
        (let ((user  (gethash :user ningle:*session*))
              (posts (list (list :author (list :username "Bob")  :content "Experimenting with Dylan" :created-at "2025-01-24 @ 13:34")
                           (list :author (list :username "Jane") :content "Wrote in my diary today"  :created-at "2025-01-24 @ 13:23"))))
          (djula:render-template* "main/index.html" nil :title "Home" :user user :posts posts))))

(setf (ningle:route *app* "/profile")
      (lambda (params)
        (let ((user (gethash :user ningle:*session*)))
            (if user
                (djula:render-template* "main/profile.html" nil :title "Profile" :user user)
                (progn
                    (setf (lack.response:response-status ningle:*response*) 403)
                    (djula:render-template* "error.html" nil :title "Error" :error "Unauthorized"))))))

(setf (ningle:route *app* "/people")
      (lambda (params)
        (let ((users (mito:retrieve-dao 'ningle-auth/models:user)))
          (djula:render-template* "main/people.html" nil :title "People" :users users :user (cu-sith:logged-in-p)))))

(setf (ningle:route *app* "/people/:person")
      (lambda (params)
        (let* ((username-or-email (ingle:get-param :person params))
               (person (first (mito:select-dao
                              'ningle-auth/models:user
                              (where (:or (:= :username username-or-email)
                                          (:= :email username-or-email)))))))
          (djula:render-template* "main/person.html" nil :title "Person" :person person :user (cu-sith:logged-in-p)))))

(defmethod ningle:not-found ((app ningle:<app>))
    (declare (ignore app))
    (setf (lack.response:response-status ningle:*response*) 404)
    (djula:render-template* "error.html" nil :title "Error" :error "Not Found"))

(defun start (&key (server :woo) (address "127.0.0.1") (port 8000))
    (djula:add-template-directory (asdf:system-relative-pathname :ningle-tutorial-project "src/templates/"))
    (djula:set-static-url "/public/")
    (clack:clackup
     (lack.builder:builder (envy-ningle:build-middleware :ningle-tutorial-project/config *app*))
     :server server
     :address address
     :port port))

(defun stop (instance)
    (clack:stop instance))

Templates

Now that our application logic is done, we turn now towards our templates, there’s only three, we need to update the base, our person template, and to write our new profile template.

base.html

In our base.html we will be adapting the upper right of the screen, where we previously had a registration button, we will expand this somewhat to include “register” and “login” if a user is not logged in otherwise a profile link and “logout”.

<div class="d-flex ms-auto">
    {% if user %}
        <a href="/profile" class="btn btn-primary">{{ user.username }}</a>
        &nbsp;|&nbsp;
        <a href="/auth/logout" class="btn btn-secondary">Logout</a>
    {% else %}
        <a href="/auth/register" class="btn btn-primary">Register</a>
        &nbsp;|&nbsp;
        <a href="/auth/login" class="btn btn-success">Login</a>
    {% endif %}
</div>

person.html

Since we have adjusted the data that we pass into the person template, we need to likewise adapt the template to the new data. The reason we have both user and person is that the user is the active logged in user, and the person is the one that is being looked up to view this page, and these are very likely to be different values, unless, you know, you’re Ed Balls.

<div class="col">
    {% if not person %} ;; change 'user' to 'person'
        <h1>No users</h1>
    {% else %}
        <div class="card">
            <div class="card-body">
                <h5 class="card-title">{{ person.username }}</h5> ;; change 'user' to 'person'
                <p class="card-text">{{ person.email }}</p> ;; change 'user' to 'person'
                <p class="text-muted small"></p>
            </div>
        </div>
    {% endif %}
</div>

profile.html

Our new profile template will be real simple, since the check is if it is accessible at all, it really doesn’t have to contain much, at least, right now.

1
2
3
4
5
6
7
8
9
10
11
12
13
{% extends "base.html" %}

{% block content %}
<div class="container">
    <div class="row">
        <div class="col-12 text-center">
            <div class="row">
              <h1>Profile</h1>
            </div>
        </div>
    </div>
</div>
{% endblock %}

CSS

I am by no means a CSS expert, and things aren’t really looking the way I would like them do, I will include what css I have written, although it really is beyond my ability to teach good css!

main.css

form#login input {
    display: block;  /* Ensure inputs take up the full width */
    width: 100% !important; /* Override any conflicting styles */
    max-width: 100%; /* Ensure no unnecessary constraints */
    box-sizing: border-box;
}

form#login input[type="text"],
form#login input[type="password"] {
    @extend .form-control; /* Apply Bootstrap's .form-control */
    display: block; /* Ensure they are block-level elements */
    width: 100%; /* Make the input full width */
    margin-bottom: 1rem; /* Spacing */
}

form#login input[type="submit"] {
    @extend .btn;
    @extend .btn-primary;
    width: 100%;
}

form#login div {
    @extend .mb-3;
}

form#login label {
    @extend .form-label;
    font-weight: bold;
    margin-bottom: 0.5rem;
}

Conclusion

If you are still here, thank you, truly, this was quite a lot to both write the code for, and write up, so I really do appreciate you reading this far, I certainly hope you found it helpful and interesting. It certainly covered a lot, but security is something to take seriously, and understanding how to write a complete authentication system, even one this basic, requires a lot of learning!

Fortunately next time wont be anywhere near as large, we will be looking at how to email the urls with tokens to users to make this system practical to use in the real world!

Learning Outcomes

Github

Resources

tags: CommonLisp - Lisp - tutorial - YouTube - web - dev