Всех приветствую!
Данная статья будет посвящена довольно важным вещам, таким как: refreshtoken, метод-уровневой безопасности и примеру реализации правила "свой/чужой профиль"
Не буду в этот раз делать каких-либо лирический отступлений, так что предлагаю сразу же начать.
Refresh token
Что это вообще такое и зачем оно нам надо?
В прошлой статье когда мы реализовывали сам JWT, нам приходилось выдавать пользователю обратно некий access token. Это такой токен, который даёт право пользоваться всякими апишками так как прикрепляется к каждому запросу в header'е Ahthorization, и отвечает на вопрос: "Какие права у пользователя?". Но у него есть нюанс, что он не должен долго жить, время его жизни должно быть в интервале от 15 минут до 1 часа. И это сделано специально, потому что если какой-нибудь очень не хороший человек сможет получить ваш access token, то прям сильно наделять грязи он не сможет. Но у этого есть и свой минус, дабы каждые 15 минут - час получать новый access token нужно будет постоянно логиниться, а это не прикольно. Так вот чтобы такого не происходило, придумали специальный refresh token. Он уже живёт долго, примерно от недели до месяца (как настроить) и хранится в самой базе данных в виде случайной строки символов, которая не хранит никакой полезной информации. Но как и у всего в нашем миру, у refresh token'а тоже есть свои плюсы и минусы.
Начнём пожалуй с плюсов:
Он безопасный - он как раз позволяет сделать
access tokenкороткоживущим, чтобы у плохих людей было меньше времени насолить кому либо. Потому что по истечении срока жизниaccess token'aон превращается в тыкву и с ним ничего нельзя сделать.Он удобен для использования - как я снова писал выше, он позволяет пользователю не логиниться каждые 15 минут - час.
Access tokenсам будет обновляться при помощиrefresh token'а, после истечения срока его жизни.Он позволяет контролировать сессии пользователей - сразу приведу пример: у пользователя есть несколько сессий, и на каком-либо из своих устройств он нажимает на кнопку "выйти со всех устройств", то из базы удалятся все refresh token'ы которые были у пользователя. Ну или же к примеру если есть несколько сессий, то можем показать их пользователю чтобы к примеру узнать, какие сессии нужно удалить.
Он позволяет минимизировать передачу учётных данных по сети - клиент сможет всего 1 раз передать логин и пароль при первой авторизации/регистрации. После этого север будет выдавать токены, и клиент будет использовать только их. Это даёт возможность больше нигде не передавать пароли, что полностью убирает риск утечки пароля при различных сетевых запросах.
Теперь к минусам, ведь они тоже имеются и о них нужно знать и их нужно учитывать:
Если плохие люди смогут залезть к нам в базу данных, и украсть оттуда
refresh token'ы, то это позволит им творить всё что душе угодно с помощью бесконечной генерацииaccess token'ов, и это достаточно не хорошо. И чтобы от этого защититься,refresh token'ыхэшируют черезSHA-256, чтобы плохие люди увидели только набор бесполезных символов которые обратно превратить в токен невозможно. Ну или же можно использовать ротацию токенов, это значит что при каждом обмене старогоrefresh token'aна новыйaccess token, выдаётся и новыйrefresh token, что даёт плохому человеку мало времени навредить несмотря на кражу рефреш токена. А если он попробует использовать старый токен, то сервер поймёт что токен украли и обнулит всю цепочку токенов и разлогинит пользователя.Они увеличивают нагрузку на базу из-за своего состояния (statefulness), потому что при каждой проверке токена, необходимо как раз обращаться к базе данных или кэшу.
Его сложнее реализовать - как я писал выше, для его защиты необходимо реализовывать ротацию токенов, да и хорошо было бы обрабатывать разные сценарии истечения срока действия, к примеру отдельно для
access token'ов, и отдельно дляrefresh token'овЕсть риски на клиенте - если мы будем его хранить небезопасно, например в
localStorage(встроенное хранилище в браузере), то его будет легче украсть с помощьюXSSатаки. Но для того, чтобы этого избежать мы должны отдаватьrefresh tokenчерез httpOnly cookie, а не в теле json ответа.
Теперь можно по подробнее посмотреть как идут обращения с этими токенами и запросами:

На рисунке я показал как взаимодействуют клиент и сервер при использовании JWT. Сначала клиент логинится (ну или регистрируется) и получает access и refresh токены, затем access токен будет передаётся в заголовке Authorization: Bearer <access token> при каждом обращении к серверу где нужно быть авторизованным (потому что есть ссылки для которых авторизация не нужна, например /login или /reg). Но когда access token истёк, клиент отправляет refresh token для получения новой пары токенов (это происходит автоматически), при этом старый refresh token становится невалидным, что способствует ротации токенов.
Реализация Refresh Token'а
Для начала нам необходимо создать новую сущность в пакете entity
@Entity @Table(name = "refresh_tokens") @Getter @Setter @NoArgsConstructor public class RefreshToken { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") private Long id; @Column(name = "token", nullable = false) private String token; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", referencedColumnName = "id", nullable = false) private User user; @Column(name = "expiration_at", nullable = false) private Instant expiresAt; //Проверяет: истёк ли срок действия refresh token //Возвращает true, если текущее время позже установленного expirationAt private boolean revoked = false; public boolean isExpired(){ return Instant.now().isAfter(expiresAt); } }
Думаю, не стоит объяснять для чего нужно каждое поле. Так что сразу перейдём к написанию репозитория, сервиса, и остальных частей нашей программы.
@Repository public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> { //Находим токен по значению Optional<RefreshToken> findByToken(String token); //Удаляем все токены пользователя void deleteAllByUser(User user); }
@Service @RequiredArgsConstructor @Transactional @Slf4j public class RefreshTokenService { private final RefreshTokenRepository refreshTokenRepository; @Value("${jwt-refresh-expiration}") private long refreshExpiration; public RefreshToken create(User user){ log.info("Called method 'createToken' in RefreshTokenService"); //Удаляем все токены пользователя, если они есть refreshTokenRepository.deleteAllByUser(user); //Создаём новый токен RefreshToken refreshToken = new RefreshToken(); refreshToken.setUser(user); refreshToken.setToken(UUID.randomUUID().toString()); refreshToken.setExpiresAt(Instant.now().plusMillis(refreshExpiration)); //Сохраняем токен в базу return refreshTokenRepository.save(refreshToken); } //Проверка валидности токена: он существует, не истёк, не отозван? public RefreshToken verify(String stringToken){ log.info("Called method 'verify' in RefreshTokenService"); RefreshToken token = refreshTokenRepository.findByToken(stringToken).orElseThrow(() -> new RefreshTokenException("Refresh token not found")); if(token.isExpired() || token.isRevoked()){ //Если отозван или истёк, то удалить конкретный токен refreshTokenRepository.delete(token); throw new RefreshTokenException("Refresh token expired. Please login again."); } return token; } //Отзыв самого токена public void revoke(String stringToken){ log.info("Called method 'revoke' in RefreshTokenService"); //Ищем токен по названию и если есть, то ставим revoke = true и сохраняем в бд, //иначе ничего не делаем refreshTokenRepository.findByToken(stringToken).ifPresent(token ->{ token.setRevoked(true); refreshTokenRepository.save(token); }); } }
Если внимательно почитать, то можно увидеть что есть нереализованный класс RefreshTokenException. В целом реализация у него довольно простая и представлю я её ниже
public class RefreshTokenException extends ErrorResponseException { private static ProblemDetail asProblem(String detail) { ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.CONFLICT); problemDetail.setTitle("Refresh token"); problemDetail.setDetail(detail); return problemDetail; } public RefreshTokenException(String detail) { super(HttpStatus.CONFLICT, asProblem(detail), null); } }
Теперь нам необходимо заняться обновлением DTO, AuthService, AuthController
Начнём пожалуй по порядку с DTO:
//Меняем AuthResponse и добавляем RefreshRequest public record AuthResponse(String accessToken, String refreshToken) {} public record RefreshRequest(String refreshToken) {}
Далее по порядку идёт AuthService из которого я убрал некоторые комментарии из прошлой статьи:
@Service @RequiredArgsConstructor public class AuthService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtService jwtService; private final AuthenticationManager authenticationManager; private final RefreshTokenService refreshTokenService; //Добавляем сервис //Регистрация пользователя public AuthResponse register(RegisterRequest request){ if(userRepository.existsByUsername(request.username())){ throw new UsernameTakenException("this username already taken"); //Делаем более понятный ответ ошибки } //Создание пользователя User user = new User(); user.setUsername(request.username()); user.setPassword(passwordEncoder.encode(request.password())); user.setRoles(Set.of(Role.USER)); userRepository.save(user); //Создание access и refresh токенов String accessToken = jwtService.generateToken(user); RefreshToken refreshToken = refreshTokenService.create(user); //Возвращаем только токены return new AuthResponse(accessToken, refreshToken.getToken()); } //Аутентификация пользователя public AuthResponse login(LoginRequest request){ authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( request.username(), request.password() ) ); User user = userRepository.findByUsername(request.username()).orElseThrow(); //Создание access и refresh токенов String accessToken = jwtService.generateToken(user); RefreshToken refreshToken = refreshTokenService.create(user); return new AuthResponse(accessToken, refreshToken.getToken()); } public void logout(String refreshToken){ //Во врем логаута происходит отзыв refresh token'а refreshTokenService.revoke(refreshToken); } }
Теперь необходимо заняться AuthController'ом
@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor public class AuthController { private final AuthService authService; @PostMapping("/register") public ResponseEntity<AuthResponse> register(@RequestBody RegisterRequest request){ return ResponseEntity.ok(authService.register(request)); } @PostMapping("/login") public ResponseEntity<AuthResponse> login (@RequestBody LoginRequest request){ return ResponseEntity.ok(authService.login(request)); } //Эндпоинт для обновления токенов по одному refresh token'у @PostMapping("/refresh") public ResponseEntity<AuthResponse> refresh(@RequestBody RefreshRequest request){ return ResponseEntity.ok(authService.refresh(request.refreshToken())); } //Эндпоинт для выхода из системы, который отзывает refresh token //чтобы им больше нельзя было обновить access token @PostMapping("/logout") public ResponseEntity<String> logout(@RequestBody RefreshRequest request){ authService.logout(request.refreshToken()); return ResponseEntity.ok("Logout success!"); } }
Но я писал выше, что лучше хранить refresh token в HttpOnly Cookie, потому что так мы можем защитить наш токен от XSS-атаки. Так что в контроллер нужно внести изменение и добавить один DTO:
public record AccessTokenResponse(String accessToken){}
Эта маленькая ДТОшка нужна будет для обновлённого контроллера
@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor public class AuthController { private final AuthService authService; @PostMapping("/register") public ResponseEntity<AuthResponse> register(@RequestBody RegisterRequest request){ return ResponseEntity.ok(authService.register(request)); } @PostMapping("/login") public ResponseEntity<AuthResponse> login (@RequestBody LoginRequest request){ return ResponseEntity.ok(authService.login(request)); } //Принимает HttpOnly куку @PostMapping("/refresh") public ResponseEntity<AccessTokenResponse> refresh(@CookieValue("refreshToken") String refreshToken, HttpServletResponse response){ //Обновляем токены из принятого токена из куки AuthResponse tokens = authService.refresh(refreshToken); //Обновляем refresh token setRefreshCookie(response, tokens.refreshToken()); return ResponseEntity.ok(new AccessTokenResponse(tokens.accessToken())); } //Эндпоинт для выхода из системы, который отзывает refresh token //чтобы им больше нельзя было обновить access token @PostMapping("/logout") public ResponseEntity<String> logout(@RequestBody RefreshRequest request){ authService.logout(request.refreshToken()); return ResponseEntity.ok("Logout success!"); } private void setRefreshCookie(HttpServletResponse response, String token){ Cookie cookie = new Cookie("refreshToken", token); cookie.setHttpOnly(true); //Для того, чтобы JS не смог прочитать cookie.setSecure(true); //Только для HTTPS протокола cookie.setPath("/api/auth"); // Только для путей /api/auth/** cookie.setMaxAge(7 * 24 * 3600); //Сколько будет жить кука (7 дней) response.addCookie(cookie); } }
Тут добавился метод который кладёт refresh token в HttpOnly куку вместо тела JSON ответа. Ну и обновился метод который обновляет токены. Думаю по комментариям в коде будет и так всё понятно :)
Дальше хорошо было бы заняться правами доступа чуть по серьёзнее, потому что то, что есть сейчас, это довольно не красиво, и по хорошему нужно немного улучшить, так что зайдём в SecurityConfig и внесём небольшие изменения:
@Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { private final UserDetailsServiceImpl userDetailsService; private final PasswordEncoder passwordEncoder; private final JwtAuthenticationFilter jwtAuthenticationFilter; @Bean public AuthenticationProvider authenticationProvider(){ DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); provider.setPasswordEncoder(passwordEncoder); return provider; } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config){ return config.getAuthenticationManager(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{ http.csrf( //Отключение защиты от csrf атаки (для тестирования в будущем) csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth.requestMatchers("/api/auth/**").permitAll() // Теперь смотреть на чужие профили можно всем .requestMatchers(HttpMethod.GET, "/api/profiles/**").permitAll() // только для админа .requestMatchers("/api/admin/**").hasRole("ADMIN") //Для того чтобы можно было менять профиль, нужна роль либо MOD либо ADMIN .requestMatchers("/api/mod/**").hasAnyRole("ADMIN", "MOD") //Ищет точное имя в GrantedAythority .requestMatchers("/api/**").hasAuthority("API_ACCESS") .requestMatchers("/error").permitAll() //добавлена строка .anyRequest().authenticated()) //Без сессий каждый запрос аутентифицируется по токену .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authenticationProvider(authenticationProvider()) //Наш фильтр встаёт перед стандартным фильтром логина .addFilterBefore( jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class ); return http.build(); } }
Нужно немного подробнее погрузиться в строку .requestMatchers("/api/**").hasAuthority("API_ACCESS").
Когда пользователь отправляет любой запрос, spring security смотрит на информацию о том, какие права были выданы ему после входа в систему, а эти права находятся в GrantedAuthority. И если у пользователя есть право с названием Api_Access, то запрос проходит дальше к контроллеру, а если такого права нет, то spring security вернёт код ошибки 403 Forbidden.
Ещё было бы не плохо понять разницу между hasRole и hasAuthority. Эта разница заключается в том, что hasRole("USER") идёт в GrantedAuthority и ищет "ROLE_USER", а в это время hasAuthority("USER") также идёт в GrantedAuthority но ищет право с точным названием "USER". Причём достаточно символично, что hasAuthority("ROLE_USER") это равносильно hasRole("USER").
Теперь можем заняться что то, типа соц сеткой с профилями пользователей, как я и обещал во второй статье.
Код будет выглядеть слишком красиво, но в будущем он будет улучшаться, так что пока что в уже имеющийся один AuthController можем добавить метод для получения профиля текущего пользователя, но перед этим нужно снова сделать новый DTO:
public record UserDto( Long id, String username, Set<Role> roles ) { public static UserDto from(User user){ return new UserDto(user.getId(), user.getUsername(), user.getRoles()); } }
И теперь новый метод контроллера
@GetMapping("/profile") public ResponseEntity<UserDto> getProfile(@AuthenticationPrincipal UserDetails userDetails){ User user = (User) userDetails; return ResponseEntity.ok(UserDto.from(user)); }
Появилась новая аннотация @AuthenticationPrincipal - она автоматически подставляет в параметр метода текущего аутентифицированного пользователя. Если простыми словами, то допустим есть некий пользователь который аутентифицировался на нашем приложении и вбил в url /api/auth/profile, то spring security сам передаст объект этого пользователя в параметр метода, благодаря чему не нужно вручную лезть в securityContext и искать там этого пользователя.
А под капотом происходит следующее: после успешной аутентификации spring security создаёт новый объект principal и помещает его в SecurityContext. Когда мы вызываем метод, в параметрах которого есть аннотация @AuthenticationPrincipal, spring достаёт этот principal из SecurityContext и автоматически передаёт его в параметры метода.
Теперь можно заняться Метод-уровневой безопасностью
Метод-уровневая безопасность
Что это такое? По сути это многоуровневой подход к защите. Он обозначает, что безопасность встроена в каждый слой архитектуры (контроллер, сервис, репозиторий). На контроллере она реализуется цепочкой фильтров, на сервисе специальной аннотацией @PreAuthorize(...), а с репозиторием довольно интересно, и пожалуй можно обсудить это по подробнее.
Интересно это в том плане, что spring позволяет ограничить доступ к конкретным строкам или таблицам в БД, зависимо от того, какой пользователь сделал запрос. Выглядит это примерно следующим образом:
@Query("select u from users u where u.username = ?#{principal.username}")
Данная строка будет гарантировать, что пользователь достанет только свои данные и ничьи больше.
Ещё по поводу SQL-инъекций думаю само собой всё понятно, hibernate сразу использует PreparedStatement для создания различных запросов к базе, так что опустим этот момент.
Теперь пожалуй вернёмся к разработке и добавим в новую аннотацию @EnableMethodSecurity над классом SecurityConfig, что позволит нам использовать аннотации @PreAuthorize и @PostFilter которые можно использовать как в сервисе, так и в контроллере.
Сейчас я покажу небольшой пример как можно использовать аннотации @PreAuthorize и @PostFilter на сервисных уровнях (Не надо жаловыться, что нет аннотации @Transactional, это код чтобы просто показать как можно использовать другие аннотации, этот код не для разработки, а примера).
@Service public class ArticleService { @PreAuthorize("hasRole('ADMIN')") public void deleteArticle(Long id) { ... } @PreAuthorize("hasRole('ADMIN') or #username == authentication.name") public UserProfile getProfile(String username) { ... } @PreAuthorize("@articleSecurity.canEdit(authentication, #articleId)") public void editArticle(Long articleId, ArticleDto dto) { ... } @PostFilter("hasRole('ADMIN') or filterObject.authorUsername == authentication.name") public List<Article> findAll() { ... } }
Первый метод удаляет статью, но может это сделать только администратор. Проверка происходит ещё дол входа в метод (проксирование привет), так что если роль любая кроме админа, выкинет AccessDeniedException.
Второй метод возвращает профиль, но только при том условии, что ты админ или же запрашиваемый username совподает с твоим. Этого требет SpEL(Spring Expression Language)
Третий метод позволяет редактировать статью, но проверка владения вынесена в отдельный бин, потому что проверка не сводится к сравнению строк, а нужно сходить в базу и посмотреть на автора статьи. Не буду показывать полностью как это сделать, а просто кратко опишу: создаётся новый класс к примеру ArticleSecurityService, затем накидыватеся аннотация @Component() и в параметре аннотации нужно указать название "artucleSecurity", далее создаём метод boolean canEdit (Authentication auth, Long articleId){} и там уже внуьри реализуем логику поиска автора
Ну и наконец мы можем релизовать логику всего приложения, и расширять наш проект пожалуй начнём с класса User, потому что нынешних полей достаточно мало для реализации всей нашей логики и нужно добавить право "API_ACCESS":
@Entity @Table(name = "users") @Getter @Setter @NoArgsConstructor public class User implements UserDetails { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id", nullable = false) private Long id; // В продакшене нужно использовать UUID @Column(name = "username", nullable = false, unique = true) private String username; @Column(name = "password", nullable = false) private String password; // хранится в зашифрованном виде // Это поле это признак того, что аккаунт активен private boolean enabled = true; @Column(name = "display_name", length = 30) private String displayName; //Отображаемое имя @Column(name = "avatar_url") private String avatarUrl; //Допустим аватарка будет только по ссылке @Column(name = "status") private String status; //Статус профиля @Column(name = "phone_number", length = 20) private String phoneNumber; //номер телефона @Column(name = "email", unique = true, length = 100) private String email; //Эмейл @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id")) @Enumerated(EnumType.STRING) private Set<Role> roles = new HashSet<>(); @Override public Collection<? extends GrantedAuthority> getAuthorities() { Set<GrantedAuthority> authorities = roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name())) .collect(Collectors.toSet()); authorities.add(new SimpleGrantedAuthority("API_ACCESS")); return authorities; } @Override public @Nullable String getPassword() { return password; } @Override public String getUsername() { return username; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return enabled; } }
Добавили несколько полей в класс User, и допустим что поля displayName, avatarUrl и status будут публичными для просмотра, а phoneNumber и email будут приватными.
Теперь создадим новый DTO (в очередной раз) который будет использоваться в показе профиля, но у него есть важный нюанс: он будет выглядеть по разному в зависимости от того, смотрит ли пользователь свой профиль или чужой, то есть в своём профиле пользователь будет видеть всё, а в чужом профиле будут видны только некоторые поля
public record ProfileDto( String username, String displayName, String avatarUrl, String status, String phoneNumber, // null - если профиль смотрит не владелец String email // null - если профиль смотрит не владелец ) { public static ProfileDto publicView(User user){ return new ProfileDto( user.getUsername(), user.getDisplayName(), user.getAvatarUrl(), user.getStatus(), null, null ); } public static ProfileDto ownerView(User user){ return new ProfileDto( user.getUsername(), user.getDisplayName(), user.getAvatarUrl(), user.getStatus(), user.getPhoneNumber(), user.getEmail() ); } }
Ещё было бы не плохо сделать ещёёё один DTO (вроде как последний) который позволит редактировать профиль
public record UpdateProfileRequest( String displayName, String avatarUrl, String status, String phoneNumber, String email ) { }
Всё, DTO теперь больше не будет. Переходим к реализации основного функицонала нашего проекта.
@Service @RequiredArgsConstructor public class ProfileService { private final UserRepository userRepository; @PreAuthorize("isAuthenticated()") public ProfileDto getProfile(String username, String curUsername){ //Находим пользователя в базы User user = userRepository.findByUsername(username).orElseThrow(()-> new UsernameNotFoundException("Profile with username: " + username + " not found")); //Проверяем юзернейм владельца профиля и пользователя boolean isOwner = user.getUsername().equals(curUsername); //В зависимости от того, владелец или нет, возвращаем Dto с разным наполнением return isOwner ? ProfileDto.ownerView(user) : ProfileDto.publicView(user); } @Transactional @PreAuthorize("hasRole('ADMIN') or #username == authentication.name") public ProfileDto updateProfile(String username, UpdateProfileRequest request){ User user = userRepository.findByUsername(username).orElseThrow(() -> new UsernameNotFoundException("Profile with username: " + username + " not fund")); user.setDisplayName(request.displayName()); user.setAvatarUrl(request.avatarUrl()); user.setStatus(request.status()); user.setPhoneNumber(request.phoneNumber()); user.setEmail(request.email()); //Можем спокойно не писать userRepository.save(user); Поскольку user уже в persistence контексте и hibernate следит за его изменениями return ProfileDto.ownerView(user); } @Transactional @PreAuthorize("hasRole('ADMIN') or #username == authentication.name") public void delete(String username){ User user = userRepository.findByUsername(username).orElseThrow(() -> new UsernameNotFoundException("Profile with username: " + username + " not fund")); user.setEnabled(false); } }
Теперь реализуем контроллер и перйдём к тестированию нашего приложения (да GlobalExceptionHandler я упущу, потому что это не главная цель всех моих статей)
@RestController @RequestMapping("/api/profiles") @RequiredArgsConstructor public class ProfileController { private final ProfileService profileService; @GetMapping("/{username}") public ResponseEntity<ProfileDto> getProfile(@PathVariable String username, @AuthenticationPrincipal UserDetails userDetails){ return ResponseEntity.ok(profileService.getProfile(username, userDetails.getUsername())); } @PostMapping("/{username}") public ResponseEntity<ProfileDto> updateProfile(@PathVariable String username, @RequestBody UpdateProfileRequest request){ return ResponseEntity.ok(profileService.updateProfile(username, request)); } @DeleteMapping("/{username}") public ResponseEntity<Void> deleteProfile(@PathVariable String username){ profileService.delete(username); return ResponseEntity.noContent().build(); } }
При первом запуске приложения, оно упадёт, всё потому что я забыл добавить поля в классе DataIntializer, теперь он выглядит так:
@Configuration @RequiredArgsConstructor public class DataInitializer { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; @Bean public CommandLineRunner initTestUser(){ return args -> { if(userRepository.existsByUsername("test")){ return; } User user = new User(); user.setUsername("test"); user.setPassword(passwordEncoder.encode("test123")); user.setRoles(Set.of(Role.USER)); user.setDisplayName("Apollon"); user.setAvatarUrl(null); user.setStatus("status"); user.setPhoneNumber("8899338844"); user.setEmail("sdsds@sds.sds"); userRepository.save(user); System.out.println("\nТестовый пользователь создан\n"); }; } }
Тестирование
Для начала попробуем зарегистрироваться в нашем же сервисе

Как мы можем видеть, регистрация прошла успешно и нам вернулись access и refresh токены.
Далее попробуем пройти аутентификацию с использованием токена

Аутентификация тоже прошла успешно, и теперь было бы хорошо проверить как работает обновление профиля и просмотр чужих профилей

После логина нужно скопировать access token и вставить его в поле token в Auth Type: Bearer token. После отправки всё было сработано успешно. Теперь попробуем получить и отредактировать свой профиль.

Свой профиль успешно получили, а теперь надо бы его обновить

Как мы видим, всё успешно работает!
Но хочется сделать небольшое отступление по поводу url в SecurityConfig и контроллерах.
В конфиге написано что мы можем менять профиль по url: /api/mod/**, хотя в контроллере просто POST-запрос на: /api/profiles/{username}. Почему так? Это сделано не для того чтобы использовать на продакшене, а чтобы показать, что можно ограничивать доступ к определённым ссылкам и разрешать его только пользователям с определёнными ролями. Сам контроллер реализован максимально просто, чтобы использовать готовый рабочий пример без лишних усложнений. По этому в моих статьях упор делается не на идеальный код и архитектуру, а на изучение возвожностей Spring Security. На продакшене лучше, чтобы url указаные в конфиге и в контроллере совпадали, так всё будет гораздо правильнее и чище.
Спасибо всем кто дочитал это статью и кому она оказалась полезной! Как обычно открыт к аргументированной критике)
