Group Study (2023-2024)/Spring 입문

[스프링 입문] 4주차 chap 05 - 스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기

woooni 2023. 11. 27. 01:03

chap05 - 스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기

스프링 시큐리티는 막강한 인증과 인가 기능을 가진 프레임 워크이다. 스프링 기반의 애플리케이션에서는 보안을 위한 표준이라고 생각하면 된다. 인터셉터, 필터 기반의 보안 기능을 구현하는 것보다 스프링 시큐리티를 통해 구현하는 것을 적극적으로 권장하고 있다.

5.1 스프링 시큐리티와 스프링 시큐리티 OAuth2 클라이언트

먼저 OAuth란 무엇인지에 대해서 찾아봤다.

  • OAuth (Open Authorization) 은 인터넷 사용자들이 비밀번호를 제공하지 않고, 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 개방형 표준을 의미한다. 

OAuth 로그인 구현 로그인시 보안, 비밀번호 찾기, 비밀번호 변경 등의 많은 고려사항들을 모두 구글, 페이스북, 네이버 등에 맡길 수 있어 서비스 개발에 집중할 수 있다.

스프링 부트 1.5 vs 스프링 부트 2.0

OAuth2 연동방법이 2.0에서 크게 변경되었지만, spring-security-oauth2-autoconfigure 라이브러리 덕분에 설정 방법에 크게 차이가 없다.

5.2 구글 서비스 등록

다음은 구글 서비스에 신규 서비스를 생성하는 과정이다.

  • 구글 클라우드 플랫폼 주소 (https://console.cloud.google.com)으로 이동
  • [프로젝트 선택 탭] 클릭
  • 등록될 서비스의 이름 입력
  • API 및 서비스 카테고리로 이동
  • 사이드 바 중간의 [사용자 인증 정보] 클릭
  • [사용자 인증 정보 만들기] 버튼 클릭
  • [OAuth 클라이언트 ID] 항목 클릭
  • [동의화면 구성] 버튼 클릭
  • OAuth 동의화면 탭에서 애플리케이션 이름, api 의 범위(email, profile, openid) 설정
  • OAuth 클라이언트 ID 만들기 화면으로 바로 이동
  • [웹애플리케이션] 으로 애플리케이션 유형 선택
  • 프로젝트 이름 등록
  • [승인된 리디렉션 URL] 항목에 http://localhost:8080/login/oauth2/code/google 입력
  • [생성] 버튼 클릭
  • 클라이언트 ID와, 클라이언트 보안 비밀 정보를 볼 수 있음

application-oauth 등록

src/main/resources/ 디렉토리에 application-oauth.properties 파일 생성한다. 스프링부트에서 properties의 이름을 application-xxx.properties로 만들면 xxx라는 이름의 profile이 생성되어 이를 통해 관리할 수 있다고 한다. 즉, profile=xxx라는 식으로 호출하면 해당 properties의 설정들을 가져올 수 있다.

.gitignore 등록

앞서 작성했던 application-oauth.properties에는 클라이언트 아이디와 클라이언트 보안 비밀이 들어있기 때문에 .gitignore에 추가하여 깃허브에 올라가지 않도록 한다.

5.3 구글 로그인 연동하기

1. User

사용자 정보를 담당할 도메인

@Enumerated(EnumType.STRING) : JPA로 데이터베이스를 저장할 때 Enum 값을 어떤 형태로 저장할지를 결정하는데 기본적으로는 int로된 숫자가 저장된다. 숫자로 저장되면 데이터베이스로 확인할 때 그 값이 무슨 코드를 의미하는지 알 수 없기 때문에 문자열로 저장될 수 있도록 선언한다.

2. Role

Enum 클래스, 각 사용자의 권한을 관리

package com.jojoldu.book.springboot.domain.user;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum Role {

    GUEST("ROLE_GUEST", "손님"),
    USER("ROLE_USER", "일반 사용자");

    private final String key;
    private final String title;
}

스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야한다. 그렇기 떄문에 코드별 키 값을 ROLE_ 형식으로 지정했다.

3. UserRepository

User의 CRUD를 책임짐.

package com.jojoldu.book.springboot.domain.user;

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

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long>{
    Optional<User> findByEmail(String email);
}

findByEmail: 소셜 로그인으로 반환되는 값 중 email을 통해 이미 생성된 사용자인지 처음 가입하는 사용자인지를 판단하기 위한 메서드

스프링 시큐리티 설정

1. build.gradle

스프링 시큐리티 관련 의존성 추가

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

이제 OAuth 라이브러리를 이요한 소셜 로그인 설정 코드를 작성한다. config.auth 패키지를 생성해서 시큐리티 관련 클래스는 이곳에 담는다.

2. SecurityConfig

  • @EnableWebSecurity : spring security 설정들을 활성화 시켜준다.
  • csrf().disable().headers().frameOptions().disable() : h2-console 화면을 사용하기 위해 해당 옵션을 disable
  • authorizeRequests : URL 별 관리를 설정하는 옵션의 시작점. 이게 선언되어야 antMatchers옵션을 사용할 수 있다.
  • antMatchers : 권한 관리 대상을 지정하는 옵션이다.
  • anyRequest : 설정된 값들 이외 나머지 URL들을 나타낸다.
  • logout().logoutSuccessUrl("/") : 로그아웃 성공 시 / 주소로 이동한다.
  • oauth2Login : OAuth 2 로그인 기능에 대한 여러 설정의 진입점
  • userInfoEndpoint : OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당
  • userService : 소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록

3. CustomOAuth2UserService

구글 로그인 이후 가져온 사용자의 정보들을 기반으로 가입 및 정보수정, 세션 저장 등의 기능을 지원

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

        return userRepository.save(user);
    }
}

update 기능도 구현되어 사용자의 이름이나 프로필 사진이 변경되면 user 엔티티에도 반영됨.

4. OAuthAttributes

Dto로 보기 때문에 config.auth.dto 패키지 안에 존재

@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;
    }

    //
    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();
    }

    //
    public User toEntity(){
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(Role.GUEST)
                .build();
    }
}

 

4. SessionUser

인증된 사용자 정보만 필요하므로 그 외에 필요한 정보들은 없어 name, email, picture만 필드로 선언

@Getter
public class SessionUser implements Serializable {
    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();
    }
}

만약 User 클래스를 사용했다면 에러가 발생한다. User 클래스가 엔티티이기 때문에 에러가 발생한다. 직렬화 기능을 가진 세션 Dto를 하나 추가로 만드는 것이 이후 운영 및 유지 보수 때 많은 도움이 된다.

로그인 테스트

1. index.mustache

  • {{#userName}} : 머스테치에서는 항상 최종값을 넘겨줘야 하므로 userName이 있다면 userNmae을 노출시키도록 구성했다.
  • {{^userName}} : 머스테치에서 해당 값이 존재하지 않는 경우에는 ^를 사용한다.

2. IndexController

@RequiredArgsConstructor
@Controller
public class IndexController {

    private final PostsService postsService;
    private final HttpSession httpSession;

    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("posts", postsService.findAllDesc());
        SessionUser user = (SessionUser) httpSession.getAttribute("user");
        if (user != null){
            model.addAttribute("userName", user.getName());
        }
        return "index";
    }

index.mustache에서 userName을 사용할 수 있게 userName을 모델에 저장하는 코드를 추가한다.

 

잘 노출되고 구글 로그인도 잘 진행된다.

3. 403 에러

현재 로그인된 사용자의 권한은 guest로 posts 기능을 쓸 수 없기 때문에 403 권한 거부가 발생한다.

h2-console에서 사용자의 role을 user로 변경하면 글이 잘 등록된다.

 

5.4 어노테이션 기반으로 개선하기

같은 코드가 반복되는 것을 어노테이션 기반으로 개션한다.

1. @LoginUser 어노테이션

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}
  • @Target(ElementType.PARAMETER) : 이 어노테이션이 생성될 수 있는 위치를 지정한다.
  • @interface : 이 파일을 어노테이션 클래스로 지정한다.

2. LoginUserArgumentResolver

HandlerMethodArgumentResolver 인터페이스를 구현한 클래스 

3. WebConfig

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
    private final LoginUserArgumentResolver loginUserArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers){
        argumentResolvers.add(loginUserArgumentResolver);
    }
}

HandlerMethodArgumentResolver 는 항상 WebMvcConfigure의 addArgumentResolvers()를 통해서 추가해야한다.

4. IndexController 에서 반복 개선하기

  • @LoginUser SessionUser user : 기존에 httpSession.getAttribute("user")로 가져오던 세션 정보 값이 개선되었다. 어느 컨트롤러 든지 @LoginUser 만 사용하면 세션 정보를 가져올 수 있다.

 

5.5 세션 저장소로 데이터 베이스 사용하기

세션이 내장 톰캣의 메모리에 저장되기 때문에 실행 시 항상 초기화

즉, 배포할 때 마다 톰캣이 재시작 -> 우리는 데이터베이스를 세션 저장소로 사용하는 방식을 사용!

1. spring-session-jdbc 등록

build.gradle 의존성에 추가

implementation('org.springframework.session:spring-session-jdbc')

application.properties에 추가

spring.session.store-type=jdbc

 

5.6 네이버 로그인

1. 네이버 API 등록

순서는 다음과 같다.

  • 애플리 케이션 이름 등록
  • 네이버 아이디로 로그인 선택
  • 회원이름, 이메일, 프로필 사진 권한 선택
  • 환경으로 PC 웹 선택
  • 서비스 URL 을 http://localhost:8080/
  • 네이버아이디로 로그인 callback URL을 http://localhost:8080/login/oauth2/code/naver
  • 네이버 서비스에 등록되면 클라이언트 아이디, 클라이언트 시크릿 확인 가능

application-oauth.properties에 등록

  • user_name_attribute=response : 기준이 되는 유저 네임의 이름을 네이버에서는 response로 해야한다.

2. 스프링 시큐리티 설정 등록

public static  OAuthAttributes of(String registrationId, String userNameAttributeName,
                                      Map<String, Object> attributes){

        if ("naver".equals(registrationId)){
            return ofNaver("id", attributes);
        }
        return ofGoogle(userNameAttributeName, attributes);
    }

@@ -42,6 +46,18 @@ private  static OAuthAttributes ofGoogle(String userNameAttributeName, Map<Strin
                .build();
    }

    private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes){
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        return OAuthAttributes.builder()
                .name((String) response.get("name"))
                .email((String) response.get("email"))
                .picture((String) response.get("profile_image"))
                .attributes(response)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }

네이버인지 판단하는 코드와 네이버 생성자만 추가하고, 머스테치에 네이버 로그인 버튼을 추가한다.

 

네이버 로그인과 버튼이 잘 작동한다.

5.7 기존 테스트에 시큐리티 적용하기

1. 문제 CustomOAuth2UserService을 찾을 수 없음

소셜 로그인 관련 설정값들이 없기 때문에 발생한다.

테스트 환경을 위한 application.properties를 새로 만들어서 가짜 설정값을 집어넣는다.

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb
spring.datasource.hikari.jdbc-url=jdbc:h2:mem://localhost/~/testdb;MODE=MYSQL
spring.h2.console.enabled=true
spring.profiles.include=oauth
spring.session.store-type=jdbc

# Test OAuth
spring.security.oauth2.client.registration.google.client-id=test
spring.security.oauth2.client.registration.google.client-secret=test
spring.security.oauth2.client.registration.google.scope=profile,email

테스트가 4개 통과된다.

2. 문제 302 Status Code

스프링 시큐리티 설정 때문에 인증되지 않은 사용자의 요청은 이동시키기 때문에 build.gradle에 의존성 추가

testImplementation('org.springframework.security:spring-security-test')

또한 PostsApiControllerTest의 2개 테스트 메소드에 임의 사용자 인증 추가

  • @WithMockUser(roles="USER") : 인증된 모의 사용자를 만들어서 사용한다. 이 어노테이션으로 인해 ROLE_USER 권한을 가진 사용자가 API를 요청하는 것과 동일한 효과를 가진다.

변경하더라도 실제로 작동하지 않아 코드를 다시 변경해야함 -> 책 참고

변경하면 전체 테스트에서 6개 통과된다.

3. 문제 @WebMvcTest 에서 CustomOAuth2UserService을 찾을 수 없음

@WebMvcTest 에서 CustomOAuth2UserService를 스캔하지 않는다. 그렇기 때문에 스캔대상에서 SecurityConfig를 제거한다.

@WebMvcTest(controllers = HelloController.class,
        excludeFilters = {
                @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)
        }
)

 

HelloControllerTest에서도 @WithMockUser를 사용해서 가짜로 인증된 사용자를 생성한다.

-> 여기서 테스트를 돌려도 추가 에러가 발생한다.

Application.java에서 @EnableJpaAuditing 를 제거한다.

//@EnableJpaAuditing가 삭제됨
@SpringBootApplication
public class Application {
    public static void main(String[] args){

        SpringApplication.run(Application.class, args);
    }
}

그리고 JpaConfig 를 생성하여 @EnableJpaAuditing 를 추가한다.

@Configuration
@EnableJpaAuditing //JPA Auditing 활성화
public class JpaConfig {
}

모든 테스트가 통과된다.