IAS - Oauth2.0 SNS 로그인 기능 구현

IKNOW·2024년 2월 14일
0

See space

목록 보기
5/9

Authentication Server에 SNS 계정으로 로그인으로 잘 알려져 있는 Oauth2.0 로그인을 도입하기로 결정했다.

Authentication Server는 Restful 하게 설계하기로 결정하였기 때문에 Front와의 상호작용을 포함해야 정상적으로 동작하므로, 간단하게 js를 알고 있어야 더욱 이해하기 쉬울 것같다.
또한 전체적인 과정을 위의 시퀀스 다이어그램으로 표현하였으므로, 참고하여 확인하면 이해에 도움이 될 것같다.

1. 링크 요청

프론트는 BackEnd SpringBoot에게 플랫폼에 접근할 수 있는 Url을 요청한다.

const getOauthtUrl = (platform) => {
    authServerAxios.get(`/account/oauth/url/${platform}`)
        .then((res) => {
            console.log(res.data.url);
            window.location.href = res.data.url;
        })
        .catch(() => {
            alert('로그인에 실패하였습니다.\n잠시 후 다시 시도해주세요.');
        });
    }

2. 링크 전달

public class OauthAccountController {
    @GetMapping("/url/{platform}")
    public ResponseEntity<Map> getOauthUrl(@PathVariable String platform) {
        return oauthAccountService.getOauthUrl(platform);
    }
}
public class OauthAccountServiceImpl implements OauthAccountService {
	@Override
    public ResponseEntity<Map> getOauthUrl(String platform) {

        final String url = oauthAccountProperties.getUrl().get(platform);
        final String clientId = oauthAccountProperties.getClientId().get(platform);
        final String redirectUri = oauthAccountProperties.getRedirectUri().get(platform);

        String loginUrl = url + "?client_id=" + clientId + "&redirect_uri=" + redirectUri + "&response_type=code";
        return ResponseEntity.ok(Map.of("url", loginUrl, "platform", platform, "status", "success"));
    }
}

스프링에서는 전달받은 플랫폼에 해당하는 url을 작성하고 응답한다.

1~2에서 굳이 Vue에서 Spring으로 url을 요청하는 이유는 보안과 유지 보수 때문이다.

  • 현재는 clientId와 redirectUrl만 필요하기 때문에 보안적으로 크게 위험하진 않지만, 추후 플랫폼에서 제공하는 secret을 추가할때, secret을 노출하지 않기 위해 backend로 url을 요청한다.
  • 혹시 플랫폼이 업데이트 되며, url등이 변경된다면, 프론트의 코드를 수정할 필요 없이, 스프링의 코드 수정만으로 변경에 대응할 수 있다.

3. 플랫폼 로그인

프론트에서 전달받은 url로 접속하면

oauth 플랫폼이 제공하는 로그인 화면에 접속할 수 있고, 해당 플랫폼 로그인을 진행할 수 있다.

4. Redirect

플랫폼 로그인 화면에서 로그인을 마치게 되면, 먼저 설정하였던 리다이렉트 uri로 이동하게 된다.

이때!, query parameter로 Authorization code를 전달한다.

5. Authorization code를 Backend Server로 전달.

front에서는 이 Authorization code를 읽어서 스프링 서버로 보내준다.

onMounted(() => {
    authServer.get(`/account/oauth/callback/kakao`, {params: {code: code}, withCredentials: true})
    .then((res) => {
        authServer.defaults.headers.common['Authorization'] = `Bearer ${res.data.accessToken}`
        router.push("/")
    })
})

6~7. Platform으로 Authorization code 전달

public String getAccessToken(String platform, String code) {

        RestTemplate restTemplate = new RestTemplate();
        HttpHeaders headers = new HttpHeaders();

        headers.add("Content-type", "application/x-www-form-urlencoded");
        headers.add("Accept", "application/json");

        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("grant_type", "authorization_code");
        body.add("client_id", oauthAccountProperties.getClientId().get(platform));
        body.add("redirect_uri", oauthAccountProperties.getRedirectUri().get(platform));
        body.add("code", code);
        final String tokenRequestUrl = oauthAccountProperties.getTokenUrl().get(platform);
        HttpEntity<MultiValueMap> tokenRequest = new HttpEntity<>(body, headers);
        ResponseEntity<Map> response = restTemplate.exchange(tokenRequestUrl, HttpMethod.POST, tokenRequest, Map.class);
        return response.getBody().get("access_token").toString();

    }

여기서는 RestTemplate을 사용해서 Authorization code를 비롯해 요구하는 정보들을 담아 HTTP 요청을 하였고 그 결과로 플랫폼이 제공하는 access token과 refresh token을 비롯해 기본적인 정보를 얻을 수 있다.

8~9. access token을 사용한 추가 정보 요청

public String requestOauthId(String platform, String accessToken) {
        RestTemplate restTemplate = new RestTemplate();
        HttpHeaders httpHeaders = new HttpHeaders();

        HttpEntity<Map<String, String>> request = new HttpEntity<>(httpHeaders);
        httpHeaders.add("Authorization", "Bearer " + accessToken);

        ResponseEntity<Map> response = restTemplate.exchange(oauthAccountProperties.getUserInfoUrl().get(platform), HttpMethod.GET,request, Map.class);
        System.out.println(response.getBody())  ;
        return response.getBody().get("id").toString();
    }

나는 platform account의 unique id를 원했기 때문에 access token을 사용하여 추가 적인 정보를 요청하고 응답을 받았다.

10~12. unique id를 사용한 로그인 및 token 발행

@Override
public ResponseEntity<Map> login(String platform, String code) {
    String oauthAccessToken = getAccessToken(platform, code);
    String oauthId = requestOauthId(platform, oauthAccessToken);
		//현재 여기까지 진행

    Optional<OauthAccount> maybeAccount = accountRepository.findByOauthId(oauthId);
    OauthAccount account;
    if (maybeAccount.isEmpty()) {
        account = join(platform, oauthId);
    } else {
        account = maybeAccount.get();
    }
    String accessToken = issueToken(account);
    return ResponseEntity.ok(Map.of("accessToken", "Bearer " + accessToken, "status", "success"));
}

public String issueToken(OauthAccount account) {
    ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
    HttpServletResponse response = sra.getResponse();

    String accessToken = jwtService.generateAccessToken(account);
    String refreshToken = jwtService.generateRefreshToken(account);

    Cookie cookie = new Cookie("refreshToken", refreshToken);
    cookie.setHttpOnly(true);
    cookie.setPath("/");
    response.addCookie(cookie);
    return accessToken;
}

oauthId 까지 받아온 이후( 주석으로 처리된 부분까지)

Repository에서 동일한 OauthId가 존재하는지 먼저 확인하고 존재하지 않는다면 회원가입을 진행하고,

다음 Token을 발행한다.

여기서 발행하는 토큰은 Oauth 플랫폼이 제공하는 토큰이 아닌 자체 발행한 access token, refresh token이다. 자체 발행 토큰을 사용하는 이유는 혹시 모를 외부 공격에 있어 서버가 공격당할 경우 자체 토큰을 사용하므로써 추가 피해를 막기 위해서이다.

해당 토큰 발행과정(jwtService.generateToken())에서는 외부 Redis에 token을 accountId: token쌍으로 저장하는 로직이 포함되어 있다.

마지막으로 발행된 토큰의 경우 HttpServletResponse 를 사용해 Cookie를 담았고, ResponseEntity에 accessToken을 담아 응답하여 이로서 Restful backend server의 Oauth2.0 로그인 과정을 정리하였다.

해당 코드의 일체는
SpringBoot - iknow-authentication-server
Vue - iknow-main-frontend
에서 확인 할 수 있다!

profile
조금씩,하지만,자주

0개의 댓글

관련 채용 정보