Куки и сессии
HTTP не помнит прошлых запросов: каждый приходит «с чистого листа». Чтобы сервер узнал пользователя снова, используют куки (cookie) — небольшие пары имя=значение, которые сервер присылает заголовком Set-Cookie, а браузер возвращает с каждым следующим запросом. Сессия — данные пользователя на сервере, а в куке лежит только её номер. Basic-авторизация из урока о ролях хороша для API, а для страниц блога сделаем вход через форму и сессию.
Куки
// 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("Сохранено");
});
config.routes.get("/theme", ctx -> {
String theme = ctx.cookie("theme");
ctx.result("Тема: " + (theme == null ? "light" : theme));
});
config.routes.delete("/theme", ctx -> {
ctx.removeCookie("theme");
ctx.status(204);
});Третий аргумент ctx.cookie — срок жизни в секундах (здесь год). ctx.cookie("theme") читает куку из запроса, ctx.removeCookie просит браузер её удалить.
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:39:43 GMT; Max-Age=31536000
Сохранено
Тема: dark
Ключ -b отправляет куку, как это сделал бы браузер. Ответ на DELETE /theme содержит Set-Cookie: theme=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT — дата в прошлом, и браузер удаляет куку.
Для нескольких значений есть ctx.cookieStore(): set("visits", 3) и get("visits"). Он хранит их в одной куке javalin-cookie-store в виде JSON в Base64, например eyJ2aXNpdHMiOjF9 — это {"visits":1}. Пользователь может прочитать и подменить любую куку, поэтому роли и права в них не храните.
Вход через сессию
Форма входа — шаблон src/main/resources/templates/login.mustache с полями username и password и блоком {{#error}}, как у формы поста. Контроллер:
// 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", "Неверный логин или пароль"));
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) кладёт значение в сессию; Jetty при этом создаёт сессию и ставит куку JSESSIONID. invalidate() удаляет сессию целиком. Кнопку выхода — форму с method="post" и action="/logout" — добавьте в posts.mustache.
Проверка роли из сессии
В Auth проверка пароля переезжает в публичный метод login, а roleOf сначала смотрит в сессию. Гостя на страницах /blog... отправляем на форму входа вместо ответа 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());
}
// в check(), сразу после Role role = roleOf(ctx);
if (role == null && ctx.path().startsWith("/blog")) {
ctx.redirect("/login", HttpStatus.SEE_OTHER);
return;
}Перенаправление в beforeMatched отменяет основной обработчик. Осталось раздать роли маршрутам:
// src/main/java/com/example/blog/Main.java
var login = new LoginController();
// в блоке apiBuilder
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);Проверка
Ключ -c сохраняет полученные куки в файл, -b отправляет их обратно. В ответах показаны статус и главные заголовки:
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=node01rn51o6ddvwn81ed0hp9q2bsnd0.node0; Path=/; HttpOnly; SameSite=Lax
Location: /blog
HTTP/1.1 200 OK
Content-Type: text/html
Гостя отправили на вход, после входа форма открылась. Кука HttpOnly недоступна JavaScript на странице. Неверный пароль вернёт 401 и форму с текстом «Неверный логин или пароль», а POST /logout удалит сессию — и /blog/new снова перенаправит на /login.
Частая ошибка
Jetty хранит сессии в памяти. После перезапуска сервера старая кука JSESSIONID указывает на сессию, которой больше нет, и вошедший пользователь снова гость:
curl -i -b cookies.txt http://localhost:8080/blog/newHTTP/1.1 303 See Other
Location: /login
Это нормально для разработки. Если сервер часто перезапускают или их несколько, сессии хранят в базе данных или Redis, а в куку кладут только номер.