Spring Security + OAuth (3): Service 정의

Ajisai·2024년 6월 17일
0

이전 글에서 서술했다시피 다음과 같은 흐름으로 인증이 진행된다.

  1. OAuth2UserService가 Resource server로 인증 요청을 하고 인증 정보를 받아온다.
  2. 받아온 인증 정보는 OAuth2UserService.loadUser()의 파라미터(OAuth2UserRequest 객체)를 통해 받아와지고,
  3. 필요에 따라 적당한 처리 후 인증 정보 OAuth2User의 구현 타입으로 반환한다.
  4. 반환된 인증 정보를 바탕으로 인증이 수행된다(SecurityContext에 인증 객체가 등록된다).
  5. 인증 성공 시 oauth2Login()에서 설정된 AuthenticationSuccessHandler의 구현체가,
    실패 시 설정된 AuthenticationFailureHandler의 구현체가 동작한다.

뭘 해야 할지는 정해졌고, 순서는 각자의 취향대로 하면 된다.

일단 떠오르는 대로 나열해보자

  1. SecurityConfig가 있어야 할 것이다.

    • Filter chain을 구성한다.
    • CORS 설정을 구성한다(하지만 지금은 필요없다).
    • 필요한 Bean을 등록한다(Bean이 아직 다 정의되지 않아서 아직은 필요없다).
  2. OAuth2UserService를 정의한다

    • ..를 위해 OAuth2User의 구현 타입 OAuth2MemberPrincipal을 정의한다.
    • ....를 위해 OAuth2Provider를 정의한다(카카오와 구글만 할 거니까).

참고로 이 프로젝트에서는 사용자를 user가 아닌 member로 했다.
별 이유는 없고, DBMS로 MySQL을 썼는데, MySQL의 기본 데이터베이스인 mysqluser 테이블이 있어서 이름 중복으로 문제가 생길까봐 그랬다.
그래서 OAuth2UserXXX와 같은 이름은 모두 OAuth2MemberXXX로 정의했다.

Principal Vs. Info

이전 글에서 서술되어 있듯이, Resource server에서 인가받은 사용자 정보를 가져오는 endpoint를 userInfoEndpoint()라는 builder method로 설정한다.
여기서는 info인데 나는 principal이라는 이름(OAuth2MemberPrincipal)으로 클래스를 정의했다.

사실 큰 상관없고, 대부분의 영단어가 그렇듯이 뉘앙스의 차이다.
info는 말 그대로 사용자의 정보(information)를 말한다. 즉 프로필 사진, 이메일, 사용자 번호, 권한 외 기타 모든 정보가 여기에 포함된다.
principal은 인증에 쓰이는 정보다. 즉 info 중에서 실제로 인증에 쓰이는 정보(Ex: 이메일, 패스워드)를 의미한다(principalinfo)(\text{principal} ⊆ \text{info}).

Spring security에서 OAuth를 사용한 인증은 OAuth2UserService에서 반환된 값이 Authentication 객체로 변환되어
SecurityContext에 등록됨으로써 진행된다.
따라서 OAuth2User의 구현 클래스 이름은 OAuth2MemberInfo보다 OAuth2MemberPrincipal이 적절하다고 판단했다.

크게 중요한 부분은 아닐 수 있지만... 꼭 여기서가 아니어도 사소한 네이밍에서 혼란이 오는 경우가 꽤 있다. 혼란도 다 비용이다.

꼭 이런 이유가 아니어도 어차피 OAuth2UserOAuth2AuthenticatedPrincipal을 상속받기 때문에, 잘 모를 때는 그냥 따라가는 게 안전하다(보통은 다 그렇게 되어 있는 이유가 있다).

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을 보면 위와 같다.
AttributesAuthorities가 보이는데, 전자는 Resource server에서 받아오는 정보를, 후자는 권한을 의미한다.
그리고 Attributes는 이름과 값으로 이루어지므로 Map<String, Object>으로 받아온다.

GrantedAuthority

package org.springframework.security.core;

import java.io.Serializable;

public interface GrantedAuthority extends Serializable {
    String getAuthority();
}

권한을 나타내는 이 인터페이스에 대해 알아보자.
조금 헷갈리는데, 다시 정리해보자.

  1. OAuth2UserServiceOAuth2User 타입으로 방금 로그인한 사용자 정보를 반환하고,
    이게 SecurityContext에 등록됨으로써 인증이 완료된다.
  2. OAuth2UserOAuth2AuthenticatedPrincipal을 상속받는다.
  3. 그런데 OAuth2AuthenticatedPrincipal은 속성과 권한을 반환하는 메소드가 있다.
  4. 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("|")
            );
    }
}
  • 이 클래스에 포함되어야 할 정보는 SecurityContext에 등록되어야 할 정보다.
  • memberId는 데이터베이스에서 사용자가 갖는 정보다.
    • 추후에 보겠지만 JWT에 포함되며 이걸로 사용자를 식별하므로 꼭 필요한 정보다.
  • authorities는 사용자 권한을 나타내며, 실질적으로 Role이라는 enum 클래스로 표현된다.
    • RoleUSER, ADMIN의 두 가지가 있다.
    • 앞서 언급했듯이 모든 사용자의 권한이 동일하며 하나만 가질 수 있지만,
      일단 여러 개를 가질 수 있다고 전제하고 개발했다.
  • attributes에는 Resource server에서 인가 후 받아와지는 정보를 그대로 사용했다.
  • OAuth2Provider는 Resource server를 나타내며, KAKAO, GOOGLE로 구성된 enum 클래스다.
  • MemberStatus는 로그인한 사용자를 신규 사용자와 기존 사용자로 구분하기 위한 enum 클래스다.
    • Spring security와는 관계가 없으므로 무시해도 상관없다.

생성자가 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]로 처리했다.

OAuth2MemberAuthorityRole

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. 를 하기 위해 OAuth2ProviderOAuth2MemberAuthority, 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);
    }
}
  • 기본적인 OAuth 인가에 쓰이는 DefaultOAuth2UserService를 상속받았다.
  • attrs에는 Resource server로부터 받은 정보(구글: sub, email, ... / 카카오: id)가 포함된다.
    • 해당 정보의 이름을 key로 해서 attrs.get(key); 하면 된다.
    • 구글의 sub 값은 String으로, 카카오의 id 값은 Long으로 받아와진다.
  • 앞서 언급했듯이 OAuth2Provider는 회원가입에서만 필요하다(DB에 저장하기 위해).
    그래서 이 때만 GOOGLE 또는 KAKAO를 쓰는 거고, 이후 로그인에서는 PROTECTED가 인증에 쓰인다.

흐름

  1. DefaultOAuth2UserService.loadUser()로 기본 OAuth2User 객체를 생성한다.
  2. 원래대로면 이걸 그대로 반환해 인증(Authentication 등록)하겠지만, 보통은 좀 가공이 필요하다.
  3. 그 가공을 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;
}
  • OAuth 특성 상 로그인과 회원가입 구분이 없다.
  • 그래서 지금 로그인된 사용자를 세 가지로 구분해야 했다.
    • 신규 사용자(MemberStatus.NEW)
    • 기존 사용자(MemberStatus.OLD)
    • 회원가입은 했는데 추가정보 입력이 안 된 사용자(MemberStatus.NUL)
  • 이에 따라 로그인 API의 응답 코드는 각각 201 CREATED, 200 OK, 204 NO CONTENT로 구분되고,
    프론트엔드에서는 이에 맞춰 화면 흐름을 제어했다.
  • 즉 이 프로젝트에서 필요해서 정의했을 뿐, Spring security 자체와는 무관하다.

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로 해도 될 듯 하다.

profile
Java를 하고 싶었지만 JavaScript를 하게 된 사람

0개의 댓글

관련 채용 정보