이전 프로젝트들은 oauth만을 이용한 로그인을 구현해왔었다.
oauth를 이용한 소셜로그인은 보안상 완벽하다고 생각했다.
하지만 프로젝트를 두가지 동시에 진행하면서 테스팅 하던 중 카카오 측에서 발급해준 accessToken이 꼬였고, 다른 프로젝트에 해당 accessToken을 넣어서 테스팅해버렸다.
그런데? 놀랍게도 로그인 처리가 문제없이 되었다.
사실 당연한것이다.
기존 oauth만 사용한 방식에서 accesstoken로 유저정보 확인하는 경우
이렇게 토큰만 넣어서 호출하는 방식이기때문에 어디서 발급한 토큰이더라도 로그인이 가능해진다.
🤔인가 vs 인증
인가란, 자원에 접근할 권한을 부여하는 것이다.
인가가 완료되면 리소스 접근 권한이 담긴 Access Token이 클라이언트에게 부여된다.
반면에 인증이란, 접근 자격이 있는지 검증하는 것이다.
이렇게 둘의 차이점을 알고 보았을 때, 지금까지 사용했던 Oauth2.0만 사용한 방식은 인가만 담당했던 것이다.
따라서 인증의 과정을 제쳐두고 진행한 방식이었고, 인증을 추가해 줘야했다.
이것을 해결하려면 어떻게 해야할까?
1. 인가코드만 프론트측에서 받아오고 나머지로직은 백엔드에서 진행한다.
몇번 해본 로직이라 이렇게 진행하면 해결가능했다.
프론트측에서 넘겨주는값이 accesstoken이 아니라 code이고, 이 인가코드는 일회용이라 보안상에도 큰 문제가없었다.
하지만, 내 경험을 돌아봤을 때 해당 로직은 몇가지 문제점이 있었다.
- code를 통해 accesstoken을 받아오고 이 엑세스토큰으로 한번 더 사용자 정보 받아오기 외부 api를 한번 더 호출해야한다.
- 로그인과 회원가입 로직을 분리하기 어려워진다.
- 로그인 시도 후 계정이 없을 시 회원가입로직을 진행하는데 해당 계정정보와 회원가입이 필요하다 라는 정보를 한번에 전달할 방법을 고안해야한다.
사실 이 경우, 다른 어플리케이션에서 획득한 토큰으로 로그인하는 것만 막은 것뿐이지 인증이라고 보기 어렵다.
Oauth의 본질적인 목적은 인증이 아니고 API호출의 권한을 확인 하는 인가인 것이다.
이러한 문제점들때문에 다른 대안을 찾아보았다.
2. 인가코드를 전달하는 로직과 토큰을 다시 백엔드로 전달하여 진행하되 OIDC로 구현한다.
로직자체는 1번의 문제점을 해결하는 프론트측에서 토큰전달 로직을 사용한다.
하지만, 처음에 언급한 문제를 해결하기 위해 우리 서버에서 발급한 토큰만 로그인이 가능하도록 검증한다.
이 기능을 가능케 하는 토큰이 바로 OIDC방식의 idToken이다.
❓ OIDC란? (OpenID Connect)
OIDC는 OAuth 2.0 프로토콜의 상위 계층에서 인증을 담당하는 프로토콜이다.
Client가 User의 신원을 검증하도록 해줌과 동시에 기본적인 유저 정보를 얻을 수 있게 해준다.
OAuth프로토콜을 통해 리소스에 접근하기 위해서는 먼저 인증이 선행되어야 하므로 OIDC가 OAuth의 상위 계층에 위치하게 되는 것이다.
가장 중요한 특징은 표준이라는 것이다.
카카오, 구글, 애플 등등 OIDC를 사용하는 어디든 같은 방식을 사용한다.
즉, OIDC의 인증과정에서 사용되는 idToken만 있으면 어떤 것이든 로직으로 로그인 구현이 가능해진다.
여기서 idToken은 OIDC에서 사용되는 정보들을 담은 JWT이다.
이 토큰의 payload에는 사용자 정보들이 담겨있기 때문에 토큰으로 사용자 유저 정보 확인하기 등 추가정보를 확인할 필요가 없다.
그냥 토큰의 검증을 마치면 추가적인 api호출없이 유저정보를 알수 있다는 의미다.
기존방식보다 외부 api 호출횟수를 1회 줄일 수 있고, 로그인이 빈번해질수록 성능이 좋아진다는 장점이 있다.
🛠️ 구현하기
이제 구현을 해보자.
문서가 가장 깔끔하게 정리된 kakao Developer를 보면서 구현했다.
위의 사진에서 idToken은 Step 2:토큰 받기에서 받아올 수 있다.
받아오는것은 아래처럼 OpenID Connect를 설정한 후 기존 accesstoken받기와 같은 api를 호출하면 안에 idtoken이 포함되어 있다.
1. idToken 발급
그럼 idToken을 받아와보자
@FeignClient(
name = "RequestKakaoTokenClient",
url = "<https://kauth.kakao.com>",
configuration = RequestKakaoTokenErrorDecoder.class)
public interface RequestKakaoTokenClient {
@PostMapping("/oauth/token?grant_type=authorization_code")
KakaoTokenInfoDto getToken(
@RequestParam("client_id") String clientId,
@RequestParam("redirect_uri") String redirectUri,
@RequestParam("code") String code,
@RequestParam("client_secret") String clientSecret);
@GetMapping("/.well-known/jwks.json")
PublicKeysDto getPublicKeys();
}
코드의 가독성과 편리함때문에 Feign client를 사용하였지만, RestTemplate등 외부 api를 호출할 수 있는 아무거나 사용해도 된다.
getToken으로 받아온 토큰들은 아래와 같이 나온다. (api의 response값)
{
"access_token": "~~~",
"refresh_token": "~~~",
"id_token": "~~~" // jwt로 인코딩
...
...
}
아래는 그 중 idToken을 디코드한 것이다.
/* header */
{
"kid": "~~~~~", // 공개키 아이디
"typ": "JWT",
"alg": "RS256"
}
/* payload */
{
"aud": "해당 client Id (app key)",
"sub": "나의 카카오 유저 고유 id",
"auth_time": 1696294455,
"iss": "<https://kauth.kakao.com>", //발급처
"nickname": "김슬기",
"exp": 1696316055,
"iat": 1696294455,
"email": "tmfrk0426@gmail.com"
}
2. 공개키 목록 가져오기
이제 다음 과정으로 유효성을 검증해야한다.
그 전에 유효성 검증 중 서명검증에서 사용할 RSAPublicKey를 생성해야한다.
이 암호화된 키는 각 소셜로그인이 제공하는 공개키 목록을 조회한 후 목록들 중 하나를 선택하여 만든다.
단, 여기서 공개키는 kid가 발급받은 idToken의 헤더부분의 kid와 일치해야한다.
이 공개키는 카카오 디벨로퍼에 있는 공개키 목록 조회하기 api를 사용한다.
HTTP/1.1 200 OK
{
"keys": [
{
"kid": "3f96980381e451efad0d2ddd30e3d3",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "q8zZ0b_MNaLd6Ny8wd4cjFomilLfFIZcmhNSc1ttx_oQdJJZt5CDHB8WWwPGBUDUyY8AmfglS9Y1qA0_fxxs-ZUWdt45jSbUxghKNYgEwSutfM5sROh3srm5TiLW4YfOvKytGW1r9TQEdLe98ork8-rNRYPybRI3SKoqpci1m1QOcvUg4xEYRvbZIWku24DNMSeheytKUz6Ni4kKOVkzfGN11rUj1IrlRR-LNA9V9ZYmeoywy3k066rD5TaZHor5bM5gIzt1B4FmUuFITpXKGQZS5Hn_Ck8Bgc8kLWGAU8TzmOzLeROosqKE0eZJ4ESLMImTb2XSEZuN1wFyL0VtJw",
"e": "AQAB"
}, {
"kid": "9f252dadd5f233f93d2fa528d12fea",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "qGWf6RVzV2pM8YqJ6by5exoixIlTvdXDfYj2v7E6xkoYmesAjp_1IYL7rzhpUYqIkWX0P4wOwAsg-Ud8PcMHggfwUNPOcqgSk1hAIHr63zSlG8xatQb17q9LrWny2HWkUVEU30PxxHsLcuzmfhbRx8kOrNfJEirIuqSyWF_OBHeEgBgYjydd_c8vPo7IiH-pijZn4ZouPsEg7wtdIX3-0ZcXXDbFkaDaqClfqmVCLNBhg3DKYDQOoyWXrpFKUXUFuk2FTCqWaQJ0GniO4p_ppkYIf4zhlwUYfXZEhm8cBo6H2EgukntDbTgnoha8kNunTPekxWTDhE5wGAt6YpT4Yw",
"e": "AQAB"
}
]
} // 카카오에 올라온 예시이다.
본인은 토큰을 받을 때와 마찬가지로 feign client를 사용했다.
@GetMapping("/.well-known/jwks.json")
PublicKeysDto getPublicKeys();
//나머지 코드는 위의 토큰발급 부분에 있다.
위의 kid목록 중 kid가 일치하는 녀석을 가져와서 RSA암호화 키를 생성하면 된다.
위의 공개키 목록 중 하나를 하나 까보자.
{
"kid": "9f252dadd5f233f93d2fa528d12fea",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "qGWf6RVzV2pM8YqJ6by5exoixIlTvdXDfYj2v7E6xkoYmesAjp_1IYL7rzhpUYqIkWX0P4wOwAsg-Ud8PcMHggfwUNPOcqgSk1hAIHr63zSlG8xatQb17q9LrWny2HWkUVEU30PxxHsLcuzmfhbRx8kOrNfJEirIuqSyWF_OBHeEgBgYjydd_c8vPo7IiH-pijZn4ZouPsEg7wtdIX3-0ZcXXDbFkaDaqClfqmVCLNBhg3DKYDQOoyWXrpFKUXUFuk2FTCqWaQJ0GniO4p_ppkYIf4zhlwUYfXZEhm8cBo6H2EgukntDbTgnoha8kNunTPekxWTDhE5wGAt6YpT4Yw",
"e": "AQAB"
}
- kid : idToken의 kid와 동일한 키값인지 확인할 때 사용
- kty : 공개키 타입으로 RSA로 고정
- alg : 암호화 알고리즘 종류
- use : 공개키의 용도, sig(서명)으로 고정
- n : 공개키 모듈(Modulus), 공개키는 n과 e의 쌍으로 구성됨
- e : 공개키 모듈(Modulus), 공개키는 n과 e의 쌍으로 구성됨
이 정보들로 RSA암호화 키를 생성해야 한다.
암호화 키를 생성해 본적이 없어 이 과정에서 좀 헤맸다.
순서대로 차근차근 구현해보자.
우선, 검증되지 않은 idToken에서 kid만 가져오도록 하자
/* JwtIdTokenProvider */
/* kid 서명검증없이 가져오기 */
public String getKid(String idToken){
try{
String[] idTokenParts = idToken.split("\\\\.");
String encodedHeader = idTokenParts[0];
String decodedHeader = new String(Base64.getUrlDecoder().decode(encodedHeader), StandardCharsets.UTF_8);
ObjectMapper objectMapper = new ObjectMapper();
Map<String, String> map = objectMapper.readValue(decodedHeader, java.util.Map.class);
return map.get("kid");
} catch (Exception e){
throw new InvalidTokenException();
}
}
jwt는 .을 기준으로 헤더, 페이로드, 시그니쳐를 구분하므로 .으로 잘라준다.
헤더를 디코딩한 후 kid를 빼온다.
그 값으로 공개기 목록을 조회하여 kid값이 일치하는 공개키를 찾자.
/* 공개키 조회 */
@Cacheable(value = KAKAO_PUBLIC_KEYS, cacheManager = "redisCacheManager")
public PublicKeysDto getCachedKakaoPublicKeys(){
return requestKakaoTokenClient.getPublicKeys();
}
@Cacheable(value = GOOGLE_PUBLIC_KEYS, cacheManager = "redisCacheManager")
public PublicKeysDto getCachedGooglePublicKeys(){
return requestGooglePublicKeysClient.getPublicKeys();
}
여기서 @Cacheable를 사용한 이유는 공개키 조회 자체를 캐싱처리하기 위함이다.
카카오에서도 고지하듯이.. 빈번하게 요청하면 차단된다고 한다.
(실제로.. 스웨거로 무분별하게 조회하다보니.. 차단을 한번 당했었다. 이유를 찾다가 이러한 한줄이 섞여있는걸 알게되었다….)
여기서 redisCacheManager은 이렇게 설정해줬다.
@RequiredArgsConstructor
@EnableCaching
@Configuration
public class RedisCacheManagerConfig {
private final RedisConnectionFactory redisConnectionFactory;
@Bean
public CacheManager redisCacheManager() {
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.entryTtl(Duration.ofDays(1L));
RedisCacheManager redisCacheManager = RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory)
.cacheDefaults(redisCacheConfiguration)
.withInitialCacheConfigurations(customConfigurationMap(redisCacheConfiguration))
.build();
return redisCacheManager;
}
/* 커스텀하여 만료기간 설정 */
private Map<String, RedisCacheConfiguration> customConfigurationMap(RedisCacheConfiguration redisCacheConfiguration) {
Map<String, RedisCacheConfiguration> customConfigurationMap = new HashMap<>();
customConfigurationMap.put(KAKAO_PUBLIC_KEYS, redisCacheConfiguration.entryTtl(Duration.ofDays(1L)));
customConfigurationMap.put(GOOGLE_PUBLIC_KEYS, redisCacheConfiguration.entryTtl(Duration.ofDays(1L)));
customConfigurationMap.put(REFRESH_TOKEN, redisCacheConfiguration.entryTtl(Duration.ofDays(14L)));
return customConfigurationMap;
}
}
더 진행해보자
/* 공개키 목록중 kid값일치하는것 찾기 */
PublicKeysDto publicKeys = publicKeyProcessor.getCachedKakaoPublicKeys();// 공개키 목록을 조회합니다.
PublicKeyDto key = publicKeys.getKeys().stream()
.filter(k -> k.getKid().equals(kid))
.findFirst()
.orElseThrow(() -> new IncorrectIssuerTokenException());
3. 공개키를 이용하여 RSAPublicKey 생성하기
이제 그 공개키를 가지고 RSA암호화 키를 생성해보자.
키를 생성할 때 필요한 값인 알고리즘 종류, 모듈 n, 모듈 e를 가져온다.
public static RSAPublicKey excute(String kty, String n, String e) {
BigInteger modulus = new BigInteger(1, Base64.getUrlDecoder().decode(n));
BigInteger exponent = new BigInteger(1,Base64.getUrlDecoder().decode(e));
try{
KeyFactory keyFactory = KeyFactory.getInstance(kty);
RSAPublicKeySpec keySpec = new RSAPublicKeySpec(modulus, exponent);
return (RSAPublicKey) keyFactory.generatePublic(keySpec);
} catch (NoSuchAlgorithmException | InvalidKeySpecException exception){
throw new PublicKeyGenerationException();
}
}
new BigInteger(1, Base64.getUrlDecoder().decode(n)); 에서 1 은 그냥 양수라는 뜻이다.
이렇게 생성한 RSAPublicKey로 서명검증을 진행 할 수 있다.
4. idToken 페이로드 & 서명 검증
유효성 검증를 진행하자.
과정은 카카오 디벨로퍼스에 자세히 설명되어있다. 카카오는 최고다.
우선 여기서 보면 페이로드를 디코딩한 후 iss, aud, exp 값을 비교해야한다.
jwt Provider로 claim을 가져오는 과정에서 exp는 자동으로 검증이 되니 iss, aud를 확인해주면 된다.
또 use 정보 처럼 jwt에선 signature에 키를 넣어 서명 검증이 가능하다.
이렇게되면 한번에 signature를 세팅해서 확인해주면 사실 2~ 7번의 과정이 한번에 진행되게 된다.
코드로 확인해보자.
/* JwtIdTokenProvider */
/* iss, aud, 만료시간 검증 & 서명검증 & 유저정보 가져오기 */
public UserInfoFromIdToken getUserInfo(String idToken, RSAPublicKey publicKey, String iss, String aud) {
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(publicKey)//서명 검증
.requireIssuer(iss) // iss검증
.requireAudience(aud) // aud검증
.build()
.parseClaimsJws(idToken) //만료시간은 자동으로 검증
.getBody();
return UserInfoFromIdToken.builder()
.email(claims.get("email", String.class))
.build();
} catch (SignatureException exception) {
throw new InvalidSignatureTokenException();
}catch (IncorrectClaimException exception){
throw new IncorrectIssuerTokenException();
}catch (ExpiredJwtException exception) {
throw new ExpiredTokenException();
} catch (Exception exception){
throw new InvalidTokenException();
}
}
이러면 매우 깔끔하게 토큰의 유효성도 한번에 검사가 진행되게된다.
이렇게 가져온 유저정보로 로그인을 수행한다.
OIDC로 인증을 걸친후 idToken에 담긴 유저정보로 우리 서버의 회원을 찾아서 사용하면 된다.
이렇듯 외부 api 호출횟수도 적어지고, 보다 보안이 강력한 로그인을 구현 할 수 있었다.
5. 로그인, 회원가입 분리
마지막으로 로그인과 회원가입을 분리해보자
아래는 이부분의 컨트롤러다.
@Operation(summary = "로그인", description = "id token과 login type으로 로그인 합니다.")
@PostMapping("/{logintype}/login")
public SuccessResponse<Object> login(
@RequestParam("idtoken") String idToken,
@PathVariable("logintype") String loginType){
AccountTokenDto accountTokenDto = loginUseCase.execute(loginType,idToken);
return SuccessResponse.of(accountTokenDto);
}
@Operation(summary = "회원가입", description = "id token과 login type으로 회원가입 합니다.")
@PostMapping("/{logintype}/signup")
public SuccessResponse<Object> signUp(
@RequestParam("idtoken") String idToken,
@PathVariable("logintype") String loginType,
@RequestBody SignUpRequestDto signUpRequestDto){
AccountTokenDto accountTokenDto = signUpUseCase.execute(loginType,idToken,signUpRequestDto);
return SuccessResponse.of(accountTokenDto);
}
우선 로그인의 로직은 보통 이렇게 굴러간다.
- 로그인 시도 api 호출 >> 회원가입된 유저 O >> 로그인 성공
- 로그인 시도 api 호출>> 회원가입된 유저 X >> 로그인 실패 >> 회원가입이 필요하다고 알림 >> 회원가입 api 호출
이렇게 진행되어야하므로 로그인 api에 출력값으로 회원가입 여부를 확인시켜줘야한다.
/* AccountTokenDto */
@Data
@Builder
public class AccountTokenDto {
@JsonInclude(NON_NULL)
private String accessToken;
@JsonInclude(NON_NULL)
private String refreshToken;
private Boolean isRegistered;
public static AccountTokenDto of(String accessToken,String refreshToken){
return AccountTokenDto.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.isRegistered(true)
.build();
}
public static AccountTokenDto notRegistered(){
return AccountTokenDto.builder()
.accessToken(null)
.refreshToken(null)
.isRegistered(false)
.build();
}
}
이와 같이 회원가입과 로그인 모드 같은 Dto로 출력하되,
회원가입 여부에 따라 isRegistered값을 다르게준다.
그리고 이 값이 false의 경우 앞선 accessToken과 RefreshToken은 필요없어지게 되므로
@JsonInclude(NON_NULL)으로 아예 없애버리도록 했다.
이렇게 보다 깔끔하고 안전하게 로그인을 구현해보았다.
💨 후기
OIDC를 알게되고 한번쯤 구현해봐야겠다고 마음만 먹고있었다.
항상 자료가 너무 부족해서 미루기만 하다가 그래도 멋사회사에서 사용하는 만큼
보안을 신경써야한다는 생각이들어 이번 기회에 드디어 구현했다.
OIDC 를 직접 구현한 자료가 너무 적어서 카카오와 구글 문서를 보면서 구현했다.
(확실히 구글보단 카카오가 설명이 자세하다)
이렇게 카카오를 먼저 OIDC로 구현했고 구글도 거의 동일한 내용이라 구글은 구현하기 쉬웠다.
혹시 둘다 구현할 생각이라면 카카오를 먼저 하기를 추천드립니다!!!
'백엔드 > Spring Boot' 카테고리의 다른 글
[Spring Boot] 멀티모듈 적용 및 세팅 (0) | 2023.10.01 |
---|