스프링 API 권한 체크 어떻게 할 수 있을까?

Belluga·2021년 6월 4일
2

✨ 권한 체크

권한을 의미하는 필드 생성

제가 진행하고 있는 쇼핑몰 서비스는
서비스 이용 목적에 따라 일반 사용자, 판매자, 관리자로 권한을 나눌 수 있습니다.

일반 사용자의 상품 생성 행위를 허용하면 안되는 것 처럼

클라이언트가 서비스 내 API를 호출하는 경우 컨트롤러에서 요청자가 해당 API를 호출할 권한이 있는지 체크 후 어떤 동작을 할지 결정하게 됩니다.

따라서 Member 도메인 클래스에 권한을 의미하는 필드를 추가하도록 하겠습니다.
이때 숫자를 프로퍼티에 사용하면 타입이 안전하지 않아 위험할 수 있습니다. 💥

아래와 같이 정수형 상수 값으로 권한을 정의하는 경우 아래와 같은 문제점이 발생할 수 있습니다.

Class User {

	private static final int ADMIN = 1;
	private static final int SELLER = 2;
	private static final int BASIC_MEMBER = 3;

 	int role;

	public void setRole(int role) {
		this.role = role;
	}
}

💥 case1

  user1.setRole(other.getSum());

role 타입이 int이기 때문에 다른 종류의 정보를 넣는 실수를 해도 컴파일러가 체크해주지 않습니다.

💥 case2

user1.setLevel(1000);

범위를 벗어나는 값을 넣을 위험도 있습니다.

Enum 타입 권한 생성

🌼 결론

public enum Role {

    ADMIN(1), SELLER(2), BASIC_MEMBER(3);

    private final int value;

    Role(int value) {
        this.value = value;
    }
}
  
@Builder
@Getter
public class Member {

    private Long id;
    private String email;
    private String password;
    private String name;
    private Timestamp createDate;
    private Role role;
}

따라서 int 타입 대신 Role enum 클래스를 정의하여
Role 타입의 변수를 Member 클래스에 추가해주었습니다.

그러나 Role enum 클래스는 오브젝트이므로 DB에 저장될 수 있는 SQL 타입이 아닙니다.

TypeHandler

따라서 (1)DB에 저장할 때는 int 값으로 저장하고 (2)DB에서 조회시에는 Role 타입의 enum 오브젝트로 변환하는 과정을 거치도록 해야합니다.

Enum(Java) ↔ int(DB)와 같이 자바 타입과 JDBC 타입이 일치하지 않는 경우
MyBatis는 ResultSet에서 값을 가져올때마다 적절한 자바 타입의 값을 가져오기 위해 TypeHandler를 사용합니다.

표준 자바 타입의 경우 사용할 수 있는 디폴트 TypeHandlers가 있으나 저희는 비표준인 enum class 오브젝트를 사용하기 때문에 TypeHandler 인터페이스를 오버라이드하여 사용할 수 있습니다.

TypeHandler 인터페이스는 아래와 같으며
(1) ResultSet으로부터 결과를 조회할 때,
(2) PreparedStatement에 parameter를 설정할 때 사용되는 메서드를 재정의 할 수 있습니다.

그러나 한가지 생각해볼 것이 이러한 Enum 클래스마다 TypeHandler 인터페이스를 각각 구현하는 것은 파일의 수도 많아지고 관리가 힘들어지기 때문에
해당 이슈가 있는 Enum 클래스에 대한 공통 인터페이스 CodeEnum과 제네릭으로 범용적인 사용가능한 CodeEnumTypeHandler를 사용합니다.

public interface CodeEnum {

    int getCode();
}
@NoArgsConstructor
public class CodeEnumTypeHandler<E extends CodeEnum> implements TypeHandler<CodeEnum> {

    public Class<E> type;

    public CodeEnumTypeHandler(Class<E> type) {
        this.type = type;
    }

    @Override
    public void setParameter(PreparedStatement ps, int i, CodeEnum parameter, JdbcType jdbcType) throws SQLException {
        ps.setInt(i, parameter.getCode());
    }

    @Override
    public CodeEnum getResult(ResultSet rs, String columnName) throws SQLException {
        int code = rs.getInt(columnName);
        return getEnum(code);
    }

    @Override
    public CodeEnum getResult(ResultSet rs, int columnIndex) throws SQLException {
        int code = rs.getInt(columnIndex);
        return getEnum(code);
    }

    @Override
    public CodeEnum getResult(CallableStatement cs, int columnIndex) throws SQLException {
        int code = cs.getInt(columnIndex);
        return getEnum(code);
    }

    private CodeEnum getEnum(int code) {
        Optional<CodeEnum> anyEnum = Arrays.stream((CodeEnum[]) type.getEnumConstants())
                .filter(anEnum -> anEnum.getCode() == code).findAny();
        return anyEnum.orElseThrow(() -> new UnnownEnumValueException(type.getSimpleName(), code));
    }
}

(1) 데이터베이스에 enum 타입을 int 값으로 저장가능하도록 정수형 값으로 변환하는 getCode() 메서드와
(2) 반대로 조회시 int 값을 Role 타입의 enum 오브젝트로 변환하는 과정을 거치도록 하는 getEnum(int code) 메서드를 정의하였습니다.
public T[] getEnumConstants() 는 특정 enum 타입이 갖고 있는 모든 값을 반환합니다.

해당 이슈가 있는 Enum 클래스는 getCode() 메서드가 반드시 존재해야 하기 때문에 CodeEnum 인터페이스에 getCode() 메서드를 선언합니다.

public enum Role implements CodeEnum {

    ADMIN(1), SELLER(2), BASIC_MEMBER(3);

    private final int value;

    Role(int value) {
        this.value = value;
    }

    @Override
    public int getCode() {
        return value;
    }

    @MappedTypes(Role.class)
    public static class TypeHandler extends CodeEnumTypeHandler<Role> {

        public TypeHandler() {
            super(Role.class);
        }
    }
}
  

CodeEnum 인터페이스를 구현하는 Role Enum 클래스는 getCode()를 오버라이딩합니다.

Role Enum 클래스의 내부 클래스로 TypeHandler 클래스를 만들어주었습니다.
TypeHandler 내부 클래스에 @MappedTypes 애노테이션 추가함으로써
TypeHandler로 다루고자 하는 자바타입을 지정할 수 있습니다.

Config

마지막으로 MyBatis DB Config 설정에 TypeHandler를 등록해주기 위해 application.properties 파일에 CodeEnumTypeHandler가 위치한 패키지를 입력해줍니다.

Enum ↔ int 확인

@Mapper
public interface MemberMapper {

    @Insert("INSERT INTO MEMBER_INFO(email, password, name) VALUES(#{member.email}, #{member.password}, #{member.name})")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    int create(@Param("member") Member member);
}


저는 데이터베이스에 삽입시 일반 사용자를 나타내는 상수인 3을 default로 설정하였는데요, 위와 role값이 3으로 셋팅됩니다.

해당 데이터에 대해 API 조회 결과 아래와 같이 BASIC_MEMBER 값을 반환하는 것을 확인할 수 있습니다.

➕ 추가로 아래와 같이 정의되지 않는 role 값을 조회하는 경우
알 수 없는 형식의 값을 갖습니다.(Role:0) 에러 메시지를 반환합니다.


권한 확인

권한 확인이라는 작업은 핵심 로직이라기 보다 공통적으로 사용되는 부가기능입니다.

부가기능 구현을 위해 인터셉터AOP를 떠올릴 수 있었습니다.
특징을 간략히 정리해보면 아래와 같습니다.

핸들러 인터셉터의 경우 핸들러 맵핑에 설정하여 특정 요청 작업 전, 후에 가로챌 수 있습니다.
AOP의 경우 비즈니스 단에서 비즈니스 메서드 실행 전, 후에 가로챌 수 있습니다.

이때 권한 확인 작업은 외부 사용자가 API를 호출할 때 통과시킬지 말지 결정하는것이기 때문에 인터셉터로 구현하기로 결정하였습니다.

@Target(value = {ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Authority {

    Role target();
}  

특정 권한이 필요한 API 핸들러에 적용할 수 있는 어노테이션을 선언합니다.

@Authority(target = Role.BASIC_MEMBER)
@GetMapping("/members/{id}")
public Member getById(@PathVariable("id") String id) {
    return memberService.getById(id);
}

어노테이션을 사용하는 경우 위와 같이 컨트롤러의 메서드를 보고 API 호출을 위해 어떤 권한이 필요한지 명시적으로 나타낼 수 있습니다.

Handler Interceptor 구현

Handler Interceptor는 핸들러를 실행하기 전, 후(랜더링 전), 완료(랜더링 이후) 시점에 부가 작업을 하고 싶은 경우 사용할 수 있습니다.
HandlerInterceptor 인터페이스를 구현함으로써 인터셉터를 구현할 수 있습니다.

핸들러를 실행하기 전, 후(랜더링 전), 완료(랜더링 이후) 시점에 부가 작업을 하고 싶은 경우에 사용할 수 있습니다.

boolean preHandle(request, response, handler)

핸들러가 실행하기 전에 호출 되는 preHandle 메서드를 구현하여 권한을 확인하는 부가기능을 구현하도록 하겠습니다.
리턴값으로 다음 인터셉터나 핸들러로 전달할지(true), 응답 처리가 이곳에서 끝나는지(false) 알려줍니다.

@Component
@RequiredArgsConstructor
public class AuthorityInterceptor implements HandlerInterceptor {

    private final Authentication authentication;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        if(!(handler instanceof HandlerMethod)) return true;
        Authority auth = ((HandlerMethod) handler).getMethodAnnotation(Authority.class);

        if(auth == null) return true;

        //  로그인 유무를 확인합니다.
        AuthMember authMember = authentication.getLoginMember();
        if(authMember == null) throw new UnAuthorizedException();

        /*
            - ADMIN 권한을 가진 사용자는 모든 API를 호출할 수 있습니다.
            - 로그인한 사용자라면 BASIC_MEMBER 권한이 필요한 API를 호출할 수 있습니다.
         */
        if(authMember.getRole() == Role.ADMIN) return true;
        if(ArrayUtils.contains(auth.target(), Role.BASIC_MEMBER)) return true;

        if(!ArrayUtils.contains(auth.target(), authMember.getRole())) {
            throw new ForbiddenException();
        }

        return true;
    }
}

ADMIN 권한을 가진 사용자는 모든 API를 호출할 수 있습니다.
로그인한 사용자라면 BASIC_MEMBER 권한이 필요한 API를 호출할 수 있습니다.
그 외의 경우 사용자가 가진 권한 확인 후 다음 로직 수행 유무를 결정합니다.
@Component 어노테이션을 통해 bean으로 등록합니다.

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final AuthorityInterceptor authorityInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authorityInterceptor);
    }
}

@Configuration 어노테이션 설정 및 WebMvcConfigurer 인터페이스를 구현함으로써, 추가적인 설정을 구성할 수 있습니다.
마지막으로 우리가 생성한 AuthorityInterceptor 를 등록해줍니다.

주석

1: https://hibernate.org/validator/

References

https://codinghack.tistory.com/

https://meetup.toast.com/posts/223

토비의 스프링

https://minkwon4.tistory.com/169

https://publish.dayone.app/post/1TlmzRe

https://www.holaxprogramming.com/2015/11/12/spring-boot-mybatis-typehandler/

https://velog.io/@kyle/%ED%9A%8C%EC%9B%90%EC%97%90-%EA%B6%8C%ED%95%9C%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EA%B4%80%EB%A6%AC%ED%95%A0%EA%B9%8C

https://soon-devblog.tistory.com/4

0개의 댓글