Encodera

Спасибо, что читаете!

Пожертвуйте сайту, мы стараемся ради вас.

Оглавление

Куки и сессии

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/theme
HTTP/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/new
HTTP/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/new
HTTP/1.1 303 See Other
Location: /login

Это нормально для разработки. Если сервер часто перезапускают или их несколько, сессии хранят в базе данных или Redis, а в куку кладут только номер.

Спасибо, что читаете!

Пожертвуйте сайту, мы стараемся ради вас.