게시판 프로젝트를 진행하면서 너무나도 많이 사용되는 OAuth 기능을 사용해보고자 KaKao OAuth2.0을 적용하여 사용자 인증 서비스를 제공하려고 한다. 프로젝트에 도입하면서 과정을 기록해놔야 다음에 적용할 때 참고하기 좋을 것 같다! ( 꽤 길다..) 그럼 순차적으로 진행해 보겠다.
이 글은 Spring Security에 OAuth2.0을 적용하는 과정이며 OAuth2.0의 작동원리나 자세한 스펙은 KaKao Developers의 문서를 꼭꼭 참고하길 바란다. -정말 자세히 친절하게 설명되어 있음.
+ full code change는 github pr을 참조하면 좋을 것 같다.
1. KaKao Developers에서 카카오 API를 사용하기 위한 설정 진행.
1.1 애플리케이션 등록하기
카카오 디벨로퍼에 가서 카카오 계정으로 로그인한 후 내 애플리케이션 -> 애플리케이션 추가하기를 선택한다
간단하게 앱 이름과 사업자명(본인 이름을 작성했음)을 작성하고 저장을 한다.
1.2 REST API 키 확인하기
생성된 애플리케이션에 들어가면 앱 키를 얻을 수 있는데
네이티브 앱 -> Android, iOS와 같은 앱에서 사용할 때 필요한 키
REST API 키 -> 우리가 사용할 키, 말 그대로 REST API에 사용할 키
JavaScript 키 -> 프론트쪽에서 자바스크립트에서 사용할 키
Admin 키 -> Admin 권한을 사용할 때 필요한 키
1.3 사이트 도메인 등록하기
왼쪽에 보면 플랫폼<을 선택하여 들어와 도메인을 등록해야 한다. 도메인은 localhost(로컬 개발환경)과 배포 도메인을
작성하여 추가해주면 된다.
여기서 기본 도메인이 설정되는데, 입력한 도메인들 중 맨 위로 잡히며 현재는 개발 중이기 때문에 localhost를 맨 위로 두었음.
1.4 Redirect URI 등록하기
바로 위 사진에서 도메인을 등록하면 아래에 Redirect URI를 등록하러 갈 수 있다.
Redirect URI는 카카오 로그인을 하고 나서 그 로그인이 정상적으로 됐다면 응답받을 주소이다.
여기서 설정한 URI Format은 Spring 문서에 나오는 format을 바탕으로 설정해 주었음. yml파일에 설정하기 때문에 자유롭게 해 주어도 무방한 듯하다.
1.5 동의항목 설정하기
동의 항목을 설정해 로그인 시 사용자 정보를 가져올 수 있다.
필수 항목은 1가지만 선택할 수 있고 동의 목적을 간단하게 작성해 준다.
1.6 Client Secret 키 확인하기
보안탭으로 이동해서 Client Secret을 확인할 수 있다.
-추가로 카카오 로그인이 OFF 되어있는데, ON으로 바꿔줘야 사용 가능하다
간단하게 KaKao OAuth2.0을 사용하기 위한 설정은 끝났고, 이제 프로젝트로 돌아가서 applcation.yml을 수정해 준다.
application.yml 설정
spring:
security:
oauth2:
client:
registration:
kakao:
client-id: ${KAKAO_OAUTH_CLIENT_ID}
client-secret: ${KAKAO_OAUTH_CLIENT_SECRET}
authorization-grant-type: authorization_code # 사용자 권한 인증 방식 중 하나
redirect-uri: "{baseUrl}/login/oauth2/code/kakao" # 인증 시 redirect 되는 주소
client-authentication-method: POST # 클라이언트 인증 시 사용할 메서드
provider: # 사용자 정보를 카카오 uri OAuth 서버로 호출해서 가져오는 것
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize # 권한을 알 떄 필요한 Uri
token-uri: https://kauth.kakao.com/oauth/token # token을 발급받을 때 필요한 uri
user-info-uri: https://kapi.kakao.com/v2/user/me # user 정보 uri, Oauth 서버로부터 가져오는 uri, db에 저장하지 않고..
user-name-attribute: id # user name을 확인하기 위한 식별자 -> json에서의 attribute를 지정
yml 설정은 registraion과 provider에 대한 설정으로 나뉘는데, registration에서 볼 부분은 {baseUrl}인데, 우리가 호스팅 하는 웹페이지의 url은 local이 될 수도, 배포 도메인이 될 수도 있기 때문에 상황에 따라 알맞은 url을 넣어주는 옵션이다.
provider부분에 작성된 내용은 카카오 문서를 참고하여 작성한다.
설명은 주석으로 대체.
프로젝트에 OAuth 관련 설정을 하기 전에 간단하게 Spring Security에서 어떻게 사용자 인증을 진행하는지 설명한다.
- 사용자가 로그인 정보와 함께 인증 요청을 한다.(Http Request)
- AuthenticationFilter가 요청을 가로채고, 가로챈 정보를 통해 UsernamePasswordAuthenticationToken의 인증용 객체를 생성한다.
- AuthenticationManager의 구현체인 ProviderManager에게 생성한 UsernamePasswordToken 객체를 전달한다.
- AuthenticationManager는 등록된 AuthenticationProvider(들)을 조회하여 인증을 요구한다.
- 실제 DB에서 사용자 인증정보를 가져오는 UserDetailsService에 사용자 정보를 넘겨준다.
- 넘겨받은 사용자 정보를 통해 DB에서 찾은 사용자 정보인 UserDetails 객체를 만든다.
- AuthenticationProvider(들)은 UserDetails를 넘겨받고 사용자 정보를 비교한다.
- 인증이 완료되면 권한 등의 사용자 정보를 담은 Authentication 객체를 반환한다.
- 다시 최초의 AuthenticationFilter에 Authentication 객체가 반환된다.
- Authenticaton 객체를 SecurityContext에 저장한다.
그리고 Controller에서 Authentication 객체를 가져와 사용자 정보를 사용한다!
카카오 OAuth2.0 기능을 사용하면, 우리의 DB에는 사용자 정보가 없다. 따라서 우리는 Kakao OAuth에서 제공해주는 사용자 정보를 가지고서 DB에 사용자를 저장해줘야 한다.
그전에 build.gradle에 가서 다음과 같은 코드를 적당한 위치에 한 줄 추가하자.
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
그렇다면 카카오에서는 어떤 정보를 우리에게 제공해 줄까? 이도 마찬가지로 문서에 가보면 Response 부분에 내려주는 내용이 있다.
더 쉽게 볼 수 있게 테스트 코드로 어떤 정보들이 주어지는지 보여주도록 하겠다.
@DisplayName("DTO - Kakao OAuth 2.0 인증 응답 데이터 테스트")
class KakaoOAuth2ResponseTest {
private final ObjectMapper mapper = new ObjectMapper();
@DisplayName("인증 결과를 Map(deserialized json)으로 받으면, 카카오 인증 응답 객체로 변환한다.")
@Test
void givenMapFromJson_whenInstantiating_thenReturnsKakaoResponseObject() throws Exception {
// Given
// 자바 15에서 도입된 기술 블록 텍스트 기능
String serializedResponse = """
{
"id": 1234567890,
"connected_at": "2022-01-02T00:12:34Z",
"properties": {
"nickname": "홍길동"
},
"kakao_account": {
"profile_nickname_needs_agreement": false,
"profile": {
"nickname": "홍길동"
},
"has_email": true,
"email_needs_agreement": false,
"is_email_valid": true,
"is_email_verified": true,
"email": "test@gmail.com"
}
}
""";
Map<String, Object> attributes = mapper.readValue(serializedResponse, new TypeReference<>() {});
// When
KakaoOAuth2Response result = KakaoOAuth2Response.from(attributes);
// Then
assertThat(result)
.hasFieldOrPropertyWithValue("id", 1234567890L)
.hasFieldOrPropertyWithValue("connectedAt", ZonedDateTime.of(2022, 1, 2, 0, 12, 34, 0, ZoneOffset.UTC)
.withZoneSameInstant(ZoneId.systemDefault())
.toLocalDateTime()
)
.hasFieldOrPropertyWithValue("kakaoAccount.profile.nickname", "홍길동")
.hasFieldOrPropertyWithValue("kakaoAccount.email", "test@gmail.com");
}
}
추가로 Response를 입력받을 KakaoOAuth2Response 객체도 생성해야 한다.
/*
https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info-response 를 보고 만든 Record
*/
@SuppressWarnings("unchecked") // TODO: Map -> Object 변환 로직이 있어 제네릭 타입 캐스팅 문제를 무시한다.
public record KakaoOAuth2Response(
Long id,
LocalDateTime connectedAt,
Map<String, Object> properties,
KakaoAccount kakaoAccount
){
public record KakaoAccount(
Boolean profileNicknameNeedsAgreement,
Profile profile,
Boolean hasEmail,
Boolean emailNeedsAgreement,
Boolean isEmailValid,
Boolean isEmailVerified,
String email
) {
public record Profile(String nickname) {
public static Profile from(Map<String, Object> attributes) {
return new Profile(String.valueOf(attributes.get("nickname")));
}
}
public static KakaoAccount from(Map<String, Object> attributes) {
return new KakaoAccount(
Boolean.valueOf(String.valueOf(attributes.get("profile_nickname_needs_agreement"))),
Profile.from((Map<String, Object>) attributes.get("profile")),
Boolean.valueOf(String.valueOf(attributes.get("has_email"))),
Boolean.valueOf(String.valueOf(attributes.get("email_needs_agreement"))),
Boolean.valueOf(String.valueOf(attributes.get("is_email_valid"))),
Boolean.valueOf(String.valueOf(attributes.get("is_email_verified"))),
String.valueOf(attributes.get("email"))
);
}
public String nickname() { return this.profile().nickname(); }
}
public static KakaoOAuth2Response from(Map<String, Object> attributes) {
return new KakaoOAuth2Response(
Long.valueOf(String.valueOf(attributes.get("id"))),
LocalDateTime.parse(
String.valueOf(attributes.get("connected_at")),
DateTimeFormatter.ISO_INSTANT.withZone(ZoneId.systemDefault())
),
(Map<String, Object>) attributes.get("properties"),
KakaoAccount.from((Map<String, Object>) attributes.get("kakao_account"))
);
}
public String email() { return this.kakaoAccount().email(); }
public String nickname() { return this.kakaoAccount().nickname(); }
}
★ 이렇게 세팅을 해놓으면 카카오 인증 응답 정보를 받아와 사용할 수 있다.
자 그럼 Kakao OAuth 기능을 통해 로그인했을 때 반환되는 정보를 어떻게 저장하고 로그인하는지 알아보자
SecurityConfig에 oauth2Login 설정하기
Spring Security에서 OAuth2Login에 대해 처리할 로직을 구현하도록 한다.
...
http.oauth2Login(oAuth -> oAuth
.userInfoEndpoint(userInfo -> userInfo
.userService(oAuth2UserService))
)
...
@Bean
public OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService(
UserAccountService userAccountService,
PasswordEncoder passwordEncoder
) {
// 기본 OAuth2.0 인증 처리 구현체 사용
final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
return userRequest -> {
OAuth2User oAuth2User = delegate.loadUser(userRequest); // UserDetailsService에서 제공해야하는 loadByUserName과 같음
KakaoOAuth2Response kakaoResponse = KakaoOAuth2Response.from(oAuth2User.getAttributes());
String registrationId = userRequest.getClientRegistration().getRegistrationId(); // yml에 설정한 고유값을 반환 -> "kakao"
String providerId = String.valueOf(kakaoResponse.id());
// 카카오에선 username을 주지 않는다. pk로 사용할 id를 생성해야 한다.
String username = registrationId + "_" + providerId;
// password는 카카오에서 관리할 일이지 우리 DB와는 관계가 없지만 DB 테이블 설계상 notnull이기에 생성해준다.
String dummyPassword = passwordEncoder.encode("{bcrypt}" + UUID.randomUUID()); // {}에 encoding 알고리즘을 넣어준다.
return userAccountService.searchUser(username)
.map(BoardPrincipal::from)
.orElseGet(() ->
BoardPrincipal.from(
userAccountService.saveUser(
username,
dummyPassword,
kakaoResponse.email(),
kakaoResponse.nickname(),
null // memo는 null로 넣어줌
)
));
};
}
간단하게 설명하면
- 기본 OAuth2.0 인증 처리 구현체를 사용하여 OAuth2User라는 객체를 불러온다.
- username이나 password는 카카오에서 관리하기 때문에 우리 DB에는 pk로 사용할 수 있도록 로직을 구현하여 저장한다.
- username으로 된 계정이 DB에 있는지 확인, 없다면 DB에 저장하는 로직까지 구현
+ 뷰에 관한 것은 맨 위의 링크에 있는 github를 참고. 설명은 생략하겠다.
프로젝트를 실행하면
간단하게 kakao login 아이콘을 가져와 넣어놓았다. 클릭하게 되면
처음 로그인하게 되면 필수 정보 동의를 하는 창으로 넘어가는데, 나는 이전에 로그인했던 기록이 세션에 남아있었는지 바로 로그인이 됨!
nickname이 나타나는 위치에 내 이름이 나오게 되면서 Kakao OAuth2.0 로그인 기능이 적용되었다.
설정을 잘못하게 되면 아래와 같은 페이지로 이동하게 되는데, 해결방안에 들어가 에러코드를 확인해보면 쉽게 해결할 수 있을 것이다.
결론
- 사용자 계정에 관한 관리는 보안적으로도 매우 중요하며 이슈가 많이 발생할 수 있음
- 서비스에서 계정관리는 반복적인 작업이며 매번 보안과 인증 때문에 많은 시간을 소모
- 이에 따라 Kakao, Google과 같이 OAuth2.0 기능을 제공하는 곳에 인증 기능을 맡기게 되면 서비스 개발에 더 집중하고 시간을 아낄 수 있다.
- OAuth2.0 설정은 관련 공식 문서를 찾아보면 정말 세세히 나와있다. API 스펙과 적용 방법 등.
이번 게시글에선 `로그인`에 대해서만 적용을 한 것이다.
- 카카오에 대해 로그아웃을 하지 않았기 때문에 저장된 세션에 의해 로그아웃하고 다시 로그인을 누르면 자동으로 로그인이 되어버림.
다음 게시글에선 로그아웃에 대해 어떻게 적용할 지에 대해 정리해보도록 하겠습니다!
'Spring' 카테고리의 다른 글
톰캣과 스프링 web 설정 (0) | 2023.10.25 |
---|---|
Mockito 테스트 Argument(s) are different 에러 (0) | 2023.05.01 |
Spring Security 작동 원리 (0) | 2022.11.23 |
Refresh Token이란? (1) | 2022.11.19 |
JWT란 무엇인가? (0) | 2022.11.18 |