Kotlin Spring + Mybatis + Mysql 샘플 데이터 토이프로젝트 -6 Jwt를 이용한 사용자 인증

선종우·2024년 8월 21일
0

Spring 노트

목록 보기
6/10
  • 프로젝트에서 Spring Security와 같은 프레임워크를 사용하지 않고 인증 프로세스를 구현하였다.

  • 회원가입은 회원정보를 이용해 회원을 가입시키고 이에 대한 인증토큰을 발급하는 프로세스로 구성하였다.


회원가입

@Configuration
class AuthConfig {
    @Bean
    fun passwordEncoder(): PasswordEncoder{
        return BCryptPasswordEncoder()
    }
}

@Service
@Transactional
class UserService(
    private val userRepository: UserRepository,
    private val passwordEncoder: PasswordEncoder,
    private val authenticationService: AuthenticationService,
) {

    fun signupUser(request: UserSignupRequest): UserSignUpResponse{
        val encodedPassword = passwordEncoder.encode(request.password)
        val newUser = User.of(request.email, encodedPassword, request.userName)
        userRepository.saveUser(newUser)
        requireNotNull(newUser.id)
        val token = authenticationService.generateToken(newUser)

        return UserSignUpResponse(newUser.id, request.email, request.userName,
            loginInfo = Login(token))
    }
}
  • 회원가입 시 비밀번호는 BcryptPasswordEncoder를 사용해 해싱한다. 사용자가 입력한 문자열은 다음과 같은 형태로 해싱된다.$2a$10$VvVu/IuIlFCzdZv0o3WY5esiM5P9CQF25qzzRCG1eo.OhnnONDc9W
    • $2a의 경우 bcryt 해시 알고리즘 식별자이다. 다른 알고리즘을 썼다면 다른 식별자를 사용하게 된다.
      Bcrypt - 위키피디아
  • 해시는 일방향 함수이기 때문에 해당 문자열을 복화하는 것은 불가능하다.

로그인

@Service
@Transactional
class UserService(
    private val userRepository: UserRepository,
    private val passwordEncoder: PasswordEncoder,
    private val authenticationService: AuthenticationService,
) {

	fun loginUser(request: UserLoginRequest): UserLoginResponse{
        val user: User = findValidUserByEmail(request.email)

        if(!passwordEncoder.matches(request.password, user.password))
            throw SecurityException(ErrorCode.BAD_CREDENTIALS_ERROR)

        user.updateLastLoginTime()
        requireNotNull(user.id)
        check(userRepository.updateUser(userId = user.id, lastLoginAt = user.lastLoginAt)){"update last login time fail"}

        val token = authenticationService.generateToken(user)
        return UserLoginResponse(loginInfo = Login(token))
    }

    @Transactional(readOnly = true)
    fun findValidUserByEmail(email: String): User {
        val user: User = userRepository.findByUserEmail(email) ?: throw SecurityException(ErrorCode.USER_NOT_FOUND)
        check(!user.isWithdrawal()) { "The user(${user.email}) is withdraw" }
        return user
    }
}
  • accessToken을 신규로 발급받기 위해서는 login 로직을 통해 accessToken을 발급받아야 한다.
  • 회원가입 시 비밀번호는 hashing하여 저장하였다. db에는 원본 비밀번호가 저장되지 않으므로 로그인 시 전달된 암호문자열을 hashing하여 db에 저장된 문자열과 비교한다.
    • hash 함수 특성 상 입력값이 같으면 출력값이 같다.

토큰 발급

  • 회원가입 또는 로그인에 성공한 경우 토큰을 발급해준다.
@Transactional
@Service
class AuthenticationService(
    private val refreshTokenRepository: RefreshTokenRepository,
    private val userRepository: UserRepository,
    private val passwordEncoder: PasswordEncoder,
    private val tokenUtil : TokenUtil
) : Log {

    fun generateToken(user: User): Token {
        requireNotNull(user.id)
        val token = tokenUtil.generateToken("test", user.id, user.email)
        val refreshToken = RefreshToken(user.email, passwordEncoder.encode(token.refreshToken))
        refreshTokenRepository.save(refreshToken)
        return token
    }
}
  • 서버에서는 만료기한이 짧은 accessToken과 만료기한이 긴 refreshToken을 생성한다. refreshToken은 db에 저장해 두고 이후 사용자 accessToken이 만료됐을 경우 accessToken을 재발급할 수 있도록 한다.

토큰을 이용한 인증

  • 토큰을 발급받은 클라이언트는 이후 서버 접속 시 헤더에 accessToken을 담아 서버에 전송한다. 이때 jwt 표준에 따라 헤더의 key는 Authorization으로 정하고 value는 Bearer접두사를 붙인다.
  • 서버로 전송된 토큰은 Interceptor를 이용해 파싱한다.
@Component
class TokenVerifyInterceptor(
    private val tokenUtil: TokenUtil,
    private val userService: UserService
) : HandlerInterceptor, Log {
    companion object {
        const val USER_KEY = "userProfile"
    }

    override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
        val method = request.method
        if(method == HttpMethod.OPTIONS.name())
            return true

        try {
            val token: String = tokenUtil.getToken(request)
            tokenUtil.verifyToken(token)
            val userEmail = tokenUtil.getEmail(token)
            val user = userService.findValidUserByEmail(userEmail)
            request.setAttribute(USER_KEY, user)
        } catch (e: Exception) {
            when (e) {
                is java.lang.SecurityException,
                is MalformedJwtException,
                is UnsupportedJwtException,
                is IllegalArgumentException -> {
                    log.debug("token is expired")
                    throw SecurityException(ErrorCode.BAD_CREDENTIALS_ERROR)
                }
                is ExpiredJwtException -> {
                    log.debug("token is expired")
                    throw SecurityException(ErrorCode.TOKEN_EXPIRED)
                }
                is SecurityException -> {
                    log.warn("user cannot access cause : {}", e.errorCode)
                    throw e
                }
                else -> {
                    log.error("unexpected error occurs on verifying token. request uri : {}, token - {}, msg - {}",
                    request.requestURI,
                    request.getHeader("Authorization"),
                    e.message, e)
                    throw SecurityException(ErrorCode.BAD_CREDENTIALS_ERROR)
                }

            }
        }
        return true
    }
}
  • 만약 interceptor의 반환값이 false이거나 예외가 발생한다면 client요청은 거절된다. 사용자의 정보는 requet 저장소에 저장해둔다. 사용자 정보가 request 저장소에 저장된 시점에 로그인은 성공한다.
  • 생성한 interceptor는 WebConfig에 등록해준다. interceptor가 적용되거나 적용되지 않을 url은 이때 설정한다.
@Configuration
class WebConfig(
    private val tokenVerifyInterceptor: TokenVerifyInterceptor,
    private val loginArgumentResolver: UserArgumentResolver,
    private val authorizationInterceptor: AuthorizationInterceptor
) : WebMvcConfigurer {

    override fun addInterceptors(registry: InterceptorRegistry) {
        registry.addInterceptor(tokenVerifyInterceptor)
            .addPathPatterns("/**")
            .excludePathPatterns("/employee/name/**")
            .excludePathPatterns("/user/*")
            .excludePathPatterns("/auth/*")
            .excludePathPatterns("/h2-console/*")
            .excludePathPatterns("/favicon.ico")
            .excludePathPatterns("/test/**")
    }

}

로그인 정보 활용

  • 이후 비즈니스 로직에서 로그인 성공 정보를 활용한다.
@RestController
@RequestMapping("/employee")
class EmployeeController(
    private val employeeService: EmployeeService,
    private val updateValidator: EmployeeUpdateValidator
) {
    @GetMapping("/{empNo}")
    fun getEmployee(
        @LoginUser user: User,
        @PathVariable empNo: Int): CommonApiResponse<Employee> {
        val employee = employeeService.findEmployeeByEmpNo(empNo)
        return CommonApiResponse(
            success = true,
            data = employee
        )
    }
  • 직원정보는 로그인한 사용자만 볼 수 있다고 가정하였다.
  • LoginUser애노테이션이 붙은 경우 ArgumentResolver를 이용해 인증이 완료된 사용자 정보를 가져온다.
@Component
class UserArgumentResolver : HandlerMethodArgumentResolver {
    override fun supportsParameter(parameter: MethodParameter): Boolean {
        return parameter.hasParameterAnnotation(LoginUser::class.java)
    }

    override fun resolveArgument(
        parameter: MethodParameter,
        mavContainer: ModelAndViewContainer?,
        webRequest: NativeWebRequest,
        binderFactory: WebDataBinderFactory?
    ): User {
        return webRequest.getNativeRequest(HttpServletRequest::class.java)
            ?.getAttribute(TokenVerifyInterceptor.USER_KEY) as User?
            ?: throw SecurityException(ErrorCode.USER_NOT_FOUND)
    }
}
  • 이전에 interceptor에서 request저장소에 USER_KEY를 key값으로 user정보를 담아뒀다. 같은 key값을 이용해 저장소에서 user 정보를 꺼내온다.
@Configuration
class WebConfig(
    private val tokenVerifyInterceptor: TokenVerifyInterceptor,
    private val loginArgumentResolver: UserArgumentResolver,
    private val authorizationInterceptor: AuthorizationInterceptor
) : WebMvcConfigurer {

    override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) {
        resolvers.add(loginArgumentResolver)
    }
}
  • 작성한 ArgumentResolver는 WebConfig에 등록해준다.
  • 이후 EmployeeController#getEmployee함수의 첫번째 인자에는 정상적으로 사용자의 정보가 담긴 User객체가 인자로 전달된다.

0개의 댓글