이전 글에서 서술했다시피 다음과 같은 흐름으로 인증이 진행된다.
OAuth2UserService가 Resource server로 인증 요청을 하고 인증 정보를 받아온다.OAuth2UserService.loadUser()의 파라미터(OAuth2UserRequest 객체)를 통해 받아와지고,OAuth2User의 구현 타입으로 반환한다.SecurityContext에 인증 객체가 등록된다).oauth2Login()에서 설정된 AuthenticationSuccessHandler의 구현체가,AuthenticationFailureHandler의 구현체가 동작한다.뭘 해야 할지는 정해졌고, 순서는 각자의 취향대로 하면 된다.
SecurityConfig가 있어야 할 것이다.
OAuth2UserService를 정의한다
OAuth2User의 구현 타입 OAuth2MemberPrincipal을 정의한다.OAuth2Provider를 정의한다.이 프로젝트에서는 사용자 테이블 이름을 user가 아닌 member로 했다.
별 이유는 없고, DBMS로 MySQL을 썼는데, MySQL의 기본 데이터베이스인 mysql에 user 테이블이 있어서 이름 겹치는 게 찜찜해서 그랬다.
그래서 OAuth2UserXXX와 같은 이름은 모두 OAuth2MemberXXX로 정의했다.
Principal Vs. Info이전 글에서 서술되어 있듯이, Resource server에서 인가받은 사용자 정보를 가져오는 endpoint를 userInfoEndpoint()라는 builder method로 설정한다.
여기서는 info인데 나는 principal이라는 이름(OAuth2MemberPrincipal)으로 클래스를 정의했다.
사실 큰 상관없고, 대부분의 영단어가 그렇듯이 뉘앙스의 차이다.
info는 말 그대로 사용자의 정보(information)를 말한다. 즉 프로필 사진, 이메일, 사용자 번호, 권한 외 기타 모든 정보가 여기에 포함된다.
principal은 인증에 쓰이는 정보다. 즉 info 중에서 실제로 인증에 쓰이는 정보(Ex: 이메일, 패스워드)를 의미한다(principal ⊆ info).
Spring security에서 OAuth를 사용한 인증은 OAuth2UserService에서 반환된 값이 Authentication 객체로 변환되어
SecurityContext에 등록됨으로써 진행된다.
따라서 OAuth2User의 구현 클래스 이름은 OAuth2MemberInfo보다 OAuth2MemberPrincipal이 적절하다고 판단했다.
크게 중요한 부분은 아닐 수 있지만... 꼭 여기서가 아니어도 사소한 네이밍에서 혼란이 오는 경우가 꽤 있다. 혼란도 다 비용이다.
꼭 이런 이유가 아니어도 어차피 OAuth2User가 OAuth2AuthenticatedPrincipal을 상속받기 때문에, 잘 모를 때는 그냥 따라가는 게 안전하다(보통은 다 그렇게 되어 있는 이유가 있다).
OAuth2User의 구현체package org.springframework.security.oauth2.core;
import java.util.Collection;
import java.util.Map;
import org.springframework.lang.Nullable;
import org.springframework.security.core.AuthenticatedPrincipal;
import org.springframework.security.core.GrantedAuthority;
public interface OAuth2AuthenticatedPrincipal extends AuthenticatedPrincipal {
@Nullable
default <A> A getAttribute(String name) {
return this.getAttributes().get(name);
}
Map<String, Object> getAttributes();
Collection<? extends GrantedAuthority> getAuthorities();
}
OAuth2User의 구현체를 정의하기 위해 OAuth2User가 상속받는 OAuth2AuthenticatedPrincipal을 보면 위와 같다.
Attributes와 Authorities가 보이는데, 전자는 Resource server에서 받아오는 정보를, 후자는 권한을 의미한다.
그리고 Attributes는 이름과 값으로 이루어지므로 Map<String, Object>으로 받아온다.
GrantedAuthoritypackage org.springframework.security.core;
import java.io.Serializable;
public interface GrantedAuthority extends Serializable {
String getAuthority();
}
권한을 나타내는 이 인터페이스에 대해 알아보자.
조금 헷갈리는데, 다시 정리해보자.
OAuth2UserService는 OAuth2User 타입으로 방금 로그인한 사용자 정보를 반환하고,SecurityContext에 등록됨으로써 인증이 완료된다.OAuth2User는 OAuth2AuthenticatedPrincipal을 상속받는다.OAuth2AuthenticatedPrincipal은 속성과 권한을 반환하는 메소드가 있다.SecurityContext에는 방금 로그인한 사용자의 정보(속성)와 그 사용자가 우리 서비스에서 갖는 역할(권한)을 기반으로 Authentication 객체가 만들어지고, 이게 SecurityContext에 등록되며 인증된다.결론적으로 OAuth2User의 구현체인 OAuth2MemberPrincipal과,
권한을 나타내는 GrantedAuthority의 구현체를 정의해야 한다.
그러면 사실 GrantedAuthority의 구현체는 필요가 없다.
그냥 OAuth2MemberPrincipal에서 권한을 특정 디폴트 값으로 하든 null로 설정하든 없는 셈 치면 된다.
내가 진행한 프로젝트도 사용자 권한 구분이 없었지만, 추후에 관리자 등이 추가될 것에 대비해 GrantedAuthority의 구현체인 OAuth2MemberAuthority 클래스를 정의했다.
class OAuth2MemberPrincipal implements OAuth2User@AllArgsConstructor
public class OAuth2MemberPrincipal implements OAuth2User {
private final Long memberId;
private final List<OAuth2MemberAuthority> authorities;
@Getter
private final OAuth2Provider oAuth2Provider;
@Getter
private final MemberStatus memberStatus;
private Map<String, Object> attributes;
// 회원 정보 입력 후에만 쓰이는 생성자
public OAuth2MemberPrincipal(
Long memberId,
List<OAuth2MemberAuthority> authorities,
OAuth2Provider oAuth2Provider
) {
this.memberId = memberId;
this.authorities = authorities;
this.oAuth2Provider = oAuth2Provider;
this.memberStatus = MemberStatus.OLD;
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.copyOf(authorities); // shallow copy
}
@Override
public String getName() {
return Long.toString(memberId);
}
// 각 사용자가 여러 개의 권한을 가질 수 있다
// ...고 가정하고 모든 권한을 "|"로 구분해 얻을 수 있다.
public String getAuthority() {
return authorities.stream()
.map(OAuth2MemberAuthority::getAuthority)
.collect(
Collectors.joining("|")
);
}
}
memberId는 데이터베이스에서 사용자가 갖는 정보다.authorities는 사용자 권한을 나타내며, 실질적으로 Role이라는 enum 클래스로 표현된다.Role은 USER, ADMIN의 두 가지가 있다.attributes에는 Resource server에서 인가 후 받아와지는 정보를 그대로 사용했다.OAuth2Provider는 Resource server를 나타내며, KAKAO, GOOGLE로 구성된 enum 클래스다.MemberStatus는 로그인한 사용자를 신규 사용자와 기존 사용자로 구분하기 위한 enum 클래스다.생성자가 2개인 이유는 회원가입할 때와 회원가입 이후 로그인에서의 인증 정보가 다르기 때문이다.
아래에서 서술한다.
OAuth2Provider@RequiredArgsConstructor
@Getter
public enum OAuth2Provider {
GOOGLE("GOOGLE"),
KAKAO("KAKAO"),
PROTECTED("[PROTECTED]"); // 로그인 이후에 사용됨
private final String value;
public static boolean validate(String name) {
return name.equals(GOOGLE.value) || name.equals(KAKAO.value);
}
}
데이터베이스에는 해당 사용자가 어느 Resource server를 통해 회원가입했는지 저장된다.
따라서 회원가입에서는 Resource server 정보가 필요하다.
하지만 회원가입 이후에는 이미 데이터베이스에 있으므로 불필요하다. 이 경우에는 [PROTECTED]로 처리했다.
OAuth2MemberAuthority과 RoleOAuth2MemberAuthority@RequiredArgsConstructor
public class OAuth2MemberAuthority implements GrantedAuthority {
private final Role role;
@Override
public String getAuthority() {
return role.name();
}
}
Role@RequiredArgsConstructor
public enum Role {
ROLE_USER("ROLE_USER"),
ROLE_ADMIN("ROLE_ADMIN");
private final String value;
}
ROLE_은 Spring security에서 hasRole()이라는 메소드를 사용할 때 자동으로 붙는 prefix다.
hasRole()은 어떤 요청에 대해 요청한 사용자(정확히는 SecurityContext에 등록된 ≡ 인증된 사용자)의 역할을 검증할 때 사용하는 메소드다.
이것도 이 프로젝트에서 당장은 사용하지 않지만, 나중을 대비한 것이다.
OAuth2UserService를 정의하기 위한 사전작업이다.
즉
1. OAuth2UserService 정의
2. 를 하기 위해 OAuth2User의 구현 타입 OAuth2MemberPrincipal 정의
3. 를 하기 위해 OAuth2Provider와 OAuth2MemberAuthority, Role 정의
를 한 것이다.
이제 OAuth2UserService의 구현체를 정의하면 된다.
OAuth2UserService의 구현체/*
여기서는 Resource server로부터 인증 정보를 받아오고
그 정보를 Security Context에 등록하는 역할을 수행한다.
여기서 인증 성공시 CustomOAuth2SuccessHandler로,
실패 시 CustomOAuth2FailureHandler로 이동한다.
이 때 Security Context에 등록되는 내용은 OAuth2User의 구현체이므로
OAuth2User의 구현체를 override함으로써 등록되는 정보를 customize할 수 있다.
*/
@RequiredArgsConstructor
public class CustomOAuth2MemberService extends DefaultOAuth2UserService {
private final MemberService memberService;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest)
throws OAuth2AuthenticationException {
// 유저 정보 생성
OAuth2User oAuth2User = super.loadUser(userRequest);
return process(userRequest, oAuth2User);
}
private OAuth2User process(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
String provider = userRequest.getClientRegistration().getClientName();
// 이상한 provider
if (!OAuth2Provider.validate(provider)) {
throw new AuthException(AuthErrorCode.BAD_OAUTH_INFO);
}
OAuth2Provider oAuth2Provider = OAuth2Provider.valueOf(provider);
Map<String, Object> attrs = oAuth2User.getAttributes();
// 역할 (아직 안씀)
// List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList(
// Role.ROLE_USER.name()
// );
String sub = switch (oAuth2Provider) {
case GOOGLE -> (String) attrs.get("sub");
case KAKAO -> Long.toString((Long) attrs.get("id"));
case PROTECTED -> OAuth2Provider.PROTECTED.getValue();
};
MemberPrincipal memberPrincipal = loginOrRegister(sub, oAuth2Provider);
// 여기서 반환된 정보가 SecurityContext에 등록된다.
OAuth2MemberPrincipal oAuth2MemberPrincipal = OAuth2MemberPrincipalFactory.authForLogin(
memberPrincipal,
(DefaultOAuth2User) oAuth2User
);
return oAuth2MemberPrincipal;
}
// 로그인 또는 회원가입
public MemberPrincipal loginOrRegister(String sub, OAuth2Provider oAuth2Provider) {
return memberService.loginOrRegister(sub, oAuth2Provider);
}
}
DefaultOAuth2UserService를 상속받았다.attrs에는 Resource server로부터 받은 정보(구글: sub, email, ... / 카카오: id)가 포함된다.attrs.get(key); 하면 된다.sub 값은 String으로, 카카오의 id 값은 Long으로 받아와진다.OAuth2Provider는 회원가입에서만 필요하다(DB에 저장하기 위해).GOOGLE 또는 KAKAO를 쓰는 거고, 이후 로그인에서는 PROTECTED가 인증에 쓰인다.DefaultOAuth2UserService.loadUser()로 기본 OAuth2User 객체를 생성한다.Authentication 등록)하겠지만, 보통은 좀 가공이 필요하다.process()의 다음 부분에서 수행한다.String sub = switch (oAuth2Provider) {
case GOOGLE -> (String) attrs.get("sub");
case KAKAO -> Long.toString((Long) attrs.get("id"));
case PROTECTED -> OAuth2Provider.PROTECTED.getValue();
};
MemberPrincipal은 또 뭔가요@AllArgsConstructor
@Getter
public class MemberPrincipal {
private Long id;
private Role role;
private OAuth2Provider provider;
private MemberStatus memberStatus;
}
MemberStatus.NEW)MemberStatus.OLD)MemberStatus.NUL)201 CREATED, 200 OK, 204 NO CONTENT로 구분되고,OAuth2MemberPrincipalFactory는 뭔가요public class OAuth2MemberPrincipalFactory {
// 회원가입에 쓰이는 팩토리
public static OAuth2MemberPrincipal authForLogin(
MemberPrincipal memberPrincipal,
DefaultOAuth2User defaultOAuth2User
) {
return new OAuth2MemberPrincipal(
memberPrincipal.getId(),
List.of(new OAuth2MemberAuthority(memberPrincipal.getRole())),
memberPrincipal.getProvider(),
memberPrincipal.getMemberStatus(),
defaultOAuth2User.getAttributes()
);
}
// 로그인 이후에 쓰이는 팩토리
public static OAuth2MemberPrincipal of(
Long id,
Role role,
OAuth2Provider oAuth2Provider
) {
return new OAuth2MemberPrincipal(
id,
List.of(new OAuth2MemberAuthority(role)),
oAuth2Provider
);
}
}
앞에서 봤듯이 OAuth2MemberPrincipal의 생성자가 2개다.
이거 신경쓰면서 생성하기 싫어서 그냥 Factory 클래스로 추상화한 것 뿐이다.
두 생성자의 차이는 앞 부분 참조 바람.
근데 또 갑자기 DefaultOAuth2User라는 게 나왔는데, 그냥 OAuth2User의 구현 클래스다.
사실 왜 OAuth2User가 아닌 DefaultOAuth2User로 한 건지 모르겠다. 그냥 OAuth2User로 해도 될 듯 하다.