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

Frontend

On this page

    The Login Screen

    Since protect endpoints and roles, the API refuses every request for data that has no token, and the pages have never logged in. The Books page shows one word, Unauthorized, above an empty table. This lesson teaches the pages to log in.

    Our goal: a login page that gets a token from the API, Books and report pages that send it with every request, and a menu that shows the report link only to managers.

    Here is the route:

    1. Write the login page.
    2. Decide where the browser keeps the token. There are four places, and the choice matters.
    3. Write one small shared script that sends the token with every request, and goes to the login page when the API answers 401.
    4. Change the Books page: use that script, show who is logged in, add a Log out button, and show the report link to managers only.
    5. Change the report page to use the script.
    6. Try it as bob and as alice.

    As before, the pages are ugly on purpose: no styling, only the HTML and the script the job needs. Also left out: staying logged in after the browser closes, tokens that renew themselves, and logging in with a cookie that scripts cannot read (named under step 2, not built).

    This is the login page we build first:

    The login page in a browser: a heading Log in, a User name box, a Password box and a Log in button

    Where we are

    This is the solution from the end of protect endpoints and roles. Two files are new and two change, all in wwwroot:

    BookShop/
      src/
        BookShop.Api/
          wwwroot/
            index.html                     <- changes
            report.html                    <- changes
            login.html                     <- NEW
            auth.js                        <- NEW
    

    Only the files that change are listed. Run every command from the solution folder, BookShop/. Keep the app running (dotnet run --project src/BookShop.Api) and refresh the browser after each edit. The table should hold the five sample books from reading books. The Books page is long, so this lesson shows only the new pieces of it, each marked // new or <!-- new -->, and the whole page once at the end.

    Here is how the pages fit together once we are done:

    Three pages and one shared script login.html gets the token token kept index.html the Books page a link report.html the report page auth.js: adds the token to every request a 401, or Log out: back to login

    The login page

    Create src/BookShop.Api/wwwroot/login.html. The whole file is:

    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="utf-8">
      <title>Log in</title>
    </head>
    <body>
      <h1>Log in</h1>
    
      <p id="message"></p>
    
      <form id="login-form">
        <p><label>User name <input name="userName"></label></p>
        <p><label>Password <input name="password" type="password"></label></p>
        <p><button type="submit">Log in</button></p>
      </form>
    
      <script>
        const form = document.getElementById('login-form');
    
        form.addEventListener('submit', async (event) => {
          event.preventDefault();
    
          const response = await fetch('/auth/login', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              userName: form.elements.userName.value,
              password: form.elements.password.value
            })
          });
    
          if (!response.ok) {
            const problem = await response.json();
            document.getElementById('message').textContent = problem.title;
            return;
          }
    
          const answer = await response.json();
          sessionStorage.setItem('token', answer.token);
          sessionStorage.setItem('role', answer.role);
          sessionStorage.setItem('userName', form.elements.userName.value);
          location.href = '/';
        });
      </script>
    </body>
    </html>
    

    Most of it is the Books form from the Books screen, smaller:

    • type="password" makes the browser show dots instead of the letters. It changes only what is shown on screen: the password still travels as plain text in the request body. (On a live site, HTTPS encrypts the whole request on its way.)
    • The request is a POST to /auth/login with the JSON body the login endpoint expects, {"userName": ..., "password": ...}.
    • On failure the page shows the title of the Problem Details: Wrong user name or password. for a 401, or the validation title for an empty form.
    • On success the page keeps three things in sessionStorage (the next section explains it): the token, the role and the user name. setItem(name, value) stores a value under a name.
    • location.href = '/' sends the browser to another address, here the Books page. It is the same as clicking a link.

    This page uses plain fetch, and not the helper we write below. The login request carries no token, and a 401 here means "wrong password", not "go and log in".


    Where to keep the token

    The login page has the token, and then the browser leaves the page. The token has to survive that, so the Books page can find it. The browser has four places to keep it:

    Place Survives a click to another page Survives closing the tab A script on the page can read it
    A variable in the script no no yes
    sessionStorage yes no yes
    localStorage yes yes, until removed yes
    A cookie marked HttpOnly yes depends on the cookie no

    A variable is out for us. As the report screen showed, a link loads another page, and its script starts from nothing. Our app is three separate pages, so the token would be lost at every click. (Apps built as one page that never reloads can keep it in a variable.)

    sessionStorage and localStorage are two small stores the browser keeps for each site. They work the same way (setItem, getItem), and each site sees only its own. The difference is how long they keep things: sessionStorage forgets when the tab is closed, localStorage keeps everything until it is removed. On a shared computer at the shop counter, localStorage means the next person who opens the site is still logged in as the last one.

    Both have the same weak spot: any script running on the page can read them. In the first web page, a book title with HTML in it showed how a stranger's script could end up on a page, and textContent kept it out. If such a script ever did get in, it could read the token and send it away, and a token works for whoever holds it.

    A cookie marked HttpOnly fixes that: the browser keeps it and sends it with every request, and no script can read it at all. That is the safest place against a stolen token. But then the API must read the token from a cookie instead of the Authorization header, and cookies that the browser sends by itself need their own protection against other sites sending requests in your name. That is a different setup, and we leave it out.

    How long each store keeps the token shorter longer variable lost at every page change sessionStorage until the tab closes our choice localStorage until removed, even after the browser closes

    We use sessionStorage: it survives the clicks between our pages, and it is gone when the tab is closed. Like localStorage, a script on the page could read it, so it relies on the page never letting a stranger's script in, which is why every page in this course writes data with textContent. This is a choice, and teams argue about it; the HttpOnly cookie is the stronger one when you can set it up.


    One shared script for the token

    Both the Books page and the report page need the same thing: add the token to each request, and go to the login page if the API answers 401. Until now, each page had all its script inside it. Two pages with the same job is the moment to move that job into one file, so a change is made in one place.

    Create src/BookShop.Api/wwwroot/auth.js. The whole file is:

    async function apiFetch(url, options) {
      options = options ?? {};
      options.headers = options.headers ?? {};
    
      const token = sessionStorage.getItem('token');
      if (token) {
        options.headers['Authorization'] = 'Bearer ' + token;
      }
    
      const response = await fetch(url, options);
      if (response.status === 401) {
        location.href = '/login.html';
      }
      return response;
    }
    
    function logOut() {
      sessionStorage.clear();
      location.href = '/login.html';
    }
    

    apiFetch is fetch with the token added. The pages call it exactly as they called fetch, with the same address and the same options:

    • The first two lines make sure there is an options object and a headers object to add to. a ?? b means "a, or b if a is missing", the same ?? as in problem.errors ?? {}. A GET passes no options at all; a POST passes its own headers, such as Content-Type, and they are kept.
    • The token is read back with getItem, and added as the Authorization header in the form the API expects: Bearer, a space, the token. If there is no token (nobody has logged in in this tab), no header is added, and the API answers 401.
    • On a 401 the browser goes to the login page. One rule covers both cases: never logged in, and a token that ran out after its hour. A 403 is not sent to the login page, because logging in again would not help (bob would still be bob).
    • The response goes back to the caller, so the page goes on as before: response.ok, response.json(), and so on.

    logOut empties sessionStorage (clear() removes everything this site stored in this tab) and goes to the login page. Be honest about what it does: the browser forgets the token, and the token itself is still valid until it expires, as what a JWT is explained. For a token that only ever lived in this tab, that is fine.

    A page loads a script file with <script src="auth.js"></script>. The browser fetches the file, runs it, and then apiFetch and logOut are there for the page's own script below it.


    The Books page

    Open src/BookShop.Api/wwwroot/index.html. Four changes.

    1. A line under the heading: who is logged in, the report link, and a Log out button. It replaces the plain report link from the report screen lesson:

      <h1>Books</h1>
    
      <p>                                                                    <!-- new -->
        Logged in as <span id="user-name"></span>.                           <!-- new -->
        <a href="report.html" id="report-link" hidden>Stock report</a>        <!-- new -->
        <button type="button" id="log-out">Log out</button>                  <!-- new -->
      </p>                                                                   <!-- new -->
    
      <p id="message"></p>
    

    The link starts with hidden, an attribute that tells the browser not to show the element. The script shows it to managers. <span> is an element with no look of its own, a place to put the user name.

    2. Load the shared script, in a line of its own just before the page's script:

      <script src="auth.js"></script>                                        <!-- new -->
      <script>
        const form = document.getElementById('book-form');
    

    3. Every fetch becomes apiFetch. There are three: in loadBooks, in deleteBook, and in the form's submit handler. Change the name only, and keep everything inside the brackets:

          const response = await apiFetch('/books');                                     // new
    
          const response = await apiFetch('/books/' + book.id, { method: 'DELETE' });   // new
    
          const response = await apiFetch(id ? '/books/' + id : '/books', {              // new
    

    4. Fill in the line from step 1, at the end of the script, just before loadBooks();:

        cancelButton.addEventListener('click', stopEdit);
    
        document.getElementById('user-name').textContent = sessionStorage.getItem('userName');   // new
        if (sessionStorage.getItem('role') === 'Manager') {                                       // new
          document.getElementById('report-link').hidden = false;                                  // new
        }                                                                                         // new
        document.getElementById('log-out').addEventListener('click', logOut);                     // new
    
        loadBooks();
    

    The user name goes in with textContent, like every other piece of data. The role decides the menu: a manager gets the report link, anyone else does not. The Log out button calls logOut from auth.js.

    ⚠️ Hiding the link is a convenience, not a lock. bob can still type /report.html in the address bar, or call /reports/stock with curl. What keeps the report from him is the 403 from the API, from the last lesson. Never let a page's menu be the only thing between a user and data: anyone can change what a page shows, in the developer tools, in a minute.

    The whole page is at the end of the lesson, to compare.


    The report page

    The report page needs the shared script and apiFetch, nothing else. Open src/BookShop.Api/wwwroot/report.html. The whole file is:

    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="utf-8">
      <title>Stock report</title>
    </head>
    <body>
      <h1>Stock report</h1>
    
      <p><a href="/">Books</a></p>
    
      <p id="message"></p>
    
      <table>
        <thead>
          <tr>
            <th>Category</th>
            <th>Titles</th>
            <th>Copies</th>
            <th>Stock value</th>
          </tr>
        </thead>
        <tbody id="report-rows"></tbody>
      </table>
    
      <script src="auth.js"></script>                                        <!-- new -->
      <script>
        async function loadReport() {
          const response = await apiFetch('/reports/stock');                 // new
          if (!response.ok) {
            document.getElementById('message').textContent =
              'Could not load the report. The API answered ' + response.status + '.';
            return;
          }
    
          const lines = await response.json();
          const rows = document.getElementById('report-rows');
    
          for (const line of lines) {
            const row = rows.insertRow();
            row.insertCell().textContent = line.category;
            row.insertCell().textContent = line.titles;
            row.insertCell().textContent = line.copies;
            row.insertCell().textContent = line.stockValue.toFixed(2);
          }
        }
    
        loadReport();
      </script>
    </body>
    </html>
    

    The message for a failed request was already there. It now gets its first real use: a 403.


    Try it

    Open the developer tools (F12) on the Network tab before each case, so you can watch the requests. Each case starts where the one before ended.

    Case A: open the Books page without logging in

    Open http://localhost:5101/ in a new tab. The address bar ends on /login.html. The Network tab shows why, in order:

    200  /
    200  /auth.js
    401  /books
    200  /login.html
    

    The page loaded, asked for the books with no token, got a 401, and apiFetch sent the browser to the login page. (You may also see a 404 for /favicon.ico, the little icon browsers ask every site for; ignore it.)

    Case B: bob, with a wrong password

    Type bob and guess, and click Log in. The request body is:

    {"userName":"bob","password":"guess"}
    

    The API answers 401, and the page shows the title: Wrong user name or password. The page stays where it is.

    Case C: bob, with his password

    Now bob and bob-pass-1:

    {"userName":"bob","password":"bob-pass-1"}
    

    The login answers 200, the browser goes to the Books page, and GET /books answers 200 with the five books. Click that request in the Network tab and look at its request headers: there is the Authorization: Bearer eyJ... line that apiFetch added. The top of the page shows bob, and no report link:

    The top of the Books page for bob: Logged in as bob, and a Log out button, with no Stock report link

    The token is in sessionStorage now. In the Console, sessionStorage.getItem('role') answers 'Staff'.

    Case D: bob types the report address

    bob knows the address. He types http://localhost:5101/report.html. The page loads, because pages are open to anyone, and then:

    Could not load the report. The API answered 403.
    

    The headings of the table are there, and no numbers. The request for /reports/stock carried bob's good token, and the API said 403. apiFetch left the page alone, because a 403 is not a reason to log in again.

    Case E: log out, and log in as alice

    Go back to the Books page and click Log out. The browser is on the login page, and sessionStorage.length in the Console answers 0: the token is gone. Log in as alice with alice-pass-1. The top of the Books page now has the link:

    The top of the Books page for alice: Logged in as alice, a Stock report link, and a Log out button

    Click Stock report. The request carries alice's token, the role is Manager, and the report shows the same numbers as in the report screen lesson:

    Category Titles Copies Stock value
    Cooking 2 6 181.00
    Programming 3 22 852.30

    Adding, editing and deleting books work as before, for both users: apiFetch keeps the Content-Type header the form sends and adds the token next to it. We added a book as bob to check, and removed it again.


    The whole Books page

    Compare with your file:

    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="utf-8">
      <title>Books</title>
    </head>
    <body>
      <h1>Books</h1>
    
      <p>
        Logged in as <span id="user-name"></span>.
        <a href="report.html" id="report-link" hidden>Stock report</a>
        <button type="button" id="log-out">Log out</button>
      </p>
    
      <p id="message"></p>
      <ul id="problems"></ul>
    
      <form id="book-form" novalidate>
        <input type="hidden" name="bookId">
        <p><label>Title <input name="title"></label></p>
        <p><label>Author <input name="author"></label></p>
        <p><label>Price <input name="price" type="number" step="0.01"></label></p>
        <p><label>Stock <input name="stock" type="number"></label></p>
        <p><label>Category <input name="category"></label></p>
        <p>
          <button type="submit">Save</button>
          <button type="button" id="cancel-edit" hidden>Cancel</button>
        </p>
      </form>
    
      <table>
        <thead>
          <tr>
            <th>Title</th>
            <th>Author</th>
            <th>Price</th>
            <th>Stock</th>
            <th>Category</th>
            <th></th>
          </tr>
        </thead>
        <tbody id="book-rows"></tbody>
      </table>
    
      <script src="auth.js"></script>
      <script>
        const form = document.getElementById('book-form');
        const cancelButton = document.getElementById('cancel-edit');
    
        async function loadBooks() {
          const response = await apiFetch('/books');
          if (!response.ok) {
            await showProblem(response);
            return;
          }
    
          const books = await response.json();
          const rows = document.getElementById('book-rows');
          rows.replaceChildren();
    
          for (const book of books) {
            const row = rows.insertRow();
            row.insertCell().textContent = book.title;
            row.insertCell().textContent = book.author;
            row.insertCell().textContent = book.price.toFixed(2);
            row.insertCell().textContent = book.stock;
            row.insertCell().textContent = book.category;
    
            const editButton = document.createElement('button');
            editButton.textContent = 'Edit';
            editButton.addEventListener('click', () => startEdit(book));
    
            const deleteButton = document.createElement('button');
            deleteButton.textContent = 'Delete';
            deleteButton.addEventListener('click', () => deleteBook(book));
    
            row.insertCell().append(editButton, ' ', deleteButton);
          }
        }
    
        function readBook() {
          return {
            title: form.elements.title.value,
            author: form.elements.author.value,
            price: Number(form.elements.price.value),
            stock: Number(form.elements.stock.value),
            category: form.elements.category.value
          };
        }
    
        function startEdit(book) {
          clearProblems();
          form.elements.bookId.value = book.id;
          form.elements.title.value = book.title;
          form.elements.author.value = book.author;
          form.elements.price.value = book.price;
          form.elements.stock.value = book.stock;
          form.elements.category.value = book.category;
          cancelButton.hidden = false;
        }
    
        function stopEdit() {
          form.reset();
          form.elements.bookId.value = '';
          cancelButton.hidden = true;
        }
    
        async function deleteBook(book) {
          if (!confirm('Delete "' + book.title + '"?')) {
            return;
          }
    
          clearProblems();
          const response = await apiFetch('/books/' + book.id, { method: 'DELETE' });
          if (response.ok) {
            await loadBooks();
          } else {
            await showProblem(response);
          }
        }
    
        function clearProblems() {
          document.getElementById('message').textContent = '';
          document.getElementById('problems').replaceChildren();
        }
    
        async function showProblem(response) {
          const problem = await response.json();
          document.getElementById('message').textContent = problem.title;
    
          const list = document.getElementById('problems');
          list.replaceChildren();
          for (const messages of Object.values(problem.errors ?? {})) {
            for (const text of messages) {
              const item = document.createElement('li');
              item.textContent = text;
              list.append(item);
            }
          }
        }
    
        form.addEventListener('submit', async (event) => {
          event.preventDefault();
          clearProblems();
    
          const id = form.elements.bookId.value;
          const response = await apiFetch(id ? '/books/' + id : '/books', {
            method: id ? 'PUT' : 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(readBook())
          });
    
          if (response.ok) {
            stopEdit();
            await loadBooks();
          } else {
            await showProblem(response);
          }
        });
    
        cancelButton.addEventListener('click', stopEdit);
    
        document.getElementById('user-name').textContent = sessionStorage.getItem('userName');
        if (sessionStorage.getItem('role') === 'Manager') {
          document.getElementById('report-link').hidden = false;
        }
        document.getElementById('log-out').addEventListener('click', logOut);
    
        loadBooks();
      </script>
    </body>
    </html>
    

    Where we ended

    Compare with your solution:

    BookShop/
      src/
        BookShop.Api/
          wwwroot/
            login.html                     logs in, keeps the token, role and name in sessionStorage
            auth.js                        apiFetch (adds the token, a 401 goes to login) and logOut
            index.html                     the Books screen, through apiFetch, with a menu for the role
            report.html                    the stock report, through apiFetch
    

    The system the course set out to build is complete: an API with a books screen and a report screen, logging in, and two roles. Staff see the books. Managers see the books and the report. The API decides, and the pages follow.


    Recap

    • The login page sends the user name and password to POST /auth/login, and keeps the token, the role and the name in sessionStorage.
    • A token can live in a variable (lost at each page change), sessionStorage (until the tab closes), localStorage (until removed) or an HttpOnly cookie (no script can read it). We chose sessionStorage; a script on the page could read it, so the pages never let one in.
    • auth.js is a script shared by two pages. apiFetch adds Authorization: Bearer to each request and goes to the login page on a 401. A 403 stays on the page.
    • Log out makes the browser forget the token. The token itself stays valid until it expires.
    • The menu shows the report link only to managers, with hidden. That is a convenience: the API's 403 is what protects the report.

    Questions to think about

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

    1. Why can the token not be kept in a variable in our pages?
    Show answer

    Every link loads a new page, and the new page's script starts from nothing. The token would be lost at the first click.

    1. bob does not see the Stock report link. Is that what keeps the report from him?
    Show answer

    No. Hiding the link only tidies the menu. The API refuses his token on /reports/stock with a 403, whatever the page shows.

    1. Why does apiFetch go to the login page on a 401, but not on a 403?
    Show answer

    A 401 means the API does not know who you are, which logging in fixes. A 403 means it knows, and the answer would be the same after logging in again.

    1. What is the risk of keeping a token where a script can read it?
    Show answer

    If a stranger's script ever gets into the page, it can read the token and use it, because a token works for whoever holds it. Writing data with textContent is what keeps such scripts out.

    Next lesson: logging, the program's own diary of what happened, now that there are logins, refusals and reports to keep track of.

    ← Protect Endpoints and Roles