[TIL] Spring Boot - OAuth2, Spring Security, Serializable
Today I Learned...
오늘 배운 내용은 OAuth2를 이용한 구글 로그인이다. Ch05의 절반까지인데, 상당히 내용도 많고 복잡한데도 책에서 자세히 설명되기보단 실습 위주로 빠르게 넘어가는 식으로 서술되어 있어서 (ㅠㅜ 조금 더 설명하셔도 다 읽을게요.. 더 적어주세요!) 스스로 검색도 해보고 내용을 조금 추가했다.
1. OAuth란?
OAuth("Open Authorization")는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로써 사용되는, 접근 위임을 위한 개방형 표준이다
역시 위키백과는 그냥 봐선 설명이 이해가 잘 되지 않는다.. 다른 블로그 글을 참고해보자.
참고한 글 : https://hudi.blog/oauth-2.0/
OAuth 2.0 개념과 동작원리
2022년 07월 13일에 작성한 글을 보충하여 새로 포스팅한 글이다. OAuth 등장 배경 우리의 서비스가 사용자를 대신하여 구글의 캘린더에 일정을 추가하거나, 페이스북, 트위터에 글을 남기는 기능을
hudi.blog
우리가 예를 들어 카카오톡에 저장된 친구들에게 초대장을 보내는 서비스를 만든다고 해보자. 이름은 대충 인바이트라고 해보자. 그럼 다음과 같은 메커니즘이 사용될 수 있을 것이다. (물론 카카오톡은 OTP를 사용하기에 이런 방법은 아마도 안될 거야....)
- 사용자로부터 카카오톡 ID/PW를 받아온다. 이를 인바이트 DB에 저장해 두고, 이 사용자가 인바이트에 접속하면 해당 ID와 PW를 DB로부터 가져와 카카오 서버에 로그인을 시도한다.
- 카카오 서버에 로그인을 한 뒤, 이 사용자의 친구 정보를 모두 긁어온다. 그럼 인바이트는 친구 한 명 (혹은 전부 다) 내가 만든 초대장을 뿌린다.
- 자, 이제 인바이트를 론칭하면 된다!
보안에 조금 경각심이 있는 사람이라면 어디가 문제인 지 바로 알아챌 수 있을 것이다. 내가 고작 초대장 하나 보내겠다고 보안이 튼튼한지도 모르겠는 이상한(?) 서비스에 내 중요한 카카오톡 ID랑 비밀번호를 넘길 수 있을까? 인바이트 자체는 문제가 없어도 보안 사고 한 번 터지면 내 ID와 PW는 해커들의 공공재가 되어버린다... 정보 살리려면 비밀번호도 다 바꿔줘야 하고... 그렇다면 카카오톡의 정보를 다른 서비스에서 사용하는 방법은 없을까?
이 문제를 해결하기 위해 OAuth가 등장한다. 위에서 말하는 접근 위임이란, 타사의 서비스가 카카오톡, 구글, 네이버 등의 서비스에 접근하는 권한을 임시로 가질 수 있도록 하는 것을 말한다.
OAuth2.0는 보안 문제와 모바일 환경을 배려한 새로운 표준으로 이젠 2.0을 기반으로 코드를 작성하면 된다.
알아야 하는 용어는 다음과 같다.
Resource Owner (자원 소유자): 자원이란 내 이메일, 이름, 사진, 캘린더 일정 등 내가 저장한 정보들을 말한다. 즉, 자원 소유자는 접근하는 유저를 말한다.
Authorization&Resource Server: Authorization Server는 Resource Owner를 인증하고, 토큰을 발급해 준다. Resource Server는 자원을 담고 있는 서버이다.
Client: 자원을 이용하고자 하는 서비스, 즉 위에서 말한 인바이트와 같은 서비스이다. (유저를 뜻하는 Client가 아니다...)
애플리케이션 등록: 우리가 이용하고자 하는 서비스(구글, 네이버, 카카오톡...)는 우리 서비스가 어떤 서비스인지 알고 있어야 한다. 이때, Redirect URL를 함께 등록하게 된다.
Redirect URL: 인증이 완료된 후, Authorization Code를 넘겨줄 URL를 의미한다. 이걸 왜 따로 등록하냐면 이상한 URL로 접속 시 Authorization Code란 정보가 그리로 유출될 수 있기 때문이다. 일반적으로 HTTPS 접속만 허용되지만, 루프백은 HTTP도 허용한다.
Client ID, Secret: Client ID는 클라이언트가 누구인지 구별하고, Secret는 해당 서비스가 맞는지 검증하는 용도로 사용된다. ID는 유출돼도 상관없지만 Secret은 보안 사고로 이어질 수 있으니 꼭. gitignore 해놓자 ㅎㅎ..
Authorization Code: 위에서 8번째 redirect to client with authorization code란 걸 볼 수 있을 텐데 Authorization Code는 Resource Owner가 받고 이를 Client에게 전달하면 Client는 Authorization Server에게 이를 전달해 주고 Access Token를 받게 된다. 수명이 매우 짧다. (5-10분) Access Token을 발급하면 비활성화된다고 한다.
Access Token: 자원에게 접근하기 위한 Client가 가지는 키값이다. 그만큼 중요하고 절대로 유출되어서는 안 된다.
Authorization Code vs Access Token
왜 이 2개는 나뉘어서 우리가 접속할 때 한 단계를 더 거치게 만들었을까? 그건 Access Token의 권한이 너무 강력하기 때문이다. Access Token 하나로 우린 자원을 가져올 수 있는데 그 말은 즉슨 유출되면 ID, PW 없이 바로 내 정보를 긁어올 수 있다는 것이다. 이런 중요한 정보를 Redirect URL에 담에서 Resource Owner의 브라우저에 전송하고 저장해 두는 것보단 그래도 서버에 안전하게 저장하는 편이 나은 것이기에 이런 과정을 거치게 된다. 즉, 브라우저에 정보를 안 주기 위해 이런 과정을 만든 것이다.
끝! Spring으로 한번 위에 과정을 Spring Security로 간단하게 만들어보자.
2. Spring Security
솔직히 위의 과정은 코드로 만들자니 좀 많이(아니 좀 지나치게) 복잡하고 어렵고 시간도 많이 걸릴 거 같다. 그럼 역시 미리 만들어둔 라이브러리를 사용해야 하겠다. 다행히도 Spring Security란 프레임워크로 OAuth과정과 Google Login과정을 단순화시킬 수 있다고 한다. Spring Security가 어떤 것일까?
Spring Security is a powerful and highly customizable authentication and access-control framework. It is the de-facto standard for securing Spring-based applications. Spring Security is a framework that focuses on providing both authentication and authorization to Java applications. Like all Spring projects, the real power of Spring Security is found in how easily it can be extended to meet custom requirements
Spring Security는 강력하고 다양하게 변환 가능한 인증 및 접근 제어 프레임워크입니다. Spring 기반 애플리케이션의 보안을 위한 사실상 표준 프레임워크죠. Spring Security는 자바 애플리케이션에게 인증 및 인가를 주기 위해 초점을 둔 프레임워크입니다. 다른 모든 Spring 프로젝트들처럼, Spring Security의 진정한 능력은 얼마나 쉽게 커스텀 된 요구사항을 충족시킬 수 있는 지에서 발견할 수 있죠.
라고 한다. 한마디로 Spring 표준 보안 프레임워크인 것이다.
3. OAuth 구현
OAuth 관련 의존성은 다음 코드로 진행한다.
compile('org.springframework.boot:spring-boot-starter-oauth2-client') // OAuth2 의존성
이제 OAuth 관련된 코드들이 추가되었다. 참고로 좀 과정이 많이 복잡하니 하나하나 따라가 보자.
@RequiredArgsConstructor
@EnableWebSecurity // Spring Security 설정 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomOAuth2UserService service;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.headers().frameOptions().disable()// 여기까지 h2-console 사용을 위해 비활성화
.and()
.authorizeRequests() // URL별 권한 관리를 위한 옵션 시작점 (antMatcher를 위한 필수)
.antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**")
.permitAll()
.antMatchers("/api/v1/**")// 권한 관리 대상을 지정하는 옵션, permitAll()은 전체 접근 가능.
.hasRole(Role.USER.name()) // /api/v1는 USER권한 사람만
.anyRequest().authenticated() // 나머지는 모두 인증된 사람들만
.and()
.logout() // 로그아웃 설정 진입점
.logoutSuccessUrl("/") // 성공시 /로 리다이렉트
.and()
.oauth2Login()
.userInfoEndpoint() // OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들 담당
.userService(service); // 소셜 로그인 성공 시 UserService 인터페이스 구현체로 후속 조치 등록
}
}
이는 Spring Security를 사용하기 위한 설정 파일이다. 여기서 각 엔드포인트에 접근을 설정할 수 있고, 로그인, 로그아웃, 리다이렉트등 다양한 설정들을 부가할 수 있다. 자세한 건 주석을 참고하고, 우린 CustomOAuth2 UserService란 파일을 볼 것이다.
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository repository;
private final HttpSession session;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId(); // 현재 로그인 진행 중인 서비스를 구분하는 코드 (Google, Naver)
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUserNameAttributeName(); // OAuth2 로그인 진행 시 키가 되는 필드값(PK). 구글의 경우 'sub'
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
// OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담는 클래스
User user = saveOrUpdate(attributes);
session.setAttribute("user", new SessionUser(user));
// 세션에 사용자 정보를 저장하기 위한 DTO 클래스. 다른 클래스에서 저 attribute key로 user값을 가져올 수 있음
return new DefaultOAuth2User(Collections.singleton(
new SimpleGrantedAuthority(user.getRoleKey())),
attributes.getAttributes(),
attributes.getNameAttributeKey()
);
}
/**
* Google 사용자 정보가 업데이트 되었을 때 User 엔티티에 이를 반영함
* @param attributes
* @return
*/
private User saveOrUpdate(OAuthAttributes attributes) {
User user = repository.findByEmail(attributes.getEmail())
.map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
.orElse(attributes.toEntity());
return repository.save(user);
}
}
이 부분에서 정말 머리가 많이 아팠다.
위에 2개의 클래스는 설명이 없어 내가 직접 원문을 찾아봤다.
OAuth2UserService <R, U> : 이 인터페이스의 구현체는 클라이언트에게 인가해준 Access Token를 통해 접속한 유저정보 엔드포인트에서 Resource Owner 유저의 속성을 가져오고, OAuth2User의 형태 속 AuthenticatedPrincipal를 리턴하는 것을 담당합니다.
OAuth2User: OAuth2 user는 하나 혹은 이상의 속성을 가지게 됩니다. (성, 이름, 중간 이름, 이메일 등등...) 각각의 속성은 키(이름)와 값을 가지게 되고, 키값은 OAuth2AuthenticatedPrincipal.getAttributes()에 있습니다.
(원문 직역이라 조금 어색할 수 있습니다 ㅎㅎ...)
즉, OAuth2UserService에는 Resource Owner의 속성을 가져오도록 하는 구현체 DefaultOAuth2UserService를 넣게 되고, 이를 통해 OAuth2User는 loadUser()로 가져온 유저의 정보를 담게 된다. 이 OAuth2User.getAttributes()를 호출하면 Map<K,V>형태의 유저의 정보가 담긴 객체가 반환된다. 휴.....
saveOrUpdate()는 DB에 1. 이미 저장된 사용자의 정보를 로그인 때마다 새로 불러온 값(이름, 사진)과 비교하여 달라지면 업데이트하는 역할을 한다.(entity-entity.update(attributes.getName(), attributes.getPicture()) 2. 처음 접속은 당연히 그냥 저장하는 것만 한다.(orElse(attributes.toEntitiy())
이후 HttpSession에 "user"란 키를 가진 SessionUser를 담게 되면 세션에 사용자가 드디어 등록될 수 있는 것이다! ㅠㅠ 어렵다.
Google Login
RegistrationID - google
UserNameAttributeName - sub
attributes - {sub=XXXXXXX, name=XXXX, given_name=XXXXX, picture=https://lh3.googleusercontent.com/a/XXXXX, email=XXXX@gmail.com, email_verified=true, locale=ko} | user info - XXXX
별개로 궁금해서 콘솔에 찍어보니 해당 값이 들어온다. 아마 더 많은 정보를 요구하면 더 많은 정보가 attributes에 담기지 않을까 싶다.
여기서 우선 OAuthAttributes는 다음과 같다.
@Getter
public class OAuthAttributes {
private Map<String, Object> attributes;
private String nameAttributeKey;
private String name;
private String email;
private String picture;
@Builder
public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey, String name, String email, String picture) {
this.attributes = attributes;
this.nameAttributeKey = nameAttributeKey;
this.name = name;
this.email = email;
this.picture = picture;
}
/**
* OAuth2User에서 반환하는 사용자 정보는 Map<K,V> 형태이기 때문에 값 하나하나를 반환해야만 함
* @param registrationId
* @param userNameAttributeName
* @param attributes
* @return
*/
public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
return ofGoogle(userNameAttributeName, attributes);
}
private static OAuthAttributes ofGoogle(String usernameAttributeName, Map<String, Object> attributes) {
return OAuthAttributes.builder()
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.picture((String) attributes.get("picture"))
.attributes(attributes)
.nameAttributeKey(usernameAttributeName)
.build();
}
/**
* User 엔티티 생성
* OAuthAttributes 에서 엔티티 생성 시점은 처음 가입할 떄
* 기본 원한은 GUEST로 생성
* 생성이 끝났다면 같은 패키지에 SessionUser 클래스로 생성해야만 함.
* @return
*/
public User toEntity() {
return User.builder()
.name(name)
.email(email)
.picture(picture)
.role(Role.GUEST)
.build();
}
}
of(String, String, Map<K,V>) 메서드를 보면 registrationId는 무슨 서비스에 로그인을 한 것인지 구분할 용도(Google만 만들었기 때문에 구분을 위한 코드는 생략됨), userNameAttributeName은 OAuth 구별을 위한 Primary Key 값(구글은 'sub'이라고 한다). attribute는 해당 유저의 정보를 담은 Map객체(위에 찍어본 attributes JSON)이다.
다음은 SessionUser이다.
@Getter
public class SessionUser implements Serializable {
private String name;
private String email;
private String picture;
public SessionUser(User user) {
this.name = user.getName();
this.email = user.getEmail();
this.picture = user.getPicture();
}
}
Serializable은 뭘까? 그게 또 안 적혀있어서 찾아봤다 ㅎㅎ....
What is Serializable? 직렬화란 무엇인가??
[참고] http://lueseypid.tistory.com/42 http://hyeonstorage.tistory.com/252 직렬화란? 객체의 직렬화는 객체의 내용을 바이트 단위로 변환하여 파일 또는 네트워크를 통해서 스트림(송수신)이 가능하도록 하는
weicomes.tistory.com
즉, 객체를 저장, 전송(네트워크 포함), 혹은 DB에 저장하기 위해 객체를 일정한 형태의 바이트로 변형하는 작업을 의미한다. 참고로 직렬화되어 받은 데이터 덩어리를 다시 객체형태로 바꾸는 걸 역직렬화라고 한다고 한다.
그럼 왜 SessionUser는 직렬화 되어야만 할까? 이는 세션은 어딘가에 지속적으로 저장할 필요가 있기 때문(영속화)이다. 세션을 메모리 위에서만 굴린다면 모르겠지만, DB나 파일 등에 저장하는 옵션이 필요하기 때문에 아마 HttpSession은 Serializable을 강제하는 게 아닐까 싶다. StackOverflow에서 이런 답변을 찾았다.
Your entire servlet session can be serialized to disk or another store at any moment. So all objects in it must be serializable.
모든 서블릿 세션은 디스크 혹은 다른 저장소에 언제나 직렬화가 가능해야 하기에 모든 객체가 직렬화 가능해야만 한다.
User Entity는 언젠가 관계로 묶이고 상속되는 과정을 거칠 수 있다. 이런 과정을 거치면 Serializable이 역병처럼 모든 객체에 영향을 끼치기에 성능 이슈가 발생할 수 있어 SessionUser는 따로 분리하여 직렬화시키는 것이다.
세션은 다음과 같은 옵션으로 저장할 수 있다고 한다.
1. 톰캣 세션: 기본값 설정, 2대 이상의 WAS를 굴리면 세션값을 공유할 솔루션이 필요해짐.
2. MySQL과 같은 데이터베이스: 가장 간단하게 공유가능한 세션 저장소를 구축할 수 있는 옵션. 단 I/O 성능이 떨어짐.
3. Redis, Memcached와 같은 인메모리 데이터베이스: 가장 성능이 좋아 B2C 서비스에서 광범위하게 사용됨. 단, 별도 저장소가 필요한 경우가 많음.
해당 블로그 글의 코드는 아래에서 볼 수 있습니다!
https://github.com/kcdevdes/springboot-webservice/
Tomorrow I will learn...
- 네이버 로그인 구현하기
- 우리은행 코테 보고 오기