본문 바로가기

[Spring] Spring Security login (jwt)

JWT token이란?

간단히 말해서 암호화된 token이다. 좋은 점은 token에 issue time, expire time등을 설정 할 수 있다는 것이다.

https://jwt.io/

 

JWT.IO

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

jwt.io

Spring Security

spring Security의 인증 방식은 다음과 같다.

Dependency 추가

build.gradle에 다음과 같이 추가한다.

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
	val kotlinVersion = "1.6.21"
	id("org.springframework.boot") version "2.6.7"
	id("io.spring.dependency-management") version "1.0.11.RELEASE"
	kotlin("jvm") version kotlinVersion
	kotlin("plugin.spring") version kotlinVersion
	kotlin("plugin.jpa") version kotlinVersion
	kotlin("kapt") version kotlinVersion
}

group = "com.gogifarm"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_11

configurations {
	compileOnly {
		extendsFrom(configurations.annotationProcessor.get())
	}
}

repositories {
	mavenCentral()
}

val snippetsDir by extra { file("build/generated-snippets") }

dependencies {
	implementation("org.springframework.boot:spring-boot-starter-data-jpa")
	implementation("org.springframework.boot:spring-boot-starter-web")
	implementation("org.springframework.boot:spring-boot-starter-mustache")
	implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")

	/// 추가
	implementation("org.springframework.boot:spring-boot-starter-security")
	implementation("org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE")
	implementation("org.springdoc:springdoc-openapi-ui:1.6.4")
	implementation("commons-fileupload:commons-fileupload:1.4")
	implementation("com.querydsl:querydsl-jpa")
	
  /// 추가
	implementation("io.jsonwebtoken:jjwt:0.9.1")
	kapt(group = "com.querydsl", name = "querydsl-apt", classifier = "jpa")
	annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
	annotationProcessor(group = "com.querydsl", name = "querydsl-apt", classifier = "jpa")
	runtimeOnly("com.h2database:h2")
	runtimeOnly("org.springframework.boot:spring-boot-devtools")
	testImplementation("org.springframework.boot:spring-boot-starter-test")
}
allOpen {
	annotation("javax.persistence.Entity")
	annotation("javax.persistence.MappedSuperclass")
	annotation("javax.persistence.Embeddable")
}
tasks.withType<KotlinCompile> {
	kotlinOptions {
		freeCompilerArgs = listOf("-Xjsr305=strict")
		jvmTarget = "11"
	}
}

tasks.withType<Test> {
	useJUnitPlatform()
}

tasks.test {
	outputs.dir(snippetsDir)
}

sourceSets["main"].withConvention(org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet::class) {
	kotlin.srcDir("$buildDir/generated/source/kapt/main")
}

Security Config 추가

Security config를 하기 위해 다음과 같이 코드를 작성한다.

package com.gogifarm.config

import com.gogifarm.auth.JwtAuthenticationFilter
import com.gogifarm.auth.JwtTokenProvider
import com.gogifarm.user.UserRole
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.autoconfigure.security.servlet.PathRequest
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.builders.WebSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.crypto.factory.PasswordEncoderFactories
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter

@Configuration
@EnableWebSecurity
class SecurityConfig(
    private val jwtTokenProvider: JwtTokenProvider
): WebSecurityConfigurerAdapter() {

    override fun configure(web: WebSecurity) {
        web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations())
    }

    override fun configure(http: HttpSecurity) {
        http.csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatchers("/v1/admins/**").hasRole(UserRole.ADMIN.toString())
            .antMatchers("/v1/users/**").hasRole(UserRole.ADMIN.toString())
            .anyRequest().permitAll().and()
            .addFilterBefore(JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter::class.java)
            .formLogin().disable()
    }
}

@Configuration
class PassWordConfig() {
    @Bean
    fun passwordEncoder() : PasswordEncoder{
        return PasswordEncoderFactories.createDelegatingPasswordEncoder()
    }
}
  • sessionMangement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    • jwt token은 token이 request body 또는 cookie에 들어오기 때문에 서버가 session에 저장하고 있을 필요가 없다.
  • antMatchers()
    • 특정 ant-pattern으로 들어오는 요청에 대해 해당 권한을 가지고 있는지 확인한다.
  • addFilterBefore
    • 요청이 들어오기 전에 filter를 한번 거치게 한다.
  • formLogin().disable()
    • api server로 역할을 만들 거여서 없앤다.

JwtTokenProvider 및 JwtAuthenticationFilter 작성

package com.gogifarm.auth

import com.gogifarm.user.UserRole
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Component
import org.springframework.web.filter.GenericFilterBean
import java.util.Date
import javax.servlet.FilterChain
import javax.servlet.ServletRequest
import javax.servlet.ServletResponse
import javax.servlet.http.Cookie
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
import org.springframework.security.core.userdetails.User as UserDetailBuilder

@Component
class JwtTokenProvider(
    @Value("\\${jwt.secretKey}")
    private val secretKey: String,

    @Value("\\${jwt.accessValidTime}")
    private val accessValidTime: Int,

    @Value("\\${jwt.refreshValidTime}")
    private val refreshValidTime: Int
) {

    private val cookiePath: String = "/"

    private val accessTokenHeader: String = "X-AUTH-TOKEN-ACCESS"

    private val refreshTokenHeader: String = "X-AUTH-TOKEN-REFRESH"

    fun createAuthenticationToken(httpServletResponse: HttpServletResponse, userPk: String, userRole: UserRole) {
        val accessToken = createAccessToken(httpServletResponse, userPk, userRole)
        val refreshToken = createRefreshToken(httpServletResponse)
    }

    fun createAccessToken(httpServletResponse: HttpServletResponse, userPk: String, userRole: UserRole): String {
        val claims = Jwts.claims().setSubject(userPk)
        claims["role"] = userRole
        val now = Date()
        val token = Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)
            .setExpiration(Date(now.time + accessValidTime))
            .signWith(SignatureAlgorithm.HS512, secretKey)
            .compact()
        val accessCookie = Cookie(accessTokenHeader, token)
        accessCookie.path = cookiePath
        accessCookie.maxAge = accessValidTime
        httpServletResponse.addCookie(accessCookie)
        return token
    }

    fun createRefreshToken(httpServletResponse: HttpServletResponse): String {
        val claims = Jwts.claims().setSubject("refresh")
        val now = Date()
        val token = Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)
            .setExpiration(Date(Date().time + refreshValidTime))
            .signWith(SignatureAlgorithm.HS512, secretKey)
            .compact()
        val refreshCookie = Cookie(refreshTokenHeader, token)
        refreshCookie.path = cookiePath
        refreshCookie.maxAge = refreshValidTime
        httpServletResponse.addCookie(refreshCookie)
        return token
    }

    fun updateAccessToken(httpServletRequest: HttpServletRequest, httpServletResponse: HttpServletResponse): String {
        val token = getRefreshToken(httpServletRequest)
            ?: throw IllegalStateException("refresh token required")
        if (!validateToken(token)) {
            throw IllegalStateException("refresh token invalid!!")
        }
        return createAccessToken(httpServletResponse, getUserPk(token!!), getUserRole(token!!))
    }

    fun getAuthentication(token: String): Authentication {
        val userDetails = UserDetailBuilder
            .builder()
            .username(getUserPk(token))
            .password("")
            .roles(getUserRole(token).toString())
            .build()
        return UsernamePasswordAuthenticationToken(userDetails, "", userDetails.authorities)
    }

    fun getUserPk(token: String): String {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).body.subject
    }

    fun getUserRole(token: String): UserRole{
        return UserRole.valueOf(Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).body["role"].toString())
    }

    fun getAccessToken(request: HttpServletRequest): String? {
        return request.getHeader(accessTokenHeader)
            ?: request.cookies?.find { cookie -> cookie.name == accessTokenHeader }?.value
    }

    fun getRefreshToken(request: HttpServletRequest): String? {
        return request.getHeader(refreshTokenHeader)
            ?: request.cookies?.find { cookie -> cookie.name == refreshTokenHeader }?.value
    }

    fun validateToken(token: String): Boolean {
        val claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token)
            ?: return false
        return claims.body.expiration.after(Date())
    }
}

class JwtAuthenticationFilter(
    private val jwtTokenProvider: JwtTokenProvider
) : GenericFilterBean() {
    override fun doFilter(request: ServletRequest?, response: ServletResponse?, chain: FilterChain?) {
        val req = request as HttpServletRequest
        if (!checkAccessToken(req)) {
            checkRefreshToken(req)
        }
        chain?.doFilter(request, response)
    }

    private fun checkAccessToken(httpServletRequest: HttpServletRequest): Boolean {
        val token = jwtTokenProvider.getAccessToken(httpServletRequest)
        if (token != null) {
            try {
                if (jwtTokenProvider.validateToken(token!!)) {
                    val authentication = jwtTokenProvider.getAuthentication(token!!)
                    SecurityContextHolder.getContext().authentication = authentication
                    return true
                }
            } catch (exception: Exception) {
                return false
            }
        }
        return false
    }

    private fun checkRefreshToken(httpServletRequest: HttpServletRequest) {
        val token = jwtTokenProvider.getRefreshToken(httpServletRequest)
        if (token != null) {
        }
    }
}

AuthController 작성

package com.gogifarm.auth

import com.gogifarm.user.UserService
import org.springframework.http.MediaType
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

@RestController
@RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE])
class AuthController(
    private val userService: UserService,
    private val jwtTokenProvider: JwtTokenProvider
) {

    @GetMapping("/v1/login")
    fun login(
        httpServletResponse: HttpServletResponse,
        @RequestParam(name = "id") id: String,
        @RequestParam(name = "password") password: String,
    ) {
        val user = userService.getUserByEmailWithPasswordCheck(id, password)
        jwtTokenProvider.createAuthenticationToken(httpServletResponse, user.email!!, user.role)
    }

    @GetMapping("/v1/login/access")
    fun updateAccessToken(
        httpServletRequest: HttpServletRequest,
        httpServletResponse: HttpServletResponse
    ): String {
        return jwtTokenProvider.updateAccessToken(httpServletRequest, httpServletResponse)
    }

}

추가적인 설명은 나중에….ㅎㅎ