Spring

[Spring Boot] Spring Security & OAuth2.0 사용 - 소셜 로그인 기능 구현하기1

do_hyuk 2023. 11. 8. 22:42

오늘은 Spring Security 와 OAuth 2.0 으로 소셜 로그인을 구현해 봤는데 각각 뭘 의미하는지와

버전이 달라지면서 바뀐 부분들을 설명하겠다.


0. 환경 설정

Gradle 사용

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.1.2'
    id 'io.spring.dependency-management' version '1.1.2'
}

group = 'jpabook'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

1. 구글 서비스 등록

구글 클라우드 플랫폼 주소로 이동하여 프로젝트를 생성해야 하는데 이 과정은 여기를 참고하면 되겠다.


2-1. application-oauth 등록

application-oauth.yml가 있는  src/main/resources/   디렉토리에 application-oauth.yml 파일을 생성

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: 클라이언트 ID
            client-secret: 클라이언트 보안 비밀
            scope: profile. email

 

application-oauth.properties를 쓸 때는 아래와 같이 한다.

spring.security.oauth2.client.registration.google.client-id = 클라이언트 ID
spring.security.oauth2.client.registration.google.client-secret = 클라이언트 보안 비밀
spring.security.oauth2.client.registration.google.scope = profile, email

 

✓ yml 과 properties의 차이점

더보기

properties와 yml의 대표적인 차이는 내부 구조가 있다. properties의 경우엔 각 줄마다 key=value의 형태로 이루어져 있지만, yml의 경우엔 들여쓰기로 구분되는 계층 구조 및 key: value의 형태로 이루어져 있다. 

 

yml에서는 공통되는 구조는 상위에 한 번만 작성하고 하위에 다시 공통되는 구조가 작성되는 식으로 내려가다가 마지막에 최종적으로 값을 설정하게 된다. 이는 properties에서는 같은 구조 내부의 설정이라도 구조 전체를 작성해야 했던 점이 개선되었다고 볼 수 있다.

 

결론적으로는 어떤 것을 사용해도 아무 문제가 없고, 기본적인 구조는 비슷하기 때문에 편한 것을 사용하면 되겠다. 개인적으로는 yml을 사용하는 것이 더 구조를 파악하기 쉽고, 중복되는 코드가 줄어들기 때문에 yml을 사용하는 것이 더 좋다고 생각한다.

 

주의할 점은 properties와 yml을 함께 사용하면 properties 파일이 우선순위가 높아 yml 파일에서 설정한 내용이 덮어씌워질 수 있다는 점이다. 따라서 되도록이면 둘 중 한쪽만 사용하는 것이 권장된다.

  • scope의 기본값 : openid, profile, email
  • openid scope가 있으면 Open Id Provider로 인식하기 때문에, 강제로 scope를 profile, email로 설정
    • 만약 Open Id Provider로 인식하게 되면 Open Id Provider 서비스(구글)와 그렇지 않은 서비스(네이버/카카오)를 나눠서
      각각 OAuth2Service를 만들어야 하므로, 그러지 않기 위해 open id scope는 빼야한다.
  • 스프링 부트에서는 application-xxx.properties로 만들면 xxx라는 이름의 profile이 생성되어 이를 통해 관리할 수 있다.
    • profile=xxx 라는 방식으로 호출 => 해당 properties의 설정들을 가져올 수 있다.

 

2-2. application 수정

application.yml에서 application-oauth.yml를 포함하도록 구성합니다.

spring:
  profiles:
    include: oauth

 

✓ gitignore

보안을 위해 깃허브에 application-oauth.yml 파일이 올라가는 것을 방지하는 코드를 추가 작성한다.

application-oauth.yml

 

 


 

3. 구글 로그인 연동하기

✓ User.java

domain 패키지 아래에 user 패키지를 생성 후 User.java클래스를 생성한다.

package com.example.studyweb.domain;

import com.example.studyweb.domain.problem.Problem;
import com.example.studyweb.domain.enums.RoleType;
import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.util.ArrayList;
import java.util.List;

@Entity
@NoArgsConstructor
@Getter @Setter
@Table(name="users")
public class User extends BaseEntity {

    @Id @GeneratedValue
    @Column(name="user_id")
    private Long id;

    private String name;

    @Enumerated(value = EnumType.STRING)
    @Column(nullable = false)
    private RoleType role;

    @Column(nullable = false)
    private String email;

    @OneToMany(mappedBy = "member", cascade = CascadeType.ALL)
    private List<Problem> problemList = new ArrayList<>();

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

    public User update(String name){
        this.name = name;
        return this;
    }

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

 

 

[JPA] 매핑 어노테이션

오늘은 엔티티 내 필드에서 매핑컬럼에 대한 특성을 지정할 수 있는 매핑 어노테이션을 알아보겠다. @Column @Enumerated @Temporal @Lob @Transient @Column name @Column(name = "컬럼명") 필드와 매핑할 테이블의

backend-repository.tistory.com

MySQL 예약어 문제가 발생하여, User.java 클래스에 @Table(name="Users") 어노테이션을 추가한다.

 

✓ Role.java

각 사용자의 권한을 관리할 Enum 클래스 Role.java를 생성한다.

package com.example.studyweb.domain.enums;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum RoleType {
    USER("ROLE_USER", "일반 사용자"),
    GUEST("ROLE_GUEST","손님");

    private final String key;
    private final String title;

}
Spring Security에서는 권한 코드에 항상 ROLE_이 앞에 있어야만 한다.

 

✓ UserRepository.java

user의 CRUD를 책임질 UserRepository.java도 생성한다.

package com.example.studyweb.domain;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);
}

 

✓ build.gradle

Spring Security 관련 의존성 하나를 추가한다.

// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' //여기

 

✓ SecurityConfig.java

config.auth 패키지를 생성 후 SecurityConfig.java 클래스를 생성한다.

Spring Security 버전 업으로 인한 코드 변경 - 

 

기존 코드

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .headers().frameOptions().disable()
                .and()
                .authorizeRequests(authorize -> authorize
                    .antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**").permitAll()
                    .antMatchers("/api/v1/**").hasRole(Role.USER.name())
                    .anyRequest().authenticated())
                .logout(logout -> logout
                    .logoutSuccessUrl("/"))
                .oauth2Login(oauth2Login -> oauth2Login
                    .userInfoEndpoint()
                    .userService(customOAuth2UserService));

        return http.build();
    }
}

 

 

변경 코드(버전 업)

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf((csrfConfig) ->
                    csrfConfig.disable()
                ) // 1번
                .headers((headerConfig) ->
                    headerConfig.frameOptions(frameOptionsConfig ->
                            frameOptionsConfig.disable()
                    )
                )// 2번
                .authorizeRequests(authorize -> authorize
                        .requestMatchers(new AntPathRequestMatcher("/"), new AntPathRequestMatcher("/css/**"),
                                new AntPathRequestMatcher("/images/**"), new AntPathRequestMatcher("/js/**"),
                                new AntPathRequestMatcher("/h2-console/**"), new AntPathRequestMatcher("/login/")
                        ).permitAll()

                        .requestMatchers(new AntPathRequestMatcher("/admins")).hasRole(RoleType.USER.name())
                        .anyRequest().authenticated())

                .logout(logout -> logout
                        .logoutSuccessUrl("/"))

                .oauth2Login(oauth2Login ->
                    oauth2Login.userInfoEndpoint(userInfoEndpoint ->
                        userInfoEndpoint.userService(customOAuth2UserService)
                    )
                );// OAuth 2.0 로그인 구현

        return http.build();
    }
}

 

  • @EnableWebSecurity => Spring Security 설정들을 활성화
  • csrf((csrfConfig) -> csrfConfig.disable()) 와
    headers((headerConfig) ->
                        headerConfig.frameOptions(frameOptionsConfig ->
                                frameOptionsConfig.disable()

    => h2-console 화면을 사용하기 위해 해당 옵션들 disable()
  • authorizeRequests => URL 별 권한 관리를 설정하는 옵션의 시작점
  • requestMatchers
    • 권한 관리 대상을 지정하는 옵션
    • URL, HTTP 메소드별로 관리가 가능
    • "/" 등 지정된 URL들은 permitAll() 옵션을 통해 전체 열람 권한을 제공
    • "/admins" 주소를 가진 API는 USER 권한을 가진 사람만 가능하도록 함
  • anyRequest
    • 설정된 값들 이외 나마지 URL들을 나타냄
    • 여기서는 authenticated()를 추가하여 나머지 URL들은 모두 인증된 사용자들에게만 허용하게 함
      (인증된 사용자 = 로그인한 사용자)
  • logout().logoutSuccessUrl("/")
    • 로그아웃 기능에 대한 여러 설정의 진입점
    • 로그아웃 성공 시 / 주소로 이동
  • oauth2Login
    • OAuth2 로그인 기능에 대한 여러 설정의 진입점
  • userInfoEndpoint
    • OAuth2 로그인 성공 후 사용자 정보를 가져올 때 설정들을 담당
  • userService
    • 소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록
    • 리소스 서버(소셜 서비스)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능 명시

✓ CustomOAuth2UserService 클래스 생성

구글 로그인 이후 가져온 사용자의 정보들을 기반으로 가입 및 정보수정, 세션 저장 등의 기능을 지원하는 CustomOAuth2UserService.java 클래스를 생성한다.

@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();
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

        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()))
                .orElse(attributes.toEntity());

        return userRepository.save(user);
    }
}

 

  • registrationId
    • 현재 로그인 진행 중인 서비스를 구분하는 코드
    • 지금은 구글만 사용하는 불필요한 값이지만, 이후 네이버 로그인 연동 시에
      네이버 로그인인지 구글 로그인인지 구분을 위해 사용
  • userNameAttributeName
    • OAuth2 로그인 진행 시 키가 되는 필드 값을 이야기함
      • Primary Key와 같은 의미
    • 구글의 경우 기본적으로 코드를 지원하지만, 네이버 / 카카오 등은 기본 지원하지 않음.
      • 구글의 기본 코드는 "sub"
    • 이후 네이버 로그인과 구글 로그인 동시 지원 시 사용
  • OAuthAttributes
    • OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스
    • 이 후 네이버 등 다른 소셜 로그인에도 이 클래스 사용
  • SessionUser
    • 세션에 사용자 정보를 저장하기 위한 DTO 클래스

✓ OAuthAttributes 클래스 생성

@Getter
public class OAuthAttributes {

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

    @Builder
    public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey,
                          String name, String email) {

        this.attributes = attributes;
        this.nameAttributeKey = nameAttributeKey;
        this.name = name;
        this.email = email;
    }

    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"))
                .attributes(attributes)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }

    public User toEntity(){
        return User.builder()
                .name(name)
                .email(email)
                .role(RoleType.GUEST)
                .build();
    }
}
  • of()
    • OAuth2User에서 반환하는 사용자 정보는 Map이기에 값 하나하나를 변환해야 한다.
  • toEntity()
    • User 엔티티를 생성
    • OAuthAttributes에서 엔티티를 생성하는 시점은 처음 가입할 때
    • 가입할 때의 기본 권한을 GUEST로 주기 위해서 role 빌더 값에는 RoleType.GUEST를 사용
    • OAuthAttributes 클래스 생성이 끝났으면 같은 패키지에 SessionUser 클래스를 생성

✓ SessionUser 클래스 생성

@Getter
public class SessionUser implements Serializable {

    private String name;
    private String email;

    public SessionUser(User user){
        this.name = user.getName();
        this.email = user.getEmail();
    }
}
  • 인증된 사용자 정보만 필요하고, 그 외 필요 정보들은 없으니 name, email만 필드로 선언한다.
  • User 클래스를 사용하면 안되는 이유
    • 세션에 저장하기 위해 User 클래스를 세션에 저장하려 하니,
      User클래스에 직렬화를 구현하지 않았다는 에러가 발생
    • User클래스는 엔티티이기 때문에 언제 다른 엔티티와 관계가 형성될 지 모른다.
    • 직렬화를 구현한다면 성능 이슈, 부수 효과가 발생할 확률들이 높다.
    • 직렬화 기능을 가진 세션 DTO를 하나 추가로 만드는게 이후 운영 및 유지보수에 많은 도움이 된다.

4. 로그인 테스트

✓ index.mustache

index.mustache에 로그인 버튼과 로그인 성공 시 사용자 이름을 보여주는 코드를 작성한다.

<!DOCTYPE HTML>
<html>
<head>
    <title>스프링부트 웹서비스</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<h1>스프링 부트로 시작하는 웹 서비스</h1>
{{#userName}}
    Logged in as : <span id="user">{{userName}}</span>
    <a href="/logout" class="btn btn-info active" role="button">Logout</a>
{{/userName}}
{{^userName}}
    <a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
{{/userName}}
<a href="/admins" class="btn btn-success active" role="button">go to admin</a>
</body>
</html>

 

✓ IndexController.java

index.mustache에서 userName을 사용할 수 있게 IndexController.java에서 userName을 model에 저장하는 코드를 추가

@Controller
@RequiredArgsConstructor
public class IndexController {

    private final ProblemService problemService;
    private final HttpSession httpSession;

    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("problems", problemService.findAllDesc());
        SessionUser user = (SessionUser) httpSession.getAttribute("user");

        if(user != null){
            model.addAttribute("userName", user.getName());
        }
        return "index";
    }

    @GetMapping("/admins")
    public String admin(Model model) {

        model.addAttribute("problems", problemService.findAllDesc());
        SessionUser user = (SessionUser) httpSession.getAttribute("user");

        if(user != null){
            model.addAttribute("userName", user.getName());
        }
        return "admin";
    }
}
admin은 GUEST가 USER 권한에 접근했을때 403 error가 발생하는지 알아보기 위해 작성
  • (SessionUser) httpSession.getAttribute("user")
    • 앞서 작성된 CustomOAuth2UserService에서 로그인 성공 시 세션에
      SessionUser를 저장하도록 구성
    • 즉, 로그인 성공 시 httpSession.getAttribute("user")에서 값을 가져올 수 있다.
  • if(user != null)
    • 세션에 저장된 값이 있을 때만 model에 userName으로 등록
    • 세션에 저장된 값이 없으면 model에는 아무 값도 없는 상태이니 로그인 버튼이 보이게 된다.