УРОК 13 / 30 0%

Authorization: роли и ownership

🎯

Цель урока

Сделать полноценный CRUD для задач с правильными HTTP-статусами (201/204/404), обработкой ошибок, и подготовить Task к подключению JPA.

🧠

Теория · для собеса

1 Authentication vs Authorization
  • Authentication = "Кто ты?" (login, JWT) — урок 12
  • Authorization = "Что тебе можно?" — этот урок
2 3 уровня авторизации в Spring
  1. URL-based: .requestMatchers("/api/admin/**").hasRole("ADMIN") — крупно, на весь endpoint
  2. Method-based: @PreAuthorize("hasRole('ADMIN')") на методе сервиса/контроллера
  3. 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 способами:

  1. В коде: repo.findByOwner(currentUser) — фильтрация вручную
  2. JPA Specification: динамический фильтр where owner = ? — урок 18

Мы пойдём путём №1 — проще, понятнее, рекомендуется в 90% случаев.

---

💻

Практика: улучшаем CRUD

  1. 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. 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. 3
    dto/TaskResponse.java — добавляем ownerId
    
    public record TaskResponse(
        Long id,
        String title,
        String description,
        boolean done,
        Long ownerId,
        String ownerUsername,
        Instant createdAt,
        Instant updatedAt
    ) {}
    
  4. 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. 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. 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. 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 List findAll() {
            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. 8
    @EnableMethodSecurity в SecurityConfig
    
    @Configuration
    @EnableWebSecurity
    @EnableMethodSecurity  // ← для @PreAuthorize
    public class SecurityConfig {
        // ...
    }
    
  9. 9
    Опционально: @PreAuthorize на методах
    
    @PreAuthorize("hasRole('ADMIN')")
    public List findAll() { ... }
    

    Но в нашем случае логика сложнее (admin или owner), поэтому @PreAuthorize не подходит — проверка идёт в коде.

  10. 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",...}]
    

    ---

🎯

Зачем это на собесе

После урока ты должен уметь ответить на:

IDOR (Insecure Direct Object Reference) — самая частая уязвимость в API. Юзер меняет id в URL и получает чужие данные. Ownership checks её закрывают.
@PreAuthorize — для простых случаев (роль-в-зависимости-от-эндпоинта).
In-code checks — для сложной логики (admin OR owner). Spring не даёт «из коробки» row-level, поэтому пишем сами.
Транзакция @Transactional оборачивает весь метод — атомарность: read + check + write.

5 вопросов на углубление

Раскрой вопрос и нажми «🤔 Хочу разобрать подробнее» — он попадёт в страницу ответов.

1
requestMatchers vs @PreAuthorize — где проверять доступ?

Разбор внутри: URL-level security и method-level security.

2
Почему principal.id часто не работает в SpEL?

Разбор внутри: UserDetails, username и кастомный principal.

3
If в сервисе vs @PreAuthorize + @PostFilter — что выбрать?

Разбор внутри: читаемость, тестируемость и контроль owner/admin логики.

4
SecurityContextHolder напрямую vs CurrentUser бин

Разбор внутри: низкоуровневый API и удобная типизированная обёртка.

5
Как тестировать ownership без полного Spring Security?

Разбор внутри: Mockito-моки CurrentUser и быстрые unit-тесты сервиса.

Готов идти дальше?

Выбери вопросы, которые тебе интересны, и изучи их. Потом — к следующему уроку.

🚀 Перейти к Уроку 14

Сначала пройди все секции и выбери хотя бы 1 вопрос

⬅️ Назад к Уроку 12
🎉
Новый тир
Новый тир достигнут!