티스토리 뷰

OAuth

  • 사용자의 정보를 가지고 있는 제3의 서비스로부터 접근 권한을 위임 받아서 웹 사이트의 자원에 접근하는 방식
  • 사용자의 정보를 저장하고 관리해야 하는 부담을 줄일 수 있다.
  • 사용자도 자신의 개인정보를 신뢰할 수 없는 타인에게 제공하는 단점을 해결한다.
  • OAuth 제공자는 네이버, 카카오, 구글 등이 있다.

JWT

  • Header, Payload, Signature로 구성된 암호화된 토큰
  • session 기반 인증 방식과 달리 stateless한 구현이 가능하다.

 

로그인 흐름

  1. Resource Owner(사용자)가 Client(서버) 자원에 접근하려 하면 Resource Server(OAuth 제공자)의 로그인 창을 호출하여 인증을 요구한다.
  2. 로그인을 성공하면 Resource Server에 로그인 API를 만들때 등록한 Redirect URI(http://{Client IP 주소}/login/oauth2/code/{registrationId}?{code}) 경로로 요청이 들어온다.
  3. Client는 {code} 값을 이용해서 다시 Resource Server로 access 토큰을 요청한다.
  4. Client는 access 토큰으로 다시 Resource Server로 사용자 정보를 요청한다.
  5. 사용자 정보를 이용해서 우리 서비스의 회원인지 아닌지 판단하고 웹사이트의 사용자이면 JWT로 access 토큰과 refresh 토큰을 만들어서 반환하고 웹사이트의 사용자가 아니면 회원가입에 필요한 사용자 기본 정보를 반환한다.
  6. 만약 JWT로 만들어서 반환한 access 토큰이 만료된 경우 refresh 토큰으로 Resource Server에게 access 토큰 재발행을 요청한다.

 

SecurityConfig.java

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .headers().frameOptions().disable()
            .and()
            .cors()
            .and()
            .sessionManagement()//세션 정책 설정
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatchers("/login/**", "/user", "/oauth2/**", "/auth/**", "/h2-console/**").permitAll()
            .anyRequest().authenticated()
            .and()
            .oauth2Login()
            .redirectionEndpoint()
            .baseUri("/oauth2/code/*")
            ;

        return http.build();
    }
}
  • csrf().disable() : REST API는 stateless 하기 때문에 Cross-site request forgery 공격을 막는 코드가 필요없다.
  • headers().frameOptions().disable() : h2-console 화면을 사용하기 위해서 비활성화
  • cors() : 프론트엔드 서버와 통신을 위해 설정
  • sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) : 스프링 시큐리티가 세션을 만들지도 기존 것을 사용하지도 않음.
  • authorizeRequests().antMatchers("/login/**", "/user", "/oauth2/**", "/auth/**", "/h2-console/**").permitAll() : 인증 절차 없이 허용할 URI
  • anyRequest().authenticated() : 위에서 허용한 URI를 제외한 모든 요청은 인증을 완료해야 접근 가능
  • oauth2Login().redirectionEndpoint().baseUri("/oauth2/code/*") : oauth 인증 후 리디렉션할 URI 지정

 

SecurityFilter.java

@Slf4j
@Component
@RequiredArgsConstructor
public class SecurityFilter extends OncePerRequestFilter {
    private final SecurityUtil securityUtil;
    private final UserRepository userRepository;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
        log.debug("**** SECURITY FILTER START");

        try {
            String authorizationHeader = request.getHeader("Authorization");
            String token;
            String userId;
            String provider;

            if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
                token = authorizationHeader.substring(7);

                if (securityUtil.isExpiration(token)) { // 만료되었는지 체크
                    throw new BadRequestException("EXPIRED_ACCESS_TOKEN");
                }

                userId = (String) securityUtil.get(token).get("userId");
                provider = (String) securityUtil.get(token).get("provider");

                if(!userRepository.existsByIdAndAuthProvider(userId, AuthProvider.findByCode(provider))){
                    throw new BadRequestException("CANNOT_FOUND_USER");
                }
            }

            filterChain.doFilter(request, response);
        } catch (BadRequestException e) {
            if (e.getMessage().equalsIgnoreCase("EXPIRED_ACCESS_TOKEN")) {
                writeErrorLogs("EXPIRED_ACCESS_TOKEN", e.getMessage(), e.getStackTrace());
                JSONObject jsonObject = createJsonError(String.valueOf(UNAUTHORIZED.value()), e.getMessage());
                setJsonResponse(response, UNAUTHORIZED, jsonObject.toString());
            } else if (e.getMessage().equalsIgnoreCase("CANNOT_FOUND_USER")) {
                writeErrorLogs("CANNOT_FOUND_USER", e.getMessage(), e.getStackTrace());
                JSONObject jsonObject = createJsonError(String.valueOf(UNAUTHORIZED.value()), e.getMessage());
                setJsonResponse(response, UNAUTHORIZED, jsonObject.toString());
            }
        } catch (Exception e) {
            writeErrorLogs("Exception", e.getMessage(), e.getStackTrace());

            if (response.getStatus() == HttpStatus.OK.value()) {
                response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
            }
        } finally {
            log.debug("**** SECURITY FILTER FINISH");
        }
    }

    private JSONObject createJsonError(String errorCode, String errorMessage) {
        JSONObject jsonObject = new JSONObject();

        try {
            jsonObject.put("error_code", errorCode);
            jsonObject.put("error_message", errorMessage);
        } catch (JSONException ex) {
            writeErrorLogs("JSONException", ex.getMessage(), ex.getStackTrace());
        }

        return jsonObject;
    }

    private void setJsonResponse(HttpServletResponse response, HttpStatus httpStatus, String jsonValue) {
        response.setStatus(httpStatus.value());
        response.setContentType(APPLICATION_JSON_VALUE);

        try {
            response.getWriter().write(jsonValue);
            response.getWriter().flush();
            response.getWriter().close();
        } catch (IOException ex) {
            writeErrorLogs("IOException", ex.getMessage(), ex.getStackTrace());
        }
    }

    private void writeErrorLogs(String exception, String message, StackTraceElement[] stackTraceElements) {
        log.error("**** " + exception + " ****");
        log.error("**** error message : " + message);
        log.error("**** stack trace : " + Arrays.toString(stackTraceElements));
    }

}
  • Filter에서 Bearer에 입력된 JWT 토큰의 유효성을 검사한다.

 

KakaoRequestService.java

@Service
@RequiredArgsConstructor
public class KakaoRequestService implements RequestService {
    private final UserRepository userRepository;
    private final SecurityUtil securityUtil;
    private final WebClient webClient;

    @Value("${spring.security.oauth2.client.registration.kakao.authorization-grant-type}")
    private String GRANT_TYPE;

    @Value("${spring.security.oauth2.client.registration.kakao.client-id}")
    private String CLIENT_ID;

    @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}")
    private String REDIRECT_URI;

    @Value("${spring.security.oauth2.client.provider.kakao.token_uri}")
    private String TOKEN_URI;

    @Override
    public SignInResponse redirect(TokenRequest tokenRequest) {
        TokenResponse tokenResponse = getToken(tokenRequest);
        KakaoUserInfo kakaoUserInfo = getUserInfo(tokenResponse.getAccessToken());

        if(userRepository.existsById(String.valueOf(kakaoUserInfo.getId()))){
            String accessToken = securityUtil.createAccessToken(
                    String.valueOf(kakaoUserInfo.getId()), AuthProvider.KAKAO, tokenResponse.getAccessToken());
            String refreshToken = securityUtil.createRefreshToken(
                    String.valueOf(kakaoUserInfo.getId()), AuthProvider.KAKAO, tokenResponse.getRefreshToken());
            return SignInResponse.builder()
                    .authProvider(AuthProvider.KAKAO)
                    .kakaoUserInfo(null)
                    .accessToken(accessToken)
                    .refreshToken(refreshToken)
                    .build();
        } else {
            return SignInResponse.builder()
                    .authProvider(AuthProvider.KAKAO)
                    .kakaoUserInfo(kakaoUserInfo)
                    .build();
        }
    }

    @Override
    public TokenResponse getToken(TokenRequest tokenRequest) {
        MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
        formData.add("grant_type", GRANT_TYPE);
        formData.add("redirect_uri", REDIRECT_URI);
        formData.add("client_id", CLIENT_ID);
        formData.add("code", tokenRequest.getCode());

        return webClient.mutate()
                .baseUrl(TOKEN_URI)
                .build()
                .post()
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .body(BodyInserters.fromFormData(formData))
                .retrieve()
                .bodyToMono(TokenResponse.class)
                .block();
    }

    @Override
    public KakaoUserInfo getUserInfo(String accessToken) {
        return webClient.mutate()
                .baseUrl("https://kapi.kakao.com")
                .build()
                .get()
                .uri("/v2/user/me")
                .headers(h -> h.setBearerAuth(accessToken))
                .retrieve()
                .bodyToMono(KakaoUserInfo.class)
                .block();
    }

    @Override
    public TokenResponse getRefreshToken(String provider, String refreshToken) {
        MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
        formData.add("grant_type", "refresh_token");
        formData.add("client_id", CLIENT_ID);
        formData.add("refresh_token", refreshToken);

        return webClient.mutate()
                .baseUrl("https://kauth.kakao.com")
                .build()
                .post()
                .uri("/oauth/token")
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .body(BodyInserters.fromFormData(formData))
                .retrieve()
                .bodyToMono(TokenResponse.class)
                .block();
    }
}
  • code값을 가지고 oauth 공급자에게 access 토큰을 요청한다.
  • access 토큰을 가지고 사용자 정보를 oauth 공급자에게 요청한다

 

application-oauth2.yml

spring:
  config:
    activate:
      on-profile: local
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: {google 로그인 api를 만들때 발급 받은 client-id}
            client-secret: {google 로그인 api를 만들때 발급 받은 client-secret}
            redirect-uri: "http://localhost:8081/login/oauth2/code/google"
            authorization-grant-type: authorization_code
            scope: profile, email
          kakao:
            client-id: {kakao 로그인 api를 만들때 발급 받은 client-id}
            redirect-uri: "http://localhost:8081/login/oauth2/code/kakao"
            client-authentication-method: POST
            authorization-grant-type: authorization_code
            scope: profile_nickname, profile_image, account_email
            client-name: Kakao
          naver:
            client-id: {naver 로그인 api를 만들때 발급 받은 client-id}
            client-secret: {naver 로그인 api를 만들때 발급 받은 client-id}
            redirect-uri: "http://localhost:8081/login/oauth2/code/naver"
            authorization-grant-type: authorization_code
            scope: name, email, profile_image
            client-name: Naver
        provider:
          google:
            authorization-uri: https://accounts.google.com/o/oauth2/v2/auth?prompt=consent&access_type=offline
            token_uri: https://oauth2.googleapis.com/token
            user-info-uri: https://www.googleapis.com/oauth2/v2/userinfo
          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
          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
  • 로그인 API를 만들면 제공되는 정보들은 공유하면 안되기 때문에 src/main/resources 하위 경로에 application-oauth2.yml 작성해서 각자 client-id와 client-secret 정보를 입력한다.

 

전체 소스

https://github.com/seogineer/demo-oauth-springboot

 

참고

https://www.rfc-editor.org/rfc/rfc6749#page-7

https://tecoble.techcourse.co.kr/post/2021-07-10-understanding-oauth/

https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-JWTjson-web-token-%EB%9E%80-%F0%9F%92%AF-%EC%A0%95%EB%A6%AC

https://velog.io/@swchoi0329/%EC%8A%A4%ED%94%84%EB%A7%81-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0%EC%99%80-OAuth-2.0%EC%9C%BC%EB%A1%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84

https://velog.io/@tmdgh0221/Spring-Security-%EC%99%80-OAuth-2.0-%EC%99%80-JWT-%EC%9D%98-%EC%BD%9C%EB%9D%BC%EB%B3%B4

https://www.baeldung.com/spring-security-5-oauth2-login

https://iseunghan.tistory.com/300

https://deeplify.dev/back-end/spring/oauth2-social-login

댓글
Total
Today
Yesterday
링크
Apple 2023 맥북 프로 14 M3, 스페이스 그레이, M3 8코어, 10코어 GPU, 512GB, 8GB, 한글