hyte-web-dev

Application State and User Authentication

Maintaining State in Web Applications

In any realistic interactive Web application you will come across the need of retaining information between individual pages. This is referred to as “maintaining state” or as the “persistence of data”. HTTP protocol does not support this, it is a stateless protocol - every page request starts in a blank state with no knowledge of data that was available on the previous page. Since HTTP is stateless by nature, web applications need to implement their own methods for maintaining state. There is several common strategies to implement this on the server-side.

Choosing the right method for maintaining state depends on the specific requirements of your application, such as the type of data being stored, security considerations, scalability, and whether the state is user-specific or shared across users. It’s common to use a combination of these methods to address different aspects of application state.

Cookies

sequenceDiagram
    participant Client
    participant Server

    Client->>Server: HTTP Request (initial visit)
    Note over Server: Server decides to set a cookie
    Server->>Client: HTTP Response with Set-Cookie header
    Note over Client: Client stores the cookie
    Client->>Server: Subsequent HTTP Request with cookie
    Note over Server: Server reads cookie and personalizes response
    Server->>Client: Personalized HTTP Response

Server-Side Sessions

sequenceDiagram
    participant Client
    participant Server
    participant SessionStore

    Client->>Server: HTTP Request (initial visit)
    Note over Server: Server creates a session
    Server->>SessionStore: Create new session data
    Note over SessionStore: Stores session data
    Server->>Client: HTTP Response with session ID ( e.g. cookie)
    Note over Client: Client stores session ID
    Client->>Server: Subsequent HTTP Request with session ID
    Server->>SessionStore: Retrieve session data
    Note over Server: Server personalizes response
    Server->>Client: Personalized HTTP Response

URL Parameters and Query Strings

Hidden Form Fields

Token-Based Authentication

The process of authentication using JWT and subsequent requests with the token in typical client-server interaction:

sequenceDiagram
    participant Client
    participant Server
    participant AuthServer

    Client->>Server: HTTP Request with Credentials (login)
    Note over Server: Validates credentials
    Server->>AuthServer: Request for JWT
    Note over AuthServer: Creates and signs JWT
    AuthServer->>Server: JWT
    Server->>Client: HTTP Response with JWT
    Note over Client: Stores JWT (usually in memory)
    
    Client->>Server: Subsequent HTTP Request with JWT in header
    Note over Server: Verifies JWT
    alt JWT valid
        Server->>Client: Authorized Response
    else JWT invalid or expired
        Server->>Client: Unauthorized Response
    end

Client-Side State Management

State in Express

Express is a minimalistic framework that does not provide any built-in mechanisms for maintaining state. However, it is easy to implement your own state management using cookies, sessions, or other methods.

User Authentication and Authorization with JWT in Express

In web applications, authentication is typically done by verifying a username and password combination. Authorization is typically done by checking the user’s role or permissions for the requested resource.

  1. Install jsonwebtoken
  2. Generate a secret key for signing the tokens and store it in the .env file: JWT_SECRET=...
    • use a long random string, e.g. from random.org
    • or use the crypto module to generate a random string
  3. Create a route POST /api/auth/login that accepts a username and password in the request body.
    • add a new route handler to routes/auth-router.js, controller method to controllers/auth-controller.js, and use the user model to query the database or create a new model for authentication.
  4. In the user model implement a method for verifying the username and password combination and returning the user object if found:

    ...
    const selectUserByNameAndPassword = async (user) => {
      try {
        const sql = `SELECT user_id, username, email, user_level_id
                  FROM Users WHERE username = ? AND password = ?`;
    ...
    ...
    
  5. In the auth controller implement token generation for the logged in user, something like this:

     import jwt from 'jsonwebtoken';
     import {selectUserByNameAndPassword} from '../models/user-model.js';
     import 'dotenv/config';
    
     const postLogin = async (req, res) => {
       console.log('postLogin', req.body);
       const user = await login(req.body);
       if (user) {
         const token = jwt.sign(user, process.env.JWT_SECRET, {expiresIn: '24h'});
         res.json({...user, token});
       } else {
         res.sendStatus(401);
       }
     };
     ...
    
    • if the user is found, jsonwebtoken is used to generate a JWT token and then token is sent back to the client along with the user object.
  6. Create a middleware for handling requests to endpoints where authentication is needed middlewares/authentication.js

     import jwt from 'jsonwebtoken';
     import 'dotenv/config';
    
     const authenticateToken = (req, res, next) => {
       console.log('authenticateToken', req.headers);
       const authHeader = req.headers['authorization'];
       const token = authHeader && authHeader.split(' ')[1];
       console.log('token', token);
       if (token == undefined) {
         return res.sendStatus(401);
       }
       try {
         req.user = jwt.verify(token, process.env.JWT_SECRET);
         next();
       } catch (err) {
         res.status(403).send({message: 'invalid token'});
       }
     };
    
     export {authenticateToken};
    
  7. Add a new route handler for GET /api/auth/me that returns the user object based on the token in the request header (this can be used to check if the token stored on the client-side is valid and get the user info):

     // router:
     ...
     import {getMe, postLogin} from '../controllers/auth-controller.js';
     import {authenticateToken} from '../middlewares/authentication.js';
     ...
     authRouter.route('/me').get(authenticateToken, getMe);
     ...
    
     // controller:
     const getMe = async (req, res) => {
       console.log('getMe', req.user);
       if (req.user) {
         res.json({message: 'token ok', user: req.user});
       } else {
         res.sendStatus(401);
       }
     };
    
  8. Test login and the protected route with VS Code REST Client (or Postman):

     ### Post login
     POST http://localhost:3000/api/auth/login
     content-type: application/json
    
     {
       "username": "JohnDoe",
       "password": "to-be-hashed-pw1"
     }
    
    • Check the response and copy the token from the response body.
     ### Get my user info
     GET http://localhost:3000/api/auth/me
     Authorization: Bearer <put-your-token-from-login-response-here>
    
    • or test with Postman (just set ‘Bearer token’ on ‘Authorization’ tab after succesful login POST).
  9. Now you can use the authentication middleware with any route where needed (just like with getMe in authRouter).
    • Information about the authenticated user is passed to the controller in req.user object. For example:
     // user-router.js
     ...
     userRouter.route('/:id').put(authenticateToken, putUser);
     ...
    
    
     // user-controller.js
     ...
     const putUser = async (req, res) => {
       // get user id from token
       const token_user_id = req.user.user_id;
       // get user id from request
       const user_id = req.params.id;
       // check that user is updating own data
       if (token_user_id !== user_id) {
         return res.status(403).json({error: 403, message: 'forbidden'});
       }
     ...
     ...
    

Password Hashing

  1. Install bcryptjs: npm i bcryptjs (bcrypt is another option but it has more dependencies)
  2. Update user creation function in user controller to use bcrypt and implement password hashing (asynchronously)
    • import bcrypt from 'bcryptjs';
    • Generate a salt: const salt = await bcrypt.genSalt(10);
    • Hash a password: const hashedPassword = await bcrypt.hash(myPlaintextPassword, salt);
    • Store the hashed password to database instead of the plain text password
  3. Update user authentication to use bcrypt for password check in auth-controller.js
    • Compare the posted password to the hash found in database: const match = await bcrypt.compare(myPlaintextPassword, hash); (returns a boolean)
    • You need to modify the user model to get first the password hash from database and then compare it to the posted password (you cannot use the password value in the SQL query anymore).
    • Only if the password matches, generate a token and return it to the client
    • Do not return the password hash to the client (remove it from the user object before sending it to the client)
    • Note that after this the plain text passwords in the database are not usable anymore, so you need to create new users with a new password or update the existing users’ passwords.

Assignment

  1. Continue your existing Express app and create a branch authentication
    • See the teacher’s example (link in Oma) for reference to get started
  2. Implement user authentication to your app
    • Add endpoint POST /api/auth/login
    • Use JWT for authentication
    • Use bcrypt for password hashing
  3. Implement proper authorization for protected routes, e.g.:
    • PUT /api/entries/:id - only entry owner can update entry
    • DELETE /api/entries/:id - only entry owner can delete entry
    • PUT /api/users/ - users can update only their own user info
    • and so on…
    • describe your rules in README.md
  4. Extra (optional): think about how existing endpoints should work and what other endpoints you might need for your app and implement them with proper authentication and authorization. e.g.:
    • GET /api/entries - list only user’s own entries: get all entries by user id from token
    • GET /api/entries/stats - get some statistics about entries like sleep time average, etc.
  5. Extra (optional): implement user roles (e.g. admin, user) with different permissions (role based resource authorization)
    • Regular users can only delete and edit their own data
      • Modify the DELETE and UPDATE SQL queries in models so that queries will also check that the owner of the item (user_id) matches the user_id property in the req.user object. req.user is decoded from the token and needs to be passed as a parameter from controller to corresponding model method.
    • Admin level users can see, update or delete any diary entries, user info, etc.
      • You cant create a new function in the user model that checks if the user is an admin and returns a boolean value.
      • Add another DELETE and UPDATE SQL queries into model functions that do not check the user_id property. Instead, you need to check that the user id from the token belongs to an admin user.
      • Use e.g. conditional statements in the models to decide which SQL query to use based on the user level.

Returning: See related assignment in Oma.