Tutorials / Build a REST API with C# and .NET · Lesson 36 of 38

Backend

On this page

    The Login Endpoint

    In what a JWT is, reception printed a badge after checking an identity card. Our API has no reception yet. This lesson builds it.

    Our goal: an endpoint, POST /auth/login, that takes a user name and a password and, when they are right, answers with a signed token.

    Here is the route:

    1. Install the package that makes tokens.
    2. Add the settings for the token: who makes it, who it is for, how long it lasts, and the secret key.
    3. Add two users, alice and bob, with their passwords stored safely.
    4. Write a small service that builds and signs the token.
    5. Write the login endpoint, and run it: a right password, a wrong one, and an empty form.
    6. Test it.

    Left out: checking the token on the other endpoints. After this lesson every endpoint is still open to anyone; protect endpoints and roles closes them. Also left out: a users table, signing up, changing a password, and ASP.NET Core Identity, Microsoft's complete ready-made user system.

    Where we are

    This is the solution from the end of the report screen. A new folder, Auth, holds the pieces of logging in:

    BookShop/
      Directory.Packages.props               <- changes (one package)
      src/
        BookShop.Api/
          Program.cs                         <- changes
          appsettings.json                   <- changes
          appsettings.Development.json       <- changes
          Auth/
            JwtSettings.cs                   <- NEW
            AppUser.cs                       <- NEW
            IUserService.cs                  <- NEW
            DemoUserService.cs               <- NEW
            TokenService.cs                  <- NEW
          Controllers/
            AuthController.cs                <- NEW
          Dtos/
            LoginRequest.cs                  <- NEW
            LoginResponse.cs                 <- NEW
      tests/
        BookShop.Api.Tests/
          DemoUserServiceTests.cs            <- NEW
          TokenServiceTests.cs               <- NEW
          AuthControllerTests.cs             <- NEW
    

    Only the files that change are listed. Run every command from the solution folder, BookShop/. No project is added, so there is no solution map.

    Here is where the new pieces sit when a login arrives:

    The path of a login Client name + password AuthController POST /auth/login DemoUserService 1. is the password right? TokenService 2. make and sign a token 200 with a token, or 401 when wrong

    The controller only speaks HTTP, as in every earlier lesson. Checking a password and making a token are two separate jobs, so each gets its own small class.


    Install the package

    The package Microsoft.AspNetCore.Authentication.JwtBearer is Microsoft's support for JWTs in ASP.NET. It brings two halves: the library that makes tokens, which we use now, and the part that checks them on every request, which we use in the next lesson. One package for both.

    Terminal:

    dotnet add src/BookShop.Api package Microsoft.AspNetCore.Authentication.JwtBearer
    

    Visual Studio: right-click BookShop.Api, choose Manage NuGet Packages, and install Microsoft.AspNetCore.Authentication.JwtBearer.

    As before, the version goes into Directory.Packages.props. The whole file now looks like this (your numbers may be newer):

    <Project>
      <PropertyGroup>
        <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
      </PropertyGroup>
      <ItemGroup>
        <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.14" />   <!-- new -->
        <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
        <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.13">
          <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
          <PrivateAssets>all</PrivateAssets>
        </PackageVersion>
        <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.13" />
        <PackageVersion Include="MSTest" Version="4.0.1" />
        <PackageVersion Include="Scalar.AspNetCore" Version="2.18.2" />
      </ItemGroup>
    </Project>
    

    The settings for the token

    Four values describe our tokens. Three of them are ordinary settings. The fourth is a secret.

    Setting Our value In the token Meaning
    Issuer BookShop.Api iss Who makes the token.
    Audience BookShop aud Who the token is for.
    MinutesValid 60 exp How long a token lasts.
    Key a long secret (none) The secret key that makes the signature.

    The key is the one thing that must never leak: whoever has it can make a token that says Manager. So it does not go in appsettings.json, the file every environment reads. On your machine it goes in appsettings.Development.json, which creating the solution showed is read only in Development. On a live server the key comes from outside the code, for example from an environment variable or a secret store that the hosting company provides. That setup belongs to deployment, and the course leaves it out.

    Open src/BookShop.Api/appsettings.json. The whole file is:

    {
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft.AspNetCore": "Warning"
        }
      },
      "AllowedHosts": "*",
      "ConnectionStrings": {
        "BookShop": "Data Source=bookshop.db"
      },
      "Jwt": {                                  // new
        "Issuer": "BookShop.Api",               // new
        "Audience": "BookShop",                 // new
        "MinutesValid": 60                      // new
      }                                         // new
    }
    

    (The // new marks are only for this page. JSON has no comments, so do not type them. Mind the comma after the ConnectionStrings block.)

    Open src/BookShop.Api/appsettings.Development.json. The whole file is:

    {
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft.AspNetCore": "Warning"
        }
      },
      "Jwt": {                                                              // new
        "Key": "development-only-key-replace-me-at-least-32-bytes"          // new
      }                                                                     // new
    }
    

    ASP.NET reads both files and merges them, so in Development the Jwt section has all four values. Make your own key if you like: any text of at least 32 characters. HS256 refuses a key that is too short. We tried short-key, and the login answered with a 500, with IDX10653: The encryption algorithm 'HS256' requires a key size of at least '128' bits in the log.

    ⚠️ This key is now in a file that goes into git with the rest of the code. That is acceptable for a key that only ever signs practice tokens on your own machine. A real key never goes into git. .NET has a tool for keeping even development secrets out of the project folder, dotnet user-secrets, which we leave out.

    Now a class to hold the four values, so the code reads settings.Issuer and not a string like "Jwt:Issuer". Create a folder Auth in src/BookShop.Api, and in it JwtSettings.cs:

    public class JwtSettings
    {
        public string Issuer { get; set; } = "";
        public string Audience { get; set; } = "";
        public string Key { get; set; } = "";
        public int MinutesValid { get; set; }
    }
    

    The names match the names in the Jwt section, which is how ASP.NET knows which value goes where. We fill it in Program.cs, later in this lesson.


    Two users

    A real shop keeps its users in a table, with a page to sign up and a way to reset a password. That is a lot of work that has nothing to do with tokens, so we keep two users in the code: alice, the manager, and bob, staff. This is demo code, and its name says so.

    First, the user. Create Auth/AppUser.cs:

    public record AppUser(string UserName, string Role);
    

    A one-line record, as in records: a user only carries two values. It is called AppUser because .NET already has several classes called User.

    Then the job, as an interface, because the demo users will one day be replaced by a table. Create Auth/IUserService.cs:

    public interface IUserService
    {
        AppUser? FindUser(string userName, string password);
    }
    

    FindUser answers the user when the name and the password are right, and null otherwise. A wrong password is an ordinary outcome, not a crash, so it is a null and not an exception.

    Passwords are never stored as they are

    The tempting way is to keep each password next to its user and compare the two strings. If anyone ever reads that list (a leaked backup, a curious colleague, a bug that logs it), every password is out, and people use the same password in many places.

    So a password is stored as a hash: the result of a one-way mixer. The mixer turns bob-pass-1 into a long string, and there is no way to turn the string back into the password. At login, the mixer runs again on what the user typed, and the two results are compared.

    A password stored as a hash Once bob-pass-1 hash AQAAAAIAAYagAAAAEO/4... stored At each login what bob typed stored hash Verify Success or Failed

    Do not write the mixer yourself. ASP.NET has one, PasswordHasher, and it adds a random part (a salt) to every hash. So the same password hashed twice gives two different strings. We ran it twice on bob-pass-1:

    AQAAAAIAAYagAAAAEO/432cw+Q3K75/qrDmg9U8dqA6HyW/L+Ty9KlPTikZDlGpu36O6QogEX27r28jbPg==
    AQAAAAIAAYagAAAAELJ1ZTexNus+a4nXZ2HxBHC+TEtKxD9lSoxNpEJMop3/abdma6AMEEINL5llUvaNtw==
    

    The salt is stored inside the hash, and the hasher's own VerifyHashedPassword knows how to read it. Two people with the same password get different hashes, so a thief cannot spot them, nor look them up in a list of known hashes.

    Now the demo users. Create Auth/DemoUserService.cs:

    using Microsoft.AspNetCore.Identity;
    
    public class DemoUserService : IUserService
    {
        private readonly PasswordHasher<AppUser> _hasher = new();
        private readonly Dictionary<string, (AppUser User, string PasswordHash)> _users = [];
    
        public DemoUserService()
        {
            Add(new AppUser("alice", "Manager"), "alice-pass-1");
            Add(new AppUser("bob", "Staff"), "bob-pass-1");
        }
    
        private void Add(AppUser user, string password)
        {
            _users[user.UserName] = (user, _hasher.HashPassword(user, password));
        }
    
        public AppUser? FindUser(string userName, string password)
        {
            if (!_users.TryGetValue(userName, out var entry))
            {
                return null;
            }
    
            var result = _hasher.VerifyHashedPassword(entry.User, entry.PasswordHash, password);
            if (result == PasswordVerificationResult.Failed)
            {
                return null;
            }
    
            return entry.User;
        }
    }
    

    Line by line:

    • PasswordHasher<AppUser> is the hasher. It lives in Microsoft.AspNetCore.Identity, which is part of ASP.NET, so there is nothing to install. The <AppUser> says which kind of user it hashes for; this hasher does not look inside it.
    • _users is a Dictionary: a list you look up by a key, here the user name. Each entry holds two values, the user and the hash, written (AppUser User, string PasswordHash). That pair is called a tuple: a quick way to keep two values together without writing a class.
    • The constructor adds the two users. Add hashes the password at once and keeps only the hash.
    • FindUser looks up the name (TryGetValue answers false when there is no such key, so an unknown user gives null), then asks the hasher to compare the typed password with the stored hash.

    Be honest about the shortcut: the two passwords are written in the code, so anyone who reads the code knows them. The hashing shows the right habit for the day the users move to a table, where only hashes are stored. Never ship a user list like this one.


    The token service

    Now the reception printer. Create Auth/TokenService.cs:

    using System.Security.Claims;
    using System.Text;
    using Microsoft.IdentityModel.JsonWebTokens;
    using Microsoft.IdentityModel.Tokens;
    
    public class TokenService
    {
        private readonly JwtSettings _settings;
    
        public TokenService(JwtSettings settings)
        {
            _settings = settings;
        }
    
        public string CreateToken(AppUser user)
        {
            var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.Key));
    
            var descriptor = new SecurityTokenDescriptor
            {
                Subject = new ClaimsIdentity(
                [
                    new Claim("name", user.UserName),
                    new Claim("role", user.Role)
                ]),
                Issuer = _settings.Issuer,
                Audience = _settings.Audience,
                Expires = DateTime.UtcNow.AddMinutes(_settings.MinutesValid),
                SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256)
            };
    
            return new JsonWebTokenHandler().CreateToken(descriptor);
        }
    }
    

    Read it as filling in a badge form, then handing it to the printer:

    • SymmetricSecurityKey turns the secret key text into bytes the signing code can use. "Symmetric" means the same key makes the signature and checks it, which is how HS256 works.
    • SecurityTokenDescriptor is the form. Each property becomes part of the token:
      • Subject holds the claims, the facts about the user. A Claim is a name and a value. We use the short names name and role, so the payload reads exactly as it did in the JWT lesson. (.NET also has long names for claims, which are web addresses; we leave them out.) The [ ... ] is the collection expression from small C# shortcuts.
      • Issuer and Audience become iss and aud.
      • Expires becomes exp: now, plus 60 minutes. UtcNow because token times are always in UTC.
      • SigningCredentials names the key and the way to sign: HMAC with SHA-256, which is what HS256 stands for.
    • JsonWebTokenHandler().CreateToken is the printer: it writes the header and the payload, signs them, and returns the token as one string. It also adds iat and nbf (the time it was made) by itself.

    The service takes JwtSettings in its constructor, like every other service takes what it needs. It does not read the settings files itself, which makes it easy to test with a test key.


    The login endpoint

    What the client sends and what it gets back are two small records, next to the other request and response classes. Create Dtos/LoginRequest.cs:

    using System.ComponentModel.DataAnnotations;
    
    public record LoginRequest([Required] string UserName, [Required] string Password);
    

    And Dtos/LoginResponse.cs:

    public record LoginResponse(string Token, string Role);
    

    The response carries the role as well as the token. The role is inside the token already, but a web page would have to decode the token to find it. Sending it next to the token is simpler for the page, and it is not a secret: the page may see it either way.

    Now the controller. Create Controllers/AuthController.cs:

    using Microsoft.AspNetCore.Mvc;
    
    [ApiController]
    [Route("auth")]
    public class AuthController : ControllerBase
    {
        private readonly IUserService _userService;
        private readonly TokenService _tokenService;
    
        public AuthController(IUserService userService, TokenService tokenService)
        {
            _userService = userService;
            _tokenService = tokenService;
        }
    
        [HttpPost("login")]
        [ProducesResponseType<LoginResponse>(StatusCodes.Status200OK)]
        [ProducesResponseType(StatusCodes.Status400BadRequest)]
        [ProducesResponseType(StatusCodes.Status401Unauthorized)]
        public ActionResult<LoginResponse> Login(LoginRequest request)
        {
            var user = _userService.FindUser(request.UserName, request.Password);
            if (user == null)
            {
                return Problem(
                    statusCode: StatusCodes.Status401Unauthorized,
                    title: "Wrong user name or password.");
            }
    
            var token = _tokenService.CreateToken(user);
            return new LoginResponse(token, user.Role);
        }
    }
    

    Most of it is the controller shape you know. Three choices are new:

    • POST, not GET. The password travels in the body. A GET would put it in the address, and addresses end up in server logs and in the browser's history. Logging in is not a read of a resource, so the "verb in the address" (/login) is the same kind of exception as the /sell address of the sale.
    • 401 Unauthorized is the status for "I do not know who you are". The name is old and misleading: it is about authentication, not authorization. The body is the usual Problem Details, with our own title.
    • One message for both mistakes. An unknown user and a wrong password get the same answer. If the API said "no such user", anyone could try names until they found the real ones, and then only guess passwords.

    Login is not async. Nothing in it waits for a database or the network: the users and the key are in memory.


    Register the services

    Open src/BookShop.Api/Program.cs. The whole file is:

    using BookShop.Data;
    using Microsoft.EntityFrameworkCore;
    using Scalar.AspNetCore;
    
    var builder = WebApplication.CreateBuilder(args);
    
    builder.Services.AddControllers();
    builder.Services.AddOpenApi();
    builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
    builder.Services.AddProblemDetails();
    
    var connectionString = builder.Configuration.GetConnectionString("BookShop");
    builder.Services.AddDbContext<BookShopDbContext>(options => options.UseSqlite(connectionString));
    builder.Services.AddScoped<IBookService, BookService>();
    builder.Services.AddScoped<IStockReportService, StockReportService>();
    
    var jwtSettings = builder.Configuration.GetSection("Jwt").Get<JwtSettings>()!;   // new
    builder.Services.AddSingleton(jwtSettings);                                       // new
    builder.Services.AddSingleton<TokenService>();                                    // new
    builder.Services.AddSingleton<IUserService, DemoUserService>();                   // new
    
    var app = builder.Build();
    
    app.UseExceptionHandler();
    app.UseStatusCodePages();
    
    if (app.Environment.IsDevelopment())
    {
        app.MapOpenApi();
        app.MapScalarApiReference();
    }
    
    app.UseHttpsRedirection();
    
    app.UseDefaultFiles();
    app.UseStaticFiles();
    
    app.MapControllers();
    
    app.Run();
    
    • GetSection("Jwt").Get<JwtSettings>() reads the Jwt section of the settings files and fills a JwtSettings with it. It answers null if the section is missing; the ! at the end tells the compiler we know it is there. We keep the object in a variable because the next lesson needs it again in this file.
    • AddSingleton(jwtSettings) registers that one object, so TokenService can ask for it in its constructor.
    • Singleton for all three. None of them holds anything that belongs to one request, and none of them changes after it is made: the settings, the key and the two users are the same for everyone. That is the case for a singleton from service lifetimes: singleton, and it means the passwords are hashed once, when the app starts, not on every login.

    Run it

    Start the app (dotnet run --project src/BookShop.Api). Each case below is one request, run on its own.

    Case A: bob, with the right password

    The request body:

    {"userName":"bob","password":"bob-pass-1"}
    
    curl -i -X POST http://localhost:5101/auth/login -H "Content-Type: application/json" -d '{"userName":"bob","password":"bob-pass-1"}'
    
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    Date: Sat, 31 Oct 2026 09:52:23 GMT
    Server: Kestrel
    Transfer-Encoding: chunked
    
    {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJCb29rU2hvcCIsImlzcyI6IkJvb2tTaG9wLkFwaSIsImV4cCI6MTc5MzQ0Mzk0MywibmFtZSI6ImJvYiIsInJvbGUiOiJTdGFmZiIsImlhdCI6MTc5MzQ0MDM0MywibmJmIjoxNzkzNDQwMzQzfQ._dlpMAmhQU7RATNTOXovXe4H_gXos0snljjUYR9pyuw","role":"Staff"}
    

    This is the very token that the JWT lesson took apart: its payload says bob, Staff, issued at 09:52:23 and valid for one hour, and the Date header shows the same 09:52:23. Your token will be different, because the times are different, and so is the signature.

    Case B: bob, with a wrong password

    {"userName":"bob","password":"guess"}
    
    curl -i -X POST http://localhost:5101/auth/login -H "Content-Type: application/json" -d '{"userName":"bob","password":"guess"}'
    
    HTTP/1.1 401 Unauthorized
    Content-Type: application/problem+json; charset=utf-8
    

    The body, one field per line (the real body is a single line):

    {
      "type": "https://tools.ietf.org/html/rfc9110#section-15.5.2",
      "title": "Wrong user name or password.",
      "status": 401,
      "traceId": "00-88fc31dd9f8ea53b4ad229b48d3bb581-71d17ec7bb4aa6a9-00"
    }
    

    A 401, our title, and no token. (Ignore the traceId, as before.) We also sent a user who does not exist, carol, and got exactly the same body, apart from the traceId.

    Case C: an empty body

    {}
    
    curl -i -X POST http://localhost:5101/auth/login -H "Content-Type: application/json" -d '{}'
    
    HTTP/1.1 400 Bad Request
    Content-Type: application/problem+json; charset=utf-8
    
    {
      "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
      "title": "One or more validation errors occurred.",
      "status": 400,
      "errors": {
        "Password": ["The Password field is required."],
        "UserName": ["The UserName field is required."]
      },
      "traceId": "00-67546da7f97b78c9cc7c3b9e3a8c3b9b-e8fc198c814257f3-00"
    }
    

    The [Required] rules on LoginRequest stopped it before Login ran, the same gate as for a book. A 400 says "your request is malformed", a 401 says "your request is fine, and you are not who you say".

    Still open

    The books are still open to anyone, with or without a token:

    curl -i http://localhost:5101/books
    
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    

    followed by the five books, as always. Making tokens changes nothing for the other endpoints yet. Nobody checks them. That is the next lesson.

    In the test page

    The Scalar page lists the new endpoint, POST /auth/login, with a body of userName and password. Send bob's body from case A and look for the 200 and the token. (We checked the OpenAPI document behind the page, not the clicks.)


    Test it

    Three small test files, one per new class, in tests/BookShop.Api.Tests. None of them needs a fake: the user service keeps its users in memory, and the token service needs only a settings object.

    The users

    Create DemoUserServiceTests.cs:

    namespace BookShop.Api.Tests;
    
    [TestClass]
    public class DemoUserServiceTests
    {
        private readonly DemoUserService _service = new();
    
        [TestMethod]
        public void FindUser_RightPassword_ReturnsTheUserWithTheirRole()
        {
            var user = _service.FindUser("alice", "alice-pass-1");
    
            Assert.IsNotNull(user);
            Assert.AreEqual("Manager", user.Role);
        }
    
        [TestMethod]
        public void FindUser_WrongPassword_ReturnsNull()
        {
            var user = _service.FindUser("alice", "guess");
    
            Assert.IsNull(user);
        }
    
        [TestMethod]
        public void FindUser_UnknownUser_ReturnsNull()
        {
            var user = _service.FindUser("carol", "alice-pass-1");
    
            Assert.IsNull(user);
        }
    }
    

    The third test uses alice's real password with a name that does not exist, so it proves the name matters, not only the password.

    The token

    Create TokenServiceTests.cs:

    using Microsoft.IdentityModel.JsonWebTokens;
    
    namespace BookShop.Api.Tests;
    
    [TestClass]
    public class TokenServiceTests
    {
        private static readonly JwtSettings Settings = new()
        {
            Issuer = "BookShop.Api",
            Audience = "BookShop",
            Key = "a-test-key-that-is-long-enough-for-hs256",
            MinutesValid = 60
        };
    
        [TestMethod]
        public void CreateToken_User_CarriesTheirNameAndRole()
        {
            var service = new TokenService(Settings);
    
            var token = service.CreateToken(new AppUser("bob", "Staff"));
    
            var read = new JsonWebToken(token);
            Assert.AreEqual("bob", read.GetClaim("name").Value);
            Assert.AreEqual("Staff", read.GetClaim("role").Value);
            Assert.AreEqual("BookShop.Api", read.Issuer);
        }
    
        [TestMethod]
        public void CreateToken_User_ExpiresAfterTheConfiguredMinutes()
        {
            var service = new TokenService(Settings);
    
            var token = service.CreateToken(new AppUser("bob", "Staff"));
    
            var read = new JsonWebToken(token);
            var minutesLeft = (read.ValidTo - DateTime.UtcNow).TotalMinutes;
            Assert.IsTrue(minutesLeft > 59 && minutesLeft <= 60);
        }
    }
    
    • new JsonWebToken(token) decodes the token, like atob in the JWT lesson. It does not check the signature, and it does not need to: this test is about what goes into the token.
    • The test key is its own text, so the tests never depend on the key in the settings files.
    • The expiry test allows a little time between making the token and reading it: more than 59 minutes and at most 60.

    The endpoint

    Create AuthControllerTests.cs:

    using Microsoft.AspNetCore.Mvc;
    
    namespace BookShop.Api.Tests;
    
    [TestClass]
    public class AuthControllerTests
    {
        private AuthController _controller = null!;
    
        [TestInitialize]
        public void Setup()
        {
            var settings = new JwtSettings
            {
                Issuer = "BookShop.Api",
                Audience = "BookShop",
                Key = "a-test-key-that-is-long-enough-for-hs256",
                MinutesValid = 60
            };
            _controller = new AuthController(new DemoUserService(), new TokenService(settings));
        }
    
        [TestMethod]
        public void Login_RightPassword_ReturnsATokenAndTheRole()
        {
            var result = _controller.Login(new LoginRequest("alice", "alice-pass-1"));
    
            Assert.IsNotNull(result.Value);
            Assert.IsFalse(string.IsNullOrEmpty(result.Value.Token));
            Assert.AreEqual("Manager", result.Value.Role);
        }
    
        [TestMethod]
        public void Login_WrongPassword_Returns401()
        {
            var result = _controller.Login(new LoginRequest("alice", "guess"));
    
            var answer = result.Result as ObjectResult;
            Assert.IsNotNull(answer);
            Assert.AreEqual(401, answer.StatusCode);
        }
    }
    

    The controller gets the real DemoUserService and TokenService. That breaks the habit of unit testing basics, where a unit test replaces the other classes with stand-ins. We allow it here because both classes are already stand-ins themselves (the users live in memory) and run in a blink. When the users move to a table, this test gets a fake IUserService, like FakeBookService.

    Run the tests:

    dotnet test
    
    Passed!  - Failed:     0, Passed:    27, Skipped:     0, Total:    27, Duration: 1 s - BookShop.Api.Tests.dll (net10.0)
    

    Twenty earlier tests and seven new ones. The Data tests did not change, and still pass.


    Where we ended

    Compare with your solution:

    BookShop/
      Directory.Packages.props               the JWT package added
      src/
        BookShop.Api/
          Program.cs                         reads the Jwt settings, registers three singletons
          appsettings.json                   Jwt: issuer, audience, minutes
          appsettings.Development.json       Jwt: the key, for your machine only
          Auth/
            JwtSettings.cs                   the four token settings, as a class
            AppUser.cs                       a user: name and role
            IUserService.cs                  "is this name and password right?"
            DemoUserService.cs               alice and bob, passwords kept as hashes
            TokenService.cs                  builds and signs a token
          Controllers/
            AuthController.cs                POST /auth/login: 200 with a token, or 401
          Dtos/
            LoginRequest.cs                  user name and password
            LoginResponse.cs                 the token and the role
      tests/
        BookShop.Api.Tests/
          DemoUserServiceTests.cs            right, wrong, unknown
          TokenServiceTests.cs               the claims and the expiry
          AuthControllerTests.cs             200 with a token, 401
    

    The API now has a reception desk. It hands out badges, and none of the doors checks them yet.


    Recap

    • POST /auth/login takes a user name and a password and answers 200 with a token, 401 when they are wrong, and 400 when they are missing.
    • The token settings live in the Jwt section. The secret key stays out of appsettings.json: in Development it is in appsettings.Development.json, and on a live server it comes from outside the code.
    • Passwords are stored as hashes, made and checked by ASP.NET's PasswordHasher, which adds a salt so equal passwords give different hashes.
    • TokenService fills a SecurityTokenDescriptor (claims, issuer, audience, expiry, signing key) and JsonWebTokenHandler turns it into a signed token.
    • A wrong password and an unknown user get the same answer, so the API does not reveal which user names exist.
    • The settings, the token service and the users are singletons: nothing in them belongs to one request.
    • Handing out tokens protects nothing by itself. The endpoints are still open until they check the token.

    Questions to think about

    Each question has its answer folded away underneath. Guess first, then open it to check.

    1. Why is the secret key not in appsettings.json?
    Show answer

    Whoever has the key can sign any token, including one that says Manager. appsettings.json is read everywhere and lives with the code, so the real key comes from outside the code.

    1. Why does the API store a hash of each password, and not the password?
    Show answer

    If the stored list ever leaks, a hash cannot be turned back into the password. The API can still check a login by running the hasher on what the user typed.

    1. Why do a wrong password and an unknown user get the same answer?
    Show answer

    A different answer for an unknown user would let anyone find out which user names exist, which is half of breaking in.

    1. Why is the login a POST and not a GET?
    Show answer

    A GET puts its values in the address, and addresses are kept in logs and in the browser's history. The password belongs in the body.

    1. bob has a token. Can he read the stock report without it?
    Show answer

    Yes, at the moment. No endpoint checks tokens yet, so the report is open to anyone. The next lesson makes the API check them.

    Next lesson: protect endpoints and roles, where the API checks the token and keeps the report for managers.

    ← What a JWT IsProtect Endpoints and Roles →