Комбинированная авторизация в Spring Security: Социальные сети и логин через username/password
Привет! Меня зовут Данекер, я Fullstack-разработчик (Java, Angular). Несмотря на то, что уже работаю в компании, я продолжаю находить время для собственных проектов, через которые изучаю интересующие меня технологии и подходы. В рамках одного из таких проектов я решил разобраться с авторизацией и аутентификацией на основе базы данных в Spring Security 6, а также внедрить авторизацию с помощью социальных сетей (Google, GitHub и другие). В этой версии произошло немало изменений по сравнению с предыдущими. Примеры из документации не всегда полны, а материалов на русском языке по этой теме я почти не нашел. Информацию я собирал по крупицам из различных иностранных источников. Теперь я хочу поделиться с вами тем, что удалось узнать.
Предполагаю, что большинство читателей знакомы с понятиями авторизации и аутентификации, а также с их различиями. Однако, для тех, кто только начинает изучать эту тему, кратко объясню: аутентификация — это процесс проверки личности пользователя, чтобы определить, имеет ли он доступ к ресурсу в целом. Авторизация же — это распределение прав и возможностей для уже аутентифицированных пользователей. Авторизация основывается на ролях и других характеристиках зарегистрированного пользователя, о которых мы поговорим позже.
Основная проблема состоит в том, что начиная с версии Spring Security 5.7.0 класс WebSecurityConfigurerAdapter объявлен устаревшим и его использование в будущих версиях невозможно. Однако большинство существующих руководств все еще опираются на наследование этого класса.
Итак, для начала создадим новый проект и финальный build.gradle (если вы используете maven тогда pom.xml) должен выглядет таким образом:
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.5'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'kz.danekerscode'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.session:spring-session-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'org.postgresql:postgresql'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
Также допольнительные конфигурации в application.yaml
spring:
application:
name: habr-spring-security-6
datasource:
url: jdbc:postgresql://localhost:5432/habr_spring_security_6 # или ссылка для любой другой реляционной базы данных
username: postgres # поменяйте если не совпадает с вашим
password: postgres # это тоже
jpa:
open-in-view: false
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
enable_lazy_load_no_trans: true
format_sql: true
data:
redis:
host: localhost
port: 6379
security:
oauth2:
client:
registration:
github:
provider: github
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
scope:
- user:email
- read:user
provider:
github:
user-name-attribute: login
Для того чтобы получить параметры GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET вам нужно создать OAuth клиента в GitHub.Подробная документация по этой ссылке. Также не забываем что redirect-uri должен быть http://localhost:8080/login/oauth2/code/github
Далее мы создадим сущность для пользователей с помощью которой будем раскладывать пользователей из базы в объекты, а также интерфейс UserRepository, расширяющий JpaRepository.
@Entity
@Getter
@Setter
@Table(name = "users")
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
@Enumerated(EnumType.STRING)
private AuthType authType;
private String email;
private String password;
private String role = "ROLE_USER"; // TODO советуй использовать Enum или же другую сущность
@Override
public Collection extends GrantedAuthority> getAuthorities() {
return new HashSet<>(){{
add(new SimpleGrantedAuthority(role));
}};
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
Теперь нам нужно отслеживать успешные логины с помощью гитхаба, таким образом мы будем сохранять пользователей в базу данных, если они впервые в нашей системе
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
// Данные поля подходят только github api, если у вас другой провайдер,
// вам следует проверить его документацию
record EmailDetails(String email, Boolean primary, Boolean verified) {
}
private final UserRepository userRepository;
private final OAuth2AuthorizedClientService authorizedClientService;
private final RestClient restClient = RestClient.builder()
.baseUrl("https://api.github.com/user/emails") // другой url если другой провайдер соотвественно
.build(); // лучше получать это значение с ClientRegistration
@Override
public void onAuthenticationSuccess(
HttpServletRequest request,
HttpServletResponse response,
Authentication auth
) throws IOException {
if (auth instanceof OAuth2AuthenticationToken auth2AuthenticationToken) {
var principal = auth2AuthenticationToken.getPrincipal();
var username = principal.getName();
var email = fetchUserEmailFromGitHubApi(auth2AuthenticationToken.getAuthorizedClientRegistrationId(), username);
if (!userRepository.existsByEmail(email)) {
var user = new User();
user.setEmail(email);
user.setUsername(username);
userRepository.save(user);
}
}
super.clearAuthenticationAttributes(request);
super.getRedirectStrategy().sendRedirect(request, response, "/api/v1/user/me");
}
private String fetchUserEmailFromGitHubApi(String clientRegistrationId, String principalName) {
var authorizedClient = authorizedClientService.loadAuthorizedClient(clientRegistrationId, principalName);
var accessToken = authorizedClient.getAccessToken().getTokenValue();
var userEmailsResponse = restClient.get()
.headers(headers -> headers.setBearerAuth(accessToken))
.retrieve()
.body(EmailDetails[].class);
if (userEmailsResponse == null) {
return "null";
}
var fetchedEmailDetails = Arrays.stream(userEmailsResponse)
.filter(emailDetails -> emailDetails.verified() && emailDetails.primary())
.findFirst()
.orElseGet(() -> null);
return fetchedEmailDetails != null ? fetchedEmailDetails.email() : "null";
}
}
В данном классе есть единственный метод onAuthenticationSuccess который будет вызван после успешной авторизации с помощью OAuth2 провайдеров. После вызова данного метода Spring Security создаст сессию в редисе.
Далее переходим к традиционному подходу.
Сперва создадим обычные дто для наших будущих ендпоинтов
public record LoginRequest(
String email,
String password
) {
}
public record RegistrationRequest(
String email,
String password,
String username
) {
}
Следующим нашем шагом станет создание сервисного слоя где будет ядро бизнес логики.
@Service
@Slf4j
@RequiredArgsConstructor
public class AuthService {
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
private final AuthenticationManager authenticationManager;
private final SecurityContextRepository securityContextRepository;
public void register(RegistrationRequest request) {
if (userRepository.existsByEmailAndAuthType(request.email(), AuthType.MANUAL)) {
throw new IllegalArgumentException("Email already registered");
}
var user = new User();
user.setAuthType(AuthType.MANUAL);
user.setUsername(request.username());
user.setEmail(request.email());
user.setPassword(passwordEncoder.encode(request.password()));
userRepository.save(user);
}
public Authentication login(
LoginRequest loginRequest,
HttpServletRequest request,
HttpServletResponse response
) {
var passwordAuthenticationToken = new UsernamePasswordAuthenticationToken(
loginRequest.email(), loginRequest.password()
);
var auth = authenticationManager.authenticate(passwordAuthenticationToken);
var securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(auth);
securityContextRepository.saveContext(securityContext, request, response); // сохраняем новую сессию в редис
log.info("Authenticated and created session for {}", auth.getName());
return auth;
}
}
Отлично, мы создали AuthService с двумя простыми методами. Теперь нам осталось создать контроллер для обработки http запросов.
@RequiredArgsConstructor
@RestController
@RequestMapping("api/v1/auth")
public class AuthController {
private final AuthService authService;
@GetMapping("me")
Principal me(Principal principal) {
return principal;
}
@PostMapping("register")
@ResponseStatus(HttpStatus.CREATED)
void register(@RequestBody RegistrationRequest request) {
authService.register(request);
}
@PostMapping("login")
Object login(
@RequestBody LoginRequest loginRequest,
HttpServletRequest request,
HttpServletResponse response
) {
return authService
.login(loginRequest, request, response)
.getPrincipal();
}
}
Здесь мы добавили три ендпоинта.
/api/v1/auth/me — Получение текущего пользователя
/api/v1/auth/register — Регистрация
/api/v1/auth/login — Логин
Время тестировать
Как мы видим Spring приложие запущено на порте 8080. После запуска переходим по ссылке http://localhost:8080/oauth2/authorization/github
Затем перед нами откроется страница логина гитхаба. После вводе данных в попадаем в ендпоинт api/v1/auth/me. В ответ получим данные текущего пользователя.
Перейдем к тестированию традиционного подхода.
Отправляем данный запрос
POST http://localhost:8080/api/v1/auth/register
Content-Type: application/json
{
"username": "Daneker" ,
"password": "password" ,
"email": "daneker2005@gmail.com"
}
В ответ получаем статус 201, затем отправляем запрос в api/v1/auth/login
POST http://localhost:8080/api/v1/auth/login
Content-Type: application/json
{
"password": "password" ,
"email": "daneker2005@gmail.com"
}
После успешного логина получаем в ответ данные текущего пользователя
{
"id": 12,
"username": "Daneker",
"email": "daneker2005@gmail.com",
"authType": "MANUAL",
"role": "ROLE_USER",
"authorities": [
{
"authority": "ROLE_USER"
}
],
"enabled": true
}
Тем временем в редисе создано две сессий
Если у вас появятся вопросы, буду рад ответить на них в комментариях. Также буду благодарен за обратную связь от более опытных и искушённых разработчиков.
Репозиторий проекта здесь.