Регистрация по взрослому: @AuthenticationalPrinciple, JWT, UserDetails

в 16:15, , рубрики: AuthenticationalPrinciple, AuthUser, java, jwt, security, spring

Казалось бы, что может быть проще создания регистрации и входа: взял пароль, взял username, сохранил в базу, когда пользователь заходит, просто сравниваешь значения с теми, что лежат в базе.

Отсюда вытекает две опасности и одна проблема:

Опасность 1: Допустим в приложении пользователи могут обмениваться сообщениями. Взломав один аккаунт, злоумышленник может отправить фишинговое сообщение и, тем самым, взломать еще один аккаунт и дальше по цепной реакции

Опасность 2: Проблема отслеживания аутентификации пользователя. Как мы можем понять аутентифицирован ли пользователь и действительно ли это пользователь, про которого мы думаем, имеет ли он право на то или иное действие

Проблема: Допустим, что он большое приложение, в котором множество элементов завязаны на uuid/id/email пользователя. Как нам сделать чтобы эти параметры были доступны везде внутри приложения?

Сегодня рассмотрим вариант как решить эти две опасности и одну проблему.

JWT

JWT - JSON Web Token - стандарт, который определяет компактный способ безопасной передачи информации между участниками соединения как JSON объект. В интернете много статей про актуальность, работу, условия и причины использования этого токена. Мы остановимся только на том, что он нам нужен для валидации аутентификации и нам том что он хранится в заголовке 'Authentication' и начинается со слова 'Bearer'.

Перед тем как что-то использовать, это что-то надо создать. Начнем с создания:

JWT должен быть подписан. Ключем к подписи может быть что угодно, от слова "test", до случайного набора символов. Также, нам нужен флаг, который бы искал необходимый заголовок. Также давайте добавим необходимый префикс, по которому мы будем проверять наличие токена у пользователя. Я для этого использую класс JwtConstants

public class JwtConstants {
    public static final String SECRET_KEY = "wpembytrwcvnryxksdbqwjebruyGHyudqgwveytrtrCSnwifoesarjbwe";
    public static final String JWT_HEADER = "Authorization";
    public static final String TOKEN_PREFIX = "Bearer ";
}

Теперь перейдем к созданию самого JWT - класс JwtProvider

public class JwtProvider {
    static SecretKey key = Keys.hmacShaKeyFor(JwtConstants.SECRET_KEY.getBytes());

    public static String generateToken(Authentication auth) {
        Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
        String roles = populateAuthorities(authorities);

        Map<String, Object> authUserMap = new HashMap<>();
        authUserMap.put("uuid", ((AuthUser) auth.getPrincipal()).getUuid());
        authUserMap.put("email", ((AuthUser) auth.getPrincipal()).getEmail());
        authUserMap.put("name", ((AuthUser) auth.getPrincipal()).getName());
        authUserMap.put("surname", ((AuthUser) auth.getPrincipal()).getSurname());
        authUserMap.put("password", ((AuthUser) auth.getPrincipal()).getPassword());

        return Jwts.builder()
                .issuedAt(new Date())
                .expiration(new Date(new Date().getTime() + 86400000))
                .claim("auth", authUserMap) 
                .claim("email", auth.getName())
                .claim("authorities", roles)
                .signWith(key)
                .compact();
    }

    private static String populateAuthorities(Collection<? extends GrantedAuthority> authorities {
        Set<String> auths = new HashSet<>();
        for(GrantedAuthority authority: authorities) {
            auths.add(authority.getAuthority());
        }
        return String.join(",",auths);
    }
}

Теперь давайте по порядку:

Строчка #2 - здесь мы создаем подпись - шифруем ключ с помощью SHA алгоритма (на самом деле алгоритм зависит от длины изначального ключа: автоматически выбирается либо SHA256, либо SHA384, либо SHA256

А дальше все упирается в Authorization, поэтому перейдем сразу к строчке #15. Интуитивно понятно, что мы собираем JWT объект, который создан (issueAt) сейчас, испортится через 24 часа начиная с момента создания, внутрь токена добавляем пару значений, а потом подписываем сгенерированным в самом начале ключом.

SecurityContext

Перейдем к аутентификации. Но начнем с регистрации*.

@Override
@NotNull
public AuthUser registration(@NotNull UserSignUpFirstForm userSignupFormFirst) {
    User user = new User();
    String encodedPassword = passwordEncoder.encode(userSignupFormFirst.getPassword());

    user
        .setUserType(UserType.VISITOR)
        .setPassword(encodedPassword)
        .setEmail(userSignupFormFirst.getEmail())
        .setCreatedAt(LocalDateTime.now())
        .setUpdatedAt(LocalDateTime.now());
    userRepository.save(user);

    User savedUser = userRepository.findByEmail(userSignupFormFirst.getEmail()).orElseThrow(
            () -> new RuntimeException("User not found")
    );

    AuthUser authUser = new AuthUser();
    authUser.setUuid(savedUser.getUuid());
    authUser.setEmail(savedUser.getEmail());
    authUser.setPassword(encodedPassword);

    Authentication authentication = new UsernamePasswordAuthenticationToken(
            authUser,
            encodedPassword,
            authUser.getAuthorities()
    );

    SecurityContextHolder.getContext().setAuthentication(authentication);

    String jwtToken = JwtProvider.generateToken(authentication);
    authUser.setToken(jwtToken);

    return authUser;
}

Строчка #5: По технике безопасности нам необходимо хранить пароль пользователя в зашифрованном виде, мой passwordEncoder - это бин, который возвращает BCryptPasswordEncoder.

Строчки #7-13: Создаем пользователя и сохраняем его в базу.

Строчки #15-17: Думаю, что этот кусок не нуждается в объяснениях. Он нужен не столько для того, что достать только что созданного пользователя, сколько подтвердить, что мы успешно занесли информацию в базу.

Строчки #24-30: Чтобы иметь доступ к некоторой информации об аутентифицированном пользователе, мы должны положить его в контекст приложения. Сделать это мы можем предвательно создав объект Authentication с имплементацией UsernamePasswordAuthenticationTokenкласса. Из названия становится понятно, что мы создаем некоторый токен, который будет хранить информацию об имени пользователя (в моем случае имя = email) и его пароле (причем зашифронного).

Но теперь встает вопрос, кто такой, черт возьми, AuthUser, если у нас уже есть просто User?!

Скрытый текст

Соглашусь, что возвращать AuthUser довольно спорно.

Зачем что-то вообще возвращать? Потому что мы должны проверить пользователя на авторизованность, то есть запросы с фронта должны приходить с заголовком 'Authorization': 'Bearer ' + user.token

Насколько возвращать только токен верно, тоже спорно...

В общем жду совета в комментариях как это лучше оформить

Два объекта пользователя

Давайте обсудим такой подход. Подумаем еще раз

  • Чего мы хотим? - Иметь доступ к некоторой информации об пользователе во всем приложении

  • О каком пользователе мы хотим иметь эту информацию? - Об авторизованном

  • Есть ли смысл иметь в контексте всю информацию об пользователе? - Как будто бы нет

Приходим к выводу, что нам нужно два объекта - полноценный пользователь и аутентифицированный пользователь, который будет хранить только необходимую для нас инфу. Получаем: (наличие сеттеров и геттеров очевидно)

Полноценный юзер:

@Entity
@Table(name = "users")
public class User extends BaseUser {
    String role = "CUSTOM_USER";

public class BaseUser extends UuidAbleTimedEntity implements Serializable {
    private String password;
    private String email;
    private String name;
    private String surname;
    private String phone;
    @Enumerated(EnumType.STRING)
    private UserType userType;
    @OneToMany(mappedBy = "owner")
    private List<Attraction> ownerAttractions;

Аутентифицированный юзер:

public class AuthUser implements UserDetails {
    private String uuid;
    private String email;
    private String password;
    private String token;
    private String name;
    private String surname;

  // ...
  // ...
  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
      return List.of(new SimpleGrantedAuthority("ROLE_USER"));
  }

UserDetails - это интерфейс из пакета org.springframework.security.core.userdetails, который мы можем имплементировать для удобства. Базово он добавляет следующие поля:

default boolean isAccountNonExpired() {
    return true;
}

default boolean isAccountNonLocked() {
    return true;
}

default boolean isCredentialsNonExpired() {
    return true;
}

default boolean isEnabled() {
    return true;
}

Возвращаемся к регистрации

Для удобства продублирую кусок кода

@Override
@NotNull
public AuthUser registration(@NotNull UserSignUpFirstForm userSignupFormFirst) {
    User user = new User();
    String encodedPassword = passwordEncoder.encode(userSignupFormFirst.getPassword());

    user
        .setUserType(UserType.VISITOR)
        .setPassword(encodedPassword)
        .setEmail(userSignupFormFirst.getEmail())
        .setCreatedAt(LocalDateTime.now())
        .setUpdatedAt(LocalDateTime.now());
    userRepository.save(user);

    User savedUser = userRepository.findByEmail(userSignupFormFirst.getEmail()).orElseThrow(
            () -> new RuntimeException("User not found")
    );

    AuthUser authUser = new AuthUser();
    authUser.setUuid(savedUser.getUuid());
    authUser.setEmail(savedUser.getEmail());
    authUser.setPassword(encodedPassword);

    Authentication authentication = new UsernamePasswordAuthenticationToken(
            authUser,
            encodedPassword,
            authUser.getAuthorities()
    );

    SecurityContextHolder.getContext().setAuthentication(authentication);

    String jwtToken = JwtProvider.generateToken(authentication);
    authUser.setToken(jwtToken);

    return authUser;
}

Строчки #19-22: мы заполняем объект аутентифицированного пользователя.

Строчки #32-33: мы генерируем JWT, который позже кладем в AuthUser.

Замечаем, что мы передаем как параметр authentication. Возвращаемся еще на шаг назад

Теперь по новой

public static String generateToken(Authentication auth) {
    Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
    String roles = populateAuthorities(authorities);

    Map<String, Object> authUserMap = new HashMap<>();
    authUserMap.put("uuid", ((AuthUser) auth.getPrincipal()).getUuid());
    authUserMap.put("email", ((AuthUser) auth.getPrincipal()).getEmail());
    authUserMap.put("name", ((AuthUser) auth.getPrincipal()).getName());
    authUserMap.put("surname", ((AuthUser) auth.getPrincipal()).getSurname());
    authUserMap.put("password", ((AuthUser) auth.getPrincipal()).getPassword());

    return Jwts.builder()
            .issuedAt(new Date())
            .expiration(new Date(new Date().getTime() + 86400000))
            .claim("auth", authUserMap) // Store the Map as "auth"
            .claim("email", auth.getName())
            .claim("authorities", roles)
            .signWith(key)
            .compact();
  }

Для того, чтобы создать JWT для определенного пользователя, мы должны зашифровать информацию об пользователе внутри этого самого токена (к сожалению, мы не можем напрямую положить в .claim() объект Authentication, поэтому приходится собирать мапу и передавать ее). Также дополнительно передаем email пользователя как уникальное свойство и roles - роль текущего пользователя (в моем случае у меня всего одна роль - "ROLE_USER", но их может быть несколько и функционал пользователя может зависеть от его роли.

Фух, с регистрацией вроде бы разобрались, перейдем к логину.

Логин

Окей, теперь у пользователя есть JWT. А как понять действительно он есть. Для этого я использую класс JwtTokenValidator

public class JwtTokenValidator extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
        @NotNull HttpServletRequest request,
        @NotNull HttpServletResponse response,
        @NotNull FilterChain filterChain
    ) throws ServletException, IOException {
        String jwt = request.getHeader(JwtConstants.JWT_HEADER);
        if (jwt != null && jwt.startsWith(JwtConstants.TOKEN_PREFIX)) {
            jwt = jwt.substring(7);
            try {
                SecretKey key = Keys.hmacShaKeyFor(JwtConstants.SECRET_KEY.getBytes());

                @SuppressWarnings("deprecation")
                Claims claims = Jwts.parser().setSigningKey(key).build().parseClaimsJws(jwt).getBody();

                Map<String, Object> authUserMap = (Map<String, Object>) claims.get("auth");

                AuthUser authUser = new AuthUser();
                authUser.setUuid((String) authUserMap.get("uuid"));
                authUser.setEmail((String) authUserMap.get("email"));
                authUser.setName((String) authUserMap.get("name"));
                authUser.setSurname((String) authUserMap.get("username"));
                authUser.setPassword((String) authUserMap.get("password"));

                String authorities = String.valueOf(claims.get("authorities"));
                List<GrantedAuthority> auth = AuthorityUtils.commaSeparatedStringToAuthorityList(authorities);

                Authentication authentication = new UsernamePasswordAuthenticationToken(authUser, null, auth);
                SecurityContextHolder.getContext().setAuthentication(authentication);

            } catch (Exception e) {
                throw new BadCredentialsException("Invalid token", e);
            }
        }

        filterChain.doFilter(request, response);
    }
}

Видим, что мы наследуем класс OncePerRequestFilter , это необходимо, чтобы фильтровать запросы в securityFilterChain, .addFilterBefore()

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .sessionManagement(management ->
                management.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        )
        .authorizeHttpRequests((requests) -> requests
            .requestMatchers(
                "/singin",
                "/signup",
                "/signup/name",
                "/",
                "/logout")
            .permitAll()
            .anyRequest()
            .authenticated()
        )
        .addFilterBefore(new JwtTokenValidator(), BasicAuthenticationFilter.class)
        .csrf(AbstractHttpConfigurer::disable)
        .cors(cors -> cors.configurationSource(webConfig.corsConfigurationSource()));

    return http.build();
}

В общих чертах: если у нас запрос содержит заголовок Authorization и начинается со слова Bearer, то мы воспроизводим оригинальный ключ, вычленяем из пришедшего токена наши Claims и перебираем их в мапу, а потом снова создаем* AuthUser и кладем его в контекст приложения.

Скрытый текст

Тут я столкнулся с двумя нерешенными проблемами:

1) Допустим я зарегистрировался в хроме, потом вышел. Если я захочу зайти в аккаунт через сафари, то возникнут проблемы (насколько я знаю jwt хранится в браузере, как хранить его к кэшэ, например, пока не знаю, предположительно можно класть в redis и доставать, если в запросе нет токена и проверять на его наличие в кэше)

2) Не удалось протестировать, что будем, если токен протухнет

Код самого логина

@Override
@NotNull
public AuthUser login(
    @NotNull UserSignInForm userSignInForm
) {
    Authentication authentication = authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(
            userSignInForm.getEmail(),
            userSignInForm.getPassword()
        )
    );

    SecurityContextHolder.getContext().setAuthentication(authentication);
    String token = JwtProvider.generateToken(authentication);

    AuthUser authUser = new AuthUser();
    authUser.setEmail(userSignInForm.getEmail());
    authUser.setToken(token);

    return authUser;
}

Снова создаем Authentication объект и кладем его в контекст (суть такая же как и при регистрации).

@AuthenticationalPrinciple

Заключительно на сегодня, то ради чего это все и затевалось, эффект, которого я достигал неделю - обращение к пользователю внутри контекста.

@PostMapping("/name")
public AuthUser signupNames(
    @Validated @RequestBody UserSignUpSecondForm userSignupSecondForm,
    BindingResult bindingResult,
    @AuthenticationPrincipal AuthUser authUser

Внутри AuthUser мы будем иметь всю информацию об авторизованном пользователе. Мы можем использовать этот объект абсолютно везде. Нам больше не нужно передавать uuid пользователя в ответе от клиента, чтобы, например, разработать функционал изменения имени пользователя, мы можем получить этот uuid из @AuthenticatinalPrinciple

Заключение

Я считаю имплементацию этого функционала своим личным достижением. Именно это послужило причиной написания моей первой статьи. По-моему мнению, это почти идеальное исполнение со всех сторон. Тем не менее, я не против получить совет или хотя бы ссылку на крутую статью по этой теме.

Спасибо за внимание!

Автор: gagarin1love

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js