Skip to content

Commit ec25aa3

Browse files
authored
Merge pull request #66 from prgrms-web-devcourse-final-project/feat/be/40
Security 추가 작업
2 parents e453e1c + 658e896 commit ec25aa3

File tree

14 files changed

+285
-12
lines changed

14 files changed

+285
-12
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.back.koreaTravelGuide.common.config
2+
3+
import org.springframework.context.annotation.Bean
4+
import org.springframework.context.annotation.Configuration
5+
import org.springframework.data.redis.connection.RedisConnectionFactory
6+
import org.springframework.data.redis.core.RedisTemplate
7+
import org.springframework.data.redis.serializer.StringRedisSerializer
8+
9+
@Configuration
10+
class RedisConfig {
11+
@Bean
12+
fun redisTemplate(connectionFactory: RedisConnectionFactory): RedisTemplate<String, String> {
13+
val template = RedisTemplate<String, String>()
14+
15+
template.connectionFactory = connectionFactory
16+
17+
// Key와 Value의 Serializer를 String으로 설정
18+
19+
template.keySerializer = StringRedisSerializer()
20+
21+
template.valueSerializer = StringRedisSerializer()
22+
23+
return template
24+
}
25+
}

src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2LoginSuccessHandler.kt

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,67 @@
1-
package com.back.koreaTravelGuide.security
1+
package com.back.koreaTravelGuide.common.security
22

33
import com.back.koreaTravelGuide.domain.user.enums.UserRole
44
import com.back.koreaTravelGuide.domain.user.repository.UserRepository
5+
import jakarta.servlet.http.Cookie
56
import jakarta.servlet.http.HttpServletRequest
67
import jakarta.servlet.http.HttpServletResponse
8+
import org.springframework.beans.factory.annotation.Value
9+
import org.springframework.data.redis.core.RedisTemplate
710
import org.springframework.security.core.Authentication
8-
import org.springframework.security.oauth2.core.user.OAuth2User
911
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler
1012
import org.springframework.stereotype.Component
1113
import org.springframework.transaction.annotation.Transactional
14+
import java.util.concurrent.TimeUnit
1215

1316
@Component
1417
class CustomOAuth2LoginSuccessHandler(
1518
private val jwtTokenProvider: JwtTokenProvider,
1619
private val userRepository: UserRepository,
20+
private val redisTemplate: RedisTemplate<String, String>,
21+
@Value("\${jwt.refresh-token-expiration-days}") private val refreshTokenExpirationDays: Long,
1722
) : SimpleUrlAuthenticationSuccessHandler() {
1823
@Transactional
1924
override fun onAuthenticationSuccess(
2025
request: HttpServletRequest,
2126
response: HttpServletResponse,
2227
authentication: Authentication,
2328
) {
24-
val oAuth2User = authentication.principal as OAuth2User
25-
val email = oAuth2User.attributes["email"] as String
29+
val customUser = authentication.principal as CustomOAuth2User
30+
31+
val email = customUser.email
2632

2733
val user = userRepository.findByEmail(email)!!
2834

2935
if (user.role == UserRole.PENDING) {
3036
val registerToken = jwtTokenProvider.createRegisterToken(user.id!!)
37+
3138
val targetUrl = "http://localhost:3000/signup/role?token=$registerToken"
39+
3240
redirectStrategy.sendRedirect(request, response, targetUrl)
3341
} else {
3442
val accessToken = jwtTokenProvider.createAccessToken(user.id!!, user.role)
43+
44+
val refreshToken = jwtTokenProvider.createRefreshToken(user.id!!)
45+
46+
val redisKey = "refreshToken:${user.id}"
47+
48+
redisTemplate.opsForValue().set(redisKey, refreshToken, refreshTokenExpirationDays, TimeUnit.DAYS)
49+
50+
val cookie =
51+
Cookie("refreshToken", refreshToken).apply {
52+
isHttpOnly = true
53+
54+
secure = true
55+
56+
path = "/"
57+
58+
maxAge = (refreshTokenExpirationDays * 24 * 60 * 60).toInt()
59+
}
60+
61+
response.addCookie(cookie)
62+
3563
val targetUrl = "http://localhost:3000/oauth/callback?accessToken=$accessToken"
64+
3665
redirectStrategy.sendRedirect(request, response, targetUrl)
3766
}
3867
}
Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.back.koreaTravelGuide.security
1+
package com.back.koreaTravelGuide.common.security
22

33
import org.springframework.security.core.GrantedAuthority
44
import org.springframework.security.oauth2.core.user.DefaultOAuth2User
@@ -8,4 +8,15 @@ class CustomOAuth2User(
88
val email: String,
99
authorities: Collection<GrantedAuthority>,
1010
attributes: Map<String, Any>,
11-
) : DefaultOAuth2User(authorities, attributes, "email")
11+
val nameAttributeKey: String,
12+
) : DefaultOAuth2User(authorities, attributes, nameAttributeKey) {
13+
override fun getName(): String {
14+
val nameAttribute = getAttribute<Any>(nameAttributeKey)
15+
16+
if (nameAttribute is Map<*, *>) {
17+
return nameAttribute["id"] as String
18+
}
19+
20+
return nameAttribute.toString()
21+
}
22+
}

src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2UserService.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.back.koreaTravelGuide.security
1+
package com.back.koreaTravelGuide.common.security
22

33
import com.back.koreaTravelGuide.domain.user.entity.User
44
import com.back.koreaTravelGuide.domain.user.enums.UserRole
@@ -43,11 +43,14 @@ class CustomOAuth2UserService(
4343

4444
val authorities = listOf(SimpleGrantedAuthority("ROLE_${user.role.name}"))
4545

46+
val userNameAttributeName = userRequest.clientRegistration.providerDetails.userInfoEndpoint.userNameAttributeName
47+
4648
return CustomOAuth2User(
4749
id = user.id!!,
4850
email = user.email,
4951
authorities = authorities,
5052
attributes = attributes,
53+
nameAttributeKey = userNameAttributeName,
5154
)
5255
}
5356

src/main/kotlin/com/back/koreaTravelGuide/common/security/JwtAuthenticationFilter.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
package com.back.koreaTravelGuide.security
1+
package com.back.koreaTravelGuide.common.security
22

33
import jakarta.servlet.FilterChain
44
import jakarta.servlet.http.HttpServletRequest
55
import jakarta.servlet.http.HttpServletResponse
6+
import org.springframework.data.redis.core.RedisTemplate
67
import org.springframework.security.core.context.SecurityContextHolder
78
import org.springframework.stereotype.Component
89
import org.springframework.web.filter.OncePerRequestFilter
910

1011
@Component
1112
class JwtAuthenticationFilter(
1213
private val jwtTokenProvider: JwtTokenProvider,
14+
private val redisTemplate: RedisTemplate<String, String>,
1315
) : OncePerRequestFilter() {
1416
override fun doFilterInternal(
1517
request: HttpServletRequest,
@@ -18,8 +20,11 @@ class JwtAuthenticationFilter(
1820
) {
1921
val token = resolveToken(request)
2022

21-
if (token != null && jwtTokenProvider.validateToken(token)) {
23+
val isBlacklisted = if (token != null) redisTemplate.opsForValue().get(token) != null else false
24+
25+
if (token != null && !isBlacklisted && jwtTokenProvider.validateToken(token)) {
2226
val authentication = jwtTokenProvider.getAuthentication(token)
27+
2328
SecurityContextHolder.getContext().authentication = authentication
2429
}
2530

@@ -28,6 +33,7 @@ class JwtAuthenticationFilter(
2833

2934
private fun resolveToken(request: HttpServletRequest): String? {
3035
val bearerToken = request.getHeader("Authorization")
36+
3137
return if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
3238
bearerToken.substring(7)
3339
} else {

src/main/kotlin/com/back/koreaTravelGuide/common/security/JwtTokenProvider.kt

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
package com.back.koreaTravelGuide.security
1+
package com.back.koreaTravelGuide.common.security
22

33
import com.back.koreaTravelGuide.domain.user.enums.UserRole
44
import io.jsonwebtoken.Claims
55
import io.jsonwebtoken.Jwts
66
import io.jsonwebtoken.security.Keys
7+
import org.slf4j.LoggerFactory
78
import org.springframework.beans.factory.annotation.Value
89
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
910
import org.springframework.security.core.Authentication
@@ -17,8 +18,12 @@ import javax.crypto.SecretKey
1718
class JwtTokenProvider(
1819
@Value("\${jwt.secret-key}") private val secretKey: String,
1920
@Value("\${jwt.access-token-expiration-minutes}") private val accessTokenExpirationMinutes: Long,
21+
@Value("\${jwt.refresh-token-expiration-days}") private val refreshTokenExpirationDays: Long,
2022
) {
23+
private val logger = LoggerFactory.getLogger(JwtTokenProvider::class.java)
24+
2125
private val key: SecretKey by lazy {
26+
2227
Keys.hmacShaKeyFor(Base64.getEncoder().encode(secretKey.toByteArray()))
2328
}
2429

@@ -27,6 +32,7 @@ class JwtTokenProvider(
2732
role: UserRole,
2833
): String {
2934
val now = Date()
35+
3036
val expiryDate = Date(now.time + accessTokenExpirationMinutes * 60 * 1000)
3137

3238
return Jwts.builder()
@@ -38,8 +44,22 @@ class JwtTokenProvider(
3844
.compact()
3945
}
4046

47+
fun createRefreshToken(userId: Long): String {
48+
val now = Date()
49+
50+
val expiryDate = Date(now.time + refreshTokenExpirationDays * 24 * 60 * 60 * 1000)
51+
52+
return Jwts.builder()
53+
.subject(userId.toString())
54+
.issuedAt(now)
55+
.expiration(expiryDate)
56+
.signWith(key)
57+
.compact()
58+
}
59+
4160
fun createRegisterToken(userId: Long): String {
4261
val now = Date()
62+
4363
val expiryDate = Date(now.time + 5 * 60 * 1000)
4464

4565
return Jwts.builder()
@@ -53,21 +73,37 @@ class JwtTokenProvider(
5373
fun validateToken(token: String): Boolean {
5474
try {
5575
getClaimsFromToken(token)
76+
5677
return true
5778
} catch (e: Exception) {
79+
logger.error("Token validation error: ${e.message}")
80+
5881
return false
5982
}
6083
}
6184

6285
fun getAuthentication(token: String): Authentication {
6386
val claims = getClaimsFromToken(token)
87+
6488
val userId = claims.subject.toLong()
89+
6590
val role = claims["role"] as? String ?: "ROLE_PENDING"
91+
6692
val authorities = listOf(SimpleGrantedAuthority(role))
6793

6894
return UsernamePasswordAuthenticationToken(userId, null, authorities)
6995
}
7096

97+
fun getUserIdFromToken(token: String): Long {
98+
return getClaimsFromToken(token).subject.toLong()
99+
}
100+
101+
fun getRemainingTime(token: String): Long {
102+
val expiration = getClaimsFromToken(token).expiration
103+
104+
return expiration.time - Date().time
105+
}
106+
71107
private fun getClaimsFromToken(token: String): Claims {
72108
return Jwts.parser()
73109
.verifyWith(key)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package com.back.koreaTravelGuide.domain.auth.controller
2+
3+
import com.back.koreaTravelGuide.common.ApiResponse
4+
import com.back.koreaTravelGuide.domain.auth.dto.request.UserRoleUpdateRequest
5+
import com.back.koreaTravelGuide.domain.auth.dto.response.AccessTokenResponse
6+
import com.back.koreaTravelGuide.domain.auth.dto.response.LoginResponse
7+
import com.back.koreaTravelGuide.domain.auth.service.AuthService
8+
import io.swagger.v3.oas.annotations.Operation
9+
import jakarta.servlet.http.HttpServletRequest
10+
import jakarta.servlet.http.HttpServletResponse
11+
import org.springframework.beans.factory.annotation.Value
12+
import org.springframework.http.ResponseEntity
13+
import org.springframework.security.core.annotation.AuthenticationPrincipal
14+
import org.springframework.web.bind.annotation.CookieValue
15+
import org.springframework.web.bind.annotation.PostMapping
16+
import org.springframework.web.bind.annotation.RequestBody
17+
import org.springframework.web.bind.annotation.RequestMapping
18+
import org.springframework.web.bind.annotation.RestController
19+
20+
@RestController
21+
@RequestMapping("/api/auth")
22+
class AuthController(
23+
private val authService: AuthService,
24+
@Value("\${jwt.refresh-token-expiration-days}") private val refreshTokenExpirationDays: Long,
25+
) {
26+
@PostMapping("/refresh")
27+
fun refreshAccessToken(
28+
@CookieValue("refreshToken") refreshToken: String,
29+
response: HttpServletResponse,
30+
): ResponseEntity<ApiResponse<AccessTokenResponse>> {
31+
val (newAccessToken, newRefreshToken) = authService.refreshAccessToken(refreshToken)
32+
33+
val cookie =
34+
jakarta.servlet.http.Cookie("refreshToken", newRefreshToken).apply {
35+
isHttpOnly = true
36+
secure = true
37+
path = "/"
38+
maxAge = (refreshTokenExpirationDays * 24 * 60 * 60).toInt()
39+
}
40+
response.addCookie(cookie)
41+
42+
return ResponseEntity.ok(ApiResponse("Access Token이 성공적으로 재발급되었습니다.", AccessTokenResponse(newAccessToken)))
43+
}
44+
45+
@Operation(summary = "신규 사용자 역할 선택")
46+
@PostMapping("/role")
47+
fun updateUserRole(
48+
@AuthenticationPrincipal userId: Long,
49+
@RequestBody request: UserRoleUpdateRequest,
50+
): ResponseEntity<ApiResponse<LoginResponse>> {
51+
val loginResponse = authService.updateRoleAndLogin(userId, request.role)
52+
return ResponseEntity.ok(ApiResponse("역할이 선택되었으며 로그인에 성공했습니다.", loginResponse))
53+
}
54+
55+
@Operation(summary = "로그아웃")
56+
@PostMapping("/logout")
57+
fun logout(request: HttpServletRequest): ResponseEntity<ApiResponse<Unit>> {
58+
val token =
59+
request.getHeader("Authorization")?.substring(7)
60+
?: throw IllegalArgumentException("토큰이 없습니다.")
61+
62+
authService.logout(token)
63+
64+
return ResponseEntity.ok(ApiResponse("로그아웃 되었습니다."))
65+
}
66+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.back.koreaTravelGuide.domain.auth.dto.request
2+
3+
import com.back.koreaTravelGuide.domain.user.enums.UserRole
4+
5+
data class UserRoleUpdateRequest(
6+
val role: UserRole,
7+
)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.back.koreaTravelGuide.domain.auth.dto.response
2+
3+
data class AccessTokenResponse(
4+
val accessToken: String,
5+
)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.back.koreaTravelGuide.domain.auth.dto.response
2+
3+
data class LoginResponse(
4+
val accessToken: String,
5+
)

0 commit comments

Comments
 (0)