현재 내가 소마에서 진행 중인 프로젝트는 jwt를 활용중이며, rdb에 refresh token을 저장하고 재발급에 활용하고 있다. 시퀀스 다이어그램으로 보면 다음과 같은 구조다.

 

(참고 : refresh token을 db에 저장한 뒤 클라이언트가 재발급 요청 시 보내진 refresh token과 대조하는 이유 = 악의적 사용자가 지 맘대로 만든 refresh token으로 재발급 요청해서 access token을 받으려 하는걸 막을 수 있고, 악의적 사용자가 refresh token을 탈취했다고 해도 우리 쪽에서 db에 있는 refresh token을 없애준다든가 하는 식으로 조치를 취해줄 수도 있고,, 로그아웃 구현 시에도 편리하고.. 등등)

 

그러나 이 방법의 단점을 꼽자면 다음과 같은 부분들이 있다

 

  1. rdb에서 refresh token을 가져오는데 시간이 좀 걸린다
  2. rdb에 저장된 토큰데이터들 중 만료기간이 지난 토큰들을 개발자가 코드를 작성하든 뭐 하든 해서 직접 삭제시켜줘야한다

 

이를 인메모리 데이터베이스인 redis를 사용하면, 해당 단점들을 개선할 수 있다

 

  1. 우선 메모리에 저장하기 때문에 rdb대비 토큰을 가져오는 속도가 빠를 것이고
  2. ttl을 설정해주면 만료기간 지난 토큰들은 알아서 없어진다

 

따라서 refresh token을 관리하던 방법을 rdb에서 redis로 바꾸기로 했다. 물론 인메모리 데이터베이스인 만큼 꺼지면 다 날라가긴 하지만, refresh token은 날라가도 크리티컬한 피해는 없다. 번거로울 수 있지만 다시 로그인하면 되는 거니까.

 


우선 AWS에서 ElastiCache로 Redis를 쓸 거다. 문제는 ElastiCache는 같은 VPC에서만 접속하는걸 허용해서 로컬에서 접근할 수 없다는 것. 그러나 ssh 터널링 등을 통한 방법으로 로컬에서도 ElastiCache에 접근이 가능하다. 이건 내가 쓴 글이 있으니 링크를 달겠다.

 

https://jofestudio.tistory.com/110

 

[AWS] SSH 포트포워딩 및 SSM을 통해서 로컬에서 Private Subnet에 있는 RDS 접근하기

소마 프로젝트를 진행하면서, 인프라 쪽을 내가 어느 정도 담당하고 있어서(사실 담당한다고 하기에도 민망할 정도로 기본적인 부분들만 담당하긴 하지만) RDS를 띄워보기로 했다. Private Subnet에

jofestudio.tistory.com

 

Bastion Host를 통해 접근하는 방식이므로 ElastiCache에 달아주는 보안그룹에는 Bastion Host 쪽에서 오는 6379포트에 대한 인바운드를 열어줘야 한다.

 

스프링부트에선 다음과 같은 의존성을 추가한다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

 

그 다음 application.properties에 다음과 같이 작성해준다(application.yml을 사용한다면 그에 맞춰 수정하면 됨)

spring.data.redis.host=localhost
spring.data.redis.port=6379

 

참고로 로컬에서 ssh 포트포워딩을 통해 elastiCache에 접속하는 중이므로 host는 localhost로 한 것이다.

그리고 다음과 같은 Configuration Class를 만들어준다.

 

@Configuration
public class RedisConfig {
    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate =  new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());

        // 일반적인 key:value의 경우 시리얼라이저
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());

        return redisTemplate;
    }
}

 

  • RedisConnectionFactory : Redis 서버와의 연결을 만들고 관리하는 빈
  • RedisTemplate : Redis와의 상호작용을 돕는 빈. 즉 얘를 핸들링하여 레디스와 짝짝꿍하는 것
  • Serializer설정 : RedisTemplate의 디폴트 Serializer는 JdkSerializationRedisSerializer인데, 문제는 redis-cli를 통해 개발자가 직접 redis안에 있는 애들을 볼 때 Jdk직렬화된 애들은 그 값을 도무지 알아먹을 수가 없어서, 편의를 위해 StringRedisSerializer로 설정(이렇게 하면 String값 그대로 Redis에 저장됨)

 

참고 : 직렬화(seriailize) = 객체 등을 바이트 스트림 형태의 연속적인 데이터로 변환하는 것. 반대는 역직렬화(deserialize)

 

그리고 다음과 같은 RefreshTokenService를 만들어준다.

@RequiredArgsConstructor
@Service
public class RefreshTokenService {
    private final RedisTemplate<String, Object> redisTemplate;
    private final Long REFRESH_TOKEN_EXPIRE_TIME = 60 * 60 * 24 * 30L;

    public void saveRefreshToken(String email, String refreshToken) {
    	// 이메일을 key로, 토큰값을 value로
        redisTemplate.opsForValue().set(email, refreshToken, REFRESH_TOKEN_EXPIRE_TIME, TimeUnit.SECONDS);
    }

    public String getRefreshToken(String email) {
        Object refreshToken = redisTemplate.opsForValue().get(email);
        return refreshToken == null ? null : (String) refreshToken;
    }

    public void deleteRefreshToken(String email) {
        redisTemplate.delete(email);
    }
}

 

나는 Refresh Token의 유효기간을 1달로 잡았기 때문에, 60 * 60 * 24 * 30 = 30일을 유효기간으로 설정해 저장하도록 구현했다. 분이 아니라 다른 시간 단위로도 저장 가능하니, 본인 취향따라 하면 된다.(TimeUnit.SECONDS 이쪽)

 

이제 이를 활용해 원래 rdb를 사용하던 로직을 바꿔줬다.

 

  • 로그인 후 jwt(access token, refresh token)을 생성하고 refresh token을 rdb에 저장하던 것 => refresh token을 redis로 저장. 이 때 redis에 기존에 쓰던 refresh token이 있으면 덮어씌운다
  • 토큰 재발급 요청시 rdb에서 refresh token가져오던 것 => redis에서 가져온다
  • 토큰 재발급 후 재발급된 refresh token을 rdb에 저장하던 것 => redis로 저장. 이 때 redis에 기존에 쓰던 refresh token이 있으면 덮어씌운다

 

이제 로그인 요청, 인가인증이 필요한 요청, 토큰 재발급 요청에 대한 수행과정들을 시퀀스 다이어그램으로 표현하면 다음과 같아진다.

 

 

ppt로 힘들게 만드러따..

아 그리고 이 과정에서 추가적으로 고민했던 게, 토큰재발급 요청을 인증된 유저만 받을지였다. 구글링해서 나오는 다른 예제들을 보면 재발급 요청은 인증을 안 해도 가능하게끔 하는 예제가 대부분이었는데, 나는 고민 끝에 재발급 요청도 인증을 한 유저만 할 수 있게끔 했다. 왜냐하면, 당연히 보안을 위해서. 인증할 필요없이 refresh token만 덜렁 보내서 재발급받을 수 있다면, refresh token이 탈취당하면 너도나도 내 refresh token으로 재발급을 무료로 시도할 수 있기 때문이다. 

 

인증은 access token을 통해 이뤄지며, 재발급 요청은 우리 플젝에선 앱 접속시 자동로그인을 위한 재발급을 위할 때가 아니라면 access token이 만료됐을 때 이뤄질 거다. 만료된 토큰은 검증과정에서 ExpiredJwtException을 뱉을 텐데, 그러면 만료된 토큰에 대한 인증을 어떻게 할 수 있는가?

 

처음에 생각한 것은 임시 통행증(Authentication)을 끊어주는 거였다. 스프링 시큐리티는 ContextHolder에 Authentication이 들어있다면 인증됐다고 판단하므로, ExpiredJwtException이 터졌을 때 재발급경로로 요청이 온 것이라면(이는 request객체를 까서 판단 가능) 임시로 Authentication을 만들어 ContextHolder에 넣어주면 되는거다. 

try {
	jwtProvider.validateAccessToken(accessToken);
} catch (ValidTimeExpiredJwtException ex) {
	// access token이 만료됐지만 재발급하는 요청에 대한 처리 - 임시로 인증됐다고 처리한다
	if (request.getRequestURI().equals(REISSUE_PATH)) {
		setTemporaryAuthenticationToContextHolder();
		return;
	}
	request.setAttribute("exception", ex.getMessage());
	return;
} catch (CustomJwtException ex) {
	request.setAttribute("exception", ex.getMessage());
	return;
}
    private void setTemporaryAuthenticationToContextHolder() {
        // 임시 권한 생성
        List<GrantedAuthority> temporaryAuthorities = new ArrayList<>();
        temporaryAuthorities.add(new SimpleGrantedAuthority(ROLE_USER.name()));
        // 임시 통행증 발급
        Authentication authentication = new UsernamePasswordAuthenticationToken(
                "temporaryAuthentication", "", temporaryAuthorities
        );
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

 

이렇게 해도 잘 동작한다. 하지만! 나중에 소마 내 다른 연수생이 작성한 코드들을 몰래보다가, ExpiredJwtException의 인스턴스로부터 jwt클레임을 추출 가능하다는 놀라운 사실(!)을 깨달았다. 따라서 단순히 유효기간이 만료된 토큰이라면 정상적인 토큰에서 claim을 빼내는 것처럼 claim을 빼낼 수 있다.

    private String extractEmailFromToken(String accessToken) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(accessKey)
                    .build()
                    .parseClaimsJws(accessToken)
                    .getBody()
                    .getSubject();
        } catch (ExpiredJwtException ex) {
            return ex.getClaims().getSubject();
        }
    }

역시 남의 코드 보면서 배우는게 참 많아.. 암튼, 이를 통해 ExpiredJwtException이 터지면 재발급 요청인지를 판단하고, 재발급 요청이라면 기존 인증 절차와 마찬가지로 동작시킬 수 있다.

 


이런 과정들을 거쳐 redis를 도입했다. 그러면 속도는 얼마나 개선됐을까?

 

RDB(AWS RDS)만 사용했을 땐 평균 180ms정도가 걸렸으나,

 

Redis(AWS ElastiCache)를 사용하고나선 평균 100ms정도가 걸리게 됐다. 약 80ms정도 속도가 빨라진 것이다.

 

아직 학생 수준인 내게 80ms 정도라는 숫자가 의미가 있는지는 모르겠다. 그러나 트래픽이 많아질수록, 단축된 시간 X 요청횟수가 실제적인 성능 개선이라고 생각한다면 의미있는 개선을 이뤄낸게 아닐까싶다. okky에 문의한(?) 결과 운영자분께서 elastiCache를 통해 80ms 정도 개선한 것이면 잘 한 것이라고 해서 나름 뿌듯함.

 

굳이 레디스를 도입했어야 하는가? 라는 질문도 받을 수 있다. 맞는 말이다. 일단 배포를 안 한 만큼 재발급 요청에 대한 트래픽이 현재 없는 상태인데 굳이 80ms 높이겠다고 레디스? + 사용자 입장에서도 체감되는 개선인가? 라는 질문도 추가적으로 받을 수 있겠다. 한마디로, 지금 레디스를 도입하는 건 오버 엔지니어링이 아니겠냐는 말로 정리할 수 있겠다

 

그러나 속도 측면 말고도 유효기간이 만료된 토큰이 개발자가 뭘 하지 않아도 레디스에서 날라간다는 점에서 개인적으로 나름의 의미는 있다고 생각한다. 또한 학생 신분인 내가 레디스를 이렇게 처음 접하게 된 것도 의미가 충분한다. 마지막으로 돈 문제. ElastiCache 그거 돈 들지 않느냐! 굳이 레디스 필요없는데 왜 레디스 달아서 돈 나가게 하냐! 

 

당연히 실무라면, 혹은 내가 실제로 이 서비스를 운영하고 있는 단계면 어떻게든 돈 적게 쓸라고 쥐어짰겠지만, 난 지금 소마에서 지원금을 받으면서 플젝을 하고 있는 상태. 돈이 좀 드는 AWS 서비스들을 이것저것 써보면서 학습하는게 스스로한테 좋을 터. 그렇기 때문에 Redis를 이렇게 써보는게 의미가 있다고 생각한다. 허허헣

 

 

 

 

 

 

현재 프로젝트를 하면서 소셜로그인 기능을 만드는 중이다. 구글 & 카카오 & 애플 로그인을 만들고 있으며 마지막에 개발하게 된 놈이 바로 애플.

 

애플 디벨로퍼 설정은 다음 블로그를 참고했다

 

https://dalgoodori.tistory.com/49

 

[Flutter] Apple Login ① - 준비

마지막으로 구현할 소셜로그인은 애플 로그인 입니다. 애플 로그인을 구현하기 위해서는 개발자 계정에 가입되어있어야하고 비용은 1년에 13만원 입니다. ㅠㅠ Apple Developer There’s never been a bette

dalgoodori.tistory.com

 

 

또한, 플러터에서 애플로그인하는 기능은 다음 패키지를 활용했다

 

https://pub.dev/packages/sign_in_with_apple

 

sign_in_with_apple | Flutter Package

Flutter bridge to initiate Sign in with Apple (on iOS, macOS, and Android). Includes support for keychain entries as well as signing in with an Apple ID.

pub.dev

 

설정방법도 꽤나 상세히 나와있으니, 플러터로 애플로그인 구현하시는 분들은 저 2개 링크타고 가서 보면 될듯

 


암튼 뭐 그렇게 해서 플러터에서 애플로그인하는 기능은 만들었다. 구글 & 카카오 로그인 만들 때는 access token을 바로 플러터 단에서 얻게 되는 방식이었는데, 애플은 access token이 아니라 authorization codeidentity token이란 걸 준다.

 

나는 현재 프론트 단에서 access token을 받아서 그걸 백엔드로 넘긴 뒤 백엔드에서 해당 토큰을 통해 유저 정보를 조회하는 방식으로 만들고 있었기 때문에, 발급받은 identity token으로 사용자 정보를 조회할 수 있는 엔드포인트가 있나를 먼저 살폈다(구글, 카카오는 access token 통해서 사용자 정보 조회하는 엔드포인트가 있기 때문..) 그러나! 애플은 그런 엔드포인트가 없었다.. 애플 개발자문서에도 지들은 그런 엔드포인트 없다고 말하고 있음. ㅋㅋ

 

그 대신에 처음에 로그인 성공했을 때 authorization code랑 identity token말고도 로그인한 유저의 정보(이메일 등)을 주긴 준다. 이 정보를 백엔드로 보낸다면? 이란 생각이 들었으나, 바로 철회했다. 지금 플젝에서 어차피 보내게 될 정보야 끽해야 이메일인데, 사실상 이메일과 플랫폼명(애플, 카카오 등)을 받아서 로그인해주는 api를 만드는 건 보안상 매우 위험하기 때문. 공격자들이 내 이메일을 안다면, 지들도 로그인하는 엔드포인트로 내 이메일이랑 플랫폼명만 보내면 로그인된다면 그 서비스를 누가 이용하겠는가. 사실 구글, 카카오 로그인 만들 때도 같은 이유로 프론트 단에서 회원정보가 아니라 플랫폼들로부터 받은 access token을 넘기게 했던 거고.

 

그러면 어떻게 해야 할까? 애플로부터 발급받은 identity token에는 공급자, 대상자, 유효시간, 사용자 이메일과 같은 정보들도 들어있다. 따라서 identity token을 백엔드로 보내고 백엔드에서 사용자 이메일을 꺼내서 사용해야겠다는 생각이 들었다.

 

 

전달해주는건 쉽지. 그러면 어떻게 꺼내야 하는가? identity token은 일단 jwt형식이어서, 이 토큰을 만들 때 사용했던 키를 몰라도 디코딩해서 내용을 볼 수 있다. 애플이 지들 키 써서 만든 토큰인데 당연히 내가 그 키를 알 방법은 없고, 직접 디코딩해서 보는 식으로 해야겠군! 이라는 생각이 들었다. split메서드와 Base64 decoder를 이용해 디코딩해서 금방 email값을 뽑아낼 수 있었다.

 

근데 이 방법, 생각해보니 보안적으로 위험하다. 우선 구글과 카카오에서 썼던 "토큰을 동봉해 유저정보를 조회하는 엔드포인트로 요청을 보내서 받는 방식"은 내부적으로 그 토큰이 유효한 토큰인지 검증해줄 것이다. 근데 지금 내가 애플에서 온 토큰을 직접 까는 이 방식은 요 토큰이 유효한 토큰인지, 즉 제대로 된 토큰인지 검증하지 않는다. 한마디로 악의적 사용자가 대충 내 이메일을 알아낸다음 내 이메일을 jwt로 만들어서 우리 백엔드의 애플로그인 엔드포인트로 요청을 보내면 로그인이 되는 말도 안 되는 상황이 연출될 수 있다.

 

한마디로, 넘겨받은 identity token이 합법적인 토큰인지 검증할 필요가 있다. 찾아보니 공식 문서에 해당 내용에 대한 글이 있었다. 다행쓰..

 

애플은 다음과 같이 5가지 스텝을 통해 identity token을 우리가 알아서(?) 검증하라고 한다

 

https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/verifying_a_user

 

애플의 공개키로 identity token의 서명을 확인하라는 내용을 볼 수 있다. 아니 근데 애플 양반 당신들이 쓰는 키를 내가 어떻게 알아!라고 말하고 싶지만 보다시피 공개키다 공개키. 

 

애플은 다음 링크에 있는 엔드포인트를 통해 자기들이 현재 시점에서 사용중인 키들의 정보를 제공한다. 허허허..

 

https://developer.apple.com/documentation/sign_in_with_apple/fetch_apple_s_public_key_for_verifying_token_signature

 

Fetch Apple’s public key for verifying token signature | Apple Developer Documentation

Fetch Apple’s public key to verify the ID token signature.

developer.apple.com

 

다음과 같은 형식일 것이다.

 

{
  "keys": [
    {
      "kty": "RSA",
      "kid": "YuyXoY",
      "use": "sig",
      "alg": "RS256",
      "n": "1JiU4l3YCeT4o0gVmxGTEK1IXR-Ghdg5Bzka12tzmtdCxU00ChH66aV-4HRBjF1t95IsaeHeDFRgmF0lJbTDTqa6_VZo2hc0zTiUAsGLacN6slePvDcR1IMucQGtPP5tGhIbU-HKabsKOFdD4VQ5PCXifjpN9R-1qOR571BxCAl4u1kUUIePAAJcBcqGRFSI_I1j_jbN3gflK_8ZNmgnPrXA0kZXzj1I7ZHgekGbZoxmDrzYm2zmja1MsE5A_JX7itBYnlR41LOtvLRCNtw7K3EFlbfB6hkPL-Swk5XNGbWZdTROmaTNzJhV-lWT0gGm6V1qWAK2qOZoIDa_3Ud0Gw",
      "e": "AQAB"
    },
    {
      "kty": "RSA",
      "kid": "fh6Bs8C",
      "use": "sig",
      "alg": "RS256",
      "n": "u704gotMSZc6CSSVNCZ1d0S9dZKwO2BVzfdTKYz8wSNm7R_KIufOQf3ru7Pph1FjW6gQ8zgvhnv4IebkGWsZJlodduTC7c0sRb5PZpEyM6PtO8FPHowaracJJsK1f6_rSLstLdWbSDXeSq7vBvDu3Q31RaoV_0YlEzQwPsbCvD45oVy5Vo5oBePUm4cqi6T3cZ-10gr9QJCVwvx7KiQsttp0kUkHM94PlxbG_HAWlEZjvAlxfEDc-_xZQwC6fVjfazs3j1b2DZWsGmBRdx1snO75nM7hpyRRQB4jVejW9TuZDtPtsNadXTr9I5NjxPdIYMORj9XKEh44Z73yfv0gtw",
      "e": "AQAB"
    },
    {
      "kty": "RSA",
      "kid": "W6WcOKB",
      "use": "sig",
      "alg": "RS256",
      "n": "2Zc5d0-zkZ5AKmtYTvxHc3vRc41YfbklflxG9SWsg5qXUxvfgpktGAcxXLFAd9Uglzow9ezvmTGce5d3DhAYKwHAEPT9hbaMDj7DfmEwuNO8UahfnBkBXsCoUaL3QITF5_DAPsZroTqs7tkQQZ7qPkQXCSu2aosgOJmaoKQgwcOdjD0D49ne2B_dkxBcNCcJT9pTSWJ8NfGycjWAQsvC8CGstH8oKwhC5raDcc2IGXMOQC7Qr75d6J5Q24CePHj_JD7zjbwYy9KNH8wyr829eO_G4OEUW50FAN6HKtvjhJIguMl_1BLZ93z2KJyxExiNTZBUBQbbgCNBfzTv7JrxMw",
      "e": "AQAB"
    }
  ]
}

 

즉 정상적인 경우라면, 내가 아까 발급받은 identity token은 위에 있는 키 후보들(?) 중 하나로 만들어진 셈이다. 즉 위 후보들 중 실제로 내가 받은 identity token을 만들 때 써진 키를 찾아야 한다.

 

근데 어떻게 찾느냐? identity token은 아까 말했듯 jwt다. jwt의 header 부분에는 어떤 알고리즘을 썼는지에 대한 정보들이 있는데, identity token도 그와 마찬가지로 헤더에 관련 정보들이 있다. 구체적으로는 alg값과 kid값이 들어있다.

 

근데 위에 있는 후보 키들을 보면, 각각 kid값과 alg값을 갖고 있는 걸 볼 수 있다. 즉, identity token의 header에 들어있는 alg & kid와 동일한 값을 갖는 후보 키가 identity token을 만들 때 쓰인 키다. 

 

말은 쉽지~ 코드를 보여줘! 난 다음과 같이 구현했다. 전체 코드가 아닌 메서드들만..허헣

 

    // identity token의 헤더에서 alg, kid 추출해서 저장하는 메서드
    private Map<String, String> getAlgAndKidFromIdToken(String idToken) throws ParseException {
        Map<String, String> algAndKid = new HashMap<>();

        String header = idToken.split("\\.")[0];
        Base64.Decoder decoder = Base64.getUrlDecoder();
        JSONObject headerContent = (JSONObject) jsonParser.parse(
                new String(decoder.decode(header))
        );

        algAndKid.put("alg", (String) headerContent.get("alg"));
        algAndKid.put("kid", (String) headerContent.get("kid"));

        return algAndKid;
    }

    // 애플의 공개키들을 받는 엔드포인트로부터 키 후보들을 가져오는 메서드
    private JSONArray getAvailablePublicKeyObjects() throws ParseException {
        HttpEntity<String> httpEntity = new HttpEntity<>(new HttpHeaders());
        ResponseEntity<String> res = restTemplate.exchange(
                APPLE_PUBLIC_KEY_URL, HttpMethod.GET, httpEntity, String.class);
        JSONObject availablePublicKeysContent = (JSONObject) jsonParser.parse(res.getBody());
        return (JSONArray) availablePublicKeysContent.get("keys");
    }

    // 키 후보들과 identity token에서 뽑아낸 alg, kid를 비교해 매칭되는 키를 찾는 메서드
    private JSONObject findMatchedPublicKeyObj(JSONArray availablePublicKeyObjects, String alg, String kid) {
        if (availablePublicKeyObjects == null || availablePublicKeyObjects.size() == 0) {
            throw new AppleKeyInfoNotReceivedException();
        }

        for (JSONObject keyObj : (Iterable<JSONObject>) availablePublicKeyObjects) {
            String algFromKey = (String) keyObj.get("alg");
            String kidFromKey = (String) keyObj.get("kid");

            if (Objects.equals(algFromKey, alg) && Objects.equals(kidFromKey, kid)) {
                return keyObj;
            }
        }

        return null;
    }
    
    // 찾아낸 후보 키(jsonObject)를 공개키 인스턴스로 만드는 메서드
    private PublicKey generatePublicKey(JSONObject applePublicKeyObj) {
        if (applePublicKeyObj == null) {
            throw new MatchedKeyNotFoundException();
        }

        String kty = (String) applePublicKeyObj.get("kty");
        byte[] modulusBytes = Base64.getUrlDecoder().decode((String) applePublicKeyObj.get("n"));
        byte[] exponentBytes = Base64.getUrlDecoder().decode((String) applePublicKeyObj.get("e"));

        RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(
                new BigInteger(1, modulusBytes),
                new BigInteger(1, exponentBytes)
        );

        try {
            KeyFactory keyFactory = KeyFactory.getInstance(kty);
             return keyFactory.generatePublic(publicKeySpec);
        } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
            throw new ApplePublicKeyNotGenerateException();
        }
    }

 

이렇게 만든 키를 활용해, identity token의 서명을 확인할 수 있을 것이다. 한 마디로 이 키로 합법적으로 토큰을 깔 때(?) 오류가 안 나야 한다는 말.

 

그 뒤에 nonce검증이 있는데, 이건 패쓰.. nonce값이 토큰에 안 담겨있어서,,허허

 

iss, aud 검증은 다음과 같이 했다. 단순히 equal 비교

 

    public void verifyAppleIdTokenClaim(Claims claims) {
        if (!claims.getIssuer().equals(issuer)) {
            throw new IssuerNotMatchedException();
        }

        if (!claims.getAudience().equals(clientId)) {
            throw new ClientIdNotMatchedException();
        }
    }

 

유효기간 만료의 경우 이미 키를 통해 claims를 얻는 과정이 잘 통과되면 검증된 것과 마찬가지여서 굳이 넣지는 않았다. 필요하다면 claims의 getExpiration과 before같은 걸 조합해서 만들 수 있을 것이다.

 

읽기 전에 : 해당 글은 스프링 시큐리티 6.1.2버전 기준으로, 읽는 시점에 따라 다른(deprecate됐다든지) 내용일 수도 있습니다.


어떠한 요청을 보냈을 때, 요청을 보낸 이에 대한 인가/인증이 수반돼야 한다. 이때

 

인증(Authentication) : 자원에 접근하는 사용자의 신원을 검증하는 것(너 누군데?)

인가(Authorization) : 사용자가 그 자원에 대해 접근할 수 있는지 확인하는 것(누군지 알겠는데, 너가 그런 권한이 있어?)

 

그렇다면 스프링 진영에서 보안을 담당하는 대표적인 스프링 하위 프레임워크인 스프링 시큐리티에서는 인증과 인가를 어떤 방식으로 해줄까? 이에 대해 알아보자


그 전에 알고 가면 좋은 지식

SecurityContextHolder

 

https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html#servlet-authentication-securitycontextholder

 

스프링 시큐리티가 인증된 사용자의 정보(Authentication)을 SecurityContext에 저장하는데 이 놈을 가지고 있는 바구니가 바로 SecurityContextHolder다. 한 마디로 여기에 사용자의 정보(Authentication)이 있으면 인증된 것으로 취급하며, 나중에 이렇게 담은 정보(Authentication)을 통해 인가를 한다. 참고로 어떻게 넣는지는 상관없이 걍 여기에 정보(Authentication)가 들어있기만 하면 된다.

 

 

Authentication

Spring Security에서 발급해주는 통행증이라고 생각하면 된다. 이 통행증은 다음과 같은 내용들을 가진다

 

  • Principal : 보안 주체에 해당하는 개념으로, 쉽게 말해 "누구냐"에 해당. 보통 UserDetails라는 "사용자의 정보가 담긴 문서"가 이 역할을 한다.
  • Credential : 주로 비밀번호
  • Authorities : Principal이 뭘 할 수 있는지에 대한 권한, 즉 Principal에게 부여된 권한들이며 GrantedAuthority들이 이 역할을 한다

 


인증

우선 사용자를 인증하는 일반적인 방식은 사용자로부터 하여금 이름과 비밀번호를 입력하도록 요구하는 것이다. 이를 통해 인증이 수행되면 ID(세션 ID)를 발급하고, 이를 통한 인증을 수행할 수 있다. 스프링 시큐리티 역시 이름과 비밀번호를 통한 인증 방식을 지원하며, 다음과 같은 과정을 거쳐 진행된다.

 

https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/basic.html

 

 

  1. BasicAuthenticationFilter(폼로그인의 경우, UsernamePasswordAuthenticationFilter가 이 역할을 함)가 클라이언트로부터 온 HttpServletRequest로부터 username과 password를 뽑아낸다
  2. 뽑아낸 내용을 바탕으로 UsernamePasswordAuthenticationToken(Authentication의 일종)이란 걸 만든다
  3. 사용자를 인증하기 위해 이 토큰을 AuthenticationManager란 놈에게 보낸다
  4. Authenticatoin이 이 토큰을 바탕으로 "인증"을 시도한다
  5. 인증에 실패하면 -> SecurityContextHolder가 비워지고, RememberMeServices의 loginFail이 호출되며, AuthenticationEntryPoint가 호출된다
  6. 인증에 성공하면 -> 해당 인증 정보가 SecurityContextHolder에 설정 즉 저장되고, RememberMeServices의 loginSuccess가 호출되고 filterChain.doFilter를 호출해 남은 로직(다음 필터들에서 진행될 것들)을 수행한다

 

근데 생각해보면.. 매번 요청을 보낼 때마다 이름과 비밀번호를 함께 보내는 방식으로 인증받는 건 당연히 번거롭다. 최초 로그인할 때만 이렇게 하고 SecurityContext를 세션(HttpSession)에 저장한뒤 세션 id를 클라이언트에게 발급한다. 이후 다시 접속하면 세션으로부터 SecurityContext를 가져와 SecurityContextHolder에 꽂아준다. 따라서 해당 SecurityContext가 Authentication을 갖고 있으면 인증상태가 유지된다. (앞서 말했듯, SecurityContext에 Authentication이 들어있어야 인증된 상태라고 취급하기 때문이다)

 

 

여기서 내가 의문이 든 것은, 로그인 이후의 요청에 대해 왜 다시 SecurityContext를 다시 SecurityContextHolder에 꽂아주는가였다. 이미 로그인할 때 Authentication이 들어있는 SecurityContext를 SecurityContextHolder에 넣어줬을텐데? 

하지만 이유는 간단했다. 사용자가 보낸 request를 처리하는 스레드가 종료되면 SecurityContextHolder를 비워주기 때문이다. 그래서 다시 세션으로부터 SecurityContext를 가져와 SecurityContextHolder에 꽂아주는거다.

 

코드레벨에서 보면 쉽게 파악할 수 있다.

 

// 간단히 말하면 필터체인의 앞단에서 SecurityContext를 로드하는 걸 담당하는 필터
public class SecurityContextHolderFilter extends GenericFilterBean {
	// ..생략
	private final SecurityContextRepository securityContextRepository;
	// ..생략

	private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws ServletException, IOException {
		// 대충 처음 접속하면 이 if조건문의 doFilter가 실행됨
		if (request.getAttribute(FILTER_APPLIED) != null) {
			chain.doFilter(request, response);
			return;
		}
		request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
		// SecurityContext를 가져오는 모습
		Supplier<SecurityContext> deferredContext = this.securityContextRepository.loadDeferredContext(request);
		try {
			this.securityContextHolderStrategy.setDeferredContext(deferredContext);
			chain.doFilter(request, response);
		}
		finally {
			// 마지막에 SecurityContextFilter를 비워내는 모습!
			this.securityContextHolderStrategy.clearContext();
			request.removeAttribute(FILTER_APPLIED);
		}
	}
    
	// ..생략

 

다음과 같이 Session에서 SecurityContext를 가져오는 모습도 코드레벨에서 훔쳐볼 수 있다

 

public class HttpSessionSecurityContextRepository implements SecurityContextRepository {
	// ..생략

	@Override
	public DeferredSecurityContext loadDeferredContext(HttpServletRequest request) {
		// 세션으로부터 SecurityContext를 가져오는 모습
		Supplier<SecurityContext> supplier = () -> readSecurityContextFromSession(request.getSession(false));
		return new SupplierDeferredSecurityContext(supplier, this.securityContextHolderStrategy);
	}
    
	// ..생략

 

 

참고로, SecurityContextHolder는 ThreadLocal을 사용한다. 즉 SecurityContextHolder는 여러 쓰레드가 공유하는 요소가 아니다.

 

// SecurityContextHolder의 ThreadLocalSecurityContextHolderStrategy 코드 일부
final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {

	private static final ThreadLocal<Supplier<SecurityContext>> contextHolder = new ThreadLocal<>();

	@Override
	public void clearContext() {
		contextHolder.remove();
	}

	@Override
	public SecurityContext getContext() {
		return getDeferredContext().get();
	}
    
    // ... 생략

 

보다시피 ThreadLocal을 내부적으로 사용하는 모습을 볼 수 있다. 각 쓰레드별로 서로 다른 내용물을 갖는 SecurityContextHolder를 쓰게 되는 것이며, 이를 통해 사용자별로 SecurityContextHolder내에 서로 다른 인증객체(Authentication)을 가지게 되는 효과를 준다! 동일한 쓰레드 내에선 언제든지 SecurityContext를 참조가능하다.

 

ThreadLocal : 쓰레드마다 할당되는 자신만의 고유한 공간. 멀티쓰레드 환경이어도 ThreadLocal은 다른 쓰레드로부터 안전한 공간이다.

 

 

즉 정리하자면

 

  1. 사용자별로 보내는 요청들을 쓰레드를 통해 처리한다
  2. username, password기반으로 이런 요청들에 대한 인증 작업을 한다. 인증이 완료되면 SecurityContext에 Authentication을 꽂아넣으며, Spring Security는 SecurityContext에 Authentication이 들어가있는지 여부를 통해 인증됐는지 아닌지를 판단한다.
  3. 근데 매번 username, password를 함께 보낼 순 없으니 세션을 활용한다. 세션id를 통해 세션으로부터 SecurityContext를 가져와 이를 SecurityContextHolder에 꽂아넣는 식이다.
  4. 사용자별 Authentication 구분은 SecurityContextHolder가 ThreadLocal을 사용함으로써 가능하다.

 


인가

인증 과정이 끝났다면, SecurityContextHolder에 SecurityContext가 들어있고 이 안에 Authentication이란 "통행증"이 들어있으며 이를 통해 인가를 진행할 수 있다. Authentication은 내부적으로 Principal(보안 주체에 해당하는 개념)의 authorities(권한)들을 가지며, 구체적으론 GrantedAuthority인터페이스의 구현체들이 Collection형태로 들어가 있다. 

 

GrantedAuthority의 getAuthority()메서드를 통해 권한 정보들을 읽을 수 있으며, AccessDicisionManager라는 놈이 이 권한 정보들을 읽어서 접근 가능한지 아닌지를 판단해준다.

 

인가는 다음 과정을 통해 진행된다.

 

https://docs.spring.io/spring-security/reference/servlet/authorization/authorize-http-requests.html

 

  1. 필터체인의 AuthorizationFilter가 SecurityContextFilter로부터 Authentication(통행증)을 받아온다. 
  2. 만약 Authentication을 받아오는데 실패하면 AuthenticationException을 던진다.
  3. AuthorizationManager(얘가 AccessDicisionManager역할도 해준다)에게 받아온 Authentication과 HttpServletRequest를 전달한다
  4. AuthorizationManager가 해당 request를 등록된 패턴과 match되는지를 판단해준다
  5. 거부됐다면 -> AuthorizationDeniedEvent가 발행되고 AccessDeniedException을 던진다
  6. 승인됐다면 -> AuthorizationGrantedEvent가 발행되고 필터체인을 마저 진행한다

 

AuthorizationFilter에서 Authentication을 받아오는 메서드의 코드는 다음과 같으며, Authentication을 못 받아오면 예외를 던지는 모습을 직관적으로 바로 확인가능하다.

 

private Authentication getAuthentication() {
	Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
	if (authentication == null) {
		throw new AuthenticationCredentialsNotFoundException(
			"An Authentication object was not found in the SecurityContext");
	}
	return authentication;
}

 

참고로 AuthenticationCredentialsNotFoundException은 AuthenticationException을 상속받은 클래스다.

 

또한 디폴트 설정은 필터체인의 마지막 필터가 AuthorizationFilter이니 알아두면 좋다.

 


인증/인가 실패 시에 대한 예외처리

인증에 실패하면 AuthenticationException이, 인가에 실패하면 AccessDeniedException이 던져진다. 이들은 어떻게 처리되는가?

바로 필터체인의 ExceptionTranslationFilter에서 처리된다.

 

 

이 필터에서 doFilter를 통해 필터체인의 로직들을 수행하다가, AuthenticationException이나 AccessDeniedException이 발생하면 그에 대한 처리를 해주는거다.

 

유저가 인증되지 않았거나 AuthenticationException이 발생하면

 

  1. SecurityContextHolder를 비움
  2. 인증 성공 후 다시 replay가 가능하게끔 클라이언트에게 온 request를 저장
  3. AuthenticationEntryPoint라는 놈을 이용해 클라이언트에게 자격 증명을 요청(즉 클라이언트에게 너 누군지 신원을 대라고 하는 거다. 여기선 AuthenticationEntryPoint가 인증 실패시 써먹는 놈이라고만 알고 넘어가면 된다)

 

AccessDeniedException이 발생하면, 심플하다. 등록된 AccessDeniedHandler를 호출한다.

 

이를 수도 코드로 보면 다음과 같다고 보면 된다.

 

try {
	filterChain.doFilter(request, response);
} catch (AccessDeniedException | AuthenticationException ex) {
	if (!authenticated || ex instanceof AuthenticationException) {
		// 클라이언트에게 자격 증명을 요청(인증 실패했으니까)
		startAuthentication();
	} else {
		// AccessDeniedHandler 호출
		accessDenied();
	}
}

 

실제로 ExceptionTranslationFilter 내부를 뜯어보면 다음과 같은 코드를 볼 수 있다.

 

private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response,
	FilterChain chain, RuntimeException exception) throws IOException, ServletException {
	if (exception instanceof AuthenticationException) {
		handleAuthenticationException(request, response, chain, (AuthenticationException) exception);
	}
	else if (exception instanceof AccessDeniedException) {
		handleAccessDeniedException(request, response, chain, (AccessDeniedException) exception);
	}
}

 

handleAuthenticationException메서드는 내부적으로 sendStartAuthentication메서드를 호출하며, 코드는 다음과 같다.

 

protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
	AuthenticationException reason) throws ServletException, IOException {
	// SEC-112: Clear the SecurityContextHolder's Authentication, as the
	// existing Authentication is no longer considered valid
	SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
	this.securityContextHolderStrategy.setContext(context);
	this.requestCache.saveRequest(request, response);
	this.authenticationEntryPoint.commence(request, response, reason);
}

 

request를 저장하고, AuthenticationEntryPoint의 commence라는 메서드를 호출하는 모습을 볼 수 있다.

handleAccessDiniedException메서드는 AccessDiniedHandler의 handle메서드를 호출한다. 

 

나중에 본인이 커스텀한 AuthenticationEntryPoint와 AccessDeniedHandler를 이용해, 인증/인가를 실패했을 때에 대한 처리방식을 직접 지정해줄 수도 있어서 이렇게 설명해봤다.

 

 


스프링 시큐리티 원리에 대한 내용을 계속 구글링하면서 뒤져봤는데, 구글링해서 보는 페이지마다 다 같은 내용들만 써져 있어서 내가 궁금한 내용들에 대한 서치가 힘들었다. 각 용어들에 대한 개념은 블로그들을 보면서 어느 정도 알겠는데, 어떤 과정으로 진행되는지에 대한 궁금증들이 있었다. 그러던 중 그냥 공식 문서를 파보자는 생각으로 공식 문서들을 파봤다.

 

사실 이전에 React 공부할 때를 빼면, 공식문서를 이렇게까지(물론 지금도 엄청나게 한 것도 아니지만) 공부한 적은 없었다. 개발자로 산다는 사람이 공식문서를 진득하게 본 적 없다는게 사실 생각해보면 부끄러운 일인 것 같다. 처음부터 공식문서를 보는건 개념 잡기가 어렵다는 생각이 들어 지금껏 새로운 걸 공부할 때마다 공식문서로 시작하는 걸 기피해왔던 것 같기도.. 근데 이번에 느낀게, 구글링해서 블로그들 보면서 어느 정도 용어들에 대한 개념만 잡고 그 후엔 공식문서로 공부하는게 되게 좋은 방법 같다. 오피셜한 문서인 만큼 내용에 대한 의심이 안 들고, 정리가 잘 돼있다. 지금 다룬 스프링 시큐리티만 해도 글을 작성하는 현재를 기준으로 구글링해서 나오는 블로그들은 다 옛날 버전이라 deprecate된 내용들이 많다. 예를 들면 인가 과정에서 FilterSecurityInterceptor를 쓴다고들 하는데 6.1.2버전 기준 FilterSecurityInterceptor는 deprecate됐다. 또 SecurityContextPersistenceFilter도 deprecate됐다. 이들 대신에 AuthorizationFilter와 SecurityContextHolderFilter를 현재는 사용중이다.

 

암튼 앞으로 내가 모르는 기술들을 공부할 땐, 구글링해서 개념만 대강 잡고 공식문서로 시작하는게 꽤나 좋은 방법인 듯. 나만의 공부방법을 정립해가야할 때다.

 

 

 

참고

https://catsbi.oopy.io/f9b0d83c-4775-47da-9c81-2261851fe0d0

 

스프링 시큐리티 주요 아키텍처 이해

목차

catsbi.oopy.io

https://docs.spring.io/spring-security/reference/index.html

 

Spring Security :: Spring Security

If you are ready to start securing an application see the Getting Started sections for servlet and reactive. These sections will walk you through creating your first Spring Security applications. If you want to understand how Spring Security works, you can

docs.spring.io

 

소마 프로젝트를 진행하며, 내가 먼저 담당하게 된 기능은 로그인 및 회원가입 기능들이었다. 그 중에서도 소셜로그인 기능을 만들기로 했는데, 구글 & 카카오 & 애플에 대해서 만들기로 했다.

 

야생형 학습(일단 만들면서 그때그때 필요한 걸 찾아가는)이 좋다는 걸 알지만, Oauth라는 것 자체에 내가 무지했던 만큼 최소한 이게 어떤 건지, 어떤 원리로나마 굴러가는건지 파악을 한 뒤 기능개발에 들어가보기로 했다.

 

아래 포스트는 당시 내가 Oauth에 대해 찾아보며 정리했던 포스트다.

https://jofestudio.tistory.com/105

 

OAuth의 등장배경 및 기본적인 원리 & 과정 - top down형식으로 알아보자

등장배경 우리가 만든 서비스가 유저를 대신해서 구글에서 제공하는 서비스에 뭔가를 하고 싶은 일들이 생겼던 거다. 가령 구글 캘린더에 일정등록을 우리가 만든 서비스가 해준다든가, 등등..

jofestudio.tistory.com

 

Oauth가 굴러가는 원리는 내 머릿속에선 다음과 같이 새겨졌다

 

  1. 각 플랫폼에 들어가서 redirect uri 등등을 세팅
  2. 각 플랫폼(구글 등)에서 지정한 로그인페이지로 들어감
  3. 거기서 해당 플랫폼 계정으로 로그인하면 authorization code를 지급받는데, 사전 설정한 redirect uri를 통해 이걸 백엔드로 보내게 됨
  4. 백엔드에선 해당 code를 통해 플랫폼으로부터 access token을 받음
  5. 그걸 통해서 회원정보도 조회하고.. 등등

 

우리 프로젝트는 자체적으로 구축할 db에 회원정보를 저장할 것이다. 따라서 백엔드에서 4번 과정을 통해 받은 access token을 활용해 플랫폼으로부터 회원정보를 받아오고, 해당 회원정보를 갖는 회원이 실제로 우리 db에 있는지를 판단해서 로그인 및 회원가입을 만들면 되겠다는 그림이 머릿속에 그려졌다. 네이티브 앱은 세션 로그인이 불가능하기 때문에, 해당 회원에 대한 JWT를 만들어서 플러터 단으로 주면 되겠다는 생각도 들었다.

 

"그러면 일단 저 순서대로 만들어가면 되겠네?" 라는 생각이 들었다. 그러나 문제는.. 내가 모든 게 처음이라는 것이었다.

 


앱에서는 authorization code만 받는 걸 어떻게 하지?

"구현방법은 조금씩 달라도, 결국 저 순서를 따르는 식으로들 만들겠지!" 라고 처음에 생각했다. 각 플랫폼 별로 지정한 로그인 페이지를 띄우려면 웹뷰를 띄워야겠다고 생각했고, 웹뷰 띄우는 방법을 먼저 검색해야 한다는 갈피가 잡혔다. 

 

그러나 얼마 지나지 않아.. 구글에서는 보안 정책상의 이유로 웹뷰를 통한 구글로그인을 막아놨다는 걸 알게 됐다. user agent를 변경하여 이를 우회할 수도 있다고 하나, 다른 것도 아니고 앱의 보안과 사용성에 부정적인 영향을 끼칠 수 있어서 막아둔 걸 굳이 우회해서 이용할 필요는 없다고 생각했다.

 

..그러면 다른 방법을 찾아야 한다! 일단 플러터 구글로그인 예제를 검색해봤다. google_sign_in이라는 패키지를 통해 구글로그인을 할 수 있다는 걸 알게 되고, 써봤다. 매우 쉽게 google에서 지급한 access token을 플러터 단에서 받을 수 있었다.

 

여기서 뭔가 이상한 걸 느꼈다. 내가 이해한 Oauth2 프로토콜은 사용자 측에서 redirect uri를 통해 백엔드로 authorization code를 보내면 백엔드가 그걸 통해서 resource server(ex: google)로부터 access token을 받아야 한다. 그러나 지금은 버튼을 딸깍 하고 누르니 바로 플러터 단에서 google의 access token이 받아져(?)버렸다.

 

물론 백엔드로 authorization code를 넘기는 것은 내가 그린 그림이었을 뿐, 프론트 쪽에서 토큰을 받는 식으로 처리해도 상관은 없다. 애당초 Oauth2 프로토콜에서 말하는 "client"라는 것도 엄밀히 말하면 우리가 구축하는 서비스를 말하는 것이지 백엔드만을 말하는 게 아니니까. 따라서 이 상태, 즉 프론트에서 토큰을 받은 상태에서 그대로 백엔드 단으로 access token을 넘긴 뒤 백엔드에서 그걸 통해서 유저 정보를 조회하고 JWT를 만드는 식으로 가도 될 것이다. 그러나 access token자체를 백엔드로 넘긴다? 이거 탈취당하면 어떡할건데? 라는 생각이 머리를 스쳤다.  그리고 우리 서비스에서 사용할 커스텀 JWT를 만들기 위해 access token을 백엔드로 넘기는 거면 백엔드에서 그 access token을 가지고 resource server에서 회원정보 가져올 텐데, 어차피 플러터 쪽에서(즉 프론트에서) access token을 가지고 있는거면 플러터에서 바로 resource server로부터 회원정보를 가져오고 그걸 백엔드로 넘기는게 더 효율적일 수도 있다는 생각도 들었다.

 

결국 access token이 아닌 authorization code를 백엔드로 보내게 되는게 보안상 안전하다는 생각이 들었다. 그래서 어떻게든 authorization code를 받아오는 방법을 찾아봐야겠다는 생각을 하고 여기저기 쑤셔대기 시작했다.

 

그러나 구글링해서 나오는 예제는 죄다 google_sign_in패키지를 활용해서 바로 access token을 받아오는 것들이었고,, 거의 대부분이 firebase와 연계해 인가/인증을 하는 예제들이었다. 나는 따로 회원정보를 자체적으로 구축할 db에 저장하고 이를 활용할 것(외래키로 사용 등..)이기 때문에, 최대한 firebase를 쓰는 예제들은 지양하고 있었다. 물론 firebase를 쓴다 해도 그와는 별개로 우리 플젝에서 쓸 db에도 어찌어찌 회원데이터를 넣을 순 있겠으나, firebase와 자체db의 정보를 계속 동기화하는 것 등등 신경쓸 것이 너무나 많아질 것이란 생각도 들었고, 내가 만들 백엔드를 통해 인가/인증을 하지 못하게 될 거라는 점도 맘에 안 들었다. 

 

결국 authorization code만 따로 쏙 빼고 싶으면 웹뷰를 써야 하는데.. 일단 아까 말했다시피 구글에서는 웹뷰를 통한 로그인을 막아둔 상태. 그리고 이걸 배제한다고 해도 문제가 있었다. redirect uri를 통해 백엔드로 바로 authorization code를 넘겨야 하는데, 웹뷰에서 redirect uri를 통해 백엔드로 보낼 수가 있는가?라는 의문점이 든거다. 즉 나는 로컬환경에서 개발 중인데, 노트북에서 에뮬레이터로 띄운 휴대폰 기기의 웹뷰에서 redirect uri로 localhost뭐시기를 했을 때.. 내 로컬 "노트북"에서 돌아가는 스프링부트로 authorization code가 오는가..의 문제. 당시에는 웹뷰 입장에선 자신을 돌리고 있는건 핸드폰 기기인 셈이니 얘한테 있어서 localhost는 기기를 말하지 않겠냐는 생각이 머리에서 돌아가게 된 거다. 이에 대한 해결책으론 배포를 해서 배포된 주소로 보내든가.. 아니면 내 노트북의 ip주소로 보내든가해야 하는데, 두 방법 모두 썩 좋은 해결책은 아니라는 생각이 들었고.. 무엇보다도 애당초 구글은 웹뷰를 통한 로그인을 막아뒀다!

 

(나중에 에뮬레이터의 웹뷰에서 localhost를 때려도 노트북으로 http를 보낼 수 있다는 걸 알게 되긴 했다.

https://learn.microsoft.com/ko-kr/xamarin/cross-platform/deploy-test/connect-to-local-web-services)

 

 

원래는 이렇게 만들고 싶었다.. (파워포인트로 후루룩 만듦ㅋㅋ)

 

 

머리가 아파졌고,, 그냥 카카오 로그인이나 먼저 만들까란 생각으로 카카오 로그인 공식문서를 열었다.

근데 열자마자..!

 

 

앱 서비스에서는 Redirect 방식 사용불가라는 말이 떡하니 있었다. 게다가 얘도 클라이언트단에서 인가 코드와 토큰 발급을 모두 처리하는 방식을 가지고 있었다.

 

나는 우리 프로젝트에서 사용할 소셜플랫폼인 카카오, 구글, 애플은 동일한 형태의 로그인을 만들고 싶었다(셋 다 토큰 자체를 백으로 넘기든가, 아니면 셋 다 authorization code를 백으로 넘기든가..). 근데 어차피 구글은 웹뷰를 통한 로그인을 막아놨고, 카카오도 공식문서피셜로는 네이티브 앱 환경에서는 Redirect 방식을 사용할 수 없다고 공지를 내렸었다.

 

그러면 결단을 내릴 차례.. 하는 수 없이 그냥 "플러터에서 토큰 넘기자"로 결정하게 됐다. 프로젝트에서 주어진 시간도 많지 않았기 때문에, authorization code를 보내는 방법을 더 알아내는 것보단 일단 로그인 자체를 만드는 것에 더 포커스를 두기로 한 것도 있었다. 

 

 


AWS Cognito와의 사투

그렇게 플러터 단에서 토큰을 받아서 백엔드로 넘기는 로직을 만들었을 때쯤, 연수생 동기와 Oauth에 대한 얘기를 주고 받았었다. 근데 그 친구가 firebase를 써서 소셜로그인을 만들었다는 얘기를 했고, firebase를 사용해서도 자체 백엔드에서 인가인증이 가능하다는 얘기를 해줬다!

 

https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl 

 

ID 토큰 확인  |  Firebase Authentication

Google I/O 2023에서 Firebase의 주요 소식을 확인하세요. 자세히 알아보기 의견 보내기 ID 토큰 확인 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Firebase 클라이

firebase.google.com

 

사실 나는 이전에 firebase를 잠깐 써봤을 때 걔네가 지들이 알아서 인가인증을 다 해줬던 걸로 기억해, firebase를 쓰면 내 백엔드에서 인가인증하는 식으로 못 만든다고 생각해서 지금까지 지양했던건데,, 역시 난 우물 안 개구리였구나라는 생각이 들었다.. ㅎㅎㅎ;;

 

그 때 한창 소마에서 지원해주는 AWS 심화교육(Architection on AWS)를 듣고 있던 터였는데, 우리 얘기를 듣고 있던 강사님께서 AWS에서 제공하는 Cognito란 걸 사용하면 똑같은 걸 할 수 있다고 말씀해주셨다. 그 때 삘이 꽂혀서,, ㅎㅎ; 갑자기 코그니토를 통한 소셜로그인을 엄청나게 검색하고 따라해봤다.

 

뭐. 결론적으로 말하면 이 방법은 성공하지 못 했다. AWS Amplify를 통해 Flutter에서 이것저것 시도를 해봤고,, 공식문서도 그대로 따라하는걸 10번 정도는 한 것 같다. 근데 자꾸 에러가 났다 ㅎㅎ

 

결국 시간 문제에 다시 직면했고, 타협을 해야 할 지점이 왔다. 로그인/회원가입을 만드는 것에서 더 이상 시간을 끌 수 없던 노릇이라, AWS Cognito를 사용하는 방식을 버리기로 했다. 그러면 firebase를 그냥 쓸까?라는 생각이 들었지만, 내가 직접 JWT를 만들어서 인가인증을 만들어보는게 더 낫겠다는 생각이 들었다. 이전에 jwt를 활용한 로그인을 따라하면서 어떻게 만들어본 적은 있지만, 뭔지 알고 만들었던 건 아니었어서 이번에 각잡고 만들어봐야겠다는 마음이 들었고.. 이게 어쨌든 좀 더 공부가 될 것 같았기 때문.

 

그래서 다음과 같은 흐름으로 만들어보기로 했다. 탕탕탕!

 

 


스프링 시큐리티 & JWT로 인가 인증 만들기

member테이블을 만들고, db에 회원정보가 있는지 없는지 조회하는 건 금방 만들었다. 근데 스프링 시큐리티라는 놈이 인가인증을 어떻게 해주는건지, 그리고 스프링 시큐리티가 JWT를 통해서는 어떤 식으로 인가인증을 하는지는 잘 몰랐다. 그래서 일단 이놈들의 이론적인 부분에 대해(?) 먼저 짚고 넘어가기로 했다.

 

https://jofestudio.tistory.com/112

 

[Spring Security] 스프링 시큐리티의 인증/인가 과정(6.1.2버전 기준, with 공식문서)

읽기 전에 : 해당 글은 스프링 시큐리티 6.1.2버전 기준으로, 읽는 시점에 따라 다른(deprecate됐다든지) 내용일 수도 있습니다. 어떠한 요청을 보냈을 때, 요청을 보낸 이에 대한 인가/인증이 수반돼

jofestudio.tistory.com

(스프링 시큐리티 원리에 대해 공부하고 정리한 글. 실제로 개발할 때는 완전 딥하게는 공부하지 못했고, 로그인/회원가입을 일단 만든 뒤에 몰랐던 부분 더 찾아본 뒤에 정리해서 작성한 글이다)

 

 

스프링 시큐리티가 인가인증을 해주는 "기본적인" 방식은 내 머릿속엔 다음과 같이 새겨졌다.

 

  1. 유저가 로그인을 시도(with username & password)
  2. 필터에서 DB에 있는 유저인지 체크함(username을 활용)
  3. 있는 놈이면 "그 유저의 정보를 담은 문서" = UserDetails를 만듦
  4. 그 UserDetails의 내용과 유저가 로그인할 때 보낸 정보(password)를 비교.
  5. 같다면 통행증 = Authentication을 끊어줌. 통행증에는 누군지(=principal이라 부르고, 얘 역할을 UserDetails가 함), 그리고 뭘 할 수 있는 앤지(=GrantedAuthority)가 적혀있음
  6. 그걸 SecurityContextHolder에 저장하면서 세션에도 저장!
  7. 유저에게 세션id를 주고 이후 요청들은 이걸로 인가인증하는 거임

 

한마디로 SecurityContextHolder라는 곳에 Authentication이라는 통행증을 발급해서 넣어둔다. 그 후 나중에 이렇게 넣어둔 Authentication이 있는지를 봐서 "인증"을 하고, 이 통행증에 써져 있는 권한(GrantAuthority)를 보고 "인가"를 하는 식으로 Spring Security가 동작한다.

 

jwt를 활용해 인가인증을 할 거다. 그럼 위 과정을 토큰방식으로 바꾼다면 어떨까? 토큰방식으로 바꿔도 뭐가 됐든 간에 Authentication을 만든 다음 그걸 SecurityContextHolder에 넣는 것은 동일할 것이다. 그 후 SecurityContextHolder에 Authentication이 들어있는지를 보며 "인증"을 하고, 나중에 Authentication을 꺼내 GrantedAuthority를 보고 "인가"를 할 것이다.

 

이렇게 대략적인 흐름이 머리 속에 잡혔고, 그 후 구글링을 통해 스프링 시큐리티와 jwt관련된 예제들을 살폈다. 나름 원리를 알고 나니(?) 예제들을 보면서 "아 여기선 뭘 하고 있는 거구나" 이런 게 느껴졌다. 

 

 

다음과 같은 흐름으로 만들기로 했다

 

  1. 우선 로그인 성공하면 jwt반환(Access token) -> 여긴 유저의 이메일 정보를 담는다
  2. 클라이언트가 헤더에 Access Token을 담아 요청을 보냄
  3. 헤더에서 Access Token 추출
  4. Access Token 검증(유효기간 만료 등)
  5. 이제 Access Token으로부터 Authentication을 만들고 이를 SecurityContext에 꽂아야 한다
  6. 우선 Access Token에서 이메일 추출
  7. 해당 이메일을 갖는 유저의 UserDetails를 만듦
  8. 이 UserDetails를 활용해 Authentication을 만든다(principal은 UserDetails가, grantedAuthority는 UserDetails.getAuthorities를 활용
  9. 그렇게 만든 Authentication을 SecurityContext에 꽂는다

 

그림으로 보면 다음과 같다. jwtFilter, jwtProvider 등등은 내가 정한 클래스들의 이름이다.

 

 

이 과정에서 잠깐 고민했던 부분은 UserDetails의 구현체를 어떤 방식으로 만드는가였다. User(내 프로젝트는 Member) 클래스가 UserDetails를 구현하는 식으로 만들 수도 있고, 정말 아예 User클래스와 CustomUserDetails(UserDetails의 구현체 클래스)를 별도로 둘 수도 있었다.

 

나는 후자로 했다! 물론 UserDetails를 User가 구현하도록 하는게 뭔가 더 편할 것 같긴 하지만, UserDetails의 원래 용도 자체는 "사용자의 정보가 담긴 문서"인 만큼, User가 UserDetails를 구현하면 유저 자체가 통째로 사용되는 것이니 조금 번거롭더라도 UserDetails를 구현하는 별도의 커스텀 클래스를 만드는게 보안상 좀 더 안전하리라 생각했다.

 

아 그리고 UserDetails를 받은 뒤 Authentication을 만드는 과정에서, 요 Authentication이란 놈은 인터페이스여서 구현체를 만들어서 써야 하는데, 이건 UsernamePasswordToken이란 놈이 사용된다. 근데 이 놈은 생성자가 2개다. 

 

public static UsernamePasswordAuthenticationToken unauthenticated(Object principal, Object credentials) {
	return new UsernamePasswordAuthenticationToken(principal, credentials);
}

public static UsernamePasswordAuthenticationToken authenticated(Object principal, Object credentials,
	Collection<? extends GrantedAuthority> authorities) {
	return new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
}

 

전자는 인증되지 않은 상태의 Authentication을 만드는 거고, 후자는 인증된 상태의 Authentication을 만드는 생성자이다. 전자는 도대체 왜 있는거지? 싶었는데, 알고보니 username & password로 하는 전통의 원조(?) 로그인에서는 전자를 통해서 인증되지 않은 상태로 만든 뒤 UserDetails와 비밀번호를 대조하는.. 뭐 그런 과정들을 거친 뒤에 인증된 상태로 바꿔주는 과정이 있는 거였다. ㅇㅇ

 

하지만 지금 내가 하는 과정에서는 굳이 그렇게까지 할 이유가 안 보였다. jwt가 클라이언트로부터 온다는 거 자체가 이미 이전에 로그인을 통한 신원 확인이 끝나서 클라이언트가 서버로부터 jwt를 받은 것이기 때문. 

 


인증/인가 실패 시 처리

인증/인가를 할 수 있도록 Authentication을 SecurityContext에 꽂아넣는 건 만들었다. (비하인드 : 요로코롬 SecurityContext에 Authentication을 꽂아둬야 인증한 것으로 취급된다는 걸 당시엔 명확하게 몰라서 무수히 많은 로그를 찍어봤었 건 비밀). ok. SecurityContext에 Authentication이 없으면 인증에 실패한 것으로 처리되는 것도 알겠다. 그러면 인증이 실패했을 때와 인가에 실패했을 때에 대한 처리는 어떻게 하는가? 예를 들어, Access Token의 검증에 실패하면 그건 인증 실패로 처리해야 할테니까.

 

일단 인증실패는 AuthenticationEntryPoint라는걸 통해서 가능했다! 인증에 실패하면 AuthenticationException이 발생하는데, 이걸 AuthenticationEntryPoint에서 처리한다. 커스텀하게 AuthenticationEntryPoint를 만들어 클라이언트에게로 가는 Response를 조작할 수 있는데, 여기서 고민이 생겼다. 클라이언트로 가는 Response들에 세부적인 메시지를 내가 달아주고 싶은데, 그걸 어떻게 하지? 라는 고민. 이게 무슨 말이냐?

 

public interface AuthenticationEntryPoint {
	/**
	 * @param request that resulted in an AuthenticationException
	 * @param response so that the user agent can begin authentication
	 * @param authException that caused the invocation
	 */
	void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
			throws IOException, ServletException;

}

 

요렇게 AuthenticationEntryPoint의 commence메서드를 통해 AuthenticationException을 처리한다. 그리고 내가 만든 jwtFilter에서는 다양한 이유들로 인증 실패를 낼 수 있다. Access Token의 만료기간이 지났다든지, 유효하지 않는 서명을 사용했다느든지 등등.. 그럴 때마다 단순히 "인증 실패요~"라는 것보다는 클라이언트에게 다양한 메시지(뭐 땜에 실패했어요~)를 전달하고 싶은데, 그 오류메시지(?)들을 어떻게 AuthenticationEntryPoint로 넘기는지를 고민하게 된 거다.

 

AuthenticationException도 추상 클래스이기 때문에, 커스텀하게 이를 구현하는 클래스를 만들어 내가 원하는 오류 문구를 담을 수도 있을 것이다. 그러면 jwtFilter에서 이렇게 커스텀하게 만든 AuthenticationException을 만들어 던진다면 어떨까? 싶었는데.. 일단 지금와서 돌이켜보면 당시에는 이 생각을 못 했다. 지금이야 돌이켜보니 일단 만드는 과정에서 부족하지만 나름대로의 지식이 생기긴 했지만.. 당시엔 뭐 어떻게되는지 모르겠지만 SecurityContext에 Authentication을 안 꽂아두면 뒷단에 있는 필터에서 AuthenticationException이 발생하고, AuthenticationEntryPoint가 이를 처리하네! 정도만 알던 상태라.. 

지금 돌이켜보면 jwtFilter에서 커스텀하게 만든 AuthenticationException을 던져도 그게 ExceptionTranslationFilter에서 처리되지 않는다는 걸 안다. 왜냐하면 ExceptionTranslationFilter는 doFilter를 통해 자기보다 뒷단의 필터(AuthorizationFilter)에서 일어나는 AuthenticationException만 처리하는데, 내가 만든 jwtFilter는 ExceptionTranslationFilter보다 앞단에 위치하기 때문이다. 

 

암튼 그래서 당시엔 다른 사람들이 만든 코드들을 살피면서 방법을 찾다가, 나름 기똥찬(?) 방법을 발견했다. 그건 바로 request에 에러메시지를 저장하는 것! 자세히보면 commence메서드가 AuthenticationException뿐만 아니라 request와 response도 파라미터로 받고 있는걸 볼 수 있다. 즉 jwtFilter쪽에서 request에 에러메시지를 세팅해두면, 나중에 AuthenticationEntryPoitn의 commence메서드에선 파라미터로 받은 request에 들어가있는 에러메시지를 꺼낼 수 있는 거다!

 

// 내가 만든 JwtFilter 내부..
try {
	jwtProvider.validateAccessToken(accessToken);
} catch (CustomJwtException ex) {
	request.setAttribute("exception", ex.getMessage());
	return;
}
// .. 생략

 

// 커스텀 AuthenticationEntryPoint
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
	@Override
	public void commence(HttpServletRequest request, HttpServletResponse response,
						AuthenticationException authException)
			throws IOException, ServletException {
		// request로부터 꺼내기
		String errorMessage = (String) request.getAttribute("exception");
		setResponse(response, errorMessage);
	}
	// ..생략

 

그러면 인가실패는 어떻게 처리하는가? 인가에 실패하면 AccessDeniedException이 던져지며, 이는 AccessDeniedHandler를 통해 처리할 수 있다. 이 핸들러또한 커스텀하게 만들 수 있어서, response를 내가 원하는 식으로 조작 가능하다.

 

인가 실패는 그럼 어떤 상황에서 이뤄지는가? 다음과 같다.

 

  1. AuthorizationFilter에서 진행됨
  2. SecurityContextHolder에서 Authentication을 가져옴. 못 가져오면 AuthenticationException
  3. Authorities들, 즉 사용자의 권한 목록을 꺼냄
  4. 시큐리티 설정에서 hasRole을 사용했다면, 해당 메서드의 인자로 넘긴 값들과 사용자의 권한 목록을 비교. 참고로 hasRole의 인자로 넘긴 값들에 대해 자동으로 ROLE_이라는 prefix를 붙여줌. 예를 들어 hasRole("USER") -> 사용자의 권한 목록에 ROLE_USER가 있어야 한다는 거
  5. 일치하는게 있으면 ok. 없으면 AccessDeniedException

 

참고로 난 원래 Member엔티티들이 가지는 Role을 USER, ADMIN이런 식으로 줬어서.. 급하게 앞에 ROLE_들을 붙이는 방식으로 수정했었다.

 

이후 Configuration파일에서 다음과 같은 설정을 통해 커스텀하게 만든 AuthenticationEntryPoint, AccessDeniedHandler를 등록해줬다. 당연한 소리지만 이렇게 커스텀하게 만든 애들을 등록하지 않으면 디폴트로 있는 애들이 쓰인다.

 

@Configuration
public class SecurityConfig {
    // ... 생략

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                // ... 생략
                ).exceptionHandling((exceptionHandling) -> {
                    exceptionHandling.authenticationEntryPoint(new JwtAuthenticationEntryPoint());
                    exceptionHandling.accessDeniedHandler(new JwtAccessDeniedHandler());
                });

        return http.build();
    }
}

 


토큰 재발급 처리

Access Token & Refresh Token을 발급해서 클라이언트에게 넘기는 건 끝났다. 클라이언트(플러터) 단에서는 Access Token과 Refresh Token을 보관소에 가지고 있도록 했는데, 로그인 유지 기능을 위해 앱 접속 시 자동으로 보관소에 들어있는 토큰들을 갱신해주기로 했다. 난 Access Token의 유효기간을 1시간, Refresh Token의 유효기간을 30일로 만들었으니 앱을 접속을 안 하고 있었다고 해도 30일 내로 접속한다면 로그인이 유지된 채로 앱을 이용할 수 있는 거다.

 

일단 당연히 Refresh Token을 서버로 보내면 걔를 검증(유효기간 만료 등)해야 한다. 그럼 이 검증만 하고 재발급을 바로 해주면 되나? 싶었지만 좀 더 보안을 위한 방법이 있었다. 바로 Refresh Token원본을 DB에 저장하고, 클라이언트로부터 온 Refresh Token을 그와 대조하는 것.

 

즉 다음과 같은 흐름이다.

 

소셜로그인 과정.. Resource Server에서 토큰받아오고 그런 과정은 과감하게 생략했다

 

근데 DB에 Refresh Token을 보관상으로 얻는 구체적인 이점들은 도대체 뭘까? 이거 디비에 둘 필요 없이 그냥 검증만 해주면 되는거 아니야? 왜 굳이 DB에 저장해? (라고 생각했던 건 본인)

 

  1. 우선 단순히 Refresh Token을 검증만 하고 재발급하는 수준이라면 -> 악의적 사용자도 Refresh Token 탈취해서 악용이 가능. 하지만 DB에 Refresh Token을 저장하는 구조라면? 악의적 사용이 보고됐을 때 DB에 저장된 Refresh Token을 삭제함으로써, 악의적 사용자가 Refresh Token을 보내도 검증은 되겠지만 DB에 Refresh Token이 없으니 재발급을 안해주는 식으로 대응이 가능하다
  2. Access Token을 발급받을 때 Refresh Token도 함께 발급받으면서 DB에 기존에 있던 Refresh Token을 삭제한 뒤 새로 발급받은 Refresh Token을 저장하는 방식이면 -> 악의적 사용자가 어찌저찌 Refresh Token을 탈취해도, 유저가 다시 로그인하거나 하면 DB에 있던 Refresh Token도 갱신되는 셈 -> 악의적 사용자가 지가 가지고 있던 Refresh Token을 보내도 물론 검증은 통과하겠지만 DB에는 해당 Refresh Token은 삭제됐으니 재발급을 안해주는 식의 대응이 가능해진다

 

 

요 내용은 다음 글에서 좀 더 자세히 확인 가능하다. 영어로 된 글이니 놀라지 말 것..

 

https://betterprogramming.pub/should-we-store-tokens-in-db-af30212b7f22

 

3 Scenarios Where You Can Store JWT Token in Your DB

Know when and why

betterprogramming.pub

 

소마 프로젝트를 진행하면서, 인프라 쪽을 내가 어느 정도 담당하고 있어서(사실 담당한다고 하기에도 민망할 정도로 기본적인 부분들만 담당하긴 하지만) RDS를 띄워보기로 했다. Private Subnet에 RDS를 띄우기로 했는데, 문제는 "로컬에서 Private Subnet에 어떻게 접근하는가?" 였다.

 

Private Subnet은 외부와 단절된 네트워크로, 외부에서 직접 접근이 불가능하다. NAT Gateway를 둬서 Private Subnet에서 외부로 트래픽을 보낼 순 있어도, 외부에서 먼저 Private Subnet으로 트래픽을 보낼 순 없다. VPN등을 통해 직접 로컬에서 Private Subnet으로 연결할 순 있으나, 비용이 무시무시하다는 소문이 들려서 다른 방법을 찾아보기로 했다

 

당장 떠오르는 방법은 Public Subnet에 EC2를 둔 후, ssh로 EC2에 원격으로 연결한 다음 거기서 Private Subnet으로 접근하는 것. 같은 VPC안의 애들끼리는 서로 통신이 가능하기 때문에 이 방법을 쓸 수 있을 것이다. 이렇게 외부에서 내부 네트워크로의 안전한 접근을 제어하기 위해 사용되는 중간 서버를 Bastion Host라고 부른다.

 

로컬에서 ssh로 Public Subnet의 EC2에 원격으로 연결한 뒤, 거기서 Private Subnet의 RDS로 직접 쿼리를 날릴 순 있다.

 

이렇게 하면 로컬에서 Private Subnet에 있는 RDS로 create등의 쿼리를 작성해 날릴 수 있다. 그러나 "얘를 로컬에서 돌리고 있는 스프링부트와 어떻게 연결하는가"가 숙제. 스프링부트에서 만들어지는 쿼리를 터미널에 인풋으로 주도록 내가 직접 구축을 해야 하나..? 그건 너무 에바같은데.. 

 

이 때 ssh 포트 포워딩(다른 말로는 ssh 터널링)을 통해 이 문제를 해결할 수 있다. ssh 포트포워딩이란 ssh를 사용해 로컬 머신과 원격 서버 사이에서 포트 전달을 설정하는 것으로, 이를 통해 로컬에서 특정 포트로 트래픽을 보내면 ssh터널(ssh를 통해 연결된 통로라고 생각)을 통해 원격서버의 특정 포트로 보낼 수가 있다. 즉 내가 로컬의 3306포트와 원격 서버의 3306포트를 포트포워딩하면, 내가 로컬의 3306포트로 보내는 트래픽을 원격 서버의 3306포트로 보낼 수가 있는 거다. (참고로 이 예시는 로컬 포트 포워딩에 해당하며, 리모트 포트 포워딩을 하면 원격 서버에서 특정 포트로 들어오는 트래픽을 내 로컬 머신의 포트에서 받을 수 있다)

 

이런 로컬 포트 포워딩은 다음과 같이 설정해 사용 가능하다

ssh -L [로컬에서 사용할 포트]:[최종적으로 접근할 곳(ip:port형태)] [SSH Server 주소]

예시로, aws에서 지급받은 pem키와 함께 사용하려면 다음과 같이 하면 된다.

ssh -i [pem키 경로] -L 3306:[RDS 엔드포인트]:3306 [사용자명(ex:ubuntu)]@[Bastion Host public ip]

 

이렇게하면 로컬의 3306포트와 Private RDS의 3306이 연결됐으니 이제 스프링부트에서도 datasource를 내 로컬의 3306으로 한 뒤 쓰면 된다. 물론 각각 보안그룹들이 다 설정돼있어야 한다!(RDS는 Bastion으로부터 3306포트로 오는 인바운드 허용, Bastion은 22번 인바운드와 3306아웃바운드 허용 등..) 이 때 Public Subnet에 둔 EC2는 점프호스트로 쓰는 거라고 볼 수 있다

 

 

하지만! SSM이란 서비스를 사용하면 좀 더 쉽게 Private Subnet접근이 가능하다.

 

 

SSM이란?

AWS System Manager. Simple System Manager라고도 불리며 이의 줄임말이 SSM이다. 다양한 기능들을 제공하는데, Session manager라는 걸 통해 EC2접속을 할 수 있는 서비스도 제공한다.

 

https://aws.amazon.com/ko/blogs/architecture/how-wego-secured-developer-connectivity-to-amazon-relational-database-service-instances/

https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager.html

 

AWS Systems Manager Session Manager - AWS Systems Manager

AWS Systems Manager Session Manager Session Manager is a fully managed AWS Systems Manager capability. With Session Manager, you can manage your Amazon Elastic Compute Cloud (Amazon EC2) instances, edge devices, on-premises servers, and virtual machines (

docs.aws.amazon.com

 

Session Manager를 사용하면 기존에 했던 것처럼 Bastion Host를 둘 필요 없이 Private Subnet에 있는 EC2인스턴스에 직접 접근이 가능하다(당연히 퍼블릭도 가능). ssh로 연결할 때 사용하던 pem키 관리 및 EC2인스턴스들에 22번 포트를 여는 인바운드 설정도 할 필요가 없다. 즉 더 안전하게 사용 가능한 방법이라고도 볼 수 있다. 또한 포트포워딩도 지원하기 때문에 로컬에서 Private Subnet에 있는 RDS로의 접근 역시 가능하다!

(Session Manager를 통해 EC2들에 대한 직접 액세스는 가능하나 RDS등은 직접 액세스는 안 돼서 포트포워딩을 거쳐야 함. 즉 EC2라는 관리 인스턴스를 "점프 호스트"로 사용해 RDS로 접근하는 것이며, ssh 포트포워딩을 통해 public에 있던 EC2를 점프호스트로 쓰던 것과 같은 원리이다.)

 

참고로 이때 SSM을 통해 직접 액세스하게 되는 EC2는 22번 인바운드는 열릴 필요가 없으나, 443번 아웃바운드는 열려있어야 한다. 해당 EC2에서 AWS SSM에게 지속적인 폴링을 통해 연결을 유지하기 때문이다. 따라서 보안그룹 설정시 꼭 주의할 것. 관련된 내용은 아래 링크의 [엔드포인트에 연결] 섹션을 참고하면 된다

 

https://docs.aws.amazon.com/ko_kr/systems-manager/latest/userguide/session-manager-prerequisites.html

 

1단계: Session Manager 사전 조건 완료 - AWS Systems Manager

이 페이지에 작업이 필요하다는 점을 알려 주셔서 감사합니다. 실망시켜 드려 죄송합니다. 잠깐 시간을 내어 설명서를 향상시킬 수 있는 방법에 대해 말씀해 주십시오.

docs.aws.amazon.com

 

그럼 본격적으로 SSM으로 포트포워딩을 설정해 로컬에서 Private Subnet의 RDS에 접속하는 법을 알아보자.

 


1. 로컬환경에 aws cli 설치하기

https://docs.aws.amazon.com/ko_kr/cli/latest/userguide/getting-started-install.html#getting-started-install-instructions

 

최신 버전의 AWS CLI 설치 또는 업데이트 - AWS Command Line Interface

이전 버전에서 업데이트하는 경우 unzip 명령을 실행하면 기존 파일을 덮어쓸지 묻는 메시지가 표시됩니다. 스크립트 자동화와 같은 경우에 이러한 프롬프트를 건너뛰려면 unzip에 대한 -u 업데이

docs.aws.amazon.com

 

2. aws IAM을 만든 다음, Access Key와 Secret Access Key를 발급받는다.

 

3. 로컬에서 터미널을 띄우고 aws configure를 입력해 구성한다

$ aws configure
AWS Access Key ID [None]: {각자에게 주어진 Access Key}
AWS Secret Access Key [None]: {각자에게 주어진 Secret Access Key}
Default region name [None]: ap-northeast-2
Default output format [None]: json

 

4. 로컬에 Session Manager Plugin을 설치한다

https://docs.aws.amazon.com/ko_kr/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html

 

용 Session Manager 플러그인 설치 AWS CLI - AWS Systems Manager

이 페이지에 작업이 필요하다는 점을 알려 주셔서 감사합니다. 실망시켜 드려 죄송합니다. 잠깐 시간을 내어 설명서를 향상시킬 수 있는 방법에 대해 말씀해 주십시오.

docs.aws.amazon.com

 

5. EC2를 띄우고, 그 EC2에 AmazonSSMManagedInstanceCore라는 정책이 있는 역할을 부여한다

참고로 EC2에 SSM agent가 설치돼있어야 한다. aws에서 제공하는 AMI로 만들어진 EC2들은 ssm agent가 기본적으로 깔려있는 애들이 많다! 다음 링크의 [SSM Agent의 상태 확인] 섹션을 참고해 SSM agent가 설치돼있는지 살펴보고, 안돼있으면 설치해야한다. 설치된 상황이라면 해당 역할을 부여한 후 SSM agent를 재시작해준다.

 

재시작하는 커맨드는 상태 확인 커맨드에서 status만 restart로 바꾸면 된다.

 

https://docs.aws.amazon.com/ko_kr/systems-manager/latest/userguide/ami-preinstalled-agent.html

 

Amazon Machine Images(AMIs), SSM Agent 사전 설치 - AWS Systems Manager

SSM Agent가 이 목록에 없는 AWS 관리형 AMIs에 사전 설치되어 있을 수 있습니다. 이 경우 일반적으로 운영 체제(OS)에서 모든 Systems Manager 기능이 완벽하게 지원되지 않습니다. 또는 SSM Agent가 AWS Marketp

docs.aws.amazon.com

 

6. 터미널에 다음 커맨드를 입력한다

aws ssm start-session --target {EC2 인스턴스의 id} \
                       --document-name AWS-StartPortForwardingSessionToRemoteHost \
                       --parameters '{"portNumber":["3306"],"localPortNumber":["3306"],"host":["{RDS 엔드포인트}"]}'

 

그러면 ssh 포트포워딩을 했을 때처럼 로컬의 3306포트와 RDS의 3306포트가 연결된다.

 

 

 

 

 

참고한 글

https://blog.naver.com/PostView.naver?blogId=alice_k106&logNo=221364560794 

 

150. [SSH, Network] SSH 포트 포워딩(SSH 터널링)의 개념 및 사용 방법

이번 포스트에서는 'SSH 포트 포워딩' 또는 'SSH 터널링' 이라고 불리는 것에 대해서 설명한다. 1. ...

blog.naver.com

https://musma.github.io/2019/11/29/about-aws-ssm.html

 

AWS SSM으로 EC2 인스턴스에 접근하기 (SSH 대체)

목차 서론 들어가기: 더 좋은 방법 대상 독자 SSM: AWS Systems Manager 원격 호스트 접속 방법 비교: SSH (기존) vs. SSM (개선) S...

musma.github.io

https://hbase.tistory.com/328

 

[Linux] ssh 터널링(ssh port forwarding) - Local / Remote / Dynamic Tunneling

sh는 Secure SHell의 줄임말로 원격 호스트에 접속하기 위해 사용되는 보안 프로토콜이다. 당연하게도 ssh는 원격 호스트로 접속하기 위해 가장 많이 사용된다. 그런데 ssh는 원격 호스트로의 접속과

hbase.tistory.com

https://hwan-shell.tistory.com/382

 

AWS Session manager 란? (설명 및 설정 방

EC2를 접속할 때 기본적으로 SSH를 통해서 접속합니다. SSH를 통해서 접속하려면 SSH key가 필요하고 EC2 Inbound 22 port를 허용해 줘야 합니다. SSH를 사용하게 되면 Key 없이는 접속할 수 없고, Key를 분실

hwan-shell.tistory.com

https://aws.amazon.com/ko/about-aws/whats-new/2022/05/aws-systems-manager-support-port-forwarding-remote-hosts-using-session-manager/

 

AWS Systems Manager, Session Manager를 사용한 원격 호스트로의 포트 포워딩 지원 발표

AWS Systems Manager가 Session Manager를 사용한 원격 호스트로의 포트 포워딩 지원을 발표합니다. AWS Systems Manager는 AWS 애플리케이션 및 리소스를 위한 운영 허브이며, 하이브리드 클라우드 환경에 대해

aws.amazon.com

 

+ Recent posts