With that, it's time to put it all in practice and extend our application. The requirements for this extension are simple. We want to:
/albums/recently-added
routes (both the GET and POST) to only authenticated users.If we break this down, there are a few work items for us. We need to create a login form that we'll use to get the user's credentials to authenticate, which should take the place of item 1. We can also satisfy item 2 by putting a classic Remember my username checkbox on the authentication form as well. Finally, we'll need to put a logout link somewhere on our site. No problemo! Let's get cracking!
We're going to build the login form first because, hey, we need to get the credentials somehow and a phone call isn't going to cut it. For now, it's probably easiest if we just create the login Selmer template next to the signup form template we created in Chapter 7, Getting Started with the Database. We can do the following:
resources/templates/login.html
.{% extends "templates/base.html" %} <!-- 1 --> {% block content %} <h1>Login. <span class="small">That last session was so lame.</span></h1> <div class="row"> <div class="col-md-6"> {% if invalid-credentials? %} <!-- 2 --> <p class="errors">The provided username and/or password are incorrect. </p> {% endif %} <form role="form" method="POST" action="/login"> <div class="form-group"> <label for="username">Username</label> <input type="text" name="username" class="form-control" id="username" placeholder="AtticusButch"> </div> <div class="form-group"> <label for="password">Password</label> <input type="password" name="password" class="form-control" id="password"> </div> <div class="form-group"> </div> <button type="submit" class="btn btn-default">Submit</button> </form> </div> </div> {% endblock %}
The form looks pretty similar to our signup form. We're extending our base template (at <!-- 1 -->
), and we have a username and password field that we'll be POSTing to /login
. The last thing we're doing is conditionally rendering a Wrong username/password message if the :invalid-credentials?
key is on the context (<!-- 2 -->
).
Of course we need to be able to render this form, so let's create a new route for the /login
URI. The /signup
route is handled in the hipstr.routes.home
namespace, so we'll put the /login
route there as well. In the hipstr.routes.home
namespace, do the following:
home-routes
that manages the GET method for /login
:(GET "/login" [] (login-page))
hipstr.routes.home/login-page
route helper function. This will simply render the login page:(defn login-page "Renders the login form." [] (layout/render "login.html"))
That should be all there is to it. We can now fire up a browser and navigate to http://localhost:3000/login
, and we should see something similar to the following:
Next, let's lock down the /albums/recently-added
routes.
As we saw earlier in the chapter, restricting access to routes is a two-step process. We need to A, mark the route as restricted
, and B, tell Luminus how to grant a access to the restricted route. Let's first restrict the route.
In the hipstr.routes.albums
namespace, add the following requirement:
(:require … [noir.util.route :refer [restricted]])
This imports the restricted
macro that we'll use to restrict access to the 2 routes in question. To do this, we'll modify the GET and POST routes for /albums/recently-added
as restricted
:
(defroutes album-routes (GET "/albums/recently-added" [] (restricted (recently-added-page))) (POST "/albums/recently-added" [& album-form] (restricted (recently-added-submit album-form))) …
That's all there is to restricting access to a route, as far as the route is concerned. However, now we have to tell Luminus when not to restrict access to the route. And for that, we'll need to create a new access rule in our app handler.
I typically like to keep things partitioned a bit. We need to put some code somewhere that checks if a user is authenticated. For now the most natural place for that is in our hipstr.models.user-model
namespace (or someplace else, if you prefer).
Let's create the hipstr.models.user-model/is-authed?
function first, to check if a request is authenticated. This function will simply check if there's a :user_id
in the session for the current request.
First, bring the noir.session
into the hipstr.models.user-model
namespace:
… (:require … [noir.session :as session] …)
Secondly, add the following function to hipstr.models.user-model
:
(defn is-authed? "Returns false if the current request is anonymous; otherwise true." [_] ;#1 (not (nil? (session/get :user_id))))
The substance of this function is simple. The only thing to note is that we don't care about the parameter (#1). Every access rule is passed the request map, but we're more concerned about the session, so we just ignore it.
The last thing we need to do is define the access rule. We know that, if our application were to grow big and strong, we may define several access rules governing various routes. So, instead of cluttering up the hipstr.handler
namespace, we'll create a new hipstr.routes.access
namespace and define our rules there.
hipstr.routes.access
, and give it access to our new is-authed?
function:(ns hipstr.routes.access (:require [hipstr.models.user-model :refer [is-authed?]])
rules
that returns a vector of access-rule maps. Our vector will only contain a single rule, which defers validation to the is-authed?
function:(def rules "The rules for accessing various routes in our application." [{:redirect "/login" :rule is-authed?}])
hipstr.handler/app
application handler, set our :access-rules
to the rules vector we just defined:(def app (app-handler … :access-rules access/rules …))
With that, any anonymous request to a restricted route will automatically be redirected to the /login
route, where our gorgeous login form will be presented to the user. We can test our route restriction by trying to navigate directly to http://localhost:3000/albums/recently-added
, which should automatically redirect us to /login
.
Now that we have a restricted route and a login form, let's move on to authenticating the user.
The form POST will do 3 things. First, it will try and validate the user's credentials. There's no need for us to validate the format of the credentials coming up the pipe, because we already ensured they're in the appropriate format when we put them in the database as part of our Signup page. So the credentials on the login form will either match something in the database or they won't. Secondly, if the set of credentials fail to validate, then we'll re-render the login page and tell the user that their username/password was incorrect (that is, make use of that invalid-credentials
context value the form is currently expecting). Finally, if the credentials successfully validate, we'll redirect the user to the /albums/recently-added
route we had previously locked down.
Since our hipstr.models.user-model
has the is-authed?
function, it makes sense to put an auth-user
function beside it. The function will grab a user from the database matching the provided username
and, if it exists, will check if the passwords match. However, you'll recall in Chapter 7, Getting Started with the Database that we store a hashed version of the password, so we'll need to make use of the crypto.password.bcrypt
namespace (the same namespace we used to originally hash the password for storage). If the username and password match, we'll return the user map from the database; otherwise we'll return nil
(including if a user with the provided username does not exist).
First, we'll need to create a simple SQL query that fetches a user by username. Add the following in our users.sql
file:
-- name: get-user-by-username -- Fetches a user from the DB based on username. SELECT * FROM users WHERE username=:username
The preceding query will be processed by YeSQL at runtime, resulting in a get-user-by-username
function in our namespace. Next, add the following function to the hipstr.models.user-model
:
(defn auth-user "Validates a username/password and, if they match, adds the user_id to the session and returns the user map from the database. Otherwise nil." [username password] (let [user (first (get-user-by-username {:username username}))] ;#1 (when (and user (password/check password (:password user))) ;#2 (session/put! :user_id (:user_id user)) ;#3 (dissoc user :password)))) ;#4
The auth-user
function makes use of the YeSQL-generated get-user-by-username
function (#1
), which, if successful, will return a map of matching users. Since our users.username
database field has a unique index constraint on it, we know that only 0 or 1 result will be returned in the vector, hence the call to first
. We then make use of the crypto.password.bcrypt/check
function (#2
), which returns true if the hashed password matches an unhashed password. If the user exists and the passwords match, we then stuff the :user_id
into the session (#3
), and then return the user map – but without the password (#4
), as there's no need to proliferate that throughout our app.
If all is happy, then the :user_id
will be added to the user's session, which is what our access rule checks for all restricted routes. The last thing we need to do to authenticate the user is to handle that pesky form POST.
We'll overload the hipstr.routes.home/login-page
to accept a different arity of arguments, specifically the login form's value map. Extend it to the following:
(defn login-page ([] (layout/render "login.html" {:username (cookies/remember-me)})) ([credentials] (if (apply u/auth-user (map credentials [:username :password])) (response/redirect "/albums/recently-added")) (layout/render "login.html" {:invalid-credentials? true}))))
The overloaded function is pretty simple: If the user successfully authenticates with the username and password, then redirect them to the restricted route /albums/recently-added.
Otherwise, re-render the login form with the invalid-credentials
set to true. The last thing to do before we try out the login form is to create the POST /login
route. Add the following to the home-routes
:
(POST "/login" [& login-form] (login-page login-form))
Try it out for yourself! If we provide an incorrect username or password, our login form is re-rendered with a foreboding blood-red error message:
And when we provide a valid username/password, we're redirected to the /albums/recently-added page
:
But typing in our username is so boring! I have a thousand websites and a thousand usernames, so let's take care of action item number 2 in our requirements and add that remember me cookie.
Over the years, I've become rather disdainful of random cookie code littered throughout a web application. I prefer to keep cookies in a rather central location. That way, when we want to change the way a cookie behaves we only have to do it once instead of a gazillion times. For that reason, create another namespace, hipstr.cookies
, and throw the following in there:
(ns hipstr.cookies (:require [noir.cookies :as c])) (defn remember-me ([] "Gets the username in the remember-me cookie." (c/get :remember-me)) ([username] "Sets a remember-me cookie to the user's browser with the user's username." (if username (c/put! :remember-me {:value username :path "/" :max-age (* 60 60 24 365)}) (c/put! :remember-me {:value "" :path "/" :max-age -1}))))
The hisptr.cookies
acts as a kind of business wrapper to the noir.cookies
namespace. In this namespace, we put an overloaded remember-me
function, which will either get or set the value of the remember-me
cookie, depending on whether or not it's called with a value. You'll notice in the overloaded function that, if we call remember-me with a falsey value (nil
or ""
) that we'll basically delete the cookie.
To make use of the remember-me
cookie, let's extend our login form to include a simple checkbox. Back in the resources/templates/login.html,
add the following between the password field and the submit button:
<div class="form-group"> <label for="password">Password</label> <input type="password" name="password" class="form-control" id="password"> </div> <div class="form-group"> <input type="checkbox" name="remember-me" {% if username %} checked{% endif %}> Remember me on this computer </div> <button type="submit" class="btn btn-default">Submit</button>
All we did here was add a simple HTML checkbox. Yep. Living on the edge! The checkbox will now be marked checked
if there's a username
on the context map. Let's also set the value of the username
text field to the username
context value:
<input type="text" name="username" class="form-control" id="username" placeholder="AtticusButch" value="{{ username }}">
Next we'll modify our hipstr.routes.home/login-page
to take the remember-me
cookie and checkbox into account. First, import the hipstr.cookies
namespace:
(:require … [hipstr.cookies :as cookies] …)
Next, we'll set the username
context value when rendering the login page:
([] (layout/render "login.html" {:username (cookies/remember-me)})
Finally, if the user has checked the remember-me checkbox, and they successfully authenticate on POST, we'll write their username to the remember-me
cookie, otherwise we'll set the cookie to nil
(essentially deleting it):
(if (apply u/auth-user (map credentials [:username :password])) (do (if (:remember-me credentials) (cookies/remember-me (:username credentials)) (cookies/remember-me "")) (response/redirect "/albums/recently-added")))
With all that, we should now see a checkbox on the login form. The first time we view it, it will be unchecked. However, if we check it and successfully authenticate, and then go back to the login form, you'll notice that it will be checked and our username will be pre-populated in the username text field, shown as follows:
Finally, the last thing we need to do is create the logout route and link to it.
Our logout link will be simple. Any form of navigation (GET, POST, etc.) to /logout
will invalidate the user's authenticated status and redirect back to /
. Since our is-authed?
and auth-user
functions are in hisptr.models.user-model
, we will add a third method alongside them, called invalidate-auth
:
(defn invalidate-auth "Invalidates a user's current authenticated state." [] (session/clear!))
That's it. We'll just blow away anything in the session because, hey, why not! Technically, if all we wanted to do was prohibit the user from accessing restricted routes, we could have simply called (session/remove! :user_id)
, and that would have sufficed. But for now, there are no business rules keeping us from blowing everything away – also, it frees up some memory.
Next, we'll add the route and the helper function. Add the following function and route to our hipstr.routes.home
namespace:
(defn logout [] "Logs the user out of this session." (u/invalidate-auth) (response/redirect "/")) (defroutes home-routes … (ANY "/logout" [] (logout)) …)
Finally, we need to get a Logout link in there. Since we're extending base.html
for all of our templates, that seems like the best place to put it. This allows us to put the logout link in the top-right corner of every page. Open the templates/base.html
file and append the following highlighted markup (roughly line 19):
<div class="navbar-collapse collapse "> <ul class="nav navbar-nav"> <li class="{{home-selected}}"> <a href="{{servlet-context}}/">Home</a></li> <li class="{{about-selected}}"> <a href="{{servlet-context}}/about">About</a></li> </ul> <ul class="nav navbar-nav navbar-right"> <li><a href="{{servlet-context}}/logout">Logout</a></li> </ul> </div>
Save the file and refresh your browser, and you'll now see a Logout link in the top-right corner of your browser:
There's only one problem though: A link saying Logout doesn't make a lot of sense if you're not already authenticated. so change the code to the following:
<ul class="nav navbar-nav navbar-right"> {% if is-authed? %} <li><a href="{{servlet-context}}/logout">Logout</a></li> {% else %} <li><a href="{{servlet-context}}/login">Login</a></li> {% endif %} </ul>
We will now render the appropriate link if the is-authed?
context value is true. But where is that value coming from? We need to set it on the context. Considering that this link will be on every single one of our pages, it makes sense for us to adjust the hipstr.layout/render
function. This is the function we call every time we render an HTML template. We can associate the :is-authed?
context value with the parameter map coming in:
hipstr.models.user-model
in hipstr.layout
namespace:(:require … [hipstr.models.user-model :as user] …)
render
function to associate the is-authed?
key with the value returned by hipstr.models.user-model/is-authed?
:(defn render [template & [params]] (let [params (-> (or params {}) (assoc :is-authed? (user/is-authed? nil)))] (RenderableTemplate. template params)))
The base.html
template will now show the appropriate Login/Logout link, depending on the user's current authenticated status.
3.145.18.101