Мое путешествие с Spring Security 6 — часть I
Я уже реализовал некоторые аутентификации в своих проектах с помощью Spring Security и JWT, но в основном я следовал некоторым учебным пособиям, не совсем понимая сложности Spring Security, и стараясь изо всех сил реализовать то, что я считаю лучшими практиками.
С Spring 3 и Spring Security 6 я попытался перенести мой проект и погрузиться в официальную документацию Spring Security, пытаясь понять архитектуру обширной долины компонентов, составляющих Spring Security. И это был мой подход.
Домены и сведения о пользователе
Во-первых, я начал с домена для своей сущности пользователя, это операции CRUD и реализация UserDetails. Я использовал JPA и Lombook для упрощения. Я также использую класс AbstractDomain, он просто реализует интерфейс Serializable, вы можете напрямую реализовать его в своих доменах.
Пользовательский домен:
@Getter @Setter @Entity @Table(name = "tb_user") public class UserLoreKeeper extends AbstractDomain { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id_user") private Integer idUserLoreKeeper; @Column(name = "username", unique = true, nullable = false) private String dsUsername; @Column(name = "email", unique = true, nullable = false) private String dsEmail; @Column(name = "password", nullable = false) private String dsPassword; @Enumerated(EnumType.STRING) @ElementCollection(targetClass = Role.class, fetch = FetchType.EAGER) @CollectionTable(name = "tb_role") @Column(name = "role", nullable = false) private List<Role> roleList; }
И роль, которую я создал как Enum:
public enum Role { USER; }
Для моей реализации UserDetails я предпочел создать домен для моих UserDetails и моего Entity в двух отдельных объектах, чтобы поддерживать все, что связано с модулем безопасности моего проекта, отдельно от других модулей. Пока я игнорирую бизнес-логику остальных методов на потом.
@AllArgsConstructor public class UserSecurity implements UserDetails { private UserLoreKeeper userLoreKeeper; @Override public Collection<? extends GrantedAuthority> getAuthorities() { return userLoreKeeper.getRoleList().stream() .map(role -> new SimpleGrantedAuthority(role.name())) .toList(); } @Override public String getPassword() { return userLoreKeeper.getDsPassword(); } @Override public String getUsername() { return userLoreKeeper.getDsUsername(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }
В моей CRUD-службе объекта «Пользователь» я создал метод JPA для поиска пользователя с именем пользователя или адресом электронной почты, чтобы разрешить вход в систему с любым из этих полей. Я не уверен, что это лучший подход для этого, но сработало, как я и предполагал.
@Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserLoreKeeperService userLoreKeeperService; @Override public UserDetails loadUserByUsername(String dsUsername) throws UsernameNotFoundException { return new UserSecurity(userLoreKeeperService.findByDsEmailOrDsUsername(dsUsername, dsUsername).orElseThrow(createException(dsUsername))); } private Supplier<UsernameNotFoundException> createException(String dsUsername) { return () -> new UsernameNotFoundException("User " + dsUsername + " not found!"); } }
Наконец, я также создал домен для возврата токенов JWT клиенту.
@Getter @AllArgsConstructor public class Token extends AbstractDomain { private String dsAccessToken; private String dsRefreshToken; }
Фильтры и зависимости
Это была самая напряженная часть, я не на 100% доволен тем, как я это сделал, но был намного более организованным, чем мои предыдущие проекты.
Я создал два разных фильтра. Один для аутентификации с использованием имени пользователя и пароля и записи токена JWT доступа и обновления в ответе клиенту, а другой — для аутентификации с помощью токена JWT, полученного от клиента, т. е. один для входа в систему, а другой — для аутентификации оставшихся запросов. Я также создал сервис для управления токеном JWT.
Для службы JWT я использовал библиотеку auth0 java-jwt. Секрет на данный момент жестко запрограммирован для целей тестирования.
@Service public class JWTService { private static final String SECRET_KEY = "RandomKey"; public String createAcessToken(UserDetails user, String dsIssuer, Integer nrMinutes) { return JWT.create() .withSubject(user.getUsername()) .withIssuer(dsIssuer) .withClaim("roleList", user.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList())) .withExpiresAt(new Date(System.currentTimeMillis() + nrMinutes * 60 * 1000)) .sign(Algorithm.HMAC256(SECRET_KEY)); } public String createRefreshToken(UserDetails user, String dsIssuer, Integer nrMinutes) { return JWT.create() .withSubject(user.getUsername()) .withIssuer(dsIssuer) .withExpiresAt(new Date(System.currentTimeMillis() + nrMinutes * 60 * 1000)) .sign(Algorithm.HMAC256(SECRET_KEY)); } public DecodedJWT decodeToken(String dsToken) { return JWT.require(Algorithm.HMAC256(SECRET_KEY)).build().verify(dsToken); } }
Для фильтра входа в систему я получил имя пользователя и пароль из запроса в виде формы и использовал JWTService для записи маркеров доступа и обновления в ответ после успешной аутентификации. Я использовал RequestMatcher, чтобы указать, к каким конечным точкам должен применяться этот фильтр. Для всех фильтров проверки подлинности я предпочел расширить AbstractAuthenticationProcessingFilter вместо простого OncePerRequestFilter.
public class LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter { @Autowired private JWTService jwtService; private static final RequestMatcher antPathLogin = new AntPathRequestMatcher("/auth/login", HttpMethod.POST.name()); public LoginAuthenticationFilter(AuthenticationManager authenticationManager) { super(antPathLogin, authenticationManager); } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { String username = request.getParameter("username"); String password = request.getParameter("password"); UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password); return super.getAuthenticationManager().authenticate(authToken); } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException { UserDetails user = (UserDetails) authResult.getPrincipal(); String accessToken = jwtService.createAcessToken(user, request.getRequestURI(), 10); String refreshToken = jwtService.createRefreshToken(user, request.getRequestURI(), 60); Token token = new Token(accessToken, refreshToken); response.setContentType(MediaType.APPLICATION_JSON_VALUE); new ObjectMapper().writeValue(response.getOutputStream(), token); } }
Для аутентификации JWT мне нужно было перезаписать метод doFilter, чтобы он работал так, как я предполагал. Я не мог использовать метод successfulAuthentication без необходимости переписывать кучу вещей, поэтому я предпочел сохранить аутентификацию в методе doFilter. Я также использовал NegatedRequestMatcher, чтобы применить фильтр ко всем конечным точкам, кроме аутентификационных. На данный момент я не беспокоюсь об обработке исключений и написании клиенту соответствующих ответов об ошибках, поэтому я просто создал BadCredentialExceptions.
public class JWTAuthenticationFilter extends AbstractAuthenticationProcessingFilter { @Autowired private JWTService jwtService; private static final NegatedRequestMatcher antPathLogin = new NegatedRequestMatcher(new AntPathRequestMatcher("/auth/**", HttpMethod.POST.name())); public JWTAuthenticationFilter(AuthenticationManager authenticationManager) { super(antPathLogin, authenticationManager); } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { try { if (!requiresAuthentication((HttpServletRequest) request, (HttpServletResponse) response)) { return; } SecurityContextHolder.getContext().setAuthentication(attemptAuthentication((HttpServletRequest) request, (HttpServletResponse) response)); } catch (AuthenticationException e) { unsuccessfulAuthentication((HttpServletRequest) request, (HttpServletResponse) response, e); } catch (JWTVerificationException e) { unsuccessfulAuthentication((HttpServletRequest) request, (HttpServletResponse) response, parseException(e)); } finally { chain.doFilter(request, response); } } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); if (Objects.nonNull(authHeader) && authHeader.startsWith("Bearer ")) { return createUsernamePasswordToken(jwtService.decodeToken(authHeader.substring(7))); } throw new BadCredentialsException("Test"); } private AuthenticationException parseException(JWTVerificationException e) { return new BadCredentialsException("Test"); } private UsernamePasswordAuthenticationToken createUsernamePasswordToken(DecodedJWT decodedJWT) { String dsUsername = decodedJWT.getSubject(); List<SimpleGrantedAuthority> authorityList = Arrays.stream(decodedJWT.getClaim("roleList").asArray(String.class)).map(SimpleGrantedAuthority::new).collect(Collectors.toList()); return new UsernamePasswordAuthenticationToken(dsUsername, null, authorityList); } }
Конфигурация веб-безопасности
Здесь у нас было больше всего изменений по сравнению с Spring 3 с использованием набора bean-компонентов и лямбда-выражений. Я объявил все необходимые bean-компоненты в одном классе Configuration, он более организован, чем мои предыдущие проекты.
Я добавил два предыдущих фильтра авторизации в SecurityFilterChain и исключил авторизацию из конечных точек аутентификации.
@Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfiguration { private final UserDetailsService userDetailsService; @Bean @SneakyThrows public SecurityFilterChain securityFilterChain(HttpSecurity http) { http.csrf().disable() .authorizeHttpRequests((authorize) -> { authorize.requestMatchers(HttpMethod.POST,"/auth/**").permitAll() .anyRequest().authenticated(); }) .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() .authenticationProvider(authenticationProvider()) .addFilterAfter(loginAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterAfter(jwtAuthenticationFilter(), LoginAuthenticationFilter.class); return http.build(); } @Bean public LoginAuthenticationFilter loginAuthenticationFilter() { return new LoginAuthenticationFilter(authenticationManager()); } @Bean public JWTAuthenticationFilter jwtAuthenticationFilter() { return new JWTAuthenticationFilter(authenticationManager()); } @Bean public AuthenticationManager authenticationManager() { return new ProviderManager(authenticationProvider()); } @Bean public AuthenticationProvider authenticationProvider() { DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(); daoAuthenticationProvider.setUserDetailsService(userDetailsService); daoAuthenticationProvider.setPasswordEncoder(passwordEncoder()); return daoAuthenticationProvider; } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
Регистрация и обновление токена
Это была очень простая часть. Сначала я попытался сделать фильтр для обработки этих запросов, но я подумал, что это слишком много и потребовал много переписывания. Я предпочел сделать простой контроллер с сервисами.
Для контроллера я автоматически подключил необходимые службы и использовал HttpServeletRequest в методе refreshToken, чтобы получить путь сервлета для использования в качестве эмитента новых токенов.
@RestController @RequestMapping("/auth") public class AuthController { @Autowired private UserLoreKeeperService userLoreKeeperService; @Autowired private RefreshService refreshService; @PostMapping("/register") public ResponseEntity<UserLoreKeeper> saveUser(@RequestBody UserLoreKeeper userLoreKeeper) { return ResponseEntity.status(HttpStatus.CREATED).body(userLoreKeeperService.save(userLoreKeeper)); } @PostMapping("/refresh") public ResponseEntity<Token> refreshToken(HttpServletRequest httpServletRequest, @RequestBody Token token) { return ResponseEntity.ok(refreshService.createTokenFromRefresh(token.getDsRefreshToken(), httpServletRequest.getRequestURI())); } }
Служба RefreshService просто декодирует токен обновления, чтобы получить пользователя, и создает с его помощью новые токены.
@Service public class RefreshService { @Autowired private UserDetailsService userDetailsService; @Autowired private JWTService jwtService; public Token createTokenFromRefresh(String dsOldRefreshToken, String dsIssuer) { DecodedJWT decodedJWT = jwtService.decodeToken(dsOldRefreshToken); UserSecurity userSecurity = (UserSecurity) userDetailsService.loadUserByUsername(decodedJWT.getSubject()); String dsAccessToken = jwtService.createAcessToken(userSecurity, dsIssuer, 10); String dsNewRefreshToken = jwtService.createRefreshToken(userSecurity, dsIssuer, 60); return new Token(dsAccessToken, dsNewRefreshToken); } }
Заключение
Я все еще изучаю Spring Security и знаю, что в нем есть много дыр, которые нужно исправить, но за один день кодирования и использования только того, что мне предоставила документация Spring, это то, чего я достиг.
Я все еще пытаюсь узнать, как лучше всего писать хорошие сообщения об ошибках для клиента, так как я не могу использовать Spring ExceptionHandlers в контексте FilterChain, и это следующий шаг. Я возьмусь за свой проект.
У меня также было несколько проблем с @EnabledGlobalAutheticatio, которые мне нужно исправить, чтобы использовать аннотации на моем контроллере для обработки авторизации, но все это я оставлю во второй части.