Під час написання своєї програми зіткнулася з відсутністю виразних статей, як зробити так, щоб користувач реєструвався і через email, і через соціальні мережі. Були добрі туторіали з налаштування класичної форми входу. Були хороші туторіали по OAuth2 . Як подружити два способи інформації було злочинно мало. У процесі пошуків удалося вивести працездатне рішення. Воно не претендує на істину в останній інстанції, проте виконує свою функцію. У цій статті я покажу, як з нуля продати сервіс для зберігання нотаток з такою конфігурацією Spring Security. Примітка:добре, якщо читач пройшов хоча б пару туторіалів по Spring, тому що увага акцентуватиметься тільки на Spring Security, без детальних пояснень репозиторіїв, контролерів тощо. Інакше і так чимала стаття вийшла б гігантською. Зміст
Бачимо, що новий користувач з'явився у базі даних. Пароль зашифровано.
Нотатки збереглися до бази даних.
Бачимо, що проект успішно запускається та працює. Для повного щастя нам не вистачає лише можливості входу через соціальні мережі. Що ж, приступимо!
Тепер як Principal у нас може виступати і User, і OAuth2Authentication. Так як ми завчасно врахували в UserService підвантаження користувача через дані Google, програма буде працювати для обох видів користувачів. Модифікуємо контролер головної сторінки проекту, щоб він перенаправляв користувачів, що ввійшли за допомогою OAuth2 на сторінку нотаток.
Користувач успішно входить і через звичайну форму, і через обліковий запис Google. Цього ми й досягали! Сподіваюся, ця стаття прояснила певні моменти у створенні веб-застосунку, його захисті за допомогою Spring Security та комбінуванні різних способів входу. З повним кодом проекту ви можете
- Створення проекту
- Створення сутностей та логіки додатку
- Налаштування 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, і прописувати різні класи подібних сервісів у методі оголошення фільтра.
@GetMapping("/")
public String index(Principal principal)
{
if(principal != null)
{
return "redirect:/notes";
}
return "index";
}
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ