Spring Security - OAuth 개념 및 동작 방식

BlackHan·2023년 11월 22일
3
post-thumbnail
post-custom-banner

우리는 보통 로그인할 때 네이버로 로그인, 구글 계정으로 로그인 같은 것을 본 적이 있다. 이를 이용하게 되면 해당 서비스에 따로 가입 정보를 입력하지 않아도 곧바로 이용이 가능하다.

OAuth란

Open Authorization의 약자로 제 3자 인증 방식으로, 신뢰할 수 있는 웹사이트에 등록되어 있는 회원 정보를 활용하여 서비스에 로그인을 하는 기능을 말한다. 이는 정확히 말하자면, 대신 로그인을 하는 게 아니고 사용자 정보를 위임하는 기술이다.

🤷‍♂️ 등장 배경

HTTPS는 HTTP 프로토콜을 암호화하여, 패킷을 가로챌 수 있는 위험성을 줄였다. 그런 HTTPS의 암호화 방식 중에 제 3자 인증 방식이 있는데, 그것이 바로 OAuth이다. 개인정보의 보안성을 높이기 위해서 나온 것으로 생각하면 된다.

장점

  • 클라이언트에 개인정보를 저장하지 않기 때문에 보안성이 높아진다.
  • 개인 정보를 제공하지 않아도 리소스에 접근할 수 있어서 편리하다.
  • 다양한 웹사이트에서 사용할 수 있기 때문에 확장성이 높다.

🔁 동작 과정

네이버를 예로 들어본다.

  1. 서비스를 제공하는 클라이언트는 사용자에게 네이버에 대한 접근 권한을 요청한다.
  2. 사용자는 로그인 화면에서 "네이버로 로그인" 버튼을 클릭하면, 클라이언트는 사용자를 네이버 로그인 페이지로 리디렉션합니다.
  3. 네이버 로그인 페이지에서 사용자는 자신의 네이버 계정으로 로그인하고 인증을 요청한다.
  4. 네이버는 사용자의 인증을 확인하고, 사용자에게 네이버에 대한 접근 권한을 부여할지 여부를 묻는다.
  5. 사용자가 네이버에 대한 접근 권한을 부여하면, 네이버는 클라이언트에 액세스 토큰을 발급한다.
  6. 클라이언트는 액세스 토큰을 사용하여 네이버 리소스에 접근한다.
  7. 클라이언트는 네이버 리소스에서 사용자의 정보를 조회하고, 정보를 기반으로 서비스를 제공한다.

SpringBoot 구현

우리는 이 과정에서 프론트 엔드로 로그인 토큰 반환 과정까지 진행해 본다.

우선 네이버 로그인 API를 이용한다.
원하는 정보를 선택하고, 환경 설정을 해준다. 나는 PC 웹으로 진행했다.
여기서 Callback URL은 인가코드를 요청한 뒤 리다이렉트되는 주소이다.
( 서버는 클라이언트가 지정한 콜백 URL로 사용자를 리다이렉트 하고, 콜백 URL로 이동한 사용자는 인가 코드를 수신한다. )

1. SpringBoot의 gradle에 의존성을 주입한다.

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

2. yaml 파일을 수정해 준다.

spring:
  security:
    oauth2:
      client:
        provider:
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user-name-attribute: response
        registration:
          naver:
            client-id: RP8tuCd8ec5_zuNb7Yv2
            client-secret: VnYHzwP2PN
            redirect-uri: http://localhost:8080/login/oauth2/code/naver
            authorization-grant-type: authorization_code
            client-authentication-method: client_secret_post
            client-name: Naver
            scope:
              - nickname
              - email
              - profile_image

여기서

  • authorization-uri : 위에서 작성했던 콜백 URL을 쓴다.
  • token-uri : 사용자 정보 요청을 위한 Access Token을 받기 위한 URL을 작성한다.
  • user-info-uri : 사용자 정보를 조회하기 위한 URL을 작성한다.
  • user-name-attribute : 클라이언트로부터 받은 사용자 정보 중 어떤 부분을 활용하는 지를 작성한다. 서비스에서 필요로 하는 사용자 정보를 자동으로 선택하기 위한 용도로 활용되며, 실제로 사용자에게 제공되거나 외부로 노출되지는 않는다.
    => 여기에 response를 적용하면, 이는 주로 내부 데이터 처리를 위한 용도로 사용되는 것이기 때문에 사용자에게 직접적으로 노출되지 않고, 서비스의 효율성과 유연성을 높이기 위해 활용되고 있다는 것을 의미한다.
  • client-id , client-secret: 클라이언트 측에 저희가 어떤 서비스인지를 인증하기 위한 값이다. 이는 애플리케이션 정보창에서 확인 가능하다.
  • redirect-uri : 위에서 작성했던 콜백 URL을 쓴다.
  • authorization-grant-type : 어떤 방식으로 Access Token을 받을지를 정의한니다. 이 부분은 일반적으로 authorization_code로 유지합니다.
    authorization_code 유형은 사용자의 인증 정보가 클라이언트에 직접 노출되지 않으며, 인가 코드를 이용하여 액세스 토큰을 얻기 때문에 중간자 공격을 어렵게 만든다는 이점이 있다.
  • client-authentication-method : 클라이언트가 자신을 인증하는 방식을 나타내며, Client Id와 Client Secret를 요청의 어디에 포함할지를 정의한다. client_secret_post는 그중 하나로, 클라이언트 ID와 클라이언트 시크릿을 POST 요청의 본문(body)에 포함하여 보내는 방식이다.
    예를 들어 클라이언트가 액세스 토큰을 요청할 때, 해당 요청의 본문에 클라이언트 ID와 클라이언트 시크릿을 담아 서버로 전송하는데 이는 클라이언트 시크릿이 URL이나 헤더에 노출되지 않는다는 장점을 가진다.

3. 인증 이후에 사용자 데이터를 처리하기 위한 OAuth2UserServiceImpl를 작성한다.

@Slf4j
@Service
public class OAuth2UserServiceImpl extends DefaultOAuth2UserService {

    @Override
    public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest)
    throws OAuth2AuthenticationException {
  	// 부모 클래스의 loadUser 메서드를 호출하여 OAuth2User 정보를 가져옴
        OAuth2User oAuth2User = super.loadUser(oAuth2UserRequest);

SpringBoot에서는 DefaultOAuth2UserService와 같은 미리 구성된 서비스를 제공한다. 이를 상속받아서 설정이 정의된 서비스 제공자에 대하여 큰 설정 없이 사용할 수 있게 해 준다.
super.loadUser(oAuth2UserRequest)를 호출하여 부모 클래스의 loadUser 메서드를 실행한다. 이는 Spring Security가 제공하는 기본 OAuth2UserService로, OAuth 2.0 제공자로부터 사용자 정보를 가져오는 역할을 한다.

즉, loadUser 메서드가 반환하는 OAuth2User 객체를 통해 네이버로부터 받은 사용자 정보에 접근할 수 있다.
이제 사용자 정보를 담을 Map을 생성할 차례이다.

        // 새로운 속성을 저장할 Map을 생성
        Map<String, Object> attributes = new HashMap<>();
        
        // 사용자 정보의 제공자(provider)를 "naver"로 설정함
        attributes.put("provider", "naver");

        // 네이버로부터 받은 데이터를 아래와 같이 활용함
        Map<String, Object> responseMap 
        = oAuth2User.getAttribute("response");
        attributes.put("id", responseMap.get("id"));
        attributes.put("email", responseMap.get("email"));
        attributes.put("nickname", responseMap.get("nickname"));

        // 사용자의 식별에 사용할 속성을 "email"로 설정함
        String nameAttribute = "email";

        // 사용자 정보를 담은 DefaultOAuth2User 객체를 생성하여 반환함
        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority("USER")), 
                // "USER"라는 권한을 부여함
                attributes, // 사용자의 추가 정보를 담은 Map
                nameAttribute // 사용자를 식별하는 데 사용할 속성
        );
    }
}

위와 같이 작성하여, 네이버로부터 받은 데이터 중에서 사용하고자 하는 정보를 선택하여 새로운 Map에 담고 DefaultOAuth2User 객체를 생성하여 반환한다.

4. 이번에는 인증 이후 어떻게 동작할지를 정의하는 OAuth2SuccessHandler를 작성한다.

@Slf4j
@Component
public class OAuth2SuccessHandler 
	extends SimpleUrlAuthenticationSuccessHandler {
	//JwtTokenUtils 를 활용해 JWT를 생성하도록 함
    private final JwtTokenUtils tokenUtils;

    public OAuth2SuccessHandler(JwtTokenUtils tokenUtils) {
        this.tokenUtils = tokenUtils;
    }
    
   	//인증 성공 시 동작 정의
    @Override
    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication
    ) throws IOException, ServletException {
        // OAuth2 인증을 통해 얻은 사용자 정보를 가져옴
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();

        // JWT 토큰 생성
        String jwt = tokenUtils.generateToken(
       			User
                	.withUsername(oAuth2User.getName())	
                    .password(oAuth2User.getAttribute("id").toString())
                	.build());

        // 생성한 JWT를 이용하여 리다이렉트할 URL 생성
        String targetUrl = String.format(
        	"http://localhost:8080/token/val?token=%s", jwt
            );

        // 생성한 URL로 리다이렉트
        // 아래는 SimpleUrlAuthenticationSuccessHandler의 메서드
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}

여기서는 impleUrlAuthenticationSuccessHandler 클래스에서 상속받은 onAuthenticationSuccess를 정의한다. 이는 인증 성공 시 동작을 정의한다.
(1).authentication.getPrincipal()으로 인증을 통해 얻은 사용자 정보를 가져온다.
(2) 그 후 tokenUtils.generateToken을 사용하여 Token을 발급받는다.
(3) 이 JWT를 포함한 리다이렉트 URL을 생성하고,
(4) getRedirectStrategy().sendRedirect으로 자동 리다이렉트 되도록 한다.

5. 여기까지 작성했다면 WebSecurityConfig 를 통해 OAuth2UserServiceImpl 과 OAuth2SuccessHandler를 구성한다.

여기서는 이 두 가지 Bean객체를 SecurityFilterChain 구성 시 추가해 준다.

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        // 기본 HTTP 기본 인증 및 CSRF 보안 설정을 비활성화
        .httpBasic(AbstractHttpConfigurer::disable)
        .csrf(AbstractHttpConfigurer::disable)
        
        // 특정 URL 패턴에 대한 권한 설정
        .authorizeHttpRequests(authHttp -> authHttp
            .requestMatchers("/token/**", "view url") 
            // "/token/**", "뷰 url" 경로에 대해서는 권한 검사를 하지 않음.이는 ViewController 작성 시 경로
            .permitAll() // 모든 사용자에게 허용
            .anyRequest().authenticated() // 그 외의 요청은 인증된 사용자에게만 허용
        )
        
        // OAuth 2.0 로그인 설정
        .oauth2Login(oauth2Login -> oauth2Login
            .loginPage("/views/login") // 사용자를 로그인 페이지로 리다이렉트
            .successHandler(oAuth2SuccessHandler) // 로그인 성공 시의 핸들러로 oAuth2SuccessHandler를 사용
            .userInfoEndpoint(userInfo -> userInfo
                .userService(oAuth2UserService) // 사용자 정보 엔드포인트를 처리하는 사용자 서비스 설정
            )
        )
        
        // 세션 관리 설정
        .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        
        // JWT 토큰 필터 추가
        .addFilterBefore(jwtTokenFilter, AuthorizationFilter.class);

    return http.build();
}

우리가 살펴볼 곳은 로그인 설정 구간이다.

  • .loginPage() : 로그인이 성공하지 않았을 때 사용자를 리다이렉트할 로그인 페이지를 정의한다.
    여기서 .oauth2Login()을 구성하면 Spring Security가 자동으로 기본 로그인 페이지를 생성한다.
    (1) 사용자 정의 로그인 페이지 생성
    (2) 항상 401 응답을 반환하는 엔드포인트 구성
    여기서는 첫 번째 옵션을 선택하였고,로그인 페이지를 "/views/login"로 지정하였다.
  • successHandler() : 인증이 성공했을 때 사용할 핸들러 객체를 설정
  • userInfoEndpoint() : 사용자 정보를 조회하는 Endpoint 설정을 담당한다.

결과

마지막으로 Controller를 작성하고, login 링크를 만들면 소셜 로그인을 진행할 수 있다.

네이버 로그인이 생긴 것을 확인할 수 있고, 권한을 부여하면 아래와 같은 JWT 페이로드 정보를 받을 수 있다.
클라이언트는 이 정보를 안전하게 보관하고, 필요할 때마다 HTTP 헤더에 JWT를 포함시켜 서버에 보내서 인증 과정을 진행한다.

회고

여기까지 네이버 로그인을 진행해 봤다. 네이버 로그인 구현을 통해 OAuth의 기본 원리를 이해하고 코드를 작성하는 경험이 되었다. 이로써 OAuth를 사용한 로그인 시스템을 구축하는 데 필요한 기본 기능들을 숙지했다.

OAuth는 코드의 재사용성이 높기 때문에, 이제는 다른 플랫폼에도 쉽게 확장할 수 있을 것으로 예상된다. OAuth의 핵심 메커니즘은 공통적으로 적용되기 때문에, 네이버 로그인에서 구현한 코드를 기반으로 구글과 카카오 로그인을 적용하는 것은 어렵지 않을 것으로 추측..
다음 단계에서는 구글과 카카오 로그인을 진행해 보면서, 각 플랫폼에서의 고유한 특징과 동작 방식에 대한 이해를 높이고, 이러한 다양한 로그인 시나리오를 다루어 보고자 한다.

profile
Slow-starter
post-custom-banner

0개의 댓글