[OAuth] OAuth2.0

무1민·2023년 7월 30일
0

SpringSecurity

목록 보기
3/3

OAuth란?

제 3의 서비스에 계정 관리를 맡기는 방식. (네이버, 구글, ...)

용어 정리

  • 리소스 오너

    자신의 정보를 사용하도록 인증 서버에 허가하는 주체. 서비스를 이용하는 사용자

  • 리소스 서버

    리소스 오너의 정보를 가지며, 리소스 오너의 정보를 보호하는 주체(네이버, 구글, ...)

  • 인증 서버

    클라이언트에게 리소스 오너의 정보에 접근할 수 있는 토큰을 발급하는 역할을 하는 애플리케이션

  • 클라이언트 애플리케이션

    인증 서버에게 인증을 받고 리소스 오너의 리소스를 사용하는 주체
    지금 만들 서비스가 이에 해당

권한 부여 코드 승인 타입

주로 권한 부여 코드 승인 타입을 사용한다.

권한 요청이란?

클라이언트, 즉, 스프링 부트 서버가 특정 사용자 데이터에 접근하기 위해 권한 서버, 즉, 카카오나 구글 권한 서버에 요청을 보내는 것.

  • client_id

    인증 서버가 클라이언트에 할당한 고유 식별자.
    클라이언트 애플리케이션을 OAuth 서비스에 등록할 때 서비스에서 생성하는 값

  • redirect_uri

    로그인 성공 시 이동해야 하는 URI

  • response_type

    클라이언트가 제공받길 원하는 응답 타입
    인증 코드를 받을 떄는 code 값을 포함해야 한다.

  • scope

    제공받고자 하는 리소스 오너의 정보 목록

데이터 접근용 권한 부여

인증 서버에 요청을 처음 보내는 경우 사용자에게 보이는 페이지를 로그인 페이지로 변경하고 사용자의 데이터에 접근 동의를 얻는다.
이 과정은 최초 1회만 진행한다.
로그인이 성공되면 권한 부여 서버는 데이터에 접근할 수 있게 인증 및 권한 부여를 수신한다.

인증 코드 제공

사용자가 로그인에 성공하면 권한 요청 시에 파라미터로 보낸 redirect_uri로 리다이렉션된다.
이때 파라미터에 인증 코드를 함께 제공한다.

액세스 토큰 응답?

인증 코드를 받으면 액세스 토큰으로 교환해야 한다. 액세스 토큰은 로그인 세션에 대한 보안 자격을 증명하는 식별 코드를 의미

  • client_secret

    OAuth 서비스에 등록할 때 제공받는 비밀키

  • grant_type

    권한 유형을 확인하는 데 사용
    이때는 authorization_code로 설정
    권한 서버는 요청 값을 기반으로 유효한 정보인지 확인하고, 유효한 정보라면 액세스 토큰으로 응답

액세스 토큰으로 API 응답 & 반환

제공받은 액세스 토큰으로 리소스 오너의 정보를 가져올 수 있다.
정보가 필요할 때마다 API 호출을 통해 정보를 가져오고 리소스 서버는 토큰이 유효한지 검사한 뒤에 응답

토큰 발급받기

구글 로그인 기능을 추가하려면 인증 서버에게 토큰을 제공받아야 한다.

1단계

https://cloud.google.com/cloud-console?hl=ko 로 접속한 후 콘솔 클릭

2단계

[프로젝트 선택]을 누르고 [새 프로젝트] 버튼을 누른다.

프로젝트 이름은 자유롭게 짓고 프로젝트 완성

3단계

사용자 인증 정보를 만들려면 OAuth 동의 화면을 먼저 구성해야 한다.
왼쪽 햄버거 메뉴에서 [API 및 서비스 -> OAuth 동의 화면] 버튼을 누른다.

원래 있는 앱의 [앱 수정]을 누른다.

4단계

[저장 후 계속]을 누르고 [범위 추가 또는 삭제]를 누른다.

openid와 이메일 주소를 추가하고 [업데이트]를 눌러 마무리하고 업데이트를 누른다.

계속 [저장 후 계속]을 누르고 마무리한다.

5단계

6단계

유형은 [웹 애플리케이션], 이름은 springboot-developer, 승인된 리디렉션 URI에는 http://localhost:8080/login/oauth2/code/google을 입력한 뒤에 만들기를 누른다.

그 뒤에 나오는 클라이언트 ID와 클라이언트 보안 비밀번호를 따로 저장해둔다.

7단계

application.yml을 열고 다음 내용을 추가한다.

스프링 시큐리티로 OAuth2를 구현하고 적용하기

먼저 쿠키 관리 클래스를 구현하고, OAuth2에서 제공받은 인증 객체로 사용자 정보를 가져오는 역할을 하는 서비스를 구현하겠다.
WebSecurityConfig 대신 사용할 OAuth2 설정 파일을 구현한다.

의존성 추가하기

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

쿠키 관리 클래스 구현

OAuth2 인증 플로우를 구현하며 쿠키를 사용할 일이 생기는데 그때마다 쿠키를 생성하고 삭제하는 로직을 추가하면 불편하므로 유틸리티로 사용할 쿠키 관리 클래스를 구현하겠다.

CookieUtil

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.util.SerializationUtils;

import java.util.Base64;

public class CookieUtil {

    // 요청값(이름, 값, 만료 기간)을 바탕으로 HTTP 응답에 쿠키 추가
    public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
        Cookie cookie = new Cookie(name, value);
        cookie.setPath("/");
        cookie.setMaxAge(maxAge);

        response.addCookie(cookie);
    }

    // 쿠키의 이름을 입력받아 쿠키 삭제
    // 실제로 삭제하는 방법은 없으므로 파라미터로 넘어온 키의 쿠키를 빈 값으로 바꾸고 만료 시간을 0으로 설정해 쿠키가 재생성되자마자 만료 처리한다.
    public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
        Cookie[] cookies = request.getCookies();

        if (cookies == null) {
            return;
        }

        for (Cookie cookie : cookies) {
            if (name.equals(cookie.getName())) {
                cookie.setValue("");
                cookie.setPath("/");
                cookie.setMaxAge(0);
                response.addCookie(cookie);
            }
        }
    }

    //객체를 직렬화해 쿠키의 값으로 변환
    public static String serialize(Object obj) {
        return Base64.getUrlEncoder()
                .encodeToString(SerializationUtils.serialize(obj));
    }

    //쿠키를 역직렬화해 객체로 변환
    public static <T> T deserialize(Cookie cookie, Class<T> cls) {
        return cls.cast(
                
                SerializationUtils.deserialize(
                        Base64.getUrlDecoder().decode(cookie.getValue())
                )
        );
    }
}

OAuth2 서비스 구현

사용자 정보를 조회해 users 테이블에 사용자 정보가 있다면 리소스 서버에서 제공해주는 이름을 업데이트하고 없다면 users 테이블에 새 사용자를 생성해 데이터베이스에 저장하는 서비스를 구현한다.

1단계

domain User파일에 사용자 이름과 OAuth 관련 키를 저장하는 코드 추가

2단계

config 패키지에 oauth 패키지를 만들고 OAuth2UserCustomService 파일을 생성한 다음 리소스 서버에서 보내주는 사용자 정보를 불러오는 메서드인 loadUser()를 통해 사용자를 조회하고, users 테이블에 사용자 정보가 있다면 이름을 업데이트하고 없다면 saveOrUpdate()를 통해 users 테이블에 회원 데이터를 추가한다.


@RequiredArgsConstructor
@Service
public class OAuth2UserCustomService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        
        OAuth2User user = super.loadUser(userRequest); // 요청을 바탕으로 유저 정보를 담은 객체 반환
        saveOrUpdate(user);

        return user;
    }

    // 유저가 있으면 업데이트, 없으면 유저 생성
    private User saveOrUpdate(OAuth2User oAuth2User) {
        Map<String, Object> attributes = oAuth2User.getAttributes();

        String email = (String) attributes.get("email");
        String name = (String) attributes.get("name");

        User user = userRepository.findByEmail(email)
                .map(entity -> entity.update(name))
                .orElse(User.builder()
                        .email(email)
                        .nickname(name)
                        .build());

        return userRepository.save(user);
    }
}

부모 클래스인 DefaultOAuth2UserService에서 제공하는 OAuth 서비스에서 제공하는 정보를 기반으로 유저 객체를 만들어주는 loadUser() 메서드를 사용해 사용자 객체를 불러옵니다. 사용자 객체는 식별자, 이름, 이메일, 프로필 사진 링크 등의 정보를 담고 있다.

OAuth2 설정 파일 작성

OAuth2와 JWT를 함께 사용하려면 기존 스프링 시큐리티를 구현하며 작성한 설정이 아니라 다른 설정을 사용해야 한다.
기존의 WebSecurityConfig 파일을 지우고 WebOAuthSecurityConfig 파일을 생성하고 코드를 작성한다.



@RequiredArgsConstructor
@Configuration
public class WebOAuthSecurityConfig {

    private final OAuth2UserCustomService oAuth2UserCustomService;
    private final TokenProvider tokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;
    private final UserService userService;

    @Bean
    public WebSecurityCustomizer configure() {  // 스프링 시큐리티 기능 비활성화
        return (web) -> web.ignoring()
                .requestMatchers(toH2Console())
                .requestMatchers("/img/**", "/css/**", "/js/**");
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // 토큰 방식으로 인증을 하기 때문에 기존에 사용하던 폼로그인, 세션 비활성화
        http.csrf().disable()
                .httpBasic().disable()
                .formLogin().disable()
                .logout().disable();

        //헤더를 확인할 커스텀 필터 추가
        http.sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        //헤더를 확인할 커스텀 필터 추가
        http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        //토큰 재발급 URL은 인증 없이 접근 가능하도록 설정. 나머지 API URL은 인증 필요
        http.authorizeHttpRequests()
                .requestMatchers("/api/token").permitAll()
                .requestMatchers("/api/**").authenticated()
                .anyRequest().permitAll();

        http.oauth2Login()
                .loginPage("/login")
                .authorizationEndpoint()
                //Authorization 요청과 관련된 상태 저장
                .authorizationRequestRepository(oAuth2AuthorizationRequestBasedOnCookieRepository())
                .and()
                .successHandler(oAuth2SuccessHandler()) //인증 성공 시 실행할 핸들러
                .userInfoEndpoint()
                .userService(oAuth2UserCustomService);

        http.logout()
                .logoutSuccessUrl("/login");

        //  /api로 시작하는 url일 경우 401상태 코드를 반환하도록 예외 처리
        http.exceptionHandling()
                .defaultAuthenticationEntryPointFor(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
                        new AntPathRequestMatcher("/api/**"));


        return http.build();
    }


    @Bean
    public OAuth2SuccessHandler oAuth2SuccessHandler() {
        return new OAuth2SuccessHandler(tokenProvider,
                refreshTokenRepository,
                oAuth2AuthorizationRequestBasedOnCookieRepository(),
                userService
        );
    }

    @Bean
    public TokenAuthenticationFilter tokenAuthenticationFilter() {
        return new TokenAuthenticationFilter(tokenProvider);
    }

    @Bean
    public OAuth2AuthorizationRequestBasedOnCookieRepository oAuth2AuthorizationRequestBasedOnCookieRepository() {
        return new OAuth2AuthorizationRequestBasedOnCookieRepository();
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

인증 요청과 관련된 상태를 저장할 저장소

OAuth2에 필요한 정보를 세션이 아닌 쿠키에 저장해서 쓸 수 있도록 인증 요청과 관련된 상태를 저장할 저장소 구현.
권한 인증 흐름에서 클라이언트의 요청을 유지하는 데 사용하는 AuthorizationRequestRepository 클래스를 구현해 쿠키를 사용해 OAuth의 정보를 가져오고 저장하는 로직을 작성하겠다.


public class OAuth2AuthorizationRequestBasedOnCookieRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {

    public final static String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
    private final static int COOKIE_EXPIRE_SECONDS = 18000;

    @Override
    public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) {
        return this.loadAuthorizationRequest(request);
    }

    @Override
    public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
        Cookie cookie = WebUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
        return CookieUtil.deserialize(cookie, OAuth2AuthorizationRequest.class);
    }

    @Override
    public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
        if (authorizationRequest == null) {
            removeAuthorizationRequestCookies(request, response);
            return;
        }

        CookieUtil.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtil.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS);
    }

    public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
        CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
    }
}

인증 성공 시 실행할 핸들러

BCryptPasswordEncoder를 삭제하고 BCryptPasswordEncoder를 생성자를 사용해 직접 생성해서 패스워드를 암호화할 수 있게 코드를 수정한 다음 findByEmail() 추가

public User findByEmail(String email) {
        return userRepository.findByEmail(email)
                .orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
}

OAuth2SuccessHandler

@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    public static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token";
    public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(14);
    public static final Duration ACCESS_TOKEN_DURATION = Duration.ofDays(1);
    public static final String REDIRECT_PATH = "/articles";

    private final TokenProvider tokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;
    private final OAuth2AuthorizationRequestBasedOnCookieRepository authorizationRequestRepository;
    private final UserService userService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        User user = userService.findByEmail((String) oAuth2User.getAttributes().get("email"));

        //리프레시 토큰 생성 -> 저장 -> 쿠키에 저장
        //토큰 제공자를 사용해 리프레시 토큰을 만든 뒤에,
        String refreshToken = tokenProvider.generateToken(user, REFRESH_TOKEN_DURATION);
        //해당 리프레시 토큰을 데이터베이스에 유저 아이디와 함께 저장
        saveRefreshToken(user.getId(), refreshToken);
        //클라이언트에서 액세스 토큰이 만료되면 재발급 요청하고 쿠키에 리프레시 토큰 저장
        addRefreshTokenToCookie(request, response, refreshToken);

        //액세스 토큰 생성 -> 패스에 액세스 토큰 추가
        //토큰 제공자를 사용해 액세스 토큰을 만든 뒤에 쿠키에서 리다이렉트 경로가 담긴 값을 가져와 쿼리 파라미터에 액세스 토큰 추가
        String accessToken = tokenProvider.generateToken(user, ACCESS_TOKEN_DURATION);
        String targetUrl = getTargetUrl(accessToken);

        //인증 관련 설정값, 쿠키 제거
        //인증 프로세스를 진행하면서 세션과 쿠키에 임시로 저장해둔 인증 관련 데이터 제거
        //기본적으로 제공하는 메서드인 clearAuthenticationAttributes()는 그대로 호출하고 removeAuthorizationRequestCookies()를 추가로 호출해
        //OAuth 인증을 위해 저장된 정보도 삭제
        clearAuthenticationAttributes(request, response);

        //리다이렉트
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

    //생성된 리프레시 토큰을 전달받아 데이터베이스에 저장
    private void saveRefreshToken(Long userId, String newRefreshToken) {
        RefreshToken refreshToken = refreshTokenRepository.findByUserId(userId)
                .map(entity -> entity.update(newRefreshToken))
                .orElse(new RefreshToken(userId, newRefreshToken));

        refreshTokenRepository.save(refreshToken);
    }

    //생성된 리프레시 토큰을 쿠키에 저장
    private void addRefreshTokenToCookie(HttpServletRequest request, HttpServletResponse response, String refreshToken) {
        int cookieMaxAge = (int) REFRESH_TOKEN_DURATION.toSeconds();

        CookieUtil.deleteCookie(request, response, REFRESH_TOKEN_COOKIE_NAME);
        CookieUtil.addCookie(response, REFRESH_TOKEN_COOKIE_NAME, refreshToken, cookieMaxAge);
    }

    //인증 관련 설정값, 쿠키 제거
    private void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
        super.clearAuthenticationAttributes(request);
        authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
    }

    //액세스 토큰을 패스에 추가
    private String getTargetUrl(String token) {
        return UriComponentsBuilder.fromUriString(REDIRECT_PATH)
                .queryParam("token", token)
                .build()
                .toUriString();
    }
}

테스트

  • 액세스 토큰

  • 리프레시 토큰

profile
야호

0개의 댓글