방구석 CTO
245
2020-03-04 02:03:18
0
1441

코틀린(Kotlin)으로 스프링 시큐리티(Spring Security) 유저 정보 조회


원문

https://anomie7.tistory.com/65

--------------------

코틀린(Kotlin)으로 스프링 시큐리티(Spring Security) 유저 정보 조회

들어가며

코틀린(Kotlin)을 회사 웹 서비스에 도입하기 위해 공부하고 있다.
Kotlin으로 Spring Boot Security - Login 맛보기 라는 글을 참고해 로그인 부분을 구현했다. 훌륭한 글이지만 실무에서 사용할 만한 기능의 예시는 없어서 내가 테스트해보고 글을 적는다.

본 글에서 다루는 내용

  • UserDetails를 구현해서 필요한 유저 정보를 로그인 시 SecurityContext에 저장하기
  • 저장한 유저 정보를 SecurityContextHolder로 조회
  • 유저 정보를 불러오는 코틀린 함수 정의(JAVA의 유틸 클래스(Util Class)를 대체)
  • JAVA에서 코틀린 함수를 호출
  • 코틀린의 안전한 캐스팅(safe cast), 스마트 캐스팅(smart cast)

UserDetails 구현

class CustomUserDetails private constructor(
        private val userName: String,
        private val password: String,
        private val roles: MutableSet<AccountRole>,
        val createDt: LocalDateTime,
        val visitCount: Int = 5) : UserDetails {

    companion object {
        fun from(account: Account): CustomUserDetails {
            return with(account) {
                CustomUserDetails(userName = email, 
                                  password = password, 
                                  roles = roles, 
                                  createDt = createDt)
            }
        }
    }

    override fun getUsername(): String {
        return userName
    }

    override fun getPassword(): String {
        return password
    }

    override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
        return roles.stream().map { role -> SimpleGrantedAuthority("ROLE_$role") }.collect(Collectors.toSet())
    }

    override fun isEnabled(): Boolean {
        return true
    }

    override fun isCredentialsNonExpired(): Boolean {
        return true
    }

    override fun isAccountNonExpired(): Boolean {
        return true
    }

    override fun isAccountNonLocked(): Boolean {
        return true
    }
}

코틀린을 잘 모르면 이해하기 어려울 수 있다.
우선 UserDetails를 구현했다. 코틀린에서는 자바처럼 extends 나 implements 키워드 대신 ':' 라는 기호를 사용한다.
코틀린에서는 오버라이드된 함수는 명시적으로 표시(override)를 한다.
주 생성자를 접근 제한자(Visibility Modifiers) private로 클래스 내부에서만 호출할 수 있도록 한다.
companion object로 팩토리 메소드를 만들어서 객체를 생성하도록 한다. (자바의 정적 메소드와 같은 용도)
visitCount, createDt처럼 필자가 추가하고 싶은 유저 속성도 추가했다.
팩토리 메소드를 구현한 이유는 아래의 UserDetailsService 구현 클래스 코드를 본 후 설명하겠다.

UserDetailsService 구현

@Service
class CustomUserDetailsService(private val accountRepository: AccountRepository,
                               private val passwordEncoder: PasswordEncoder) : UserDetailsService {
    override fun loadUserByUsername(username: String): UserDetails {
        return accountRepository.findByEmail(username)?.let { CustomUserDetails.from(it) }
                ?: throw UsernameNotFoundException("$username Can Not Found")
    }
}

loadUserByUsername() 메소드는 로그인 이후 자동 실행되는 메소드로 UserDetails를 Authentication로 변환 후 인증을 거쳐 인증정보를 저장시켜주는 메소드이다.

자세한 동작 원리는 Spring-security-구조 참고

Account라는 Entity를 DB에서 조회해서 UserDetails로 변환하는 부분이다.
코틀린은 Named Argument로 함수 호출시 인자를 명시할 수 있다. (Builder 패턴 대체 가능)
Named Argument를 사용했을 때 인자가 너무 많아서 가독성이 떨어지고 특정 객체 타입(Account)으로만 Userdetails를 생성하도록 제한하고 싶어서 팩토리 메소드를 사용했다.

아래는 Named Argument를 사용한 경우이다. 람다식 안에 생성자가 들어있으니 가독성이 떨어진다.

    override fun loadUserByUsername(username: String): UserDetails {
        return accountRepository.findByEmail(username)?.let { 
                CustomUserDetails(userName = it.email, 
                                  password = it.password, 
                                  roles = it.roles, 
                                  createDt = it.createDt) }
                ?: throw UsernameNotFoundException("$username Can Not Found")
    }

유저정보 조회하기

스프링으로 웹 서비스를 개발할 때 로그인한 유저정보를 조회하는 상황이 많다.
자바(JAVA)를 이용할 때는 보통 아래와 같은 방법을 사용한다.

    Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    if (principal.equals("anonymous")) {
        Assertions.assertEquals("anonymousUser", principal);
    } else {
        CustomUserDetails userDetails = (CustomUserDetails) principal;
        String username = userDetails.getUsername();
        LocalDateTime createDt = userDetails.getCreateDt();
    }

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (!(authentication instanceof AnonymousAuthenticationToken)) {
    String currentUserName = authentication.getName();
    return currentUserName;
}

나는 위와 같은 방법으로 유저 정보를 가져오는 것을 선호한다.
그러나 여러 방법이 있다.

공통으로 SecurityContextHolder를 호출한다.
instanceof로 객체 타입을 검사하거나 Object를 캐스팅해서 UserDetails(혹은 UserDetails의 서브 타입)으로 변환한다.
이런 코드를 스프링(Spring Framework)의 Controller나 Service 빈(Bean)에서 유저 정보가 필요할 때마다 호출하면 코드가 중복되고 가독성도 안 좋다.
그래서 보통은 유틸클래스(Util Class)를 작성해서 사용하는 편이다.

public class AuthUtil {
    public static String getEmail() {
        Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        if (principal.equals("anonymousUser")) {
           return null;
        }else {
            UserDetails userDetails = (UserDetails) principal;
           return userDetails.getUsername();
        }
    }
    }

위와 비슷한 코드를 코틀린에서 작성해보자.

코틀린에서 유저정보 조회하기

    //파일명 : SecurityUtil.kt
    package com.example.demo.utill 

    fun getCustomUserDetails(): CustomUserDetails? {
        val principal = SecurityContextHolder.getContext().authentication?.principal
        return if (principal is CustomUserDetails) principal else null
    }

유틸 클래스(Util Class)는 필요없다.
코틀린에서 함수는 단독으로 선언할 수 있다.
SecurityUtil.kt 라는 코틀린 파일에 getCustomUserDetails()라는 함수를 구현했다.
그리고 is라는 키워드를 사용했는데 자바의 instanceof와 같은 기능을 한다.
코틀린은 스마트 캐스팅(smart cast)을 지원해서 is로 타입을 비교했을 떄 비교한 타입과 일치하면 자동으로 해당 타입으로 캐스팅을 해준다.

한번 위 함수를 호출해보자.

    @Test
    @WithAnonymousUser
    fun `로그인하지 않은 사용자의 정보를 조회`() {
        val customUserDetails = getCustomUserDetails()
        val userName = customUserDetails?.username ?: "anonymous"
        Assertions.assertEquals("anonymous", userName)
    }

    @Test
    @WithUserDetails(value = "user@test.com", userDetailsServiceBeanName = "customUserDetailsService")
    fun `로그인한 사용자의 정보를 조회`() {
        val customUserDetails = getCustomUserDetails()
        Assertions.assertTrue(customUserDetails?.username == "user@test.com")
    }

작성한 함수가 널이 가능한(Nullable) 객체 타입을 반환하기 때문에 안전한 호출(?.)과 엘비스 연산자(?:)를 사용해서 코드를 깔끔하게 작성할 수 있다.
코틀린 함수를 자바에서 호출해보자.

    @Test
    @WithAnonymousUser
    void 로그인하지_않은_사용자의_정보를_조회() {
        CustomUserDetails customUserDetails = SecurityUtillKt.getCustomUserDetails();

        Assertions.assertNull(customUserDetails);
    }

    @Test
    @WithUserDetails(value = "user@test.com", userDetailsServiceBeanName = "customUserDetailsService")
    void 로그인한_사용자의_정보를_조회() {
        CustomUserDetails customUserDetails = SecurityUtillKt.getCustomUserDetails();

        Assertions.assertEquals("user@test.com", customUserDetails.getUsername());
    }

코틀린 파일 SecurityUtil.kt에 작성된 getCustomUserDetails()라는 함수를 자바에서 호출했다.
SecurityUtillKt라는 자바 클래스(Java Class)의 정적 메소드(static method)를 호출하듯이 코틀린 함수를 호출한다.

안전한 캐스팅(safe cast)

    @file:JvmName("SecurityUtil")
    //파일명 : SecurityUtil.kt
    package com.example.demo.utill 

    fun getCustomUserDetails(): CustomUserDetails? {
        val principal = SecurityContextHolder.getContext().authentication?.principal
        //return if (principal is CustomUserDetails) principal else null
        return principal as? CustomUserDetails
    }

getCustomUserDetails()의 최종 코드이다.
처음부터 위의 코드를 보면 코틀린을 모르는 분들은 혼란스러울 것 같아서 가장 마지막에 작성한다.
as?는 특정 타입으로 변환 가능하면 그 타입의 널 가능한 타입으로 변환하고 불가능하면 null을 반환해준다.
그래서 주석처리한 부분과 같은 기능을 한다.
그리고 패키지(package) 상위에 선언한 @file:JvmName("SecurityUtil")은 자바에서 이 함수를 호출할 때 클래스 이름을 지정해준다.
그래서 아래와 같이 호출할 수 있다.

    CustomUserDetails customUserDetails = SecurityUtil.getCustomUserDetails();

마치면서

스프링 시큐리티(Spring Security)에서 유저 정보를 가져오는 코틀린 함수를 구현하는 예시를 들면서 코틀린의 여러 기능을 소개해봤다.
자바에서 불필요하게 작성하는 부분을 줄일 수 있었다.
이미 자바로 구현된 프로젝트에 코틀린을 도입할 때 테스트 코드와 유틸 클래스 부터 코틀린으로 대체하는 것을 추천하던데.
이 글이 그 예시로써 읽는 사람에게 도움이 되었으면 좋겠다.

1
  • 댓글 0

  • 로그인을 하시면 댓글을 등록할 수 있습니다.