본문 바로가기
부트캠프 개발일기/Main-Project

104일차: Main-Project Day 11 (OAuth2.0 회원가입+로그인 로직 구현)

by shyun00 2023. 7. 12.

드디어..! OAuth2.0 회원가입/로그인 로직까지 최종 마무리 했다.

 

지난번 백엔드 서버에서 사용자에게 구글 로그인 URI를 제공하던것에서 프론트 서버에서 로그인 URI를 제공하는것으로 변경했다.

(혹시 백엔드 서버에서 URI를 제공하는것으로 유지하려면, 전체 비즈니스로직 처리 결과가 프론트 서버로 전송되는 과정을 추가하면 될것같기는 하다.)

 

결국, 프론트 서버에서 Authorization code를 구글에서 받아서 백엔드 서버로 전달해주면 백엔드 서버에서는 해당 code를 활용해서 회원 정보를 가져오는 로직을 구현하면 된다.

 

Authorization Code를 가지고 구글에서 AccessToken을 받아오기 위해서는 리디렉션 URI, client id, client secret이 필요하다.

client id, client secret는 프론트 담당자분께 전달받았는데 리디렉션 URI는 따로 설정하지 않으셨다고 했다.

(백엔드 서버로 테스트할때는 해당 부분을 명시적으로 작성해주었었다. 어떤 차이인지는 좀 더 공부해봐야할것같다.)

 

프론트엔드 로직을 잘 모르다보니 구글 로그인용 링크가 어떻게 생성되는지 정확히 모르겠지만

링크에 [redirect_uri=storagerelay%3A%2F%2Fhttp%2Flocalhost%3A5173%3Fid%3Dauth00000]라고 적혀있는 부분이 있었다. 해당 주소를 디코딩해보면 storagerelay://http/localhost:5173?id=auth000000로 해석된다.

리디렉션 URI를 http://localhost:5173 으로 설정하니 구글에서 정상적으로 액세스 토큰이 받아와졌다.

(해당 부분은 실제 배포가 진행되면 변경되지 않을까 추측해본다.)

 

사용자에게 제공되는 URI에는 다음과 같은 여러 옵션들이 있다. 세부 내용은 공식문서에 잘 설명되어있다.

현재 우리 프로젝트에서 생성된 링크는 다음과 같다. (가독성을 위해 파라미터 단위로 구분했다.)

https://accounts.google.com/o/oauth2/v2/auth/oauthchooseaccount

?gsiwebsdk=3  -> Google Sign In 웹 SDK의 버전

&client_id={클라이언트 ID}  -> 애플리케이션의 클라이언트 ID

&scope=openid%20profile%20email  -> 요청하는 권한 범위. OpenId, 프로필, 이메일 주소에 대해 요청하고있다.

&redirect_uri={리다이렉트URI}  -> 사용자 인증이 완료되었을때 리디렉션할 URI

&prompt=consent  -> 사용자에게 동의를 요청하는 방법 지정. consent로 설정되어있어 권한 동의 창이 표시된다.

&access_type=offline  -> 액세스 유형을 나타냄. offline이므로 새로고침 토큰을 통해 사용자 데이터에 오프라인으로 액세스 가능

&response_type=code  -> 응답 유형을 나타냄. code로 설정되어있으므로 인증 코드를 받게됨

 

프론트엔드에서 소셜로그인 요청이 오면, 로그인 인증 과정은 구글에서 진행되므로 백엔드 서버에서는 회원 정보가 자체 DB에 있는지만 확인하고 있으면 해당 회원에 대한 AccessToken과 RefreshToken을 발급해주고 혹시 회원정보가 없을경우 필요한 정보만 저장하고 토큰을 발급해주는 방식으로 로직을 작성했다.

[코드작성]

* 이전에 진행했던 코드들의 내용이 포함되어있다. 필요한 경우 이전 내용을 참고할 수 있다.

 

1. 컨트롤러

"/login/oauth" 엔드포인트로 로그인 요청이 오면 소셜 로그인을 시도한다.

@RestController
@RequestMapping("/")
public class MemberController {
    private final MemberMapper mapper;
    private final MemberService memberService;
    private final OauthService oauthService;

    public MemberController(MemberMapper mapper, MemberService memberService, OauthService oauthService) {
        this.mapper = mapper;
        this.memberService = memberService;
        this.oauthService = oauthService;
    }

    ...

    @PostMapping("/login/oauth")
    public ResponseEntity googleLogInWithAuthorization(HttpServletRequest request, HttpServletResponse response) {
        response = oauthService.socialLogInWithAuthorization(request, response);
        return new ResponseEntity<>(HttpStatus.OK);
    }
    
    ...
}

 

2. OAuth 서비스

소셜 로그인 요청이 왔을때 실제 비즈니스 로직을 처리하는 클래스이다.

작성을 하다보니 OAuth2.0 라이브러리를 사용하지 않고 직접 액세스 토큰을 받아오고 해당 액세스토큰으로 리소스를 받아오는 방식으로 구현하게 되었다. 

RestTemplate를 사용해 Resource 서버와 Authorization 서버에 직접 HTTP 요청을 보내고 응답을 받아왔다.

 

-> 리팩토링: OAuth2.0 라이브러리를 사용해 회원 정보를 받아오는 로직을 구현해볼 예정이다.

-> 추가학습: 스프링5부터는 RestTemplate보다는 WebClient 사용을 권장한다고 한다. 해당 방식에 대한 학습이 필요하다.

@Slf4j
@Service
public class OauthService {
    private final RestTemplate restTemplate = new RestTemplate();
    private final MemberRepository memberRepository;
    private final JwtTokenizer jwtTokenizer;

    public OauthService(MemberRepository memberRepository, JwtTokenizer jwtTokenizer) {
        this.memberRepository = memberRepository;
        this.jwtTokenizer = jwtTokenizer;
    }

    @Value("${security.oauth2.google.token-uri}")
    private String tokenUri;

    @Value("${security.oauth2.google.resource-uri}")
    private String resourceUri;

    @Value("${security.oauth2.google.fe-uri}")
    private String feUri;

    @Value("${security.oauth2.google.fe-id}")
    private String feId;

    @Value("${security.oauth2.google.fe-secret}")
    private String feSecret;

    public HttpServletResponse socialLogInWithAuthorization(HttpServletRequest request, HttpServletResponse response) {
        log.info("==social log in==");
        String authorization = request.getParameter("authorization");

        GoogleUserInfo memberInfo = getUserResource(getAccessTokenWithAuthorization(authorization));

        Member member = memberRepository.findByLoginId(memberInfo.getEmail());

        // memberRepository에 회원정보 없으면 회원가입 후 토큰 발급, 있으면 바로 토큰 발급
        if (member == null) {
            Member newMember = new Member();
            newMember.setLoginId(memberInfo.getEmail());
            newMember.setMemberName(memberInfo.getName());
            newMember.setMemberType(Member.MemberType.GOOGLE);
            newMember.setMemberRole(Member.MemberRole.CUSTOMER);

            member = memberRepository.save(newMember);
        }

        String accessToken = jwtTokenizer.delegateAccessToken(member);

        response.setHeader("Authorization", "Bearer " + accessToken);
        response.addHeader("Set-Cookie", jwtTokenizer.createCookie(member).toString());

        return response;
    }

    public String getAccessTokenWithAuthorization(String authorizationCode) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("code", authorizationCode);
        body.add("client_id", feId);
        body.add("client_secret", feSecret);
        body.add("redirect_uri", feUri);
        body.add("grant_type", "authorization_code");

        HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(body, headers);

        ResponseEntity<AccessTokenResponse> responseEntity = restTemplate.exchange(
                tokenUri,
                HttpMethod.POST,
                requestEntity,
                AccessTokenResponse.class
        );

        if (responseEntity.getStatusCode() == HttpStatus.OK) {
            AccessTokenResponse accessTokenResponse = (AccessTokenResponse) responseEntity.getBody();
            return accessTokenResponse.getAccessToken();
        } else {
            throw new RuntimeException("Failed to get access token");
        }
    }

    public GoogleUserInfo getUserResource(String accessToken) {

        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(accessToken);

        RequestEntity<?> requestEntity = RequestEntity.get(resourceUri)
                .headers(headers)
                .build();

        RestTemplate restTemplate = new RestTemplate();
        ResponseEntity<GoogleUserInfo> responseEntity = restTemplate.exchange(
                requestEntity,
                GoogleUserInfo.class
        );

        if (responseEntity.getStatusCode().is2xxSuccessful()) {
            return responseEntity.getBody();
        } else {
            throw new RuntimeException("Failed to fetch Google user info");
        }
    }
}

 

[결과확인]

ngork을 통해 백엔드 서버를 열고 프론트엔드 서버에서 요청을 보냈을때 정상적으로 가입, 로그인, 토큰 발급까지 되는것을 확인했다.

 

참고자료

Spring Security와 OAuth2를 이용한 소셜 로그인

RestTemplate과 WebClient

SpringSecurity와  Oauth 2.0으로 로그인 구현하기 (SpringBoot + React)