들어가기 전

이번 포스팅에서는 스프링 시큐리티 + JWT + 코틀린을 사용하여 일반 로그인 구현하는 방법에 대해서 알아보겠습니다.

만약 스프링 시큐리티 + Oauth2.0 + JWT + 자바를 이용한 소셜 로그인 구현하는 방법에 대해서 궁금하신 분은 아래 포스팅을 참고하시는 것을 추천드립니다.

 

 

https://hoestory.tistory.com/32

 

[OAuth] Spring Boot + React + OAuth2.0 이용한 네이버, 카카오 로그인

들어가기 전 토이 프로젝트를 진행하면서 OAuth를 이용한 소셜 로그인을 구현해보았습니다. 프론트는 React를 이용하였고 백엔드는 Spring Boot를 이용하였습니다. 네이버, 카카오 로그인에 대한 코

hoestory.tistory.com

 

 

설정

 

build.gradle.kts

 

//security
implementation("org.springframework.boot:spring-boot-starter-security")
 
//jwt
implementation("io.jsonwebtoken:jjwt-api:0.11.2")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.2")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.2")

 

 

application.yml

 

jwt:
  secret-key : ${SECRET_KEY}
  access-token-expire-time : 300
  refresh-token-expire-time : 7200

 

secret-key 같은 경우는 base64로 encode 한 값입니다. base64 Encode 하실 분은 아래 사이트에서 진행하시면 됩니다.

access-token의 만료시간은 5분으로 설정하였습니다.

 

https://www.base64encode.org/ko/

 

Base64 인코딩 및 디코딩 - 온라인

Base64 형식으로 인코딩해보세요. 아니면 다양한 고급 옵션으로 디코딩해보세요. 저희 사이트에는 데이터 변환하기에 사용하기 쉬운 온라인 도구가 있습니다.

www.base64encode.org

 

지금까지 일반 로그인을 구현하기 위한 설정에 대해서 알아보았습니다. 이제 로그인을 구현하는 코드에 대해서 알아보겠습니다.

 

 

 

RefreshToken, RefreshTokenRepository

@Entity
class RefreshToken(
    @Enumerated(EnumType.STRING)
    val role: Role,
    @Column(nullable = false)
    val token: String,
    @Column(nullable = false)
    val expireAt: LocalDateTime,
    @OneToOne(fetch = FetchType.LAZY)
    val account: Account,
    @Column(nullable = false)
    val accessToken: String,
    @Column(nullable = false)
    val accessTokenExpireAt: LocalDateTime
) : BaseEntity() {
}

// repository
interface RefreshTokenRepository : JpaRepository<RefreshToken, Long> {

    fun existsByAccountId(accountId: Long): Boolean

    fun findByAccountId(accountId: Long): RefreshToken?
    fun deleteByAccountId(accountId: Long)

    fun findByAccessToken(accessToken: String): RefreshToken?
}

 

SecurityConfig

 

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
class SecurityConfig(
    private val tokenProvider: TokenProvider,
    private val jwtAccessDeniedHandler: JwtAccessDeniedHandler,
    private val refreshTokenRepository: RefreshTokenRepository,
) : WebSecurityConfigurerAdapter() {

 	override fun configure(http: HttpSecurity) {
            http.httpBasic().disable()
            .cors().configurationSource(corsConfigurationSource())
            .and()
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .addFilterBefore(
                LoginFilter(tokenProvider, refreshTokenRepository),
                UsernamePasswordAuthenticationFilter::class.java
            )
            .exceptionHandling()
            .accessDeniedHandler(jwtAccessDeniedHandler)
            .and()
            .authorizeRequests()
            .antMatchers("/**")
            .permitAll()
            .anyRequest().authenticated()

    }
 }
  • cors().configurationSource(corsConfigurationSource()) : Cors에 대한 설정
  • . addFilterBefore(LoginFilter(tokenProvider, refreshTokenRepository),         UsernamePasswordAuthenticationFilter::class.java) : 로그인을 할 때 UsernamePasswordAuthenticationFilter 전에 실행되어야 하는 필터를 등록하는 설정
  • accessDeniedHandler(jwtAccessDeniedHandler) : 인가 안된 사용자가 접근했을 때에 대한 처리 설정
  •  .authorizeRequests().antMatchers("/**") : 접근 경로 설정
  • @EnableGlobalMethodSecurity(securedEnabled = true) : @Secured를 사용하여 인가 절차를 수행할 수 있게 해주는 설정

 

LoginFilter

  • 디스패처 서블릿에 도달하기 전에 토큰의 유효성 검증 역할을 하는 필터
class LoginFilter(
    private val tokenProvider: TokenProvider,
    private val refreshTokenRepository: RefreshTokenRepository,
) : OncePerRequestFilter() {
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        tokenProvider.resolveToken(request = request as? HttpServletRequest?)?.also {
            val loginToken = it.split(" ")[1]
            val findToken = refreshTokenRepository.findByAccessToken(loginToken)

            if (findToken == null || LocalDateTime.now()
                    .isAfter(findToken.accessTokenExpireAt) || !it.toLowerCase()
                    .startsWith("bearer ") || !tokenProvider.validateToken(loginToken)
            ) {
                response.sendError(401)
                return
            }
            val authentication = tokenProvider.getAuthentication(loginToken)
            SecurityContextHolder.getContext().authentication = authentication
        }
        filterChain.doFilter(request, response)
    }
}

 

JwtAccessDeniedHandler

  • 인가 과정에서 권한이 없는 사용자가 접근했을때에 대한 핸들러
@Component
class JwtAccessDeniedHandler : AccessDeniedHandler{
    override fun handle(request: HttpServletRequest?, response: HttpServletResponse, accessDeniedException: AccessDeniedException?) {
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}

 

LoginToken

  • 토큰, 토큰 만료시간을 담고 있는 객체
data class LoginToken(
    val id: Long,
    val grantType: String = "Bearer",
    val accessToken: String,
    val refreshToken: String,
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    val accessTokenExpiredAt : LocalDateTime,
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    val refreshTokenExpiredAt : LocalDateTime,
)

 

TokenProvider

  • 토큰(access, refresh) 생성 및 만료시간 체크
  • 토큰에 담겨져 있는 사용자 정보 추출
  • 토큰 유효성 검증
@Component
class TokenProvider(
    private val accountRepository: AccountRepository,
    @Value("\${jwt.secret-key}")
    private val secretKey: String,
    @Value("\${jwt.access-token-expire-time}")
    private val ACCESS_TOKEN_EXPIRE_TIME: String,
    @Value("\${jwt.refresh-token-expire-time}")
    private val REFRESH_TOKEN_EXPIRE_TIME: String,
    private val refreshTokenRepository: RefreshTokenRepository,
) {

    val key: Key by lazy {
        Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey))
    }

    /**
     * accessToken, refreshToken 생성
     */
    fun getToken(id: Long, role: String, now: Date): LoginToken {
        val now: LocalDateTime = LocalDateTime.now()

        val accessTokenTime: LocalDateTime =
            now.plus(ACCESS_TOKEN_EXPIRE_TIME.toLong(), ChronoUnit.SECONDS)
        val refreshTokenTime: LocalDateTime =
            now.plus(REFRESH_TOKEN_EXPIRE_TIME.toLong(), ChronoUnit.SECONDS)
        val accessToken = createToken(
            id = id,
            tokenExpireTime = accessTokenTime.toInstant(ZoneOffset.UTC).toEpochMilli(),
            role = role
        )
        val loginUserRole = Role.valueOf(role)
        var loginToken: LoginToken? = refreshTokenRepository.findByAccountId(id)?.let {
            if (now.isAfter(it.expireAt)) {
                throw CustomException(ExceptionCode.REFRESH_TOKEN_EXPIRE)
            }
            val account = accountRepository.findByIdOrNull(it.id)
                ?: throw CustomException(ExceptionCode.NOT_EXSISTS_INFO)
            LoginToken(
                accessToken = accessToken,
                refreshToken = it.token,
                accessTokenExpiredAt = accessTokenTime,
                refreshTokenExpiredAt = it.expireAt,
                id = account.id
            )
        }
        if (loginToken == null) {
            val refreshToken = createToken(
                id = id,
                tokenExpireTime = refreshTokenTime.toInstant(ZoneOffset.UTC).toEpochMilli(),
                role = role
            )
            val account = accountRepository.findByIdOrNull(id)
                ?: throw CustomException(ExceptionCode.NOT_EXSISTS_INFO)
            val savedRefreshToken = refreshTokenRepository.save(
                RefreshToken(
                    account = account,
                    token = refreshToken,
                    expireAt = refreshTokenTime,
                    role = loginUserRole,
                    accessToken = accessToken,
                    accessTokenExpireAt = accessTokenTime
                )
            )
            loginToken = LoginToken(
                accessToken = accessToken,
                refreshToken = refreshToken,
                accessTokenExpiredAt = accessTokenTime,
                refreshTokenExpiredAt = refreshTokenTime,
                id = savedRefreshToken.id
            )
        }
        return loginToken
    }

    fun getAuthentication(token: String): Authentication {
        val account = accountRepository.findByIdOrNull(getAccount(token)) ?: throw CustomException(
            ExceptionCode.NOT_EXSISTS_INFO
        )
        val loginUserDetail = LoginUserDetail(account.email, account.role.name);
        return UsernamePasswordAuthenticationToken(loginUserDetail, "", loginUserDetail.authorities)
    }

    fun getAccount(token: String): Long {
        return Jwts.parserBuilder().setSigningKey(secretKey).build()
            .parseClaimsJws(token).body.subject.toLong()
    }


    fun validateToken(token: String): Boolean {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).body
            return true;
        } catch (e: SecurityException) {
            e.printStackTrace()
        } catch (e: ExpiredJwtException) {
            return false
        } catch (e: UnsupportedJwtException) {
            e.printStackTrace()
        } catch (e: IllegalArgumentException) {
            e.printStackTrace()
        }
        return false
    }

    fun resolveToken(request: HttpServletRequest?): String? {
        return request?.getHeader("Authorization")
    }

    //토큰 생성
    private fun createToken(id: Long, tokenExpireTime: Long, role: String): String {
        return Jwts.builder()
            .setSubject(id.toString())
            .claim("auth", role)
            .setExpiration(Date(tokenExpireTime))
            .signWith(key, SignatureAlgorithm.HS512)
            .compact()
    }
}

 

 

토큰 생성

 private fun createToken(id: Long, tokenExpireTime: Long, role: String): String {
        return Jwts.builder()
            .setSubject(id.toString())
            .claim("auth", role)
            .setExpiration(Date(tokenExpireTime))
            .signWith(key, SignatureAlgorithm.HS512)
            .compact()
}

 

 

토큰 유효성 검증

fun validateToken(token: String): Boolean {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).body
            return true;
        } catch (e: SecurityException) {
            e.printStackTrace()
        } catch (e: ExpiredJwtException) {
            return false
        } catch (e: UnsupportedJwtException) {
            e.printStackTrace()
        } catch (e: IllegalArgumentException) {
            e.printStackTrace()
        }
        return false
}

 

 

헤더에 담긴 토큰 추출

fun resolveToken(request: HttpServletRequest?): String? {
    return request?.getHeader("Authorization")
}

 

 

토큰에 담긴 사용자 정보 추출

fun getAccount(token: String): Long {
    return Jwts.parserBuilder().setSigningKey(secretKey).build()
        .parseClaimsJws(token).body.subject.toLong()
}

 

Access, Refresh 토큰 저장 및 응답

fun getToken(id: Long, role: String, now: Date): LoginToken {
        val now: LocalDateTime = LocalDateTime.now()

        val accessTokenTime: LocalDateTime =
            now.plus(ACCESS_TOKEN_EXPIRE_TIME.toLong(), ChronoUnit.SECONDS)
        val refreshTokenTime: LocalDateTime =
            now.plus(REFRESH_TOKEN_EXPIRE_TIME.toLong(), ChronoUnit.SECONDS)
        val accessToken = createToken(
            id = id,
            tokenExpireTime = accessTokenTime.toInstant(ZoneOffset.UTC).toEpochMilli(),
            role = role
        )
        val loginUserRole = Role.valueOf(role)
        var loginToken: LoginToken? = refreshTokenRepository.findByAccountId(id)?.let {
            if (now.isAfter(it.expireAt)) {
                throw CustomException(ExceptionCode.REFRESH_TOKEN_EXPIRE)
            }
            val account = accountRepository.findByIdOrNull(it.id)
                ?: throw CustomException(ExceptionCode.NOT_EXSISTS_INFO)
            LoginToken(
                accessToken = accessToken,
                refreshToken = it.token,
                accessTokenExpiredAt = accessTokenTime,
                refreshTokenExpiredAt = it.expireAt,
                id = account.id
            )
        }
        if (loginToken == null) {
            val refreshToken = createToken(
                id = id,
                tokenExpireTime = refreshTokenTime.toInstant(ZoneOffset.UTC).toEpochMilli(),
                role = role
            )
            val account = accountRepository.findByIdOrNull(id)
                ?: throw CustomException(ExceptionCode.NOT_EXSISTS_INFO)
            val savedRefreshToken = refreshTokenRepository.save(
                RefreshToken(
                    account = account,
                    token = refreshToken,
                    expireAt = refreshTokenTime,
                    role = loginUserRole,
                    accessToken = accessToken,
                    accessTokenExpireAt = accessTokenTime
                )
            )
            loginToken = LoginToken(
                accessToken = accessToken,
                refreshToken = refreshToken,
                accessTokenExpiredAt = accessTokenTime,
                refreshTokenExpiredAt = refreshTokenTime,
                id = savedRefreshToken.id
            )
        }
        return loginToken
    }

 

 

@LoginUser

  • 로그인한 사용자 정보를 가지고 있는 커스텀 어노테이션
@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
annotation class LoginUser

 

 

LoginInterception

  • @LoginUser 어노테이션에 사용자 정보를 담아주는 인터셉터
@Component
class LoginInterception(
    private val accountRepository: AccountRepository,
) : HandlerMethodArgumentResolver {
    override fun supportsParameter(parameter: MethodParameter): Boolean {
        return parameter.hasParameterAnnotation(LoginUser::class.java)
    }

    override fun resolveArgument(
        parameter: MethodParameter,
        mavContainer: ModelAndViewContainer?,
        webRequest: NativeWebRequest,
        binderFactory: WebDataBinderFactory?
    ): Any? {
        val authentication = SecurityContextHolder.getContext().authentication
        val role = authentication.authorities.first().toString().split("_")
        var authenRole =
            if (role.size == 2) {
                role[1]
            } else {
                "${role[1]}_${role[2]}"
            }
        val account = accountRepository.findByEmailAndRole(
            authentication.name, Role.valueOf(authenRole)
        )
            ?: throw CustomException(ExceptionCode.TOKEN_EXPIRE)
        return LoginUserInfo(account.id, account.role)
    }
}

 

 

LoginUserDetail

  • 사용자에 대한 권한, 정보를 담는 객체
class LoginUserDetail(
    val email: String,
    val role: String?,
) : UserDetails{

    override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
        if(role == null) {
            return mutableListOf()
        }
        return mutableListOf(SimpleGrantedAuthority("ROLE_${role}"))
    }

    override fun getPassword(): String = ""

    override fun getUsername(): String = email

    override fun isAccountNonExpired(): Boolean = true

    override fun isAccountNonLocked(): Boolean = true

    override fun isCredentialsNonExpired(): Boolean = true

    override fun isEnabled(): Boolean = true
}

 

 

이제 위 코드 기반으로 로그인 및 사용자 정보 조회 API를 만들어서 결과를 확인해 보겠습니다.

 

※참고 : 간단하게 보여주기 위해 Service 계층 없이 예제를 진행하겠습니다.

 

data class LoginToken(
    val id: Long,
    val grantType: String = "Bearer",
    val accessToken: String,
    val refreshToken: String,
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    val accessTokenExpiredAt : LocalDateTime,
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    val refreshTokenExpiredAt : LocalDateTime,
)


data class AccountInfo(
    val id: Long,
    val name: String,
    @JsonFormat(pattern = "yyyy-MM-dd")
    val birth: LocalDate
)

@ApiModel(value = "로그인 요청")
data class LoginInfo(
    @ApiModelProperty(value = "이메일")
    val email: String,
    @ApiModelProperty(value = "비밀번호")
    val password: String,
    @ApiModelProperty(value = "로그인 타입")
    val loginType: LoginType,
)

data class LoginUserInfo(val id: Long, val role: Role)
@RestController
@RequestMapping("/api")
class AccountController(
    private val accountRepository: AccountRepository,
    private val bCryptPasswordEncoder: BCryptPasswordEncoder,
    private val refreshTokenRepository: RefreshTokenRepository,
    private val tokenProvider: TokenProvider,
) {

    @ApiOperation(value = "로그인")
    @PostMapping("/login")
    fun login(@RequestBody loginInfo: LoginInfo): LoginToken  {
        val account = accountRepository.findByEmail(loginInfo.email)
            ?: throw CustomException(ExceptionCode.NOT_MATCH_ID_OR_PASSWORD)
        require(bCryptPasswordEncoder.matches(loginInfo.password, account.password)) {
            throw CustomException(ExceptionCode.NOT_MATCH_ID_OR_PASSWORD)
        }

        if (refreshTokenRepository.existsByAccountId(account.id)) {
            refreshTokenRepository.deleteByAccountId(account.id)
        }
        return tokenProvider.getToken(account.id, account.role.name, Date())
    }
    
    @ApiOperation(value = "사용자 정보")
    @GetMapping
    @Secured("ROLE_USER")
    fun getInfo(@LoginUser loginUserInfo: LoginUserInfo) : AccountInfo {
        val account = accountRepository.findByIdOrNull(loginUserInfo.id) ?: throw CustomException(
            ExceptionCode.NOT_EXSISTS_INFO
        )
        return AccountInfo(id = account.id, name =  account.name, birth = account.birth)
    }
}
  •     @Secured : 해당 어노테이션을 통해 API에 접근할 수 있는 권한을 설정할 수 있습니다.

 

사용자 권한이 ADMIN일 경우

 

 

 

사용자 권한이 USER일 경우