Cookies and sessions
HTTP does not remember earlier requests: each one arrives "from scratch". To recognise a user again, servers use cookies — small name=value pairs that the server sends in the Set-Cookie header and the browser returns with every following request. A session is the user's data kept on the server, while the cookie holds only its id. Basic authentication from the roles lesson is fine for the API; for the blog pages we will build sign-in through a form and a session.
Cookies
// src/main/java/com/example/blog/Main.java
config.routes.post("/theme/{name}", ctx -> {
ctx.cookie("theme", ctx.pathParam("name"), 60 * 60 * 24 * 365);
ctx.result("Saved");
});
config.routes.get("/theme", ctx -> {
String theme = ctx.cookie("theme");
ctx.result("Theme: " + (theme == null ? "light" : theme));
});
config.routes.delete("/theme", ctx -> {
ctx.removeCookie("theme");
ctx.status(204);
});The third argument of ctx.cookie is the lifetime in seconds (a year here). ctx.cookie("theme") reads the cookie from the request, and ctx.removeCookie asks the browser to delete it.
curl -i -X POST http://localhost:8080/theme/dark
curl -b "theme=dark" http://localhost:8080/themeHTTP/1.1 200 OK
Content-Type: text/plain
Set-Cookie: theme=dark; Path=/; Expires=Mon, 4 Oct 2027 11:37:37 GMT; Max-Age=31536000
Saved
Theme: dark
The -b option sends a cookie the way a browser would. The response to DELETE /theme contains Set-Cookie: theme=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT — a date in the past, so the browser deletes the cookie.
For several values there is ctx.cookieStore(): set("visits", 3) and get("visits"). It keeps them in one cookie, javalin-cookie-store, as JSON in Base64, for example eyJ2aXNpdHMiOjF9 — that is {"visits":1}. The user can read and change any cookie, so never keep roles or permissions in them.
Sign-in with a session
The sign-in form is the template src/main/resources/templates/login.mustache with the username and password fields and an {{#error}} block, like the post form. The controller:
// src/main/java/com/example/blog/LoginController.java
public class LoginController {
public void form(Context ctx) {
ctx.render("templates/login.mustache");
}
public void login(Context ctx) {
String username = ctx.formParam("username");
Role role = Auth.login(username, ctx.formParam("password"));
if (role == null) {
ctx.status(HttpStatus.UNAUTHORIZED);
ctx.render("templates/login.mustache", Map.of("error", "Wrong login or password"));
return;
}
ctx.sessionAttribute("user", username);
ctx.sessionAttribute("role", role);
ctx.redirect("/blog", HttpStatus.SEE_OTHER);
}
public void logout(Context ctx) {
ctx.req().getSession().invalidate();
ctx.redirect("/blog", HttpStatus.SEE_OTHER);
}
}ctx.sessionAttribute("role", role) puts a value into the session; Jetty creates the session and sets the JSESSIONID cookie. invalidate() removes the whole session. Add a sign-out button — a form with method="post" and action="/logout" — to posts.mustache.
The role from the session
In Auth, the password check moves to a public login method, and roleOf looks into the session first. A guest on /blog... pages is sent to the sign-in form instead of getting 401:
// src/main/java/com/example/blog/Auth.java
public static Role login(String username, String password) {
User user = username == null ? null : USERS.get(username);
if (user == null || !user.password().equals(password)) {
return null;
}
return user.role();
}
private static Role roleOf(Context ctx) {
Role fromSession = ctx.sessionAttribute("role");
if (fromSession != null) {
return fromSession;
}
BasicAuthCredentials credentials = ctx.basicAuthCredentials();
return credentials == null ? null : login(credentials.getUsername(), credentials.getPassword());
}
// in check(), right after Role role = roleOf(ctx);
if (role == null && ctx.path().startsWith("/blog")) {
ctx.redirect("/login", HttpStatus.SEE_OTHER);
return;
}A redirect in beforeMatched cancels the main handler. Now give the routes their roles:
// src/main/java/com/example/blog/Main.java
var login = new LoginController();
// in the apiBuilder block
get("/blog/new", posts::newForm, Role.USER, Role.ADMIN);
post("/blog", posts::submit, Role.USER, Role.ADMIN);
post("/uploads", uploads::upload, Role.USER, Role.ADMIN);
get("/login", login::form, Role.ANYONE);
post("/login", login::login, Role.ANYONE);
post("/logout", login::logout, Role.ANYONE);Trying it out
The -c option saves received cookies to a file, and -b sends them back. The responses show the status and the main headers:
curl -i http://localhost:8080/blog/new
curl -i -c cookies.txt http://localhost:8080/login -d "username=oleg" -d "password=changeme"
curl -i -b cookies.txt http://localhost:8080/blog/newHTTP/1.1 303 See Other
Location: /login
HTTP/1.1 303 See Other
Set-Cookie: JSESSIONID=node01ve8gobd2790y9b59hr9x58lj0.node0; Path=/; HttpOnly; SameSite=Lax
Location: /blog
HTTP/1.1 200 OK
Content-Type: text/html
The guest was sent to sign in, and after signing in the form opened. An HttpOnly cookie is not available to JavaScript on the page. A wrong password returns 401 and the form with the text "Wrong login or password", and POST /logout removes the session — after that /blog/new redirects to /login again.
Common mistake
Jetty keeps sessions in memory. After a server restart the old JSESSIONID cookie points to a session that no longer exists, and the signed-in user is a guest again:
curl -i -b cookies.txt http://localhost:8080/blog/newHTTP/1.1 303 See Other
Location: /login
This is fine during development. If the server restarts often or there are several servers, sessions are stored in a database or Redis, and the cookie holds only the id.