1. Проблема «User со всем сразу» в security
Когда мы начинаем хранить пользователей в БД, у новичка появляется очень естественное желание: “А давайте сделаем один класс User, сложим туда всё — и аккаунт, и профиль, и аватар, и роли, и вообще всё, что можно найти в интернете по слову user”. Это ощущение похоже на желание сложить все инструменты в один огромный ящик без перегородок: да, формально всё “в одном месте”, но каждый раз вы достаёте не то, что нужно, и рано или поздно порежетесь.
Главная беда “универсального User” в security-проекте даже не в красоте кода. Проблема в том, что security-данные почти всегда чувствительные, а профильные данные почти всегда должны удобно отдаваться и обновляться через API. И если вы смешали их в одной сущности, вы либо начнёте случайно светить лишнее, либо начнёте городить бесконечные @JsonIgnore, либо (самый частый вариант) — однажды кто-то “быстренько вернул entity из контроллера”, и вы получили… сюрприз.
Посмотрим на типичный антипример. Он не потому плох, что “так нельзя по чистому коду”, а потому что он провоцирует прямые уязвимости (утечки и несанкционированные изменения).
package com.example.securecontent.user;
import java.util.Set;
public class User {
private Long id;
private String username;
private String email;
// Эти поля почти всегда нельзя «светить» наружу через API:
private String passwordHash; // ой: даже хеш пароля — чувствительные данные
private boolean enabled; // ой: клиент не должен управлять статусом аккаунта
private Set<String> roles; // ой: роли — это часть security-ядра
// А это уже «витринные» поля профиля:
private String displayName; // профиль: то, что показываем людям
private String bio; // профиль: описание/о себе
private String avatarPath; // профиль: путь к файлу аватара (не security-данные)
}
А теперь добавим к этому “удобный” контроллер. Он выглядит мило, пока вы не вспомните, что “удобно” и “безопасно” не всегда дружат.
package com.example.securecontent.user;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MeController {
@GetMapping("/api/me")
public User me() {
// Опасная привычка: «вернуть entity/модель как есть».
// Как только тут появится реальный пользователь из БД — вы начнёте отдавать лишние поля.
return new User(); // placeholder, но в реальности кто-то вернёт entity
}
}
Даже если сейчас User пустой (потому что это skeleton), сама архитектура уже приглашает к ошибке. Через неделю вы подключите JPA, начнёте возвращать “настоящего пользователя”, и внезапно клиент получит поля, которые он вообще не должен видеть. И да, иногда “повезёт” и сериализация не включит поле, но в security такие ставки делать нельзя: “повезёт” — не стратегия.
Вторая боль — обновление. Если вы принимаете User в PATCH /api/me/profile, вы по сути говорите клиенту: “Вот тебе объект, можешь попробовать прислать мне обратно любые поля, посмотрим, что получится”. В лучшем случае вы будете бесконечно фильтровать, в худшем — однажды обновится что-то из security-ядра (например, enabled), а вы это заметите только на проде. Третья боль — поддержка: такой класс становится “магнитом” для новых полей, и через месяц в нём живёт всё, включая мечты и боль разработчика.
2. UserAccount и UserProfile: две сущности
Чтобы не превращать проект в кашу и не делать security хрупким, мы заранее вводим простую границу: учётная запись (UserAccount) и профиль (UserProfile) — это разные сущности, даже если API иногда отдаёт их вместе. Если нужен образ: UserAccount — это “паспорт для входа в систему”, а UserProfile — это “визитка/анкета пользователя”, которую он заполняет для людей (или для самого себя).
Эта граница решает сразу несколько практических задач. Во‑первых, она снижает риск утечки security-данных через API. Во‑вторых, она делает update-потоки безопаснее: профиль можно менять, не прикасаясь к паролю/ролям/статусам аккаунта. В‑третьих, она помогает структуре проекта: security-часть остаётся компактной, а профиль развивается отдельно, не перегружая authentication/authorization модель.
Удобно зафиксировать различия в маленькой таблице. Здесь важно не запомнить “как в учебнике”, а поймать интуицию: это разные вопросы системы.
| Вопрос | Отвечает UserAccount | Отвечает UserProfile |
|---|---|---|
| “Кто ты?” (идентификация) | Да (username, email) | Нет |
| “Можно ли тебя впустить?” | Да (enabled, accountNonLocked) | Нет |
| “Что тебе можно?” | Да (roles) | Нет |
| “Как тебя показать в UI?” | Частично (иногда username) | Да (displayName, bio, avatarPath) |
| “Что пользователь меняет сам в настройках?” | Редко (обычно только пароль, и то отдельно) | Да (почти всё) |
| “Какие данные нельзя отдавать в API случайно?” | Очень много (хеш, статусы, роли) | Почти нет (но всё равно фильтруем) |
Теперь посмотрим на минимальные skeleton’ы. UserAccount у нас уже был в прошлой лекции, но здесь важен именно фокус: это security-ядро.
package com.example.securecontent.security.account;
import java.util.Set;
public class UserAccount {
private Long id;
// Идентификация пользователя (то, чем он «является» в системе)
private String username;
private String email;
// Security-ядро: эти поля нельзя отдавать/принимать через обычные профильные API
private String passwordHash;
private boolean enabled;
private boolean accountNonLocked;
// Права доступа: это не «профиль», а часть авторизации
private Set<Role> roles; // детали хранения ролей — отдельная тема
}
А вот профиль — это прикладные поля. Он может быть пустым, может расширяться, но он не должен тащить в себя ни пароль, ни роли, ни “запрет входа”.
package com.example.securecontent.profile;
public class UserProfile {
// Связь с аккаунтом: профиль «надстраивается» над UserAccount по его id
private Long userId; // ссылка на аккаунт
// То, что показываем/редактируем в UI
private String displayName;
private String bio;
private String avatarPath; // путь к файлу аватара
}
Отдельно подчеркну важную мысль для новичка: “А что, если мне нужно /api/me, где есть и username, и displayName?” — это нормальная потребность, и она решается на уровне DTO, а не смешиванием моделей хранения.
3. Минимальный UserProfile: что хранить
Когда мы впервые выделили UserProfile, хочется начать “улучшать продукт”: добавить дату рождения, ссылки на соцсети, настройки приватности, любимый цвет фона и список любимых мемов. Это эмоционально понятно, но для текущего курса опасно: мы в security-треке, и нам нужен профиль ровно настолько, насколько он помогает демонстрировать protected endpoints, owner-only сценарии и file upload (аватар). Поэтому держим UserProfile минимальным и скучным — скучные модели обычно ломаются реже.
Минимальный профиль в нашем проекте — это то, что пользователь реально видит и меняет “как данные о себе”, и то, что удобно показывать в /api/me/profile. У нас это displayName, bio и avatarPath. Ключевой момент — userId, который связывает профиль с аккаунтом. Причём userId — это не “ещё один id”, а мост между двумя мирами: миром security-аккаунта и миром прикладного профиля.
Скелет можно оставить максимально коротким, и это нормально:
package com.example.securecontent.profile;
public class UserProfile {
// id аккаунта, которому принадлежит профиль
private Long userId;
// Публичные/полупубличные поля профиля
private String displayName;
private String bio;
// Храним именно «ссылку/путь», а не бинарные данные файла
private String avatarPath;
}
Инвариант здесь простой (и очень полезный для мозга): профиль принадлежит ровно одному аккаунту. На уровне мыслительной модели: если есть UserAccount с id = 10, то профиль для него либо существует (userId = 10), либо ещё не создан, но не может быть “профилем для другого аккаунта”. Это кажется очевидным, но такие очевидности потом спасают от странных багов.
Есть ещё один практический плюс разделения: вы можете обновлять профиль сколько угодно (и даже добавлять новые поля), и это не меняет ваш security core. То есть security-слой продолжает думать категориями: “id, логин, пароль, роли, enabled/locked”, а профиль развивается как нормальная прикладная фича.
4. DTO/VIEW: как собирать ответ /api/me
Очень распространённая путаница у новичков звучит так: “Раз у нас /api/me отдаёт и username, и displayName, значит надо хранить их в одном объекте”. Это логика “как отрисовать экран” вместо логики “как безопасно хранить и изменять данные”. В backend’е эти две логики должны встречаться на границе представления: в DTO, view-модели, assembler’е, mapper’е — называйте как хотите, суть одна: мы собираем ответ из разных источников, но не смешиваем источники.
Схема ответа /api/me может выглядеть так: мы берём часть данных из аккаунта, часть из профиля, а затем отдаём клиенту ровно то, что ему нужно. Никаких passwordHash, никаких enabled, никаких ролей (если вы не хотите их показывать). Для новичка это отличная привычка: не отдавать наружу entity, а делать понятный контракт.
Например, DTO “то, что клиент видит в зоне /api/me”:
package com.example.securecontent.profile.api;
public class MeView {
// То, что можно показать клиенту для идентификации в UI
private String username;
private String email;
// Профильная часть: витрина пользователя
private String displayName;
private String bio;
private String avatarPath;
}
А теперь маленький mapper. Да, его хочется “не писать, потому что и так понятно”. Но в реальном проекте именно такие классы спасают от утечек и от хаоса в контроллерах.
package com.example.securecontent.profile.api;
import com.example.securecontent.profile.UserProfile;
import com.example.securecontent.security.account.UserAccount;
public class MeViewMapper {
public MeView toView(UserAccount account, UserProfile profile) {
// Важно: здесь мы сознательно выбираем, какие поля попадут в API-ответ.
// Всё, что не проставили вручную, «случайно» наружу не уедет.
MeView v = new MeView();
// Берём только безопасные и нужные данные из аккаунта
v.setUsername(account.getUsername());
v.setEmail(account.getEmail());
// Берём профильные поля из профиля
v.setDisplayName(profile.getDisplayName());
v.setBio(profile.getBio());
v.setAvatarPath(profile.getAvatarPath());
return v;
}
}
Обратите внимание, что mapper вообще не имеет доступа к passwordHash. Даже если UserAccount его содержит, mapper просто не использует это поле. Психологически это важный барьер: “чтобы утечь, нужно явно захотеть утечь”.
Теперь сервис, который строит MeView. Здесь я намеренно делаю метод с параметром username, хотя в реальности мы получаем текущего пользователя из security context. Тонкость “как достать current user” — отдельная тема, и нам сейчас не нужно в неё прыгать.
package com.example.securecontent.profile;
import com.example.securecontent.profile.api.MeView;
import com.example.securecontent.profile.api.MeViewMapper;
import com.example.securecontent.security.account.UserAccount;
public class MeService {
private final UserAccountRepository accountRepository;
private final UserProfileRepository profileRepository;
private final MeViewMapper mapper;
public MeView getMe(String username) {
// 1) Сначала находим аккаунт по «логину» (упрощённо)
UserAccount acc = accountRepository.getByUsername(username);
// 2) Затем находим профиль по id аккаунта
UserProfile prof = profileRepository.getByUserId(acc.getId());
// 3) И только после этого собираем DTO для ответа наружу
return mapper.toView(acc, prof);
}
}
И наконец контроллер. Он остаётся “тонким”: он не собирает объект вручную, не размазывает логику получения профиля по коду, не думает о том, что и где хранится.
package com.example.securecontent.profile.api;
import com.example.securecontent.profile.MeService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MeController {
private final MeService meService;
public MeController(MeService meService) {
this.meService = meService;
}
@GetMapping("/api/me")
public MeView me() {
// Здесь должен быть текущий пользователь из security context.
// В лекции оставляем placeholder, чтобы не смешивать темы.
return meService.getMe("user"); // placeholder: текущий username
}
}
Это выглядит чуть более “многословно”, чем “вернуть entity”. Но это многословие покупает вам безопасность и предсказуемость. И, честно говоря, в security‑курсе это одна из лучших сделок.
Чтобы закрепить картинку в голове, вот небольшая схема, как /api/me собирается из двух частей:
flowchart TD Client["Client: GET /api/me"] --> Controller["MeController"] Controller --> Service["MeService"] Service --> AccRepo["UserAccountRepository"] Service --> ProfRepo["UserProfileRepository"] AccRepo --> Account["UserAccount (security ядро)"] ProfRepo --> Profile["UserProfile (прикладной профиль)"] Account --> Mapper["MeViewMapper"] Profile --> Mapper Mapper --> DTO["MeView (DTO для ответа)"] DTO --> Client
Если вам кажется, что “это слишком много слоёв для учебного проекта”, вспомните: мы не строим курс по CRUD, мы строим курс, где security должна оставаться понятной и устойчивой. Разделение account/profile — один из самых дешёвых способов добиться этого.
5. Обновление профиля: request DTO вместо entity
Следующая типичная ловушка выглядит так: “Раз профиль — это данные пользователя, пусть он присылает объект UserProfile в PATCH /api/me/profile”. Вроде бы логично, но есть нюанс: как только вы начнёте добавлять новые поля в UserProfile, вы начнёте случайно расширять контракт API. А если вы вдруг ошибётесь и будете принимать UserAccount или “универсальный User” — вы вообще откроете дверь в обновление ролей/статусов.
Поэтому безопасный подход такой: на каждую операцию обновления — свой request DTO, который содержит только то, что разрешено менять в этой операции. Это звучит “формально”, но на практике это один из самых надёжных паттернов.
Например, DTO для обновления профиля:
package com.example.securecontent.profile.api;
public class UpdateMyProfileRequest {
// Только то, что разрешено редактировать пользователю в этой операции
private String displayName;
private String bio;
}
Контроллер принимает именно этот DTO. Он не знает и не должен знать ни про роли, ни про enabled, ни про passwordHash. Он просто получает то, что пользователь может менять.
package com.example.securecontent.profile.api;
import com.example.securecontent.profile.ProfileService;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MyProfileController {
private final ProfileService profileService;
public MyProfileController(ProfileService profileService) {
this.profileService = profileService;
}
@PatchMapping("/api/me/profile")
public void update(@RequestBody UpdateMyProfileRequest req) {
profileService.updateProfile(10L, req); // placeholder: id текущего user
}
}
И сервис, который реально меняет профиль. Заметьте, что это изменение затрагивает UserProfile, а не UserAccount. Это и есть наша граница, реализованная в коде: обновление профиля не должно проходить через объект учётной записи.
package com.example.securecontent.profile;
import com.example.securecontent.profile.api.UpdateMyProfileRequest;
public class ProfileService {
private final UserProfileRepository profileRepository;
public void updateProfile(Long userId, UpdateMyProfileRequest req) {
UserProfile profile = profileRepository.getByUserId(userId);
profile.setDisplayName(req.getDisplayName());
profile.setBio(req.getBio());
profileRepository.save(profile);
}
}
Да, тут есть “плейсхолдеры” (как получить userId), и мы сознательно оставляем это за кадром. Сегодня наша задача — граница данных, а не то, как подключается security context. Но даже в таком виде видно главное: профиль обновляется в своей зоне, а security-ядро не трогается.
6. Связь в БД: id аккаунта и userId профиля
Когда вы переходите к БД, связь между account и profile нужно как-то хранить. Самая простая и понятная мысль: у UserAccount есть id, у UserProfile есть userId, и это один и тот же числовой идентификатор. То есть профиль как бы “надстройка” над аккаунтом. Это очень простая модель: один аккаунт — один профиль (или профиль может появляться позже), и она идеально подходит для курса, где мы не хотим утонуть в ORM-акробатике.
Покажу идею на упрощённых JPA-entities. Я не буду разбирать здесь нюансы @OneToOne, @MapsId и прочее — не потому, что оно “не нужно в жизни”, а потому что на этой лекции нам важнее смысловая граница, чем красота маппинга. В учебном проекте нормальная стратегия — начать с простого и прозрачного.
UserAccountEntity (упрощённо):
package com.example.securecontent.security.account;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@Entity
public class UserAccountEntity {
@Id
private Long id;
private String username;
private String email;
}
UserProfileEntity:
package com.example.securecontent.profile;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@Entity
public class UserProfileEntity {
@Id
private Long userId;
private String displayName;
private String avatarPath;
}
Да, это выглядит “слишком минимально”. И это прекрасно. Потому что минимальная модель лучше держит границы. Главное — понять, что userId в профиле указывает на аккаунт, а не живёт своей жизнью. На уровне базы данных это обычно оформляется внешним ключом, но даже без этого вы уже выстроили правильную mental model.
Плюс такой связи ещё и в том, что вы можете спокойно развивать профиль как отдельную фичу. Хотите добавить bio — добавили в UserProfile. Хотите добавить createdAt в аккаунт — добавили в UserAccount. И это не приводит к бесконечным конфликтам “а кто когда должен обновлять одну и ту же таблицу”.
7. Типичные ошибки при разделении account/profile
Ошибка №1: отсутствие DTO на API-границе.
Очень частая ситуация: вы сделали UserAccount и UserProfile, молодец, а потом в контроллере вернули UserAccount “потому что быстрее”. Формально классы разные, но проблема не решена: наружу всё равно может утечь passwordHash, роли или флаги состояния аккаунта. Лечится просто: на API-границе используйте DTO (MeView, ProfileView) и mapper.
Ошибка №2: обновление профиля через объект аккаунта.
Иногда разработчик делает PATCH /api/me и принимает UserAccount, потому что “это же данные обо мне”. Потом он начинает условно “только displayName менять”, но объект у него содержит роли/статусы/пароль — а дальше дело техники: кто-то забудет фильтр или сделает BeanUtils.copyProperties, и вы получите обновление security-полей по входным данным клиента. С точки зрения безопасности это очень плохой сюрприз. Правильный путь — отдельный request DTO для profile update.
Ошибка №3: смешанная ответственность в названиях и пакетах.
Даже если вы правильно разделили сущности, но положили всё в один пакет вроде com.example.securecontent.user, мозг начнёт путаться: где security, где профиль, где API. Через пару недель любой новый класс будет “случайно” зависеть от всего. Намного легче поддерживать границу, когда UserAccount живёт ближе к security/auth/admin зонам, а UserProfile — в profile feature. Это не догма, но хороший учебный рельс.
Ошибка №4: профиль превращается в свалку security-логики.
Иногда в UserProfile начинают появляться поля lockedReason, failedLoginAttempts, rolesText и другие вещи, которые на самом деле относятся к безопасности, а не к профилю. Тогда вы снова размываете границу, просто под другим названием. Хороший тест на здравый смысл: если поле влияет на “можно ли войти?” или “что можно делать?” — это почти наверняка UserAccount, а не UserProfile.
Ошибка №5: дублирование данных в аккаунте и профиле.
Например, email лежит и в аккаунте, и в профиле “на всякий случай”. Сначала кажется удобным, но потом вы ловите рассинхронизацию: где истина? какое поле обновлять? что отдавать в /api/me? Старайтесь, чтобы каждое поле имело одно “правильное место”. Для нашего курса: email и username живут в UserAccount, а человекоориентированные поля — в UserProfile.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ