이전 글에서 서술했다시피 다음과 같은 흐름으로 인증이 진행된다.
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: 이메일, 패스워드)를 의미한다.
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>
으로 받아온다.
GrantedAuthority
package 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
과 Role
OAuth2MemberAuthority
@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
로 해도 될 듯 하다.