diff --git a/src/main/kotlin/com/back/koreaTravelGuide/common/config/RedisConfig.kt b/src/main/kotlin/com/back/koreaTravelGuide/common/config/RedisConfig.kt new file mode 100644 index 0000000..df4e094 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/common/config/RedisConfig.kt @@ -0,0 +1,25 @@ +package com.back.koreaTravelGuide.common.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.serializer.StringRedisSerializer + +@Configuration +class RedisConfig { + @Bean + fun redisTemplate(connectionFactory: RedisConnectionFactory): RedisTemplate { + val template = RedisTemplate() + + template.connectionFactory = connectionFactory + +// Key와 Value의 Serializer를 String으로 설정 + + template.keySerializer = StringRedisSerializer() + + template.valueSerializer = StringRedisSerializer() + + return template + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2LoginSuccessHandler.kt b/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2LoginSuccessHandler.kt index 2e78bf9..aaa6449 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2LoginSuccessHandler.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2LoginSuccessHandler.kt @@ -1,19 +1,24 @@ -package com.back.koreaTravelGuide.security +package com.back.koreaTravelGuide.common.security import com.back.koreaTravelGuide.domain.user.enums.UserRole import com.back.koreaTravelGuide.domain.user.repository.UserRepository +import jakarta.servlet.http.Cookie import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.redis.core.RedisTemplate import org.springframework.security.core.Authentication -import org.springframework.security.oauth2.core.user.OAuth2User import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional +import java.util.concurrent.TimeUnit @Component class CustomOAuth2LoginSuccessHandler( private val jwtTokenProvider: JwtTokenProvider, private val userRepository: UserRepository, + private val redisTemplate: RedisTemplate, + @Value("\${jwt.refresh-token-expiration-days}") private val refreshTokenExpirationDays: Long, ) : SimpleUrlAuthenticationSuccessHandler() { @Transactional override fun onAuthenticationSuccess( @@ -21,18 +26,42 @@ class CustomOAuth2LoginSuccessHandler( response: HttpServletResponse, authentication: Authentication, ) { - val oAuth2User = authentication.principal as OAuth2User - val email = oAuth2User.attributes["email"] as String + val customUser = authentication.principal as CustomOAuth2User + + val email = customUser.email val user = userRepository.findByEmail(email)!! if (user.role == UserRole.PENDING) { val registerToken = jwtTokenProvider.createRegisterToken(user.id!!) + val targetUrl = "http://localhost:3000/signup/role?token=$registerToken" + redirectStrategy.sendRedirect(request, response, targetUrl) } else { val accessToken = jwtTokenProvider.createAccessToken(user.id!!, user.role) + + val refreshToken = jwtTokenProvider.createRefreshToken(user.id!!) + + val redisKey = "refreshToken:${user.id}" + + redisTemplate.opsForValue().set(redisKey, refreshToken, refreshTokenExpirationDays, TimeUnit.DAYS) + + val cookie = + Cookie("refreshToken", refreshToken).apply { + isHttpOnly = true + + secure = true + + path = "/" + + maxAge = (refreshTokenExpirationDays * 24 * 60 * 60).toInt() + } + + response.addCookie(cookie) + val targetUrl = "http://localhost:3000/oauth/callback?accessToken=$accessToken" + redirectStrategy.sendRedirect(request, response, targetUrl) } } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2User.kt b/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2User.kt index 7a59064..7eae026 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2User.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2User.kt @@ -1,4 +1,4 @@ -package com.back.koreaTravelGuide.security +package com.back.koreaTravelGuide.common.security import org.springframework.security.core.GrantedAuthority import org.springframework.security.oauth2.core.user.DefaultOAuth2User @@ -8,4 +8,15 @@ class CustomOAuth2User( val email: String, authorities: Collection, attributes: Map, -) : DefaultOAuth2User(authorities, attributes, "email") + val nameAttributeKey: String, +) : DefaultOAuth2User(authorities, attributes, nameAttributeKey) { + override fun getName(): String { + val nameAttribute = getAttribute(nameAttributeKey) + + if (nameAttribute is Map<*, *>) { + return nameAttribute["id"] as String + } + + return nameAttribute.toString() + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2UserService.kt index a3739c5..53be2ad 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2UserService.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2UserService.kt @@ -1,4 +1,4 @@ -package com.back.koreaTravelGuide.security +package com.back.koreaTravelGuide.common.security import com.back.koreaTravelGuide.domain.user.entity.User import com.back.koreaTravelGuide.domain.user.enums.UserRole @@ -43,11 +43,14 @@ class CustomOAuth2UserService( val authorities = listOf(SimpleGrantedAuthority("ROLE_${user.role.name}")) + val userNameAttributeName = userRequest.clientRegistration.providerDetails.userInfoEndpoint.userNameAttributeName + return CustomOAuth2User( id = user.id!!, email = user.email, authorities = authorities, attributes = attributes, + nameAttributeKey = userNameAttributeName, ) } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/common/security/JwtAuthenticationFilter.kt b/src/main/kotlin/com/back/koreaTravelGuide/common/security/JwtAuthenticationFilter.kt index 95cff49..4d596e6 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/common/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/common/security/JwtAuthenticationFilter.kt @@ -1,8 +1,9 @@ -package com.back.koreaTravelGuide.security +package com.back.koreaTravelGuide.common.security import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import org.springframework.data.redis.core.RedisTemplate import org.springframework.security.core.context.SecurityContextHolder import org.springframework.stereotype.Component import org.springframework.web.filter.OncePerRequestFilter @@ -10,6 +11,7 @@ import org.springframework.web.filter.OncePerRequestFilter @Component class JwtAuthenticationFilter( private val jwtTokenProvider: JwtTokenProvider, + private val redisTemplate: RedisTemplate, ) : OncePerRequestFilter() { override fun doFilterInternal( request: HttpServletRequest, @@ -18,8 +20,11 @@ class JwtAuthenticationFilter( ) { val token = resolveToken(request) - if (token != null && jwtTokenProvider.validateToken(token)) { + val isBlacklisted = if (token != null) redisTemplate.opsForValue().get(token) != null else false + + if (token != null && !isBlacklisted && jwtTokenProvider.validateToken(token)) { val authentication = jwtTokenProvider.getAuthentication(token) + SecurityContextHolder.getContext().authentication = authentication } @@ -28,6 +33,7 @@ class JwtAuthenticationFilter( private fun resolveToken(request: HttpServletRequest): String? { val bearerToken = request.getHeader("Authorization") + return if (bearerToken != null && bearerToken.startsWith("Bearer ")) { bearerToken.substring(7) } else { diff --git a/src/main/kotlin/com/back/koreaTravelGuide/common/security/JwtTokenProvider.kt b/src/main/kotlin/com/back/koreaTravelGuide/common/security/JwtTokenProvider.kt index 7a580b1..a7c81df 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/common/security/JwtTokenProvider.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/common/security/JwtTokenProvider.kt @@ -1,9 +1,10 @@ -package com.back.koreaTravelGuide.security +package com.back.koreaTravelGuide.common.security import com.back.koreaTravelGuide.domain.user.enums.UserRole import io.jsonwebtoken.Claims import io.jsonwebtoken.Jwts import io.jsonwebtoken.security.Keys +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.Authentication @@ -17,8 +18,12 @@ import javax.crypto.SecretKey class JwtTokenProvider( @Value("\${jwt.secret-key}") private val secretKey: String, @Value("\${jwt.access-token-expiration-minutes}") private val accessTokenExpirationMinutes: Long, + @Value("\${jwt.refresh-token-expiration-days}") private val refreshTokenExpirationDays: Long, ) { + private val logger = LoggerFactory.getLogger(JwtTokenProvider::class.java) + private val key: SecretKey by lazy { + Keys.hmacShaKeyFor(Base64.getEncoder().encode(secretKey.toByteArray())) } @@ -27,6 +32,7 @@ class JwtTokenProvider( role: UserRole, ): String { val now = Date() + val expiryDate = Date(now.time + accessTokenExpirationMinutes * 60 * 1000) return Jwts.builder() @@ -38,8 +44,22 @@ class JwtTokenProvider( .compact() } + fun createRefreshToken(userId: Long): String { + val now = Date() + + val expiryDate = Date(now.time + refreshTokenExpirationDays * 24 * 60 * 60 * 1000) + + return Jwts.builder() + .subject(userId.toString()) + .issuedAt(now) + .expiration(expiryDate) + .signWith(key) + .compact() + } + fun createRegisterToken(userId: Long): String { val now = Date() + val expiryDate = Date(now.time + 5 * 60 * 1000) return Jwts.builder() @@ -53,21 +73,37 @@ class JwtTokenProvider( fun validateToken(token: String): Boolean { try { getClaimsFromToken(token) + return true } catch (e: Exception) { + logger.error("Token validation error: ${e.message}") + return false } } fun getAuthentication(token: String): Authentication { val claims = getClaimsFromToken(token) + val userId = claims.subject.toLong() + val role = claims["role"] as? String ?: "ROLE_PENDING" + val authorities = listOf(SimpleGrantedAuthority(role)) return UsernamePasswordAuthenticationToken(userId, null, authorities) } + fun getUserIdFromToken(token: String): Long { + return getClaimsFromToken(token).subject.toLong() + } + + fun getRemainingTime(token: String): Long { + val expiration = getClaimsFromToken(token).expiration + + return expiration.time - Date().time + } + private fun getClaimsFromToken(token: String): Claims { return Jwts.parser() .verifyWith(key) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/auth/controller/AuthController.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/auth/controller/AuthController.kt new file mode 100644 index 0000000..b750e04 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/auth/controller/AuthController.kt @@ -0,0 +1,66 @@ +package com.back.koreaTravelGuide.domain.auth.controller + +import com.back.koreaTravelGuide.common.ApiResponse +import com.back.koreaTravelGuide.domain.auth.dto.request.UserRoleUpdateRequest +import com.back.koreaTravelGuide.domain.auth.dto.response.AccessTokenResponse +import com.back.koreaTravelGuide.domain.auth.dto.response.LoginResponse +import com.back.koreaTravelGuide.domain.auth.service.AuthService +import io.swagger.v3.oas.annotations.Operation +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.CookieValue +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/auth") +class AuthController( + private val authService: AuthService, + @Value("\${jwt.refresh-token-expiration-days}") private val refreshTokenExpirationDays: Long, +) { + @PostMapping("/refresh") + fun refreshAccessToken( + @CookieValue("refreshToken") refreshToken: String, + response: HttpServletResponse, + ): ResponseEntity> { + val (newAccessToken, newRefreshToken) = authService.refreshAccessToken(refreshToken) + + val cookie = + jakarta.servlet.http.Cookie("refreshToken", newRefreshToken).apply { + isHttpOnly = true + secure = true + path = "/" + maxAge = (refreshTokenExpirationDays * 24 * 60 * 60).toInt() + } + response.addCookie(cookie) + + return ResponseEntity.ok(ApiResponse("Access Token이 성공적으로 재발급되었습니다.", AccessTokenResponse(newAccessToken))) + } + + @Operation(summary = "신규 사용자 역할 선택") + @PostMapping("/role") + fun updateUserRole( + @AuthenticationPrincipal userId: Long, + @RequestBody request: UserRoleUpdateRequest, + ): ResponseEntity> { + val loginResponse = authService.updateRoleAndLogin(userId, request.role) + return ResponseEntity.ok(ApiResponse("역할이 선택되었으며 로그인에 성공했습니다.", loginResponse)) + } + + @Operation(summary = "로그아웃") + @PostMapping("/logout") + fun logout(request: HttpServletRequest): ResponseEntity> { + val token = + request.getHeader("Authorization")?.substring(7) + ?: throw IllegalArgumentException("토큰이 없습니다.") + + authService.logout(token) + + return ResponseEntity.ok(ApiResponse("로그아웃 되었습니다.")) + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/auth/dto/request/UserRoleUpdateRequest.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/auth/dto/request/UserRoleUpdateRequest.kt new file mode 100644 index 0000000..fc96527 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/auth/dto/request/UserRoleUpdateRequest.kt @@ -0,0 +1,7 @@ +package com.back.koreaTravelGuide.domain.auth.dto.request + +import com.back.koreaTravelGuide.domain.user.enums.UserRole + +data class UserRoleUpdateRequest( + val role: UserRole, +) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/auth/dto/response/AccessTokenResponse.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/auth/dto/response/AccessTokenResponse.kt new file mode 100644 index 0000000..6622d83 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/auth/dto/response/AccessTokenResponse.kt @@ -0,0 +1,5 @@ +package com.back.koreaTravelGuide.domain.auth.dto.response + +data class AccessTokenResponse( + val accessToken: String, +) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/auth/dto/response/LoginResponse.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/auth/dto/response/LoginResponse.kt new file mode 100644 index 0000000..6124fb4 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/auth/dto/response/LoginResponse.kt @@ -0,0 +1,5 @@ +package com.back.koreaTravelGuide.domain.auth.dto.response + +data class LoginResponse( + val accessToken: String, +) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/auth/service/AuthService.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/auth/service/AuthService.kt new file mode 100644 index 0000000..74e2092 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/auth/service/AuthService.kt @@ -0,0 +1,78 @@ +package com.back.koreaTravelGuide.domain.auth.service + +import com.back.koreaTravelGuide.common.security.JwtTokenProvider +import com.back.koreaTravelGuide.domain.auth.dto.response.LoginResponse +import com.back.koreaTravelGuide.domain.user.enums.UserRole +import com.back.koreaTravelGuide.domain.user.repository.UserRepository +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.security.core.AuthenticationException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.concurrent.TimeUnit + +@Service +@Transactional +class AuthService( + private val userRepository: UserRepository, + private val jwtTokenProvider: JwtTokenProvider, + private val redisTemplate: RedisTemplate, + @Value("\${jwt.refresh-token-expiration-days}") private val refreshTokenExpirationDays: Long, +) { + fun updateRoleAndLogin( + userId: Long, + role: UserRole, + ): LoginResponse { + if (role != UserRole.USER && role != UserRole.GUIDE) { + throw IllegalArgumentException("선택할 수 없는 역할입니다.") + } + + val user = + userRepository.findById(userId) + .orElseThrow { NoSuchElementException("사용자를 찾을 수 없습니다.") } + + if (user.role != UserRole.PENDING) { + throw IllegalStateException("이미 역할이 설정된 사용자입니다.") + } + + user.role = role + userRepository.save(user) + + val accessToken = jwtTokenProvider.createAccessToken(user.id!!, user.role) + + return LoginResponse(accessToken = accessToken) + } + + fun logout(accessToken: String) { + val remainingTime = jwtTokenProvider.getRemainingTime(accessToken) + + if (remainingTime > 0) { + redisTemplate.opsForValue().set(accessToken, "logout", remainingTime, TimeUnit.MILLISECONDS) + } + } + + fun refreshAccessToken(refreshToken: String): Pair { + if (!jwtTokenProvider.validateToken(refreshToken)) { + throw object : AuthenticationException("유효하지 않은 Refresh Token입니다.") {} + } + + val userId = jwtTokenProvider.getUserIdFromToken(refreshToken) + + val redisKey = "refreshToken:$userId" + val storedRefreshToken = + redisTemplate.opsForValue().get(redisKey) + ?: throw object : AuthenticationException("로그인 정보가 만료되었습니다.") {} + + if (storedRefreshToken != refreshToken) { + throw object : AuthenticationException("토큰 정보가 일치하지 않습니다.") {} + } + + val user = userRepository.findById(userId).orElseThrow { NoSuchElementException("사용자를 찾을 수 없습니다.") } + val newAccessToken = jwtTokenProvider.createAccessToken(user.id!!, user.role) + val newRefreshToken = jwtTokenProvider.createRefreshToken(user.id!!) + + redisTemplate.opsForValue().set(redisKey, newRefreshToken, refreshTokenExpirationDays, TimeUnit.DAYS) + + return Pair(newAccessToken, newRefreshToken) + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/user/controller/GuideController.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/user/controller/GuideController.kt index 5944fb7..470c69b 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/user/controller/GuideController.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/user/controller/GuideController.kt @@ -38,6 +38,7 @@ class GuideController( return ResponseEntity.ok(ApiResponse("가이드 정보를 성공적으로 조회했습니다.", guide)) } + // 가이드는 회원가입 후 프로필 수정을 통해 필드 입력 받음 @Operation(summary = "가이드 프로필 수정") @PreAuthorize("hasRole('GUIDE')") @PatchMapping("/me") diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/user/service/UserService.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/user/service/UserService.kt index f7e68e2..0eaa8de 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/user/service/UserService.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/user/service/UserService.kt @@ -5,6 +5,7 @@ import com.back.koreaTravelGuide.domain.user.dto.response.UserResponse import com.back.koreaTravelGuide.domain.user.repository.UserRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.util.NoSuchElementException @Service @Transactional diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4b65faa..ae847e2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -76,7 +76,6 @@ spring: autoconfigure: exclude: - org.springframework.boot.autoconfigure.session.SessionAutoConfiguration - - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration security: oauth2: @@ -173,4 +172,5 @@ management: # JWT 설정 jwt: secret-key: ${CUSTOM__JWT__SECRET_KEY:dev-secret-key-for-local-testing-please-change} - access-token-expiration-minutes: ${JWT_ACCESS_TOKEN_EXPIRATION_MINUTES:60} \ No newline at end of file + access-token-expiration-minutes: ${JWT_ACCESS_TOKEN_EXPIRATION_MINUTES:60} + refresh-token-expiration-days: ${JWT_REFRESH_TOKEN_EXPIRATION_DAYS:7} \ No newline at end of file