Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: RefreshToken 추가 #51

Merged
merged 2 commits into from
Dec 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.wap.wabi.auth.admin.entity;

import jakarta.persistence.*;
import org.springframework.beans.factory.annotation.Value;

@Entity
public class AdminRefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String adminName;
private String refreshToken;
private int reissueCount = 0;

public AdminRefreshToken() {
}

public AdminRefreshToken(builder builder) {
this.adminName = builder.adminName;
this.refreshToken = builder.refreshToken;
}

public void updateRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}

public boolean validateRefreshToken(String refreshToken) {
return this.refreshToken.equals(refreshToken);
}

public void increaseReissueCount() {
reissueCount++;
}

public static class builder {
private String adminName;
private String refreshToken;

public builder adminName(String adminName) {
this.adminName = adminName;
return this;
}

public builder refreshToken(String refreshToken) {
this.refreshToken = refreshToken;
return this;
}

public AdminRefreshToken build() {
return new AdminRefreshToken(this);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.wap.wabi.auth.admin.payload.response

data class AdminLoginResponse(
val name: String,
val token: String,
val accessToken: String,
val refreshToken : String,
val role: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.wap.wabi.auth.admin.repository;

import com.wap.wabi.auth.admin.entity.AdminRefreshToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface AdminRefreshTokenRepository extends JpaRepository<AdminRefreshToken, Long> {
Optional<AdminRefreshToken> findAdminRefreshTokenByAdminNameAndReissueCountLessThan(String name, long count);

Optional<AdminRefreshToken> findAdminRefreshTokenByAdminName(String name);
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package com.wap.wabi.auth.admin.service

import com.wap.wabi.auth.admin.entity.AdminRefreshToken
import com.wap.wabi.auth.admin.payload.request.AdminLoginRequest
import com.wap.wabi.auth.admin.payload.request.AdminRegisterRequest
import com.wap.wabi.auth.admin.payload.response.AdminLoginResponse
import com.wap.wabi.auth.admin.repository.AdminRefreshTokenRepository
import com.wap.wabi.auth.admin.repository.AdminRepository
import com.wap.wabi.auth.jwt.JwtTokenProvider
import com.wap.wabi.auth.admin.util.AdminValidator
import com.wap.wabi.auth.jwt.JwtTokenProvider
import com.wap.wabi.exception.ErrorCode
import com.wap.wabi.exception.RestApiException
import org.springframework.security.crypto.password.PasswordEncoder
Expand All @@ -15,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional
@Service
class AdminService(
private val adminRepository: AdminRepository,
private val adminRefreshTokenRepository: AdminRefreshTokenRepository,
private val tokenProvider: JwtTokenProvider,
private val adminValidator: AdminValidator,
private val encoder: PasswordEncoder
Expand All @@ -36,11 +39,26 @@ class AdminService(
encoder.matches(adminLoginRequest.password, admin.get().password)
}
?: throw RestApiException(ErrorCode.BAD_REQUEST_NOT_EXIST_ADMIN)
val token = tokenProvider.createToken("${admin.get().username}:${admin.get().role}")
val refreshToken = tokenProvider.createRefreshToken()
adminRefreshTokenRepository.findAdminRefreshTokenByAdminName(admin.get().username)
.ifPresentOrElse(
{ it.updateRefreshToken(refreshToken) },
{
adminRefreshTokenRepository.save(
AdminRefreshToken.builder()
.adminName(admin.get().username)
.refreshToken(refreshToken)
.build()
)
}
)

val accessToken = tokenProvider.createAccessToken("${admin.get().username}:${admin.get().role}")
return AdminLoginResponse(
name = admin.get().username,
role = admin.get().role,
token = token
accessToken = accessToken,
refreshToken = refreshToken
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.wap.wabi.auth.jwt

import io.jsonwebtoken.ExpiredJwtException
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
Expand All @@ -24,28 +25,37 @@ class JwtAuthenticationFilter(
filterChain: FilterChain
) {
val path = request.requestURI
if (path.startsWith("/swagger-ui/") || path.startsWith("/v3/api-docs/")) {
if (path.startsWith("/swagger-ui/") || path.startsWith("/v3") || path.startsWith("/auth")) {
filterChain.doFilter(request, response)
return
}

val token = parseBearerToken(request)
try {
parseBearerToken(request, HttpHeaders.AUTHORIZATION)?.let { accessToken ->
jwtTokenProvider.validateAndParseToken(accessToken)

if (token != null && jwtTokenProvider.validateTokenAndGetSubject(token) != null) {
val user = parseUserSpecification(token)
UsernamePasswordAuthenticationToken.authenticated(user, token, user.authorities)
.apply { details = WebAuthenticationDetails(request) }
.also { SecurityContextHolder.getContext().authentication = it }
} else {
SecurityContextHolder.clearContext()
val user = parseUserSpecification(accessToken)
val authentication = UsernamePasswordAuthenticationToken.authenticated(user, accessToken, user.authorities)
authentication.details = WebAuthenticationDetails(request)
SecurityContextHolder.getContext().authentication = authentication
}
} catch (e: ExpiredJwtException) {
// Access Token이 만료된 경우 리프레시 토큰으로 새 토큰 발급
if (reissueAccessToken(request, response)) {
return // 새 토큰 발급 후 요청 종료
}
} catch (e: Exception) {
response.status = HttpServletResponse.SC_UNAUTHORIZED
response.writer.write("Invalid Token")
return
}

filterChain.doFilter(request, response)
}

private fun parseBearerToken(request: HttpServletRequest): String? {
return request.getHeader(HttpHeaders.AUTHORIZATION)
?.takeIf { it.startsWith("Bearer ", ignoreCase = true) }
private fun parseBearerToken(request: HttpServletRequest, headerName: String): String? {
return request.getHeader(headerName)
.takeIf { it.startsWith("Bearer ", ignoreCase = true) }
?.substring(7)
}

Expand All @@ -55,4 +65,33 @@ class JwtAuthenticationFilter(
val (username, role) = subject.split(":")
return User(username, "", listOf(SimpleGrantedAuthority(role)))
}

private fun reissueAccessToken(request: HttpServletRequest, response: HttpServletResponse): Boolean {
return try {
val refreshToken = parseBearerToken(request, "Refresh-Token")
?: throw IllegalArgumentException("Refresh token not provided")
val oldAccessToken = parseBearerToken(request, HttpHeaders.AUTHORIZATION)
?: throw IllegalArgumentException("Access token not provided")

// 리프레시 토큰 유효성 검사 및 새로운 액세스 토큰 발급
jwtTokenProvider.validateRefreshToken(refreshToken, oldAccessToken)
val newAccessToken = jwtTokenProvider.recreateAccessToken(oldAccessToken)

// 새 액세스 토큰을 응답 헤더에 추가
response.setHeader("New-Access-Token", newAccessToken)

Comment on lines +80 to +82
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

로그인 시에는 토큰을 응답 바디로 반환하는데, 새로 발급되는 액세스 토큰을 헤더에 담아서 반환하는 이유가 있을까요?

Copy link
Member Author

@Zepelown Zepelown Dec 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

만료 시에 액세스 토큰을 헤더로 반환해서 자동로그인을 이어갈 수 있도록 하는 의도였습니다.
로그인 마다 액세스 토큰만 바디로 반환하는 게 아닌, 리프레시 토큰도 반환되어 이와 같은 과정이 필요했습니다.

어떻게 생각하시나요?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 프론트 입장에서 새로운 액세스 토큰이 헤더로 오든 바디로 오든 결국 해당값을 꺼내어 사용해야 한다는 점에서 큰 차이가 없을 것이라고 생각해요. 그렇다면 일관성있게 바디로 반환하는게 좋지 않을까 하는 생각입니다.

추가적으로, 리프레시 토큰을 통해 액세스토큰을 발급받을때 리프레시 토큰도 새로 갱신해야 한다고 생각하는데 어떻게 생각하시나요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제가 잘 몰라서 그런데 리프레시 토큰은 만료 기간이 길고 액세스 토큰을 만료 기간을 짧게 만들고
먼저 엑세스 토큰을 통해 api 요청을 할 때, 만료가 되면 리프레시 토큰을 보내서 api 요청을 진행하는 것이 이 방식의 과정 아닌가요??
그러면 리프레시 토큰을 통해 api 요청을 진행할 때, 리프레시 토큰이 만료가 안됐다면 새로운 엑세스 토큰을 반환받아야 하는 것이 맞다고 생각했었어요.
그래서 헤더를 통해 받은 것이었습니다 (api 요청 body에 넣을 순 없으므로)
이 리프레시 토큰마저 만료되면 로그인을 다시 해야하는 것이라고 생각했습니다.

리프레시 토큰을 통해 액세스 토큰을 발급받을 때 리프레시 토큰을 갱신하는 방향으로 수정하겠습니다

Copy link
Contributor

@Due-IT Due-IT Dec 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사실 저도 리프레시 토큰을 잘 아는건 아니라서 그냥 생각해봤을때, 리프레시 토큰이 자주 바뀌는게 보다 안정성 있지 않을까 생각했어요. 아래 볼르그 글을 참고해보니 관점의 차이가 좀 있었던것 같습니다.

https://velog.io/@chuu1019/Access-Token%EA%B3%BC-Refresh-Token%EC%9D%B4%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B4%EA%B3%A0-%EC%99%9C-%ED%95%84%EC%9A%94%ED%95%A0%EA%B9%8C

저는 리프레시 토큰이 탈취된다면 해당 기간동안 언제든 액세스토큰을 발급 받을 수 있기에 잦은 갱신이 필요하다 생각했지만,
위 언급한 블로그의 내용에서는 리프레시 토큰을 주고 받을 일이 많지 않기 때문에 애초에 통신과정에 탈취당할 가능성이 적어 만료기간을 1년 정도 되는 긴 기간으로 잡네요. 기존 방식이 더 권장되는 방향인것 같습니다.

만약, 이미 수정하셨다면 안정성은 확실히 더 올라갈 것으로 예상되어 좋은것 같고 아니라면 그대로 진행해도 될것 같네요 👍

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

알겠습니다!

// SecurityContext에 새 인증 정보 업데이트
val user = parseUserSpecification(newAccessToken)
val authentication =
UsernamePasswordAuthenticationToken.authenticated(user, newAccessToken, user.authorities)
authentication.details = WebAuthenticationDetails(request)
SecurityContextHolder.getContext().authentication = authentication

true
} catch (e: Exception) {
response.status = HttpServletResponse.SC_UNAUTHORIZED
response.writer.write("Refresh token invalid or expired: ${e.message}")
false
}
}
}
71 changes: 58 additions & 13 deletions wabi/src/main/kotlin/com/wap/wabi/auth/jwt/JwtTokenProvider.kt
Original file line number Diff line number Diff line change
@@ -1,27 +1,37 @@
package com.wap.wabi.auth.jwt

import com.fasterxml.jackson.databind.ObjectMapper
import com.wap.wabi.auth.admin.repository.AdminRefreshTokenRepository
import io.jsonwebtoken.ExpiredJwtException
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import java.sql.Timestamp
import java.time.Instant
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import java.util.Date
import java.util.*
import javax.crypto.spec.SecretKeySpec

@Component
class JwtTokenProvider(
@Value("\${jwt.secret-key}")
private val secretKey: String,
@Value("\${jwt.expiration-hours}")
private val expirationHours: Long,
@Value("\${jwt.expiration-minutes}")
private val expirationMinutes: Long,
@Value("\${jwt.refresh-expiration-hours}")
private val refreshExpirationHours: Long,
@Value("\${jwt.issuer}")
private val issuer: String
private val issuer: String,
private val adminRefreshTokenRepository: AdminRefreshTokenRepository
) {
fun createToken(userSpecification: String) = Jwts.builder()
private val reissueLimit = refreshExpirationHours * 60 / expirationMinutes
private val objectMapper = ObjectMapper()

fun createAccessToken(userSpecification: String) = Jwts.builder()
.signWith(
SecretKeySpec(
secretKey.toByteArray(),
Expand All @@ -31,7 +41,7 @@ class JwtTokenProvider(
.setSubject(userSpecification) // JWT 토큰 제목
.setIssuer(issuer) // JWT 토큰 발급자
.setIssuedAt(Timestamp.valueOf(LocalDateTime.now())) // JWT 토큰 발급 시간
.setExpiration(Date.from(Instant.now().plus(expirationHours, ChronoUnit.HOURS))) // JWT 토큰의 만료시간 설정
.setExpiration(Date.from(Instant.now().plus(expirationMinutes, ChronoUnit.MINUTES))) // JWT 토큰의 만료시간 설정
.compact()!! // JWT 토큰 생성

fun validateTokenAndGetSubject(token: String): String? = Jwts.parserBuilder()
Expand All @@ -41,15 +51,50 @@ class JwtTokenProvider(
.body
.subject

fun getAdminNameByToken(token: String): String {
val subject =
validateTokenAndGetSubject(token) ?: throw IllegalArgumentException("Invalid token")
val (username) = subject.split(":")
return username
}

fun getAdminName(): String {
val authentication = SecurityContextHolder.getContext().authentication
return authentication.name
}

fun createRefreshToken() = Jwts.builder()
.signWith(SecretKeySpec(secretKey.toByteArray(), SignatureAlgorithm.HS512.jcaName))
.setIssuer(issuer)
.setIssuedAt(Timestamp.valueOf(LocalDateTime.now()))
.setExpiration(Date.from(Instant.now().plus(refreshExpirationHours, ChronoUnit.HOURS)))
.compact()!!

@Transactional
fun recreateAccessToken(oldAccessToken: String): String {
val subject = decodeJwtPayloadSubject(oldAccessToken)
adminRefreshTokenRepository.findAdminRefreshTokenByAdminNameAndReissueCountLessThan(
(subject.split(':')[0]),
reissueLimit
).ifPresentOrElse(
{ it.increaseReissueCount() },
{ throw ExpiredJwtException(null, null, "레프레시 토큰이 만료되었습니다.") }
)
return createAccessToken(subject)
}

@Transactional(readOnly = true)
fun validateRefreshToken(refreshToken: String, oldAccessToken: String) {
validateAndParseToken(refreshToken)
val adminName = decodeJwtPayloadSubject(oldAccessToken).split(':')[0]
adminRefreshTokenRepository.findAdminRefreshTokenByAdminNameAndReissueCountLessThan(adminName, reissueLimit)
.ifPresentOrElse(
{ it.validateRefreshToken(refreshToken) },
{ throw ExpiredJwtException(null, null, "레프레시 토큰이 만료되었습니다.") }
)
}

fun validateAndParseToken(token: String?) = Jwts.parserBuilder() // validateTokenAndGetSubject()에서 따로 분리
.setSigningKey(secretKey.toByteArray())
.build()
.parseClaimsJws(token)!!

private fun decodeJwtPayloadSubject(oldAccessToken: String) =
objectMapper.readValue(
Base64.getUrlDecoder().decode(oldAccessToken.split('.')[1]).decodeToString(),
Map::class.java
)["sub"].toString()
}
16 changes: 16 additions & 0 deletions wabi/src/main/kotlin/com/wap/wabi/common/config/SwaggerConfig.kt
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
package com.wap.wabi.common.config

import io.swagger.v3.oas.annotations.enums.ParameterIn
import io.swagger.v3.oas.models.Components
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.Operation
import io.swagger.v3.oas.models.info.Info
import io.swagger.v3.oas.models.media.StringSchema
import io.swagger.v3.oas.models.parameters.Parameter
import io.swagger.v3.oas.models.security.SecurityRequirement
import io.swagger.v3.oas.models.security.SecurityScheme
import io.swagger.v3.oas.models.servers.Server
import org.springdoc.core.customizers.OperationCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.method.HandlerMethod


@Configuration
Expand Down Expand Up @@ -43,4 +49,14 @@ class SwaggerConfig {
.addSecurityItem(securityRequirement)
.components(components)
}

@Bean
fun globalHeader() = OperationCustomizer { operation: Operation, _: HandlerMethod ->
operation.addParametersItem(
Parameter()
.`in`(ParameterIn.HEADER.toString())
.schema(StringSchema().name("Refresh-Token"))
.name("Refresh-Token"))
operation
}
}
3 changes: 2 additions & 1 deletion wabi/src/main/resources/application-prod.properties
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ logging.level.org.springframework.security=DEBUG
logging.level.org.springframework.web=DEBUG
logging.level.org.springframework.http=DEBUG
jwt.secret-key=${JWT.KEY}
jwt.expiration-hours=${JWT.EXPIRATION.HOURS}
jwt.expiration-minutes=${JWT.EXPIRATION.MINUTES}
jwt.refresh-expiration-hours= ${JWT.REFRESH.EXPIRATION.HOURS}
jwt.issuer=${JWT.ISSUER}
Loading