Skip to content
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
5 changes: 5 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ dependencies {
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")

// jwt
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")

// Spring AI - 1.1.0-M2 (최신 버전) - 새로운 artifact명
implementation("org.springframework.ai:spring-ai-starter-model-openai:1.1.0-M2")
// Spring AI - JDBC 채팅 메모리 저장소 (PostgreSQL 대화 기록 저장)
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.back.koreaTravelGuide.security

import com.back.koreaTravelGuide.domain.user.enums.UserRole
import com.back.koreaTravelGuide.domain.user.repository.UserRepository
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
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

@Component
class CustomOAuth2LoginSuccessHandler(
private val jwtTokenProvider: JwtTokenProvider,
private val userRepository: UserRepository,
) : SimpleUrlAuthenticationSuccessHandler() {
@Transactional
override fun onAuthenticationSuccess(
request: HttpServletRequest,
response: HttpServletResponse,
authentication: Authentication,
) {
val oAuth2User = authentication.principal as OAuth2User
val email = oAuth2User.attributes["email"] as String

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 targetUrl = "http://localhost:3000/oauth/callback?accessToken=$accessToken"
redirectStrategy.sendRedirect(request, response, targetUrl)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.back.koreaTravelGuide.security

import org.springframework.security.core.GrantedAuthority
import org.springframework.security.oauth2.core.user.DefaultOAuth2User

class CustomOAuth2User(
val id: Long,
val email: String,
authorities: Collection<GrantedAuthority>,
attributes: Map<String, Any>,
) : DefaultOAuth2User(authorities, attributes, "email")
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.back.koreaTravelGuide.security

import com.back.koreaTravelGuide.domain.user.entity.User
import com.back.koreaTravelGuide.domain.user.enums.UserRole
import com.back.koreaTravelGuide.domain.user.repository.UserRepository
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest
import org.springframework.security.oauth2.core.user.OAuth2User
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
class CustomOAuth2UserService(
private val userRepository: UserRepository,
) : DefaultOAuth2UserService() {
@Transactional
override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User {
val oAuth2User = super.loadUser(userRequest)
val provider = userRequest.clientRegistration.registrationId
val attributes = oAuth2User.attributes

val oAuthUserInfo =
when (provider) {
"google" -> parseGoogle(attributes)
else -> throw IllegalArgumentException("지원하지 않는 소셜 로그인입니다.")
}

val user =
userRepository.findByOauthProviderAndOauthId(provider, oAuthUserInfo.oauthId)
?: userRepository.save(
User(
oauthProvider = provider,
oauthId = oAuthUserInfo.oauthId,
email = oAuthUserInfo.email,
nickname = oAuthUserInfo.nickname,
profileImageUrl = oAuthUserInfo.profileImageUrl,
role = UserRole.PENDING,
),
)

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

return CustomOAuth2User(
id = user.id!!,
email = user.email,
authorities = authorities,
attributes = attributes,
)
}

private fun parseGoogle(attributes: Map<String, Any>): OAuthUserInfo {
return OAuthUserInfo(
oauthId = attributes["sub"] as String,
email = attributes["email"] as String,
nickname = attributes["name"] as String,
profileImageUrl = attributes["picture"] as String?,
)
}
}

data class OAuthUserInfo(
val oauthId: String,
val email: String,
val nickname: String,
val profileImageUrl: String?,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.back.koreaTravelGuide.security

import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter

@Component
class JwtAuthenticationFilter(
private val jwtTokenProvider: JwtTokenProvider,
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
val token = resolveToken(request)

if (token != null && jwtTokenProvider.validateToken(token)) {
val authentication = jwtTokenProvider.getAuthentication(token)
SecurityContextHolder.getContext().authentication = authentication
}

filterChain.doFilter(request, response)
}

private fun resolveToken(request: HttpServletRequest): String? {
val bearerToken = request.getHeader("Authorization")
return if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
bearerToken.substring(7)
} else {
null
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package com.back.koreaTravelGuide.security

import com.back.koreaTravelGuide.domain.user.enums.UserRole
import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.stereotype.Component
import java.util.Base64
import java.util.Date
import javax.crypto.SecretKey

@Component
class JwtTokenProvider(
@Value("\${jwt.secret-key}") private val secretKey: String,
@Value("\${jwt.access-token-expiration-minutes}") private val accessTokenExpirationMinutes: Long,
) {
private val key: SecretKey by lazy {
Keys.hmacShaKeyFor(Base64.getEncoder().encode(secretKey.toByteArray()))
}

fun createAccessToken(
userId: Long,
role: UserRole,
): String {
val now = Date()
val expiryDate = Date(now.time + accessTokenExpirationMinutes * 60 * 1000)

return Jwts.builder()
.subject(userId.toString())
.claim("role", "ROLE_${role.name}")
.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()
.subject(userId.toString())
.issuedAt(now)
.expiration(expiryDate)
.signWith(key)
.compact()
}

fun validateToken(token: String): Boolean {
try {
getClaimsFromToken(token)
return true
} catch (e: Exception) {
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)
}

private fun getClaimsFromToken(token: String): Claims {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.payload
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.back.koreaTravelGuide.common.config

import com.back.koreaTravelGuide.security.CustomOAuth2LoginSuccessHandler
import com.back.koreaTravelGuide.security.CustomOAuth2UserService
import com.back.koreaTravelGuide.security.JwtAuthenticationFilter
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.invoke
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter

@Configuration
class SecurityConfig(
private val customOAuth2UserService: CustomOAuth2UserService,
private val customOAuth2LoginSuccessHandler: CustomOAuth2LoginSuccessHandler,
private val jwtAuthenticationFilter: JwtAuthenticationFilter,
) {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
// 기본 보안 기능
csrf { disable() }
formLogin { disable() }
httpBasic { disable() }
logout { disable() }

sessionManagement {
sessionCreationPolicy = SessionCreationPolicy.STATELESS
}

oauth2Login {
userInfoEndpoint {
userService = customOAuth2UserService
}
authenticationSuccessHandler = customOAuth2LoginSuccessHandler
}

authorizeHttpRequests {
authorize("/api/auth/**", permitAll)
authorize("/swagger-ui/**", "/v3/api-docs/**", permitAll)
authorize("/h2-console/**", permitAll)
authorize("/favicon.ico", permitAll)
authorize(anyRequest, authenticated)
}
addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
}

return http.build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ enum class UserRole {
USER,
ADMIN,
GUIDE,

// 신규 사용자
PENDING,
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,11 @@ import org.springframework.stereotype.Repository
@Repository
interface UserRepository : JpaRepository<User, Long> {
fun findByRole(role: UserRole): List<User>

fun findByOauthProviderAndOauthId(
oauthProvider: String,
oauthId: String,
): User?

fun findByEmail(email: String): User?
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import com.back.koreaTravelGuide.domain.user.repository.UserRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Transactional
@Service
@Transactional
class GuideService(
private val userRepository: UserRepository,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import com.back.koreaTravelGuide.domain.user.repository.UserRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Transactional
@Service
@Transactional
class UserService(
private val userRepository: UserRepository,
) {
Expand Down