[Spring] 스프링 시큐리티 - 구글 로그인

Jin·2023년 9월 5일
2

Spring

목록 보기
7/9
post-thumbnail

스프링 시큐리티와 OAuth 2.0을 구현한 구글 로그인을 연동하여 로그인 기능을 만들어 보자.

1. 구글 서비스 등록

먼저 구글 서비스에 신규 서비스를 생성해야 한다. 여기서 발급된 인증 정보를 통해서 로그인 기능과 소셜 서비스 기능을 사용할 수 있으므로 무조건 발급받고 시작해야 한다.

구글 클라우드 플랫폼 주소로 이동하자.
https://console.cloud.google.com

1.1 프로젝트 생성하기

  1. 프로젝트 탭을 선택한다.
  2. 새 프로젝트 버튼을 누른다.
  3. 등록할 서비스의 이름을 입력한다.

1.2 사용자 인증정보 설정하기

  1. 생성이 완료된 프로젝트를 선택한다.
  2. 왼쪽 메뉴 탭을 클릭한 후 API 및 서비스 카테고리의 사용자 인증 정보를 선택한다.
  3. 사용자 인증 정보 만들기를 선택한 후 OAuth 클라이언트 ID 항목을 선택한다.
  4. 클라이언트 ID를 생성하기 전에 동의 화면 구성이 필요하므로, 동의 화면 구성 버튼을 누른다.
  5. User Type은 외부를 선택하고, 앱 정보를 입력한다.
  6. 범위 추가 또는 삭제 버튼을 누른 뒤 등록할 구글 서비스에서 사용할 범위를 선택한다.(기본 범위인 email, profile, openid 선택)
  7. 동의 화면 구성이 끝났으면, OAuth 클라이언트 ID 만들기 화면으로 이동하여 웹 애플리케이션을 선택한다.
  8. 화면 아래로 내려가면 승인된 리디렉션 URL 주소를 등록해야 한다. 여기서 승인된 리디렉션 URL 주소는 파라미터로 인증 정보를 주었을 때 인증에 성공한 경우 구글에서 리다이렉트할 URL이다.
  • 스프링 부트 2 버전의 시큐리티에서는 기본적으로 {도메인}/login/oauth2/code/{소셜서비스코드}로 리다이렉트 URL을 지원하고 있음
    - 따라서 사용자가 별도로 리다이렉트 URL을 지원하는 컨트롤러를 만들 필요가 없음 (이미 시큐리티에서 구현해 놓은 상태)
  • 현재는 개발 단계이므로 http://localhost:8080/login/oauth2/code/google만 등록함
  • 만약 AWS 서버에 배포하게 되면 주소를 추가해야 함
  1. 생성 버튼을 누르면 아래와 같이 클라이언트 인증 정보(ID, 보안 비밀번호)를 볼 수 있다.

2. application-oauth.yml에 등록하기

우선 application.properties가 있는 위치에 application-oauth.yml 파일을 생성하자.

그리고 해당 파일에 클라이언트 ID와 클라이언트 보안 비밀 코드를 아래와 같이 등록하면 된다.

spring:
  security:
    oauth2:
      client:
        registration:
          # 구글 로그인 추가
          google:
            client-id: [Client ID]
            client-secret: [Client Secret]
            scope:
              - email
              - profile

참고: 강제로 scope를 email과 profile로 등록한 이유는?
scope의 기본값은 openid, email, profile이다. 하지만 openid라는 scope가 있으면 OpenId Provider로 인식한다. 그렇게 되면 OpenId Provider인 서비스(구글)그렇지 않은 서비스(네이버, 카카오 등)로 나눠서 각각 OAuth2Service를 만들어야 한다. 따라서 하나의 OAuth2Service를 사용하기 위해 일부러 openid scope를 빼고 등록한다.

3. application.yml 설정하기

스프링 부트에서는 application-xxx.properties 또는 application-xxx.yml로 만들면 이름의 profile이 생성되며, 이를 통해 관리할 수 있다. 즉, profile=xxx라는 식으로 호출하면, 해당 properties 또는 yml의 설정들을 가져올 수 있다.

호출하는 방식은 여러 방식이 있지만, 스프링 부트의 기본 설정 파일인 application.yml에서 application-oauth.yml을 포함하도록 설정한다.

spring:
  profiles:
    include: oauth

4. 구글 로그인 연동하기

구글의 로그인 인증 정보를 발급받았으니, 프로젝트 구현을 진행해야 한다. 먼저 사용자 정보를 담당한 도메인인 User 클래스를 생성한다.

package toy.project.bulletin_board.domain;

import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "users")
public class User {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private Long id;

    @NotNull
    private String name;

    @NotNull
    private String email;

    @Column
    private String picture;

    @Enumerated(EnumType.STRING) // Enum 타입은 문자열 형태로 저장해야 함
    @NotNull
    private Role role;

    @Builder
    public User(String name, String email, String picture, Role role) {
        this.name = name;
        this.email = email;
        this.picture = picture;
        this.role = role;
    }

    public User update(String name, String picture) {
        this.name = name;
        this.picture = picture;

        return this;
    }

    public String getRoleKey() {
        return this.role.getKey();
    }
}

각 사용자의 권한을 관리할 Enum 클래스 Role을 생성한다. 스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야 한다. 그래서 코드별 키값은 ROLE_ADMIN, ROLE_USER 등으로 지정해야 한다.

package toy.project.bulletin_board.domain;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum Role {

    ADMIN("ROLE_ADMIN", "관리자"),
    USER("ROLE_USER", "사용자");

    private final String key;
    private final String title;
}

마지막으로 User의 CRUD를 책임질 리포지토리도 생성한다.

package toy.project.bulletin_board.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import toy.project.bulletin_board.domain.User;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email); // 중복 가입 확인
}

User 엔티티 관련 코드를 모두 작성했다. 이제는 시큐리티 설정을 진행해 보자.

5. 스프링 시큐리티 설정

먼저 build.gradle에 아래의 스프링 시큐리티 관련 의존성 하나를 추가해야 한다. 소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현 시 필요한 의존성으로, spring-security-oauth2-clientspring-security-oauth2-jose를 기본으로 관리해 준다.

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

build.gralde 설정이 끝났으면, config.auth 패키지를 생성하자. 앞으로 시큐리티 관련 클래스는 모두 이곳에 담을 것이다.

5.1 SecurityConfig 클래스

스프링 시큐리티 설정 코드는 아래와 같다.

package toy.project.bulletin_board.config.auth;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import toy.project.bulletin_board.domain.Role;


@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(
                        (csrfConfig) -> csrfConfig.disable()
                )
                .headers(
                        (headerConfig) -> headerConfig.frameOptions(
                                frameOptionsConfig -> frameOptionsConfig.disable()
                        )
                )
                .authorizeHttpRequests((authorizeRequest) -> authorizeRequest
                        .requestMatchers("/posts/new", "/comments/save").hasRole(Role.USER.name())
                        .requestMatchers("/", "/css/**", "images/**", "/js/**", "/login/*", "/logout/*", "/posts/**", "/comments/**").permitAll()
                        .anyRequest().authenticated()
                )
                .logout( // 로그아웃 성공 시 / 주소로 이동
                        (logoutConfig) -> logoutConfig.logoutSuccessUrl("/")
                )
                // OAuth2 로그인 기능에 대한 여러 설정
                .oauth2Login(Customizer.withDefaults()); // 아래 코드와 동일한 결과
        /*
                .oauth2Login(
                        (oauth) ->
                            oauth.userInfoEndpoint(
                                    (endpoint) -> endpoint.userService(customOAuth2UserService)
                            )
                );
        */

        return http.build();
    }
}

참고
예제 코드는 WebSecurityConfigurerAdapter를 상속받았다. 하지만 스프링 5.7.0 이후로는 Deprecated 되었기 때문에 직접 스프링 빈(@Bean)으로 등록해야 한다. (Spring Blog)
또한 csrf().disable()headers().frameOptions().disable()과 같은 방식은 이제 파라미터 없이 사용할 수 없기 때문에, Lambda 형식으로 작성했다. (https://spring.io/blog/2019/11/21/spring-security-lambda-dsl)

5.2 CustomOAuth2UserService 클래스

구글 로그인 이후 가져온 사용자의 정보(email, name, picture 등)를 기반으로 가입 및 정보 수정, 세션 저장 기능 등의 기능을 수행한다.

package toy.project.bulletin_board.config.auth;

import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import toy.project.bulletin_board.config.auth.dto.SessionUser;
import toy.project.bulletin_board.domain.User;
import toy.project.bulletin_board.repository.UserRepository;

import java.util.Collections;

@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final UserRepository userRepository;
    private final HttpSession httpSession;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        // 로그인 진행 중인 서비스를 구분
        // 네이버로 로그인 진행 중인지, 구글로 로그인 진행 중인지, ... 등을 구분
        String registrationId = userRequest.getClientRegistration().getRegistrationId();

        // OAuth2 로그인 진행 시 키가 되는 필드 값(Primary Key와 같은 의미)
        // 구글의 경우 기본적으로 코드를 지원
        // 하지만 네이버, 카카오 등은 기본적으로 지원 X
        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails()
                .getUserInfoEndpoint()
                .getUserNameAttributeName();

        // OAuth2UserService를 통해 가져온 OAuth2User의 attribute 등을 담을 클래스
        OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        // 사용자 저장 또는 업데이트
        User user = saveOrUpdate(attributes);

        // 세션에 사용자 정보 저장
        httpSession.setAttribute("user", new SessionUser(user));

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
                attributes.getAttributes(),
                attributes.getNameAttributeKey());
    }

    private User saveOrUpdate(OAuthAttributes attributes) {
        User user = userRepository.findByEmail(attributes.getEmail())
                // 구글 사용자 정보 업데이트(이미 가입된 사용자) => 업데이트
                .map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
                // 가입되지 않은 사용자 => User 엔티티 생성
                .orElse(attributes.toEntity());

        return userRepository.save(user);
    }
}

5.3 OAuthAttributes 클래스

package toy.project.bulletin_board.config.auth.dto;

import lombok.Builder;
import lombok.Getter;
import toy.project.bulletin_board.domain.Role;
import toy.project.bulletin_board.domain.User;

import java.util.Map;

@Getter
public class OAuthAttributes {

    private Map<String, Object> attributes;
    private String nameAttributeKey;
    private String name;
    private String email;
    private String picture;

    @Builder
    public OAuthAttributes(Map<String, Object> attributes,
                           String nameAttributeKey, String name,
                           String email, String picture) {
        this.attributes = attributes;
        this.nameAttributeKey = nameAttributeKey;
        this.name = name;
        this.email = email;
        this.picture = picture;
    }

    // OAuth2User에서 반환하는 사용자 정보는 Map
    // 따라서 값 하나하나를 변환해야 한다.
    public static OAuthAttributes of(String registrationId,
                                     String userNameAttributeName,
                                     Map<String, Object> attributes) {
  
        return ofGoogle(userNameAttributeName, attributes);
    }

    // 구글 생성자
    private static OAuthAttributes ofGoogle(String usernameAttributeName,
                                            Map<String, Object> attributes) {
        return OAuthAttributes.builder()
                .name((String) attributes.get("name"))
                .email((String) attributes.get("email"))
                .picture((String) attributes.get("picture"))
                .attributes(attributes)
                .nameAttributeKey(usernameAttributeName)
                .build();
    }

    // User 엔티티 생성
    public User toEntity() {
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(Role.USER)
                .build();
    }
}

5.4 SessionUser 클래스

참고: 왜 User 클래스를 사용하면 안 되는 것일까?
만약 User 클래스를 그대로 사용했다면 직렬화를 구현하지 않았다는 의미의 에러가 발생한다. 오류를 해결하기 위해 User 클래스에 직렬화 코드를 넣는 것이 옳은 해결 방법일까?
User 클래스는 언제 다른 엔티티와 관계가 형성될지 모르는 엔티티 클래스이다. 만약 @OneToMany, @ManyToMany 등 자식 엔티티를 갖고 있다면 직렬화 대상에 자식들까지 포함된다. 따라서 성능 이슈, 부수 효과가 발생할 확률이 높다.
그러므로 직렬화 기능을 가진 세션 DTO를 추가로 만드는 것이 운영 및 유지보수 때 많은 도움이 된다.

package toy.project.bulletin_board.config.auth.dto;

import lombok.Getter;
import toy.project.bulletin_board.domain.User;

import java.io.Serializable;

@Getter
public class SessionUser implements Serializable { // 직렬화 기능을 가진 세션 DTO

    // 인증된 사용자 정보만 필요 => name, email, picture 필드만 선언
    private String name;
    private String email;
    private String picture;

    public SessionUser(User user) {
        this.name = user.getName();
        this.email = user.getEmail();
        this.picture = user.getPicture();
    }
}

6. 로그인 버튼 추가하기

스프링 시큐리티가 잘 적용되었는지 확인하기 위해 화면에 로그인 버튼을 추가하자.

<div th:if="${userName}">
    <a th:href="@{/logout}" class="btn btn-info active" role="button">로그아웃</a>
    <label th:text="${userName} + 님"></label>
</div>
<div th:if="!${userName}">
    <a th:href="@{/oauth2/authorization/google}" class="btn btn-primary me-2 active" role="button">Google Login</a>
</div>

list.html에서 userName을 사용할 수 있게 컨트롤러에 userName을 Model에 저장하는 코드를 추가하자.

@Controller
@RequiredArgsConstructor
public class HomeController {

    private final PostService postService;
    private final HttpSession httpSession;

    // 메인 화면 - 게시판 목록
    @GetMapping("/")
    public String postList(Pageable pageable, Model model) {
        Page<Post> posts = postService.findAllPosts(pageable);
        model.addAttribute("posts", posts);
        
        // 세션에서 사용자 정보 꺼내기
        SessionUser user = (SessionUser) httpSession.getAttribute("user");
        if (user != null) {
            model.addAttribute("userName", user.getName());
        }
        return "posts/list";
    }
}

7. 결과 화면

  • 로그인 전
  • 로그인 화면
  • 로그인 후
  • DB

참고 도서
스프링 부트와 AWS로 혼자 구현하는 웹 서비스

profile
https://guswls28.tistory.com 💨💨

0개의 댓글