JavaRush /Курсы /Spring Security /Модель UserAccount

Модель UserAccount

Spring Security
14 уровень , 1 лекция
Открыта

1. UserAccount как security-ядро

Если упростить до житейского, то UserAccount — это не «пользователь как человек», а «пользователь как пропуск в систему». Когда запрос приходит в приложение, Spring Security должен ответить на несколько вопросов очень быстро и очень однозначно: как зовут этого пользователя в системе, можно ли его вообще пускать, и какие у него базовые права. Всё остальное — «био», аватарка, любимый цвет кнопок — к этим решениям не относится, как бы нам ни хотелось хранить всё в одном красивом классе User.

Полезно держать в голове, что раньше мы использовали in-memory пользователей как учебный стенд. Там пользователь создавался builder’ом, роли были рядом, пароль был закодирован, и всё это работало. Но с точки зрения продукта такой подход означает, что аккаунты “живут” внутри приложения, как мебель, прибитая к полу: перезапустили сервис — и мебель снова ровно на тех же гвоздях. В реальности же аккаунт должен жить в данных, переживать перезапуски, изменяться администратором, блокироваться, отключаться и при этом не требовать правки Java-конфигурации.

Чтобы не потеряться, можно представить UserAccount как минимальный ответ на вопрос: что именно нужно Spring Security, чтобы принять решение. И тут нам помогает уже знакомый контракт UserDetails: у него есть getUsername(), getPassword(), а также флаги вроде isEnabled() и isAccountNonLocked(). Наша задача — спроектировать такие поля в БД, чтобы эти флаги были не “украшением”, а реальными участниками login flow.

Ниже — очень упрощённая схема того, как UserAccount участвует в auth-решении (без погружения в детали загрузки из БД, потому что сейчас мы проектируем модель):

flowchart TD
    A["Вход: username + password"] --> B["Найдём аккаунт по username"]
    B --> C["Сверим passwordHash через PasswordEncoder"]
    C --> D{"enabled и accountNonLocked"}
    D -->|да| E["Аутентификация успешна: роли доступны для авторизации"]
    D -->|нет| F["Логин отклонён: аккаунт отключён или заблокирован"]

2. Поля UserAccount: минимум

Когда проектируешь сущность, особенно на первых шагах, всегда есть соблазн «добавить ещё чуть-чуть». Сегодня добавим displayName (ну а что), завтра timezone, послезавтра favoriteQuoteFromLinuxManual, и вот уже security-сущность стала похожа на чемодан, куда складывают всё подряд. Это и читаемость ломает, и безопасность ухудшает: чем больше полей в UserAccount, тем выше шанс однажды случайно отдать в ответе “лишнее”. Поэтому сейчас нам нужен набор полей, который отвечает ровно на security-вопросы и на базовые административные сценарии.

Ниже — удобная «карта полей», которую можно воспринимать как чек-лист. Я специально подписываю «зачем поле нужно», потому что поля без причины быстро превращаются в кладбище идей.

Поле Тип Зачем оно нужно именно security-слою
id Long Технический идентификатор в БД, удобен для связей (профиль, контент, файлы).
username String Principal-идентификатор (то, по чему ищем аккаунт при логине).
email String Контакт и отдельный инвариант уникальности; не смешиваем с username.
passwordHash String Храним хеш, а не raw-пароль. Это “пароль” глазами Spring Security.
enabled boolean Можно ли аккаунту проходить нормальный вход (допуск в систему).
accountNonLocked boolean Не заблокирован ли аккаунт (часто — реакция на нарушения/админ-действия).
roles Set<Role> Coarse-grained права: USER, EDITOR, ADMIN.
createdAt Instant Прагматичная вещь: когда создан аккаунт (аудит, отладка, админка).

Самый важный момент в этой таблице — имя passwordHash. Это не косметика и не «как назовёшь, так и поплывёт». Если поле называется password, мозг (особенно мозг новичка после трёх бессонных ночей) может однажды решить, что туда нормально положить raw-пароль. А если поле называется passwordHash, оно каждый раз напоминает: «друг, тут не пароль, тут хеш, успокойся».

Минимальный Java-скелет (пока без JPA-аннотаций, просто чтобы увидеть форму):

import java.util.Set;

public class UserAccount {
    // Технический идентификатор записи в БД (не “бизнес-логин”).
    Long id;

    // То, по чему Spring Security обычно находит пользователя при логине (principal).
    String username;

    // Контакт и отдельная уникальность: не смешиваем с username.
    String email;

    // ВАЖНО: это именно хеш/encoded-пароль, а не raw-строка.
    String passwordHash;

    // Аккаунт “включён” (админ может выключить, не удаляя запись).
    boolean enabled;

    // Аккаунт не заблокирован (отдельная ось от ролей).
    boolean accountNonLocked;

    // Набор ролей для авторизации после успешной аутентификации.
    Set<Role> roles;
}

Да, это выглядит очень “просто”. И это хорошо. В хорошей security-модели простота — не признак бедности, а признак контроля.

3. passwordHash: хранение пароля

У новичков есть вечная внутренняя борьба между «хочу, чтобы работало» и «хочу, чтобы было безопасно». Пароли — то место, где эта борьба чаще всего проигрывается минут за тридцать, особенно если дедлайн уже шепчет в ухо. Но в нашем курсе мы уже договорились: пароль живёт только в encoded form, и PasswordEncoder — единый канал, через который пароль становится тем, что можно хранить. Сейчас мы просто переносим эту дисциплину из in-memory мира в DB-backed мир.

Важно понимать простую вещь: passwordHash — это не “секретная строка в базе”, которую никто не увидит. Во-первых, базы иногда “утекают” (и это происходит чаще, чем кажется). Во-вторых, логи иногда “утекают” (и это тоже больно). В-третьих, дампы базы иногда кто-то кладёт в чат команды “чтобы помочь” (и это отдельный вид фольклора). Поэтому модель должна быть такой, чтобы даже при худшем сценарии raw-пароль не оказался в данных.

И ещё одна практическая причина: Spring Security в username/password модели ожидает, что у пользователя есть “пароль” (формально getPassword() из UserDetails). Но там не “пароль”, а encoded password. Поэтому passwordHash — это то, что будет участвовать в проверке через PasswordEncoder.matches(...).

Небольшой пример, который показывает правильную мысль: входной пароль — raw, а в UserAccount попадает только хеш.

import java.util.Set;
import org.springframework.security.crypto.password.PasswordEncoder;

public UserAccount createDemoUser(String username, String rawPassword, PasswordEncoder encoder) {
    // rawPassword живёт только здесь: мы превращаем его в хеш и дальше не таскаем по системе.
    String hash = encoder.encode(rawPassword);

    // В доменной модели мы сохраняем только результат encode(...), а не исходный пароль.
    return UserAccount.active(username, username + "@example.com", hash, Set.of(Role.USER));
}

Обратите внимание на психологически важный момент: rawPassword живёт ровно столько, сколько нужно, чтобы его захешировать. Дальше он не должен путешествовать по приложению как турист без визы.

4. Состояния: enabled и accountNonLocked

Очень типичный баг в новичковых проектах: “если у пользователя роль ADMIN, он должен иметь доступ ко всему”. Это звучит логично… пока не появляется задача “заблокировать аккаунт админа, потому что его пароль утёк”. И вот тут выясняется, что роль — это про права, а состояние аккаунта — про допуск к нормальному входу. Это две разные оси, и их нужно держать отдельно, иначе вы либо не сможете блокировать аккаунт, либо начнёте блокировать права странными способами вроде “снимем роль ADMIN, но оставим его USER”.

Флаг enabled отвечает на вопрос: «этот аккаунт вообще активен в системе?». Иногда это похоже на “выключатель”: администратор выключил — пользователь больше не может зайти, но запись в базе остаётся. Это удобно для соблюдения логики домена и аудита: аккаунт был, но выключен.

Флаг accountNonLocked отвечает на более “силовой” сценарий: аккаунт может быть активным, но временно заблокирован. Чаще всего это административная мера или реакция на подозрительное поведение. По смыслу это не “роль” и не “право”, это состояние безопасности.

Чтобы мысль стала совсем осязаемой, полезно добавить в модель маленький метод, который выражает правило входа как читаемое предложение. Пусть этот метод и не будет вызываться Spring Security напрямую (у него свои механизмы), он помогает вам думать правильно:

public boolean canUseNormalLogin() {
    // “Нормальный вход” возможен только если аккаунт и включён, и не заблокирован.
    // Роли сюда не входят: роли проверяются уже после успешной аутентификации.
    return enabled && accountNonLocked;
}

И тут появляется отличный “анти-магический” эффект: вы глазами видите, что даже ADMIN не обязан входить, если enabled == false или аккаунт залочен. Роли в этот метод не входят, потому что роли — это про доступ после успешной аутентификации.

Если провести аналогию, roles — это “какие двери тебе можно открывать внутри здания”, а enabled/accountNonLocked — “пускают ли тебя вообще в здание”. И да, иногда охрана не пускает даже директора. Особенно если директор забыл бейджик и пытается доказать, что он директор, фразой “да я тут вообще всё строил”.

5. Инварианты UserAccount

Слово “инвариант” звучит как заклинание из математического фэнтези, но на практике это просто правило вида: «если это правило нарушилось — система сломалась или стала небезопасной». У аккаунтов инварианты особенно важны, потому что на них опирается authentication flow. Если у вас два пользователя с одним username, или если passwordHash вдруг стал null, то дальше начинаются приключения уровня “почему у нас то 500, то 401, то призраки в логах”.

Есть два слоя инвариантов: те, что вы проверяете в коде (валидация на уровне сервиса), и те, что вы фиксируете в БД (constraints). На уровне fundamentals важно хотя бы понять, что БД должна помогать, а не быть “хранилищем строк без мнения”. Иначе вы рано или поздно поймаете ситуацию, когда приложение проверило “уникальность” в коде, а параллельный запрос успел вставить дубликат — и всё.

Какие инварианты для UserAccount выглядят наиболее “неубиваемо”: username должен быть уникальным и не null. Email тоже должен быть уникальным и не null (если вы приняли решение, что email обязателен). passwordHash должен быть не null, потому что иначе проверять пароль невозможно. А ещё enabled и accountNonLocked должны иметь понятные значения по умолчанию. В учебном проекте обычно удобно стартовать с enabled = true и accountNonLocked = true, чтобы аккаунт по умолчанию мог входить, и блокировка/отключение были явными событиями.

Если показать это на уровне JPA очень кратко (без “погружения в ORM”), то идея выглядит так:

import jakarta.persistence.*;

@Entity
@Table(name = "user_accounts")
public class UserAccount {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id; // Технический PK: нужен для связей и стабильных ссылок на аккаунт.

    @Column(nullable = false, unique = true)
    private String username; // Инвариант для login flow: username обязателен и уникален.
}

Это не весь класс, а только мысль: “username обязателен и уникален”. Такой кусочек аннотаций — это не “JPA-магия”, а способ зафиксировать инвариант там, где ему самое место: в структуре данных.

6. Роли: Set<Role> и границы

Когда мы храним роли, хочется сделать “быстро”: положить строку "ADMIN" в поле role и разойтись. Но реальность чуть более многогранная: один аккаунт может иметь несколько ролей (например, USER + EDITOR), и это не “сложная enterprise-история”, а вполне нормальная вещь даже в учебном проекте. Поэтому в модели роли — это набор, а не одна строка.

При этом роли не должны “подменять” состояния аккаунта. Если аккаунт заблокирован, он не должен входить в систему независимо от ролей. Если аккаунт выключен, он тоже не должен входить. И наоборот: если аккаунт активен, роли отвечают за то, что он сможет делать внутри системы. Так вы избегаете хаоса, когда блокировка реализована через “сняли роль” и внезапно пользователь входит как USER, хотя по идее не должен входить вообще.

На уровне Java-модели это выглядит предельно просто:

import java.util.Set;

public class UserAccount {
    private String username; // Principal-идентификатор: по нему находим аккаунт при логине.
    private Set<Role> roles; // Роли — это множество: без порядка и без дублей.
}

И да, Set здесь — не прихоть. Он предотвращает “две одинаковых роли в наборе”, а ещё хорошо отражает смысл: роли — это множество, а не список с порядком.

Самый важный практический принцип: роль — это coarse-grained язык, он не заменяет ни account state, ни ownership, ни тонкие permissions. Но в рамках сегодняшнего шага нам важно другое: роли должны существовать как часть аккаунта, потому что иначе админка (/api/admin/**) будет управлять “чем-то несуществующим”.

7. Минимальная JPA-разметка UserAccount

Когда появляется БД, у некоторых разработчиков просыпается внутренний архитектор с желанием построить Римскую Империю: отдельные таблицы, справочники, миграции, сложные связи. Это нормально… но сегодня мы делаем другой жанр: минимально достаточная модель, чтобы security могла опираться на реальных пользователей. Поэтому JPA-аннотации здесь нужны не для красоты и не для “правильного enterprise”, а чтобы зафиксировать: это сущность, у неё есть таблица, и поля несут инварианты.

Самое полезное, что можно сделать в этот момент — аккуратно разметить ключевые поля и дать им понятные имена колонок. Особенно важно имя для passwordHash, чтобы оно не стало “password” в схеме БД и не провоцировало ошибки.

Пример минимальной разметки для нескольких полей (опять же: коротко, по делу):

import jakarta.persistence.*;

@Entity
@Table(name = "user_accounts")
public class UserAccount {
    @Column(name = "password_hash", nullable = false, length = 200)
    private String passwordHash; // Храним только хеш/encoded-пароль (не raw).

    @Column(nullable = false)
    private boolean enabled = true; // Дефолт: аккаунт включён, отключение — явное действие.
}

Два важных комментария. Во‑первых, length = 200 — это не “магическое число”, а просто безопасный запас под хеши (bcrypt, например, обычно укладывается, но запас не мешает). Во‑вторых, значения по умолчанию на уровне поля помогают держать модель в адекватном состоянии даже до того, как вы начнёте писать полноценные сервисы управления аккаунтами.

И ещё один момент, который напрямую связан с безопасностью, хотя кажется “просто архитектурой”: не отдавайте UserAccount наружу как API-ответ. Сущность — это модель хранения, а не модель публичного контракта. Для API лучше собрать безопасное представление, где нет passwordHash вообще.

Например, минимальный view-DTO можно сделать через record:

// DTO для API: безопасная проекция аккаунта без passwordHash.
public record UserAccountView(
        Long id,
        String username,
        String email,
        boolean enabled,
        boolean accountNonLocked
) {}

Пусть оно будет скучным — это комплимент. Скучные DTO чаще всего безопаснее “универсальных красивых объектов”.

8. Типичные ошибки при работе с UserAccount

Ошибка №1: поле password и raw-пароль внутри.
Это случается не потому, что разработчик злой или глупый, а потому что мозг экономит энергию. Если поле называется password, то где-то в тестах или seed-данных рука автоматически напишет "qwerty". Имя passwordHash каждый раз напоминает, что хранить нужно результат работы PasswordEncoder, а не исходную строку.

Ошибка №2: смешивание ролей и состояний аккаунта в один “статус”.
Иногда делают поле status = "ADMIN_ACTIVE" или status = "LOCKED_USER", а потом вся логика превращается в парсинг строк и switch на “магических значениях”. На этом уровне гораздо здоровее держать роли отдельно (roles) и состояния отдельно (enabled, accountNonLocked). Тогда блокировка аккаунта не требует “переосмыслить” роли, а роли не ломают возможность входа.

Ошибка №3: хранение ролей одной строкой через запятую.
"USER,ADMIN" кажется быстрым решением, пока вы не поймаете пробелы, регистр, дубликаты и внезапные "USER,ADMIN,". Потом появляется парсер, потом второй, потом третий… и вдруг вы написали мини‑фреймворк “CSV Roles Security Edition”. Set<Role> решает 90% этой боли ещё до старта.

Ошибка №4: отсутствие уникальности username и email на уровне БД.
Проверка “нет ли такого username” в коде полезна, но без ограничения уникальности в БД вы однажды получите два одинаковых логина из-за конкурентных запросов. Для security это особенно неприятно, потому что “кто именно вошёл” становится вопросом не философским, а очень практическим. Уникальность — это не опция, а фундамент.

Ошибка №5: возврат UserAccount как JSON “для удобства”.
В какой-то момент хочется написать return userAccount; из контроллера и “потом красиво отрефакторить”. Практика показывает: “потом” может наступить через полгода, уже после того как данные утекли в логи, в Postman-коллекции команды и в привычки. Правильнее сразу разделять: сущность для хранения, DTO для API. И даже если DTO кажется “лишней бюрократией”, это бюрократия, которая однажды сэкономит вам очень много нервов.

1
Задача
Spring Security, 14 уровень, 1 лекция
Недоступна
Минимальная модель аккаунта
Минимальная модель аккаунта
1
Задача
Spring Security, 14 уровень, 1 лекция
Недоступна
SQL-схема с инвариантами аккаунта
SQL-схема с инвариантами аккаунта
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ