This page is a work in progress.You can help improve it. →

Adding Support For the Login Feature

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:

jwt

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 database
private 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 methods
public 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 of xxxx 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 response
var response = new
{
// This is the login token
token = new TokenGenerator(JWT_KEY).TokenFor(foundUser),
// The is the user details
user = foundUser
};
return Ok(response);
}
else
{
// Make a custom error response
var response = new
{
status = 400,
errors = new List<string>() { "User does not exist" }
};
// Return our error with the custom response
return 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.

© 2017 - 2022; Built with ♥ in St. Petersburg, Florida.