Під час написання своєї програми зіткнулася з відсутністю виразних статей, як зробити так, щоб користувач реєструвався і через email, і через соціальні мережі. Були добрі туторіали з налаштування класичної форми входу. Були хороші туторіали по OAuth2 . Як подружити два способи інформації було злочинно мало. У процесі пошуків удалося вивести працездатне рішення. Воно не претендує на істину в останній інстанції, проте виконує свою функцію. У цій статті я покажу, як з нуля продати сервіс для зберігання нотаток з такою конфігурацією Spring Security. Дружимо звичайний вхід через 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 на прикладі сервісу нотаток.
Дружимо звичайний вхід через email і OAuth2 у Spring Security на прикладі сервісу нотаток.
Бачимо, що новий користувач з'явився у базі даних. Пароль зашифровано.
Дружимо звичайний вхід через email і OAuth2 у Spring Security на прикладі сервісу нотаток.
Дружимо звичайний вхід через email і OAuth2 у Spring Security на прикладі сервісу нотаток.
Дружимо звичайний вхід через email і OAuth2 в Spring Security на прикладі сервісу нотаток.
Дружимо звичайний вхід через email і OAuth2 в Spring Security на прикладі сервісу нотаток.
Нотатки збереглися до бази даних.
Дружимо звичайний вхід через 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 на прикладі сервісу нотаток.
Дружимо звичайний вхід через email і OAuth2 у Spring Security на прикладі сервісу нотаток.
Дружимо звичайний вхід через 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 та комбінуванні різних способів входу. З повним кодом проекту ви можете