Pisząc moją aplikację, napotkałem brak jasnych artykułów na temat tego, jak nakłonić użytkownika do rejestracji zarówno za pośrednictwem poczty elektronicznej, jak i sieci społecznościowych. Były dobre tutoriale na temat konfigurowania klasycznego formularza logowania. Były dobre tutoriale na temat OAuth2 . Było kryminalnie mało informacji na temat łączenia obu metod. W trakcie poszukiwań udało nam się znaleźć wykonalne rozwiązanie. Nie rości sobie pretensji do prawdy ostatecznej, ale spełnia swoją funkcję. W tym artykule pokażę jak od podstaw zaimplementować usługę przechowywania notatek przy podobnej konfiguracji Spring Security. Uwaga: dobrze, jeśli czytelnik przeszedł chociaż kilka tutoriali na temat Springa, gdyż uwaga skupiona zostanie wyłącznie na Spring Security, bez szczegółowych opisów repozytoriów, kontrolerów itp. Inaczej wyszedłby już dość obszerny artykuł być gigantyczny. Treść
Widzimy, że w bazie pojawił się nowy użytkownik. Hasło jest zaszyfrowane.
Notatki zapisywane są w bazie danych.
Widzimy, że projekt został pomyślnie uruchomiony i działa. Do pełni szczęścia wystarczy nam jedynie możliwość logowania się poprzez portale społecznościowe. Cóż, zaczynajmy!
Teraz zarówno użytkownik, jak i OAuth2Authentication mogą działać jako zleceniodawcy. Ponieważ w UserService uwzględniliśmy z wyprzedzeniem ładowanie użytkownika przez dane Google, aplikacja będzie działać dla obu typów użytkowników. Modyfikujemy kontroler strony głównej projektu tak, aby przekierowywał użytkowników zalogowanych przy użyciu OAuth2 na stronę notatek.
Użytkownik pomyślnie loguje się zarówno poprzez zwykły formularz, jak i poprzez konto Google. Tego właśnie chcieliśmy! Mam nadzieję, że ten artykuł wyjaśnił niektóre kwestie dotyczące tworzenia aplikacji internetowej, zabezpieczania jej za pomocą Spring Security i łączenia różnych metod logowania. Dzięki pełnemu kodowi projektu możesz to zrobić
- Tworzenie projektu
- Tworzenie encji i logiki aplikacji
- Konfigurowanie zabezpieczeń Spring dla logowania klasycznego
- Konfigurowanie OAuth2 na przykładzie Google w Spring Security
- Konfiguracja filtra i właściwości aplikacji
- Najważniejsze informacje dotyczące rejestracji aplikacji w Google Cloud Platform
- Niestandardowe usługiUserInfoTokenServices
- Ostateczne uruchomienie projektu
Tworzenie projektu
Wchodzimy na start.spring.io i tworzymy podstawę projektu:- Web - uruchomienie aplikacji na wbudowanym Tomcacie, mapowanie adresów URL i tym podobne;
- JPA - połączenie z bazą danych;
- Mustache to silnik szablonów używany do generowania stron internetowych;
- Bezpieczeństwo - ochrona aplikacji. Właśnie po to powstał ten artykuł.
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.34</version>
</dependency>
Konfiguracja application.properties wygląda obecnie następująco:
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
Tworzenie encji i logiki aplikacji
Podmioty
Stwórzmy pakiet,entities
w którym umieścimy encje bazy danych. Użytkownik zostanie opisany przez klasę User
implementującą interfejs UserDetails
, który będzie potrzebny do konfiguracji Spring Security. Użytkownik będzie miał identyfikator, nazwę użytkownika (jest to adres e-mail), hasło, nazwę, rolę, flagę aktywności, nazwę konta Google i adres e-mail ( googleName
i 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
}
Role użytkowników służą do regulowania dostępu w Spring Security. Nasza aplikacja będzie używać tylko jednej roli:
public enum Role implements GrantedAuthority
{
USER;
@Override
public String getAuthority()
{
return name();
}
}
Stwórzmy klasę notatki zawierającą identyfikator, tytuł notatki, treść notatki i identyfikator użytkownika, do którego ona należy:
@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()
}
Repozytoria
Aby zapisywać encje do bazy danych potrzebujemy repozytoriów, które wykonają za nas całą brudną robotę. Stwórzmy pakietrepos
, w nim utworzymy interfejsy UserRepo
odziedziczone NoteRepo
z interfejsu 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);
}
Kontrolery
Nasz serwis notatek będzie miał następujące strony:- Dom;
- Rejestracja;
- Wejście;
- Lista notatek użytkownika.
controllers
zawierający klasę IndexController
zawierającą zwykłe mapowanie strony głównej. Klasa RegistrationController
odpowiedzialna jest za rejestrację użytkownika. Post-mapping pobiera dane z formularza, zapisuje użytkownika do bazy danych i przekierowuje na stronę logowania. PasswordEncoder
zostanie opisane później. Służy do szyfrowania haseł.
@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";
}
Kontroler odpowiedzialny za stronę z listą notatek zawiera obecnie uproszczoną funkcjonalność, która stanie się bardziej złożona po wdrożeniu 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";
}
}
Nie będziemy pisać kontrolera dla strony logowania, ponieważ jest on używany przez Spring Security. Zamiast tego będziemy potrzebować specjalnej konfiguracji. Jak zwykle utwórzmy kolejny pakiet, nazwijmy go config
i umieśćmy w nim klasę MvcConfig
. Kiedy napiszemy konfigurację Spring Security, będzie ona wiedzieć, do której strony się odwołujemy, gdy użyjemy „/login”.
@Configuration
public class MvcConfig implements WebMvcConfigurer
{
public void addViewControllers(ViewControllerRegistry registry)
{
registry.addViewController("/login").setViewName("login");
registry.setOrder(Ordered.HIGHEST_PRECEDENCE);
}
}
Strony
Do tworzenia stron używam silnika szablonów Mustache . Możesz wdrożyć inny, to nie ma znaczenia. Utworzono plik meta.mustache dla metainformacji używanych na wszystkich stronach. Zawiera także Bootstrap, dzięki któremu strony naszego projektu wyglądają ładniej. Strony tworzone są w katalogu „src/main/resources/templates”. Pliki mają rozszerzenie wąsy. Umieszczenie kodu HTML bezpośrednio w artykule spowoduje, że będzie on zbyt duży, dlatego poniżej znajduje się link do folderu szablonów w repozytorium GitHub projektu .Konfigurowanie zabezpieczeń Spring dla logowania klasycznego
Spring Security pomaga nam chronić aplikację i jej zasoby przed nieautoryzowanym dostępem. Stworzymy zwięzłą konfigurację roboczą w klasieSecurityConfig
odziedziczonej z WebSecurityConfigurerAdapter
, którą umieścimy w pakiecie config
. Oznaczmy to adnotacją @EnableWebSecurity, która umożliwi obsługę Spring Security, oraz adnotacją @Configuration, która wskazuje, że ta klasa zawiera pewną konfigurację. Uwaga: automatycznie skonfigurowany plik pom.xml zawierał wersję komponentu nadrzędnego Spring Boot 2.1.4.RELEASE, co uniemożliwiało wdrożenie zabezpieczeń w ustalony sposób. Aby uniknąć konfliktów w projekcie zaleca się zmianę wersji na 2.0.1.RELEASE.
Konfiguracja podstawowa SecurityConfig
Nasza konfiguracja będzie mogła:-
Szyfruj hasła za pomocą
BCryptPasswordEncoder
:@Autowired private PasswordEncoder passwordEncoder; @Bean PasswordEncoder passwordEncoder() { PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); return passwordEncoder; }
-
Zaloguj się przy użyciu specjalnie napisanego dostawcy uwierzytelniania:
@Autowired private AuthProvider authProvider; @Override protected void configure(AuthenticationManagerBuilder auth) { auth.authenticationProvider(authProvider); }
-
Zezwól anonimowym użytkownikom na dostęp do strony głównej, stron rejestracji i logowania. Wszystkie pozostałe żądania muszą być realizowane przez zalogowanych użytkowników. Przypiszmy wcześniej opisaną „/login” jako stronę logowania. Jeżeli logowanie przebiegło pomyślnie, użytkownik zostanie przeniesiony na stronę z listą notatek, w przypadku błędu pozostanie na stronie logowania. Po pomyślnym wyjściu użytkownik zostanie przeniesiony na stronę główną.
@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(); }
Niestandardowy login użytkownika
Samodzielnie napisanyAuthProvider
pozwoli użytkownikowi zalogować się nie tylko za pomocą adresu e-mail, ale także nazwy użytkownika.
@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;
}
}
Jak zapewne zauważyłeś, klasa UserService
znajdująca się w pakiecie odpowiedzialna jest za załadowanie użytkownika services
. W naszym przypadku wyszukuje użytkownika nie tylko według pola username
, jak ma to miejsce w przypadku wbudowanej implementacji, ale także według nazwy użytkownika, nazwy konta Google i adresu e-mail konta Google. Dwie ostatnie metody przydadzą nam się przy implementacji logowania poprzez OAuth2. Tutaj klasa jest podana w wersji skróconej.
@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;
}
}
Uwaga: nie zapomnij wpisać niezbędnych metod w UserRepo
!
Ulepszmy kontroler
Skonfigurowaliśmy Spring Security. Nadszedł czas, aby skorzystać z tego w kontrolerze notatek. Teraz każde mapowanie zaakceptuje dodatkowy parametr główny, za pomocą którego będzie próbował znaleźć użytkownika. Dlaczego nie mogę bezpośrednio wstrzyknąć klasyUser
? Wtedy wystąpi konflikt z powodu niedopasowania typów użytkowników, gdy napiszemy login za pośrednictwem sieci społecznościowych. Z góry zapewniamy niezbędną elastyczność. Nasz kod kontrolera notatek wygląda teraz tak:
@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";
}
Uwaga: projekt ma domyślnie włączoną ochronę CSRF , więc albo wyłącz ją dla siebie (http.csrf().disable()), albo nie zapomnij jako autor artykułu dodać ukrytego pola z tokenem csrf na wszystkie prośby o publikację.
Początek
Próbujemy uruchomić projekt.Konfigurowanie OAuth2 na przykładzie Google w Spring Security
Wdrażając OAuth2, polegałem na tym oficjalnym tutorialu ze Springa . Aby obsługiwać OAuth2, dodaj następującą bibliotekę do pom.xml:<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<version>2.0.0.RELEASE</version>
</dependency>
Zmodyfikujmy naszą konfigurację Spring Security w pliku SecurityConfig
. Najpierw dodajmy adnotację @EnableOAuth2Client. Automatycznie wyświetli dane potrzebne do zalogowania się za pośrednictwem sieci społecznościowych.
Konfiguracja filtra i właściwości aplikacji
Wstrzyknijmy OAuth2ClientContext do użycia w naszej konfiguracji zabezpieczeń.@Autowired
private OAuth2ClientContext oAuth2ClientContext;
OAuth2ClientContext jest używany podczas tworzenia filtru sprawdzającego żądanie logowania użytkownika w mediach społecznościowych. Filtr jest dostępny dzięki adnotacji @EnableOAuth2Client. Wszystko, co musimy zrobić, to wywołać go we właściwej kolejności, przed głównym filtrem Spring Security. Tylko wtedy będziemy mogli wyłapać przekierowania podczas procesu logowania za pomocą OAuth2. W tym celu używamy FilterRegistrationBean
, w którym ustawiamy priorytet naszego filtra na -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;
}
Musisz także dodać nowy filtr do funkcji konfiguracji(HttpSecurity http):
http.addFilterBefore(ssoFilter(), UsernamePasswordAuthenticationFilter.class);
Filtr musi także wiedzieć, że klient zarejestrował się przez Google. Adnotacja @ConfigurationProperties określa, których właściwości konfiguracyjnych należy szukać w application.properties.
@Bean
@ConfigurationProperties("google.client")
public AuthorizationCodeResourceDetails google()
{
return new AuthorizationCodeResourceDetails();
}
Aby zakończyć uwierzytelnianie, musisz określić punkt końcowy informacji o użytkowniku Google:
@Bean
@ConfigurationProperties("google.resource")
public ResourceServerProperties googleResource()
{
return new ResourceServerProperties();
}
Po zarejestrowaniu naszej aplikacji w Google Cloud Platform dodamy właściwości z odpowiednimi przedrostkami do 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
Najważniejsze informacje dotyczące rejestracji aplikacji w Google Cloud Platform
Ścieżka: Interfejsy API i usługi -> Poświadczenia Okno żądania dostępu OAuth:- Nazwa aplikacji: Spring formularz logowania i tutorial OAuth2
- Adres e-mail pomocy technicznej: Twój e-mail
- Zakres API Google: e-mail, profil, openid
- Autoryzowane domeny: me.org
- Link do strony głównej aplikacji: http://me.org:8080
- Link do polityki prywatności aplikacji: http://me.org:8080
- Link do warunków korzystania z aplikacji: http://me.org:8080
- Typ: aplikacja internetowa
- Tytuł: Springowy formularz logowania i tutorial OAuth2
- Dozwolone źródła JavaScript: http://me.org, http://me.org:8080
- Dozwolone identyfikatory URI przekierowań: http://me.org:8080/login, http://me.org:8080/login/google
Niestandardowe usługiUserInfoTokenServices
Czy zauważyłeś słowo Custom w opisie funkcji filtra? KlasaCustomUserInfoTokenServices
. Tak, stworzymy własną klasę z blackjackiem i możliwością zapisania użytkownika w bazie danych! Używając skrótu klawiaturowego Ctrl-N w IntelliJ IDEA, możesz znaleźć i zobaczyć, jak UserInfoTokenServices
zaimplementowano ustawienie domyślne. Skopiujmy jego kod do nowo utworzonej klasy CustomUserInfoTokenServices
. Większość z nich można pozostawić bez zmian. Zanim zmienimy logikę funkcji, dodajmy UserRepo
i jako pola prywatne klasy PasswordEncoder
. Stwórzmy dla nich setery. Dodajmy @Autowired UserRepo userRepo do klasy SecurityConfig. Patrzymy, jak znika wskaźnik błędu w metodzie tworzenia filtra i cieszymy się. Dlaczego nie można zastosować @Autowired bezpośrednio do CustomUserInfoTokenServices? Ponieważ ta klasa nie przejmie zależności, ponieważ sama nie jest oznaczona żadną adnotacją Springa, a jej konstruktor jest tworzony jawnie podczas deklaracji filtra. W związku z tym mechanizm DI Springa o tym nie wie. Jeśli dodamy adnotację @Autowired do czegokolwiek w tej klasie, otrzymamy wyjątek NullPointerException. Ale dzięki jawnym ustawieniom wszystko działa bardzo dobrze. Po zaimplementowaniu niezbędnych komponentów głównym obiektem zainteresowania staje się funkcja loadingAuthentication, w ramach której pobierana jest funkcja Map<String, Object> z informacjami o użytkowniku. To właśnie w tym projekcie zaimplementowałem zapisywanie do bazy danych użytkownika zalogowanego przez portal społecznościowy. Ponieważ jako dostawca OAuth2 korzystamy z konta Google, sprawdzamy, czy mapa zawiera typowe dla Google pole „sub”. Jeżeli jest obecny, oznacza to, że informacja o użytkowniku została otrzymana prawidłowo. Tworzymy nowego użytkownika i zapisujemy go do bazy danych.
@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);
}
W przypadku korzystania z kilku dostawców można określić różne opcje w jednym CustomUserInfoTokenServices i zarejestrować różne klasy podobnych usług w metodzie deklaracji filter.
@GetMapping("/")
public String index(Principal principal)
{
if(principal != null)
{
return "redirect:/notes";
}
return "index";
}
GO TO FULL VERSION