add project

This commit is contained in:
ParkWonYeop
2025-12-26 10:07:56 +09:00
commit aac88be0c0
22 changed files with 849 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
package me.wypark.blogbackend
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class BlogBackendApplication
fun main(args: Array<String>) {
runApplication<BlogBackendApplication>(*args)
}

View File

@@ -0,0 +1,8 @@
package me.wypark.blogbackend.api.dto
data class TokenDto(
val grantType: String = "Bearer",
val accessToken: String,
val refreshToken: String,
val accessTokenExpiresIn: Long
)

View File

@@ -0,0 +1,24 @@
package me.wypark.blogbackend.core.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
import org.springframework.web.filter.CorsFilter
@Configuration
class CorsConfig {
@Bean
fun corsFilter(): CorsFilter {
val source = UrlBasedCorsConfigurationSource()
val config = CorsConfiguration()
config.allowCredentials = true // 쿠키/토큰 허용
config.addAllowedOriginPattern("*") // 개발용 (배포 시 프론트 도메인으로 변경 추천)
config.addAllowedHeader("*")
config.addAllowedMethod("*") // GET, POST, PUT, DELETE 등 모두 허용
source.registerCorsConfiguration("/api/**", config)
return CorsFilter(source)
}
}

View File

@@ -0,0 +1,8 @@
package me.wypark.blogbackend.core.config
import org.springframework.context.annotation.Configuration
import org.springframework.data.jpa.repository.config.EnableJpaAuditing
@Configuration
@EnableJpaAuditing // 엔티티의 생성일/수정일 자동 주입 활성화
class JpaConfig

View File

@@ -0,0 +1,14 @@
package me.wypark.blogbackend.core.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
@Configuration
class PasswordConfig {
@Bean
fun passwordEncoder(): PasswordEncoder {
return BCryptPasswordEncoder()
}
}

View File

@@ -0,0 +1,43 @@
package me.wypark.blogbackend.core.config
import me.wypark.blogbackend.core.config.jwt.JwtAuthenticationFilter
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
import org.springframework.web.filter.CorsFilter
@Configuration
@EnableWebSecurity
class SecurityConfig(
private val corsFilter: CorsFilter,
private val jwtAuthenticationFilter: JwtAuthenticationFilter // 주입 추가
) {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
.httpBasic { it.disable() }
.formLogin { it.disable() }
.addFilter(corsFilter)
.sessionManagement {
it.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
}
.authorizeHttpRequests { auth ->
auth.requestMatchers("/api/auth/**").permitAll()
auth.requestMatchers(HttpMethod.GET, "/api/posts/**", "/api/categories/**", "/api/tags/**").permitAll()
auth.requestMatchers(HttpMethod.POST, "/api/comments/**").permitAll() // 비회원 댓글 허용
auth.requestMatchers("/api/admin/**").hasRole("ADMIN")
auth.anyRequest().authenticated()
}
// 필터 등록: UsernamePasswordAuthenticationFilter 앞에 JwtFilter를 실행
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
return http.build()
}
}

View File

@@ -0,0 +1,48 @@
package me.wypark.blogbackend.core.config.jwt
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.util.StringUtils
import org.springframework.web.filter.OncePerRequestFilter
import org.springframework.stereotype.Component
@Component
class JwtAuthenticationFilter(
private val jwtProvider: JwtProvider
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
// 1. Request Header에서 토큰 추출
val token = resolveToken(request)
// 2. 토큰 유효성 검사
// 토큰이 존재하고 유효하다면 인증 정보를 가져와 Context에 저장
if (StringUtils.hasText(token) && jwtProvider.validateToken(token!!)) {
val authentication = jwtProvider.getAuthentication(token)
SecurityContextHolder.getContext().authentication = authentication
}
// 3. 다음 필터로 진행
filterChain.doFilter(request, response)
}
// Request Header에서 토큰 정보 추출
private fun resolveToken(request: HttpServletRequest): String? {
val bearerToken = request.getHeader(AUTHORIZATION_HEADER)
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7) // "Bearer " 이후의 문자열만 반환
}
return null
}
companion object {
const val AUTHORIZATION_HEADER = "Authorization"
const val BEARER_PREFIX = "Bearer "
}
}

View File

@@ -0,0 +1,96 @@
package me.wypark.blogbackend.core.config.jwt
import io.jsonwebtoken.*
import io.jsonwebtoken.io.Decoders
import io.jsonwebtoken.security.Keys
import me.wypark.blogbackend.api.dto.TokenDto
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.User
import org.springframework.stereotype.Component
import java.util.*
import javax.crypto.SecretKey
@Component
class JwtProvider(
@Value("\${jwt.secret}") secretKey: String,
@Value("\${jwt.access-token-validity}") private val accessTokenValidity: Long,
@Value("\${jwt.refresh-token-validity}") private val refreshTokenValidity: Long
) {
private val key: SecretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey))
// 1. 토큰 생성 (Access + Refresh 동시 발급)
fun generateTokenDto(authentication: Authentication): TokenDto {
val authorities = authentication.authorities.joinToString(",") { it.authority }
val now = Date().time
// Access Token 생성
val accessTokenExpiresIn = Date(now + accessTokenValidity)
val accessToken = Jwts.builder()
.subject(authentication.name) // email 또는 id
.claim("auth", authorities) // 권한 정보 (ROLE_USER 등)
.expiration(accessTokenExpiresIn)
.signWith(key)
.compact()
// Refresh Token 생성 (권한 정보 등은 제외하고 만료일만 설정)
val refreshToken = Jwts.builder()
.subject(authentication.name)
.expiration(Date(now + refreshTokenValidity))
.signWith(key)
.compact()
return TokenDto(
accessToken = accessToken,
refreshToken = refreshToken,
accessTokenExpiresIn = accessTokenExpiresIn.time
)
}
// 2. 토큰에서 인증 정보(Authentication) 추출
fun getAuthentication(accessToken: String): Authentication {
val claims = parseClaims(accessToken)
if (claims["auth"] == null) {
throw RuntimeException("권한 정보가 없는 토큰입니다.")
}
val authorities: Collection<GrantedAuthority> =
claims["auth"].toString()
.split(",")
.map { SimpleGrantedAuthority(it) }
val principal = User(claims.subject, "", authorities)
return UsernamePasswordAuthenticationToken(principal, "", authorities)
}
// 3. 토큰 검증 (만료 여부, 위변조 여부 확인)
fun validateToken(token: String): Boolean {
try {
Jwts.parser().verifyWith(key).build().parseSignedClaims(token)
return true
} catch (e: SecurityException) {
// log.info("잘못된 JWT 서명입니다.")
} catch (e: MalformedJwtException) {
// log.info("잘못된 JWT 서명입니다.")
} catch (e: ExpiredJwtException) {
// log.info("만료된 JWT 토큰입니다.")
} catch (e: UnsupportedJwtException) {
// log.info("지원되지 않는 JWT 토큰입니다.")
} catch (e: IllegalArgumentException) {
// log.info("JWT 토큰이 잘못되었습니다.")
}
return false
}
private fun parseClaims(accessToken: String): Claims {
return try {
Jwts.parser().verifyWith(key).build().parseSignedClaims(accessToken).payload
} catch (e: ExpiredJwtException) {
e.claims
}
}
}

View File

@@ -0,0 +1,33 @@
package me.wypark.blogbackend.domain.auth
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.stereotype.Repository
import java.util.concurrent.TimeUnit
@Repository
class RefreshTokenRepository(
private val redisTemplate: RedisTemplate<String, String>,
@Value("\${jwt.refresh-token-validity}") private val refreshTokenValidity: Long
) {
// 저장 (Key: Email, Value: RefreshToken)
// RTR 핵심: 사용자가 로그인을 새로 하거나 토큰을 재발급 받을 때마다 덮어씌움
fun save(email: String, refreshToken: String) {
redisTemplate.opsForValue().set(
"RT:$email",
refreshToken,
refreshTokenValidity,
TimeUnit.MILLISECONDS
)
}
// 조회
fun findByEmail(email: String): String? {
return redisTemplate.opsForValue().get("RT:$email")
}
// 삭제 (로그아웃 시)
fun delete(email: String) {
redisTemplate.delete("RT:$email")
}
}

View File

@@ -0,0 +1,19 @@
spring:
datasource:
url: jdbc:postgresql://db:5432/blog_db
username: wypark
password: your_password
jpa:
hibernate:
ddl-auto: update
properties:
hibernate:
format_sql: true
data:
redis:
host: redis
port: 6379
jwt:
secret: "v3ryS3cr3tK3yF0rMyB10gPr0j3ctM4k3ItL0ng3rTh4n32Byt3s!!" # 32바이트 이상 필수
access-token-validity: 1800000 # 30분 (ms)
refresh-token-validity: 604800000 # 7일 (ms)

View File

@@ -0,0 +1,5 @@
spring:
application:
name: blog-backend
profiles:
default: prod