Добавить в проект доставки еды из ЛР-6: аутентификацию пользователей (регистрация, логин, JWT), авторизацию по ролям (USER, ADMIN) и защиту эндпоинтов с помощью Spring Security.
Ссылку на PR в ваш репозиторий (шаблон у вас есть).
Сейчас ваш API полностью открыт: любой, кто знает URL, может создать ресторан, удалить блюдо или посмотреть чужие заказы. В реальной системе это недопустимо.
Две ключевые задачи:
- Аутентификация — кто отправил запрос? Проверяем личность пользователя (логин + пароль, токен).
- Авторизация — что ему разрешено? Проверяем, имеет ли пользователь право на конкретное действие.
Аналогия: аутентификация — это паспортный контроль на входе в здание, авторизация — это пропуск, определяющий, на какие этажи вы можете попасть.
Есть два основных подхода к аутентификации в веб-приложениях:
| Stateful (сессии) | Stateless (токены) | |
|---|---|---|
| Где хранится состояние | На сервере (в памяти, Redis, БД) | На клиенте (в токене) |
| Что получает клиент | Session ID в cookie | JWT в теле ответа |
| Как клиент себя идентифицирует | Cookie с session ID | Заголовок Authorization: Bearer <token> |
| Горизонтальное масштабирование | Сложнее (нужна общая сессия) | Просто (каждый сервер проверяет токен сам) |
| Подходит для | Классические веб-приложения | REST API, мобильные клиенты, SPA |
В этой лабораторной мы реализуем stateless-подход с JWT — он лучше подходит для REST API.
JWT (JSON Web Token) — это самодостаточный токен, содержащий информацию о пользователе. Он состоит из трёх частей, разделённых точками:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyQG1haWwuY29tIiwicm9sZSI6IlVTRVIiLCJpYXQiOjE3MTE...
\_______ Header _______/ \________________________ Payload _________________________/ \_ Signature _/
- Header — алгоритм подписи (
HS256,RS256). - Payload — данные (claims): кто (
sub), роль, время выдачи (iat), время истечения (exp). - Signature — подпись, гарантирующая, что токен не был изменён.
Header и Payload — это просто Base64-кодированный JSON. Любой может их прочитать. Подпись не шифрует данные — она лишь защищает от подделки. Поэтому не храните в JWT пароли или чувствительные данные.
- Клиент отправляет
POST /auth/loginс логином и паролем. - Сервер проверяет credentials, генерирует JWT и возвращает его в теле ответа.
- Клиент сохраняет токен и отправляет его в заголовке
Authorization: Bearer <token>с каждым запросом. - Сервер проверяет подпись и срок действия токена при каждом запросе.
- Когда токен истекает, клиент запрашивает новый (через повторный логин или refresh-токен).
Spring Security работает через цепочку фильтров (Security Filter Chain). Каждый HTTP-запрос проходит через эту цепочку до того, как попадёт в контроллер.
HTTP Request
│
▼
┌─────────────────────┐
│ Security Filter Chain │
│ ┌──────────────────┐│
│ │ CORS Filter ││
│ ├──────────────────┤│
│ │ CSRF Filter ││ ← отключим для stateless
│ ├──────────────────┤│
│ │ JWT Auth Filter ││ ← наш кастомный фильтр
│ ├──────────────────┤│
│ │ Authorization ││
│ │ Filter ││
│ └──────────────────┘│
└─────────────────────┘
│
▼
Controller
Ключевые компоненты, которые нам понадобятся:
SecurityFilterChain— конфигурация: какие URL открыты, какие закрыты.UserDetailsService— как загружать пользователя из БД.PasswordEncoder— как хешировать и проверять пароли.JwtAuthenticationFilter— наш кастомный фильтр, парсящий JWT из заголовка.
Для работы с аутентификацией нужна сущность User с ролями. В нашем проекте доставки достаточно двух ролей:
USER— может просматривать рестораны, меню и создавать заказы.ADMIN— может управлять ресторанами и блюдами.
Для хранения ролей есть два подхода — выберите один из них.
Простой подход — роль хранится прямо в колонке таблицы пользователей:
enum class Role {
USER, ADMIN
}
@Entity
@Table(name = "users")
class UserEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@Column(unique = true, nullable = false)
val email: String,
@Column(nullable = false)
val password: String, // BCrypt-хеш, не plain text!
@Enumerated(EnumType.STRING)
@Column(nullable = false)
val role: Role = Role.USER
)Плюсы: просто, один запрос для загрузки пользователя. Минусы: один пользователь — одна роль, расширяемость ограничена.
Более гибкий подход — роли хранятся в отдельной таблице, связанной с пользователем через @ManyToMany:
@Entity
@Table(name = "roles")
class RoleEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@Column(unique = true, nullable = false)
val name: String // "ROLE_USER", "ROLE_ADMIN"
)
@Entity
@Table(name = "users")
class UserEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@Column(unique = true, nullable = false)
val email: String,
@Column(nullable = false)
val password: String,
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "user_roles",
joinColumns = [JoinColumn(name = "user_id")],
inverseJoinColumns = [JoinColumn(name = "role_id")]
)
val roles: Set<RoleEntity> = emptySet()
)Плюсы: пользователь может иметь несколько ролей, легко добавлять новые роли без изменения схемы. Минусы: сложнее, дополнительный JOIN при загрузке.
Таблица называется
users, а неuser, потому чтоuser— зарезервированное слово в PostgreSQL.
Сущность
Userв вашем проекте уже существует с предыдущих лабораторных — вам нужно расширить её полямиpasswordиrole(или связью с таблицей ролей), а не создавать заново. Не забудьте написать Flyway-миграцию для изменения таблицы.
Пароли никогда не хранятся в открытом виде. Используем BCrypt — алгоритм хеширования, специально разработанный для паролей:
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()BCrypt автоматически добавляет соль (случайную строку) к каждому паролю, поэтому два одинаковых пароля дадут разные хеши. Это защищает от атак по таблицам (rainbow tables).
// При регистрации
val hashedPassword = passwordEncoder.encode(rawPassword)
// hashedPassword = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
// При логине
passwordEncoder.matches(rawPassword, hashedPassword) // true/falseSpring Security использует интерфейс UserDetailsService для загрузки данных пользователя при аутентификации. Есть два подхода — выберите один.
Простой подход — загружаем сущность из БД и конвертируем в стандартный UserDetails:
@Service
class CustomUserDetailsService(
private val userRepository: UserRepository
) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails {
val user = userRepository.findByEmail(username)
?: throw UsernameNotFoundException("Пользователь не найден: $username")
return User.builder()
.username(user.email)
.password(user.password)
.roles(user.role.name)
.build()
}
}
User.builder()— этоorg.springframework.security.core.userdetails.User, не путать с вашей сущностью.
Плюсы: просто, нет дополнительных классов. Минусы: теряется связь с доменной сущностью — из UserDetails нельзя получить id или другие поля вашего UserEntity.
Ваша сущность сама реализует интерфейс UserDetails:
@Entity
@Table(name = "users")
class UserEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@Column(unique = true, nullable = false)
val email: String,
@Column(nullable = false)
private val password: String,
@Enumerated(EnumType.STRING)
@Column(nullable = false)
val role: Role = Role.USER
) : UserDetails {
override fun getAuthorities(): Collection<GrantedAuthority> =
listOf(SimpleGrantedAuthority("ROLE_${role.name}"))
override fun getPassword(): String = password
override fun getUsername(): String = email
}@Service
class CustomUserDetailsService(
private val userRepository: UserRepository
) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails {
return userRepository.findByEmail(username)
?: throw UsernameNotFoundException("Пользователь не найден: $username")
}
}Плюсы: через @AuthenticationPrincipal можно получить не только username, но и id, role и любые другие поля сущности напрямую. Минусы: доменная сущность связана с интерфейсом Spring Security.
Для работы с JWT используем библиотеку jjwt. Добавьте зависимости:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>@Service
class JwtService(
@Value("\${jwt.secret}") private val secret: String,
@Value("\${jwt.expiration-ms}") private val expirationMs: Long
) {
private val key: SecretKey by lazy {
Keys.hmacShaKeyFor(secret.toByteArray())
}
fun generateToken(email: String, role: String): String {
return Jwts.builder()
.subject(email)
.claim("role", role)
.issuedAt(Date())
.expiration(Date(System.currentTimeMillis() + expirationMs))
.signWith(key)
.compact()
}
fun extractEmail(token: String): String {
return extractAllClaims(token).subject
}
fun extractRole(token: String): String {
return extractAllClaims(token)["role"] as String
}
fun isTokenValid(token: String): Boolean {
return try {
val claims = extractAllClaims(token)
!claims.expiration.before(Date())
} catch (e: JwtException) {
false
}
}
private fun extractAllClaims(token: String): Claims {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.payload
}
}Настройки в application.yaml:
jwt:
secret: ${JWT_SECRET}
expiration-ms: ${JWT_EXPIRATION:86400000} # 24 часа по умолчаниюСекретный ключ JWT не должен быть захардкожен в
application.yaml— это чувствительные данные, которые попадут в git. Конструкция${JWT_EXPIRATION:86400000}задаёт значение по умолчанию после двоеточия — если переменная не задана, будет использовано86400000.Для локальной разработки задайте переменные в
.env-файле (добавьте его в.gitignore) или в конфигурации запуска IDE.
Это ключевой компонент — фильтр, который перехватывает каждый запрос, извлекает JWT из заголовка Authorization и устанавливает аутентификацию в SecurityContext:
@Component
class JwtAuthenticationFilter(
private val jwtService: JwtService,
private val userDetailsService: UserDetailsService
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val authHeader = request.getHeader("Authorization")
// Если заголовка нет или он не начинается с "Bearer " — пропускаем
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response)
return
}
val token = authHeader.substring(7) // убираем "Bearer "
if (jwtService.isTokenValid(token)) {
val email = jwtService.extractEmail(token)
val userDetails = userDetailsService.loadUserByUsername(email)
val authentication = UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.authorities
)
authentication.details = WebAuthenticationDetailsSource()
.buildDetails(request)
SecurityContextHolder.getContext().authentication = authentication
}
filterChain.doFilter(request, response)
}
}Логика фильтра:
- Достаём заголовок
Authorization. - Если его нет или он не содержит
Bearer— пропускаем запрос дальше (он может быть на публичный URL). - Извлекаем токен, проверяем валидность (подпись + срок).
- Если токен валиден — загружаем пользователя и устанавливаем аутентификацию.
- Всегда вызываем
filterChain.doFilter()— запрос идёт дальше по цепочке.
Теперь собираем всё вместе в конфигурационном классе:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // включает @PreAuthorize
class SecurityConfig(
private val jwtAuthenticationFilter: JwtAuthenticationFilter
) {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http {
csrf { disable() } // CSRF не нужен для stateless API
sessionManagement {
sessionCreationPolicy = SessionCreationPolicy.STATELESS
}
authorizeHttpRequests {
authorize("/auth/**", permitAll) // регистрация и логин открыты
authorize(HttpMethod.GET, "/api/v1/restaurants/**", permitAll) // просмотр открыт
authorize(anyRequest, authenticated) // остальное — только для аутентифицированных
}
addFilterBefore<UsernamePasswordAuthenticationFilter>(jwtAuthenticationFilter)
}
return http.build()
}
@Bean
fun authenticationManager(
config: AuthenticationConfiguration
): AuthenticationManager {
return config.authenticationManager
}
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
}Ключевые решения:
- CSRF отключен — он нужен для форм с cookie-сессиями, а мы используем stateless JWT.
- Session policy = STATELESS — Spring Security не будет создавать HTTP-сессии.
/auth/**открыт — без этого невозможно зарегистрироваться или залогиниться.- JWT-фильтр добавлен перед стандартным фильтром — он работает до проверки авторизации.
Этот пример использует Kotlin DSL для Spring Security (
http { ... }). Он доступен начиная с Spring Security 6.x и Spring Boot 3.x.
@RestController
@RequestMapping("/auth")
class AuthController(
private val authService: AuthService
) {
@PostMapping("/register")
fun register(@Valid @RequestBody request: RegisterRequest): ResponseEntity<AuthResponse> {
val response = authService.register(request)
return ResponseEntity.status(HttpStatus.CREATED).body(response)
}
@PostMapping("/login")
fun login(@Valid @RequestBody request: LoginRequest): ResponseEntity<AuthResponse> {
val response = authService.login(request)
return ResponseEntity.ok(response)
}
}DTO:
data class RegisterRequest(
@field:Email(message = "Некорректный формат email")
@field:NotBlank(message = "Email обязателен")
val email: String,
@field:NotBlank(message = "Пароль обязателен")
@field:Size(min = 6, message = "Пароль должен быть не менее 6 символов")
val password: String,
@field:NotBlank(message = "Имя обязательно")
val name: String
)
data class LoginRequest(
@field:NotBlank(message = "Email обязателен")
val email: String,
@field:NotBlank(message = "Пароль обязателен")
val password: String
)
data class AuthResponse(
val token: String,
val email: String,
val role: String
)@Service
class AuthService(
private val userRepository: UserRepository,
private val passwordEncoder: PasswordEncoder,
private val jwtService: JwtService,
private val authenticationManager: AuthenticationManager
) {
private val logger = KotlinLogging.logger {}
fun register(request: RegisterRequest): AuthResponse {
if (userRepository.existsByEmail(request.email)) {
throw AlreadyExistsException("Пользователь с email ${request.email} уже существует")
}
val user = UserEntity(
email = request.email,
password = passwordEncoder.encode(request.password),
name = request.name,
role = Role.USER
)
val savedUser = userRepository.save(user)
logger.info { "Зарегистрирован пользователь: ${savedUser.email}" }
val token = jwtService.generateToken(savedUser.email, savedUser.role.name)
return AuthResponse(token, savedUser.email, savedUser.role.name)
}
fun login(request: LoginRequest): AuthResponse {
// AuthenticationManager проверяет credentials через UserDetailsService + PasswordEncoder.
// Более оптимальный подход — использовать результат authenticate():
// он возвращает Authentication, из которого можно получить UserDetails через getPrincipal(),
// и не ходить повторно в БД за пользователем.
authenticationManager.authenticate(
UsernamePasswordAuthenticationToken(request.email, request.password)
)
val user = userRepository.findByEmail(request.email)
?: throw NotFoundException("Пользователь не найден")
logger.info { "Вход пользователя: ${user.email}" }
val token = jwtService.generateToken(user.email, user.role.name)
return AuthResponse(token, user.email, user.role.name)
}
}Обратите внимание:
authenticationManager.authenticate()выброситBadCredentialsException, если пароль неверен. Это исключение нужно обработать вGlobalExceptionHandler.
@PreAuthorize позволяет указать, какие роли имеют доступ к конкретному методу контроллера:
@RestController
@RequestMapping("/api/v1/restaurants")
class RestaurantController(
private val restaurantService: RestaurantService
) {
@GetMapping
fun getAll(): ResponseEntity<List<RestaurantResponse>> {
// Доступно всем (настроено в SecurityFilterChain)
return ResponseEntity.ok(restaurantService.getAll().map { it.toResponse() })
}
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
fun create(@Valid @RequestBody request: CreateRestaurantRequest): ResponseEntity<RestaurantResponse> {
// Только ADMIN может создавать рестораны
val restaurant = restaurantService.create(request.toCommand())
return ResponseEntity.status(HttpStatus.CREATED).body(restaurant.toResponse())
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
fun delete(@PathVariable id: Long): ResponseEntity<Unit> {
restaurantService.delete(id)
return ResponseEntity.noContent().build()
}
}Матрица доступа для сервиса доставки:
| Эндпоинт | USER | ADMIN | Без авторизации |
|---|---|---|---|
POST /auth/register |
- | - | + |
POST /auth/login |
- | - | + |
GET /api/v1/restaurants |
+ | + | + |
GET /api/v1/restaurants/{id}/dishes |
+ | + | + |
POST /api/v1/restaurants |
- | + | - |
POST /api/v1/restaurants/{id}/dishes |
- | + | - |
PUT, DELETE ресторанов и блюд |
- | + | - |
POST /api/v1/orders |
+ | + | - |
GET /api/v1/orders/{id} |
Свой | + | - |
PATCH /api/v1/orders/{id}/status |
- | + | - |
В защищённых эндпоинтах часто нужно знать, кто сделал запрос. Spring Security предоставляет для этого @AuthenticationPrincipal:
@PostMapping("/api/v1/orders")
@PreAuthorize("hasRole('USER')")
fun createOrder(
@Valid @RequestBody request: CreateOrderRequest,
@AuthenticationPrincipal userDetails: UserDetails
): ResponseEntity<OrderResponse> {
// userDetails.username содержит email текущего пользователя
val order = orderService.create(request, userDetails.username)
return ResponseEntity.status(HttpStatus.CREATED).body(order.toResponse())
}Это позволяет привязать заказ к конкретному пользователю без необходимости передавать userId в теле запроса (что было бы небезопасно — клиент мог бы подставить чужой ID).
Spring Security выбрасывает свои исключения, которые нужно обработать в GlobalExceptionHandler:
@ExceptionHandler(BadCredentialsException::class)
fun handleBadCredentials(e: BadCredentialsException): ResponseEntity<ErrorResponse> {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(ErrorResponse(401, "Неверный email или пароль"))
}
@ExceptionHandler(AccessDeniedException::class)
fun handleAccessDenied(e: AccessDeniedException): ResponseEntity<ErrorResponse> {
return ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(ErrorResponse(403, "Доступ запрещён"))
}HTTP-статусы:
401 Unauthorized— не аутентифицирован (нет токена, токен невалиден, неверные credentials).403 Forbidden— аутентифицирован, но нет прав (например, USER пытается создать ресторан).
Важно: не возвращайте клиенту конкретику вроде "пользователь не найден" или "неверный пароль" — это помогает злоумышленнику подбирать credentials. Единое сообщение "Неверный email или пароль" безопаснее.
После добавления Spring Security существующие тесты сломаются — они не отправляют JWT. Есть два подхода:
Подход 1: Spring Security Test
Добавьте зависимость (она уже в spring-boot-starter-test):
@AutoConfigureMockMvc
@SpringBootTest
class RestaurantIntegrationTest {
@Autowired
lateinit var mockMvc: MockMvc
@Test
@WithMockUser(roles = ["ADMIN"])
fun `создание ресторана от ADMIN возвращает 201`() {
mockMvc.perform(
post("/api/v1/restaurants")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"name": "Тест", "address": "Улица 1"}""")
).andExpect(status().isCreated)
}
@Test
@WithMockUser(roles = ["USER"])
fun `создание ресторана от USER возвращает 403`() {
mockMvc.perform(
post("/api/v1/restaurants")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"name": "Тест", "address": "Улица 1"}""")
).andExpect(status().isForbidden)
}
@Test
fun `создание ресторана без токена возвращает 401`() {
mockMvc.perform(
post("/api/v1/restaurants")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"name": "Тест", "address": "Улица 1"}""")
).andExpect(status().isUnauthorized)
}
}@WithMockUser — подставляет фиктивного пользователя с указанными ролями в SecurityContext, без реального JWT.
Подход 2: получение реального токена в тесте
fun getToken(role: String = "USER"): String {
val registerRequest = RegisterRequest(
email = "test-${UUID.randomUUID()}@test.com",
password = "password123",
name = "Test User"
)
val result = mockMvc.perform(
post("/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(registerRequest))
).andReturn()
return objectMapper.readTree(result.response.contentAsString)["token"].asText()
}
@Test
fun `создание заказа с валидным токеном`() {
val token = getToken()
mockMvc.perform(
post("/api/v1/orders")
.header("Authorization", "Bearer $token")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"dishIds": [1, 2]}""")
).andExpect(status().isCreated)
}Сущность User в проекте уже есть — её нужно дополнить, а не создавать с нуля.
- Добавьте в
UserEntityполяpasswordиrole(или связь с таблицей ролей — см. раздел 5 теории). - Напишите Flyway-миграцию для добавления новых колонок (или таблицы ролей) в существующую таблицу
users. - Добавьте в
UserRepositoryметодыfindByEmailиexistsByEmail.
POST /auth/register— принимает email, пароль, имя. Возвращает JWT.POST /auth/login— принимает email и пароль. Возвращает JWT.- Пароль хешируется через
BCryptPasswordEncoderпри сохранении. - Добавьте валидацию на DTO (email, минимальная длина пароля).
- Регистрация с существующим email возвращает
409 Conflict.
JwtService— генерация, валидация токена, извлечение email и роли.JwtAuthenticationFilter— извлекает токен изAuthorization: Bearer <token>, валидирует и устанавливает аутентификацию.- Параметры JWT (
secret,expiration) вынесены вapplication.yaml.
- CSRF отключен, session policy =
STATELESS. /auth/**открыт для всех.GET-запросы на просмотр ресторанов и меню — открыты для всех.- Остальные запросы — только для аутентифицированных пользователей.
- JWT-фильтр подключен в цепочку.
- Управление ресторанами и блюдами (
POST,PUT,DELETE) — толькоADMIN. - Создание заказа — только
USER(илиADMIN). - Просмотр заказа — только владелец заказа или
ADMIN. - Изменение статуса заказа — только
ADMIN. - Используйте
@PreAuthorizeдля декларативной защиты.
BadCredentialsException→401 Unauthorized.AccessDeniedException→403 Forbidden.- Невалидный или просроченный JWT →
401 Unauthorized. - Все ответы об ошибках — в едином формате
ErrorResponse.
- Существующие интеграционные тесты должны учитывать авторизацию (
@WithMockUserили реальный токен). - Добавьте тесты на авторизацию: запрос без токена, запрос с ролью
USERна ADMIN-эндпоинт, запрос с невалидным токеном.
| Категория | Критерий | Баллы |
|---|---|---|
| Штраф | Не проходят тесты из ЛР-6 (с учётом авторизации) | -5 |
| Сущность и миграция | UserEntity расширен полями password/role, Flyway-миграция |
2 |
| Регистрация | Эндпоинт работает, пароль хешируется BCrypt, JWT возвращается | 3 |
| Логин | Эндпоинт работает, неверные credentials → 401, JWT возвращается | 3 |
| JwtService | Генерация, валидация, извлечение claims, конфигурация через ${ENV} |
3 |
| JwtAuthenticationFilter | Фильтр парсит Bearer-токен, устанавливает аутентификацию | 2 |
| SecurityFilterChain | CSRF отключен, stateless, публичные/закрытые URL, фильтр подключен | 2 |
| Авторизация по ролям | @PreAuthorize, матрица доступа соблюдена |
3 |
| Владелец заказа | Просмотр заказа только владельцем или ADMIN | 2 |
| Обработка ошибок | 401/403 в едином формате, безопасные сообщения | 2 |
| Тесты на безопасность | Тесты на 401, 403, успешный доступ с ролью | 2 |
| Качество решения | Чистота кода, конфигурация, отсутствие захардкоженных секретов | 1 |
| Итого | 25 |
POST /auth/registerс валидными данными возвращает201и JWT.POST /auth/registerс дублирующимся email возвращает409.POST /auth/loginс верными credentials возвращает200и JWT.POST /auth/loginс неверным паролем возвращает401.GET /api/v1/restaurantsработает без токена.POST /api/v1/restaurantsбез токена возвращает401.POST /api/v1/restaurantsс токеномUSERвозвращает403.POST /api/v1/restaurantsс токеномADMINвозвращает201.POST /api/v1/ordersс токеномUSERсоздаёт заказ, привязанный к текущему пользователю.- Все прежние тесты из ЛР-6 проходят (с учётом авторизации).
- Пароли в БД хранятся в виде BCrypt-хеша, не в открытом виде.