OAuth2 (naver, kakao, google) code

ᄋᄌᄒ·2023년 12월 19일
0

Spring_Security

목록 보기
3/5
post-thumbnail

글 쓰기 전에

검색해보면 전체적인 틀은 매우 유사하단 것을 알 수 있다. 그래서 흐름을 놓치지 않고 "우리 프로젝트에 맞게"끔 커스텀하는 것이 관건이었던 것 같다. 이제 두번 다시는 oauth2에 애먹지 않겠어..

본문

코드 내용을 클론하기보다는 저번 글처럼 흐름에 맞는 형식으로 보여주고자 한다.
googleOAuth2Process

📌project에 맞게 setting

일단 우리 프로젝트는 jwt token을 발급받고, 이를 accesstoken은 쿠키에 담고, refreshtoken은 redis에 담기로 했다. 이 부분은 JwtAuthenticationFilter.class의 doFilter()에 자세히 다루도록 할것이다. 아래는 추가 사항이다. (jwt보다 oauth2에 초점을 맞췄다.)
이메일을 고유값으로 진행할 예정이기 때문에 인증서버에서도 이메일만을 가져오도록 할 것이다. scope를 통해 어떤 정보를 가져올 지 선택이 되지만 email외에는 크게 의미없다.

build.gradle

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

application_security.yml

app:
  oauth2:
    authorizedRedirectUri: "http://localhost:8080"
  jwt:
    secret: {secretKey}

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: {GoogleClientId}
            client-secret: {GoogleClientSecret}
            scope: profile, email

          naver:
            client-id: {NaverClientId}
            client-secret: {NaverClientSecret}
            redirect-uri: 'http://localhost:8080/login/oauth2/code/naver'
            authorization-grant-type: authorization_code
            client-authentication-method: client_secret_post
            scope: email
            client-name: Naver

          kakao:
            client-id: {KakaoClientId}
            client-secret: {KakaoClientSecret}
            redirect-uri: http://localhost:8080/login/oauth2/code/kakao
            client-authentication-method: client_secret_post
            authorization-grant-type: authorization_code
#            scope: profile_nickname, profile_image
            client-name: Kakao

        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

          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id

.gitignore에 해당 파일을 등록해놨기 때문에 github에 올라가지 않는다.
naver는 uri를 작은따옴표('')로 감싸줘야 될것이다. google이랑 kakao는 해당사항 없다.
kakao는 이메일을 받아오는 것이 애매해서 아직 고민중이다.(아마 카카오한테 검수를 받고 정보를 받아올 수 있게끔해야할듯)

SecurityConfig.class(filterChain method)

@Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                //cors(다른 자원 허용)
                //이하생략 -> 아직 제대로 설정하지 않았음 oauth2에서 필수적인것만 작성

        httpSecurity
                .oauth2Login()
                    .authorizationEndpoint().baseUri("/oauth2/authorize")  // 소셜 로그인 url
                    .authorizationRequestRepository(cookieAuthorizationRequestRepository)  // 인증 요청을 cookie 에 저장
                    .and()
                    //userService()는 OAuth2 인증 과정에서 Authentication 생성에 필요한 OAuth2User 를 반환하는 클래스를 지정한다.
                    .userInfoEndpoint().userService(customOAuth2UserService)  // 회원 정보 처리
                    .and()
                    .successHandler(oAuth2AuthenticationSuccessHandler)
                    .failureHandler(oAuth2AuthenticationFailureHandler);
        //order : jwtFilter -> usernamePasswordAuthentication
        //username&password를 통한 검증 로직보다 jwt를 우선시하겠다는 의미
       httpSecurity.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);


        return httpSecurity.build();
    }

WebConfig.class

//CORS setting
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry
                .addMapping("/**") //CORS 적용할 URL 패턴
                .allowedOriginPatterns("*") //자원 공유 오리진 지정
                .allowedMethods("GET","POST","PUT","PATCH","DELETE","OPTIONS") //요청 허용 메서드
                .allowedHeaders("*") //요청 허용 헤더
                .allowCredentials(true) //요청 허용 쿠키
                .maxAge(MAX_AGE_SECS);
    }

중요한 부분! CORS(Cross-Origin Resource Sharing)란 웹 브라우저에서 다른 출처(origin)의 리소스를 요청할 수 있도록 하는 메커니즘이다.이번 oauth2에서 resource를 가져와야하기 때문에 꼭 SecurityConfig에 추가해주어야한다.

📌CustomOAuth2Service

어차피 authorization_uri를 AuthorizationServer에 보내는 것 까지는 별 다른 설정이 필요없다. 받아올 scope들을 가공하여 member entity로 변환하여 db에 넣어주어야한다. 하지만 우리 프로젝트에서 필요로 하는 것은 단순히 email뿐이니, 이를 유의하고 엔티티를 생성해주도록 한다.

@Slf4j
@Transactional
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final MemberRepository memberRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException {
        OAuth2UserService oAuth2UserService = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = oAuth2UserService.loadUser(oAuth2UserRequest);

        return processOAuth2User(oAuth2UserRequest, oAuth2User);
    }

    protected OAuth2User processOAuth2User(OAuth2UserRequest oAuth2UserRequest, OAuth2User oAuth2User) {
        //OAuth2 로그인 플랫폼 구분
        AuthProvider authProvider = AuthProvider.valueOf(oAuth2UserRequest.getClientRegistration().getRegistrationId().toUpperCase());
        OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(authProvider, oAuth2User.getAttributes());

        if (!StringUtils.hasText(oAuth2UserInfo.getEmail())) {
            throw new RuntimeException("Email not found from OAuth2 provider");
        }

        log.info("member save!");
        Optional<Member> byEmail = memberRepository.findByEmail(oAuth2UserInfo.getEmail());
        Member member = byEmail.orElseGet(() -> registerMember(authProvider, oAuth2UserInfo));
        //이미 가입된 경우
        //많은 프로젝트 대부분이 플랫폼의 정보를 업데이트 했지만 우리 프로젝트에서는 단순히 '권한'만 발급받을 것

        return CustomUserDetails.create(member);
    }

    private Member registerMember(AuthProvider authProvider, OAuth2UserInfo oAuth2UserInfo) {
        log.info("register Member using OAuth2");
        Member register = Member.builder()
                .email(oAuth2UserInfo.getEmail())
                .username(oAuth2UserInfo.getOAuth2Id())
                .password(PasswordUtil.generateRandomPassword(10))
                .nickname(NameUtil.generateAutoNickname())
                .authProvider(authProvider)
                .role(Role.GUEST)           //회원가입시에만 guest로 두고 이후 사용에는 user로 돌린다
                .build();

        return memberRepository.save(register);
    }
}

중간에 이메일을 통해 기존 회원을 찾고, 없으면 oAuthUserInfo내 정보를 통해 새 멤버 엔티티를 생성하여 db에 넣는 쿼리를 던진다.

OAuth2UserInfoFactory

provider가 어떤것인지에 따라 다른 생성자를 넘겨줄 것이다. 모두 OAuth2UserInfo를 상속받기 때문에 어떤 provider이든 스위치문을 통해서 맞게 넘겨줄 수 있다.

public class OAuth2UserInfoFactory {
    public static OAuth2UserInfo getOAuth2UserInfo(AuthProvider authProvider, Map<String, Object> attributes) {
        switch (authProvider) {
            case GOOGLE: return new GoogleOAuth2User(attributes);
            case NAVER: return new NaverOAuth2User(attributes);
            case KAKAO: return new KakaoOAuth2User(attributes);

            default: throw new IllegalArgumentException("Invalid Provider Type.");
        }
    }
    @Getter
    public enum AuthProvider {
        GOOGLE, KAKAO, NAVER, WAGGLE
    }
}

OAuth2UserInfo

각 provider에서 제공받을 데이터들을 담을 공동의 그릇이라고 생각하면된다.(여기서 name은 제거해야할듯)

@Getter
@AllArgsConstructor
public abstract class OAuth2UserInfo {

    protected Map<String, Object> attributes;

    public abstract String getOAuth2Id();
    public abstract String getEmail();
}

GoogleOAuth2User(OAuth2UserInfo implements)

카카오와 네이버는 id key가 "id"이다. 구글만 "sub"

public class GoogleOAuth2User extends OAuth2UserInfo{
    public GoogleOAuth2User(Map<String, Object> attributes) {
        super(attributes);
    }

    @Override
    public String getOAuth2Id() {
        return (String) attributes.get("sub");
    }

    @Override
    public String getEmail() {
        return (String) attributes.get("email");
    }
    
}

📌OAuth2AuthenticationSuccessHandler

아래 과정은 크게 2가지로 나뉜다.
1. authorization Server로부터 가져온 redirect_uri와 client가 가지고 있는 redirect_uri 비교
2. authorization Server로 보내면서 저장한 쿠키 내용 제거

쿠키로 저장한 내용은 request, redirect_uri이다. 여기서 redirect_uri를 client(서버)가 가지고 있는 것과 비교하기 위해서 쿠키에 들고 있는 것이다.->하나의 검증 과정인 셈.

주의할 점은 우리 프로젝트에서 쿠키에 accessToken을 저장한다는 점이다. 어차피 tokenService에서 generateToken()을 실행하면 refreshToken은 redis에 넣도록 되어있기 때문에 문제없지만 accessToken은 프론트에서 쿠키에 넣도록 설정하기 위해 아무 설정이 되어있지 않다. JwtAuthenticationFilter.class 의 doFilter()메서드에서 쿠키 내용을 읽는 내용이 포함되어있기 때문에 oauth2 여기서는 프론트가 작업하는 것이 아닌 백에서 직접 쿠키에 해당 정보를 넣어줘야한다.

그러면 아래 clear메서드에서 삭제되는 것 아닌가 하지만 해당 메서드를 보면 쿠키의 모든 내용을 삭제하는 것이 아닌 request, redirect_uri만을 지우도록 되어있다.

@Slf4j
@RequiredArgsConstructor
@Component
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    @Value("${app.oauth2.authorizedRedirectUri}")
    private String redirectUri;
    private final TokenService tokenService;
    private final CookieAuthorizationRequestRepository authorizationRequestRepository;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws ServletException, IOException {
        log.info("oauth2 success");
        String targetUrl = determineTargetUrl(request, response, authentication);

        if (response.isCommitted()) {
            log.debug("Response has already been committed");
            return;
        }
        clearAuthenticationAttributes(request, response);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

    protected String determineTargetUrl(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) {
        Optional<String> redirectUri = CookieUtil.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
                .map(Cookie::getValue);

        if (redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {
            throw new SecurityHandler(ErrorStatus.AUTH_REDIRECT_NOT_MATCHING);
        }
        String targetUrl = redirectUri.orElse(getDefaultTargetUrl());

        String accessToken = tokenService.generateToken(authentication).getAccessToken();
        //쿠키에 access_token value 넣어주기
        CookieUtil.addCookie(response,"access_token",accessToken,1800);


        return UriComponentsBuilder.fromUriString(targetUrl)
                .build().toUriString();
    }

    protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
        log.info("clear cookie redirect url and auth code");
        super.clearAuthenticationAttributes(request);
        authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
    }

    private boolean isAuthorizedRedirectUri(String uri) {
        log.info("validate redirect uri");
        URI clientRedirectUri = URI.create(uri);
        URI authorizedUri = URI.create(redirectUri);

        if (authorizedUri.getHost().equalsIgnoreCase(clientRedirectUri.getHost())
                && authorizedUri.getPort() == clientRedirectUri.getPort()) {
            return true;
        }
        return false;
    }
}

CookieAuthorizationRequestRepository

component로 등록된 해당 클래스는 Authorization Code를 보낼 때 자동으로 spring이 실행하여 아래 코드와 같이 request와 redirect_uri 정보를 저장하게 된다. 이 덕분에 SuccessHandler에서 쿠키 내용들을 사용 가능하다.

@Component
public class CookieAuthorizationRequestRepository implements AuthorizationRequestRepository {
    public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
    public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
    private static final int COOKIE_EXPIRE_SECONDS = 180;


    @Override
    public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
        return CookieUtil.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)
                .map(cookie -> CookieUtil.deserialize(cookie, OAuth2AuthorizationRequest.class))
                .orElse(null);
    }

    @Override
    public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
        if (authorizationRequest == null) {
            CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
            CookieUtil.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
            return;
        }

        CookieUtil.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtil.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS);
        String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);

        if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
            CookieUtil.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, COOKIE_EXPIRE_SECONDS);
        }

    }

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


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

CookieUtil

쿠키를 생성, 제거, 직렬화, 역직렬화 하는 클래스

public class CookieUtil {
    public static Optional<Cookie> getCookie(HttpServletRequest request, String name) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null && cookies.length > 0) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals(name)) {
                    return Optional.of(cookie);
                }
            }
        }
        return Optional.empty();
    }

    public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
        Cookie cookie = new Cookie(name, value);
        cookie.setPath("/");
        cookie.setHttpOnly(true);
        cookie.setMaxAge(maxAge);
        response.addCookie(cookie);
    }

    public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null && cookies.length > 0) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals(name)) {
                    cookie.setValue("");
                    cookie.setPath("/");
                    cookie.setMaxAge(0);
                    response.addCookie(cookie);
                }
            }
        }
    }

    public static String serialize(Object object) {
        return Base64.getUrlEncoder()
                .encodeToString(SerializationUtils.serialize(object));
    }

    public static <T> T deserialize(Cookie cookie, Class<T> cls) {
        return cls.cast(SerializationUtils.deserialize(Base64.getUrlDecoder().decode(cookie.getValue())));
    }
}

JwtAuthenticationFilter

@Slf4j
@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final TokenService tokenService;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // HttpServletRequest에서 JWT 토큰 추출
        HttpServletRequest httpServletRequest = ((HttpServletRequest) request);

        String requestURI = httpServletRequest.getRequestURI();
        String token = null;
        if (httpServletRequest.getCookies() != null) {
            token = Arrays.stream(httpServletRequest.getCookies())
                    .filter(cookie -> cookie.getName().equals("access_token"))
                    .findFirst().map(Cookie::getValue)
                    .orElse(null);
        }

        // 2. validateToken으로 토큰 유효성 검사
        if (token != null && tokenService.validateToken(token)) {
            // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장
            Authentication authentication = tokenService.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            request.setAttribute("username", authentication.getName());
            log.info("set Authentication to security context for '{}', uri: '{}'", authentication.getName(), ((HttpServletRequest) request).getRequestURI());
        } else {
            log.info("no valid JWT token found, uri: {}", ((HttpServletRequest) request).getRequestURI());
        }

        chain.doFilter(request, response);
    }
}

📌OAuth2AuthenticationFailureHandler

OAuth2 로그인 실패시 호출되는 Handler
인증요청시 생성된 쿠키들을 삭제하고 error를 담아 보낸다

@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    private final CookieAuthorizationRequestRepository authorizationRequestRepository;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AuthenticationException exception) throws ServletException, IOException {
        String targetUrl = CookieUtil.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
                .map(Cookie::getValue)
                .orElse("/");

        targetUrl = UriComponentsBuilder.fromUriString(targetUrl)
                .queryParam("error", exception.getLocalizedMessage())
                .build().toUriString();

        authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}

글을 마치면서

꽤 시간이 걸렸던 것 같은데.. 정리하고보니까 쉬워보이는것은 기분탓일까

reference

[naver, kakao, google developer-setting]
https://ksh-coding.tistory.com/63
[1]추천
https://ksh-coding.tistory.com/66
https://ksh-coding.tistory.com/70
[2]
https://github.com/sh111-coder/oauth2WithJwtLogin/blob/main/src/main/java/login/oauthtest4/domain/user/controller/UserController.java
[3]추천
https://europani.github.io/spring/2022/01/15/036-oauth2-jwt.html#h-oauth2--jwt-flow
[4]
https://github.com/JianChoi-Kor/OAuth2/blob/master/src/main/java/com/security/oauth/oauth2/OAuth2AuthenticationSuccessHandler.java

0개의 댓글