В предыдущем примере была реализована 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; } }
И настроим 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
Тут делается то же самое, плюс требуется самостоятельно сравнить полученный пароль с переданным и выбросить исключение при необходимости.
В параметр 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-аутентификацию.
Спасибо!! побольше выпускай подобные статьи и не только на эту тему) обязательно буду следить за новыми статьями…
Приветствую. Мне нравится, интересно. Вопрос.
У Вас class MyUser содержит поле private String role; -> в классе class CustomAuthencationProvider в методе .roles(myUser.getRole()), параметры String.
А как быть если у меня пользователь концептуально заложен так: private Set roles; и соответствующий метод не возвращает Srting.
Как переписать аудентификацию с таким условием?
.roles() принимает также массив ролей, так что можно сделать так: .roles(roles.toArray(String[]::new))
Спасибо, это тоже вариант, все получилось.
Здравствуйте.
У меня получилось выполнить 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);
в контроллере из примера по ссылке.
Ваш пример я примени и все получилось, но вот сообщения не могу перехватить.
AuthenticationFailureHandler- переопределение данного фильтра позволяет добавить в ответ об ошибке jason сообщения
понятно)
Кажется опечатка в имени класса:
public class CustomAuthencationProvider implements AuthenticationProvider
Объясните пожалуйста. Последняя реализация configure содержит
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
//и добавляем его сюда
auth.authenticationProvider(customAuthencationProvider);
}
и нам получается auth.userDetailsService(userDetailsService); уже не нужен?
Тогда не понятно как получается в customAuthenticationProvider получение пользователя и пароля из БД
1. да, уже не нужен.
2. Проверка происходит в customAuthenticationProvider.authenticate()
Здесь userName, password — приходят из браузера, а MyUser myUser = dao.findByLogin(userName) берется из базы.
Теперь понял. Ваш сайт просто кладец в интернете! Спасибо огромное ещё раз.
Кстати, можно ещё вопрос? В SS есть такой вид авторизации как группы и права на группы, но нигде не нашел к ним пример реализации, в официальной документации только примеры схем БД (https://docs.spring.io/spring-security/site/docs/5.4.1/reference/html5/#group-authorities).
Не могли бы выпустить пример на эту тему? Ну или хотябы навести на нужный класс или интерфейс для переопределение.
Спасибо!
Можно ожидать какой-то ответ на мой последний вопрос? 🙂
Прошу прощения за задержку.
Да, эти таблицы применяются с JdbcDaoImpl. То есть вместо пользовательской реализации UserDetailsService используем готовую из коробки JdbcDaoImpl — это отчасти даже проще.
Есть пример с JdbcDaoImpl — https://sysout.ru/nastrojka-jdbc-autentifikatsii-v-spring-security/, но только в вашем случае нужно еще создать таблицы групп.
Спасибо огромное!!!
Можно ещё вопрос. Если я не буду указывать .loginProcessingUrl, а просто в форме укажу например action=»/signin» , то мне потом нужно будет в контроллере мапить PostMapping(«/signin») и обрабатывать самому проверку username и password? Я правильно понимаю, что нужно делать свой AuthenticationProvider в этом случае и потом разруливать логику если всё Ок, или нет?
А если я задам в .loginProcessingUrl значение, то Spring Security будет сам обрабатывать аутентификацию?
.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 вообще не заостряйтесь, он нужен в крайних случаях).
Отлично. Теперь пазл сложился. Спасибо большое!
Записался бы на курсы или бы купил полное видео по вашим туториалам.
Не собираетесь выпустить? 🙂
Да, думаю об этом)
Отлично. Лично я буду ждать. =)
А как отловить ошибки типа Bad Credentials? Переопределить AuthenticationFailureHandler ?
Хочу чтобы тексты ошибок отображались в моей кастомной форме.
Сейчас обработка для всех типов ошибок идёт тупо как:
Invalid username and password.
Ни как не могу разрудить эту ситуацию.
Вопрос снимается. Залез в исходники спринга и всё там нашёл. В официальных доках не нашел. В инете тоже много муры с примерами для jsp. Для других любознательных в кратце скажу (может кому понадобится), что последнюю ошибку можно взять из переменной сессии: SPRING_SECURITY_LAST_EXCEPTION
Не на том уровне находится 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
Спасибо — позиция show-sql исправлена.
Да, в новых версиях по-другому.
ИМХО комментарии у вас на сайте ужасно настроены. Их нельзя изменить, код отображается как обычный текст. Это нужно исправить.
WordPress не так просто исправить.
А что делать если в @Service public class CustomUserDetailsService программа совсем не заходит, добавил там логов, но там пусто.
При этом в этот метод заходит
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
log.error(«auth «+auth);
auth.userDetailsService(userDetailsService);
}
Затрудняюсь ответить, не видя ваш код. Но CustomUserDetailsService задействуется, когда происходит аутентификация (проверка имени/пароля) — т.е. выполняется строка
this.getAuthenticationManager().authenticate(..)
В этом примере это происходит в UsernamePasswordAuthenticationFilter (line 95). А вызывается UsernamePasswordAuthenticationFilter потому, что в методе configure(HttpSecurity http) указан formLogin() — т.е. что данные приходят с формы. (Spring автоматически включает UsernamePasswordAuthenticationFilter в цепочку фильтров в этом случае.)
В примере с JWT данные не приходят с формы и фильтр UsernamePasswordAuthenticationFilter не вызывается, но мы сами делаем конечную точку с проверкой имени/пароля @PostMapping(«/authenticate») и вызываем там тот же authenticationManager.authenticate(..).
В обоих примерах именно строка authenticationManager.authenticate() есть причина захода в CustomUserDetailsService
@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);
}
А зачем на сервере ресурсов CustomUserDetailsService, проверяющий имя/пароль ? Указан jwkSetUri — на этой конечной точке производится проверка подлинности токена, и делается это на Keyloak. Когда приходит токен, сервер ресурсов по капотом выполняет обращение на Keyloak (на то он и сервер ресурсов), и если токен подлинный, выдает ресурс.
Что бы в Principal положить свой ExtendedUserDetails и во всем приложение не переделывать механизм получения авторизованного юзера с его различной информацией.
Может такой конвертер подойдет? https://stackoverflow.com/questions/51240197/spring-oauth-with-jwt-custom-userdetails-set-principal-inside-jwtaccesstokenco
спасибо за наводки.
надо было SwitchUserFilter.class применить.
https://stackoverflow.com/questions/64514346/spring-boot-oauth2-resource-server-userdetailsservice
Подскажите, пожалуйста, что более правильно использовать? Аутентификацию, рассмотренную в этой статье или в следующей (JDBC-аутентификация). В чем принципиальная разница. Там тоже берем логин и пароль из базы, только вместо JPA repository используем Query запросы. Насколько я знаю, JPA repository под капотом использует те же нативные запросы. И насколько эти способы актуальны сегодня. Или лучше использовать что-то другое при разграничении прав пользователей?
именно так, принципиальной разницы нет. Вполне актуально, думаю.
Здравствуйте уважаемый sysout!
Подскажите пожалуйста, а как можно добавить к пользовательской аутентификации, которая описана в этой статье, шифрование логина/пароля?
оказалось достаточным добавление в класс SecurityConfig ==>
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
и добавление строчки ==> .passwordEncoder(passwordEncoder())
чтобы получилось так:
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}