Skip to content

Commit 11258a3

Browse files
committed
feat(be): AI 챗 컨트롤러 및 REST API 엔드포인트 구현
- AiChatController 6개 엔드포인트 완성 * GET /sessions (세션 목록) * POST /sessions (세션 생성) * DELETE /sessions/{id} (세션 삭제) * GET /sessions/{id}/messages (메시지 조회) * POST /sessions/{id}/messages (메시지 전송) * PATCH /sessions/{id}/title (제목 수정) - Service Layer Entity 반환으로 리팩토링 - Controller Layer DTO 변환 책임 분리 - getSessionWithOwnershipCheck 헬퍼 메서드 적용 - ChatController → AiChatController 리네임 - DTO 구조 정리 및 신규 DTO 추가
2 parents 82a3bd7 + 5eb9ba7 commit 11258a3

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+1088
-130
lines changed

build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ dependencies {
3434
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
3535
implementation("org.jetbrains.kotlin:kotlin-reflect")
3636

37+
// jwt
38+
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
39+
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
40+
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")
41+
3742
// Spring AI - 1.1.0-M2 (최신 버전) - 새로운 artifact명
3843
implementation("org.springframework.ai:spring-ai-starter-model-openai:1.1.0-M2")
3944
// Spring AI - JDBC 채팅 메모리 저장소 (PostgreSQL 대화 기록 저장)

setup-git-hooks.sh

100644100755
File mode changed.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.back.koreaTravelGuide.application
1+
package com.back.koreaTravelGuide
22

33
// TODO: 메인 애플리케이션 클래스 - 스프링 부트 시작점 및 환경변수 로딩
44
import io.github.cdimascio.dotenv.dotenv

src/main/kotlin/com/back/koreaTravelGuide/common/config/SecurityConfig.kt

Lines changed: 0 additions & 22 deletions
This file was deleted.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.back.koreaTravelGuide.security
2+
3+
import com.back.koreaTravelGuide.domain.user.enums.UserRole
4+
import com.back.koreaTravelGuide.domain.user.repository.UserRepository
5+
import jakarta.servlet.http.HttpServletRequest
6+
import jakarta.servlet.http.HttpServletResponse
7+
import org.springframework.security.core.Authentication
8+
import org.springframework.security.oauth2.core.user.OAuth2User
9+
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler
10+
import org.springframework.stereotype.Component
11+
import org.springframework.transaction.annotation.Transactional
12+
13+
@Component
14+
class CustomOAuth2LoginSuccessHandler(
15+
private val jwtTokenProvider: JwtTokenProvider,
16+
private val userRepository: UserRepository,
17+
) : SimpleUrlAuthenticationSuccessHandler() {
18+
@Transactional
19+
override fun onAuthenticationSuccess(
20+
request: HttpServletRequest,
21+
response: HttpServletResponse,
22+
authentication: Authentication,
23+
) {
24+
val oAuth2User = authentication.principal as OAuth2User
25+
val email = oAuth2User.attributes["email"] as String
26+
27+
val user = userRepository.findByEmail(email)!!
28+
29+
if (user.role == UserRole.PENDING) {
30+
val registerToken = jwtTokenProvider.createRegisterToken(user.id!!)
31+
val targetUrl = "http://localhost:3000/signup/role?token=$registerToken"
32+
redirectStrategy.sendRedirect(request, response, targetUrl)
33+
} else {
34+
val accessToken = jwtTokenProvider.createAccessToken(user.id!!, user.role)
35+
val targetUrl = "http://localhost:3000/oauth/callback?accessToken=$accessToken"
36+
redirectStrategy.sendRedirect(request, response, targetUrl)
37+
}
38+
}
39+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.back.koreaTravelGuide.security
2+
3+
import org.springframework.security.core.GrantedAuthority
4+
import org.springframework.security.oauth2.core.user.DefaultOAuth2User
5+
6+
class CustomOAuth2User(
7+
val id: Long,
8+
val email: String,
9+
authorities: Collection<GrantedAuthority>,
10+
attributes: Map<String, Any>,
11+
) : DefaultOAuth2User(authorities, attributes, "email")
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.back.koreaTravelGuide.security
2+
3+
import com.back.koreaTravelGuide.domain.user.entity.User
4+
import com.back.koreaTravelGuide.domain.user.enums.UserRole
5+
import com.back.koreaTravelGuide.domain.user.repository.UserRepository
6+
import org.springframework.security.core.authority.SimpleGrantedAuthority
7+
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService
8+
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest
9+
import org.springframework.security.oauth2.core.user.OAuth2User
10+
import org.springframework.stereotype.Service
11+
import org.springframework.transaction.annotation.Transactional
12+
13+
@Service
14+
class CustomOAuth2UserService(
15+
private val userRepository: UserRepository,
16+
) : DefaultOAuth2UserService() {
17+
@Transactional
18+
override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User {
19+
val oAuth2User = super.loadUser(userRequest)
20+
val provider = userRequest.clientRegistration.registrationId
21+
val attributes = oAuth2User.attributes
22+
23+
val oAuthUserInfo =
24+
when (provider) {
25+
"google" -> parseGoogle(attributes)
26+
else -> throw IllegalArgumentException("지원하지 않는 소셜 로그인입니다.")
27+
}
28+
29+
val user =
30+
userRepository.findByOauthProviderAndOauthId(provider, oAuthUserInfo.oauthId)
31+
?: userRepository.save(
32+
User(
33+
oauthProvider = provider,
34+
oauthId = oAuthUserInfo.oauthId,
35+
email = oAuthUserInfo.email,
36+
nickname = oAuthUserInfo.nickname,
37+
profileImageUrl = oAuthUserInfo.profileImageUrl,
38+
role = UserRole.PENDING,
39+
),
40+
)
41+
42+
val authorities = listOf(SimpleGrantedAuthority("ROLE_${user.role.name}"))
43+
44+
return CustomOAuth2User(
45+
id = user.id!!,
46+
email = user.email,
47+
authorities = authorities,
48+
attributes = attributes,
49+
)
50+
}
51+
52+
private fun parseGoogle(attributes: Map<String, Any>): OAuthUserInfo {
53+
return OAuthUserInfo(
54+
oauthId = attributes["sub"] as String,
55+
email = attributes["email"] as String,
56+
nickname = attributes["name"] as String,
57+
profileImageUrl = attributes["picture"] as String?,
58+
)
59+
}
60+
}
61+
62+
data class OAuthUserInfo(
63+
val oauthId: String,
64+
val email: String,
65+
val nickname: String,
66+
val profileImageUrl: String?,
67+
)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.back.koreaTravelGuide.security
2+
3+
import jakarta.servlet.FilterChain
4+
import jakarta.servlet.http.HttpServletRequest
5+
import jakarta.servlet.http.HttpServletResponse
6+
import org.springframework.security.core.context.SecurityContextHolder
7+
import org.springframework.stereotype.Component
8+
import org.springframework.web.filter.OncePerRequestFilter
9+
10+
@Component
11+
class JwtAuthenticationFilter(
12+
private val jwtTokenProvider: JwtTokenProvider,
13+
) : OncePerRequestFilter() {
14+
override fun doFilterInternal(
15+
request: HttpServletRequest,
16+
response: HttpServletResponse,
17+
filterChain: FilterChain,
18+
) {
19+
val token = resolveToken(request)
20+
21+
if (token != null && jwtTokenProvider.validateToken(token)) {
22+
val authentication = jwtTokenProvider.getAuthentication(token)
23+
SecurityContextHolder.getContext().authentication = authentication
24+
}
25+
26+
filterChain.doFilter(request, response)
27+
}
28+
29+
private fun resolveToken(request: HttpServletRequest): String? {
30+
val bearerToken = request.getHeader("Authorization")
31+
return if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
32+
bearerToken.substring(7)
33+
} else {
34+
null
35+
}
36+
}
37+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package com.back.koreaTravelGuide.security
2+
3+
import com.back.koreaTravelGuide.domain.user.enums.UserRole
4+
import io.jsonwebtoken.Claims
5+
import io.jsonwebtoken.Jwts
6+
import io.jsonwebtoken.security.Keys
7+
import org.springframework.beans.factory.annotation.Value
8+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
9+
import org.springframework.security.core.Authentication
10+
import org.springframework.security.core.authority.SimpleGrantedAuthority
11+
import org.springframework.stereotype.Component
12+
import java.util.Base64
13+
import java.util.Date
14+
import javax.crypto.SecretKey
15+
16+
@Component
17+
class JwtTokenProvider(
18+
@Value("\${jwt.secret-key}") private val secretKey: String,
19+
@Value("\${jwt.access-token-expiration-minutes}") private val accessTokenExpirationMinutes: Long,
20+
) {
21+
private val key: SecretKey by lazy {
22+
Keys.hmacShaKeyFor(Base64.getEncoder().encode(secretKey.toByteArray()))
23+
}
24+
25+
fun createAccessToken(
26+
userId: Long,
27+
role: UserRole,
28+
): String {
29+
val now = Date()
30+
val expiryDate = Date(now.time + accessTokenExpirationMinutes * 60 * 1000)
31+
32+
return Jwts.builder()
33+
.subject(userId.toString())
34+
.claim("role", "ROLE_${role.name}")
35+
.issuedAt(now)
36+
.expiration(expiryDate)
37+
.signWith(key)
38+
.compact()
39+
}
40+
41+
fun createRegisterToken(userId: Long): String {
42+
val now = Date()
43+
val expiryDate = Date(now.time + 5 * 60 * 1000)
44+
45+
return Jwts.builder()
46+
.subject(userId.toString())
47+
.issuedAt(now)
48+
.expiration(expiryDate)
49+
.signWith(key)
50+
.compact()
51+
}
52+
53+
fun validateToken(token: String): Boolean {
54+
try {
55+
getClaimsFromToken(token)
56+
return true
57+
} catch (e: Exception) {
58+
return false
59+
}
60+
}
61+
62+
fun getAuthentication(token: String): Authentication {
63+
val claims = getClaimsFromToken(token)
64+
val userId = claims.subject.toLong()
65+
val role = claims["role"] as? String ?: "ROLE_PENDING"
66+
val authorities = listOf(SimpleGrantedAuthority(role))
67+
68+
return UsernamePasswordAuthenticationToken(userId, null, authorities)
69+
}
70+
71+
private fun getClaimsFromToken(token: String): Claims {
72+
return Jwts.parser()
73+
.verifyWith(key)
74+
.build()
75+
.parseSignedClaims(token)
76+
.payload
77+
}
78+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.back.koreaTravelGuide.common.config
2+
3+
import com.back.koreaTravelGuide.security.CustomOAuth2LoginSuccessHandler
4+
import com.back.koreaTravelGuide.security.CustomOAuth2UserService
5+
import com.back.koreaTravelGuide.security.JwtAuthenticationFilter
6+
import org.springframework.context.annotation.Bean
7+
import org.springframework.context.annotation.Configuration
8+
import org.springframework.security.config.annotation.web.builders.HttpSecurity
9+
import org.springframework.security.config.annotation.web.invoke
10+
import org.springframework.security.config.http.SessionCreationPolicy
11+
import org.springframework.security.web.SecurityFilterChain
12+
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
13+
14+
@Configuration
15+
class SecurityConfig(
16+
private val customOAuth2UserService: CustomOAuth2UserService,
17+
private val customOAuth2LoginSuccessHandler: CustomOAuth2LoginSuccessHandler,
18+
private val jwtAuthenticationFilter: JwtAuthenticationFilter,
19+
) {
20+
@Bean
21+
fun filterChain(http: HttpSecurity): SecurityFilterChain {
22+
http {
23+
// 기본 보안 기능
24+
csrf { disable() }
25+
formLogin { disable() }
26+
httpBasic { disable() }
27+
logout { disable() }
28+
29+
sessionManagement {
30+
sessionCreationPolicy = SessionCreationPolicy.STATELESS
31+
}
32+
33+
oauth2Login {
34+
userInfoEndpoint {
35+
userService = customOAuth2UserService
36+
}
37+
authenticationSuccessHandler = customOAuth2LoginSuccessHandler
38+
}
39+
40+
authorizeHttpRequests {
41+
authorize("/api/auth/**", permitAll)
42+
authorize("/swagger-ui/**", "/v3/api-docs/**", permitAll)
43+
authorize("/h2-console/**", permitAll)
44+
authorize("/favicon.ico", permitAll)
45+
authorize(anyRequest, authenticated)
46+
}
47+
addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
48+
}
49+
50+
return http.build()
51+
}
52+
}

0 commit comments

Comments
 (0)