diff --git a/build.gradle.kts b/build.gradle.kts index 86e8ff2..81c888a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 대화 기록 저장) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/common/config/SecurityConfig.kt b/src/main/kotlin/com/back/koreaTravelGuide/common/config/SecurityConfig.kt deleted file mode 100644 index 4b0e038..0000000 --- a/src/main/kotlin/com/back/koreaTravelGuide/common/config/SecurityConfig.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.back.koreaTravelGuide.common.config - -// TODO: 보안 설정 - 개발용 전체 허용 설정 (운영 시 수정 필요) -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.configuration.EnableWebSecurity -import org.springframework.security.web.SecurityFilterChain - -@Configuration -@EnableWebSecurity -class SecurityConfig { - @Bean - fun filterChain(http: HttpSecurity): SecurityFilterChain { - http - .authorizeHttpRequests { auth -> - auth.anyRequest().permitAll() - } - .csrf { csrf -> csrf.disable() } - return http.build() - } -} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2LoginSuccessHandler.kt b/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2LoginSuccessHandler.kt new file mode 100644 index 0000000..2e78bf9 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2LoginSuccessHandler.kt @@ -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) + } + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2User.kt b/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2User.kt new file mode 100644 index 0000000..7a59064 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2User.kt @@ -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, + attributes: Map, +) : DefaultOAuth2User(authorities, attributes, "email") diff --git a/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2UserService.kt new file mode 100644 index 0000000..5a61b2c --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/common/security/CustomOAuth2UserService.kt @@ -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): 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?, +) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/common/security/JwtAuthenticationFilter.kt b/src/main/kotlin/com/back/koreaTravelGuide/common/security/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..95cff49 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/common/security/JwtAuthenticationFilter.kt @@ -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 + } + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/common/security/JwtTokenProvider.kt b/src/main/kotlin/com/back/koreaTravelGuide/common/security/JwtTokenProvider.kt new file mode 100644 index 0000000..7a580b1 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/common/security/JwtTokenProvider.kt @@ -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 + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/common/security/SecurityConfig.kt b/src/main/kotlin/com/back/koreaTravelGuide/common/security/SecurityConfig.kt new file mode 100644 index 0000000..d1c5914 --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/common/security/SecurityConfig.kt @@ -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() + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/user/enums/UserRole.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/user/enums/UserRole.kt index a77bba5..2d55c68 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/user/enums/UserRole.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/user/enums/UserRole.kt @@ -4,4 +4,7 @@ enum class UserRole { USER, ADMIN, GUIDE, + + // 신규 사용자 + PENDING, } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/user/repository/UserRepository.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/user/repository/UserRepository.kt index 35e6d6e..6c57eba 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/user/repository/UserRepository.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/user/repository/UserRepository.kt @@ -8,4 +8,11 @@ import org.springframework.stereotype.Repository @Repository interface UserRepository : JpaRepository { fun findByRole(role: UserRole): List + + fun findByOauthProviderAndOauthId( + oauthProvider: String, + oauthId: String, + ): User? + + fun findByEmail(email: String): User? } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/user/service/GuideService.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/user/service/GuideService.kt index 4f254d7..9a729c7 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/user/service/GuideService.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/user/service/GuideService.kt @@ -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, ) { 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 027eb45..f7e68e2 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 @@ -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, ) {