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