Во время написания своего приложения столкнулась с отсутствием внятных статей, как сделать так, чтобы пользователь регистрировался и через email, и через социальные сети. Были хорошие туториалы по настройке классической формы входа. Были хорошие туториалы по OAuth2. Как подружить два способа, информации было преступно мало. В процессе поисков удалось вывести работоспособное решение. Оно не претендует на истину в последней инстанции, однако свою функцию выполняет. В этой статье я покажу, как с нуля реализовать сервис для хранения заметок с подобной конфигурацией Spring Security.
Примечание: хорошо, если читатель прошел хотя бы пару туториалов по Spring, потому что внимание будет акцентироваться только на Spring Security, без детальных объяснений репозиториев, контроллеров и т. п. Иначе и так немаленькая статья получилась бы гигантской.
Содержание
![Дружим обычный вход через email и OAuth2 в Spring Security на примере сервиса заметок - 1]()
Видим, что новый пользователь появился в базе данных. Пароль зашифрован.
![Дружим обычный вход через email и OAuth2 в Spring Security на примере сервиса заметок - 3]()
![Дружим обычный вход через email и OAuth2 в Spring Security на примере сервиса заметок - 4]()
![Дружим обычный вход через email и OAuth2 в Spring Security на примере сервиса заметок - 5]()
Заметки сохранились в базу данных.
Видим, что проект успешно запускается и работает. Для полного счастья нам не хватает только возможности входа через социальные сети. Что ж, приступим!
![Дружим обычный вход через email и OAuth2 в Spring Security на примере сервиса заметок - 8]()
![Дружим обычный вход через email и OAuth2 в Spring Security на примере сервиса заметок - 9]()
![Дружим обычный вход через email и OAuth2 в Spring Security на примере сервиса заметок - 10]()
![Дружим обычный вход через email и OAuth2 в Spring Security на примере сервиса заметок - 11]()
![Дружим обычный вход через email и OAuth2 в Spring Security на примере сервиса заметок - 12]()
![Дружим обычный вход через email и OAuth2 в Spring Security на примере сервиса заметок - 13]()
![Дружим обычный вход через email и OAuth2 в Spring Security на примере сервиса заметок - 14]()
Пользователь успешно входит и через обычную форму, и через Google аккаунт. Этого мы и добивались!
Надеюсь, эта статья прояснила определенные моменты в создании веб-приложения, его защите с помощью Spring Security и комбинировании разных способов входа. C полным кодом проекта вы можете

- Создание проекта
- Создание сущностей и логики приложения
- Настройка Spring Security для классического входа
- Настройка OAuth2 на примере Google в Spring Security
- Конфигурация фильтра и application.properties
- Основные моменты регистрации приложения в Google Cloud Platform
- CustomUserInfoTokenServices
- Итоговый запуск проекта
Создание проекта
Идем на start.spring.io и формируем основу проекта:- Web — запуск приложения на встроенном Tomcat, url-сопоставления и тому подобное;
- JPA — связь с базой данных;
- Mustache — шаблонизатор, используется для генерации веб-страниц;
- Security — защита приложения. То, ради чего эта статья и создавалась.
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.34</version>
</dependency>
Конфигурация application.properties на данный момент следующая:
spring.datasource.url=jdbc:mysql://localhost:3306/springsectut?createDatabaseIfNotExist=true&useSSL=false&autoReconnect=true&useLegacyDatetimeCode=false&serverTimezone=UTC&useUnicode=yes&characterEncoding=UTF-8
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.username=yourUsername
spring.datasource.password=yourPassword
spring.jpa.generate-ddl=true
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.connection.characterEncoding=utf-8
spring.jpa.properties.connection.CharSet=utf-8
spring.jpa.properties.connection.useUnicode=true
spring.mustache.expose-request-attributes=true
Создание сущностей и логики приложения
Сущности
Создадим пакетentities
, в который поместим сущности базы данных. Пользователь будет описываться классом User
, реализующим интерфейс UserDetails
, что понадобится для конфигурации Spring Security. У пользователя будет id, username (в качестве него выступает email), пароль, имя, роль, флаг активности, имя и email аккаунта Google (googleName
и googleUsername
).
@Entity
@Table(name = "user")
public class User implements UserDetails
{
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String username;
private String password;
private String name;
private boolean active;
private String googleName;
private String googleUsername;
@ElementCollection(targetClass = Role.class, fetch = FetchType.EAGER)
@CollectionTable(name = "user_role", joinColumns = @JoinColumn(name = "user_id"))
@Enumerated(EnumType.STRING)
private Set<Role> roles;
//Геттеры, сеттеры, toString(), equals(), hashcode(), имплементация UserDetails
}
Роли пользователя используются для регуляции доступа в Spring Security. Наше приложение будет использовать только одну роль:
public enum Role implements GrantedAuthority
{
USER;
@Override
public String getAuthority()
{
return name();
}
}
Создадим класс заметки с id, заголовком заметки, телом заметки и id пользователя, которому она принадлежит:
@Entity
@Table(name = "note")
public class Note
{
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String title;
private String note;
private Long userId;
//Геттеры, сеттеры, toString(), equals(), hashcode()
}
Репозитории
Для сохранения сущностей в базу данных нам понадобятся репозитории, которые сделают всю грязную работу за нас. Создадим пакетrepos
, в нем создадим интерфейсы UserRepo
, NoteRepo
, унаследованные от интерфейса JpaRepository<Entity, Id>
.
@Service
@Repository
public interface UserRepo extends JpaRepository<User, Long>
{}
@Service
@Repository
public interface NoteRepo extends JpaRepository<Note, Long>
{
List<Note> findByUserId(Long userId);
}
Контроллеры
В нашем сервисе заметок будут следующие страницы:- Главная;
- Регистрация;
- Вход;
- Список заметок пользователя.
controllers
, в нем класс IndexController
, содержащий обычный get-mapping главной страницы.
Класс RegistrationController
отвечает за регистрацию пользователя. Post-mapping принимает данные из формы, сохраняет пользователя в базу данных и переадресовывает на страницу входа. PasswordEncoder
будет описан позднее. Он используется для шифрования паролей.
@Controller
public class RegistrationController
{
@Autowired
private UserRepo userRepo;
@Autowired
private PasswordEncoder passwordEncoder;
@GetMapping("/registration")
public String registration()
{
return "registration";
}
@PostMapping("/registration")
public String addUser(String name, String username, String password)
{
User user = new User();
user.setName(name);
user.setUsername(username);
user.setPassword(passwordEncoder.encode(password));
user.setActive(true);
user.setRoles(Collections.singleton(Role.USER));
userRepo.save(user);
return "redirect:/login";
}
Контроллер, отвечающий за страницу списка заметок, пока что содержит упрощенный функционал, который усложнится после внедрения Spring Security.
@Controller
public class NoteController
{
@Autowired
private NoteRepo noteRepo;
@GetMapping("/notes")
public String notes(Model model)
{
List<Note> notes = noteRepo.findAll();
model.addAttribute("notes", notes);
return "notes";
}
@PostMapping("/addnote")
public String addNote(String title, String note)
{
Note newNote = new Note();
newNote.setTitle(title);
newNote.setNote(note);
noteRepo.save(newNote);
return "redirect:/notes";
}
}
Для страницы входа мы не будем прописывать контроллер, потому что она задействована в Spring Security. Вместо этого нам понадобится специальная конфигурация. Привычно создадим очередной пакет, назовем его config
, поместим туда класс MvcConfig
. Когда мы напишем конфигурацию Spring Security, она будет знать, какую страницу мы имеем в виду при использовании "/login".
@Configuration
public class MvcConfig implements WebMvcConfigurer
{
public void addViewControllers(ViewControllerRegistry registry)
{
registry.addViewController("/login").setViewName("login");
registry.setOrder(Ordered.HIGHEST_PRECEDENCE);
}
}
Страницы
Для создания страниц я использую шаблонизатор Mustache. Вы можете внедрить и другой, не принципиально. Для мета-информации, которая используется на всех страницах, создан файл meta.mustache. В нем же подключен Bootstrap, чтобы страницы нашего проекта выглядели симпатичней. Страницы создаются в директории "src/main/resources/templates". Файлы имеют расширение mustache. Размещение html-кода непосредственно в статье сделает ее слишком большой, поэтому вот ссылка на папку с шаблонами в репозитории проекта на GitHub.Настройка Spring Security для классического входа
Spring Security помогает нам защищать приложение и его ресурсы от несанкционированного доступа. Мы создадим лаконичную рабочую конфигурацию в классеSecurityConfig
, унаследованный от WebSecurityConfigurerAdapter
, который поместим в пакет config
. Пометим его аннотацией @EnableWebSecurity, которая включит поддержку Spring Security, и аннотацией @Configuration, которая указывает, что этот класс содержит некую конфигурацию.
Примечание: в автоматически сконфигурированном pom.xml стояла версия родительского компонента Spring Boot 2.1.4.RELEASE, что мешало внедрить Security устоявшимся способом. Во избежание конфликтов в проекте рекомендуется изменить версию на 2.0.1.RELEASE.
Основная конфигурация SecurityConfig
Наша конфигурация будет уметь:Шифровать пароли с помощью
BCryptPasswordEncoder
:@Autowired private PasswordEncoder passwordEncoder; @Bean PasswordEncoder passwordEncoder() { PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); return passwordEncoder; }
Осуществлять вход по специально написанному провайдеру аутентификации:
@Autowired private AuthProvider authProvider; @Override protected void configure(AuthenticationManagerBuilder auth) { auth.authenticationProvider(authProvider); }
Разрешать доступ анонимным пользователям к главной странице, страницам регистрации и входа. Все остальные запросы должны выполняться вошедшими в систему пользователями. Страницей входа назначим ранее описанную "/login". При успешном входе пользователь попадет на страницу со списком заметок, при ошибке — останется на странице входа. При успешном выходе пользователь попадет на главную страницу.
@Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/resources/**", "/", "/login**", "/registration").permitAll() .anyRequest().authenticated() .and().formLogin().loginPage("/login") .defaultSuccessUrl("/notes").failureUrl("/login?error").permitAll() .and().logout().logoutSuccessUrl("/").permitAll(); }
Кастомный вход пользователя
Самостоятельно написанныйAuthProvider
позволит пользователю входить не только по email, но и по имени пользователя.
@Component
public class AuthProvider implements AuthenticationProvider
{
@Autowired
private UserService userService;
@Autowired
private PasswordEncoder passwordEncoder;
public Authentication authenticate(Authentication authentication) throws AuthenticationException
{
String username = authentication.getName();
String password = (String) authentication.getCredentials();
User user = (User) userService.loadUserByUsername(username);
if(user != null && (user.getUsername().equals(username) || user.getName().equals(username)))
{
if(!passwordEncoder.matches(password, user.getPassword()))
{
throw new BadCredentialsException("Wrong password");
}
Collection<? extends GrantedAuthority> authorities = user.getAuthorities();
return new UsernamePasswordAuthenticationToken(user, password, authorities);
}
else
throw new BadCredentialsException("Username not found");
}
public boolean supports(Class<?> arg)
{
return true;
}
}
Как вы могли заметить, за подгрузку пользователя отвечает класс UserService
, лежащий в пакете services
. В нашем случае он ищет пользователя не только по полю username
, как встроенная реализация, но еще и по имени пользователя, имени Google аккаунта и email Google аккаунта. Последние два способа пригодятся нам при реализации входа через OAuth2. Здесь класс приведен в сокращенном варианте.
@Service
public class UserService implements UserDetailsService
{
@Autowired
private UserRepo userRepo;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
{
User userFindByUsername = userRepo.findByUsername(username);
//Остальные поиски
if(userFindByUsername != null)
{
return userFindByUsername;
}
//Остальные проверки
return null;
}
}
Примечание: не забудьте прописать нужные методы в UserRepo
!
Усовершенствуем контроллер
Мы настроили Spring Security. Самое время воспользоваться этим в контроллере заметок. Теперь каждый mapping будет принимать дополнительный параметр Principal, по которому постарается найти пользователя. Почему нельзя напрямую внедрить классUser
? Тогда произойдет конфликт из-за несовпадения типов пользователей, когда мы напишем вход через социальные сети. Мы заранее обеспечиваем необходимую гибкость.
Теперь наш код контроллера заметок выглядит так:
@GetMapping("/notes")
public String notes(Principal principal, Model model)
{
User user = (User) userService.loadUserByUsername(principal.getName());
List<Note> notes = noteRepo.findByUserId(user.getId());
model.addAttribute("notes", notes);
model.addAttribute("user", user);
return "notes";
}
@PostMapping("/addnote")
public String addNote(Principal principal, String title, String note)
{
User user = (User) userService.loadUserByUsername(principal.getName());
Note newNote = new Note();
newNote.setTitle(title);
newNote.setNote(note);
newNote.setUserId(user.getId());
noteRepo.save(newNote);
return "redirect:/notes";
}
Примечание: в проекте по умолчанию включена защита CSRF, поэтому либо отключите ее для себя (http.csrf().disable()), либо не забывайте, как автор статьи, добавлять во все post-запросы скрытое поле с csrf-токеном.
Запуск
Пробуем запустить проект.






Настройка OAuth2 на примере Google в Spring Security
При внедрении OAuth2 я опиралась на этот официальный туториал от Spring. Для поддержки OAuth2 добавим в pom.xml следующую библиотеку:
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<version>2.0.0.RELEASE</version>
</dependency>
Модифицируем нашу конфигурацию Spring Security в классе SecurityConfig
. Для начала добавим аннотацию @EnableOAuth2Client. Она автоматически подтянет нужное для логина через соцсети.
Конфигурация фильтра и application.properties
Внедрим инъекцию OAuth2ClientContext, чтобы использовать ее в нашей конфигурации безопасности.
@Autowired
private OAuth2ClientContext oAuth2ClientContext;
OAuth2ClientContext используется при создании фильтра, который проверяет пользовательский запрос на вход через соцсеть. Фильтр доступен благодаря аннотации @EnableOAuth2Client. Все, что нам нужно, вызвать его в правильном порядке, до основного фильтра Spring Security. Только в таком случае мы сможем поймать перенаправления в процессе входа с OAuth2. Для этого используем FilterRegistrationBean
, в котором выставляем приоритет нашего фильтра на -100.
@Bean
public FilterRegistrationBean oAuth2ClientFilterRegistration(OAuth2ClientContextFilter oAuth2ClientContextFilter)
{
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(oAuth2ClientContextFilter);
registration.setOrder(-100);
return registration;
}
private Filter ssoFilter()
{
OAuth2ClientAuthenticationProcessingFilter googleFilter = new OAuth2ClientAuthenticationProcessingFilter("/login/google");
OAuth2RestTemplate googleTemplate = new OAuth2RestTemplate(google(), oAuth2ClientContext);
googleFilter.setRestTemplate(googleTemplate);
CustomUserInfoTokenServices tokenServices = new CustomUserInfoTokenServices(googleResource().getUserInfoUri(), google().getClientId());
tokenServices.setRestTemplate(googleTemplate);
googleFilter.setTokenServices(tokenServices);
tokenServices.setUserRepo(userRepo);
tokenServices.setPasswordEncoder(passwordEncoder);
return googleFilter;
}
Также необходимо добавить новый фильтр в функцию configure(HttpSecurity http):
http.addFilterBefore(ssoFilter(), UsernamePasswordAuthenticationFilter.class);
Фильтру также необходимо знать о регистрации клиента через Google. Аннотация @ConfigurationProperties указывает, на какие свойства конфигурации следует обратить внимание в application.properties.
@Bean
@ConfigurationProperties("google.client")
public AuthorizationCodeResourceDetails google()
{
return new AuthorizationCodeResourceDetails();
}
Чтобы завершить аутентификацию, нужно указать конечную точку пользовательской информации Google:
@Bean
@ConfigurationProperties("google.resource")
public ResourceServerProperties googleResource()
{
return new ResourceServerProperties();
}
Зарегистрировав наше приложение в Google Cloud Platform, в application.properties добавим свойства с соответствующими префиксами:
google.client.clientId=yourClientId
google.client.clientSecret=yourClientSecret
google.client.accessTokenUri=https://www.googleapis.com/oauth2/v4/token
google.client.userAuthorizationUri=https://accounts.google.com/o/oauth2/v2/auth
google.client.clientAuthenticationScheme=form
google.client.scope=openid,email,profile
google.resource.userInfoUri=https://www.googleapis.com/oauth2/v3/userinfo
google.resource.preferTokenInfo=true
Основные моменты регистрации приложения в Google Cloud Platform
Путь: API и сервисы -> Учетные данные Окно запроса доступа OAuth:- Название приложения: Spring login form and OAuth2 tutorial
- Адрес электронной почты службы поддержки: ваш email
- Область действия для API Google: email, profile, openid
- Авторизованные домены: me.org
- Ссылка на главную страницу приложения: http://me.org:8080
- Ссылка на политику конфиденциальности приложения: http://me.org:8080
- Ссылка на условия использования приложения: http://me.org:8080
- Тип: Веб-приложение
- Название: Spring login form and OAuth2 tutorial
- Разрешенные источники JavaScript: http://me.org, http://me.org:8080
- Разрешенные URI перенаправления: http://me.org:8080/login, http://me.org:8080/login/google
CustomUserInfoTokenServices
Вы заметили слово Custom в функции-описании фильтра? КлассCustomUserInfoTokenServices
. Да, мы создадим свой класс с блэкджеком и возможностью сохранить пользователя в БД! С помощью сочетания клавиш Ctrl-N в IntelliJ IDEA можно найти и посмотреть, как реализован UserInfoTokenServices
, используемый по умолчанию. Скопируем его код в свежесозданный класс CustomUserInfoTokenServices
. Большую часть можно оставить без изменений. Прежде, чем изменять логику функций, допишем в качестве приватных полей класса UserRepo
и PasswordEncoder
. Создадим для них сеттеры.
Внесем в класс SecurityConfig @Autowired UserRepo userRepo. Смотрим, как исчезает указатель на ошибку в методе создания фильтра, радуемся.
Почему нельзя было применить @Autowired непосредственно в CustomUserInfoTokenServices? Потому что этот класс не подтянет зависимость, так как сам не помечен какой-либо аннотацией Spring, к тому же его конструктор создается явно при объявлении фильтра. Соответственно, механизм DI Spring о нем не знает. Если мы аннотируем @Autowired что-либо в этом классе, то при использовании получим NullPointerException. А вот через явные сеттеры все очень даже работает.
После внедрения нужных компонентов главным объектом интереса становится функция loadAuthentication, в которой извлекается Map<String, Object> с информацией о пользователе. Именно в ней в своем и этом проекте я реализовала сохранение в базу данных вошедшего через соцсеть пользователя.
Так как в качестве провайдера OAuth2 мы используем Google аккаунт, то проверяем, содержит ли map характерное для Google поле “sub”. Если оно присутствует, значит, информация о пользователе дошла верно. Создаем нового пользователя и сохраняем его в базу данных.
@Override
public OAuth2Authentication loadAuthentication(String accessToken)
throws AuthenticationException, InvalidTokenException
{
Map<String, Object> map = getMap(this.userInfoEndpointUrl, accessToken);
if(map.containsKey("sub"))
{
String googleName = (String) map.get("name");
String googleUsername = (String) map.get("email");
User user = userRepo.findByGoogleUsername(googleUsername);
if(user == null)
{
user = new User();
user.setActive(true);
user.setRoles(Collections.singleton(Role.USER));
}
user.setName(googleName);
user.setUsername(googleUsername);
user.setGoogleName(googleName);
user.setGoogleUsername(googleUsername);
user.setPassword(passwordEncoder.encode("oauth2user"));
userRepo.save(user);
}
if (map.containsKey("error"))
{
this.logger.debug("userinfo returned error: " + map.get("error"));
throw new InvalidTokenException(accessToken);
}
return extractAuthentication(map);
}
При использовании нескольких провайдеров можно и указывать разные варианты в одном CustomUserInfoTokenServices, и прописывать разные классы подобных сервисов в методе объявления фильтра.
Теперь в качестве Principal у нас может выступать и User, и OAuth2Authentication. Так как мы заблаговременно учли в UserService подгрузку пользователя через данные Google, приложение будет работать для обоих видов пользователей.
Модифицируем контроллер главной страницы проекта, чтобы он перенаправлял вошедших с помощью OAuth2 пользователей на страницу заметок.
@GetMapping("/")
public String index(Principal principal)
{
if(principal != null)
{
return "redirect:/notes";
}
return "index";
}
Итоговый запуск проекта
После небольших косметических изменений и добавления кнопки выхода, проводим итоговый запуск проекта.







ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ