JavaRush /Курсы /Spring Security /Граница между UserAccount<...

Граница между UserAccount и UserProfile

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

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.

1
Задача
Spring Security, 14 уровень, 2 лекция
Недоступна
Безопасный ответ `/api/me`
Безопасный ответ `/api/me`
1
Задача
Spring Security, 14 уровень, 2 лекция
Недоступна
Обновление профиля без изменения аккаунта
Обновление профиля без изменения аккаунта
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ