Пользовательская аутентификация в Spring Security

В предыдущем примере была реализована In-Memory аутентификация. Но это игрушечный случай, так как пользователи обычно хранятся не в памяти приложения, а в другом месте. В статье мы продолжим предыдущий пример: реализуем пользовательскую аутентификацию, при которой пользователи хранятся в базе. Но теоретически они могут браться отовсюду, хоть по REST из внешнего источника.

Мы сделаем два варианта аутентификации:

  • Реализуем пользовательский UserDetailsService  – то есть напишем метод loadUserByUsername(). По сути тут надо всего лишь реализовать извлечение пользователя из базы.
  • Реализуем пользовательский AuthenticationProvider. Тут надо переопределить метод authenticate(Authentication authentication). То есть помимо извлечения пользователя из базы, мы будем сами сравнивать пароли и формировать объект Authentication (либо выбрасывать исключение).

Подготовка

Итак, пользователи будут храниться в базе, так что добавим в проект зависимости для работы с базой.

Maven-зависимость

Во-первых, это стартер для работы с базой через JPA:

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

Во-вторых,  In-Memory база данных H2 (она удобна для учебных примеров, поскольку не нужно ставить на компьютер реальную базу):

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
    <version>1.4.199</version>
</dependency>

Модель пользователя

Пусть пользователь содержит логин, пароль, роль и еще, к примеру, должность:

@Entity
public class MyUser {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    long id;
    private String login;
    private String password;
    private String position;
    private String role;

    //сеттеры/геттеры
}

Репозиторий

Пропишем в репозитории метод, который понадобится в дальнейшем:

@Repository
public interface MyUserRepository extends JpaRepository<MyUser, Long> {
    MyUser findByLogin(String login);
}

Схема и данные

Наконец, заполним базу парой пользователей с помощью data.sql (этот файл надо положить в папку resources):

insert into my_user(login, position, password, role) values('user', 'position1', 'password', 'USER');
insert into my_user( login, position, password, role) values('admin', 'position2', 'password', 'ADMIN');

Чтобы на старте приложения таблица создавалась на основе помеченным аннотацией @Entity класса MyUser, а sql-код из data.sql запускался, поставим такие настройки в application.yml:

spring:
  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true
  datasource:
    initialization-mode: always

Все готово для реализации пользовательской аутентификации.

Пользовательский UserDetailsService

Итак, первый способ. Реализуем метод loadUserByUsername():

@Service
public class CustomUserDetailsService implements UserDetailsService {
    @Autowired
    private MyUserRepository dao;
    @Override
    public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
        MyUser myUser= dao.findByLogin(userName);
        if (myUser == null) {
            throw new UsernameNotFoundException("Unknown user: "+userName);
        }
        UserDetails user = User.builder()
                .username(myUser.getLogin())
                .password(myUser.getPassword())
                .roles(myUser.getRole())
                .build();
        return user;
    }
}
Если пользователь не найден, необходимо выбросить UsernameNotFoundException – Spring Security его ожидает.

И настроим AuthenticationManager:

@EnableWebSecurity(debug = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private CustomUserDetailsService userDetailsService;

    ...

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
      auth.userDetailsService(userDetailsService);
    }
}

Все, теперь пример работает как предыдущий, но данные берутся из базы.

Что происходит под капотом

В фильтре UsernamePasswordAuthenticationFilter происходит аутентификация. В метод:

Authentication authenticate(Authentication token)

передается токен с именем и пароля (пришедшими с формы). Далее Spring Security вызывает реализованный выше loadUserByUsername(String userName), чтобы получить реального пользователя из базы и сравнить его с переданным токеном. Если аутентификация удалась, возвращается новый объект Authentication (как описано тут); если нет – выбрасывается исключение.

Теперь реализуем альтернативный подход. В нем последний абзац исполним вручную.

Пользовательский AuthenticationProvider

Используется редко: например, для проверки надо передать как username, так и password внешнему сервису. Пример ниже не показательный.

Тут делается то же самое, плюс требуется самостоятельно сравнить полученный пароль с переданным и выбросить исключение при необходимости.

В параметр Authentication уже приходят имя и пароль, взятые с запроса (в каком фильтре данные берутся из запроса, описано в статье про аутентификацию). Они приходят в поля principal и credentials объекта Authentication (который в аргументе). А сформировать и вернуть необходимо новый Authentication , где реальный пользователь лежит в Principal в виде UserDetails:

@Component
public class CustomAuthencationProvider implements AuthenticationProvider {
    @Autowired
    private MyUserRepository dao;

    @Override
    public Authentication authenticate(Authentication authentication) 
                                          throws AuthenticationException {
        String userName = authentication.getName();
        String password = authentication.getCredentials().toString();
        //получаем пользователя
        MyUser myUser = dao.findByLogin(userName);
        //смотрим, найден ли пользователь в базе

        if (myUser == null) {
            throw new BadCredentialsException("Unknown user "+userName);
        }
        if (!password.equals(myUser.getPassword())) {
            throw new BadCredentialsException("Bad password");
        }
        UserDetails principal = User.builder()
                .username(myUser.getLogin())
                .password(myUser.getPassword())
                .roles(myUser.getRole())
                .build();
        return new UsernamePasswordAuthenticationToken(
                principal, password, principal.getAuthorities());

    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }
}

Настройка аутентификации теперь отличается одной строчкой:

@EnableWebSecurity(debug = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    //теперь внедряем свой провайдер   
    @Autowired
    private CustomAuthencationProvider customAuthencationProvider;


    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
       //и добавляем его сюда
       auth.authenticationProvider(customAuthencationProvider);
    }
    ...
}

Итоги

Пример есть на GitHub.

Дальше рассмотрим jdbc-аутентификацию.

 

Пользовательская аутентификация в Spring Security: 32 комментария

  1. Спасибо!! побольше выпускай подобные статьи и не только на эту тему) обязательно буду следить за новыми статьями…

  2. Приветствую. Мне нравится, интересно. Вопрос.
    У Вас class MyUser содержит поле private String role; -> в классе class CustomAuthencationProvider в методе .roles(myUser.getRole()), параметры String.
    А как быть если у меня пользователь концептуально заложен так: private Set roles; и соответствующий метод не возвращает Srting.
    Как переписать аудентификацию с таким условием?

    1. .roles() принимает также массив ролей, так что можно сделать так: .roles(roles.toArray(String[]::new))

  3. Здравствуйте.
    У меня получилось выполнить Ajax форму входа из примера: https://github.com/hardselius/spring-security-ajax-login/blob/master/src/main/java/no/buypass/bptestutil/components/controllers/AjaxLoginController.java

    Вопрос к Вам, а как перехватить сообщение об ошибке выбрасываемое в вашем коде
    if (myUser == null) {
    throw new BadCredentialsException(“Unknown user “+userName);
    в контроллере из примера по ссылке.
    Ваш пример я примени и все получилось, но вот сообщения не могу перехватить.

    1. AuthenticationFailureHandler- переопределение данного фильтра позволяет добавить в ответ об ошибке jason сообщения

  4. Кажется опечатка в имени класса:
    public class CustomAuthencationProvider implements AuthenticationProvider

  5. Объясните пожалуйста. Последняя реализация configure содержит
    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
    //и добавляем его сюда
    auth.authenticationProvider(customAuthencationProvider);
    }
    и нам получается auth.userDetailsService(userDetailsService); уже не нужен?

    Тогда не понятно как получается в customAuthenticationProvider получение пользователя и пароля из БД

    1. 1. да, уже не нужен.
      2. Проверка происходит в customAuthenticationProvider.authenticate()
      Здесь userName, password – приходят из браузера, а MyUser myUser = dao.findByLogin(userName) берется из базы.

      1. Теперь понял. Ваш сайт просто кладец в интернете! Спасибо огромное ещё раз.
        Кстати, можно ещё вопрос? В SS есть такой вид авторизации как группы и права на группы, но нигде не нашел к ним пример реализации, в официальной документации только примеры схем БД (https://docs.spring.io/spring-security/site/docs/5.4.1/reference/html5/#group-authorities).
        Не могли бы выпустить пример на эту тему? Ну или хотябы навести на нужный класс или интерфейс для переопределение.
        Спасибо!

          1. Прошу прощения за задержку.
            Да, эти таблицы применяются с JdbcDaoImpl. То есть вместо пользовательской реализации UserDetailsService используем готовую из коробки JdbcDaoImpl – это отчасти даже проще.
            Есть пример с JdbcDaoImpl – https://sysout.ru/nastrojka-jdbc-autentifikatsii-v-spring-security/, но только в вашем случае нужно еще создать таблицы групп.

  6. Можно ещё вопрос. Если я не буду указывать .loginProcessingUrl, а просто в форме укажу например action=”/signin” , то мне потом нужно будет в контроллере мапить PostMapping(“/signin”) и обрабатывать самому проверку username и password? Я правильно понимаю, что нужно делать свой AuthenticationProvider в этом случае и потом разруливать логику если всё Ок, или нет?
    А если я задам в .loginProcessingUrl значение, то Spring Security будет сам обрабатывать аутентификацию?

    1. .loginProcessingUrl просто переопределяет url конечной точки, где обрабатывается username/password с формы. Если loginProcessingUrl не указывать, но оставить .formLogin(), то конечная точка для обработки формы все равно останется – по дефолтному адресу ‘/login’ (независимо от того, какой action поставлен на фронтенде на форме). Так что если не собираетесь обрабатывать формы, то конечную точку .formLogin() надо закрыть (не писать в принципе эту строку (а с ней и loginProcessingUrl)). Если же данные приходят с формы, то action должен соответствовать.

      Контроллер же нужен, если данные приходят не с формы, а например, в JSON-формате (как тут https://github.com/myluckagain/sysout/blob/master/security7-JWT/src/main/java/ru/sysout/jwt/controller/AuthenticationController.java). Но и тут совершенно не нужно делать свой AuthenticationProvider, в примере его и нет (на AuthenticationProvider вообще не заостряйтесь, он нужен в крайних случаях).

      1. Отлично. Теперь пазл сложился. Спасибо большое!
        Записался бы на курсы или бы купил полное видео по вашим туториалам.
        Не собираетесь выпустить? 🙂

  7. А как отловить ошибки типа Bad Credentials? Переопределить AuthenticationFailureHandler ?
    Хочу чтобы тексты ошибок отображались в моей кастомной форме.
    Сейчас обработка для всех типов ошибок идёт тупо как:

    Invalid username and password.

    Ни как не могу разрудить эту ситуацию.

    1. Вопрос снимается. Залез в исходники спринга и всё там нашёл. В официальных доках не нашел. В инете тоже много муры с примерами для jsp. Для других любознательных в кратце скажу (может кому понадобится), что последнюю ошибку можно взять из переменной сессии: SPRING_SECURITY_LAST_EXCEPTION

  8. Не на том уровне находится show-sql: true
    Правильно будет так

    spring:
    jpa:
    hibernate:
    ddl-auto: create
    show-sql: true
    defer-datasource-initialization: true
    sql:
    init:
    mode: always

    Кроме того, у кого Spring Boot версии 2.4 и выше, обязательна строка: defer-datasource-initialization: true

  9. ИМХО комментарии у вас на сайте ужасно настроены. Их нельзя изменить, код отображается как обычный текст. Это нужно исправить.

  10. А что делать если в @Service public class CustomUserDetailsService программа совсем не заходит, добавил там логов, но там пусто.
    При этом в этот метод заходит
    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
    log.error(“auth “+auth);
    auth.userDetailsService(userDetailsService);
    }

    1. Затрудняюсь ответить, не видя ваш код. Но CustomUserDetailsService задействуется, когда происходит аутентификация (проверка имени/пароля) – т.е. выполняется строка
      this.getAuthenticationManager().authenticate(..)
      В этом примере это происходит в UsernamePasswordAuthenticationFilter (line 95). А вызывается UsernamePasswordAuthenticationFilter потому, что в методе configure(HttpSecurity http) указан formLogin() – т.е. что данные приходят с формы. (Spring автоматически включает UsernamePasswordAuthenticationFilter в цепочку фильтров в этом случае.)
      В примере с JWT данные не приходят с формы и фильтр UsernamePasswordAuthenticationFilter не вызывается, но мы сами делаем конечную точку с проверкой имени/пароля @PostMapping(“/authenticate”) и вызываем там тот же authenticationManager.authenticate(..).
      В обоих примерах именно строка authenticationManager.authenticate() есть причина захода в CustomUserDetailsService

      1. @Override
        protected void configure(HttpSecurity http) throws Exception {
        http
        .csrf().disable()
        .authorizeRequests()

        .antMatchers(“/close”).authenticated()
        .antMatchers(“/open”).permitAll()
        .antMatchers(“/admin”).authenticated()
        .anyRequest().permitAll().and()
        .oauth2ResourceServer()
        .jwt()
        .jwtAuthenticationConverter(jwtAuthenticationConverter())
        .jwkSetUri(“http://keycloak.demo:8080/auth/realms/test_sso_clients/protocol/openid-connect/certs”);
        }

        @Bean
        public JwtDecoder jwtDecoder() {
        return JwtDecoders.fromOidcIssuerLocation(“http://keycloak.demo:8080/auth/realms/test_sso_clients”);
        }

        @Bean
        public Converter jwtAuthenticationConverter() {
        …..
        return jwtAuthenticationConverter;
        }

        @Autowired
        CustomUserDetailsService userDetailsService;

        @Autowired
        public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        LoggingFilter.logToFile(“AuthenticationManagerBuilder “+auth);
        auth.userDetailsService(userDetailsService);
        }

        1. А зачем на сервере ресурсов CustomUserDetailsService, проверяющий имя/пароль ? Указан jwkSetUri – на этой конечной точке производится проверка подлинности токена, и делается это на Keyloak. Когда приходит токен, сервер ресурсов по капотом выполняет обращение на Keyloak (на то он и сервер ресурсов), и если токен подлинный, выдает ресурс.

          1. Что бы в Principal положить свой ExtendedUserDetails и во всем приложение не переделывать механизм получения авторизованного юзера с его различной информацией.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *