240605 Spring 심화 - Spring security 기본 인증인가 공부하기

노재원·2024년 6월 5일
0

내일배움캠프

목록 보기
54/90
post-custom-banner

Spring security

어플리케이션 전반적인 JWT 인증을 구현할 수 있는 Security 강의가 드디어 찾아왔다. Security 관련해서는 레퍼런스를 조금씩 보기만 해도 설정과 구현이 할게 많아보였는데 참 어찌 될지 걱정이 앞서긴 했지만 전반적인 로직에서 코드의 복잡도를 훨씬 줄일 수 있을 것이기 때문에 기대가 되는 측면도 있다.

강의는 인증, 인가를 핵심적으로 다루고 모든 API에 처리를 쉽게 하는 방법을 알려주고 있었다.

Filter chain

앞서 프로젝트에서 레퍼런스를 뒤져보다 보면 Filter chain을 사용해서 무언가 설정을 진행하는 경우가 있었다. 이미지처럼 DispatchServlet이 요청을 Controller로 넘겨주는 과정은 익숙하지만 그 앞에 Client의 요청이 DispatchServlet에 넘어오기 전에 Filter chain이라는 게 구성이 되어있고 단순히 Filter가 순차적으로 구성되어있는 것이다.

Request에 담긴 정보를 가지고 미리 압축하고 암호화하고 로깅, 인증 인가같은 처리를 미리 진행할 수 있는 것이고 인증/인가는 클라이언트의 요청을 처리하는 것이기 때문에 Filter chain을 쓰면 가장 적절한 시점에서 처리가 가능하다고 생각할 수 있다.

그리고 Spring은 Filter를 추가할 수 있게 당연히 구현되어 있고 우리가 작성한 Bean을 이미지처럼 DelegatingFilterProxy를 통해 ServletFilter에서 사용할 수 있게 해준다. 그래서 최종적으로는 FilterChainProxy에 우리가 정의한 Filter들을 끼워 넣으면 된다.

해당 이미지처럼 Endpoint에 따라 다른 필터를 처리하게도 가능하니 Filter를 기반으로 동작하는게 이 Spring security의 핵심 요소라고 볼 수 있다.

추가로 Security인 이유는 IP기반 접근을 막는 방화벽, CSRF 공격을 막는 교차 검증등도 여기서 수행하면 훨씬 수월하기 때문이다.

Spring security의 인증 처리 과정

보안에 대한 필터도 많지만 우선은 인증에 대한 전반적인 처리부터 알아보기로 했다.

해당 그림은 Spring security 인증의 핵심으로 Security Context를 관리하는 Security Context Holder라는 게 있고 Security Context 안에는 여러 정보가 있지만 그 중에서 인증 정보인 Authentication 객체를 가지고 있다.

인증 객체는 다시 3가지를 담고 있다고 구분할 수 있다.

  • Principal
    보통 유저 아이디, 유저 이메일 같은 유저의 식별자를 담고 있다.
    어떤 유저가 인증을 시도하고 있는지 담겨있다고 생각하면 된다.
  • Credentials
    실제 인증에 필요한 정보를 담고 있는 곳으로 이메일 / 비밀번호 기반 인증이라면 비밀번호를 담고 있다고 볼 수 있다. 민감 정보를 담고 있다고 생각할 수 있고 실제 인증이 되었는지 판단하는 여부로도 사용한다.
    유출 방지를 위해 인증된 후에는 보통 바로 삭제된다.
  • Authorities
    권한 정보로 어떤 Role, Scope를 지니고 있는지에 대한 정보가 들어있다. 어드민, 허용범위등에 대한 인가를 볼 수 있다.

Authentication의 인터페이스를 보면 다양하게 있지만 그 중에서도 isAuthenticated(), setAuthenticated(boolean isAuthenticated) 를 통해 인증 됐는지 안됐는지를 체크할 수 있다.

코드로 보면 SecurityContextSecurityContextHolder에서 생성한 다음 AuthenticationSecurityContext에 심기 전에 여러 인증과정을 거치면 된다고 할 수있다.

인증 처리의 편의성을 위해 제공해주는 요소

위처럼 인증을 처리하는 과정은 필터에서 Authentication 객체를 직접 다룰 수도 있겠지만 Spring security에서 제공해주는 다양한 요소가 있다.

Http 요청 정보를 Authentication 객체에 담아주면 실제 인증 처리는 다른 객체에 위임하는 것이다.

  • AuthenticationManager interface
    Authentication을 반환하는 authentication 함수 하나만 딱 들어있다.

  • ProviderManager
    AuthenticationManager의 구현체다. 보통 이걸 사용한다.

  • AuthenticationProvider interface

public interface AuthenticationProvider {

  // 실제 인증 수행
	Authentication authenticate(Authentication authentication) throws AuthenticationException;

  // 어떤 종류의 Authentication 구현체를 통해 인증을 수행하는지 확인
	boolean supports(Class<?> authentication);

}

ProviderManager에 주입될 수 있는 객체다. 이게 여러개 주입 될 수 있어서 이메일 / 패스워드 인증, JWT 인증, 소셜 로그인 인증등 다양한 Provider를 생성할 수 있다. 인터페이스기 때문에 직접 구현할 수도 있다. Authentication 자체도 인터페이스고 다양할 수 있기 때문에 Provider도 다양해진다고 보면 된다. ProviderManager가 이걸 적절히 구분해준다.

적절한 구분을 위해 Authentication의 구현체는 class ~~Token : Authentication 형태로 쓰고 Provider를 작성할 때 supports 함수를 통해 어떤 Token을 지원하는 인증인지 표기해주면 된다.

결국 AuthenticationManager의 authentication을 수행하면 안에서 Provider가 인증의 True, False까지 전부 체크한 Authentication 객체를 반환하고 이를 Security context에 담게 되는 것이다.

아래 이미지는 전체 과정을 표현한 이미지다.

AuthenticationManager, AuthenticationProvider는 필수 요소가 아니고 편의성을 제공하는 요소로 핵심은 Filter, Authentication 객체가 인증 여부를 담당한다는 것이다. 다만 편의성 요소를 쓰면 객체지향적으로 역할 분리가 가능해진다.

이메일 / 비밀번호 기반 인증인가 구현하기

강의에서는 이메일 / 비밀번호를 통해 해당 정보가 맞는지 확인하고 JWT를 발급하는 인증, 그리고 요청에 들어온 JWT를 검증하는 내용에 대해 다룬다. JWT는 jjwt를 사용한다.

JJWT는 0.11버전과 0.12버전의 인터페이스가 달라지니 주의가 필요하다.

Spring security 기본 필터와 설정

Spring seucirty 의존성을 설정하면 기본적으로 DefaultSecurityFilterChain이 적용이 되고 기존에 쓰던 Swagger든 API든 접근할 때마다 기본 필터를 거치면서 요청을 검증한다. 그 중에서는 인증에 대한 기본 필터도 있어 Id / password를 요구하게 되고 이는 이전 프로젝트에서 나도 꺼버린 적이 있다.

logging.level.org.springframework.security=DEBUG로 자세한 로그를 확인 가능하다.

기본 필터의 목록은 다음과 같다:

[org.springframework.security.web.session.DisableEncodeUrlFilter@6bf27411, 
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@4a66949a, 
org.springframework.security.web.context.SecurityContextHolderFilter@2eef43f5, 
org.springframework.security.web.header.HeaderWriterFilter@6a2d0a19, 
org.springframework.security.web.csrf.CsrfFilter@324afa73, 
org.springframework.security.web.authentication.logout.LogoutFilter@6792aa3e, 
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@20673498, 
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@46c28400, 
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@57e83608, 
org.springframework.security.web.authentication.www.BasicAuthenticationFilter@4b4b02d, 
org.springframework.security.web.savedrequest.RequestCacheAwareFilter@6c65a7fc, 
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@3b09582d, 
org.springframework.security.web.authentication.AnonymousAuthenticationFilter@57d8d8e2, 
org.springframework.security.web.access.ExceptionTranslationFilter@4a4979bf, 
org.springframework.security.web.access.intercept.AuthorizationFilter@7a14d8a4]

굉장히 많은데 강의에서 주요 필터를 위주로 설명해주셨다.

  • SecurityContextHolderFilter
    SecurityContext 객체를 생성, 저장, 조회하는 역할
  • CsrfFilter
    CSRF 공격을 막는 필터, 서버측으로 의도치 않은 요청을 생성시키는 CSRF 공격이 들어오는지 체크하는데 주로 세션ID를 기반으로 공격이 들어오기 때문에 Stateless한 JWT는 필요하지 않다.
  • LogoutFilter
    로그아웃 URL (default로 /logout이 지정되어 있다.)로 요청이 들어오면 알아서 세션을 무효화하고 쿠키를 삭제하고 SecurityContext를 비운다.
  • UsernamePasswordAuthenticationFilter
    로그인 URL (default로 /login이 지정되어 있다.)로 요청이 들어오면 username과 password를 비교해서 실제 인증을 수행한다. 필터가 마무리되면 SecurityContext에 isAuthenticated가 수행된 Authentication 객체가 저장된다고 생각할 수 있다.
  • DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter
    Spring security 설치하자마자 보이던 로그인 / 로그아웃 페이지는 여기서 생성된다.
  • SecurityContextHolderAwareRequestFilter
    SecurityContext에 저장된 정보를 읽고 Request를 구성해주는 필터다.
  • AnonymousAuthenticationFilter
    익명 사용자에 대한 인증 처리 필터다. 앞에서 SecurityContext에 Authentication 객체가 구성되지 않았다면 AnonymousAuthenticationToken을 SecurityContext에 구성한다.
  • ExceptionTranslationFilter
    AccessDeniedException(인가예외)와 AuthenticationException(인증예외)을 처리한다.
  • AuthorizationFilter
    권한을 확인한다.

위에서 아래로 쭉 진행된다고 보면 되고 이걸 기준으로 우리 필터가 필터 체인중 어디에 추가해야 하는지에 대해 알아야하기 때문에 어느정도는 필터 체인의 주요 필터를 알아야 할 필요가 있다고 생각할 수 있다. 보통 인증 관련해선 UsernamePasswordAuthenticationFilter 부근에 위치시킨다.

Csrf 공격 체크는 해봐야할 것 같고 인증전에 로그아웃이면 먼저 처리하면 좋으니 그쯤 된다고 생각해볼 수 있다.

이런 Filter를 관리하기 위한 SecurityConfig를 만들어서 @Configuration 을 설정해주고 추가로 HTTP 기반 통신이기 때문에 @EnableWebSecurity를 켜서 관련 보안 기능도 켜준다.

그리고 default중 강의에 맞게 필요없는 filter를 비활성화하는 건 다음과 같이 진행했다.
Basic auth, CSRF, DefaultLoginPage를 껐다.

@Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        return http
            .httpBasic { it.disable() }
            .formLogin { it.disable() }
            .csrf { it.disable() }
            .build()
    }

기능을 쉽게 껐다켰다 할 수는 있는데 해당 기능이 정확히 어떤 필터를 끄고 어떤 필터에 영향이 가는지 필터 이름으로 명시적으로 나와있진 않아서 주의를 위해 어느정도 이해는 필요할 것 같다.

JWT 구현

이미 해본 내용이지만 필터에서 JWT를 생성/검증하는건 처음이고 또 Refresh Token까지 생각할 수 있는지에 대해 생각해봐야 한다.

강의는 보다보니 이전에 Todo 앱 과제에서 진행한 내용과 동일했고 몇가지 참고할 점만 정리하기로 했다.

try-catch와 runCatching
몇몇 레퍼런스에서 보던 runCatching이 강의에도 등장했는데 두 개의 차이를 조사해봤다.
try-catch는 예외를 처리하는 가장 기본적인 방법으로 코드 블록 안에서 발생한 예외를 캐치하는 방식이고
runCatching은 Kotlin이 제공하는 확장 함수로 에러가 발생할 수 있는 코드를 함수형으로 담은 후 Result<R>로 만들어 반환하고 Result는 다시 isSuccess, isFailure, onSuccess, onFailure로 사용할 수 있어 유연성이 좋아진다.

YML의 값을 읽어오는 다양한 방법
나는 바로 @Value로 값을 읽은 적도 있고 @Component로 구성한 Config class에서 @Value로 읽어보기도 했고 @ConfigurationProperties를 사용해서 읽어본 적도 있는데
@ConfigurationProperties를 사용할 때는 data class로 정의해서 읽으면 더욱 깔끔할 것 같다.
@ConfigurationProperties를 레퍼런스

JWT 검증

이제 필터 체인도 공부했고 JWT도 만들었으니 필터를 사용해 구현하면 될텐데 강의에서는 Filter를 반드시 통해서 인증할지 Controller를 통해 처리할지는 선택의 문제라고 한다. 세션 기반 인증이라면 인증되었다는 정보를 취급하기 때문에 Filter가 좋겠지만 Stateless한 JWT는 필수는 아니라는 점은 의외였다.

컨트롤러 기반의 로그인 진행후 Access token을 발급하는 건 앞서 처리해본 내용이었고 이제 JWT가 유효한지 체크하는 로직은 Filter를 사용해서 처음으로 진행하는 것이다.

우선 인증 타입부터 알아보기로 했다. 흔히 말하는 Bearer {JWT}의 경우처럼 앞에 타입을 명시해주는 경우다.

  • Basic
    사용자의 ID, PW를 Base64 인코딩한 값을 토큰으로 사용할 때를 의미함
  • Bearer
    JWT or OAuth를 통한 인증을 의미함
  • Digest
    이건 처음 보는데 서버에서 보낸 난수 문자열에 대해 사용자 정보와 함께 해쉬함수를 통해 응답해서 서버에서 풀어쓰는 걸 의미함

나는 여태 귀찮아서 다 빼고 작성했고 클라이언트에서 요청 보낼때는 일단 앞에 붙이긴 했는데 좀 제대로 알아본 건 처음이었고 서버에선 항상 Bearer를 제외하고 JWT를 추출하는게 기본이라고 한다.

강의는 Filter를 쓸 때 AuthenticationManager, AuthenticationProvider는 쓰지 않고 간단한 구현으로 보여주기로 했고 나중에 간단하지만 구조적으로 작성된 것도 한번 참고해봐야할 것 같다.

강의에서 요청마다 JWT 토큰을 검증해야 할테니 OncePerRequestFilter를 상속받아 구현한다. 멤버 함수인 doFilterInternal은 request, response, filterChain 에 대한 정보를 가지고 있어서 HttpServletRequest 에서 Authorization header를 가져오는 데엔 문제가 없다.

Bearer를 제거하는 방법은 다양하지만 정규식으로 표현시 Regex("^Bearer (.+?)$") 같은 형태로 표현할 수도 있다.

Filter는 Bean 주입을 받는 것도 딱히 제한이 없기에 만들어둔 Jwt plugin을 그대로 받아다가 Token을 검증하고 해당 결과의 payload에서 값을 추출해 Authentication에 저장하면 된다.

data class UserPrincipal(
    val id: Long,
    val email: String,
    val authorities: Collection<GrantedAuthority>
) {
    constructor(id: Long, email: String, roles: Set<String>) : this(
        id,
        email,
        roles.map { SimpleGrantedAuthority("ROLE_$it" ) })

}

내부적으로는 이제 위처럼 Authentication 객체에 저장할 Principal, AuthenticationToken 같은 걸 만들어야 하고 그 내부에서 쓰는 GrantedAuthority 같은 것도 인지를 해야한다. 인가에 필요한 Role 같은 경우엔 "ROLE_" 을 앞에 붙여줘야 한다고 하셨는데 이유는 인가에서 다룬다고 하셨다.

워낙 내부적으로 알아둬야하는 객체가 많아서 정리를 하기엔 너무 복잡한데 구체적인 내용을 정리하기 보다는 위에서 정리한 인터페이스 / 구현체가 있다는 정도를 일단 이해하고 넘어가야할 것 같다.

평범하게 Map으로 담거나 하는게 아니라 객체 단위로 묶여있는 경우가 많아서 초기 구현에서 알아봐야할 게 정말 많은 것 같고 레퍼런스 참조를 안하면 지금 당장은 건드리질 못할 것 같다.

최종적으로 필터가 적용되려면 filterChain.doFilter(request, response)가 되야하니 여기에 내가 무슨 Details를 담을지, Authentication 성공 여부는 어떻게 설정할지 같은걸 생각해야겠다.

인증 테스트와 예외처리

테스트를 위해 Swagger Config을 바꾸는 법도 강의에 나와있었는데 지난 주에 프로젝트 하면서 은근 레퍼런스가 없어서 헤매던 부분이 바로 나오니깐 약간 슬펐다.

그리고 Security config에서도 만든 filter를 Filter chain에 적용해야 하고 어떤 요청에만 적용할지도 처리해야 하기 때문에 추가 설정이 필요하다. 예를 들어 로그인 / 회원가입이나 Swagger에 접근할 때도 인증을 요구하면 안되니 이런건 수동으로 추가가 필요해진다.

@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
    return http
        .httpBasic { it.disable() }
        .formLogin { it.disable() }
        .csrf { it.disable() }
        .authorizeHttpRequests {
            it.requestMatchers(
                "/login",
                "/signup",
                "/swagger-ui/**",
                "/v3/api-docs/**",
            ).permitAll()
                // 위 URI를 제외하곤 모두 인증이 되어야 함.
                .anyRequest().authenticated()
        }
        // 기존 UsernamePasswordAuthenticationFilter 가 존재하던 자리에 JwtAuthenticationFilter 적용
        .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
        .build()
}

이렇게 복잡한 과정을 싸그리 거치면 Swagger에서 Authorize 진행하면 curl에 header에 Authorization 으로 Bearer {jwt}가 들어가게 된다.

그리고 인증 안한 상태에서 인증이 필요한 API에 접근하면 401 UnAuthorized가 이루어질 텐데 실제로 접근해보니 403 Forbidden이 뜬다.

이렇게 되는 이유는 인증과 인가의 예외를 처리하는 ExceptionTranslationFilter는 기본적으로 AuthenticationEntryPoint에 처리를 위임하는데 Spring security는 기본적으로 AuthenticationEntryPoint의 역할을 Http403ForbiddenEntryPoint가 처리하기 때문에 403 Forbidden이 된다.

403 Forbidden은 서버에 클라이언트의 요청은 들어왔지만 서버가 클라이언트의 접근을 거부할 때 사용된다.

그래서 구체적으로 인증을 안해서 접근을 거부한 거니 403 대신 401을 주려면 AuthenticationEntryPoint 또한 새로 구현해서 등록해줘야 한다. 정말 Spring security는 방대한 것 같다.

@Component
class CustomAuthenticationEntrypoint: AuthenticationEntryPoint {

    override fun commence(
        request: HttpServletRequest,
        response: HttpServletResponse,
        authException: AuthenticationException
    ) {
        response.status = HttpServletResponse.SC_UNAUTHORIZED
        response.contentType = MediaType.APPLICATION_JSON_VALUE
        response.characterEncoding = "UTF-8"

        val objectMapper = ObjectMapper()
        val jsonString = objectMapper.writeValueAsString(ErrorResponse("JWT verification failed"))
        response.writer.write(jsonString)
    }
}

AuthenticationEntryPoint를 상속받고 commence 를 구현해주면 되는데 commence는 시작하다, 개시하다같은 뜻이라고 한다.

원래 403을 반환했을 response를 수정해주는 방식으로 구현했고 이걸 SecurityConfig의 filterChain에 추가해주면 된다.

// 생성자에서 CustomAuthenticationEntrypoint 주입됨
.exceptionHandling{
    it.authenticationEntryPoint(authenticationEntrypoint)
}

인가 진행하기

인증 하겠다고 만든 파일과 공부한 내용만 한가득인데 아직 인가는 진행하지도 않았다. 본래 목적이었던 어플리케이션 전반적인 인가의 상태를 체크해야하기 때문에 이 또한 처리가 필요하다.

그 전에 인가에 대한 처리를 공부해야 한다. 바로 RBACABAC 인데 이건 밑에 따로 기록했다. 이제는 잘 기억 안나는 정보처리기사 문제를 여기서 접하게 될 줄은 몰랐다.

강의는 테이블에 맞춰서 Student, Tutor 두 개중 하나의 값만 가지는 RBAC Level 0을 다뤘다.

AuthorizationFilter AuthorizationManager에 이 역할을 위임하는데 이건 지겹도록 봤던 AuthenticationManager가 온갖 구현체를 들고있던 것 처럼 얘도 온갖 구현체를 가지고 있다. 하이고 정말 양도 많다

하지만 일단 세세한 구현체까지 알 필요는 없으니 권한에 맞춰 인가하는 방법을 생각해봐야 하는데 방법은 여러가지 있지만 대표적으로 생각해볼 수 있는 방법은 다음과 같다:

  • 요청 URI를 체크해 권한을 분리
    /admin/**에 접근하려면 ADMIN 역할이 수행한다 같은 식으로 인가 처리할 수 있다.
  • Controller 에서 요청을 수행하기 전에 역할 / 권한을 확인
  • Controller 에서 요청을 수행한 이후에 역할 / 권한을 확인
    수행한 이후면 특정 리소스를 획득했을텐데 이 때 체크하는 것이다. 이건 ABAC를 하고 있다면 리소스 권한을 추가로 체크해야하기 때문에 사용하고 RBAC만 한다면 앞에서 대부분 처리할 수 있다.

위 방법에 맞춘 대표적인 구현체가 있는데

  • RequestMatcherDelegatingAuthorizationManager
  • PreAuthorizeAuthorizationManager
  • PostAuthorizeAuthorizationManager

이렇게 존재하고 물론 추가로 구현체가 더 존재한다.

그런데 인증과는 달리 인가는 이 구현체를 직접 조사할 필요 없이 Spring security가 MethodSecurity라는 걸 지원해서 편리하게 적용할 수 있다고 한다.

SecurityConfig에 @EnableMethodSecurity를 적용하면 사용할 수 있다.

  • filterChain에 설정해서 URL 기반에 ROLE 체크하기
.permitAll()
.requestMatchers("/admin/\*\*").hasRole("ADMIN") // <-- 해당 부분
.anyRequest().authenticated()

여기서 hasRole은 SecurityExpressionRoot 에서 제공하는 함수인데, 이 함수는 Authentication 객체에 담겨있는 authorities 를 확인하고 이 때 Prefix로 ROLE_ 을 사용한다.

때문에 위에 인증 단계에서 GrantedAuthority를 저장할 때 Role 앞에 "ROLE_" 을 붙인 것이다.

추가로 Authority가 그냥 String으로 저장될 수 있는 이유는 역할 말고 권한도 체크하기 때문인데 "ROLE_"을 붙였으면 역할, 아니면 권한이라고 생각할 수 있다.

권한도 단순히 String만 쓰면 구분이 어려울 수 있으니 READ, WRITEcourse::read 처럼 구체적으로 명시할 수도 있고 이런건 컨벤션으로 정해서 역할과 권한의 네이밍을 잘 정하고 권한 체크를 위해 hasRole() 대신 hasAuthority()를 사용하면 된다.


이제 URL에 권한 거는 방법까지 했으니 Controller에 거는 방법도 알아볼 차례다.

Controller엔 @Secured, @PreAuthorize, @PostAuthorize 어노테이션이 있는데 @Secured@PreAuthorize는 인가 시점이 같지만 @Secured는 오래된 거라 Spring Expression Language 사용이 가능하고 SecurityExpressionRoot의 methods, properties에 접근도 가능하고 argument 까지 접근이 가능해지기 때문에 @PreAuthorize를 권장한다.

정확히는 Secured에서도 간단한 문자열 표현식으로 "ROLE_ADMIN" 같은 처리는 가능하지만 SpEL 처럼 복잡한 권한 표현식은 사용 안된다.
대신 Secured는 클래스 레벨에 적용이 가능해서 클래스에 사용시 모든 메소드에 적용할 수 있다고 한다.

// SpEL 사용 예시
@PreAuthorize("#user.name == principal.name")
fun doSomething1(user: User): Unit { ... }

@PreAuthorize("hasRole('ADMIN') or hasRole('STUDENT')")
fun doSomething2(course: Course): Unit { ... }

// 리소스 접근 권한 체크는 return 되는 object를 읽을 수 있다.
@PostAuthorize("returnObject.owner == authentication.name")
fun getCustomer(val id: String): Customer { ... }

Spring Expression Language는 YML에서 값을 읽어올 때 @Value 어노테이션 안에 정의하던 것과 같은 표현식이다.
참고 링크

RBAC (Role-Based Access Control)

user에 role로 user, admin 처럼 처리하면 역할 기반이라고 보면 된다. 한 유저는 하나의 role만 가지니 권한을 체크하기 쉽다.

하지만 어플리케이션의 복잡도가 증가할 수록 역할이 세분화될 수 있고 역할도 커질 수도 있어지니 골치가 아파지는데 이걸 Restful 레벨을 0 ~ 3으로 구분하는 것처럼 RBAC도 복잡도에 따라 레벨을 0 ~ 4로 구분할 수 있다고 한다. 이게 일반적으로 통용되는 건 아니고 RBAC를 정의하는 단체에 따라 내용이 달라질 수도 있다고 한다.

  • Level 0
    지금 하려는 것처럼 한 유저가 하나의 역할만 가지는 것이다.
  • Level 1 (Standard RBAC)
    유저가 하나 이상의 역할을 가질 수 있게 된다. 이게 Standard인 걸 보면 RBAC의 대표적인 예시라고 생각할 수 있을 것 같다.
  • Level 2 (Hierarchical RBAC)
    역할간의 계층을 만들어서 Admin - Manager - User 순서로 계층을 쌓을 경우 Admin이 하위 두 역할 수행을 가능하게 하고 Manager는 User가 가능한 식으로 구현한다.
    Spring security도 이걸 지원해서 Spring security moral hierarchy를 찾아보면 된다.
  • Level 3 (Constrained RBAC)
    역할별로 가능한 권한 목록을 모두 정의해놓고 모두 같은 팀원이라는 역할 안에서 추가로 권한을 설정하는 제약을 거는 것이다. (개발자 / 마케터 / 영업 으로 구분하는 것처럼) Github이나 구글같은 곳에서 많이 본 것 같다.
  • Level 4 (Dynamic RBAC)
    Level 3의 역할별로 가능한 권한 목록도 동적으로 변경되게 한다. 굉장히 동적으로 바뀌게 된다.

ABAC (Attribute-Based Access Control)

속성 별로 접근을 제어하게 하는건데 RBAC랑 상반되는 개념은 아니고 RBAC랑 함께 써서 세밀한 권한 제어를 할 때 적용할 수도 있다. 도메인마다 이 속성의 개념은 크게 달라질 수 있다.

속성은 세 가지 중요 개념을 가지고 있다.

  • 주체(Subject)의 속성
    행동을 하는 주체, 즉 사용자의 속성이라 어디선 단일 유저고 어디선 그냥 id고 어디선 그룹일 수도 있다.
  • 자원(Resource)의 속성
    자원을 생성한 사람인지, 생성일인지, 포맷인지등 아무거나 될 수 있다.
  • 행동(Action)
    위에서 말한 주체가 자원에 수행하는 동작으로 읽기, 쓰기, 실행등을 의미한다.

개념만 보면 너무 불친절한 키워드인데 예를 들어 Google drive에서 소유자, 관리자같은 역할이 이미 나눠져 있고 권한도 나눌 수 있지만 추가로 파일, 폴더별로 권한을 바꿀 수 있다는 것이 ABAC를 의미한다고 생각할 수 있다.

이처럼 ABAC의 속성은 도메인에 따라 굉장히 많이 틀려질 수 있다.

챌린지반 - OAuth 2.0 소셜 로그인 리뷰

어제까지 과제로 부랴부랴 제출한 OAuth 2.0에 대한 챌린지반 세션이 있었다.

OAuth 2.0은 네이버 / 카카오를 하면서 인가 코드 발급방식을 사용했지만 OAuth 2.0 스펙의 자세한 정보는 RFC 6749 문서를 통해 확인해볼 수 있고 4가지 프로토콜이 있다고 한다. 세션에선 네이버 / 카카오가 사용하는 Authorization Code Grant 방식만 진행했다.

대체로 이해에 성공한 내용이었지만 OAuth에 맞는 해석을 했다기 보다는 Kakao / Naver의 위치를 이해한 느낌이라 강의를 보면서는 내 로직의 Scope가 사실은 어디에 있었어야 했는가를 생각하며 봤던 것 같다.

이번엔 Spring security를 쓰지 않고 구현했었는데 마침 Spring security를 OAuth에 녹인 부분을 보니까 구조가 되게 복잡해지길래 미리 OAuth, Spring security를 공부하길 잘했던 것 같다.

이번 과제는 구현 자체에 끙끙대며 접근했지만 객체지향적인 구조를 위해서 생각할 여지는 충분히 넘친다는 걸 깨달을 수 있는 세션이 됐고 솔직히 당장 구현하기엔 좀 막막한 감이 있다.

일단 상상력이 빈약해서 어디서 책임 분리를 진행할 수 있을까가 잘 보이질 않는다.

그리고 코드 리뷰도 진행됐는데 우선 나는 Controller 에서 로직을 분리하는게 영 잘 상상이 안됐고 회원 정보를 저장할 때 구체적으로 얼만큼 저장해야 부족한 정보 / Provider별 다른 정보를 해결할지도 어려웠다.

  • OAuth2 소셜 로그인의 목표와 과정부터 설계하는 것이 부족했다.
    마지막에 회원가입을 위한 유저 테이블을 그릴 때 딱히 맥락을 많이 고려하진 못했던 것 같다.
    팀에서 근무중이라면 방향성이 결정됐을 때 팀과 상의하면 좋다.

  • 객체부터 그려보고 납득하면 코드를 작성하자.
    오히려 앱에서 구현해봐서 그런지 백엔드는 어떻게 작성하나 하고 먼저 컨트롤러에 싸그리 다 넣어놓은게 좋은 시도는 아니었던 것 같다.
    애초에 앱에서는 내가 회원가입 / 로그인까지 다 진행하고 서버에 넣는 것도 내가 했었는데 이번 구조를 생각해보면 되게 신기했던 것 같다.
    서비스 분리를 못했던 내 경우 컨트롤러가 그냥 서비스에 책임을 위임하고 서비스에서 KakaoClient, Repository에 각각 접근했으면 더 깔끔하지 않았을까 싶다.

  • Provider가 제공해준 Token의 목적이 로그인에서 끝나는지 고민해보자.

  • RestTemplate 대신 WebClient나 RestClient를 쓰자. 사실 이건 마침 지난 번에 소셜 로그인 구현할 때 정리해서 얼추 알고있긴 한데 그냥 읽어보던 레퍼런스 맞춰서 썼다.

  • Provider가 추가/삭제될 때를 가정하고 코드가 변경에 얼마나 영향을 받는지 생각해봐야 한다.

  • 소셜로그인과 일반 사용자를 합치면 복잡도가 올라가니 SocialMember로 분리하는게 더 쉬워지는 접근이다.

그냥 잘못된 책임 분리라도 먼저 상상해봤으면 제출한 것처럼 Controller에 다 넣고 보는 일은 없었을 것 같다.

Kakao OAuth 라이브 코딩 보기

튜터님이 진행해주신 Kakao를 기반으로 책임 분리를 진행하는 라이브 코딩 시간도 있었다.

  • 우선 OAuth Provider가 외부라고 해서 Infra로 가는게 아니라 도메인으로 이름은 socialmember로 출발했다.
    user와 같이 갈 때 유저 정보 조회는 어떻게 해야할지 모르겠다가도 Jwt를 잘 만들어서 써먹는게 이럴 때 나오는 건가 싶기도 하다.
  • API는 Redirect, Callback 두 가지만 정의됐다.
    유저 정보는 서비스에서 알아서 회원가입 처리하는게 좋았나보다.
  • 책임 분리가 하고 싶었으면 일단 컨트롤러에서 만들지도 않은 서비스에 요청을 보내고 생각하면 됐다. (.generateUrl(), .login())
  • callback 에서 서비스로 책임을 넘길 때 서비스에서 회원가입 / 로그인 처리, JWT 발급을 싹 다 진행했으면 좋았을 것 같다.
  • 서비스에서도 Kakao에 보낼 요청에 대한 비즈니스 로직이 아니라 치고 KakaoClient를 분리해서 책임을 넘겼다.
    Kakao와의 통신은 KakaoClient가 알아서 해줄 것이다.
  • registerIfAbsent 로 없으면 회원가입, 없으면 조회하는 코드로 명시하면 처리가 깔끔할 것 같다.
    조회해서 없으면 repository에 save하는 간단한 처리고 signup, signin을 나눠서 요청할 필요도 없다. elvis operator로 조회되면 즉시 반환, 아니면 생성해서 반환하는 방식이면 된다.
  • KakaoClient는 뭔가 비즈니스 로직같아서 Service인가 싶었는데 @Component로 구분됐다.
  • RestTemplate는 낡았고 WebClient는 Config이 좀 어려우니 RestClient를 사용했다.
    Bean으로 만들어서 KakaoClient에 주입하면 된다.
    RestTemplate 보다 RestClient가 내가 써왔던 Http client랑 비슷하게 생겼는데 진즉 이거 쓸 걸 그랬다.
  • Kakao 로직 구현이 끝나고 이제 Naver를 추가해야한다고 가정했을 때 "Kakao" 라는 이름을 몇개나 모르게 추상화할 수 있는지 고민해보셨다.

책임의 분리가 마냥 어렵게만 느껴지다가도 라이브 코딩을 보면 나도 시도해볼 수 있었네 같은게 뒤늦게서야 보이는게 참 피곤하고 골치도 아프다. 파일을 여기서 어떤 패키지에 더 만들어도 되는 건가? 라는 생각도 여전히 자주 드는데 패키지가 정해진 책임을 얘기하는 건 아닌데 빨리 겁먹는 느낌이 있다.

오늘 라이브 코딩도 값진 경험이 된 것 같다. 아직도 평소 코드를 저렇게 분리할 자신은 없지만 해당 기능에 한해서라도 분리를 시도는 해봐야 하지 않을까 싶다.


코드카타 - 프로그래머스 개인정보 수집 유효기간

고객의 약관 동의를 얻어서 수집된 1~n번으로 분류되는 개인정보 n개가 있습니다. 약관 종류는 여러 가지 있으며 각 약관마다 개인정보 보관 유효기간이 정해져 있습니다. 당신은 각 개인정보가 어떤 약관으로 수집됐는지 알고 있습니다. 수집된 개인정보는 유효기간 전까지만 보관 가능하며, 유효기간이 지났다면 반드시 파기해야 합니다.

예를 들어, A라는 약관의 유효기간이 12 달이고, 2021년 1월 5일에 수집된 개인정보가 A약관으로 수집되었다면 해당 개인정보는 2022년 1월 4일까지 보관 가능하며 2022년 1월 5일부터 파기해야 할 개인정보입니다.
당신은 오늘 날짜로 파기해야 할 개인정보 번호들을 구하려 합니다.

모든 달은 28일까지 있다고 가정합니다.

다음은 오늘 날짜가 2022.05.19일 때의 예시입니다.

약관 종류 유효기간
A 6 달
B 12 달
C 3 달
번호 개인정보 수집 일자 약관 종류
1 2021.05.02 A
2 2021.07.01 B
3 2022.02.19 C
4 2022.02.20 C
  • 첫 번째 개인정보는 A약관에 의해 2021년 11월 1일까지 보관 가능하며, 유효기간이 지났으므로 파기해야 할 개인정보입니다.
  • 두 번째 개인정보는 B약관에 의해 2022년 6월 28일까지 보관 가능하며, 유효기간이 지나지 않았으므로 아직 보관 가능합니다.
  • 세 번째 개인정보는 C약관에 의해 2022년 5월 18일까지 보관 가능하며, 유효기간이 지났으므로 파기해야 할 개인정보입니다.
  • 네 번째 개인정보는 C약관에 의해 2022년 5월 19일까지 보관 가능하며, 유효기간이 지나지 않았으므로 아직 보관 가능합니다.

따라서 파기해야 할 개인정보 번호는 [1, 3]입니다.

오늘 날짜를 의미하는 문자열 today, 약관의 유효기간을 담은 1차원 문자열 배열 terms와 수집된 개인정보의 정보를 담은 1차원 문자열 배열 privacies가 매개변수로 주어집니다. 이때 파기해야 할 개인정보의 번호를 오름차순으로 1차원 정수 배열에 담아 return 하도록 solution 함수를 완성해 주세요.

문제 링크

fun solution(today: String, terms: Array<String>, privacies: Array<String>): IntArray {
    var answer: IntArray = intArrayOf()
    val termsMap = terms.map { it.split(" ") }.associate { it[0] to it[1].toLong() }
    val privaciesPairList = privacies.map { it.split(" ") }.map { it[0] to it[1] }
    var index = 0
    val (todayYear, todayMonth, todayDay) = today.split(".").map { it.toInt() }
    privaciesPairList.forEach { (privacyDate, term) ->
        index++
        var (expireYear, expireMonth, expireDay) = privacyDate.split(".").map { it.toInt() }
        expireDay += termsMap[term]!!.toInt() * 28
        if (expireDay > 28) {
            expireMonth += expireDay / 28
            expireDay %= 28
        }
        if (expireMonth > 12) {
            expireYear += expireMonth / 12
            expireMonth %= 12
            
            if (expireMonth == 0) {
                expireYear--
                expireMonth = 12
            }
        }
        if (todayYear > expireYear ||
            (todayYear == expireYear && todayMonth > expireMonth) ||
            (todayYear == expireYear && todayMonth == expireMonth && todayDay >= expireDay)) {
            answer += index
        }
    }
    return answer
}

푸는데 시간이 꽤 걸렸다. 맨 처음엔 일단 때려넣고 봐야할 것 같아서 LocalDate와 DateFormatter를 써서 먼저 접근해 봤고 절반이 틀렸다.

역시나 문제 조건중에 모든 월은 28일로 계산한다. 라는 조건때문인지 날짜 계산식이 틀려지나 싶어 LocalDate 사용을 완전히 제거하고 Month를 별도로 분리해서 작성하다가 혹시 모르니 Day까지 계산해서 28일 단위로 쪼개기 시작했다.

너무 하드코딩이 된 것 같아 마음에 드는 건 아니었는데 이렇게 계산해서 제출해도 LocalDate와 다르지 않은 계산이 나오길래 여러 반례를 찾아보기 시작했다.

  1. Map으로 인한 이슈
    나는 바로 개인정보를 Map으로 바꾸고 시작했었는데 반례를 보면 날짜가 여러개 중복된 것도 있었다. 결국 중복 Key가 제거되면서 당연히 실패가 나오고 있었고 Pair<String, String>으로 바꿔서 해결했다.

  2. expireMonth 계산에 대한 이슈
    그럼에도 17번에서 실패가 일어났다. 17번의 반례를 읽어보니 Month가 12월일 때를 생각해봐야 한다. 라고 나와있었는데
    나는 if (expireMonth > 12)로 조건을 제어하고 있어서 expireMonth % 12를 해도 나머지가 무조건 남지 않나? 라는 생각을 했는데 이게 반례인 이유는 expireMonth가 12의 배수일 때를 고려해봐야 했다.


    expireMonth가 만약 24가 된다면 조건은 통과하고 expireYear에 2년을 더하고 expireMonth가 0이 되어버려서 answer를 추가하는 로직에서 걸려버리고 만다. 결국 expireMonth가 0일 때의 경우는 일어날 수 있는 경우였고 이에 대해 처리하고 나서 마침내 통과에 성공했다.


우여곡절끝에 성공하고 다른 제출을 확인해보면 처음에 했던 것처럼 Month만 더하거나 28일 단위로 계산하지 않고 Month 처리만 진행해도 정책상 월 단위로 더하는 거라 문제가 없음을 확인했다. 내 로직에서도 28일 단위로 굳이 안끊었다면 조금 더 깔끔했을 것 같다.

추가로 다른 제출을 보다가 가장 재밌는 풀이는 이거였다.

 fun solution2(today: String, terms: Array<String>, privacies: Array<String>) = privacies.indices.filter {
        privacies[it].split(" ").first().split("\\.".toRegex()).map(String::toInt)
            .let { (y, m, d) -> (y * 12 * 28) + (m * 28) + d } + (terms.map { it.split(" ") }
            .associate { (a, b) -> a to b.toInt() }
            .getOrDefault(privacies[it].split(" ").last(), 0) * 28) <= today.split("\\.".toRegex()).map(String::toInt)
            .let { (y, m, d) -> (y * 12 * 28) + (m * 28) + d }
    }.map { it + 1 }

너무 복잡해서 읽기는 힘들지만 내장 함수 체이닝을 정말 복잡한데도 잘 걸려있는 걸 보니 참 신기하다. 물론 실제 로직을 이렇게 작성하면 안될 것 같다.

post-custom-banner

0개의 댓글