
Мое путешествие с 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, которые мне нужно исправить, чтобы использовать аннотации на моем контроллере для обработки авторизации, но все это я оставлю во второй части.