diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index d4b5c6f..01af224 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -55,6 +55,11 @@ jobs: # Aws Credentials 환경 변수 주입 cloud.aws.credentials.accessKey: ${{ secrets.AWS_ACCESS_KEY_ID }} cloud.aws.credentials.secretKey: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + # Jwt Secret Key 환경 변수 주입 + auth.jwt.secret: ${{ auth.jwt.secret }} + # Encrypt 환경 변수 주입 + encrypt.key: ${{ encrypt.key }} + encrypt.algorithm: ${{ encrypt.algorithm }} # gradlew 파일 실행권한 설정 - name: Grant execute permission for gradlew diff --git a/build.gradle.kts b/build.gradle.kts index 7fba0aa..681bcc8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -81,6 +81,9 @@ dependencies { /** database */ runtimeOnly("com.mysql:mysql-connector-j") + /** jwt */ + implementation("com.auth0:java-jwt:${DependencyVersion.AUTH_JWT}") + /** test */ testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("io.projectreactor:reactor-test") diff --git a/buildSrc/src/main/kotlin/DependencyVersion.kt b/buildSrc/src/main/kotlin/DependencyVersion.kt index 6bdd568..de105cd 100644 --- a/buildSrc/src/main/kotlin/DependencyVersion.kt +++ b/buildSrc/src/main/kotlin/DependencyVersion.kt @@ -8,6 +8,9 @@ object DependencyVersion { const val SPRINGDOC = "2.3.0" const val JAVADOC_SCRIBE = "0.15.0" + /** auth0-jwt */ + const val AUTH_JWT = "4.4.0" + /** test */ const val TEST_CONTAINER_MYSQL = "1.19.8" const val P6SPY_LOG = "1.9.1" diff --git a/sql/DDL.sql b/sql/DDL.sql index f71a88c..fae3837 100644 --- a/sql/DDL.sql +++ b/sql/DDL.sql @@ -1,3 +1,30 @@ -- scheme CREATE DATABASE hero CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; + +-- 유저 정보 +CREATE TABLE `user_info` +( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'user id', + `nickname` varchar(64) NOT NULL COMMENT '닉네임', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '생성일', + `modified_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일', + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=200000 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='유저 정보'; + +CREATE UNIQUE INDEX uidx__nickname ON user_info (nickname); + +-- 일반 회원가입 유저 정보 +CREATE TABLE `credential_user_info` +( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'credential_user_info id', + `uid` bigint NOT NULL COMMENT 'user id', + `username` varchar(256) NOT NULL COMMENT '로그인 id', + `password` varchar(512) NOT NULL COMMENT '로그인 pw', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '생성일', + `modified_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일', + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=200000 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='유저 정보'; + +CREATE UNIQUE INDEX uidx__uid ON credential_user_info (uid); +CREATE UNIQUE INDEX uidx__username ON credential_user_info (username); diff --git a/src/main/kotlin/com/hero/alignlab/common/encrypt/EncryptData.kt b/src/main/kotlin/com/hero/alignlab/common/encrypt/EncryptData.kt new file mode 100644 index 0000000..af668d1 --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/common/encrypt/EncryptData.kt @@ -0,0 +1,17 @@ +package com.hero.alignlab.common.encrypt + +data class EncryptData( + val encData: String, +) { + fun dec(encryptor: Encryptor): String { + return encryptor.decrypt(this.encData) + } + + companion object { + fun enc(plainData: String, encryptor: Encryptor): EncryptData { + return EncryptData( + encData = encryptor.encrypt(plainData) + ) + } + } +} diff --git a/src/main/kotlin/com/hero/alignlab/common/encrypt/Encryptor.kt b/src/main/kotlin/com/hero/alignlab/common/encrypt/Encryptor.kt new file mode 100644 index 0000000..54fa806 --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/common/encrypt/Encryptor.kt @@ -0,0 +1,36 @@ +package com.hero.alignlab.common.encrypt + +import java.util.* +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +data class Encryptor( + private val key: String, + private val algorithm: String, +) { + fun encrypt(text: String): String { + val cipher = Cipher.ENCRYPT_MODE.cipher() + val encrypted = cipher.doFinal(text.toByteArray(charset(Charsets.UTF_8.name()))) + + return Base64.getEncoder().encodeToString(encrypted) + } + + fun decrypt(text: String): String { + val cipher = Cipher.DECRYPT_MODE.cipher() + val decodedBytes = Base64.getDecoder().decode(text) + val decrypted = cipher.doFinal(decodedBytes) + + return String(decrypted, Charsets.UTF_8) + } + + private fun Int.cipher(): Cipher { + val cipher = Cipher.getInstance(algorithm) + val keySpec = SecretKeySpec(key.toByteArray(), "AES") + val ivParamSpec = IvParameterSpec(key.toByteArray()) + + cipher.init(this, keySpec, ivParamSpec) + + return cipher + } +} diff --git a/src/main/kotlin/com/hero/alignlab/config/EncryptConfig.kt b/src/main/kotlin/com/hero/alignlab/config/EncryptConfig.kt new file mode 100644 index 0000000..ae22519 --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/config/EncryptConfig.kt @@ -0,0 +1,28 @@ +package com.hero.alignlab.config + +import com.hero.alignlab.common.encrypt.Encryptor +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +@EnableConfigurationProperties(EncryptConfig.EncryptProperty::class) +class EncryptConfig { + private val logger = KotlinLogging.logger {} + + @ConfigurationProperties(prefix = "encrypt") + data class EncryptProperty( + val key: String, + val algorithm: String, + ) + + @Bean + fun encryptor( + property: EncryptProperty, + ): Encryptor { + logger.info { "initialized encryptor. key: ${property.key} algorithm: ${property.algorithm}" } + return Encryptor(property.key, property.algorithm) + } +} diff --git a/src/main/kotlin/com/hero/alignlab/config/JwtConfig.kt b/src/main/kotlin/com/hero/alignlab/config/JwtConfig.kt new file mode 100644 index 0000000..46cf3a5 --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/config/JwtConfig.kt @@ -0,0 +1,20 @@ +package com.hero.alignlab.config + +import jakarta.validation.constraints.NotBlank +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.ConfigurationPropertiesBinding +import org.springframework.context.annotation.Configuration + +@Configuration +@ConfigurationProperties(prefix = "auth.jwt") +@ConfigurationPropertiesBinding +data class JwtConfig( + @field:NotBlank + var secret: String = "", + @field:NotBlank + var accessExp: Int = 0, + @field:NotBlank + var refreshExp: Int = 0, + val issuer: String = "hero-alignlab-api", + val audience: String = "hero-alignlab-api", +) diff --git a/src/main/kotlin/com/hero/alignlab/config/swagger/SpringDocConfig.kt b/src/main/kotlin/com/hero/alignlab/config/swagger/SpringDocConfig.kt index 2d89632..18c5d06 100644 --- a/src/main/kotlin/com/hero/alignlab/config/swagger/SpringDocConfig.kt +++ b/src/main/kotlin/com/hero/alignlab/config/swagger/SpringDocConfig.kt @@ -1,7 +1,12 @@ package com.hero.alignlab.config.swagger +import com.hero.alignlab.domain.auth.model.AUTH_TOKEN_KEY +import com.hero.alignlab.domain.auth.model.AuthUser +import io.swagger.v3.oas.models.Components import io.swagger.v3.oas.models.OpenAPI import io.swagger.v3.oas.models.info.Info +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme import io.swagger.v3.oas.models.servers.Server import org.springdoc.core.utils.SpringDocUtils import org.springframework.boot.info.BuildProperties @@ -26,19 +31,35 @@ class SpringDocConfig( .getConfig() .addRequestWrapperToIgnore( WebSession::class.java, - RequestContext::class.java + RequestContext::class.java, + AuthUser::class.java ) } @Bean fun openApi(): OpenAPI { + val securityRequirement = SecurityRequirement().addList(AUTH_TOKEN_KEY) return OpenAPI() + .components(authSetting()) + .security(listOf(securityRequirement)) .addServersItem(Server().url("/")) .info( Info() .title(buildProperties.name) .version(buildProperties.version) - .description("Alignlab Rest API Docs") + .description("Hero Alignlab Rest API Docs") + ) + } + + private fun authSetting(): Components { + return Components() + .addSecuritySchemes( + AUTH_TOKEN_KEY, + SecurityScheme() + .description("Access Token") + .type(SecurityScheme.Type.APIKEY) + .`in`(SecurityScheme.In.HEADER) + .name(AUTH_TOKEN_KEY) ) } } diff --git a/src/main/kotlin/com/hero/alignlab/config/web/WebFluxConfig.kt b/src/main/kotlin/com/hero/alignlab/config/web/WebFluxConfig.kt index 29ea531..cb901ba 100644 --- a/src/main/kotlin/com/hero/alignlab/config/web/WebFluxConfig.kt +++ b/src/main/kotlin/com/hero/alignlab/config/web/WebFluxConfig.kt @@ -1,6 +1,8 @@ package com.hero.alignlab.config.web import com.fasterxml.jackson.databind.ObjectMapper +import com.hero.alignlab.domain.auth.application.AuthFacade +import com.hero.alignlab.domain.auth.resolver.ReactiveUserResolver import org.springframework.context.annotation.Configuration import org.springframework.core.ReactiveAdapterRegistry import org.springframework.data.web.ReactivePageableHandlerMethodArgumentResolver @@ -19,7 +21,8 @@ import java.nio.charset.Charset @Configuration class WebFluxConfig( - private val objectMapper: ObjectMapper + private val objectMapper: ObjectMapper, + private val authFacade: AuthFacade, ) : WebFluxConfigurer { override fun addCorsMappings(registry: CorsRegistry) { registry.addMapping("/**") @@ -47,6 +50,7 @@ class WebFluxConfig( configureHttpMessageCodecs(serverCodecConfigurer) configurer.addCustomResolver( + ReactiveUserResolver(registry, authFacade), ReactiveSortHandlerMethodArgumentResolver(), ReactivePageableHandlerMethodArgumentResolver() ) diff --git a/src/main/kotlin/com/hero/alignlab/domain/auth/application/AuthFacade.kt b/src/main/kotlin/com/hero/alignlab/domain/auth/application/AuthFacade.kt new file mode 100644 index 0000000..0098e4f --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/auth/application/AuthFacade.kt @@ -0,0 +1,86 @@ +package com.hero.alignlab.domain.auth.application + +import com.hero.alignlab.common.encrypt.EncryptData +import com.hero.alignlab.common.encrypt.Encryptor +import com.hero.alignlab.config.database.TransactionTemplates +import com.hero.alignlab.domain.auth.model.AuthContextImpl +import com.hero.alignlab.domain.auth.model.AuthUserImpl +import com.hero.alignlab.domain.auth.model.AuthUserToken +import com.hero.alignlab.domain.auth.model.request.SignInRequest +import com.hero.alignlab.domain.auth.model.request.SignUpRequest +import com.hero.alignlab.domain.auth.model.response.SignInResponse +import com.hero.alignlab.domain.auth.model.response.SignUpResponse +import com.hero.alignlab.domain.user.application.CredentialUserInfoService +import com.hero.alignlab.domain.user.application.UserService +import com.hero.alignlab.domain.user.domain.CredentialUserInfo +import com.hero.alignlab.domain.user.domain.UserInfo +import com.hero.alignlab.exception.ErrorCode +import com.hero.alignlab.exception.InvalidRequestException +import com.hero.alignlab.exception.InvalidTokenException +import org.springframework.stereotype.Service +import reactor.core.publisher.Mono +import java.time.LocalDateTime + +@Service +class AuthFacade( + private val userService: UserService, + private val credentialUserInfoService: CredentialUserInfoService, + private val jwtTokenService: JwtTokenService, + private val encryptor: Encryptor, + private val txTemplates: TransactionTemplates, +) { + companion object { + private val TOKEN_EXPIRED_DATE = LocalDateTime.of(2024, 12, 29, 0, 0, 0) + } + + fun resolveAuthUser(token: Mono): Mono { + return jwtTokenService.verifyTokenMono(token) + .handle { payload, sink -> + if (payload.type != "accessToken") { + sink.error(InvalidTokenException(ErrorCode.INVALID_ACCESS_TOKEN)) + return@handle + } + + val user = userService.getUserByIdOrThrowSync(payload.id) + + sink.next( + AuthUserImpl( + uid = user.id, + context = AuthContextImpl( + name = user.nickname + ) + ) + ) + } + } + + suspend fun signUp(request: SignUpRequest): SignUpResponse { + if (credentialUserInfoService.existsByUsername(request.username)) { + throw InvalidRequestException(ErrorCode.DUPLICATED_USERNAME_ERROR) + } + + val userInfo = userService.save(UserInfo(nickname = request.username)) + + credentialUserInfoService.save( + CredentialUserInfo( + uid = userInfo.id, + username = request.username, + password = EncryptData.enc(request.password, encryptor) + ) + ) + + return SignUpResponse( + accessToken = jwtTokenService.createToken(userInfo.id, TOKEN_EXPIRED_DATE) + ) + } + + suspend fun signIn(request: SignInRequest): SignInResponse { + val credentialUserInfo = credentialUserInfoService.findByUsernameAndPassword(request.username, request.password) + + val user = userService.getUserByIdOrThrowSync(credentialUserInfo.uid) + + return SignInResponse( + accessToken = jwtTokenService.createToken(user.id, TOKEN_EXPIRED_DATE) + ) + } +} diff --git a/src/main/kotlin/com/hero/alignlab/domain/auth/application/JwtTokenService.kt b/src/main/kotlin/com/hero/alignlab/domain/auth/application/JwtTokenService.kt new file mode 100644 index 0000000..d4821da --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/auth/application/JwtTokenService.kt @@ -0,0 +1,77 @@ +package com.hero.alignlab.domain.auth.application + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import com.fasterxml.jackson.module.kotlin.readValue +import com.hero.alignlab.config.JwtConfig +import com.hero.alignlab.domain.auth.model.AuthUserToken +import com.hero.alignlab.domain.auth.model.AuthUserTokenPayload +import com.hero.alignlab.exception.ErrorCode +import com.hero.alignlab.exception.InvalidTokenException +import com.hero.alignlab.extension.decodeBase64 +import com.hero.alignlab.extension.mapper +import com.hero.alignlab.extension.toInstant +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service +import reactor.core.publisher.Mono +import java.time.LocalDateTime +import java.util.* + +private const val ACCESS_TOKEN = "accessToken" + +@Service +class JwtTokenService( + private val jwtConfig: JwtConfig, +) { + private val logger = KotlinLogging.logger {} + + private val accessJwtVerifier = JWT + .require(Algorithm.HMAC256(jwtConfig.secret)) + .withIssuer(jwtConfig.issuer) + .withAudience(jwtConfig.audience) + .withClaim("type", ACCESS_TOKEN) + .build() + + private val accessJwtVerifierWithExtendedExpiredAt = JWT + .require(Algorithm.HMAC256(jwtConfig.secret)) + .withIssuer(jwtConfig.issuer) + .withAudience(jwtConfig.audience) + .withClaim("type", ACCESS_TOKEN) + .acceptExpiresAt(jwtConfig.refreshExp.toLong()) + .build() + + fun createToken(id: Long, tokenExpiredAt: LocalDateTime): String { + return JWT.create().apply { + this.withIssuer(jwtConfig.issuer) + this.withAudience(jwtConfig.audience) + this.withClaim("id", id) + this.withClaim("type", ACCESS_TOKEN) + this.withExpiresAt(Date.from(tokenExpiredAt.toInstant())) + }.sign(Algorithm.HMAC256(jwtConfig.secret)) + } + + fun verifyToken(token: AuthUserToken): AuthUserTokenPayload { + val payload = accessJwtVerifier.verify(token.value) + .payload + .decodeBase64() + + return mapper.readValue(payload) + } + + fun verifyTokenWithExtendedExpiredAt(token: String): AuthUserTokenPayload { + val payload = runCatching { accessJwtVerifierWithExtendedExpiredAt.verify(token).payload.decodeBase64() } + .getOrNull() ?: throw InvalidTokenException(ErrorCode.INVALID_TOKEN) + + return mapper.readValue(payload) + } + + fun verifyTokenMono(authUserToken: Mono): Mono { + return authUserToken.flatMap { jwtToken -> + Mono.fromCallable { verifyToken(jwtToken) } + .onErrorResume { e -> + logger.warn { e.message } + Mono.error(InvalidTokenException(ErrorCode.FAIL_TO_VERIFY_TOKEN_ERROR)) + } + } + } +} diff --git a/src/main/kotlin/com/hero/alignlab/domain/auth/model/AuthUser.kt b/src/main/kotlin/com/hero/alignlab/domain/auth/model/AuthUser.kt new file mode 100644 index 0000000..f9e8d52 --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/auth/model/AuthUser.kt @@ -0,0 +1,75 @@ +package com.hero.alignlab.domain.auth.model + +import com.hero.alignlab.exception.ErrorCode +import com.hero.alignlab.exception.NoAuthorityException + +/** 최상위 인증 및 인가 인터페이스 */ +interface AuthUser { + /** user id */ + val uid: Long + + /** user context */ + val context: AuthContext + + fun isAuthor(uid: Long): Boolean + + fun isAuthorThrow(uid: Long) + + fun isNotAuthorThrow(uid: Long) +} + +data class AuthUserImpl( + override val uid: Long, + override val context: AuthContext, +) : AuthUser { + override fun isAuthor(uid: Long): Boolean { + return this.uid == uid + } + + override fun isAuthorThrow(uid: Long) { + if (isAuthor(uid)) { + throw NoAuthorityException(ErrorCode.NO_AUTHORITY_ERROR) + } + } + + override fun isNotAuthorThrow(uid: Long) { + if (!isAuthor(uid)) { + throw NoAuthorityException(ErrorCode.NO_AUTHORITY_ERROR) + } + } +} + +interface AuthContext { + /** 이름 */ + val name: String +} + +data class AuthContextImpl( + override val name: String, +) : AuthContext + +const val AUTH_TOKEN_KEY = "X-HERO-AUTH-TOKEN" + +data class AuthUserToken( + val key: String, + val value: String, +) { + fun isInvalid() = key.isBlank() || value.isBlank() + + companion object { + fun from(value: String): AuthUserToken { + return AuthUserToken( + key = AUTH_TOKEN_KEY, + value = value + ) + } + } +} + +data class AuthUserTokenPayload( + val id: Long, + val aud: String, + val iss: String, + val exp: Long, + val type: String, +) diff --git a/src/main/kotlin/com/hero/alignlab/domain/auth/model/request/SignInRequest.kt b/src/main/kotlin/com/hero/alignlab/domain/auth/model/request/SignInRequest.kt new file mode 100644 index 0000000..33978a2 --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/auth/model/request/SignInRequest.kt @@ -0,0 +1,6 @@ +package com.hero.alignlab.domain.auth.model.request + +data class SignInRequest( + val username: String, + val password: String, +) diff --git a/src/main/kotlin/com/hero/alignlab/domain/auth/model/request/SignUpRequest.kt b/src/main/kotlin/com/hero/alignlab/domain/auth/model/request/SignUpRequest.kt new file mode 100644 index 0000000..564fe52 --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/auth/model/request/SignUpRequest.kt @@ -0,0 +1,6 @@ +package com.hero.alignlab.domain.auth.model.request + +data class SignUpRequest( + val username: String, + val password: String, +) diff --git a/src/main/kotlin/com/hero/alignlab/domain/auth/model/response/SignInResponse.kt b/src/main/kotlin/com/hero/alignlab/domain/auth/model/response/SignInResponse.kt new file mode 100644 index 0000000..bed46ab --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/auth/model/response/SignInResponse.kt @@ -0,0 +1,5 @@ +package com.hero.alignlab.domain.auth.model.response + +data class SignInResponse( + val accessToken: String, +) diff --git a/src/main/kotlin/com/hero/alignlab/domain/auth/model/response/SignUpResponse.kt b/src/main/kotlin/com/hero/alignlab/domain/auth/model/response/SignUpResponse.kt new file mode 100644 index 0000000..dd10ab3 --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/auth/model/response/SignUpResponse.kt @@ -0,0 +1,5 @@ +package com.hero.alignlab.domain.auth.model.response + +data class SignUpResponse( + val accessToken: String, +) diff --git a/src/main/kotlin/com/hero/alignlab/domain/auth/resolver/ReactiveUserResolver.kt b/src/main/kotlin/com/hero/alignlab/domain/auth/resolver/ReactiveUserResolver.kt new file mode 100644 index 0000000..213ab99 --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/auth/resolver/ReactiveUserResolver.kt @@ -0,0 +1,51 @@ +package com.hero.alignlab.domain.auth.resolver + +import com.hero.alignlab.domain.auth.application.AuthFacade +import com.hero.alignlab.domain.auth.model.AUTH_TOKEN_KEY +import com.hero.alignlab.domain.auth.model.AuthUser +import com.hero.alignlab.domain.auth.model.AuthUserToken +import org.springframework.core.MethodParameter +import org.springframework.core.ReactiveAdapterRegistry +import org.springframework.http.server.reactive.ServerHttpRequest +import org.springframework.web.reactive.BindingContext +import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolverSupport +import org.springframework.web.server.ServerWebExchange +import reactor.core.publisher.Mono +import reactor.kotlin.core.publisher.toMono + +class ReactiveUserResolver( + adapterRegistry: ReactiveAdapterRegistry, + private val authFacade: AuthFacade, +) : HandlerMethodArgumentResolverSupport(adapterRegistry) { + override fun supportsParameter(parameter: MethodParameter): Boolean { + return parameter.parameterType == AuthUser::class.java + } + + override fun resolveArgument( + parameter: MethodParameter, + bindingContext: BindingContext, + exchange: ServerWebExchange, + ): Mono { + val tokenMono = resolveToken(exchange.request) + + return authFacade.resolveAuthUser(tokenMono) + } + + private fun resolveToken(request: ServerHttpRequest): Mono { + val authUserToken = request.headers + .asSequence() + .filter { header -> isTokenHeader(header.key) } + .mapNotNull { header -> + header.value + .firstOrNull() + ?.takeIf { token -> token.isNotBlank() } + ?.let { token -> AuthUserToken.from(token) } + }.firstOrNull() ?: AuthUserToken.from("") + + return authUserToken.toMono() + } + + private fun isTokenHeader(headerKey: String): Boolean { + return AUTH_TOKEN_KEY.equals(headerKey, ignoreCase = true) + } +} diff --git a/src/main/kotlin/com/hero/alignlab/domain/auth/resource/AuthResource.kt b/src/main/kotlin/com/hero/alignlab/domain/auth/resource/AuthResource.kt new file mode 100644 index 0000000..a5638fc --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/auth/resource/AuthResource.kt @@ -0,0 +1,33 @@ +package com.hero.alignlab.domain.auth.resource + +import com.hero.alignlab.domain.auth.application.AuthFacade +import com.hero.alignlab.domain.auth.model.request.SignInRequest +import com.hero.alignlab.domain.auth.model.request.SignUpRequest +import com.hero.alignlab.extension.wrapCreated +import com.hero.alignlab.extension.wrapOk +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "Auth 인증 및 인가 관리") +@RestController +@RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE]) +class AuthResource( + private val authFacade: AuthFacade, +) { + @Operation(summary = "회원가입") + @PostMapping("/api/v1/auth/sign-up") + suspend fun signUp( + @RequestBody request: SignUpRequest, + ) = authFacade.signUp(request).wrapCreated() + + @Operation(summary = "로그인") + @PostMapping("/api/v1/auth/sign-in") + suspend fun signUp( + @RequestBody request: SignInRequest, + ) = authFacade.signIn(request).wrapOk() +} diff --git a/src/main/kotlin/com/hero/alignlab/domain/common/converter/AbstractEncryptConverter.kt b/src/main/kotlin/com/hero/alignlab/domain/common/converter/AbstractEncryptConverter.kt new file mode 100644 index 0000000..367ec58 --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/common/converter/AbstractEncryptConverter.kt @@ -0,0 +1,17 @@ +package com.hero.alignlab.domain.common.converter + +import com.hero.alignlab.common.encrypt.EncryptData +import com.hero.alignlab.common.encrypt.Encryptor +import jakarta.persistence.AttributeConverter + +abstract class AbstractEncryptConverter : AttributeConverter { + abstract var encryptor: Encryptor + + override fun convertToDatabaseColumn(attribute: EncryptData?): String? { + return attribute?.encData + } + + override fun convertToEntityAttribute(dbData: String?): EncryptData? { + return dbData?.let { encData -> EncryptData(encData) } + } +} diff --git a/src/main/kotlin/com/hero/alignlab/domain/common/domain/BaseEntity.kt b/src/main/kotlin/com/hero/alignlab/domain/common/domain/BaseEntity.kt new file mode 100644 index 0000000..a89eaff --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/common/domain/BaseEntity.kt @@ -0,0 +1,28 @@ +package com.hero.alignlab.domain.common.domain + +import com.fasterxml.jackson.annotation.JsonFormat +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +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 + +@MappedSuperclass +@JsonIgnoreProperties(value = ["createdAt, modifiedAt"], allowGetters = true) +@EntityListeners(AuditingEntityListener::class) +abstract class BaseEntity { + /** 생성일 */ + @CreatedDate + @Column(name = "created_at", columnDefinition = "datetime default CURRENT_TIMESTAMP") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", timezone = "Asia/Seoul") + var createdAt: LocalDateTime = LocalDateTime.now() + + /** 수정일 */ + @LastModifiedDate + @Column(name = "modified_at", columnDefinition = "datetime default CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", timezone = "Asia/Seoul") + var modifiedAt: LocalDateTime = LocalDateTime.now() +} diff --git a/src/main/kotlin/com/hero/alignlab/domain/user/application/CredentialUserInfoService.kt b/src/main/kotlin/com/hero/alignlab/domain/user/application/CredentialUserInfoService.kt new file mode 100644 index 0000000..3aeed7f --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/user/application/CredentialUserInfoService.kt @@ -0,0 +1,33 @@ +package com.hero.alignlab.domain.user.application + +import com.hero.alignlab.common.encrypt.EncryptData +import com.hero.alignlab.common.encrypt.Encryptor +import com.hero.alignlab.domain.user.domain.CredentialUserInfo +import com.hero.alignlab.domain.user.infrastructure.CredentialUserInfoRepository +import com.hero.alignlab.exception.ErrorCode +import com.hero.alignlab.exception.NotFoundException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.springframework.stereotype.Service + +@Service +class CredentialUserInfoService( + private val credentialUserInfoRepository: CredentialUserInfoRepository, + private val encryptor: Encryptor, +) { + suspend fun existsByUsername(username: String): Boolean { + return withContext(Dispatchers.IO) { + credentialUserInfoRepository.existsByUsername(username) + } + } + + fun save(credentialUserInfo: CredentialUserInfo): CredentialUserInfo { + return credentialUserInfoRepository.save(credentialUserInfo) + } + + suspend fun findByUsernameAndPassword(username: String, password: String): CredentialUserInfo { + return withContext(Dispatchers.IO) { + credentialUserInfoRepository.findByUsernameAndPassword(username, EncryptData.enc(password, encryptor)) + } ?: throw NotFoundException(ErrorCode.NOT_FOUND_USER_ERROR) + } +} diff --git a/src/main/kotlin/com/hero/alignlab/domain/user/application/UserService.kt b/src/main/kotlin/com/hero/alignlab/domain/user/application/UserService.kt new file mode 100644 index 0000000..f68b42a --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/user/application/UserService.kt @@ -0,0 +1,41 @@ +package com.hero.alignlab.domain.user.application + +import com.hero.alignlab.domain.auth.model.AuthUser +import com.hero.alignlab.domain.user.domain.UserInfo +import com.hero.alignlab.domain.user.infrastructure.UserInfoRepository +import com.hero.alignlab.domain.user.model.response.UserInfoResponse +import com.hero.alignlab.exception.ErrorCode +import com.hero.alignlab.exception.NotFoundException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service + +@Service +class UserService( + private val userInfoRepository: UserInfoRepository, +) { + fun getUserByIdOrThrowSync(id: Long): UserInfo { + return userInfoRepository.findByIdOrNull(id) + ?: throw NotFoundException(ErrorCode.NOT_FOUND_USER_ERROR) + } + + fun getUserByIdOrNullSync(id: Long): UserInfo? { + return userInfoRepository.findByIdOrNull(id) + } + + fun save(userInfo: UserInfo): UserInfo { + return userInfoRepository.save(userInfo) + } + + suspend fun getUserInfo(user: AuthUser): UserInfoResponse { + val userInfo = withContext(Dispatchers.IO) { + getUserByIdOrThrowSync(user.uid) + } + + return UserInfoResponse( + uid = userInfo.id, + nickname = userInfo.nickname + ) + } +} diff --git a/src/main/kotlin/com/hero/alignlab/domain/user/domain/CredentialUserInfo.kt b/src/main/kotlin/com/hero/alignlab/domain/user/domain/CredentialUserInfo.kt new file mode 100644 index 0000000..7d54218 --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/user/domain/CredentialUserInfo.kt @@ -0,0 +1,24 @@ +package com.hero.alignlab.domain.user.domain + +import com.hero.alignlab.common.encrypt.EncryptData +import com.hero.alignlab.domain.common.domain.BaseEntity +import com.hero.alignlab.domain.user.domain.converter.PasswordConverter +import jakarta.persistence.* + +@Entity +@Table(name = "credential_user_info") +class CredentialUserInfo( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = -1, + + @Column(name = "uid") + val uid: Long, + + @Column(name = "username") + val username: String, + + @Column(name = "password") + @Convert(converter = PasswordConverter::class) + val password: EncryptData, +) : BaseEntity() diff --git a/src/main/kotlin/com/hero/alignlab/domain/user/domain/UserInfo.kt b/src/main/kotlin/com/hero/alignlab/domain/user/domain/UserInfo.kt new file mode 100644 index 0000000..5bcf855 --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/user/domain/UserInfo.kt @@ -0,0 +1,15 @@ +package com.hero.alignlab.domain.user.domain + +import com.hero.alignlab.domain.common.domain.BaseEntity +import jakarta.persistence.* + +@Entity +@Table(name = "user_info") +class UserInfo( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = -1, + + @Column(name = "nickname") + val nickname: String, +) : BaseEntity() diff --git a/src/main/kotlin/com/hero/alignlab/domain/user/domain/converter/PasswordConverter.kt b/src/main/kotlin/com/hero/alignlab/domain/user/domain/converter/PasswordConverter.kt new file mode 100644 index 0000000..7341d9a --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/user/domain/converter/PasswordConverter.kt @@ -0,0 +1,10 @@ +package com.hero.alignlab.domain.user.domain.converter + +import com.hero.alignlab.common.encrypt.Encryptor +import com.hero.alignlab.domain.common.converter.AbstractEncryptConverter +import jakarta.persistence.Convert + +@Convert +class PasswordConverter( + override var encryptor: Encryptor +) : AbstractEncryptConverter() diff --git a/src/main/kotlin/com/hero/alignlab/domain/user/infrastructure/CredentialUserInfoRepository.kt b/src/main/kotlin/com/hero/alignlab/domain/user/infrastructure/CredentialUserInfoRepository.kt new file mode 100644 index 0000000..384f99d --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/user/infrastructure/CredentialUserInfoRepository.kt @@ -0,0 +1,15 @@ +package com.hero.alignlab.domain.user.infrastructure + +import com.hero.alignlab.common.encrypt.EncryptData +import com.hero.alignlab.domain.user.domain.CredentialUserInfo +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import org.springframework.transaction.annotation.Transactional + +@Transactional(readOnly = true) +@Repository +interface CredentialUserInfoRepository : JpaRepository { + fun existsByUsername(username: String): Boolean + + fun findByUsernameAndPassword(username: String, password: EncryptData): CredentialUserInfo? +} diff --git a/src/main/kotlin/com/hero/alignlab/domain/user/infrastructure/UserInfoRepository.kt b/src/main/kotlin/com/hero/alignlab/domain/user/infrastructure/UserInfoRepository.kt new file mode 100644 index 0000000..781d45f --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/user/infrastructure/UserInfoRepository.kt @@ -0,0 +1,10 @@ +package com.hero.alignlab.domain.user.infrastructure + +import com.hero.alignlab.domain.user.domain.UserInfo +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import org.springframework.transaction.annotation.Transactional + +@Transactional(readOnly = true) +@Repository +interface UserInfoRepository : JpaRepository diff --git a/src/main/kotlin/com/hero/alignlab/domain/user/model/response/UserInfoResponse.kt b/src/main/kotlin/com/hero/alignlab/domain/user/model/response/UserInfoResponse.kt new file mode 100644 index 0000000..4a4e173 --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/user/model/response/UserInfoResponse.kt @@ -0,0 +1,6 @@ +package com.hero.alignlab.domain.user.model.response + +data class UserInfoResponse( + val uid: Long, + val nickname: String, +) diff --git a/src/main/kotlin/com/hero/alignlab/domain/user/resource/UserResource.kt b/src/main/kotlin/com/hero/alignlab/domain/user/resource/UserResource.kt new file mode 100644 index 0000000..bfe7959 --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/domain/user/resource/UserResource.kt @@ -0,0 +1,24 @@ +package com.hero.alignlab.domain.user.resource + +import com.hero.alignlab.domain.auth.model.AuthUser +import com.hero.alignlab.domain.user.application.UserService +import com.hero.alignlab.extension.wrapOk +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "사용자 관리") +@RestController +@RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE]) +class UserResource( + private val userService: UserService, +) { + @Operation(summary = "토큰 기반으로 유저 정보를 조회") + @GetMapping("/api/v1/users/me") + suspend fun getUserInfo( + user: AuthUser + ) = userService.getUserInfo(user).wrapOk() +} diff --git a/src/main/kotlin/com/hero/alignlab/exception/ErrorCode.kt b/src/main/kotlin/com/hero/alignlab/exception/ErrorCode.kt index c0fcd60..85eb824 100644 --- a/src/main/kotlin/com/hero/alignlab/exception/ErrorCode.kt +++ b/src/main/kotlin/com/hero/alignlab/exception/ErrorCode.kt @@ -14,7 +14,16 @@ enum class ErrorCode(val status: HttpStatus, val description: String) { COROUTINE_CANCELLATION_ERROR(HttpStatus.BAD_REQUEST, "coroutine cancellation error"), FAIL_TO_TRANSACTION_TEMPLATE_EXECUTE_ERROR(HttpStatus.BAD_REQUEST, "fail to tx-templates execute error"), - /** short url */ - NOT_FOUND_SHORT_URL(HttpStatus.NOT_FOUND, "not found short url"), + /** Auth Error Code */ + FAIL_TO_VERIFY_TOKEN_ERROR(HttpStatus.UNAUTHORIZED, "fail to verify token"), + INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "유효한 엑세스 토큰이 아닙니다."), + INVALID_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "유효한 리프레시 토큰이 아닙니다."), + INVALID_TOKEN(HttpStatus.BAD_REQUEST, "유효한 토큰이 아닙니다."), + NO_AUTHORITY_ERROR(HttpStatus.FORBIDDEN, "권한이 없습니다."), + INVALID_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "잘못된 oauth 벤더입니다."), + DUPLICATED_USERNAME_ERROR(HttpStatus.BAD_REQUEST, "중복된 아이디 입니다."), + + /** User Error Code */ + NOT_FOUND_USER_ERROR(HttpStatus.NOT_FOUND, "유저 정보를 찾을 수 없습니다."), ; } diff --git a/src/main/kotlin/com/hero/alignlab/extension/EncoderExtension.kt b/src/main/kotlin/com/hero/alignlab/extension/EncoderExtension.kt new file mode 100644 index 0000000..8f4f187 --- /dev/null +++ b/src/main/kotlin/com/hero/alignlab/extension/EncoderExtension.kt @@ -0,0 +1,10 @@ +package com.hero.alignlab.extension + +import java.net.URLDecoder +import java.net.URLEncoder +import java.util.* + +fun String.encodeURL(type: String = "UTF-8"): String = URLEncoder.encode(this, type) +fun String.decodeURL(type: String = "UTF-8"): String = URLDecoder.decode(this, type) + +fun String.decodeBase64(): String = String(Base64.getDecoder().decode(this)) diff --git a/src/main/resources/config/application.yml b/src/main/resources/config/application.yml index 2741095..2267e8b 100644 --- a/src/main/resources/config/application.yml +++ b/src/main/resources/config/application.yml @@ -39,3 +39,11 @@ hero: show-sql: false database: mysql database-platform: org.hibernate.dialect.MySQLDialect + +auth: + jwt: + secret: + +encrypt: + key: + algorithm: