ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Spring Security OAuth2 ,JWT : 모바일/웹 연동 (1)
    Project/TravelFeelDog 2023. 10. 16. 15:28

    이글은 Firebase 에서 OAuth2 , JWT 전환기(4) 에서 이어지는 글 입니다.

     

    https://chosunghyun18.tistory.com/176

     

    Firebase 에서 OAuth2 , JWT 전환기(4) : 예외처리

    지금까지 FireBase 를 사용한 시나리오에서 OAuth2 , JWT를 사용한 시나리오로 변경한 것은 다음과 같습니다. 1. 사용자는 구글 로그인 버튼이 있는 홈페이지에 접속합니다. - 테스트를 위한 홈페이지

    chosunghyun18.tistory.com

     

     

    필요한 작업의 목록은 다음과 같습니다.

     

    1. OAuth2 로그인이 가능한 Spring 의 내장 정적페이지를 유지한 상태로 mobile 에서 사용할 API 를 개발합니다.

     

    2. 권한별 처리를 만듭니다. 사용자의 분류의 따라 GUEST,USER,ADMIN 으로 나눠져 있으며 적절한 인가처리를 합니다.

     

    3. GUEST 의 정보를 Redis 로 관리합니다. 사용자가 구글로 로그인한뒤 회원가입을 추가로 진행하지 않으면 , 불필요한 데이터가 DB 에 남아 있는것을 처리합니다.

     

    4. 운영에 필요한 기능들을 개발합니다.

    1. AccessToken , Refresh Token  만료 기간을 0 으로 만드는 api 

    2. Refresh Token 을 넘겨주면 AccessToken 을 발급하는 api

     

     

    OAuth2 로그인이 가능한 Spring 의 내장 정적 페이지를 유지한 상태로 mobile 에서 사용할 RESTAPI 를 개발합니다.

     

     

    기존의 방식은 모바일에서 구글 클라우드 Auth 와의 통신으로 FireBase 토큰을 받아와 리소스 서버(Spring)으로 전달하는 구조였습니다.

    - 클라이언트 자격 증명 방식 (Client Credentials Grant)

     

    권한 코드 승인 방식 (Authorization Code Grant)은 다음과 같습니다. 

     

     

     

     

     

    클라이언트(안드로이드,웹) <->  리소스 서버(Spring 서버) : 구글 로그인 요청  , 리소스 서버가 리다이렉션 주소를 반환함

     

    클라이언트 -> 인증 서버 (구글 인증 서버,웹) : 리소스 서버의 API 로 로그인 요청

     

    인증 서버  -> 클라이언트 : code , 1 회성 토큰 발급

     

    7. 클라이언트 -> 리소스 서버 : code , 1 회성 토큰 전달

     

    8. 리소스 서버 -> 인증 서버  :  클라이언트에서 받은 code , 1 회성 토큰 전달     

     

    9 ~11. 인증 서버 -> 리소스 서버 :  사용자 정보 전달 (payload) 이후 DB 에서 사용자 조회

     

    12. 리소스서버 -> 클라이언트 : 조회된 사용자 정보를 바탕으로 JWT 토큰 발급 & 사용자 정보 반환

     

     

     

    -- 스프링 Security 를 사용하는 입장에서 상당부분은 FrameWork 에서 제공하고 있습니다.

     

     

     

    지금 현제 프로젝트에서 사용하는 주요 OAuth2 관련 url 은 다음과 같습니다.

     

    스프링에 구글 로그인을 요청하는 url 

     

    # request login url : http://localhost:8080/oauth2/authorization/google

     

    구글 로그인이 완료되면 나오는 리다이렉션 url

     

    # success login url :  http://localhost:8080/login/oauth2/code/google

     

     

    클라이언트에서 구글 로그인 url 을 스프링에 요청하 였을때 로그인이 완료되면 , JWT를 건내주는걸 목표로 개발합니다.

     

    기존의 코드는 다음과 같습니다.

     

    소셜 로그인 사이트에서 사용자 정보를 로드 하는 과정에서 사용자가 존재하면 토큰을 갱신하는 코드입니다. 

    public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    
        private final MemberRepository memberRepository;
        private final JwtService jwtService;
    
        @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());
            Member member = getByEmail(attributes);
            jwtService.tokenUpdateCheck(member);
            saveOrUpdate(member);
            return new DefaultOAuth2User(
                    Collections.singleton(new SimpleGrantedAuthority(member.getRoleKey())),
                    attributes.getAttributes(),
                    attributes.getNameAttributeKey());
        }
    
        public void saveOrUpdate(Member user) {
            memberRepository.save(user)
                    .orElseThrow(() -> new IllegalArgumentException("Save Or Update Error"));
        }
    
        public Member getByEmail(OAuthAttributes attributes) {
            return memberRepository.findMemberForLogin(attributes.getEmail())
                    .orElse(attributes.toEntity());
        }

     

    로그인이 완료되면 JWT 를 건내주기 위하여 SimpleUrlAuthenticationSuccessHandler 를 상속받은 LoginSuccessHandler 클라스를 추가하겠습니다.

    @Slf4j
    public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    
        private final JwtService jwtService;
        @Override
        public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                Authentication authentication) {
            String email = extractUsername(authentication);
            jwtService.tokenUpdateCheck(email);
            jwtService.addTokensForHttpServletResponse(response, jwtService.getAccessTokenByEmail(email), jwtService.getRefreshTokenByEmail(email));
            logAllResponseHeaders(response);
        }
    
        private String extractUsername(Authentication authentication) {
            OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
            Map<String, Object> attributes = oAuth2User.getAttributes();
            return (String) attributes.get("email");
        }
        public void logAllResponseHeaders(HttpServletResponse response) {
            System.out.println("Logging all response headers:");
            for (String headerName : response.getHeaderNames()) {
                System.out.print(headerName + ": ");
                Collection<String> headerValues = response.getHeaders(headerName);
                for (String headerValue : headerValues) {
                    System.out.print(headerValue + " ");
                }
                System.out.println();
            }
        }
    }

     

    추가한 Jwt 관련 서비스는 다음과 같습니다.

    public void tokenUpdateCheck(String email) {
            Member member = memberReadService.findByEmail(email);
            if (member.getRole() != Role.GUEST) {
                TokenResponse token = new TokenResponse(member.getAccessToken(),
                        member.getRefreshToken());
                token = updateToken(token, member.getEmail());
                member.updateToken(token.getAccessToken(), token.getRefreshToken());
            }
        }
    
    public void addTokensForHttpServletResponse(HttpServletResponse response, String accessToken, String refreshToken) {
            response.setStatus(HttpServletResponse.SC_OK);
            response.addHeader("AccessToken", accessToken);
            response.addHeader("RefreshToken", refreshToken);
        }
    
        public String getAccessTokenByEmail(String email) {
            Member member = memberReadService.findByEmail(email);
            if (member.getRole() != Role.GUEST) return member.getAccessToken();
            return "GUESTGUESTGUEST";
        }
    
        public String getRefreshTokenByEmail(String email) {
            Member member = memberReadService.findByEmail(email);
            if (member.getRole() != Role.GUEST) return member.getRefreshToken();
            return "GUESTGUESTGUEST";
        }

     

    LoginSuccessHandler 도 FilterChain 설정에 등록해 줍니다.

    httpSecurity.oauth2Login(request -> request
                    .successHandler(new LoginSuccessHandler(jwtService))
                    .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig
                    .userService(customOAuth2UserService)));

     

    출력문을 통해  Header 에 임시적으로 토큰이 들어간것을 확인 할 수가 있습니다.

    Logging all response headers:
    Set-Cookie: JSESSIONID=92DB7EF48027A3FB701129B69A67CCC2; Path=/; HttpOnly 
    AccessToken: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqbzE4NzcxMkBnbWFpbC5jb20iLCJpYXQiOjE2OTczNTM4MDMsImV4cCI6MTY5NzM4OTgwM30.0eaBjGxAfSrPQixGg5pc4oda4RBV8ln0IDQNhN_JaFs 
    RefreshToken: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqbzE4NzcxMkBnbWFpbC5jb20iLCJpYXQiOjE2OTczNTM4MDMsImV4cCI6MTY5NzM4OTgwM30.0eaBjGxAfSrPQixGg5pc4oda4RBV8ln0IDQNhN_JaFs

     

    그러나 http 의 명세의 따라 좋지 않은 접근입니다.

     

     리다이렉트 응답은 보통 HTTP 상태 코드 302와 함께 Location 헤더를 가지며, 대부분의 경우 추가적인 사용자 정의 헤더를 포함하지 않습니다. 그렇므로 다음과 같이 SuccessHandler 를 변경해 줍니다.

     

    @Slf4j
    public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    
        private static final String REDIRECT_URL = "/";
        private final JwtService jwtService;
        @Override
        public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                Authentication authentication) throws IOException {
            String email = extractUsername(authentication);
            jwtService.tokenUpdateCheck(email);
            getRedirectStrategy().sendRedirect(request, response, getRedirectUrl(email));
        }
    
        private String extractUsername(Authentication authentication) {
            OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
            Map<String, Object> attributes = oAuth2User.getAttributes();
            return (String) attributes.get("email");
        }
        private String getRedirectUrl(String email) {
            return UriComponentsBuilder.fromUriString(REDIRECT_URL)
                    .queryParam("Access", jwtService.getAccessTokenByEmail(email))
                    .queryParam("Refresh", jwtService.getRefreshTokenByEmail(email))
                    .build().toUriString();
        }
    }

     

     

     

    결과

     

    GUEST 유저인 경우 : 회원 가입을 아직 하지 않은 사용자는  url 에 토큰이 아닌 값을 받습니다.

     

     

    USER 유저가 로그인한  경우 url 에 토큰을 받습니다.

     

    기존의 2번의 redirection 에서 한번 더 redirection이 추가된 것을 확인 할 수 있습니다.

     

     

     

    " URL 에 Access ,Refresh 토큰 값으로 담아 전달하는것에 보안상의 위협이 있을 수 있습니다 "

     

     

    이러한 상황에서 다음 방향으로 변경을 합니다.

     

    1. 구글 로그인이 완료가 되면 url 을 통하여 임시 JWT 토큰(만료가 매우 짧은 토큰)을 SpringBoot 로 부터 발급 받습니다.

    이때 임시 JWT 토큰은 데이터베이스에 따로 저장하지 않습니다.

     

    2. 발급 받은 임시 JWT 토큰을  ResponseBody  의 담아 서버로 로그인 요청을합니다.

     

    3. Authorization 의 토큰을 SpringBoot 가 읽고 Access ,Refresh 토큰을 반환 해 줍니다.

     

     

    successs handler

      public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                Authentication authentication) throws IOException {
            String email = extractUsername(authentication);
            jwtService.tokenUpdateCheck(email);
            getRedirectStrategy().sendRedirect(request, response, getRedirectUrl(email));
        }
    
        private String extractUsername(Authentication authentication) {
            OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
            Map<String, Object> attributes = oAuth2User.getAttributes();
            return (String) attributes.get("email");
        }
        private String getRedirectUrl(String email) {
            return UriComponentsBuilder.fromUriString(REDIRECT_URL)
                    .queryParam("Authorization", jwtService.getAuthTokenByEmail(email))
                    .build().toUriString();
        }

     

    jwtService

    public String getAuthTokenByEmail(String email) {
            return jwtProvider.createAuthorizationToken(email);
        }

    jwtProvider

    public String createAuthorizationToken(String payload){
            long fiveMin = 300000L;
            return createToken(payload,fiveMin,AUTH_TOKEN_KEY).get(AUTH_TOKEN_KEY);
        }

     

     

    로그인에 사용할 API

    @RequestMapping("/login")
    public class OAuth2ApiController {
    
        private final JwtService jwtService;
        @PostMapping("/oauth2/token")
        public ApiResponse<TokenLoginResponse> mobileGoogleAuthenticationLogin(TokenLoginRequest request) {
            String email = jwtService.findEmailByToken(request.token());
            String atk = jwtService.getAccessTokenByEmail(email);
            String rtk = jwtService.getRefreshTokenByEmail(email);
            return ApiResponse.success(new TokenLoginResponse(email,new TokenResponse(atk,rtk)));
        }
    
    }

     

     

    1. 구글 로그인을 진행합니다.

     

    2. url 의 Authorization 의 값을 사용하여 token login 을 합니다.

     

    user 인 경우 access , refesh token이 정상적으로 나옵니다.

     

    GUEST 인 경우 사전의 정의한 문자가 나옵니다.

     

     

     

    RestTemplate 을 사용하여 직접 authorization code 를 구글 인증 서버에 전송하여 사용자의 정보를 받아오는 방식이 아닌

     

    SpringSecurity 와 OAuth2 라이브러리의 기능등을  최대한 활용하는 방식으로 개발을 진행해 보았습니다.

     

    배포 확인

     

    로컬에서 테스트를 진행하였으니 EC2 에 배포후 확인을 하는 작업을 합니다.

     

    프로젝트는 Nginx 와 Cerbot 을 사용하여 https 를 지원하고 가비아를 통하여 도메인을 등록 하여 사용하였는데 ,

    프로젝트 종료후 전부 삭제를 해둔 상태입니다.

     

     

    테스트용 페이지 ,  현제 IP 주소는 EC2 Spring Boot 의 주소입니다. 

     

     

     

    구글로그인 버튼을 누르면 공용 IP 가 아닌 LocalHost로 리다이렉션 되는것을 확인 할 수 있습니다.

     

     

     

    여기서 localhost 가 아닌 배포한 인스턴스의 주소만 적고 브라우저를 새로고침 해봅니다.

     

    Authorization 토큰이 잘보입니다.

     

     

    리다이렉션의 문제를 구글 클라우드 설정에 추가해 줍니다.

     

     

    + 공개 최상위 도메인(예: .com, .org)으로 끝나야 합니다.

     

     

     

    도메인을 등록해 주고 옵니다.

     

    https://chosunghyun18.tistory.com/178

     

    EC2 가비아 - 도메인 등록 + Nginx & CertBot

    ssh 의 연결을 위하여 Certbot 을 사용한 권한 증명을 받는 구성을 합니다. 3 번째로 시스템을 등록 하지만 처음으로 글을 작성해 봅니다. 사전 준비 1. AWS 의 EC2 를 구성합니다. 2. 인바운드 아웃 바

    chosunghyun18.tistory.com

     

     

     

    다음 그림 처럼 LocalHost에 추가로 등록하지만, 가능하면 url 1개만 등록 합니다.

     

     

    OAuth2 설정의 redirect-uri 도 변경해줍니다. , 변경을 local에서 안 해주면 localhost 로 리다이렉션 됩니다.

     

    spring:
      security:
        oauth2:
          client:
            registration:
              google:
                redirect-uri: ${Google_Client_REDIRECT_URI}

     

     

     

    결과 

    https 로 접속합니다.

     

     

     

         토큰이 발급되는걸 확인 가능합니다.

     

     

     

     

     

    guest 이므로 회원 가입

     

     

    Oauth2 로그인 

     

    JWT 토큰 반환 

     

     

     

     

     

    코드 저장소 

     

    https://github.com/chosunghyun18/TravelFeelDog-Server

     

    GitHub - chosunghyun18/TravelFeelDog-Server: 여행필독서 앱과 웹을 위한 벡엔드 저장소

    여행필독서 앱과 웹을 위한 벡엔드 저장소 . Contribute to chosunghyun18/TravelFeelDog-Server development by creating an account on GitHub.

    github.com

     

     

     

     

     

    참고 블로그 )

    리다이렉션 관련 흐름을 그림으로 잘 설명해준 블로그입니다.

     

    https://yelimkim98.tistory.com/45

     

    소셜로그인 2. 구글 콘솔에서 Oauth 앱 생성하기 - 승인된 리다이렉션 URI란

    구글 콘솔에서 oauth 앱을 생성해봅시다. 구글 API 콘솔에서 새 프로젝트를 생성합니다. OAuth 클라이언트 ID를 만들어줍니다. OAuth 동의화면부터 채우라고 나오네요. 여기서 필수적인것만 채워주고

    yelimkim98.tistory.com

     

Designed by Tistory.