Adding Support For the Login Feature
Reading
Adding Support For the Login Feature
For our users to login, we need to present the user with a form to provide their username and password. This data then needs to be processed by the backend to validate the information is correct and then inform the client of the success or failure.
We also need some way for the client to include the user's information with
every API request. It would not be a good idea to store the user's password
and include that with every request. We want to handle the user's unencrypted
password as little as possible. To do this, the client and the server
communicate with a system known as JavaScript Web Tokens or JWTs. (some
pronounce this as Jay-Double-U-Tees and others as joots
to rhyme with
scoots
)
The idea of a JWT is an encoded and cryptographically signed bit of data that the server can hand back to the client, which means "If you hand me back this data exactly, I'll recognize you as the user it specifies"). The server needs a way to format, sign, and then encode the response.
JWTs can store any information we wish. We should keep them small since it does add overhead to each API request. Typically we hold some details from the user to include, but not be limited to, their user id. We may also store their names and email.
What does a JWT look like?
This is an example of a JWT:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJZCI6IjEiLCJGdWxsTmFtZSI6IkdhdmluIFN0YXJrIiwiRW1haWwiOiJnYXZpbkBnc3RhcmsuY29tIiwibmJmIjoxNTk0MjMyOTE5LCJleHAiOjE1OTQyNjg5MTksImlhdCI6MTU5NDIzMjkxOX0.k-xpH_Fu45BBUQWWTWHVxATAZk-X_Ae-_hXZFjF8LQE
Pretty indecipherable, right? Fortunately, JWTs are easily decoded by our
computers. The website jwt.io
has a decoder on the home page. Try copying the
above text and pasting it into their Debugger
What you will see is this:
On the left side is our original JWT. However, you will notice that it has become color-coded. Each of the colors of the text represents what part of the JWT it represents.
There are three parts to a JWT:
- Header
- Payload
- Verification
The Header
section tells the JWT system what kind of token this is and what
algorithm encoded the token. The header comes first so the JWT system can
properly decode the rest of the token.
Next comes the payload
. The payload is the part we, as developers, specify
data. Each of these data elements is considered aclaim
. The first three
claims here, Id
, FullName
, and Email
were generated by code (which we are
about to write) and represent a logged-in user's details. The next three
represent details about the token itself. nbf
is a claim that stands for
Not Before
, meaning that the token is not valid for any time earlier than
the given timestamp. The claim exp
stands for Expiration
and represents when
this token is no longer valid. Finally, iat
is a claim that indicates when the
token was issued.
The final section is the signature of the token. It uses cryptographic
functions to add data to the token using a server's secret key. This data
represents a hash of the other parts of the token. If anyone were to change
even a single character of the other parts of the message, say changing the Id
from 1
to 2
they would not be able to resign that message with valid
data. They lack the server's secret key. The data is decodable by anyone, but
only the server itself can change/update the data. Thus it is essential not to
put secret information in the payload since JWT tokens are not
encrypted.
When a user logs in, we will have our server generate a new JWT token and return
this to the client. The client can store this token and provide it back to the
server with any API request that needs to be done by an authenticated user. We
do this by specifying a unique header
value that includes this token.
Adding a controller to manage "sessions."
Thinking again about resources we will consider the user logging in to be the
CREATION of a Session. While we won't record creating a session in our
database, though we could, we still think of this as its own resource with a
POST
create action.
As such, we will create a Sessions
controller to store this POST
action.
dotnet aspnet-codegenerator controller -name SessionController --useAsyncActions -api --relativeFolderPath Controllers
using System.Collections.Generic;using System.Threading.Tasks;using Microsoft.AspNetCore.Mvc;using Microsoft.EntityFrameworkCore;using Microsoft.Extensions.Configuration;using TacoTuesday.Models;using TacoTuesday.Utils;namespace TacoTuesday.Controllers{// All of these routes will be at the base URL: /api/Sessions// That is what "api/[controller]" means below. It uses the name of the controller// in this case RestaurantsController to determine the URL[Route("api/[controller]")][ApiController]public class SessionsController : ControllerBase{// This is the variable you use to have access to your databaseprivate readonly DatabaseContext _context;readonly protected string JWT_KEY;// Constructor that receives a reference to your database context// and stores it in _context_ for you to use in your API methodspublic SessionsController(DatabaseContext context, IConfiguration config){_context = context;JWT_KEY = config["JWT_KEY"];}}}
You will notice a few things different than our traditional controller.
In addition to storing the _context
with our DatabaseContext
, we are also
storing a variable with a JWT_KEY
. JWT_KEY
is the secret key we will use to
sign the JWT tokens.
This token is passed to us when the controller's constructor, just as the
context is. It is available to us from a config
variable the framework
supplies.
From this config
variable, we can ask for the ["JWT_KEY"]
and store that in
our variable.
This process is another example of
Dependency Injection
.
The use of
Dependency Injection
allows the framework to provide data without knowing how that information is
managed or accessed. During development, the configuration data comes from local
configuration files. However, in production, it will be handled by our hosting
provider. In either case, we are not concerned with those details. We only need
to accept this config
variable and extract the data we need.
Creating our JWT_KEY for development
Earlier your project required you to create a user secret to use in development.
To set up our development mode for storing this secret JWT_KEY
, you
initialized support for user secrets
by running this command:
dotnet user-secrets init
User secret initialization creates a file outside our project to store secret information. This way the data is not stored in our repository for others to see.
Next, you told the secrets to store JWT_KEY
dotnet user-secrets set "JWT_KEY" "Long set of Random Letters and Numbers like iExEUNxxv9zylIuT2VMrsMsQEKjjKs1XrYFntsafKgQs90HndTX0yw8xLhFHk9O"
The JWT_KEY
should be a relatively long set of random characters. These random
characters are considered high entropy implying that it will be tough for
someone to guess this secret. An excellent website to generate these kinds of
secrets is:
Gibson Research Corporation's Password Page
NOTE: If you are going to deploy this with Heroku, you'll need to run
heroku config:set JWT_KEY="xxxx"
with your specific key in place ofxxxx
at least once before you deploy. If you haven't set up deployment yet, make a note of this step to come back to once you've started your deployment process.
Returning to the controller
Now that we have generated this JWT secret, we can implement the POST
method
for creating our user login session.
[HttpPost]public async Task<ActionResult> Login(LoginUser loginUser){var foundUser = await _context.Users.FirstOrDefaultAsync(user => user.Email == loginUser.Email);if (foundUser != null && foundUser.IsValidPassword(loginUser.Password)){// create a custom responsevar response = new{// This is the login tokentoken = new TokenGenerator(JWT_KEY).TokenFor(foundUser),// The is the user detailsuser = foundUser};return Ok(response);}else{// Make a custom error responsevar response = new{status = 400,errors = new List<string>() { "User does not exist" }};// Return our error with the custom responsereturn BadRequest(response);}}
You'll notice the POST
method doesn't take a User
object, but a loginUser
of type LoginUser
. We need to do this since we do need to read the
Password
while the user is logging in. Thus we'll define this class inside the
SessionsController
to store the Email
and Password
strings.
public class LoginUser{public string Email { get; set; }public string Password { get; set; }}
The POST
method attempts to find an existing user that has the same email
address as the received user.
Next, it uses the IsValidPassword
method we wrote in the User
class to
detect if the password matches.
If we found a user foundUser != null
AND the password matches we will
generate a response that looks like this:
{"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJZCI6IjEiLCJGdWxsTmFtZSI6IkdhdmluIFN0YXJrIiwiRW1haWwiOiJnYXZpbkBnc3RhcmsuY29tIiwibmJmIjoxNTk0MjMyOTE5LCJleHAiOjE1OTQyNjg5MTksImlhdCI6MTU5NDIzMjkxOX0.k-xpH_Fu45BBUQWWTWHVxATAZk-X_Ae-_hXZFjF8LQE","user": { "Id": "1", "FullName": "Gavin Stark", "Email": "gavin@suncoast.io" }}
The token
part of this object is created using
new TokenGenerator(JWT_KEY).TokenFor(foundUser)
. SDG provides this
TokenGenerator
class to generate JWT tokens for your users. The code includes
documentation if you are interested in how the code works.
To use the TokenGenerator
code, we need to add a dependency to our project:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
We also include the user
object in the response. Including the user object in
the response provides the client a simple way to access this data.
This custom object is the payload of the successful API response.
If either the foundUser
is null
or the password does not match, we return an
error message object, which the form can process and display to the user.
Give it a try!
To test if this works, we can use both the POST /api/Users
and
POST /api/Sessions
endpoints from Insomnia. First, we can create a user. Then
we can try the same email
and password
to the Sessions
endpoint and see if
we get back a valid response. Try an invalid password or an email address that
doesn't correspond to an account to see the error messages.
Next we'll connect the user interface to these controllers.