If you haven't already, first read the previous post about Users.
The basic authentication scheme that we'll use depends on a unique identifier (e.g. email) and a secret value (e.g. password). We will check that a user knows the exact combination of these two credentials, thus "authenticating" that user into the app.
It's worth noting that there are many other authentication schemes such as, sending a user to an external service (e.g. Google or Facebook) to be authenticated, or asking the user for multiple verification factors (like receiving a text message or using a rotating token in combination with email and password) - OAuth and 2FA/MFA respectively. There are tradeoffs to using any scheme, for example, more security often means more friction for the user. So you need to find an appropriate balance in your application.
The period of time when a user is logged-in is commonly referred to as a "session". And, although a login or session isn't usually database-backed (meaning it doesn't have a corresponding table), we'll still treat it as a resource in our code. You can call the resource whatever you want, but we'll follow convention and name it sessions
. As with any resource, we first add it to the routes file (/config/routes.rb
) below our other resources:
resources "sessions"
This shorthand will expose the 7 RESTful routes. Next we want to generate the controller that we'll need. At the terminal prompt, type rails generate controller sessions
. This will create a controller file and an empty views directory. You can now run our application server using rails server
and visit the /sessions/new
path and you should see this error page that we've seen before:
"The action 'new' could not be found for SessionsController"
Read your error messages! This error is describing precisely what code we need to write next. In the sessions controller (/app/controllers/sessions_controller.rb
), add a new action:
class SessionsController < ApplicationController
def new
end
end
If you refresh your page, you'll see a new error - progress!
"SessionsController#new is missing a template for request formats: text/html"
This error message isn't quite as clear, but it's saying that we're missing the view file for this new action. In the Gitpod file explorer, find the /views/sessions
folder that we generated. Inside that folder, add a file called new.html.erb
. To test that everything is connected properly, let's add a bit of html to that file:
<h1>Login</h1>
Now when you refresh the browser, the error message is gone and we see the html we added to the view file.
The new action of a resource is where we display a form to the user. In this case, we want to display a form where the user can enter their login credentials. The rails form helper code is a bit cumbersome, so feel free to copy a form from another resource and modify it for this use case.
To start, we usually have a form that looks something like this:
<h1>Login</h1>
<form action="/things" method="post">
...form fields go here
</form>
The form is going to make a POST request to some route in your application and carry with it the form's user-entered data. It will be a POST request because the method html attribute is "post"
. The action html attribute will be the route or path of the request. Replace /things
with the url path we want to submit this form to:
<h1>Login</h1>
<form action="/sessions" method="post">
...form fields go here
</form>
The action is /sessions
because it follows the REST pattern for the create
action of a resource - the create
action is where the new form gets submitted. Now we just add the form input code for the two fields the user will enter:
<h1>Login</h1>
<form action="/sessions" method="post">
<p>
<label for="email_input">Email</label>
<input type="text" name="email" id="email_input">
<label for="password_input">Password</label>
<input type="text" name="password" id="password_input">
</p>
<button>Login</button>
</form>
In the browser, this code produces the following form:
If you try to submit the form, you'll get the familiar error that the create
action is missing. So let's fix that. In the sessions controller, define the missing action:
class SessionsController < ApplicationController
def new
end
def create
end
end
Now when you submit, no error message, but also nothing seems to happen at all. Is anything happening? If you're not sure, check the server log. Sure enough, you'll see that the request is being made to the server - you'll see Started POST "/sessions" and the form parameters. Nothing happens because the create
action isn't doing anything yet.
In other resources, here is where we would use the resource model to insert a row into the resource's database table. But that doesn't make sense for the session
resource. Instead, what we want to do is check the form data against data in the users
table and, if it matches, login the user. The first part isn't too bad - since email is a unique identifier, we can use it to find a row in the users
table. First, locate the email in the params hash. Looking at the server log, we see Parameters: {"authenticity_token"=>"[FILTERED]", "email"=>"", "password"=>"", "commit"=>"Login"}
. Unlike when submitting a form for a model, the data isn't nested. The email is accessed at params["email"]
.
def create
@user = User.find_by({ "email" => params["email"] })
end
Next, if there is a user in the database with this email, we can compare the password, otherwise we send the user back to the login form.
def create
@user = User.find_by({ "email" => params["email"] })
if @user
# check secret password
else
redirect_to "/sessions/new"
end
end
Test it out with an email that isn't in your database. Again, it doesn't look like much is happening, but if you watch the server log, you'll see: (1) the request, (2) a SQL SELECT
statement querying for a user with the given email, and then (3) a redirect (and then (4) another request to /sessions/new
).
The missing logic is the password check. We'll check if the password entered in the form matches the password stored in the table. If they match, login the user and send them to the companies index, otherwise send the user back to the login form.
def create
@user = User.find_by({ "email" => params["email"] })
if @user
if @user["password"] == params["password"]
# login the user
redirect_to "/companies"
else
redirect_to "/sessions/new"
end
else
redirect_to "/sessions/new"
end
end
Test it out with correct and incorrect data. You should be redirected to the companies page when your authentication credentials match or the login form when they don't.
Unless you're constantly monitoring the server log, it's hard to know why you're being redirected to a given path in this logic. Users don't see the server log obviously, so it would be great to provide them with feedback to let them know the login succeeded or failed. Rails has a built-in feature for this. There's a flash
hash that we can use to store a short message to display. Add this code just before each redirect:
def create
@user = User.find_by({ "email" => params["email"] })
if @user
if @user["password"] == params["password"]
# login the user
flash["notice"] = "You've logged in."
redirect_to "/companies"
else
flash["notice"] = "Unsuccessful login."
redirect_to "/sessions/new"
end
else
flash["notice"] = "Unsuccessful login."
redirect_to "/sessions/new"
end
end
Now when you submit an unknown email, you'll see that flash notice message displayed. The message will only appear on the very next redirect request - if you refresh it will disappear.
How did that message magically display?! There's a global layout view that we saw once before. Let's look at that file: /app/views/layouts/application.html.erb
. There's some boilerplate html in that file, but you'll also see this code:
<p>
<%= flash["notice"] %>
</p>
That's the notice
key in the flash
hash. If/when that key has a value (which it does because it gets assigned in the create
action before redirecting), the value is displayed. Any html that we want displayed in every view belongs in this layout file (like a navbar). It would be a pain to maintain that html in every view html.erb
file, so we put it here.
This flash
message is certainly improving the UX (user experience), but it's understandable if you're very confused how it works - it appears to be breaking the "HTTP is stateless" rule since the message is displayed on the next request after the form submission. We'll learn what's happening in a bit - spoiler alert, the same technology that we use to store this message is what we'll also use for knowing that a user is logged-in. But before we get there, let's shore up some security vulnerabilities in our code first (next post).