Продолжаю работу со своим пет-проектом, в котором создаются и редактируются музыкальные чарты. И в этой статье буду проект расширять и делать многопользовательским, потому что на данный момент работает он локально, в нём есть только один пользователь (тот который запустил приложение у себя на компьютере, то есть я), да и чарт у пользователя может быть всего один.
Задача эта актуальна и для многих других приложений, которые должны предполагать использование несколькими пользователями и сохранение ими данных под своими именами. Это могут быть приложения типа трекеров активности, трекеров питания и калорий и т. д. Там в одни и те же таблицы добавляют данные разные пользователи, и каждый хочет видеть только свои данные – сегодня мы с этим и поработаем.
План статьи (пойдём от данных к фронту):
- Добавить сущности, которые позволят хранить информацию о пользователе.
- Переработать DAO и сервисы, чтобы они учитывали фильтрацию по пользователю (в моём проекте – по чарту, который привязан к пользователю).
- Поработать с сессиями: будем там хранить информацию о выбранном чарте и пользователе, и доставать оттуда данные, чтобы передать в DAO и сервисы.
- Добавить ограничение прав на основе сессий – дать доступ к некоторым страницам только владельцу/создателю чарта.
- Добавить фильтр с ограничением доступа незалогиненных пользователей, обозначить публичные страницы, которые доступны всем.
- Сделать страницу логина, сервлеты логина и логаута, которые необходимы для корректной работы фильтра.
Выбрал я такой порядок, потому что наша цель – сделать из однопользовательского приложения многопользовательское, а не просто прикрутить авторизацию и фильтровать доступ.
Минус такого подхода в том, что сначала нужно сделать все шаги, и только потом полноценно можно всё проверить. Плюс в том, что мы сразу будем видеть, что и где нам не хватает, и каждый последующий шаг будет логичным. Мне лично так больше нравится, чем сначала создать страницу логина, потом сессию, а потом впопыхах искать, куда нужно передавать оттуда данные.
Реализация многопользовательской архитектуры
Для начала разберёмся, что мы хотим сделать, так сказать, в теории. На данный момент в моей базе есть таблица Chart для конкретного выпуска чарта, Position для позиций внутри чарта и Song для песен, которые в чарте на позициях появляются.
Очевидно, что теперь появляется ещё две сущности: пользователь User и ChartInfo с информацией о чарте, которая позволит каждому пользователю иметь сразу несколько чартов (по аналогии с Billboard, например, топ танцевальных треков, топ рок-треков и т. д.). В других задачах это могут быть несколько туду-листов на каждого пользователя: для работы и для обычной жизни, например.
Может быть соблазн не сваливать все данные в одну таблицу и каждому пользователю заводить свои таблицы (например Position_1, Position_2) – тогда в уже существующих запросах HQL нам нужно будет не менять ничего, кроме имени таблицы, к которой мы обращаемся. И хотя мне сначала это казалось неплохой идеей, логичнее оставить для каждой сущности по одной таблице. Это лучше с практической точки зрения: если мы решаем добавить какую-то колонку, то не нужно менять кучу таблиц, так легче работать с общей статистикой по всему приложению (не надо собирать данные по всем пользователям отдельно) и меньше вероятности ошибки в DAO и моделях – никаких монструозных конструкций.
Вместо этого я просто создаю таблицы и соответствующие модели.
Создание сущностей под пользователя
В пользователе храним логин, пароль, ник, биографию, аватарку (адрес) и короткий слоган – заголовок профиля.
@Entity
@Table(name="user")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name="idUser")
private int id;
@Column(name="login")
private String login;
@Column(name="password")
private String password;
@Column(name="nickname")
private String nickname;
@Column(name="bio")
private String bio;
@Column(name="slogan")
private String slogan;
@Column(name="avatar")
private String avatar;
@OneToMany(mappedBy = "owner", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<ChartInfo> charts;
<...>
Пользователь оказывается связан с ChartInfo – мы знаем, какими чартами он владеет, и также знаем, кто владелец конкретного чарта. Кроме User в информации по чартам указываем заголовок и описание.
@Entity
@Table(name="chart_info")
public class ChartInfo {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name="idChartInfo")
private int id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "owner")
private User owner;
@Column(name="title")
private String title;
@Column(name="description")
private String description;
<...>
И связываем новые сущности с существующей Chart:
@Entity
@Table(name="chart")
public class Chart {
@Id
@Column(name="idChart")
private long id;
@Column(name="Date")
private String date;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "idinfo")
private ChartInfo info;
@OneToMany(mappedBy = "pk.chart", cascade = CascadeType.ALL)
private List<Position> positions;
Теперь получаем такую структуру: на позицию помещаем песню, и позиции связаны с выпуском чарта, выпуск связан с информацией о чарте, а информация связана с пользователем.
Song ←Position → Chart → ChartInfo → User
Переработка сервисов и DAO
Теперь довольно муторный шаг. Нужно во все запросы (где это нужно) добавить фильтрацию по chartInfo и user.
Например, чтобы достать все позиции, нужно сделать так:
public static List<Position> getAll(int chartInfoId) {
Session session = HibernateUtil.getSessionFactory().openSession();
session.beginTransaction();
Query query = session.createQuery(
"FROM Position p " +
"LEFT JOIN FETCH p.pk.chart c " +
"LEFT JOIN FETCH p.pk.song s " +
"WHERE c.info.id = :ci");
query.setInteger("ci", chartInfoId);
List<Position> list = query.list();
session.getTransaction().commit();
session.close();
return list;
}
Раньше этот метод не требовал никаких аргументов, но теперь мы хотим доставать позиции только для конкретного чарта. Ничего сложного нет, просто добавляем дополнительное условие WHERE.
То же для методов по сбору статистики, например, хиты, которые провели в чарте дольше всего (опять же, нас интересует конкретный чарт, а не количество недель для песни во всех чартах):
public static List<Song> getLongestSongs(int chartInfoId) {
Session session = HibernateUtil.getSessionFactory().openSession();
session.beginTransaction();
Query query = session.createQuery(
"SELECT DISTINCT s " +
"FROM Position p " +
"JOIN p.pk.song s " +
"WHERE p.pk.chart.info.id = :ci " +
"ORDER BY s.weeks DESC, s.peak ASC");
query.setInteger("ci", chartInfoId);
query.setMaxResults(50);
List<Song> list = query.list();
session.getTransaction().commit();
session.close();
return list;
}
Таким образом за несколько часов я изменил все методы в DAO и сервисах. Но эти слои не должны знать, откуда берётся chartInfoId. Мы в DAO дописали только дополнительные условия, и в них и в сервисах поменяли сигнатуры методов – добавили дополнительный аргумент. Откуда же брать это значение? Лучше всего его хранить в сессии и доставать в нужный момент в сервлете.
Сессии
Сессия – это объект, который хранит информацию о сеансе конкретного пользователя. Она связывает его HTTP-запросы с пользователем/браузером, хотя сам по себе запрос stateless.
То есть изначально в моём приложении происходит такая логика: я открываю чарт, открываю страницу редактирования, сохраняю изменения, и сервер всё это время даже не подозревает, кто это всё делает:
GET /chart
GET /chartedit
POST /chartedit
Каждый запрос он обрабатывает отдельно и ему, собственно, без разницы, кто это всё делает. В ситуации, когда пользователь один или данные общие, нас это тоже в общем-то не интересует. Но теперь, когда мы хотим разделить разные чарты и пользователей, нам очень нужно знать, кто конкретно открыл чарт, может ли он вообще редактировать его, и в какой потом он сохраняет данные.
Для этого в нашем приложении мы добавим страницу логина и фильтр, но первым делом нужно сделать сессию.
Откуда берётся session (механика: JSESSIONID)
Пользователь делает первый запрос на сайт ,и после этого сервер может создать новую сессию и отправляет браузеру cookie вроде:
Set-Cookie: JSESSIONID=123 ; Path=/; HttpOnly;
Браузер сохраняет cookie и добавляет её в следующие запросы. Сервер по JSESSIONID находит объект HttpSession (у меня он в памяти контейнера Tomcat).
То есть Session хранится на сервере, а браузер хранит только идентификатор. И если cookie потерялась/просрочилась – для сервера это новый пользователь (новая сессия). Поэтому иногда сайты просят нас повторно зайти в профиль, забывая, кто мы.
Если куки отключены, то возможен URL-rewriting (;jsessionid=456), но в эти подробности вдаваться не будем.
Работа с HttpSession в Java
Получить сессию через метод getSession(), который создаёт сессию, если её не было, или getSession(false), который в таком случае вернёт null.
HttpSession session = request.getSession();
HttpSession session = request.getSession(false);
При логине мы обычно создаём новую сессию: getSession(). А в фильтрах/проверках доступа обычно не создаём лишних сессий: getSession(false)
Чтобы положить данные в сессию (после логина) setAttribute(name, content);
В нашем случае мы будем класть туда айди юзера, его логин/имя и номер chartInfoId, с которым работам:
HttpSession session = request.getSession();
session.setAttribute("USER_ID", user.getId());
session.setAttribute("USER_LOGIN ", user.getLogin());
Чтобы достать данные из сессии, используем getAttribute(name). Метод возвращает Object, поэтому нужен каст к нужному типу. Если атрибута нет, вернётся null.
Чтобы удалить данные (например, при логауте), можно уничтожить сессию полностью с помощью invalidate() или удалить конкретный атрибут с removeAttribute(name).
Итог: HttpSession – это механизм контейнера (Tomcat), который хранит map атрибутов и метаданные (время создания, lastAccessedTime, maxInactiveInterval). Сессия живёт до таймаута (по умолчанию зависит от контейнера/конфига) или до invalidate().
Реализация работы с сессией в проекте
У нас уже почти все сервисы ожидают от сервлетов данные по чарту, который мы собрались хранить в сессии. Чтобы не дублировать логику с доставанием и размещением значений в сессии (и чтобы легче было в случае чего рефакторить), вынесем работу с сессией в отдельный класс утилит SessionUtil.
Ключи сессии лучше хранить в виде констант, поэтому я создам отдельный класс с константами:
public final class SessionKeys {
private SessionKeys() {}
public static final String USER_ID = "userId";
public static final String USER_LOGIN = "userLogin";
public static final String CHART_INFO_ID = "chartInfoId";
}
Тогда в SessionUtil работаем с этими константами:
public final class SessionUtil {
private SessionUtil() {}
public static Integer getUserId(HttpServletRequest req) {
HttpSession s = req.getSession(false);
Object v = (s == null) ? null : s.getAttribute(SessionKeys.USER_ID);
return (v instanceof Integer) ? (Integer) v : null;
}
public static Integer getChartInfoId(HttpServletRequest req) {
HttpSession s = req.getSession(false);
Object v = (s == null) ? null : s.getAttribute(SessionKeys.CHART_INFO_ID);
return (v instanceof Integer) ? (Integer) v : null;
}
public static int requireUserId(HttpServletRequest req) {
Integer id = getUserId(req);
if (id == null) throw new IllegalStateException("Not authenticated");
return id;
}
public static int requireChartInfoId(HttpServletRequest req) {
Integer id = getChartInfoId(req);
if (id == null) throw new IllegalStateException("Chart not selected");
return id;
}
}
В этом классе мы или просто возвращаем значения из сессии, или ещё проверяем, есть ли они – для сервлетов, где нам необходима заранее авторизация. Ни в одном методе мы не создаём новую сессию, а работаем с уже существующей.
Теперь можем вызывать эти методы из наших сервлетов.
Например, сервлет списка артистов должен показывать только артистов из выбранного чарта:
@WebServlet(name = "artists", value = "/artists")
public class ArtistsServlet extends HttpServlet {
@Override
public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
int chartInfoId = SessionUtil.requireChartInfoId(request);
String searchPhrase = request.getParameter("search");
List<String> artists;
if (searchPhrase != null && !searchPhrase.isEmpty()) {
artists = SongDAOImpl.getArtistsBySearch(chartInfoId, searchPhrase);
} else {
artists = SongDAOImpl.getArtists(chartInfoId);
}
request.setAttribute("artists", artists);
request.setAttribute("search", searchPhrase);
request.getRequestDispatcher("artists.jsp").forward(request, response);
response.flushBuffer();
}
}
Здесь я получаю данные из сессии и передаю их дальше на следующие уровни. Остальные сервлеты изменяются подобным образом.
Только в ChartServlet я хочу не просто доставать данные из сессии, а также их туда сохранять.
На всякий случай проверяю, не пришёл ли параметр в адресной строке. Если пришёл, значит, кладу его в сессию. Если нет, то достаю из сессии уже тот, который лежит.
Integer chartInfoId = null;
String ciStr = request.getParameter("ci");
if (ciStr != null && !ciStr.isEmpty()) {
try {
chartInfoId = Integer.parseInt(ciStr);
} catch (Exception ignored) {}
}
f (chartInfoId == null) {
HttpSession s0 = request.getSession(false);
Object v = (s0 == null) ? null : s0.getAttribute(SessionKeys.CHART_INFO_ID);
if (v instanceof Integer) chartInfoId = (Integer) v;
}
HttpSession s = request.getSession(true);
s.setAttribute(SessionKeys.CHART_INFO_ID, chartInfoId);
Естественно, чтобы данные откуда-то пришли, нужно будет их класть в ссылки, на которые щёлкает пользователь. Вот фрагмент главной страницы, на которой размещён список чартов chartInfos:
<div class="home-box">
<div class="home-box-head">Charts</div>
<div class="home-box-body">
<c:forEach items="${chartInfos}" var="ci" begin="0" end="9">
<a class="side-link" href="/chart?ci=${ci.id}">
${fn:escapeXml(ci.title)}
<span class="muted">(${ci.issuesCount})</span>
</a>
</c:forEach>
</div>
</div>
В этом блоке мы создали сессию, поместили в неё данные и добавили работу с сессией в сервлеты. Посмотрели, как можно просто брать данные из сессии или как их можно класть, если они переменились.
Разграничение прав с помощью сессий
Но у нас есть несколько страниц, на которые не должен попадать никто, кроме владельца чарта. Например, редактирование или добавление нового чарта. Мы не хотим позволять другому пользователю или гостю это делать.
Поэтому создадим класс AuthUtil, который будет брать из сессии данные о чарте и пользователе и сравнивать их с владельцем чарта. Если текущий пользователь, который просматривает чарт, его владелец, то мы пустим его, иначе вернём 403 – Forbidden.
public final class AuthUtil {
private AuthUtil() {}
public static ChartInfo requireOwnedChartInfo(HttpServletRequest req) {
int userId = SessionUtil.requireUserId(req);
int chartInfoId = SessionUtil.requireResolvedChartInfoId(req);
ChartInfo ci = ChartInfoDAOImpl.getById(chartInfoId);
if (ci == null || ci.getOwner() == null || ci.getOwner().getId() != userId) {
throw new ForbiddenException("ChartInfo not owned by user");
}
return ci;
}
public static Chart requireOwnedChart(HttpServletRequest req, long chartId) {
ChartInfo ci = requireOwnedChartInfo(req);
Chart c = ChartDAOImpl.getById(ci.getId(), chartId);
if (c == null || c.getInfo() == null || c.getInfo().getId() != ci.getId()) {
throw new ForbiddenException("Chart not owned by user");
}
return c;
}
public static final class ForbiddenException extends RuntimeException {
public ForbiddenException(String msg) { super(msg); }
}
public static void renderForbidden(HttpServletRequest request, HttpServletResponse response,
String msg, boolean showProfileLink)
throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN); // 403
request.setAttribute("title", "Access denied");
request.setAttribute("message", msg);
request.setAttribute("showProfileLink", showProfileLink);
request.getRequestDispatcher("/WEB-INF/jsp/forbidden.jsp")
.forward(request, response);
}
}
Тогда в ChartAddServlet можем вызывать методы из этого класса перед открытием страницы редактирования.
@WebServlet(name = "chartAdd", value = "/chartadd")
public class ChartAddServlet extends HttpServlet {
@Override
public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
try {
AuthUtil.requireOwnedChartInfo(request);
} catch (AuthUtil.ForbiddenException ex) {
AuthUtil.renderForbidden(request, response,
"You can't add charts to someone else's chart.",
true);
return;
}
int chartInfoId = SessionUtil.requireChartInfoId(request);
String json = new Gson().toJson(SongDAOImpl.getAll(chartInfoId));
request.setAttribute("songs", json);
Long lastId = ChartDAOImpl.getLastId(chartInfoId);
Chart chart = (lastId == null) ? null : ChartDAOImpl.getById(chartInfoId, lastId);
request.setAttribute("chart", chart == null ? null : ChartService.getChartFull(chart));
request.getRequestDispatcher("chartadd.jsp").forward(request, response);
}
@Override
public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
try {
AuthUtil.requireOwnedChartInfo(request);
} catch (AuthUtil.ForbiddenException ex) {
response.sendError(403);
return;
}
String ids[] = request.getParameterValues("idSong[]");
String artists[] = request.getParameterValues("artists[]");
String name[] = request.getParameterValues("name[]");
int chartInfoId = SessionUtil.requireChartInfoId(request);
ChartService.formChart(chartInfoId, ids, name, artists);
response.sendRedirect("/");
response.flushBuffer();
}
}
Обратите внимание на два способа работы с ошибками. В doGet() я вызываю метод renderForbidden(), который отобразит страницу ошибки (не стандартную, а ту, которую предварительно нужно сделать в стиле приложения, чтобы сохранить дизайн). В doPost() просто передаю статус ошибки в response.
Работа с сессией на jsp-странице
Ещё одно преимущество сессий в том, что данные из неё доступны и в нашем фронте – на jsp-страницах. Не нужно дополнительно размещать данные атрибутами реквеста или передавать туда каким-нибудь ajax-ом. Например, я хочу вывести приветствие залогиненому пользователю:
Hey, <b>${sessionScope.userLogin}</b>!
Фильтр AuthFilter
Всё это славно, но все эти проверки мы выполняем в сервлетах. Но у нас есть механизм, который позволяет выполнять проверки ещё до того, как запрос добрался до сервлета или jsp-страницы. Это фильтр.
Поток запроса в Tomcat такой:
Browser → Tomcat → Filter → Servlet/JSP → Response
Tomcat находит все фильтры, подходящие под URL (/*, /admin/*, *.jsp и т. п.) и вызывает первый фильтр doFilter.
Тот либо вызывает chain.doFilter() и запрос идёт дальше по цепочке, либо не вызывает chain.doFilter() и цепочка обрывается (значит, фильтр сам отправляет редирект/ошибку/ответ).
Фильтр должен реализовывать интерфейс Filter и иметь аннотацию @WebFilter(«/*»), где в кавычках мы пишем, какие адреса нужно фильтровать. Или, если вы пишете информацию по сервлетам в web.xml, то добавить фильтр туда:
<filter>
<filter-name>AuthFilter</filter-name>
<filter-class>util.AuthFilter</filter-class>
</filter>
Реализация AuthFilter
В моём фильтре я хочу получать из сессии данные о пользователе и перенаправлять его на страницу логина, если страница закрытая. А если открытая (у меня они в методе isPublic()), то просто продолжать цепочку:
@WebFilter("/*")
public class AuthFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
String uri = request.getRequestURI();
String ctx = request.getContextPath();
String path = (ctx != null && !ctx.isEmpty()) ? uri.substring(ctx.length()) : uri;
HttpSession s = request.getSession(false);
Integer userId = null;
Integer chartInfoId = null;
if (s != null) {
Object u = s.getAttribute(SessionKeys.USER_ID);
Object ci = s.getAttribute(SessionKeys.CHART_INFO_ID);
if (u instanceof Integer) userId = (Integer) u;
if (ci instanceof Integer) chartInfoId = (Integer) ci;
}
boolean loggedInFlag = (userId != null);
request.setAttribute("loggedIn", loggedInFlag);
if (isPublic(path)) {
chain.doFilter(req, res);
return;
}
if (!loggedInFlag) {
response.sendRedirect(ctx + "/login");
return;
}
chain.doFilter(req, res);
}
private boolean isPublic(String path) {
if (path == null) return true;
if (path.equals("/") || path.equals("/login")) return true;
if (path.equals("/profile")) return true;
if (path.equals("/chart")) return true;
<...>
return false;
}
}
Таким образом, нам не нужно на каждой странице вызывать AuthUtil или колдовать с сессиями, а доступ к страницам регулируется без дополнительного кода.
Обратите внимание, что я добавил в фильтре атрибут loggedIn, который сообщит фронту, залогинен ли пользователь. Это позволит скрывать или показывать элементы, которые требуют логина. Ведь на бэке мы запрещаем пользователям некоторые действия – было бы логично и на фронте не соблазнять его лишними ссылками.
По идее можно было использовать user_id из сессии для тех же целей, но я хотел показать, что в фильтрах мы можем выполнять дополнительные действия с данными.
С фильтром получаем ситуацию, в которой мы уже контролируем некоторые страницы по содержимому сессии, но общий доступ к ним «фильтруется».
Остался только один момент: добавить саму страницу логина.
Сервлеты логина и логаута.
Возможно, с этого стоило начать, но я решил начать с данных, потом зарефакторить сервисы и DAO, потом добавить данные в сессию и сервлеты, и уже потом мы обратились к фильтрам.
В случае, если у нас в сессии нет юзера, а он нам нужен, или если страница приватная, мы отправляем пользователя по адресу /login (см. прошлый фрагмент кода для AuthFilter):
@WebServlet(name = "login", value = "/login")
public class LoginServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
HttpSession session = request.getSession(false);
if (session != null && session.getAttribute(SessionKeys.USER_ID) != null) {
response.sendRedirect("/profile");
return;
}
request.getRequestDispatcher("login.jsp").forward(request, response);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
String login = request.getParameter("login");
String password = request.getParameter("password");
if (login == null) login = "";
if (password == null) password = "";
User u = UserDAOImpl.getByLogin(login.trim());
if (u == null || u.getPassword() == null || !u.getPassword().equals(password)) {
request.setAttribute("error", "Неверный логин или пароль");
request.setAttribute("login", login);
request.getRequestDispatcher("login.jsp").forward(request, response);
return;
}
HttpSession session = request.getSession(true);
session.setAttribute(SessionKeys.USER_ID, u.getId());
session.setAttribute(SessionKeys.USER_LOGIN, u.getLogin());
session.setAttribute(SessionKeys.CHART_INFO_ID, 1);
response.sendRedirect("/profile");
}
}
Для учебного проекта я не использую никакое шифрование, но разумеется потом его нужно добавить.
Если пользователь уже залогинен, то мы отправляем его в его профиль (сервлет профиля у нас берёт данные из сессии и достанет из базы нужный профиль по user_id из сессии). Если нет, то отправляем на страницу логина.
При получении данных из форм мы или кладём данные в сессию и перенаправляем пользователя в его профиль, либо отправляем обратно на логин, если данные не совпали.
В логауте просто уничтожаем сессию и перенаправляем на домашнюю страницу.
@WebServlet(name = "logout", value = "/logout")
public class LogoutServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
HttpSession s = request.getSession(false);
if (s != null) s.invalidate();
response.sendRedirect("/");
}
}
И страница логина получается такой:
<div class="container">
<h1>TOP40!</h1>
<div class="nav">
<div name="menu" style="margin-bottom:5px;">
<b>Вход в личный кабинет</b>
</div>
</div>
<div class="inner-gray-block login-box">
<div class="sub-header" style="border:0; padding:0; margin-bottom:8px;">
<h2 style="margin:0; color:#000676;">Авторизация</h2>
</div>
<form method="post" action="/login">
<div class="login-row">
<label for="login">Логин</label>
<input id="login" type="text" name="login" value="zimowski" autocomplete="username" required />
</div>
<div class="login-row">
<label for="password">Пароль</label>
<input id="password" type="password" name="password" value="123456" autocomplete="current-password" required />
</div>
<div class="login-actions">
<input type="submit" value="Войти" />
<input type="button" value="Назад" onclick="top.location.href='/login';" />
</div>
<c:if test="${not empty error}">
<div class="error-box">
<b>Ошибка:</b> ${error}
</div>
</c:if>
</form>
</div>
</div>
На этом всё. В этой статье мы превратили проект для одного человека в многопользовательский, переписали весь бэк, учитывая, что данные в таблицах могут принадлежать разным людям, поработали с сессиями, добавили фильтр и разграничили права.