COIT20259 โ€” Week 10 Lab Tutorial

MVC, Navigation, Converters, Validators & HTTP Filters

๐Ÿ›  Apache NetBeans โ˜• GlassFish 7 ๐Ÿ“ฆ Jakarta EE 10 โฑ Estimated time: 120โ€“150 min
Introduction

What You Will Build

Extend the Week 9 Product Manager app with proper MVC layering, navigation rules, custom data validation, and session-based security.

This lab builds directly on the Week 9 application. By the end, the app will have:

LO 1 MVC Refactor

  • Extract a ProductService layer โ€” separating business logic from the Managed Bean
  • Bean becomes Controller; Service owns the Model operations

LO 2 & 3 Navigation

  • Explicit navigation rules in faces-config.xml
  • Live comparison of forward vs ?faces-redirect=true
  • Login/logout flow using outcome strings

LO 4 Converters & Validators

  • Custom Converter: enforces PRD-#### product code format
  • Custom Validator: rejects duplicate product names
  • Both wired into the existing Add Product form

LO 5 HTTP Filter

  • Login page with hardcoded credentials
  • AuthFilter protects /products.xhtml and /addProduct.xhtml
  • Unauthenticated users are redirected to /login.xhtml
Prerequisites
  • The Week 9 lab project (Week9Lab) is complete and deploys successfully on GlassFish 7
  • You understand @Named, @SessionScoped, h:form, h:dataTable, and f:validate*
  • No database is required โ€” data is still held in memory
Working on a clean copy
Before starting, right-click the Week 9 project in NetBeans โ†’ Copyโ€ฆ โ†’ rename it Week10Lab. All work in this lab goes into the new copy, leaving Week 9 intact.
Setup

Preparing the Project

Copy the Week 9 project, verify it still runs, then review the new files you will create.

Copy and Open the Project

1
Copy Week 9 into Week10Lab

In the NetBeans Projects panel, right-click Week9Lab โ†’

In the dialog, set Project Name: Week10Lab and click Copy.

2
Update the pom.xml artifactId

Open pom.xml in the new project. Change the <artifactId> and <name> (if present) to Week10Lab. Save.

3
Verify the copy still deploys

Right-click Week10Lab โ†’ Clean and Build, then Run. Confirm the Product Manager app loads at http://localhost:8080/Week10Lab/ before making any changes.

New Files You Will Create in This Lab

Week10Lab/src/main/
  java/com/university/week9/
    bean/
      ProductBean.java
      LoginBean.java
    model/
      Product.java (unchanged)
    service/ProductService.java
    converter/ProductCodeConverter.java
    validator/UniqueNameValidator.java
    filter/AuthFilter.java
  webapp/
    WEB-INF/
      web.xml (unchanged)
      beans.xml (unchanged)
      faces-config.xml
    templates/layout.xhtml (unchanged)
    index.xhtml (unchanged)
    products.xhtml
    addProduct.xhtml
    login.xhtml
Task 1 โ€” MVC Design Pattern

Refactor to a Proper MVC Layer Structure

Currently, ProductBean acts as both Controller and Model โ€” it holds the list AND manipulates it. We will extract a ProductService (Model layer) so the Bean becomes a pure Controller.

Why this matters

MVC's rule: the Controller (Managed Bean) handles requests and navigation. The Model (Service + entities) owns data and business logic. If you later replace the web interface with a REST API, the Service works unchanged โ€” because it knows nothing about HTTP or Jakarta Faces.

Create ProductService.java

Right-click Source Packages โ†’ New โ†’ Java Class
Class Name: ProductService   Package: com.university.week9.service

src/main/java/com/university/week9/service/ProductService.java Java
package com.university.week9.service;

import com.university.week9.model.Product;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Named;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;

/**
 * โ”€โ”€ MODEL LAYER โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 * ProductService owns all product data and business operations.
 *
 * @ApplicationScoped: ONE instance for the entire application lifetime.
 * This means ALL users share the same product list โ€” correct for a shared
 * product catalogue. (Compare: @SessionScoped would give each user their own
 * separate list โ€” wrong for shared data.)
 *
 * This class knows NOTHING about Jakarta Faces, HTTP, or web pages.
 * That separation is the point of MVC.
 * โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 */
@Named
@ApplicationScoped
public class ProductService implements Serializable {

    private final List<Product> products = new ArrayList<>();
    private int nextId = 6; // starts after the 5 sample products

    // โ”€โ”€ Sample data loaded once when the application starts โ”€โ”€
    public ProductService() {
        products.add(new Product(1, "PRD-0001", "Laptop Pro 15",   "Electronics", 1299.99, 15));
        products.add(new Product(2, "PRD-0002", "Office Chair",    "Furniture",   349.00,  8));
        products.add(new Product(3, "PRD-0003", "USB-C Hub",       "Electronics",  49.95,  42));
        products.add(new Product(4, "PRD-0004", "Standing Desk",   "Furniture",   699.00,  5));
        products.add(new Product(5, "PRD-0005", "Wireless Mouse",  "Electronics",  35.99,  30));
    }

    // โ”€โ”€ READ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

    public List<Product> findAll() {
        return new ArrayList<>(products); // defensive copy
    }

    public boolean nameExists(String name) {
        return products.stream()
                       .anyMatch(p -> p.getName()
                                       .equalsIgnoreCase(name.trim()));
    }

    // โ”€โ”€ WRITE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

    public void addProduct(String code, String name,
                           String category, double price, int qty) {
        products.add(new Product(nextId++, code, name, category, price, qty));
    }

    public boolean deleteById(int id) {
        return products.removeIf(p -> p.getId() == id);
    }
}
Update Product.java โ€” add the code field

The service now stores a product code. Open Product.java and add a code field with getter and setter, then update the constructor to accept code as the second parameter (after id):

src/main/java/com/university/week9/model/Product.java โ€” updated constructor and field Java
// Add this field alongside the existing ones:
private String code;

// Replace the existing constructor with this one:
public Product(int id, String code, String name,
               String category, double price, int quantity) {
    this.id       = id;
    this.code     = code;
    this.name     = name;
    this.category = category;
    this.price    = price;
    this.quantity = quantity;
}

// Add getter and setter:
public String getCode()                { return code; }
public void   setCode(String code)     { this.code = code; }

Rewrite ProductBean.java as a Pure Controller

The Managed Bean now uses the service instead of owning the data. Replace the entire contents of ProductBean.java:

src/main/java/com/university/week9/bean/ProductBean.java Java
package com.university.week9.bean;

import com.university.week9.model.Product;
import com.university.week9.service.ProductService;
import jakarta.enterprise.context.SessionScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.Serializable;
import java.util.List;

/**
 * โ”€โ”€ CONTROLLER LAYER โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 * ProductBean is now a pure controller. It no longer stores the product list
 * or contains business logic. Its only jobs are:
 *   1. Hold the form field values entered by the user
 *   2. Delegate save/delete operations to ProductService
 *   3. Return navigation outcomes
 *
 * @Inject asks CDI to supply the single @ApplicationScoped ProductService.
 * โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 */
@Named
@SessionScoped
public class ProductBean implements Serializable {

    // CDI injects the shared ProductService automatically
    @Inject
    private ProductService productService;

    // โ”€โ”€ Form fields for "Add Product" โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    private String newCode     = "";
    private String newName     = "";
    private String newCategory = "";
    private double newPrice    = 0.0;
    private int    newQuantity = 0;
    private String successMessage = "";

    // โ”€โ”€ Delegate read to service โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    public List<Product> getProducts() {
        return productService.findAll();
    }

    // โ”€โ”€ Action method: called by h:commandButton in addProduct.xhtml โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    public String addProduct() {
        productService.addProduct(newCode, newName, newCategory,
                                  newPrice, newQuantity);
        successMessage = "Product \"" + newName + "\" added successfully!";
        resetForm();
        // "success" is mapped to products.xhtml via faces-config.xml (Task 2)
        return "success";
    }

    public String deleteProduct(int id) {
        productService.deleteById(id);
        successMessage = "";
        return "success";
    }

    private void resetForm() {
        newCode = ""; newName = ""; newCategory = "";
        newPrice = 0.0; newQuantity = 0;
    }

    // โ”€โ”€ Expose service to Facelets for the validator (Task 4) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    public ProductService getProductService() { return productService; }

    // โ”€โ”€ Getters & Setters for form fields โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    public String getNewCode()               { return newCode; }
    public void   setNewCode(String c)        { this.newCode = c; }

    public String getNewName()               { return newName; }
    public void   setNewName(String n)        { this.newName = n; }

    public String getNewCategory()           { return newCategory; }
    public void   setNewCategory(String c)    { this.newCategory = c; }

    public double getNewPrice()              { return newPrice; }
    public void   setNewPrice(double p)      { this.newPrice = p; }

    public int  getNewQuantity()             { return newQuantity; }
    public void setNewQuantity(int q)        { this.newQuantity = q; }

    public String getSuccessMessage()        { return successMessage; }
    public void   setSuccessMessage(String m) { this.successMessage = m; }
}
MVC checkpoint โ€” Clean and Build

Run Clean and Build. If there are no red errors in the Output panel, your MVC refactor is structurally correct. The app may not fully run yet โ€” the products.xhtml table needs its Code column added, which happens in Task 3. Continue to Task 2 now.

Task 2 โ€” Page Navigation

Explicit Navigation with faces-config.xml

Map outcome strings like "success" and "loginRequired" to specific pages using a central navigation configuration file, then observe the difference between forward and redirect.

Create faces-config.xml

Right-click WEB-INF โ†’ New โ†’ Other โ†’ JavaServer Faces โ†’ JSF Faces Configuration.
Name it faces-config. Replace all generated content with:

src/main/webapp/WEB-INF/faces-config.xml XML
<?xml version="1.0" encoding="UTF-8"?>
<faces-config
    xmlns="https://jakarta.ee/xml/ns/jakartaee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee
    https://jakarta.ee/xml/ns/jakartaee/web-facesconfig_4_0.xsd"
    version="4.0">

    <!--
      NAVIGATION RULE 1: addProduct.xhtml
      When addProduct() returns "success" โ†’ go to products.xhtml (with redirect).
      When it returns "failure"           โ†’ stay on addProduct.xhtml.
    -->
    <navigation-rule>
        <from-view-id>/addProduct.xhtml</from-view-id>

        <navigation-case>
            <from-outcome>success</from-outcome>
            <to-view-id>/products.xhtml</to-view-id>
            <redirect/>  <!-- <redirect/> = same as ?faces-redirect=true -->
        </navigation-case>

        <navigation-case>
            <from-outcome>failure</from-outcome>
            <to-view-id>/addProduct.xhtml</to-view-id>
            <!-- No <redirect/> here = server-side forward, stays on same page -->
        </navigation-case>
    </navigation-rule>

    <!--
      NAVIGATION RULE 2: login.xhtml
      "loginSuccess" โ†’ home page (redirect so URL bar updates).
      "loginFailed"  โ†’ stay on login.xhtml to show error.
    -->
    <navigation-rule>
        <from-view-id>/login.xhtml</from-view-id>

        <navigation-case>
            <from-outcome>loginSuccess</from-outcome>
            <to-view-id>/index.xhtml</to-view-id>
            <redirect/>
        </navigation-case>

        <navigation-case>
            <from-outcome>loginFailed</from-outcome>
            <to-view-id>/login.xhtml</to-view-id>
        </navigation-case>
    </navigation-rule>

</faces-config>
How faces-config.xml navigation works

When addProduct() returns the string "success", JF looks up this file for a navigation rule whose <from-view-id> matches the current page and whose <from-outcome> matches "success". It then navigates to the <to-view-id>. The <redirect/> element instructs JF to issue an HTTP 302 redirect instead of a server-side forward โ€” solving the double-submission problem.

Observing Forward vs Redirect โ€” Live Experiment

Before adding the <redirect/> tag you will be able to observe the problem directly. Follow these steps:

1
Remove <redirect/> temporarily from the "success" case

In faces-config.xml, comment out the <redirect/> line under the success case. Redeploy.

2
Add a product through the form

Fill in the Add Product form and click Add Product. You are taken to the Product List โ€” but notice the URL bar still shows /addProduct.xhtml, not /products.xhtml.

3
Press the browser's Refresh button (F5)

The browser asks "Resend form data?" โ€” click Yes. A duplicate product appears in the table. This is the Post-Redirect-Get problem in action.

4
Restore <redirect/> and redeploy

Uncomment the <redirect/> line. Redeploy. Repeat the test โ€” this time pressing Refresh reloads the Product List page cleanly with no duplicate.

Task 3 โ€” Custom Converter

Build a ProductCode Converter

Enforce a strict product code format (PRD-####) at the framework level. The converter runs automatically before any validator โ€” you never need to add format-checking code to your bean.

What a Converter does โ€” two directions

getAsObject (Phase 2โ†’3): the browser sends the raw string the user typed, e.g. "prd-0042". Your converter cleans and validates the format, returning a canonical "PRD-0042" โ€” or throwing a ConverterException if the format is wrong.

getAsString (Phase 6): when JF renders the page, it calls this to turn the Java value back into a display string for the browser.

Create ProductCodeConverter.java

Right-click Source Packages โ†’ New โ†’ Java Class
Class Name: ProductCodeConverter   Package: com.university.week9.converter

src/main/java/com/university/week9/converter/ProductCodeConverter.java Java
package com.university.week9.converter;

import jakarta.faces.application.FacesMessage;
import jakarta.faces.component.UIComponent;
import jakarta.faces.context.FacesContext;
import jakarta.faces.convert.Converter;
import jakarta.faces.convert.ConverterException;
import jakarta.faces.convert.FacesConverter;

/**
 * Custom Converter: enforces the product code format PRD-####
 * where #### is exactly 4 digits (e.g. PRD-0001, PRD-0042, PRD-9999).
 *
 * @FacesConverter("productCodeConverter") registers this converter
 * under the ID "productCodeConverter". We reference this ID in the
 * Facelets page using <f:converter converterId="productCodeConverter"/>
 *
 * Converters run BEFORE validators (Phase 2โ€“3 of the JF lifecycle).
 * If conversion fails the validator never runs.
 */
@FacesConverter("productCodeConverter")
public class ProductCodeConverter implements Converter<String> {

    /** Valid format: PRD- followed by exactly 4 digits */
    private static final String PATTERN = "^PRD-\\d{4}$";

    /**
     * Direction 1: String (from browser) โ†’ Java object (for the bean).
     * Called during lifecycle Phase 2 (Apply Request Values).
     *
     * Steps:
     *  1. Trim whitespace the user may have accidentally typed
     *  2. Convert to uppercase so "prd-0001" and "PRD-0001" both work
     *  3. Validate against the regex โ€” throw ConverterException if invalid
     *  4. Return the cleaned, canonical product code
     */
    @Override
    public String getAsObject(FacesContext context,
                              UIComponent component,
                              String value) {

        // Null/empty: let required="true" handle this case
        if (value == null || value.trim().isEmpty()) {
            return null;
        }

        String cleaned = value.trim().toUpperCase();

        if (!cleaned.matches(PATTERN)) {
            // ConverterException tells JF to add this message and skip further phases
            FacesMessage msg = new FacesMessage(
                FacesMessage.SEVERITY_ERROR,
                "Invalid product code.",
                "Format must be PRD-#### (e.g. PRD-0001). Got: " + value
            );
            throw new ConverterException(msg);
        }

        return cleaned;
    }

    /**
     * Direction 2: Java object โ†’ String (for the browser).
     * Called during lifecycle Phase 6 (Render Response).
     * Simply return the string value as-is for display.
     */
    @Override
    public String getAsString(FacesContext context,
                              UIComponent component,
                              String value) {
        return (value != null) ? value : "";
    }
}

Wire the Converter into addProduct.xhtml

Open addProduct.xhtml. Add a new Product Code field above the existing Product Name field. Also add a Code column to products.xhtml.

addProduct.xhtml โ€” add the code field (paste this before the name form-row div)

addProduct.xhtml โ€” new Product Code field to add Facelets / XHTML
<!-- โ”€โ”€ PRODUCT CODE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
     The converter runs BEFORE any validator.
     If the format is wrong (e.g. "abc") the ConverterException
     fires here and validation phases are skipped.
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -->
<div class="form-row">
    <h:outputLabel for="code" value="Product Code: *"/>
    <h:inputText id="code"
                 value="#{productBean.newCode}"
                 required="true"
                 requiredMessage="Product code is required."
                 placeholder="e.g. PRD-0006">
        <!-- Reference the converter by its registered ID -->
        <f:converter converterId="productCodeConverter"/>
    </h:inputText>
    <h:message for="code" styleClass="error"/>
</div>

products.xhtml โ€” add a Code column as the first column inside h:dataTable

products.xhtml โ€” add this column before the ID column Facelets / XHTML
<h:column>
    <f:facet name="header">Code</f:facet>
    <h:outputText value="#{p.code}"/>
</h:column>
Checkpoint โ€” Test the Converter

Redeploy and open the Add Product form. Try these inputs for Product Code:

  • abc โ†’ Error: "Invalid product code. Format must be PRD-####โ€ฆ"
  • prd-0006 (lowercase) โ†’ Accepted and stored as PRD-0006
  • PRD-006 (only 3 digits) โ†’ Error: format mismatch
  • PRD-0006 โ†’ Accepted; product appears in the table with code column
Task 4 โ€” Custom Validator

Build a UniqueNameValidator

Prevent duplicate product names by consulting the ProductService inside a custom Validator. This shows how validators can implement real business rules โ€” not just range checks.

Validator vs Converter โ€” the key difference

A Converter transforms the data type. A Validator checks a rule without changing the value. They run in sequence: conversion first (Phase 2), then validation (Phase 3). If conversion fails, the validator never runs.

Create UniqueNameValidator.java

Right-click Source Packages โ†’ New โ†’ Java Class
Class Name: UniqueNameValidator   Package: com.university.week9.validator

src/main/java/com/university/week9/validator/UniqueNameValidator.java Java
package com.university.week9.validator;

import com.university.week9.service.ProductService;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.component.UIComponent;
import jakarta.faces.context.FacesContext;
import jakarta.faces.validator.FacesValidator;
import jakarta.faces.validator.Validator;
import jakarta.faces.validator.ValidatorException;
import jakarta.inject.Inject;

/**
 * Custom Validator: rejects a product name that already exists in the catalogue.
 *
 * @FacesValidator(value="uniqueNameValidator", managed=true)
 *   value   โ†’ the ID used in <f:validator validatorId="uniqueNameValidator"/>
 *   managed โ†’ tells CDI to manage this bean so @Inject works inside it.
 *             Without managed=true, @Inject would be null and
 *             the service call would throw NullPointerException.
 *
 * This validator runs in Phase 3 (Process Validations).
 * If it throws ValidatorException, Phase 4 and 5 are skipped.
 */
@FacesValidator(value = "uniqueNameValidator", managed = true)
public class UniqueNameValidator implements Validator<String> {

    // Inject the shared product service to check existing names
    @Inject
    private ProductService productService;

    /**
     * Called by FacesServlet during Phase 3.
     *
     * @param context   the current FacesContext
     * @param component the UI component this validator is attached to
     * @param value     the ALREADY-CONVERTED value (String after the converter ran)
     */
    @Override
    public void validate(FacesContext context,
                         UIComponent component,
                         String value) throws ValidatorException {

        if (value == null || value.trim().isEmpty()) {
            return; // let required="true" handle blank values
        }

        if (productService.nameExists(value)) {
            FacesMessage msg = new FacesMessage(
                FacesMessage.SEVERITY_ERROR,
                "Duplicate product name.",
                "A product named \"" + value + "\" already exists. Choose a different name."
            );
            // Throwing ValidatorException adds the message and stops the lifecycle
            throw new ValidatorException(msg);
        }
        // If we reach here, validation passed โ€” no exception thrown
    }
}

Wire the Validator into addProduct.xhtml

Find the existing Product Name <h:inputText> block in addProduct.xhtml and add <f:validator> inside it:

addProduct.xhtml โ€” update the name field (replace the existing name form-row) Facelets / XHTML
<div class="form-row">
    <h:outputLabel for="name" value="Product Name: *"/>
    <h:inputText id="name"
                 value="#{productBean.newName}"
                 required="true"
                 requiredMessage="Product name is required.">
        <f:validateLength minimum="2" maximum="80"/>
        <!--
            Our custom validator โ€” referenced by the ID from @FacesValidator.
            This runs AFTER f:validateLength. If the length check fails,
            this validator does NOT run (JF stops at the first failure).
        -->
        <f:validator validatorId="uniqueNameValidator"/>
    </h:inputText>
    <h:message for="name" styleClass="error"/>
</div>
Checkpoint โ€” Test the Validator
  • Try adding a product with name "Laptop Pro 15" (already exists) โ†’ error: "Duplicate product name."
  • Try adding a product with name "Gaming Keyboard" โ†’ succeeds (unique name)
  • The existing f:validateLength still works โ€” enter a one-character name to confirm
Task 5 โ€” Web Security

Implement Session-Based Login and an HTTP Filter

Build a login page, a LoginBean that creates a session, and an AuthFilter that intercepts every request to protected pages โ€” redirecting unauthenticated users to the login page.

Part A โ€” Create LoginBean.java

Right-click Source Packages โ†’ bean โ†’ New โ†’ Java Class
Class Name: LoginBean   Package: com.university.week9.bean

src/main/java/com/university/week9/bean/LoginBean.java Java
package com.university.week9.bean;

import jakarta.enterprise.context.RequestScoped;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.inject.Named;
import jakarta.servlet.http.HttpSession;

/**
 * Manages login and logout.
 *
 * @RequestScoped: a fresh bean for each HTTP request โ€” correct for a login form.
 * The bean only needs to exist long enough to validate credentials.
 * The actual "are you logged in?" state is stored in the HTTP SESSION,
 * not in this bean. That is why the filter (AuthFilter) can check the session
 * directly without needing this bean to be in scope.
 *
 * IMPORTANT โ€” hardcoded credentials are used here for simplicity.
 * In a real application, passwords must be hashed (e.g. BCrypt) and
 * stored in a database. Never store passwords in plain text.
 */
@Named
@RequestScoped
public class LoginBean {

    /** The session attribute key used by both LoginBean and AuthFilter */
    public static final String SESSION_USER_KEY = "loggedInUser";

    private String username = "";
    private String password = "";

    // โ”€โ”€ Action: called by the login form's commandButton โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    public String login() {
        // Hardcoded credentials โ€” replace with DB lookup in a real app
        if ("admin".equals(username) && "password123".equals(password)) {

            // Store the username in the HTTP session โ€” this is the "login flag"
            HttpSession session = (HttpSession) FacesContext.getCurrentInstance()
                    .getExternalContext().getSession(true);
            session.setAttribute(SESSION_USER_KEY, username);

            return "loginSuccess"; // mapped in faces-config.xml โ†’ index.xhtml
        }

        // Wrong credentials: add a message and stay on login page
        FacesContext.getCurrentInstance().addMessage(null,
            new FacesMessage(FacesMessage.SEVERITY_ERROR,
                "Login failed.",
                "Username or password is incorrect."));

        return "loginFailed"; // mapped in faces-config.xml โ†’ stay on login.xhtml
    }

    // โ”€โ”€ Action: invalidate session and return to login โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    public String logout() {
        FacesContext fc = FacesContext.getCurrentInstance();
        HttpSession session = (HttpSession) fc.getExternalContext().getSession(false);
        if (session != null) {
            session.invalidate(); // destroys the entire session
        }
        return "/login.xhtml?faces-redirect=true";
    }

    // โ”€โ”€ Getters & Setters โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    public String getUsername()             { return username; }
    public void   setUsername(String u)     { this.username = u; }

    public String getPassword()             { return password; }
    public void   setPassword(String p)     { this.password = p; }
}

Part B โ€” Create login.xhtml

Right-click Web Pages โ†’ New โ†’ XML/XHTML file โ†’ name it login.xhtml:

src/main/webapp/login.xhtml Facelets / XHTML
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:f="jakarta.faces.core">
<h:head>
    <title>Login โ€” Product Manager</title>
    <style>
        body { font-family: Arial, sans-serif; background: #f0f0f0; margin:0; }
        .login-wrap { display:flex; justify-content:center; align-items:center; min-height:100vh; }
        .login-box {
            background: #fff; border-radius: 8px; padding: 40px 44px;
            box-shadow: 0 2px 12px rgba(0,0,0,0.12); width: 360px;
        }
        .login-box h2 { color: #c0392b; margin-bottom: 6px; font-size:1.4rem; }
        .login-box .subtitle { color: #888; font-size: .88rem; margin-bottom: 24px; }
        .form-row { margin-bottom: 16px; }
        .form-row label { display:block; font-weight:bold; margin-bottom:4px; font-size:.9rem; }
        .form-row input { width:100%; padding:9px 10px; border:1px solid #ccc; border-radius:4px; font-size:.95rem; }
        .btn { width:100%; background:#c0392b; color:white; border:none; padding:11px; border-radius:4px; cursor:pointer; font-size:1rem; margin-top:8px; }
        .btn:hover { background:#a93226; }
        .error-msg { color:#c0392b; font-size:.88rem; margin-top:3px; }
    </style>
</h:head>
<h:body>
<div class="login-wrap">
    <div class="login-box">

        <h2>Product Manager</h2>
        <div class="subtitle">Sign in to continue</div>

        <!-- Global error message (wrong credentials) -->
        <h:messages globalOnly="true" styleClass="error-msg" layout="list"/>

        <h:form id="loginForm">

            <div class="form-row">
                <h:outputLabel for="uname" value="Username"/>
                <h:inputText id="uname"
                             value="#{loginBean.username}"
                             required="true"
                             requiredMessage="Username is required."/>
                <h:message for="uname" styleClass="error-msg"/>
            </div>

            <div class="form-row">
                <h:outputLabel for="pw" value="Password"/>
                <h:inputSecret id="pw"
                               value="#{loginBean.password}"
                               required="true"
                               requiredMessage="Password is required."/>
                <h:message for="pw" styleClass="error-msg"/>
            </div>

            <h:commandButton value="Sign In"
                             action="#{loginBean.login}"
                             styleClass="btn"/>

        </h:form>

        <p style="font-size:.8rem; color:#aaa; margin-top:20px; text-align:center;">
            Hint: username admin, password password123
        </p>
    </div>
</div>
</h:body>
</html>

Part C โ€” Create AuthFilter.java

This is the most important class in this task. Right-click Source Packages โ†’ New โ†’ Java Class
Class Name: AuthFilter   Package: com.university.week9.filter

src/main/java/com/university/week9/filter/AuthFilter.java Java
package com.university.week9.filter;

import com.university.week9.bean.LoginBean;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.IOException;

/**
 * โ”€โ”€ SECURITY LAYER โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 * AuthFilter intercepts EVERY request to the URL patterns listed in @WebFilter
 * BEFORE FacesServlet and your Managed Beans ever see it.
 *
 * @WebFilter(urlPatterns = {"/products.xhtml", "/addProduct.xhtml"})
 *   Protects only these two pages. The home page (index.xhtml) and
 *   login.xhtml are intentionally NOT in this list โ€” they must remain
 *   accessible without login.
 *
 * Sequence of events per protected request:
 *   Browser โ†’ AuthFilter.doFilter() โ†’ [allowed?] โ†’ FacesServlet โ†’ Page
 *                                    โ†’ [denied]  โ†’ redirect to login.xhtml
 * โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 */
@WebFilter(urlPatterns = {"/products.xhtml", "/addProduct.xhtml"})
public class AuthFilter implements Filter {

    /**
     * doFilter is called for every request matching the urlPatterns above.
     *
     * @param request  the incoming HTTP request
     * @param response the outgoing HTTP response (we use this to redirect)
     * @param chain    the filter chain โ€” calling chain.doFilter() passes the
     *                 request through to FacesServlet (and ultimately your page)
     */
    @Override
    public void doFilter(ServletRequest request,
                         ServletResponse response,
                         FilterChain chain)
            throws IOException, ServletException {

        // Cast to HTTP-specific types to access session and redirect
        HttpServletRequest  httpReq  = (HttpServletRequest)  request;
        HttpServletResponse httpResp = (HttpServletResponse) response;

        // Get the existing session โ€” false means "don't create one if missing"
        HttpSession session = httpReq.getSession(false);

        // Check whether the session holds our login flag
        boolean loggedIn = (session != null)
                        && (session.getAttribute(LoginBean.SESSION_USER_KEY) != null);

        if (loggedIn) {
            // โœ“ User is authenticated โ€” pass the request through to FacesServlet
            chain.doFilter(request, response);
        } else {
            // โœ— Not logged in โ€” redirect to the login page
            // getContextPath() returns e.g. "/Week10Lab"
            // We build the full path: /Week10Lab/login.xhtml
            String loginUrl = httpReq.getContextPath() + "/login.xhtml";
            httpResp.sendRedirect(loginUrl);
        }
    }

    // init and destroy are optional โ€” leave empty unless you need startup/cleanup
    @Override public void init(FilterConfig fc) {}
    @Override public void destroy() {}
}

Part D โ€” Add a Logout Button to the Nav Bar

Open templates/layout.xhtml. In the .nav div, add a logout button after the existing links:

templates/layout.xhtml โ€” add to the nav div Facelets / XHTML
<!-- Add this after the existing h:link elements -->
<h:form style="display:inline; margin:0;">
    <h:commandButton
        value="Logout"
        action="#{loginBean.logout}"
        style="background:transparent; border:1px solid #888;
               color:#ccc; padding:4px 12px; border-radius:4px;
               cursor:pointer; font-size:.9rem; margin-left:auto;"/>
</h:form>
Why h:form is needed around the Logout button
h:commandButton always submits a form โ€” it cannot exist outside an h:form. Since the nav bar is in the template (not in any page's form), we wrap just the logout button in its own minimal h:form.
Checkpoint โ€” Test the complete security flow
  1. Redeploy the application
  2. Navigate directly to http://localhost:8080/Week10Lab/products.xhtml โ€” you should be redirected to the login page immediately
  3. Enter wrong credentials โ†’ error message appears, you stay on the login page
  4. Enter admin / password123 โ†’ you are redirected to the home page
  5. Click Product List in the nav โ€” the page loads normally (you are authenticated)
  6. Click Logout โ†’ redirected to login page
  7. Try the browser's Back button โ€” navigating back to products.xhtml redirects you to login again
Verification

End-to-End Testing Checklist

Every item must pass. Work through them in order โ€” later items depend on earlier ones.

MVC Layer Separation

  • ProductService is @ApplicationScoped โ€” all users see the same product list
  • ProductBean uses @Inject to get the service โ€” no new ProductService() call anywhere in the bean
  • ProductService contains no Jakarta Faces imports and no h:/f: references
  • Products added by one browser tab are visible in another tab (proving shared @ApplicationScoped state)

Navigation

  • faces-config.xml is present in WEB-INF/ with both navigation rules
  • After adding a product, the URL bar shows /products.xhtml โ€” not /addProduct.xhtml (redirect confirmed)
  • Pressing Refresh on the product list page does NOT create a duplicate product
  • Login failure keeps the user on /login.xhtml with an error message
  • Successful login redirects to /index.xhtml

Custom Converter

  • Entering abc in the Product Code field shows the converter's error message
  • Entering PRD-006 (3 digits) shows an error
  • Entering prd-0006 (lowercase) is accepted and stored as PRD-0006
  • The Product List table shows a Code column with values like PRD-0001

Custom Validator

  • Entering Laptop Pro 15 (existing name) shows the duplicate name error
  • Entering a unique name like Mechanical Keyboard passes validation and adds the product
  • The validator error message names the duplicate: "A product named 'Laptop Pro 15' already exists"
  • Entering a one-character name still triggers the f:validateLength error (validator ordering confirmed)

HTTP Filter & Security

  • Visiting /products.xhtml without logging in redirects to /login.xhtml
  • Visiting /addProduct.xhtml without logging in redirects to /login.xhtml
  • /index.xhtml is accessible without login (filter does not protect it)
  • Wrong credentials show error message; correct credentials allow access
  • Logout invalidates the session โ€” pressing Back and then refreshing redirects to login

Final File Structure

Week10Lab/src/main/
  java/com/university/week9/
    bean/ProductBean.java โœ“
    bean/LoginBean.java โœ“
    model/Product.java โœ“
    service/ProductService.java โœ“
    converter/ProductCodeConverter.java โœ“
    validator/UniqueNameValidator.java โœ“
    filter/AuthFilter.java โœ“
  webapp/
    WEB-INF/
      web.xml โœ“   beans.xml โœ“   faces-config.xml โœ“
    templates/layout.xhtml โœ“
    index.xhtml โœ“   products.xhtml โœ“   addProduct.xhtml โœ“   login.xhtml โœ“
Troubleshooting

Common Errors and How to Fix Them

Error / SymptomMost Likely CauseFix
NullPointerException inside UniqueNameValidator at productService.nameExists() managed=true is missing from the @FacesValidator annotation โ€” CDI cannot inject into it Change to @FacesValidator(value="uniqueNameValidator", managed=true). Without managed=true, @Inject is ignored and productService stays null.
Converter error appears for valid codes like "PRD-0001" when the form first loads (on page render, not submit) The getAsString method is returning null or throwing an error Ensure getAsString returns "" (empty string) when value is null: return (value != null) ? value : "";
After adding a product, the URL bar still shows /addProduct.xhtml The <redirect/> element is missing inside the "success" navigation case in faces-config.xml Ensure the success navigation case in faces-config.xml contains <redirect/> as a child of <navigation-case>.
Visiting /products.xhtml goes to the page directly without login (filter not running) The @WebFilter annotation URL pattern doesn't match, or beans.xml is misconfigured Check @WebFilter(urlPatterns={"/products.xhtml","/addProduct.xhtml"}) โ€” the leading slash is required. Also ensure beans.xml has bean-discovery-mode="all".
Logout button has no effect โ€” page stays the same The h:commandButton for logout is not inside an h:form Wrap the logout h:commandButton in its own <h:form style="display:inline">...</h:form> directly in the template nav bar.
@Inject in ProductBean gives NullPointerException โ€” productService is null CDI is not active โ€” beans.xml is missing or has wrong namespace/mode Confirm src/main/webapp/WEB-INF/beans.xml exists with bean-discovery-mode="all" and the jakarta namespace URI (not javax). Clean and rebuild.
Product list is empty after login (0 rows shown) ProductService is being instantiated multiple times โ€” the sample data constructor is not running once Confirm @ApplicationScoped is on ProductService (not @SessionScoped or @RequestScoped). Also check that getProducts() in ProductBean calls productService.findAll() and not a local field.
faces-config.xml navigation rules are ignored โ€” implicit navigation still used The outcome string returned by the action method doesn't match the <from-outcome> in the config, or the file has the wrong namespace Ensure the config uses xmlns="https://jakarta.ee/xml/ns/jakartaee" and version="4.0". Verify the outcome string (e.g., "success") exactly matches โ€” case-sensitive.
Error: "The class ... is not a Converter" at deployment ProductCodeConverter does not implement Converter<String>, or the interface is from the wrong package Ensure the implements clause reads implements Converter<String> and the import is jakarta.faces.convert.Converter (not javax.faces.*).
Ampersand & in faces-config.xml causes XML parse error at deployment Raw & in an XML file is illegal โ€” must be escaped Replace any raw & in XML with &amp;. This applies to all XML files: web.xml, beans.xml, faces-config.xml, and .xhtml pages.
Extension Tasks

Going Further

Extension 1 โ€” Role-Based Access Control

Extend the security model so that there are two roles: admin and viewer. Add a second hardcoded user viewer/view123. Store both the username and role in the session. Modify AuthFilter so that:

  • /products.xhtml โ€” accessible to both roles
  • /addProduct.xhtml โ€” accessible only to admin; viewer is redirected to an accessDenied.xhtml page you create

Hint: Store the role as a second session attribute: session.setAttribute("userRole", role);. In the filter, read both attributes to make the decision.

Extension 2 โ€” Composite Validator (Code Uniqueness)

Currently, the product code converter enforces the format but does not check for duplicates. Create a second validator called UniqueCodeValidator (following the same pattern as UniqueNameValidator) that checks whether the product code already exists in the service. Wire it into the Product Code field alongside the converter.

Verify: adding a product with code PRD-0001 (already taken by "Laptop Pro 15") should show a validator error.

Extension 3 โ€” Logging Filter

Create a second filter called RequestLoggingFilter that applies to all requests (urlPatterns="/*"). It should log the following to System.out for every request:

  • Timestamp
  • HTTP method (GET or POST)
  • Request URI
  • Username from the session (or "anonymous" if not logged in)
  • Time taken to process the request in milliseconds (capture before and after chain.doFilter())

View the output in NetBeans by opening the GlassFish Server tab in the Output panel while navigating the app.

Key insight: A filter can call code both before and after chain.doFilter(). Everything before the call runs on the way in; everything after runs on the way out.

Extension 4 โ€” Session Timeout Handling

Set the session timeout to 1 minute in web.xml:

<!-- Add inside <web-app> in web.xml -->
<session-config>
    <session-timeout>1</session-timeout> <!-- minutes -->
</session-config>

Log in, wait 90 seconds, then attempt to navigate to the product list. Observe what happens. Does the filter correctly redirect you? Does clicking the Logout button after session expiry throw a ViewExpiredException? Handle the expired session gracefully by catching the exception in the filter and redirecting to the login page with a ?timeout=true query parameter. Display a "Your session has expired, please log in again" message on the login page when this parameter is present.

COIT20259 Enterprise Computing โ€” Week 10 Lab Tutorial  ยท  Jakarta EE 10 / GlassFish 7 / Apache NetBeans