Project/TravelFeelDog

Spring Security OAuth2 ,JWT : 모바일/웹 연동 (1)

sung.hyun.1204 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