[Spring Boot] OAuth GitHub, Google 로그인

naneun·2022년 5월 19일
8

Spring Boot

목록 보기
1/1
post-thumbnail

프로젝트 설계도

로그인 프로세스

Server <-> Resource Server

public interface OAuthService {

    default OAuthAccessToken requestAccessToken(String code) {
        return null;
    }

    default OAuthAccessToken renewAccessToken(Long userId) {
        return null;
    }

    default Member requestUserInfo(OAuthAccessToken accessToken) {
        return null;
    }
}
  • 다음은 OAuthService 의 구현체들이 공통적으로 수행해야하는 작업입니다.

    • OAuthAccessToken requestAccessToken(String code) : AccessToken 을 발급받습니다.

    • OAuthAccessToken renewAccessToken(Long userId) : AccessToken 을 갱신합니다.

    • Member requestUserInfo(OAuthAccessToken accessToken) : Resource Server 에서 사용자 정보를 조회합니다.

GitHubAccessToken 발급

        GitHubAccessToken gitHubAccessToken = webClient.post()
                .uri(accessTokenUri)
                .accept(MediaType.APPLICATION_JSON)
                .bodyValue(
                        GitHubAccessTokenRequest.of(
                                code,
                                clientId,
                                clientSecret
                        )
                )
                .retrieve()
                .bodyToMono(GitHubAccessToken.class)
                .blockOptional()
                .orElseThrow(GitHubOAuthException::new);

GoogleAccessToken 발급

        GoogleAccessToken googleAccessToken = webClient.post()
                .uri(accessTokenUri)
                .accept(MediaType.APPLICATION_JSON)
                .bodyValue(
                        GoogleAccessTokenRequest.of(
                                code,
                                clientId,
                                clientSecret
                        )
                )
                .retrieve()
                .bodyToMono(GoogleAccessToken.class)
                .blockOptional()
                .orElseThrow(GoogleAccessTokenException::new);
  • 각각 Resource Server 에서 응답 받은 결과 값을 인터페이스 스펙에 맞춰 변환해주는 작업이 필요합니다.

    • GitHubAccessToken, GoogleAccessToken -> OAuthAccessToken
    • GitHubUser, GoogleUser -> Member
@Service("github")
public class GitHubOAuthService implements OAuthService {
@Service("google")
public class GoogleOAuthService implements OAuthService {
private final Map<String, OAuthService> oAuthServices;

@GetMapping("/{resource-server}/callback")
public ResponseEntity<Void> login(@PathVariable("resource-server") String resourceServer,
                                String code, HttpServletResponse response) {

    OAuthService oAuthService = oAuthServices.get(resourceServer);
    ....
  • OAuthService 인터페이스 구현체의 빈 이름을 redirect url 인 /{resource-server}/callback 에서 {resource-server} 값과 일치시키면 login 메서드 하나로 모든 OAuth 로그인 처리를 수행할 수 있습니다.

Client <-> Server

  • jwt (json web token) 는 웹표준 (RFC 7519) 으로 전자 서명된 토큰입니다.

  • client, server 사이에서 주고 받는 토큰으로 server 에서 서명 된 jwt 생성하여 클라이언트에게 발행하고, client 에서 보관합니다.

    • local storage, cookie
  • client 는 발행 받은 토큰을 Authorization Header 담아 요청이 필요할 때마다 같이 전송합니다.

  • server 에서는 로그인 여부를 확인하기 위해 헤더에 담긴 jwt 를 검증하는 작업이 필요합니다.

  • 토큰 자체에 사용자의 권한 정보나 서비스를 사용하기 위한 정보가 포함됩니다.

    세션

    • 보안에 강합니다.
    • 서버의 확장성이 떨어지고, 서버의 자원을 사용하여 서버에 부담을 주게 됩니다.
      • 트래픽 분산을 위해 여러 대의 서버를 사용해야 한다면 사용자가 로그인 했을 때 만들어진 세션을 참조해야 하기 때문에 처음 로그인한 서버에만 요청을 보내야 한다는 단점이 있습니다.

토큰

  • 토큰이 유효한지만 체크하면 되기 때문에 어떤 서버로 요청을 보내도 상관이 없습니다.

jwt 구조

[header].[payload].[signature]
  • jwt 는 header, payload, signature 로 구성됩니다.
header

{
	"alg": 알고리즘 종류
    "typ": 토큰의 타입 
}
payload

{
	"iss": (Issuer): 발급자
    "sub": (Subject): 제목
    "aud": (Audience): 대상자
    "exp": (Expiration time): 만료 시간
    "nbf": (Not before): 활성 날짜
    "iat": (Issued At): 발급 시간
    "jti": (JWT Id): JWT 토큰 식별자 (issuer가 여러 명일 때 이를 구분하기 위한 값)
}

토큰에 담아 보내고자 하는 데이터를 클레임(claim) 이라고 하고, payload 에 key - value 형태로 저장합니다.

  • jwt 의 표준 스펙상 key 의 이름은 3글자로 되어있습니다.

  • 표준 스펙 외에도 claim 을 정의할 수 있습니다. (단, payload 에 중요한 정보를 담지 않습니다.)

    header 와 payload는 json 이 디코딩되어있을 뿐이지 특별한 암호화가 걸려있는 것이 아니기 때문에 누구나 jwt 를 가지고 디코딩을 한다면 header 나 payload 에 담긴 값을 알 수 있기 때문입니다. jwt 에서 header 와 payload 는 특별한 암호화 없이 흔히 사용할 수 있는 base64 인코딩을 사용하기 때문에 서버가 아니더라도 그 값들을 확인할 수 있습니다. 그래서 jwt 는 단순히 "식별을 하기 위한" 정보만을 담아두어야 하는 것입니다.

  • payload (데이터) 가 많아지면 토큰이 켜져서 요청 시 서버에 부담이 갈 수 있습니다.

  • 토큰이 사용자 정보가 DB 에서 갱신되더라도 해당 정보로 토큰을 재발급하지 않으면 적용되지 않는 문제가 있습니다.

  • 데이터를 자체적으로 가지고 있기 때문에 데이터를 얻기 위한 별도의 작업이 줄어 서버의 부담이 줄어들게 됩니다.

  • 토큰의 만료시간까지는 강제적으로 만료시킬 수 없으므로 탈취 당하면 만료 시까지 별다른 조치를 취할 수 없다는 단점이 존재합니다.

signature

시그니처에는 위의 헤더와 페이로드를 합친 문자열을 서명한 값입니다. 서명은 헤더의 alg 에 정의된 알고리즘과 secret key 를 이용해 생성하고 base64 url-safe 로 인코딩합니다. secret key 를 포함해서 암호화 되어있습니다.

public String issueAccessToken(Member member) {
    return JWT.create()
            .withAudience(member.getId().toString())
            .withIssuer(issuer)

            .withIssuedAt(Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant()))
            .withExpiresAt(Date.from(LocalDateTime.now().plusHours(1L).atZone(ZoneId.systemDefault()).toInstant()))

            .withClaim(SOCIAL_ID, member.getSocialId())
            .withClaim(RESOURCE_SERVER, member.getResourceServer().name())
            .withClaim(TOKEN_TYPE, ACCESS_TOKEN)

            .sign(algorithm);
}

public String issueRefreshToken(Member member) {
    return JWT.create()
            .withAudience(member.getId().toString())
            .withIssuer(issuer)

            .withIssuedAt(Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant()))
            .withExpiresAt(Date.from(LocalDateTime.now().plusWeeks(2L).atZone(ZoneId.systemDefault()).toInstant()))

            .withClaim(SOCIAL_ID, member.getSocialId())
            .withClaim(RESOURCE_SERVER, member.getResourceServer().name())
            .withClaim(TOKEN_TYPE, REFRESH_TOKEN)

            .sign(algorithm);
}
String jwtAccessToken = jwtTokenProvider.issueAccessToken(member);
String jwtRefreshToken = jwtTokenProvider.issueRefreshToken(member);

response.setHeader(ACCESS_TOKEN, jwtAccessToken);
response.setHeader(REFRESH_TOKEN, jwtRefreshToken);

redisService.saveJwtRefreshToken(member.getId().toString(), jwtRefreshToken);

응답결과

API 요청

HandlerInterceptor 인터페이스를 구현한 JwtInterceptor 을 통해 로그인 여부를 검사합니다.

  • 클라이언트는 Authorization header 에 Jwt Access Token 값을 설정하여 API 를 요청합니다.
  • 토큰의 유효 기간이 만료되었다면 (TokenExpiredException) 401 (UNAUTHORIZED) 에러코드로 응답합니다.
  • 그 외에 토큰의 유효성이 검증되지 않는다면 (JWTVerificationException - AlgorithmMismatchException, SignatureVerificationException, InvalidClaimException 포함) 403 (FORBIDDEN) 에러코드로 응답합니다.

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
    return verifyJwtToken(request, response);
}

private boolean verifyJwtToken(HttpServletRequest request, HttpServletResponse response) {
    String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

    if (!Strings.isNotBlank(authorizationHeader) || !authorizationHeader.startsWith(BEARER)) {
        response.setStatus(HttpStatus.FORBIDDEN.value());
        return false;
    }

    try {
        String accessToken = authorizationHeader.replaceFirst(BEARER, Strings.EMPTY).trim();
        DecodedJWT decodedJWT = jwtTokenProvider.verifyToken(accessToken);
        request.setAttribute(USER_ID, jwtTokenProvider.getUserId(decodedJWT));
    } catch (TokenExpiredException e) { // 해당 토큰의 기한이 만료되었다면?!
        response.setStatus(HttpStatus.UNAUTHORIZED.value()); // UNAUTHORIZED(401) 으로 응답한다.
        return false;
    } catch (JWTVerificationException ex) { // 유효한 토큰이 아니라면?!
        response.setStatus(HttpStatus.FORBIDDEN.value());
        return false;
    } 

    return true;
}

Jwt Access Token 기간 만료?!

@GetMapping("/update/jwt-access-token")
public void updateJwtAccessToken(@AccessTokenHeader String accessToken,
                                 @RefreshTokenHeader String refreshToken,
                                 HttpServletResponse response) {

    try {
        jwtTokenProvider.verifyToken(accessToken); // Access Token 한 번 더 검증!
    } catch (TokenExpiredException e) {
        DecodedJWT decodedJWT = jwtTokenProvider.decodeToken(refreshToken);
        Long userId = jwtTokenProvider.getUserId(decodedJWT);
        validateRefreshToken(refreshToken, redisService.getJwtRefreshToken(userId.toString()));
        Member member = memberService.findMember(userId);
        response.setHeader(ACCESS_TOKEN, jwtTokenProvider.issueAccessToken(member));
    }
}

private void validateRefreshToken(String refreshToken1, String refreshToken2) {
    if (refreshToken1.equals(refreshToken2)) {
        throw new JwtRefreshTokenException();
    }
}
  • 클라이언트가 Access Token 과 Refresh Token 을 각각 header 에 담아 Access Token 갱신 요청을 보냅니다.
  • Access Token 의 유효성 검사를 먼저 수행하합니다.
  • header 에 담긴 Refresh Token 에 담긴 정보를 사용하여 Redis 에 저장되어있는 Refresh Token 과 클라이언트가 보낸 Refresh Token 의 일치 여부를 확인합니다.
  • Access Token 을 갱신하여 클라이언트에게 응답합니다.
  • Refresh Token 또한 만료되었다면 사용자에게 로그인을 요구합니다.

  • Access Token 은 서버에 따로 저장해 둘 필요가 없지만, Refresh Token 의 경우 검증을 위해 서버에 저장해두어야합니다.
  • Refresh Token 을 이용한다는 것은 추가적인 I/O 작업이 필요하다는 것을 의미하며, 이는 I/O 작업이 필요 없는 빠른 인증 처리를 장점으로 내세우는 JWT 의 스펙에 포함되지 않는 부가적인 기술입니다.
  • Refresh Token 은 탈취되어서는 곤란하므로 클라이언트는 보안이 유지되는 공간에 이를 저장해두어야 합니다.
  • Refresh Token 은 서버에서 저장을 하고 있기 때문에 강제로 토큰을 만료시키는 것이 가능합니다.
public void saveJwtRefreshToken(String userId, String refreshToken) {
    ValueOperations<String, String> values = redisTemplate.opsForValue();
    values.set(userId, refreshToken, Duration.ofDays(14));
}

public String getJwtRefreshToken(String userId) {
        ValueOperations<String, String> values = redisTemplate.opsForValue();
    return values.get(userId);
}

public void removeJwtRefreshToken(String userId) {
    redisTemplate.delete(userId);
}

로그아웃

@GetMapping
public ResponseEntity<Void> login(@LoginId Long loginId) {
    redisService.removeJwtRefreshToken(loginId.toString());
    log.debug("loginId: {}", redisService.getJwtRefreshToken(loginId.toString()));
    return ResponseEntity.ok().build();
}

Version 1 - 어플리케이션이 서버 입장에서 jwt_refresh_token 을, 클라이언트 입장에서 oauth_access_token, oauth_refresh_token 을 저장하고 있도록 member 테이블에 칼럼 추가

->

Version 2 - jwt_refresh_token 칼럼을 삭제하고 해당 토큰은 Redis 를 사용하여 보관하도록 변경합니다.

Redis

https://aws.amazon.com/ko/elasticache/what-is-redis/

  • 데이터를 디스크 또는 SSD에 저장하는 대부분의 데이터베이스 관리 시스템과는 달리 모든 Redis 는 데이터를 서버의 주 메모리에 저장합니다. 디스크에 엑세스해야 할 필요가 없습니다.

  • 캐싱

    • 관계형 데이터베이스 앞단에서 Redis는 인 메모리 캐시를 생성하여 엑세스 지연 시간을 줄일 수 있습니다.
  • Redis를 사용하면 사용자가 다양한 데이터 유형에 매핑되는 키를 저장할 수 있습니다. 기본적인 데이터 유형은 String 이지만, 거의 모든 유형의 데이터가 Redis를 사용하여 인 메모리에 저장될 수 있습니다.

+ OAuth Refresh Token

  • GitHub


  • GitHub Resource Server 의 경우, 만료된 Access Token 을 받으면 Refresh Token 을 보내줍니다.

  • 응답 받은 Refresh Token 으로 다시 Resource Server 에 요청하여 Access Token 을 새로 발급 받을 수 있습니다.

  • Google


  • Google Resource Server 의 경우, Access Token 의 유효기간이 1시간 입니다.

  • Access Token 을 발급 받을 때 Refresh Token 도 함께 발급 받습니다. (별도의 요청 파라미터를 설정해야합니다.)

  • Spring Batch 를 사용하여 주기적으로 Access Token 을 갱신할 수 있습니다.

GitHub Repository: https://github.com/naneun/2-weeks-of-individual-study


https://velog.io/@_woogie/JWT-%EB%A1%9C%EA%B7%B8%EC%9D%B8%EB%B0%A9%EC%8B%9D-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-feat.-session%EC%97%90%EC%84%9C-jwt%EB%A1%9C

https://velog.io/@junghyeonsu/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%90%EC%84%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8%EC%9D%84-%EC%B2%98%EB%A6%AC%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95

profile
riako

2개의 댓글

comment-user-thumbnail
2022년 7월 6일

Good!

1개의 답글