diff --git a/CONFIG b/CONFIG index 6231ff2..ee2d674 160000 --- a/CONFIG +++ b/CONFIG @@ -1 +1 @@ -Subproject commit 6231ff2486f85ed1d670b3e17f23dbf0fd3acde7 +Subproject commit ee2d674052b453ee4cb9ba339f513ae222f72cdf diff --git a/build.gradle.kts b/build.gradle.kts index 94a4a9f..b25dc48 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,77 +5,82 @@ val imageTag = System.getenv("IMAGE_TAG") ?: "latest" val repoURL: String? = System.getenv("IMAGE_REPO_URL") plugins { - id("org.springframework.boot") version "3.2.3" - id("io.spring.dependency-management") version "1.1.4" - id("com.google.cloud.tools.jib") version "3.4.1" - kotlin("jvm") version "1.9.22" - kotlin("plugin.spring") version "1.9.22" - kotlin("plugin.jpa") version "1.9.22" - kotlin("plugin.allopen") version "1.9.22" + id("org.springframework.boot") version "3.2.3" + id("io.spring.dependency-management") version "1.1.4" + id("com.google.cloud.tools.jib") version "3.4.1" + kotlin("jvm") version "1.9.22" + kotlin("plugin.spring") version "1.9.22" + kotlin("plugin.jpa") version "1.9.22" + kotlin("plugin.allopen") version "1.9.22" } group = "com.vacgom" version = "0.0.1-SNAPSHOT" java { - sourceCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_17 } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation("org.springframework.boot:spring-boot-starter-data-jpa") - implementation("org.springframework.boot:spring-boot-starter-security") - implementation("org.springframework.boot:spring-boot-starter-web") - implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin") - implementation("org.flywaydb:flyway-core") - implementation("org.flywaydb:flyway-mysql") - implementation("org.jetbrains.kotlin:kotlin-reflect") - developmentOnly("org.springframework.boot:spring-boot-devtools") - runtimeOnly("com.mysql:mysql-connector-j") - testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("org.springframework.security:spring-security-test") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.flywaydb:flyway-core") + implementation("org.flywaydb:flyway-mysql") + implementation("org.jetbrains.kotlin:kotlin-reflect") + developmentOnly("org.springframework.boot:spring-boot-devtools") + runtimeOnly("com.mysql:mysql-connector-j") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") + + implementation("io.jsonwebtoken:jjwt-api:0.11.5") + implementation("io.jsonwebtoken:jjwt-gson:0.11.5") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5") + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") } tasks.withType { - kotlinOptions { - freeCompilerArgs += "-Xjsr305=strict" - jvmTarget = "17" - } + kotlinOptions { + freeCompilerArgs += "-Xjsr305=strict" + jvmTarget = "17" + } } tasks.withType { - useJUnitPlatform() + useJUnitPlatform() } tasks.processResources { - dependsOn("initConfig") + dependsOn("initConfig") } tasks.register("initConfig") { - from("./CONFIG") - include("*.yml") - into("./src/main/resources") + from("./CONFIG") + include("*.yml") + into("./src/main/resources") } jib { - from { - image = "amazoncorretto:17-alpine3.18" - } - to { - image = repoURL - tags = setOf(imageTag) - } - container { - jvmFlags = listOf( - "-Dspring.profiles.active=${activeProfile}", - "-Dserver.port=8080", - "-XX:+UseContainerSupport", - ) - ports = listOf("8080") - } + from { + image = "amazoncorretto:17-alpine3.18" + } + to { + image = repoURL + tags = setOf(imageTag) + } + container { + jvmFlags = listOf( + "-Dspring.profiles.active=${activeProfile}", + "-Dserver.port=8080", + "-XX:+UseContainerSupport", + ) + ports = listOf("8080") + } } diff --git a/src/main/kotlin/com/vacgom/backend/application/auth/AuthFactory.kt b/src/main/kotlin/com/vacgom/backend/application/auth/AuthFactory.kt new file mode 100644 index 0000000..e43a50d --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/application/auth/AuthFactory.kt @@ -0,0 +1,30 @@ +package com.vacgom.backend.application.auth + +import com.vacgom.backend.domain.auth.oauth.OauthConnector +import com.vacgom.backend.domain.auth.oauth.OauthUriGenerator +import com.vacgom.backend.domain.auth.oauth.constants.ProviderType +import com.vacgom.backend.global.exception.error.BusinessException +import com.vacgom.backend.global.security.exception.AuthError +import org.springframework.stereotype.Component + +@Component +class AuthFactory( + private val connectors: List, + private val uriProviders: List +) { + fun getAuthConnector(provider: String): OauthConnector { + val providerType = ProviderType.from(provider) + + return connectors.firstOrNull { + it.isSupported(providerType) + } ?: throw BusinessException(AuthError.UNSUPPORTED_PROVIDER) + } + + fun getAuthUriGenerator(provider: String): OauthUriGenerator { + val providerType = ProviderType.from(provider) + + return uriProviders.firstOrNull { + it.isSupported(providerType) + } ?: throw BusinessException(AuthError.UNSUPPORTED_PROVIDER) + } +} diff --git a/src/main/kotlin/com/vacgom/backend/application/auth/AuthService.kt b/src/main/kotlin/com/vacgom/backend/application/auth/AuthService.kt new file mode 100644 index 0000000..526530e --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/application/auth/AuthService.kt @@ -0,0 +1,55 @@ +package com.vacgom.backend.application.auth + +import com.vacgom.backend.application.auth.dto.LoginResponse +import com.vacgom.backend.application.auth.dto.MemberResponse +import com.vacgom.backend.application.auth.dto.TokenResponse +import com.vacgom.backend.domain.auth.constants.Role.ROLE_TEMP_USER +import com.vacgom.backend.domain.auth.oauth.constants.ProviderType +import com.vacgom.backend.domain.member.Member +import com.vacgom.backend.global.security.jwt.JwtService +import com.vacgom.backend.infrastructure.member.persistence.MemberRepository +import jakarta.transaction.Transactional +import org.springframework.http.HttpHeaders +import org.springframework.stereotype.Component +import java.net.URI + +@Component +class AuthService( + private val authFactory: AuthFactory, + private val jwtService: JwtService, + private val memberRepository: MemberRepository +) { + fun createRedirectHeaders(redirectUri: URI): HttpHeaders { + val headers = HttpHeaders() + headers.location = redirectUri + return headers + } + + fun getAuthorizationUri(provider: String): URI { + return authFactory.getAuthUriGenerator(provider).generate() + } + + @Transactional + fun login( + providerType: String, + code: String + ): LoginResponse { + val authConnector = authFactory.getAuthConnector(providerType) + val oauthToken = authConnector.fetchOauthToken(code) + val memberInfo = authConnector.fetchMemberInfo(oauthToken.accessToken) + val member = findOrCreateMember(memberInfo.id, ProviderType.from(providerType)) + + val memberResponse = MemberResponse(member.id!!, member.role) + val tokenResponse = TokenResponse(jwtService.createAccessToken(member)) + + return LoginResponse(memberResponse, tokenResponse) + } + + private fun findOrCreateMember( + kakaoProviderId: Long, + providerType: ProviderType + ): Member { + return memberRepository.findByProviderIdAndProviderType(kakaoProviderId, providerType) + ?: memberRepository.save(Member(kakaoProviderId, providerType, ROLE_TEMP_USER)) + } +} diff --git a/src/main/kotlin/com/vacgom/backend/application/auth/dto/LoginResponse.kt b/src/main/kotlin/com/vacgom/backend/application/auth/dto/LoginResponse.kt new file mode 100644 index 0000000..f1c3e1c --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/application/auth/dto/LoginResponse.kt @@ -0,0 +1,6 @@ +package com.vacgom.backend.application.auth.dto + +data class LoginResponse( + val member: MemberResponse, + val token: TokenResponse +) diff --git a/src/main/kotlin/com/vacgom/backend/application/auth/dto/MemberResponse.kt b/src/main/kotlin/com/vacgom/backend/application/auth/dto/MemberResponse.kt new file mode 100644 index 0000000..777344f --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/application/auth/dto/MemberResponse.kt @@ -0,0 +1,9 @@ +package com.vacgom.backend.application.auth.dto + +import com.vacgom.backend.domain.auth.constants.Role +import java.util.* + +data class MemberResponse( + val memberId: UUID, + val role: Role +) diff --git a/src/main/kotlin/com/vacgom/backend/application/auth/dto/OauthTokenResponse.kt b/src/main/kotlin/com/vacgom/backend/application/auth/dto/OauthTokenResponse.kt new file mode 100644 index 0000000..9a39430 --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/application/auth/dto/OauthTokenResponse.kt @@ -0,0 +1,7 @@ +package com.vacgom.backend.application.auth.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy::class) +data class OauthTokenResponse(val accessToken: String) diff --git a/src/main/kotlin/com/vacgom/backend/application/auth/dto/ResourceIdResponse.kt b/src/main/kotlin/com/vacgom/backend/application/auth/dto/ResourceIdResponse.kt new file mode 100644 index 0000000..dd023ee --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/application/auth/dto/ResourceIdResponse.kt @@ -0,0 +1,5 @@ +package com.vacgom.backend.application.auth.dto + +data class ResourceIdResponse( + var id: Long +) diff --git a/src/main/kotlin/com/vacgom/backend/application/auth/dto/TokenResponse.kt b/src/main/kotlin/com/vacgom/backend/application/auth/dto/TokenResponse.kt new file mode 100644 index 0000000..6951055 --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/application/auth/dto/TokenResponse.kt @@ -0,0 +1,6 @@ +package com.vacgom.backend.application.auth.dto + + +data class TokenResponse( + val accessToken: String +) diff --git a/src/main/kotlin/com/vacgom/backend/domain/auth/RefreshToken.kt b/src/main/kotlin/com/vacgom/backend/domain/auth/RefreshToken.kt new file mode 100644 index 0000000..9cb0c5c --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/domain/auth/RefreshToken.kt @@ -0,0 +1,24 @@ +package com.vacgom.backend.domain.auth + +import com.vacgom.backend.domain.member.Member +import com.vacgom.backend.global.auditing.BaseEntity +import jakarta.persistence.* +import java.time.LocalDateTime + + +@Entity +@Table(name = "t_refresh_token") +class RefreshToken( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member, + var token: String, + var userAgent: String, + var expiredAt: LocalDateTime +) : BaseEntity() { + @Id + @GeneratedValue + @Column(name = "rt_id") + private val id: Long? = null +} + diff --git a/src/main/kotlin/com/vacgom/backend/domain/auth/constants/Role.kt b/src/main/kotlin/com/vacgom/backend/domain/auth/constants/Role.kt new file mode 100644 index 0000000..47cd7a0 --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/domain/auth/constants/Role.kt @@ -0,0 +1,19 @@ +package com.vacgom.backend.domain.auth.constants + +enum class Role { + ROLE_GUEST, + ROLE_TEMP_USER, + ROLE_USER; + + fun isGuest(role: Role): Boolean { + return role == ROLE_GUEST + } + + fun isTempUser(role: Role): Boolean { + return role == ROLE_TEMP_USER + } + + fun isUser(role: Role): Boolean { + return role == ROLE_USER + } +} diff --git a/src/main/kotlin/com/vacgom/backend/domain/auth/oauth/OauthConnector.kt b/src/main/kotlin/com/vacgom/backend/domain/auth/oauth/OauthConnector.kt new file mode 100644 index 0000000..36022e1 --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/domain/auth/oauth/OauthConnector.kt @@ -0,0 +1,11 @@ +package com.vacgom.backend.domain.auth.oauth + +import com.vacgom.backend.application.auth.dto.OauthTokenResponse +import com.vacgom.backend.application.auth.dto.ResourceIdResponse +import com.vacgom.backend.domain.auth.oauth.constants.ProviderType + +interface OauthConnector { + fun isSupported(provider: ProviderType): Boolean + fun fetchOauthToken(code: String): OauthTokenResponse + fun fetchMemberInfo(accessToken: String): ResourceIdResponse +} diff --git a/src/main/kotlin/com/vacgom/backend/domain/auth/oauth/OauthUriGenerator.kt b/src/main/kotlin/com/vacgom/backend/domain/auth/oauth/OauthUriGenerator.kt new file mode 100644 index 0000000..df89c50 --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/domain/auth/oauth/OauthUriGenerator.kt @@ -0,0 +1,9 @@ +package com.vacgom.backend.domain.auth.oauth + +import com.vacgom.backend.domain.auth.oauth.constants.ProviderType +import java.net.URI + +interface OauthUriGenerator { + fun isSupported(provider: ProviderType): Boolean + fun generate(): URI +} diff --git a/src/main/kotlin/com/vacgom/backend/domain/auth/oauth/constants/ProviderType.kt b/src/main/kotlin/com/vacgom/backend/domain/auth/oauth/constants/ProviderType.kt new file mode 100644 index 0000000..904bca5 --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/domain/auth/oauth/constants/ProviderType.kt @@ -0,0 +1,19 @@ +package com.vacgom.backend.domain.auth.oauth.constants + +import com.vacgom.backend.global.exception.error.BusinessException +import com.vacgom.backend.global.security.exception.AuthError + +enum class ProviderType(val provider: String) { + KAKAO("kakao"); + + companion object { + fun from(provider: String): ProviderType { + return entries.firstOrNull { it.provider == provider } + ?: throw BusinessException(AuthError.UNSUPPORTED_PROVIDER) + } + } + + fun isKakao(): Boolean { + return this == KAKAO + } +} diff --git a/src/main/kotlin/com/vacgom/backend/domain/member/Member.kt b/src/main/kotlin/com/vacgom/backend/domain/member/Member.kt index de0538c..686f3ed 100644 --- a/src/main/kotlin/com/vacgom/backend/domain/member/Member.kt +++ b/src/main/kotlin/com/vacgom/backend/domain/member/Member.kt @@ -1,21 +1,25 @@ package com.vacgom.backend.domain.member +import com.vacgom.backend.domain.auth.constants.Role +import com.vacgom.backend.domain.auth.oauth.constants.ProviderType import com.vacgom.backend.global.auditing.BaseEntity import jakarta.persistence.* +import org.hibernate.annotations.GenericGenerator +import java.util.* @Entity @Table(name = "t_member") -class Member(nickname: String) : BaseEntity() { +class Member( + var providerId: Long, + @Enumerated(EnumType.STRING) var providerType: ProviderType, + @Enumerated(EnumType.STRING) var role: Role, +) : BaseEntity() { @Id - @Column(name = "member_id") - @GeneratedValue(strategy = GenerationType.IDENTITY) - var id: Long? = null + @GeneratedValue(generator = "uuid2") + @GenericGenerator(name = "uuid2", strategy = "uuid2") + @Column(columnDefinition = "BINARY(16)", name = "member_id") + val id: UUID? = null - @Column(name = "nickname") - val nickname: String - - init { - this.nickname = nickname - } + var name: String? = null } diff --git a/src/main/kotlin/com/vacgom/backend/exception/member/MemberError.kt b/src/main/kotlin/com/vacgom/backend/exception/member/MemberError.kt new file mode 100644 index 0000000..04bab5e --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/exception/member/MemberError.kt @@ -0,0 +1,13 @@ +package com.vacgom.backend.exception.member + +import com.vacgom.backend.global.exception.error.ErrorCode +import org.springframework.http.HttpStatus + + +enum class MemberError( + override val message: String, + override val status: HttpStatus, + override val code: String +) : ErrorCode { + NOT_FOUND("사용자를 찾을 수 없습니다.", HttpStatus.NOT_FOUND, "M_001"), +} diff --git a/src/main/kotlin/com/vacgom/backend/global/auditing/BaseEntity.kt b/src/main/kotlin/com/vacgom/backend/global/auditing/BaseEntity.kt index be0e3a8..e660e5c 100644 --- a/src/main/kotlin/com/vacgom/backend/global/auditing/BaseEntity.kt +++ b/src/main/kotlin/com/vacgom/backend/global/auditing/BaseEntity.kt @@ -1,13 +1,13 @@ package com.vacgom.backend.global.auditing import com.fasterxml.jackson.annotation.JsonFormat +import jakarta.persistence.Column import jakarta.persistence.EntityListeners import jakarta.persistence.MappedSuperclass import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.jpa.domain.support.AuditingEntityListener import java.time.LocalDateTime -import jakarta.persistence.Column @MappedSuperclass @EntityListeners(value = [AuditingEntityListener::class]) @@ -24,7 +24,7 @@ abstract class BaseEntity { shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd a HH:mm" ) - val createdDate: LocalDateTime? = null + open var createdDate: LocalDateTime? = null @Column( nullable = false, @@ -36,5 +36,5 @@ abstract class BaseEntity { shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd a HH:mm" ) - val updatedDate: LocalDateTime? = null + open var updatedDate: LocalDateTime? = null } diff --git a/src/main/kotlin/com/vacgom/backend/global/config/AuditingConfig.kt b/src/main/kotlin/com/vacgom/backend/global/config/AuditingConfig.kt new file mode 100644 index 0000000..dc2455e --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/global/config/AuditingConfig.kt @@ -0,0 +1,8 @@ +package com.vacgom.backend.global.config + +import org.springframework.context.annotation.Configuration +import org.springframework.data.jpa.repository.config.EnableJpaAuditing + +@Configuration +@EnableJpaAuditing +class AuditingConfig diff --git a/src/main/kotlin/com/vacgom/backend/global/config/LoggerConfig.kt b/src/main/kotlin/com/vacgom/backend/global/config/LoggerConfig.kt new file mode 100644 index 0000000..1af634e --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/global/config/LoggerConfig.kt @@ -0,0 +1,15 @@ +package com.vacgom.backend.global.config + +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class LoggerConfig { + + @Bean + fun logger(): Logger { + return LoggerFactory.getLogger(this.javaClass) + } +} diff --git a/src/main/kotlin/com/vacgom/backend/global/config/RestTemplateConfig.kt b/src/main/kotlin/com/vacgom/backend/global/config/RestTemplateConfig.kt new file mode 100644 index 0000000..1710c03 --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/global/config/RestTemplateConfig.kt @@ -0,0 +1,15 @@ +package com.vacgom.backend.global.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.client.RestTemplate + +@Configuration +class RestTemplateConfig { + + @Bean + fun restTemplate(): RestTemplate { + return RestTemplate() + } +} + diff --git a/src/main/kotlin/com/vacgom/backend/global/config/SecurityConfig.kt b/src/main/kotlin/com/vacgom/backend/global/config/SecurityConfig.kt new file mode 100644 index 0000000..700f1d3 --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/global/config/SecurityConfig.kt @@ -0,0 +1,72 @@ +package com.vacgom.backend.global.config + +import com.vacgom.backend.global.exception.ApiExceptionHandlingFilter +import com.vacgom.backend.global.security.filter.JwtAuthFilter +import com.vacgom.backend.global.security.matcher.CustomRequestMatcher +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.annotation.Order +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer +import org.springframework.security.config.annotation.web.configurers.FormLoginConfigurer +import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.CorsConfigurationSource +import org.springframework.web.cors.UrlBasedCorsConfigurationSource + +@Configuration +@EnableWebSecurity +class SecurityConfig( + private val customRequestMatcher: CustomRequestMatcher, + private val jwtAuthFilter: JwtAuthFilter, + private val apiExceptionHandlingFilter: ApiExceptionHandlingFilter +) { + + @Bean + @Order(0) + fun authFilterChain(http: HttpSecurity): SecurityFilterChain { + http.securityMatchers { matcher -> matcher.requestMatchers(customRequestMatcher.authEndpoints()) } + http.authorizeHttpRequests { auth -> auth.anyRequest().permitAll() } + http.addFilterBefore(apiExceptionHandlingFilter, UsernamePasswordAuthenticationFilter::class.java) + + return commonHttpSecurity(http).build() + } + + @Bean + @Order(1) + fun anyRequestFilterChain(http: HttpSecurity): SecurityFilterChain { + http.authorizeRequests { auth -> + auth.requestMatchers(customRequestMatcher.tempUserEndpoints()).hasRole("TEMP_USER") + .anyRequest().hasRole("USER") + } + .addFilterAfter(jwtAuthFilter, UsernamePasswordAuthenticationFilter::class.java) + .addFilterBefore(apiExceptionHandlingFilter, UsernamePasswordAuthenticationFilter::class.java) + + return commonHttpSecurity(http).build() + } + + private fun commonHttpSecurity(http: HttpSecurity): HttpSecurity { + return http + .csrf { csrf: CsrfConfigurer -> csrf.disable() } + .cors { corsConfigurationSource() } + .formLogin { formLogin: FormLoginConfigurer -> formLogin.disable() } + .httpBasic { basic: HttpBasicConfigurer -> basic.disable() } + } + + @Bean + fun corsConfigurationSource(): CorsConfigurationSource { + val configuration = CorsConfiguration() + + configuration.setAllowedOriginPatterns(listOf("*")) + configuration.allowedMethods = listOf("HEAD", "POST", "GET", "DELETE", "PUT") + configuration.allowedHeaders = listOf("*") + configuration.allowCredentials = true + + val source = UrlBasedCorsConfigurationSource() + source.registerCorsConfiguration("/**", configuration) + return source + } +} diff --git a/src/main/kotlin/com/vacgom/backend/global/exception/ApiExceptionHandler.kt b/src/main/kotlin/com/vacgom/backend/global/exception/ApiExceptionHandler.kt index 36f6c8a..f73cc3a 100644 --- a/src/main/kotlin/com/vacgom/backend/global/exception/ApiExceptionHandler.kt +++ b/src/main/kotlin/com/vacgom/backend/global/exception/ApiExceptionHandler.kt @@ -4,7 +4,7 @@ import com.vacgom.backend.global.exception.error.BusinessException import com.vacgom.backend.global.exception.error.ErrorCode import com.vacgom.backend.global.exception.error.ErrorResponse import com.vacgom.backend.global.exception.error.GlobalError -import com.vacgom.backend.global.logger.CommonLogger +import org.slf4j.Logger import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.MethodArgumentNotValidException @@ -14,8 +14,7 @@ import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestControllerAdvice @RestControllerAdvice -class ApiExceptionHandler { - companion object : CommonLogger(); +class ApiExceptionHandler(private val log: Logger) { @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(MethodArgumentNotValidException::class) diff --git a/src/main/kotlin/com/vacgom/backend/global/logger/CommonLogger.kt b/src/main/kotlin/com/vacgom/backend/global/logger/CommonLogger.kt deleted file mode 100644 index 0d8e1c9..0000000 --- a/src/main/kotlin/com/vacgom/backend/global/logger/CommonLogger.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.vacgom.backend.global.logger - -import org.slf4j.LoggerFactory - -abstract class CommonLogger { - val log = LoggerFactory.getLogger(this.javaClass)!! -} diff --git a/src/main/kotlin/com/vacgom/backend/global/security/annotation/AuthId.kt b/src/main/kotlin/com/vacgom/backend/global/security/annotation/AuthId.kt new file mode 100644 index 0000000..b687876 --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/global/security/annotation/AuthId.kt @@ -0,0 +1,9 @@ +package com.vacgom.backend.global.security.annotation + +import org.springframework.security.core.annotation.AuthenticationPrincipal + + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.VALUE_PARAMETER) +@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : id") +annotation class AuthId diff --git a/src/main/kotlin/com/vacgom/backend/global/security/exception/AuthError.kt b/src/main/kotlin/com/vacgom/backend/global/security/exception/AuthError.kt new file mode 100644 index 0000000..a6f2de2 --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/global/security/exception/AuthError.kt @@ -0,0 +1,18 @@ +package com.vacgom.backend.global.security.exception + +import com.vacgom.backend.global.exception.error.ErrorCode +import org.springframework.http.HttpStatus + + +enum class AuthError( + override val message: String, + override val status: HttpStatus, + override val code: String +) : ErrorCode { + INVALID_JWT_SIGNATURE("시그니처가 유효하지 않습니다.", HttpStatus.UNAUTHORIZED, "A_001"), + INVALID_JWT_TOKEN("토큰이 유효하지 않습니다.", HttpStatus.UNAUTHORIZED, "A_002"), + EXPIRED_JWT_TOKEN("토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED, "A_003"), + UNSUPPORTED_JWT_TOKEN("지원되지 않는 토큰입니다.", HttpStatus.UNAUTHORIZED, "A_004"), + UNSUPPORTED_PROVIDER("지원하지 않는 로그인 클라이언트입니다.", HttpStatus.BAD_REQUEST, "A_005"), + KAKAO_OAUTH_ERROR("카카오 서버 통신 간 오류입니다.", HttpStatus.BAD_REQUEST, "A_006"); +} diff --git a/src/main/kotlin/com/vacgom/backend/global/security/filter/JwtAuthFilter.kt b/src/main/kotlin/com/vacgom/backend/global/security/filter/JwtAuthFilter.kt new file mode 100644 index 0000000..8ea2dca --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/global/security/filter/JwtAuthFilter.kt @@ -0,0 +1,60 @@ +package com.vacgom.backend.global.security.filter + +import com.vacgom.backend.domain.auth.constants.Role.ROLE_GUEST +import com.vacgom.backend.global.security.jwt.JwtService +import com.vacgom.backend.global.security.model.CustomUser +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.authentication.AnonymousAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.userdetails.User +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +@Component +class JwtAuthFilter( + private val jwtService: JwtService +) : OncePerRequestFilter() { + companion object { + private const val HEADER_AUTHORIZATION = "Authorization" + private const val TOKEN_PREFIX = "Bearer " + } + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + chain: FilterChain + ) { + val token = getAccessToken(request) + if (token == null) { + val authority = SimpleGrantedAuthority(ROLE_GUEST.toString()) + val principal: User = CustomUser( + null, + "guest", + "", setOf(authority)) + val authenticationToken = AnonymousAuthenticationToken("guest", principal, setOf(authority)) + SecurityContextHolder.getContext().authentication = authenticationToken + chain.doFilter(request, response) + println("principal = ${principal}") + return + } + val authentication = jwtService.getAuthentication(token) + SecurityContextHolder.getContext().authentication = authentication + println("authentication = ${authentication}") + chain.doFilter(request, response) + } + + private fun getAccessToken( + request: HttpServletRequest + ): String? { + val headerValue = request.getHeader(HEADER_AUTHORIZATION) + ?: return null + return if (headerValue.startsWith(TOKEN_PREFIX)) { + headerValue.substring(TOKEN_PREFIX.length) + } else null + } +} + + diff --git a/src/main/kotlin/com/vacgom/backend/global/security/jwt/JwtService.kt b/src/main/kotlin/com/vacgom/backend/global/security/jwt/JwtService.kt new file mode 100644 index 0000000..9f58d27 --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/global/security/jwt/JwtService.kt @@ -0,0 +1,68 @@ +package com.vacgom.backend.global.security.jwt + +import com.vacgom.backend.domain.member.Member +import com.vacgom.backend.global.exception.error.BusinessException +import com.vacgom.backend.global.security.exception.AuthError +import com.vacgom.backend.global.security.model.CustomUser +import io.jsonwebtoken.* +import io.jsonwebtoken.io.Decoders +import io.jsonwebtoken.security.Keys +import io.jsonwebtoken.security.SecurityException +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.security.core.userdetails.User +import org.springframework.stereotype.Service +import java.security.Key +import java.util.* + + +@Service +class JwtService( + @Value("\${jwt.secret.key}") secret: String, + @Value("\${jwt.access-token-validity}") private val accessTokenValidity: Long +) { + private val key: Key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)) + + fun createAccessToken(member: Member): String { + val now = Date() + val expireDate = Date(now.time + accessTokenValidity) + + return Jwts.builder() + .setSubject(member.id.toString()) + .setIssuedAt(now) + .claim("role", member.role) + .signWith(key, SignatureAlgorithm.HS256) + .setExpiration(expireDate) + .compact() + } + + fun getTokenClaims(token: String): Jws { + return try { + Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + } catch (exception: SecurityException) { + throw BusinessException(AuthError.INVALID_JWT_SIGNATURE) + } catch (exception: MalformedJwtException) { + throw BusinessException(AuthError.INVALID_JWT_TOKEN) + } catch (exception: ExpiredJwtException) { + throw BusinessException(AuthError.EXPIRED_JWT_TOKEN) + } catch (exception: UnsupportedJwtException) { + throw BusinessException(AuthError.UNSUPPORTED_JWT_TOKEN) + } catch (exception: IllegalArgumentException) { + throw BusinessException(AuthError.UNSUPPORTED_JWT_TOKEN) + } + } + + fun getAuthentication(token: String): Authentication? { + val claims: Claims = getTokenClaims(token).body + val authorities = Arrays.stream(arrayOf(claims["role"].toString())) + .map { role: String? -> SimpleGrantedAuthority(role) } + .toList() + val principal: User = CustomUser(UUID.fromString(claims.subject), claims.subject, "", authorities) + return UsernamePasswordAuthenticationToken(principal, this, authorities) + } +} diff --git a/src/main/kotlin/com/vacgom/backend/global/security/matcher/CustomRequestMatcher.kt b/src/main/kotlin/com/vacgom/backend/global/security/matcher/CustomRequestMatcher.kt new file mode 100644 index 0000000..664c0d6 --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/global/security/matcher/CustomRequestMatcher.kt @@ -0,0 +1,22 @@ +package com.vacgom.backend.global.security.matcher + +import org.springframework.security.web.util.matcher.AntPathRequestMatcher +import org.springframework.security.web.util.matcher.OrRequestMatcher +import org.springframework.security.web.util.matcher.RequestMatcher +import org.springframework.stereotype.Component + +@Component +class CustomRequestMatcher { + + fun authEndpoints(): RequestMatcher { + return OrRequestMatcher( + AntPathRequestMatcher("/api/v1/oauth/**") + ) + } + + fun tempUserEndpoints(): RequestMatcher { + return OrRequestMatcher( + AntPathRequestMatcher("/api/v1/api/main") + ) + } +} diff --git a/src/main/kotlin/com/vacgom/backend/global/security/model/CustomUser.kt b/src/main/kotlin/com/vacgom/backend/global/security/model/CustomUser.kt new file mode 100644 index 0000000..108e9ac --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/global/security/model/CustomUser.kt @@ -0,0 +1,13 @@ +package com.vacgom.backend.global.security.model + +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.userdetails.User +import java.util.* + + +class CustomUser( + val id: UUID?, + username: String, + password: String, + authorities: Collection +) : User(username, password, authorities) diff --git a/src/main/kotlin/com/vacgom/backend/infrastructure/auth/oauth/kakao/KakaoConnector.kt b/src/main/kotlin/com/vacgom/backend/infrastructure/auth/oauth/kakao/KakaoConnector.kt new file mode 100644 index 0000000..5cb6694 --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/infrastructure/auth/oauth/kakao/KakaoConnector.kt @@ -0,0 +1,72 @@ +package com.vacgom.backend.infrastructure.auth.oauth.kakao + +import com.vacgom.backend.application.auth.dto.OauthTokenResponse +import com.vacgom.backend.application.auth.dto.ResourceIdResponse +import com.vacgom.backend.domain.auth.oauth.OauthConnector +import com.vacgom.backend.domain.auth.oauth.constants.ProviderType +import com.vacgom.backend.global.exception.error.BusinessException +import com.vacgom.backend.global.security.exception.AuthError +import com.vacgom.backend.infrastructure.auth.oauth.kakao.model.KakaoProperties +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.client.RestClientException +import org.springframework.web.client.RestTemplate + +@Component +class KakaoConnector( + private val restTemplate: RestTemplate, + private val kakaoProperties: KakaoProperties +) : OauthConnector { + override fun isSupported(provider: ProviderType): Boolean { + return provider.isKakao() + } + + override fun fetchOauthToken(code: String): OauthTokenResponse { + val headers = createHttpHeaders() + val body = LinkedMultiValueMap() + + body.add("grant_type", kakaoProperties.authorizationGrantType) + body.add("client_id", kakaoProperties.clientId) + body.add("redirect_uri", kakaoProperties.redirectUri) + body.add("client_secret", kakaoProperties.clientSecret) + body.add("code", code) + + val request = HttpEntity>(body, headers) + + return try { + restTemplate.postForObject( + kakaoProperties.tokenEndpoint, + request, + OauthTokenResponse::class.java + ) ?: throw BusinessException(AuthError.KAKAO_OAUTH_ERROR) + } catch (exception: RestClientException) { + throw BusinessException(AuthError.KAKAO_OAUTH_ERROR) + } + } + + override fun fetchMemberInfo(accessToken: String): ResourceIdResponse { + val headers = createHttpHeaders() + headers.set("Authorization", "Bearer $accessToken") + + val request = HttpEntity(headers) + return runCatching { + restTemplate.exchange( + kakaoProperties.userinfoEndpoint, + HttpMethod.GET, + request, + ResourceIdResponse::class.java + ).body + }.getOrNull() ?: throw BusinessException(AuthError.KAKAO_OAUTH_ERROR) + } +} + + +private fun createHttpHeaders(): HttpHeaders { + val headers = HttpHeaders() + headers.contentType = MediaType.APPLICATION_FORM_URLENCODED + return headers +} diff --git a/src/main/kotlin/com/vacgom/backend/infrastructure/auth/oauth/kakao/KakaoUriGenerator.kt b/src/main/kotlin/com/vacgom/backend/infrastructure/auth/oauth/kakao/KakaoUriGenerator.kt new file mode 100644 index 0000000..76e2272 --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/infrastructure/auth/oauth/kakao/KakaoUriGenerator.kt @@ -0,0 +1,28 @@ +package com.vacgom.backend.infrastructure.auth.oauth.kakao + +import com.vacgom.backend.domain.auth.oauth.OauthUriGenerator +import com.vacgom.backend.domain.auth.oauth.constants.ProviderType +import com.vacgom.backend.infrastructure.auth.oauth.kakao.model.KakaoProperties +import org.springframework.stereotype.Component +import org.springframework.web.util.UriComponentsBuilder +import java.net.URI + +@Component +class KakaoUriGenerator( + private val kakaoProperties: KakaoProperties +) : OauthUriGenerator { + + override fun isSupported(provider: ProviderType): Boolean { + return provider.isKakao() + } + + override fun generate(): URI { + return UriComponentsBuilder + .fromUriString(kakaoProperties.authorizationEndpoint) + .queryParam("response_type", "code") + .queryParam("client_id", kakaoProperties.clientId) + .queryParam("redirect_uri", kakaoProperties.redirectUri) + .build() + .toUri() + } +} diff --git a/src/main/kotlin/com/vacgom/backend/infrastructure/auth/oauth/kakao/model/KakaoProperties.kt b/src/main/kotlin/com/vacgom/backend/infrastructure/auth/oauth/kakao/model/KakaoProperties.kt new file mode 100644 index 0000000..426447e --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/infrastructure/auth/oauth/kakao/model/KakaoProperties.kt @@ -0,0 +1,15 @@ +package com.vacgom.backend.infrastructure.auth.oauth.kakao.model + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component + +@Component +data class KakaoProperties( + @Value("\${spring.security.oauth2.client.registration.kakao.client-id}") val clientId: String, + @Value("\${spring.security.oauth2.client.registration.kakao.client-secret}") val clientSecret: String, + @Value("\${spring.security.oauth2.client.registration.kakao.redirect-uri}") val redirectUri: String, + @Value("\${spring.security.oauth2.client.registration.kakao.authorization-grant-type}") val authorizationGrantType: String, + @Value("\${spring.security.oauth2.client.provider.kakao.authorization-endpoint}") val authorizationEndpoint: String, + @Value("\${spring.security.oauth2.client.provider.kakao.token-endpoint}") val tokenEndpoint: String, + @Value("\${spring.security.oauth2.client.provider.kakao.user-info-endpoint}") val userinfoEndpoint: String +) diff --git a/src/main/kotlin/com/vacgom/backend/infrastructure/member/persistence/MemberRepository.kt b/src/main/kotlin/com/vacgom/backend/infrastructure/member/persistence/MemberRepository.kt new file mode 100644 index 0000000..77d6a1b --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/infrastructure/member/persistence/MemberRepository.kt @@ -0,0 +1,13 @@ +package com.vacgom.backend.infrastructure.member.persistence + +import com.vacgom.backend.domain.auth.oauth.constants.ProviderType +import com.vacgom.backend.domain.member.Member +import org.springframework.data.jpa.repository.JpaRepository +import java.util.* + +interface MemberRepository : JpaRepository { + fun findByProviderIdAndProviderType( + providerId: Long, + providerType: ProviderType + ): Member? +} diff --git a/src/main/kotlin/com/vacgom/backend/presentation/auth/OAuthController.kt b/src/main/kotlin/com/vacgom/backend/presentation/auth/OAuthController.kt new file mode 100644 index 0000000..a55e022 --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/presentation/auth/OAuthController.kt @@ -0,0 +1,31 @@ +package com.vacgom.backend.presentation.auth + +import com.vacgom.backend.application.auth.AuthService +import com.vacgom.backend.application.auth.dto.LoginResponse +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import java.net.URI + +@RestController +@RequestMapping("/api/v1/oauth") +class OAuthController(private val authService: AuthService) { + + @GetMapping("/{provider}") + fun redirectToAuthorization( + @PathVariable provider: String + ): ResponseEntity { + val authorizationUri: URI = authService.getAuthorizationUri(provider) + val headers = authService.createRedirectHeaders(authorizationUri) + return ResponseEntity(headers, HttpStatus.FOUND) + } + + @GetMapping("/{provider}/login") + fun login( + @PathVariable provider: String, + @RequestParam code: String + ): ResponseEntity { + val loginResponse = authService.login(provider, code) + return ResponseEntity.ok(loginResponse) + } +} diff --git a/src/main/kotlin/com/vacgom/backend/presentation/auth/SimpleController.kt b/src/main/kotlin/com/vacgom/backend/presentation/auth/SimpleController.kt new file mode 100644 index 0000000..236985b --- /dev/null +++ b/src/main/kotlin/com/vacgom/backend/presentation/auth/SimpleController.kt @@ -0,0 +1,19 @@ +package com.vacgom.backend.presentation.auth + +import com.vacgom.backend.global.security.annotation.AuthId +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.* + +@RestController +@RequestMapping("/api/v1/test") +class SimpleController { + + @GetMapping + fun securityTest(@AuthId id: UUID): ResponseEntity { + println("UUID/ id = ${id}") + return ResponseEntity.ok("success!!") + } +}