Authorization: роли и ownership
Цель урока
Сделать полноценный CRUD для задач с правильными HTTP-статусами (201/204/404), обработкой ошибок, и подготовить Task к подключению JPA.
Теория · для собеса
1 Authentication vs Authorization ▸
- Authentication = "Кто ты?" (login, JWT) — урок 12
- Authorization = "Что тебе можно?" — этот урок
2 3 уровня авторизации в Spring ▸
- URL-based:
.requestMatchers("/api/admin/**").hasRole("ADMIN")— крупно, на весь endpoint - Method-based:
@PreAuthorize("hasRole('ADMIN')")на методе сервиса/контроллера - Domain-based (row-level): юзер видит только СВОИ сущности (ownership)
3
@PreAuthorize / @PostAuthorize
▸
@PreAuthorize— проверка ДО вызова метода@PostAuthorize— проверка ПОСЛЕ (можно посмотреть результат, например,returnObject.owner == authentication.name)
⚠️ Важно: для работы нужна аннотация @EnableMethodSecurity на классе конфигурации (Spring Security 6+).
4 SpEL (Spring Expression Language) в @PreAuthorize ▸
@PreAuthorize("hasRole('ADMIN')") // роль
@PreAuthorize("hasAnyRole('ADMIN', 'MODERATOR')")
@PreAuthorize("hasAuthority('READ_PRIVILEGE')") // authority
@PreAuthorize("isAuthenticated()")
@PreAuthorize("authentication.name == 'admin'")
@PreAuthorize("#userId == authentication.principal.id") // параметр метода
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
5 Row-level Security ▸
В Spring Security нет встроенного «по строкам». Делается 2 способами:
- В коде:
repo.findByOwner(currentUser)— фильтрация вручную - JPA Specification: динамический фильтр
where owner = ?— урок 18
Мы пойдём путём №1 — проще, понятнее, рекомендуется в 90% случаев.
---
Практика: улучшаем CRUD
-
1Добавляем
owner_idкTask(миграция)-- V3__add_owner_to_tasks.sql ALTER TABLE tasks ADD COLUMN owner_id BIGINT NOT NULL REFERENCES users(id); CREATE INDEX idx_tasks_owner ON tasks(owner_id); -
2
model/Task.java— добавляем owner@ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "owner_id", nullable = false) private User owner; // Getter / Setter public User getOwner() { return owner; } public void setOwner(User owner) { this.owner = owner; }⚠️ FetchType.LAZY — не подгружаем owner пока не спросят. Это в Дополнении, для собеса — знать что
LAZYлучшеEAGER. -
3
dto/TaskResponse.java— добавляем ownerIdpublic record TaskResponse( Long id, String title, String description, boolean done, Long ownerId, String ownerUsername, Instant createdAt, Instant updatedAt ) {} -
4
mapper/TaskMapper.java— обновляем@Mapper(componentModel = "spring") public interface TaskMapper { @Mapping(target = "ownerId", source = "owner.id") @Mapping(target = "ownerUsername", source = "owner.username") TaskResponse toResponse(Task task); @Mapping(target = "owner", ignore = true) // owner задаётся в Service Task toEntity(CreateTaskRequest req); @Mapping(target = "owner", ignore = true) void updateEntityFromRequest(UpdateTaskRequest req, @MappingTarget Task task); } -
5
security/CurrentUser.java— хелперpackage com.taskflow.security; import com.taskflow.exception.AuthException; import com.taskflow.model.User; import com.taskflow.repository.UserRepository; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @Component public class CurrentUser { private final UserRepository userRepository; public CurrentUser(UserRepository userRepository) { this.userRepository = userRepository; } public User get() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth == null || !auth.isAuthenticated()) { throw new AuthException("Not authenticated"); } return userRepository.findByUsername(auth.getName()) .orElseThrow(() -> new AuthException("User not found: " + auth.getName())); } } -
6
repository/TaskRepository.java— фильтрация по owner@Repository public interface TaskRepository extends JpaRepository{ // Только задачи конкретного owner'а List findByOwnerId(Long ownerId); Optional findByIdAndOwnerId(Long id, Long ownerId); boolean existsByIdAndOwnerId(Long id, Long ownerId); } -
7
service/TaskService.java— ownership в логикеpackage com.taskflow.service; import com.taskflow.dto.CreateTaskRequest; import com.taskflow.dto.TaskResponse; import com.taskflow.dto.UpdateTaskRequest; import com.taskflow.exception.AuthException; import com.taskflow.exception.ResourceNotFoundException; import com.taskflow.mapper.TaskMapper; import com.taskflow.model.Task; import com.taskflow.model.User; import com.taskflow.repository.TaskRepository; import com.taskflow.security.CurrentUser; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; @Service public class TaskService { private final TaskRepository repo; private final TaskMapper mapper; private final CurrentUser currentUser; public TaskService(TaskRepository repo, TaskMapper mapper, CurrentUser currentUser) { this.repo = repo; this.mapper = mapper; this.currentUser = currentUser; } public ListfindAll() { User me = currentUser.get(); if (isAdmin(me)) { return repo.findAll().stream().map(mapper::toResponse).toList(); } return repo.findByOwnerId(me.getId()).stream().map(mapper::toResponse).toList(); } public TaskResponse findById(Long id) { User me = currentUser.get(); Task task = repo.findById(id) .orElseThrow(() -> new ResourceNotFoundException("Task", id)); if (!isAdmin(me) && !task.getOwner().getId().equals(me.getId())) { throw new AuthException("Access denied to task " + id); } return mapper.toResponse(task); } @Transactional public TaskResponse create(CreateTaskRequest req) { User me = currentUser.get(); Task task = mapper.toEntity(req); task.setOwner(me); return mapper.toResponse(repo.save(task)); } @Transactional public TaskResponse update(Long id, UpdateTaskRequest req) { User me = currentUser.get(); Task task = repo.findById(id) .orElseThrow(() -> new ResourceNotFoundException("Task", id)); if (!isAdmin(me) && !task.getOwner().getId().equals(me.getId())) { throw new AuthException("Cannot edit task " + id); } mapper.updateEntityFromRequest(req, task); return mapper.toResponse(repo.save(task)); } @Transactional public void delete(Long id) { User me = currentUser.get(); Task task = repo.findById(id) .orElseThrow(() -> new ResourceNotFoundException("Task", id)); if (!isAdmin(me) && !task.getOwner().getId().equals(me.getId())) { throw new AuthException("Cannot delete task " + id); } repo.delete(task); } private boolean isAdmin(User u) { return u.getRole() == User.Role.ADMIN; } } -
8
@EnableMethodSecurityв SecurityConfig@Configuration @EnableWebSecurity @EnableMethodSecurity // ← для @PreAuthorize public class SecurityConfig { // ... } -
9Опционально: @PreAuthorize на методах
@PreAuthorize("hasRole('ADMIN')") public ListfindAll() { ... } Но в нашем случае логика сложнее (admin или owner), поэтому @PreAuthorize не подходит — проверка идёт в коде.
-
10Тест
# Alice создаёт задачу $ ALICE_TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"alice","password":"password123"}' | jq -r '.accessToken') $ curl -X POST http://localhost:8080/api/tasks \ -H "Authorization: Bearer $ALICE_TOKEN" \ -H "Content-Type: application/json" \ -d '{"title":"Alice task"}' {"id":1,"title":"Alice task","ownerUsername":"alice",...} # Bob логинится $ BOB_TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"bob","password":"password456"}' | jq -r '.accessToken') # Bob пытается посмотреть задачу Alice → 403 $ curl -i -H "Authorization: Bearer $BOB_TOKEN" http://localhost:8080/api/tasks/1 HTTP/1.1 403 {"status":403,"message":"Access denied to task 1",...} # Bob видит только свои (пусто) $ curl -H "Authorization: Bearer $BOB_TOKEN" http://localhost:8080/api/tasks [] # Admin видит все $ ADMIN_TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"admin123"}' | jq -r '.accessToken') $ curl -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8080/api/tasks [{"id":1,"title":"Alice task","ownerUsername":"alice",...}]---
Зачем это на собесе
После урока ты должен уметь ответить на:
@PreAuthorize — для простых случаев (роль-в-зависимости-от-эндпоинта).@Transactional оборачивает весь метод — атомарность: read + check + write.5 вопросов на углубление
Раскрой вопрос и нажми «🤔 Хочу разобрать подробнее» — он попадёт в страницу ответов.
Разбор внутри: URL-level security и method-level security.
Разбор внутри: UserDetails, username и кастомный principal.
Разбор внутри: читаемость, тестируемость и контроль owner/admin логики.
Разбор внутри: низкоуровневый API и удобная типизированная обёртка.
Разбор внутри: Mockito-моки CurrentUser и быстрые unit-тесты сервиса.
Готов идти дальше?
Выбери вопросы, которые тебе интересны, и изучи их. Потом — к следующему уроку.
🚀 Перейти к Уроку 14Сначала пройди все секции и выбери хотя бы 1 вопрос
⬅️ Назад к Уроку 12