Комбинированная авторизация в 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 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

795525d600adc3681665dc8e98750dfc.png

Затем перед нами откроется страница логина гитхаба. После вводе данных в попадаем в ендпоинт api/v1/auth/me. В ответ получим данные текущего пользователя.

7a935a17910570fbbf21507076d3c9d7.png

Перейдем к тестированию традиционного подхода.

Отправляем данный запрос

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
}

Тем временем в редисе создано две сессий

Тем временем в редисе создано две сессий

Если у вас появятся вопросы, буду рад ответить на них в комментариях. Также буду благодарен за обратную связь от более опытных и искушённых разработчиков.

Репозиторий проекта здесь.

© Habrahabr.ru