Во время написания своего приложения столкнулась с отсутствием внятных статей, как сделать так, чтобы пользователь регистрировался и через email, и через социальные сети. Были хорошие туториалы по настройке классической формы входа. Были хорошие туториалы по OAuth2. Как подружить два способа, информации было преступно мало. В процессе поисков удалось вывести работоспособное решение. Оно не претендует на истину в последней инстанции, однако свою функцию выполняет. В этой статье я покажу, как с нуля реализовать сервис для хранения заметок с подобной конфигурацией Spring Security. Примечание: хорошо, если читатель прошел хотя бы пару туториалов по Spring, потому что внимание будет акцентироваться только на Spring Security, без детальных объяснений репозиториев, контроллеров и т. п. Иначе и так немаленькая статья получилась бы гигантской. Содержание
  1. Создание проекта
  2. Создание сущностей и логики приложения
    1. Сущности
    2. Репозитории
    3. Контроллеры
    4. Страницы
  3. Настройка Spring Security для классического входа
    1. Основная конфигурация SecurityConfig
    2. Кастомный вход пользователя
    3. Усовершенствуем контроллер
    4. Запуск
  4. Настройка OAuth2 на примере Google в Spring Security
    1. Конфигурация фильтра и application.properties
    2. Основные моменты регистрации приложения в Google Cloud Platform
    3. CustomUserInfoTokenServices
  5. Итоговый запуск проекта

Создание проекта

Идем на start.spring.io и формируем основу проекта:
  • Web — запуск приложения на встроенном Tomcat, url-сопоставления и тому подобное;
  • JPA — связь с базой данных;
  • Mustache — шаблонизатор, используется для генерации веб-страниц;
  • Security — защита приложения. То, ради чего эта статья и создавалась.
Скачиваем получившийся архив и распаковываем в нужной вам папке. Запускаем его в IDE. Вы можете выбирать БД на свое усмотрение. В качестве базы данных для проекта я использую MySQL, поэтому в файл pom.xml в блок <dependencies> добавляю следующую зависимость:
<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

Наша конфигурация будет уметь:
  1. Шифровать пароли с помощью BCryptPasswordEncoder:

    @Autowired
    private PasswordEncoder passwordEncoder;
    
    @Bean
    PasswordEncoder passwordEncoder()
    {
      PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
      return passwordEncoder;
    }
  2. Осуществлять вход по специально написанному провайдеру аутентификации:

    @Autowired
    private AuthProvider authProvider;
    
    @Override
    protected void configure(AuthenticationManagerBuilder auth)
    {
      auth.authenticationProvider(authProvider);
    }
  3. Разрешать доступ анонимным пользователям к главной странице, страницам регистрации и входа. Все остальные запросы должны выполняться вошедшими в систему пользователями. Страницей входа назначим ранее описанную "/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-токеном.

Запуск

Пробуем запустить проект.
Дружим обычный вход через email и OAuth2 в Spring Security на примере сервиса заметок - 1
Дружим обычный вход через email и OAuth2 в Spring Security на примере сервиса заметок - 2
Видим, что новый пользователь появился в базе данных. Пароль зашифрован.
Дружим обычный вход через email и OAuth2 в Spring Security на примере сервиса заметок - 3
Дружим обычный вход через email и OAuth2 в Spring Security на примере сервиса заметок - 4
Дружим обычный вход через email и OAuth2 в Spring Security на примере сервиса заметок - 5
Дружим обычный вход через email и OAuth2 в Spring Security на примере сервиса заметок - 6
Заметки сохранились в базу данных.
Дружим обычный вход через email и OAuth2 в Spring Security на примере сервиса заметок - 7
Видим, что проект успешно запускается и работает. Для полного счастья нам не хватает только возможности входа через социальные сети. Что ж, приступим!

Настройка 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
Примечание: так как с адресом localhost:8080 Google работать не хочет, внесите в файл C:\Windows\System32\drivers\etc\hosts в конце строчку “127.0.0.1 me.org” или подобную ей. Главное, чтобы домен был в классическом виде.

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";
}

Итоговый запуск проекта

После небольших косметических изменений и добавления кнопки выхода, проводим итоговый запуск проекта.
Дружим обычный вход через 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
Дружим обычный вход через email и OAuth2 в Spring Security на примере сервиса заметок - 15
Пользователь успешно входит и через обычную форму, и через Google аккаунт. Этого мы и добивались! Надеюсь, эта статья прояснила определенные моменты в создании веб-приложения, его защите с помощью Spring Security и комбинировании разных способов входа. C полным кодом проекта вы можете ознакомиться в репозитории проекта на GitHub.