🌱 오늘의 주제 : Kakao 로그인(OAuth 2.0) in Spring Boot With 시퀀스 다이어그램
🌱 OAuth 2.0 이란?
OAuth 2.0 을 간단하게 설명하면 어떤 서비스를 만들 때 사용자 개인정보와 인증에 대한 책임을 지지 않고 신뢰할 만한 타사 플랫폼에 위임하는 겁니다.
보안적으로 문제되지 않도록 안전하게 관리해야 하고 ID/PW 에 관련된 지속적인 해킹 공격 등 여러 가지 신경써야 할 부분이 많습니다.
하지만 OAuth 2.0 을 사용해 신뢰할 수 있는 플랫폼 (구글, 페이스북, 네이버, 카카오 등) 에 개인정보, 인증 기능을 맡기면 서비스는 인증 기능에 대한 부담을 줄일 수 있습니다.
🌱 OAuth 2.0 주요 용어
Authentication
|
인증, 접근 자격이 있는지 검증하는 단계를 말합니다.
|
Authorization
|
인가, 자원에 접근할 권한을 부여하는 것입니다. 인가가 완료되면 리소스 접근 권한이 담긴 Access Token이 클라이언트에게 부여됩니다.
|
Access Token
|
리소스 서버에게서 리소스 소유자의 보호된 자원을 획득할 때 사용되는 만료 기간이 있는 Token입니다.
|
Refresh Token
|
Access Token 만료시 이를 갱신하기 위한 용도로 사용하는 Token입니다. Refresh Token은 일반적으로 Access Token보다 만료 기간이 깁니다.
|
🌱 Roles - OAuth 2.0의 4가지 역할
Resource
Owner
|
리소스 소유자 또는 사용자. 보호된 자원에 접근할 수 있는 자격을 부여해 주는 주체. OAuth2 프로토콜 흐름에서 클라이언트를 인증(Authorize)하는 역할을 수행합니다. 인증이 완료되면 권한 획득 자격(Authorization Grant)을 클라이언트에게 부여합니다. 개념적으로는 리소스 소유자가 자격을 부여하는 것이지만 일반적으로 권한 서버가 리소스 소유자와 클라이언트 사이에서 중개 역할을 수행하게 됩니다.
|
Client
|
보호된 자원을 사용하려고 접근 요청을 하는 애플리케이션입니다.
|
Resource
Server
|
사용자의 보호된 자원을 호스팅하는 서버입니다.
|
Authorization
Server
|
권한 서버. 인증/인가를 수행하는 서버로 클라이언트의 접근 자격을 확인하고 Access Token을 발급하여 권한을 부여하는 역할을 수행합니다. |
🌱 어플리케이션 등록
OAuth 2.0 서비스를 이용하기전에 선행되어야 하는 작업이 있다. Client를 Resource Server 에 등록해야하는 작업입니다. 이때, Redirect URI를 등록해야합니다. Redirect URI는 사용자가 OAuth 2.0 서비스에서 인증을 마치고 (예를 들어 구글 로그인 페이지에서 로그인을 마쳤을 때) 사용자를 리디렉션시킬 위치입니다.
Redirect URI
OAuth 2.0 서비스는 인증이 성공한 사용자를 사전에 등록된 Redirect URI로만 리디렉션 시킨니다. 승인되지 않은 URI로 리디렉션 될 경우, 추후 설명할 Authorization Code를 중간에 탈취당할 위험성이 있기 때문입니다. 일부 OAuth 2.0 서비스는 여러 Redirect URI를 등록할 수 있습니다.
Redirect URI는 기본적으로 보안을 위해 https만 허용됩니다. 단, 루프백(localhost)은 예외적으로 http가 허용됩니다.
Client ID, Client Secret
등록과정을 마치면, Client ID와 Client Secret를 얻을 수 있습니다. 발급된 Client ID와 Client Secret은 액세스 토큰을 획득하는데 사용됩니다. 이때, Client ID는 공개되어도 상관없지만, Client Secret은 절대 유출되어서는 안됩니다. 심각한 보안사고로 이어질 수 있습니다.
🌱 OAuth 2.0 Sequence Diagram
카카오 로그인 서비스를 구현하며 만들어 본 Sequence Diagram입니다.
간략히 요약하자면, SNS 로그인 완료 후 등록된 URL로 redirect 파라미터를 통해 Authorization Code를 받습니다. Authorization Code로 로그인 요청을 하면 Authorization Server에서 Access Token을 주고, 이 토큰으로 사용자의 profile 정보를 받아옵니다.
첫 로그인 시에는 가입처리를 하고, 사용자에게 Access Token 발급을 전달하는 방식입니다.
OAuth 2.0 은 일반적으로 위와 같은 플로우를 많이 사용하고 있으며 클라이언트와 서버측 모두 OAuth 2.0 플로우에 대해 숙지하고 있어야 합니다.
🌱 Authorization Code가 필요한 이유
그런데 조금 이상한 생각이 들었습니다. Authorization Code를 발급하지 않고, 곧바로 Client에게 Access Token을 발급해줘도 되지 않을까? 왜 굳이 Access Token을 획득하는 과정에 Authorization Code 발급 과정이 필요할까?
Redirect URI를 통해 Authorization Code를 발급하는 과정이 생략된다면, Authorization Server가 Access Token을 Client에 전달하기 위해 Redirect URI를 통해야 합니다. 이때, Redirect URI를 통해 데이터를 전달할 방법은 URL 자체에 데이터를 실어 전달하는 방법밖에 존재하지 않습니다. 브라우저를 통해 데이터가 곧바로 노출되는 되는 것입니다.
하지만, Access Token은 민감한 데이터이기 때문에 쉽게 노출되어서는 안됩니다. 이런 보안 사고를 방지 Authorization Code를 사용하는 것 입니다.
Redirect URI를 프론트엔드 주소로 설정하여, Authorization Code를 프론트엔드로 전달합니다. 그리고 이 Authorization Code는 프론트엔드에서 백엔드로 전달됩니다. 코드를 전달받은 백엔드는 비로소 Authorization Server의 token 엔드포인트로 요청하여 Access Token을 발급합니다.
이런 과정을 거치면 Access Token이 항상 우리의 어플리케이션과 OAuth 서비스의 백채널을 통해서만 전송되기 때문에 공격자가 Access Token을 가로챌 수 없게 됩니다.
🌱 Sever의 역할
- 카카오 OAuth 플랫폼에 인증 후 프로필 정보 가져오기
- email 정보로 사용자 확인 (없으면 새로 가입처리)
- Access Token 생성 후 내려주기
🌱 개발 환경 - build.Gradle
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.14'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}
group = 'com.smartChart'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'javax.xml.bind:jaxb-api'
// implementation 'jakarta.xml.bind:jakarta.xml.bind-api:2.3.0'
// implementation "jakarta.xml.bind:jakarta.xml.bind-api:2.3.2"
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.3.1'
testImplementation 'org.projectlombok:lombok:1.18.26'
compileOnly 'org.projectlombok:lombok'
implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter-test:2.3.1'
implementation 'org.apache.tomcat.embed:tomcat-embed-jasper'
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.5.7'
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
// OAuth2
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
// 간이 전자 우편 전송 프로토콜(Simple Mail Transfer Protocol, SMTP)
implementation 'org.springframework.boot:spring-boot-starter-mail'
// sms 문자 발송
implementation group: 'org.apache.httpcomponents', name: 'httpclient', version: '4.5.13'
// 아임포트 결제
allprojects {
repositories {
maven { url 'https://jitpack.io' }
}
}
implementation 'com.github.iamport:iamport-rest-client-java:0.2.21'
}
tasks.named('test') {
useJUnitPlatform()
}
<카카오 로그인 서비스와 관련된 간략한 프로젝트 환경>
- Spring Boot 2.7.14
- Java 17
- Spring Web
- Spring Data JPA
- Mysql
- Lombok
- JWT 라이브러리
Dependence에서 아래 코드를 추가합니다.
// OAuth2
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
🌱 application.yml
- 카카오 홈페이지에서 부여받은 client-id, client-secret를 적습니다.
security:
oauth2:
client:
registration:
kakao:
client-id:
client-secret:
scope:
- profile
- account_email
client-name: Kakao
client-authentication-method: POST
authorization-grant-type: authorization_code
redirect-uri: http://localhost:8080/auth/kakao/callback
# 네이버, 카카오는 OAuth2.0 공식 지원대상이 아니라서 provider 설정이 필요하다.
# 요청주소도 다르고, 응답 데이터도 다르기 때문이다.
# https://developers.naver.com/docs/login/devguide/#2-2-1-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8
# https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api
provider:
# naver:
# authorization-uri: https://nid.naver.com/oauth2.0/authorize
# token-uri: https://nid.naver.com/oauth2.0/token
# user-info-uri: https://openapi.naver.com/v1/nid/me
# user-name-attribute: response # 회원정보를 json의 response 키값으로 리턴해줌.
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: id
🌱 kakaoController
- Controller에 담아 주석으로 설명을 표현해 보았습니다.
package com.smartChart.patient;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.smartChart.patient.Service.PatientService;
import com.smartChart.patient.dto.RequestDto.KakaoProfile;
import com.smartChart.patient.dto.RequestDto.OAuthToken;
import com.smartChart.patient.dto.RequestDto.PatientJoinRequest;
import com.smartChart.patient.dto.RequestDto.PatientLoginRequest;
import com.smartChart.patient.entity.Patient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.client.RestTemplate;
@Controller
public class KakaoContorller {
private Logger logger = LoggerFactory.getLogger(this.getClass());
// 보안을 위해 @Value를 사용.
@Value("${cos.key}")
private String cosKey;
@Autowired
private PatientService patientService;
/**
* 카카오톡 로그인
* @param code
* @return
*/
@GetMapping("/auth/kakao/callback")
public String kakaoCallback(String code) {
// POST방식으로 key=value 데이터를 요청(카카오쪽으로)
// <3가지 요청방식>
//Retrofit2
//OKHttp
//RestTemplate
RestTemplate rt = new RestTemplate();
//HttpHeader 오브젝트 생성
HttpHeaders headers = new HttpHeaders();
headers.add("Content-type","application/x-www-form-urlencoded;charset=utf-8");
// * 변수를 만들어서 하는게 더 좋음.
// HttpBody 오브젝트 생성
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type","authorization_code");
params.add("client_id","카카오로 받은 client_id를 넣습니다.");
params.add("redirect_uri","http://localhost:8080/auth/kakao/callback");
params.add("code",code);
// 위 headers와 params의 값을 갔고 있는 entity 생성
// HttpHeader와 HttpBody를 하나의 오브젝트에 담기
HttpEntity<MultiValueMap<String,String>> kakaoTokenRequest =
new HttpEntity<>(params,headers);
// 실제 요청
// Http 요청하기 - Post방식으로 - response 변수의 응답 받음.
ResponseEntity<String> response = rt.exchange( // exchange()함수는 HttpEntity를 담게 되어있음 , 그래서 위에 HttpEntity를 만듬
"https://kauth.kakao.com/oauth/token ",
HttpMethod.POST,
kakaoTokenRequest, // header 값과 body 값이 들어있음
String.class // 응답은 String으로 받음
);
// 라이브러리 종류 - Gson, Json Simple, ObjectMapper
// Jason 데이터를 자바에서 처리하기 위해 Object를 바꾼 것.
ObjectMapper objectMapper = new ObjectMapper();
OAuthToken oAuthToken = null;
try {
oAuthToken = objectMapper.readValue(response.getBody(), OAuthToken.class);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
logger.info("####################### 카카오 엑세스 토큰 : " + oAuthToken.getAccess_token());
RestTemplate rt2 = new RestTemplate();
//HttpHeader 오브젝트 생성
HttpHeaders headers2 = new HttpHeaders();
headers2.add("Authorization","Bearer " + oAuthToken.getAccess_token()); // Bearer 뒤에 한칸 띄어놔야함.
headers2.add("Content-type","application/x-www-form-urlencoded;charset=utf-8");
// 위 headers와 params의 값을 갔고 있는 entity 생성
// HttpHeader와 HttpBody를 하나의 오브젝트에 담기
HttpEntity<MultiValueMap<String,String>> kakaoProfileRequest2 =
new HttpEntity<>(headers2);
// 실제 요청
// Http 요청하기 - Post방식으로 - response 변수의 응답 받음.
ResponseEntity<String> response2 = rt2.exchange( // exchange()함수는 HttpEntity를 담게 되어있음 , 그래서 위에 HttpEntity를 만듬
"https://kapi.kakao.com/v2/user/me",
HttpMethod.POST,
kakaoProfileRequest2, // header 값이 들어있음
String.class // 응답은 String으로 받음
);
logger.info(response2.getBody());
ObjectMapper objectMapper2 = new ObjectMapper();
KakaoProfile kakaoProfile = null;
try {
kakaoProfile = objectMapper2.readValue(response2.getBody(), KakaoProfile.class);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
// Patient 오브젝트 : name, email, password
logger.info("####################### 카카오 아이디(번호) : " + kakaoProfile.getId());
logger.info("####################### 카카오 이메일 : " + kakaoProfile.getKakao_account().getEmail());
// System.out.println("환자 이름 : " + kakaoProfile.getKakao_account().getEmail() + "_" + kakaoProfile.getId()); // // 예를 들어 유저네임 중복 안되게 하기 위한 tip
logger.info("####################### 환자 이름 : " + kakaoProfile.getProperties().getNickname());
logger.info("####################### 환자 이메일 :" + kakaoProfile.getKakao_account().getEmail());
// UUID란 -> 중복되지 않는 어떤 특정 값을 만들어내는 알고리즘
//UUID garbagePassword = UUID.randomUUID(); // 임시방편으로 넣으 놓은 비밀번호임. (결국 쓰레기 비밀번호)
logger.info("####################### 환자서버 패스워드 :" + cosKey);
// dto 값 넣기
PatientJoinRequest kakaoRequest = PatientJoinRequest.builder()
.email(kakaoProfile.getKakao_account().getEmail())
.password(cosKey)
.name(kakaoProfile.getProperties().getNickname())
.gender("null")
.age(0)
.phoneNumber(0)
.oauth("kakao")
.build();
PatientLoginRequest kakaoLoginRequest = PatientLoginRequest.builder()
.email(kakaoProfile.getKakao_account().getEmail())
.password(cosKey.toString())
.build();
// 가입자 혹은 비가입자 체크 해서 처리
logger.info("#######################", kakaoRequest.getEmail());
Patient originPatient = patientService.회원찾기(kakaoRequest.getEmail());
// 비가입자일 경우 -> 회원가입
if(originPatient.getEmail() == null) {
logger.info("####################### 기존 회원이 아니기에 자동 회원가입을 진행합니다.");
patientService.register(kakaoRequest);
}
logger.info("####################### 자동 로그인을 진행합니다.");
// 가입자일 경우 -> 로그인
patientService.authenticate(kakaoLoginRequest);
return "redirect:/patient/page-view";
}
}
🌱 PatientService
- Service 에 있는 코드입니다.
- 대표적으로 3가지 메소드가 있습니다.
- 1. 가입자 혹은 비가입자를 체크하는 회원찾기 코드
- 2. 비가입자일 경우 회원가입 시키는 코드
- 3. 가입자일 경우 로그인 하는 코드
package com.smartChart.patient.Service;
import com.smartChart.auth.AuthenticationResponse;
import com.smartChart.config.JwtService;
import com.smartChart.patient.dto.RequestDto.PatientJoinRequest;
import com.smartChart.patient.dto.RequestDto.PatientLoginRequest;
import com.smartChart.patient.dto.RequestDto.PatientMypageInterface;
import com.smartChart.patient.dto.RequestDto.PatientMypageListInterface;
import com.smartChart.patient.dto.ResponseDto.MailResponse;
import com.smartChart.patient.entity.Patient;
import com.smartChart.patient.entity.Role;
import com.smartChart.patient.repository.PatientRepository;
import com.smartChart.reservation.entity.Reservation;
import com.smartChart.reservation.repository.ReservationRepository;
import com.smartChart.token.Token;
import com.smartChart.token.TokenRepository;
import com.smartChart.token.TokenType;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class PatientService {
private Logger logger = LoggerFactory.getLogger(this.getClass());
private final PatientRepository patientRepository;
private final TokenRepository tokenRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
private final ReservationRepository reservationRepository;
private final JavaMailSender mailSender;
/**
* 회원가입
*
* @param request
* @return
*/
public AuthenticationResponse register(PatientJoinRequest request) {
var patient = Patient.builder()
// Patient
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.name(request.getName())
.gender(request.getGender())
.age(request.getAge())
.phoneNumber(request.getPhoneNumber())
.role(Role.PATIENT)
.oauth(request.getOauth())
.build();
patientRepository.save(patient);
var savedPatient = patientRepository.save(patient);
var jwtToken = jwtService.generateToken(patient); // jwtService에서 token 가져오기
savePatientToken(savedPatient, jwtToken);
return AuthenticationResponse.builder()
.token(jwtToken)
.build();
}
/**
* 로그인
*
* @param request
* @return
*/
public AuthenticationResponse authenticate(PatientLoginRequest request) {
// 유저가 올바르다면..
authenticationManager.authenticate( // to take object to users' token
new UsernamePasswordAuthenticationToken(
request.getEmail(),
request.getPassword()
)
);
// 유저가 올바르지 않다면...
var patient = patientRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new RuntimeException("code: 500" + "message : 로그인 정보가 올바르지 않습니다."));
var jwtToken = jwtService.generateToken(patient);
revokeAllPatientTokens(patient); // 모든 사용자 토큰 철회
savePatientToken(patient, jwtToken); // 사용자 토큰 저장
return AuthenticationResponse.builder()
.token(jwtToken)
.build();
}
/**
* 모든 환자의 토큰을 철회하기
*
* @param patient
*/
private void revokeAllPatientTokens(Patient patient) {
var validPatientTokens = tokenRepository.findAllValidTokensByPatient(patient.getId());
if (validPatientTokens.isEmpty())
return;
validPatientTokens.forEach(t -> {
t.setExpired(true);
t.setRevoked(true);
});
tokenRepository.saveAll(validPatientTokens);
}
/**
* 환자 토큰 저장
*
* @param patient
* @param jwtToken
*/
private void savePatientToken(Patient patient, String jwtToken) {
var token = Token.builder() // 객체를 생성할 수 있는 빌더를 builder() 함수
.patient(patient)
.token(jwtToken)
.tokenType(TokenType.BEARER)
.revoked(false)
.expired(false)
.build();
tokenRepository.save(token);
}
/**
* 카카오톡 로그인으로 회원찾기
*
* @param email
* @return
*/
@Transactional(readOnly = true)
public Patient 회원찾기(String email) {
Patient patient = patientRepository.findByEmail(email).orElseGet(() -> { // orElseGet 만약 회원을 찾았는데 없으면 빈 객체를 리턴해라.
return new Patient(); // null이 아니고 빈 객체를 반환.
});
return patient;
}
🌱 kakaoProfile DTO
- 보기 좋게 정리하기 위하여 내부 클래스를 이용하였습니다.
package com.smartChart.patient.dto.RequestDto;
import lombok.Data;
@Data
public class KakaoProfile {
public Long id;
public String connected_at;
public Properties properties;
public Kakao_account kakao_account;
@Data
public class Properties {
public String nickname;
}
@Data
public class Kakao_account {
public Boolean profile_nickname_needs_agreement;
public Profile profile;
public Boolean has_email;
public Boolean email_needs_agreement;
public Boolean is_email_valid;
public Boolean is_email_verified;
public String email;
public Boolean has_gender;
public Boolean gender_needs_agreement;
public String gender;
@Data
public class Profile {
public String nickname;
}
}
}
'Spring' 카테고리의 다른 글
JWT 토큰 소개와 Spring Security + JWT 코드 공유 (0) | 2023.09.15 |
---|---|
포트원 결제 - 카카오페이 (Spring, Java) With 시퀀스 다이어그램 (0) | 2023.09.13 |
Spring - OAuth2.0 + Social Login (0) | 2023.08.22 |
Spring - web-socket (0) | 2023.08.06 |
Spring - Interceptor란? (1) | 2023.05.17 |