본문 바로가기
Book/스프링 부트와 AWS 웹 서비스

스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기 (feat. Google, Naver)

by 달의 조각 2023. 2. 17.
이 글은 이동욱 님의 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 책을 읽으며 정리한 글입니다.

 

구글 API 콘솔에서의 OAuth 2 설정

  1. 프로젝트 만들기 → 사용 설정된 API 및 서비스
  2. OAuth 동의 화면 (User Type: 외부)
  3. 사용자 인증 정보 생성 → OAuth 클라이언트 ID
  4. 발급받은 클라이언트 ID와 보안 비밀번호(Secret)는 안전하게 보관한다.

 

네이버 API 콘솔에서의 OAuth 2 설정

네이버에서는 스프링 시큐리티를 지원하지 않기 때문에 Common-OAuth2Provider에서 기본으로 설정해 주던 값을 수정으로 입력해 주는 과정이 필요하다.

 

🔮 의존성 추가

스프링 부트 2.0부터 oauth2-client 라이브러리를 추가하면 CommonOAuth2Provider라는 enum 클래스에서 구글, 깃허브 등의 기본 설정값이 제공된다.따라서 client 인증 정보만 입력하면 된다.

dependencies {
    ...
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    ...
}

 

🔮 application-oauth.properties 등록

# Google
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
  • scope: 기본 값은 openid,profile,email이다. 따로 명시한 이유는 openid라는 scope가 있으면 Open Id Provider로 인식하기 때문이다. 이렇게 되면 OpenId Provider인 서비스(구글)와 그렇지 않은 서비스(네이버, 카카오 등)로 나눠서 각각 OAuth2Service를 만들어야 한다. 공통으로 사용하기 위해 일부러 openid scope를 빼서 등록한다.
  • application.properties에 oauth profile을 등록하기: spring.profiles.include=oauth
  • 이 파일은 .gitignore에 등록해야 한다.
# naver
# registration
spring.security.oauth2.client.registration.naver.client-id=클라이언트 ID
spring.security.oauth2.client.registration.naver.client-secret=클라이언트 보안 비밀
spring.security.oauth2.client.registration.naver.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.scope=name,email,profile_image
spring.security.oauth2.client.registration.naver.client-name=Naver

# provider
spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user-name-attribute=response # 네이버는 회원 조회 시 JSON 형태로 반환되므로 response로 설정

 

  • 스프링 시큐리티에서는 하위 필드를 명시할 수 없고, 최상위 필드만 user_name으로 설정이 가능하다. 네이버의 응답값 최상위 필드는 resultCode, message, response이므로 response를 user_name으로 설정하고, 이후 자바 코드로 response의 id를 user_name으로 지정한다.

 

🔮 SecurityConfig 구현

@RequiredArgsConstructor
@Configuration
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .headers().frameOptions().disable() // h2-console 화면 사용
                .and()
                    .authorizeRequests()
                    .antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile").permitAll()
                    .antMatchers("/api/v1/**").hasRole(Role.USER.name())
                    .anyRequest().authenticated()
                .and()
                    .logout()
                        .logoutSuccessUrl("/")
                .and()
                    .oauth2Login() // OAuth 2 로그인 설정 진입점
                        .userInfoEndpoint() // OAuth 2 로그인 성공 이후 사용자 정보를 가져올 때의 설정
                            .userService(customOAuth2UserService);

        return http.build();
    }
}

 

🔮 소셜 로그인으로 가져온 사용자 정보 활용하기

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

    // 구글 사용자 정보 업데이트 시 User 엔티티에 반영
    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);
    }
}
  • registrationId: 현재 로그인 중인 서비스(구글, 네이버 등)를 구분한다.
  • userNameAttributeName: OAuth 2 로그인 진행 시 기준이 되는 필드값이다. (PK와 유사하다.) 구글의 기본 코드는 "sub"이며, 네이버나 카카오 등은 기본 지원하지 않는다.
  • SessitonUser: 세션에 사용자 정보를 저장하기 위한 Dto 클래스이다.

 

🔮 OAuth2User의 attribute를 담을 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) {
        if ("naver".equals(registrationId)) {
            return ofNaver("id", 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();
    }

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

    // User 엔티티 생성 -> 처음 가입할 때
    public User toEntity() {
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(Role.GUEST)
                .build();
    }
}

 

🔮 SessionUser

User 클래스를 그대로 사용하면 직렬화를 구현하지 않았다는 에러가 발생한다. User 클래스에 직렬화 코드를 넣으면 연관 관계로 인해 성능 이슈, 부수 효과가 발생할 수 있다.

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

 

🔮 세션값 활용하기

@LoginUser 어노테이션을 만들어서 세션 값을 가져오는 로직을 단순화했다.

@RequiredArgsConstructor
@Controller
public class IndexController {

    private final PostsService postsService;

    @GetMapping("/")
    public String index(Model model, @LoginUser SessionUser user) {
        model.addAttribute("posts", postsService.findAllDesc());

        if (user != null) { // 값이 없으면 로그인 버튼이 보임
            model.addAttribute("userName", user.getName());
        }

        return "index";
    }

    ...
}
더보기
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}
@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    private final HttpSession httpSession;

    // 파라미터에 @LoginUser 어노테이션이 붙어 있고, 파라미터의 클래스 타입이 SessionUser.class인 경우 true
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean isLoginUserAnnotation = parameter.getParameterAnnotation(LoginUser.class) != null;
        boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());

        return isLoginUserAnnotation && isUserClass;
    }

    // 파라미터에 전달할 객체
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return httpSession.getAttribute("user");
    }
}
@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final LoginUserArgumentResolver loginUserArgumentResolver;

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

HandlerMethodArgumentResolver는 항상 WebMvcConfigurer의 addArgumentResolvers()를 통해 추가해야 한다. 다른 Handler-MethodArgumentResolver가 필요하다면 같은 방식으로 추가한다.

 

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

세션 저장소

  1. 톰캣 세션: (Default) 톰캣(WAS)에 세션이 저장되므로 2대 이상의 WAS가 구동되는 환경에서는 톰캣 간의 세션 공유를 위한 추가 설정이 필요하다.
  2. 데이터베이스(MySQL 등): 여러 WAS 간의 공용 세션을 사용할 수 있다. 로그인 요청마다 DB IO가 발생되어 성능상 이슈가 발생할 수 있다. 로그인 요청이 많이 없는 백오피스, 사내 시스템 용도로 사용한다.
  3. 메모리 DB(Redis, Memcached 등): B2C 서베스에서 많이 사용하는 방식이다. 실제 서비스로 사용하기 위해서는 Embedded Redis와 같은 방식이 아닌 외부 메모리 서버가 필요하다.
// build.gradle
implementation('org.springframework.session:spring-session-jdbc')
// application.properties
spring.session.store-type=jdbc

댓글