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
16 changes: 15 additions & 1 deletion .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ env:
DB_NAME: mydatabase
DB_USER: ${{ secrets.DB_USER }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}

jobs:
build-and-push:
Expand All @@ -23,6 +25,15 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4

- name: Generate Dynamic JWT Secret
run: |
# Generate a 64-byte hex string
DYNAMIC_JWT=$(openssl rand -hex 64)
# Mask it so it doesn't show up in GitHub logs
echo "::add-mask::$DYNAMIC_JWT"
# Save it to the environment for subsequent steps
echo "JWT_SECRET=$DYNAMIC_JWT" >> $GITHUB_ENV

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

Expand Down Expand Up @@ -63,7 +74,7 @@ jobs:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_SSH_KEY }}
envs: REGISTRY,IMAGE_NAME,DB_NAME,DB_USER,DB_PASSWORD,GITHUB_ACTOR
envs: REGISTRY,IMAGE_NAME,DB_NAME,DB_USER,DB_PASSWORD,GITHUB_ACTOR,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,JWT_SECRET
script: |
# Define the image string inside the script for safety
IMAGE="${REGISTRY}/${IMAGE_NAME}:latest"
Expand All @@ -87,6 +98,9 @@ jobs:
-e SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/$DB_NAME \
-e DB_USER="$DB_USER" \
-e DB_PASSWORD="$DB_PASSWORD" \
-e GOOGLE_CLIENT_ID="$GOOGLE_CLIENT_ID" \
-e GOOGLE_CLIENT_SECRET="$GOOGLE_CLIENT_SECRET" \
-e JWT_SECRET="$JWT_SECRET" \
$IMAGE

echo "== PRUNE =="
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
branches: [ "main" ]

jobs:
build:
lint-and-test:

runs-on: ubuntu-latest

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ out/

### Kotlin ###
.kotlin
/.env
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ RUN ./gradlew bootJar
# 5. 포트 노출 (컨테이너가 8080 포트를 사용함을 명시)
EXPOSE 8080
# 6. 실행 명령어 (컨테이너 시작 시 실행될 명령어)
ENTRYPOINT exec java $JAVA_OPTS -jar build/libs/team2-server-0.0.1-SNAPSHOT.jar
ENTRYPOINT exec java $JAVA_OPTS -jar build/libs/team2-server-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,25 @@
# 23-5-team2-server
와플스튜디오 23.5기 2조 server
와플스튜디오 23.5기 2조 server

# env

루트 경로에 다음의 `.env` 파일을 작성합니다.

```env
GOOGLE_CLIENT_ID=YOUR-GOOGLE-CLIENT-ID
GOOGLE_CLIENT_SECRET=YOUR-GOOGLE-CLIENT-SECRET
```

| Key Name | Description |
|------------------------|--------------------------|
| `GOOGLE_CLIENT_ID` | 구글 OAuth2 Client ID |
| `GOOGLE_CLIENT_SECRET` | 구글 OAuth2 Client Secret |

# Auth

- AUTH-TOKEN 쿠키를 사용합니다.

## Google OAuth2

- `/oauth2/authorization/google` 로 GET 요청을 보내면 구글 로그인 과정이 진행됩니다.
- 로그인 성공 / 실패 모두 프론트엔드 루트 경로로 리다이렉트됩니다.
8 changes: 7 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,21 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
implementation("io.github.cdimascio:dotenv-kotlin:6.4.2")
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("com.mysql:mysql-connector-j")
implementation("org.flywaydb:flyway-mysql")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("tools.jackson.module:jackson-module-kotlin")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0")
implementation("org.jsoup:jsoup:1.17.2")
compileOnly("org.projectlombok:lombok")
developmentOnly("org.springframework.boot:spring-boot-docker-compose")
runtimeOnly("com.mysql:mysql-connector-j")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
annotationProcessor("org.projectlombok:lombok")
testImplementation("org.springframework.boot:spring-boot-starter-data-jdbc-test")
testImplementation("org.springframework.boot:spring-boot-starter-flyway-test")
Expand Down
15 changes: 15 additions & 0 deletions src/main/kotlin/com/wafflestudio/team2server/DomainException.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.wafflestudio.team2server

import org.springframework.http.HttpStatus
import org.springframework.http.HttpStatusCode

open class DomainException(
// client 와 약속된 Application Error 에 대한 코드 필요 시 Enum 으로 관리하자.
val errorCode: Int,
// HTTP Status Code, 비어있다면 500 이다.
val httpErrorCode: HttpStatusCode = HttpStatus.INTERNAL_SERVER_ERROR,
val msg: String,
cause: Throwable? = null,
) : RuntimeException(msg, cause) {
override fun toString(): String = "DomainException(msg='$msg', errorCode=$errorCode, httpErrorCode=$httpErrorCode)"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.wafflestudio.team2server

import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler

@ControllerAdvice
class GlobalControllerExceptionHandler {
@ExceptionHandler(DomainException::class)
fun handle(exception: DomainException): ResponseEntity<Map<String, Any>> =
ResponseEntity
.status(exception.httpErrorCode)
.body(mapOf("error" to exception.msg, "errorCode" to exception.errorCode))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.wafflestudio.team2server.config

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class JacksonConfig {
@Bean
fun objectMapper(): ObjectMapper = ObjectMapper().registerKotlinModule()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.wafflestudio.team2server.config

import com.wafflestudio.team2server.user.OAuth2SuccessHandler
import com.wafflestudio.team2server.user.service.GoogleOAuth2UserService
import org.springframework.beans.factory.annotation.Value
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.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.web.SecurityFilterChain

@EnableWebSecurity
@Configuration
class SecurityConfig(
private val googleOAuth2UserService: GoogleOAuth2UserService,
private val oAuth2SuccessHandler: OAuth2SuccessHandler,
@Value("\${app.frontend.url}") private val frontendUrl: String,
) {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
// it does not use authorizeHttpRequests
// requiring authorization is handled at the controller level
// when use @LoggedInUser, if there is no authorization, UserArgumentResolver will return 401.
http
.csrf { it.disable() }
.httpBasic { it.disable() }
.formLogin { it.disable() }
.oauth2Login { oauth2 ->
oauth2
.userInfoEndpoint { it.userService(googleOAuth2UserService) }
.successHandler(oAuth2SuccessHandler)
.failureUrl(frontendUrl)
}
return http.build()
}

@Bean
fun bcryptPasswordEncoder(): BCryptPasswordEncoder = BCryptPasswordEncoder()
}
28 changes: 28 additions & 0 deletions src/main/kotlin/com/wafflestudio/team2server/config/WebConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.wafflestudio.team2server.config

import com.wafflestudio.team2server.user.UserArgumentResolver
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Configuration
import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer

@Configuration
class WebConfig(
private val userArgumentResolver: UserArgumentResolver,
@Value("\${app.frontend.url}") private val frontendUrl: String,
) : WebMvcConfigurer {
override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) {
resolvers.add(userArgumentResolver)
}

override fun addCorsMappings(registry: CorsRegistry) {
registry
.addMapping("/**")
.allowedOrigins(frontendUrl)
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.wafflestudio.team2server.user

import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter

@Component
class JwtAuthenticationFilter(
private val jwtProvider: JwtProvider,
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
val token = resolveToken(request)
if (token != null && jwtProvider.validateToken(token)) {
request.setAttribute("userId", jwtProvider.getUserId(token))
}
filterChain.doFilter(request, response)
}

private fun resolveToken(request: HttpServletRequest): String? = request.cookies?.find { it.name == "AUTH-TOKEN" }?.value
}
78 changes: 78 additions & 0 deletions src/main/kotlin/com/wafflestudio/team2server/user/JwtProvider.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package com.wafflestudio.team2server.user

import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import io.jsonwebtoken.security.Keys
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.ResponseCookie
import org.springframework.stereotype.Component
import java.util.Date

@Component
class JwtProvider(
@Value("\${jwt.secret}")
private val secretKey: String,
@Value("\${jwt.expiration-in-ms}")
private val expirationInMs: Long,
@Value("\${jwt.secure}") private val secure: Boolean,
) {
private val key = Keys.hmacShaKeyFor(secretKey.toByteArray())

fun createToken(userId: Long): String {
val now = Date()
val validity = Date(now.time + expirationInMs)

return Jwts
.builder()
.setSubject(userId.toString())
.setIssuedAt(now)
.setExpiration(validity)
.signWith(key, SignatureAlgorithm.HS256)
.compact()
}

fun getUserId(token: String): Long =
Jwts
.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.body
.subject
.toLong()

fun validateToken(token: String): Boolean {
try {
Jwts
.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
return true
} catch (_: Exception) {
// do nothing
}
return false
}

fun getExpiration(token: String): Long {
val claims =
Jwts
.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.body
return claims.expiration.time
}

fun createJwtCookie(token: String): ResponseCookie =
ResponseCookie
.from("AUTH-TOKEN", token)
.httpOnly(true) // Prevents JS access (XSS protection)
.path("/") // Available for all routes
.maxAge((getExpiration(token) - System.currentTimeMillis()) / 1000)
.sameSite("Lax")
.secure(secure)
.build()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.wafflestudio.team2server.user

@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
annotation class LoggedInUser
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.wafflestudio.team2server.user

import com.wafflestudio.team2server.user.repository.UserRepository
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpHeaders
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

@Component
class OAuth2SuccessHandler(
private val userRepository: UserRepository,
private val jwtProvider: JwtProvider,
@Value("\${app.frontend.url}") private val frontendUrl: String,
) : SimpleUrlAuthenticationSuccessHandler() {
override fun onAuthenticationSuccess(
request: HttpServletRequest,
response: HttpServletResponse,
authentication: Authentication,
) {
val oAuth2User = authentication.principal as OAuth2User
val email =
oAuth2User.getAttribute<String?>(
"email",
) ?: throw IllegalStateException("OAuth2 provider did not supply a non-null 'email' attribute")
val user = userRepository.findByOauthId(email) ?: throw IllegalStateException("User not found for OAuth id (email=$email)")

val token = jwtProvider.createToken(user.id!!)
val jwtCookie = jwtProvider.createJwtCookie(token)
response.addHeader(HttpHeaders.SET_COOKIE, jwtCookie.toString())

redirectStrategy.sendRedirect(request, response, frontendUrl)
}
}
Loading