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
3 changes: 1 addition & 2 deletions api/src/main/kotlin/filter/ErrorWebFilter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,14 @@ class ErrorWebFilter(
}

private fun makeErrorBody(exception: Snu4tException): ErrorBody {
return ErrorBody(exception.error.errorCode, exception.errorMessage, exception.displayMessage, exception.detail, exception.ext)
return ErrorBody(exception.error.errorCode, exception.errorMessage, exception.displayMessage, exception.ext)
}
}

private data class ErrorBody(
val errcode: Long,
val message: String,
val displayMessage: String,
val detail: Any? = null,
// TODO: 구버전 대응용 ext 필드. 추후 삭제
val ext: Map<String, String> = mapOf(),
)
6 changes: 6 additions & 0 deletions api/src/main/kotlin/handler/AuthHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ class AuthHandler(
userService.loginKakao(socialLoginRequest)
}

suspend fun loginApple(req: ServerRequest): ServerResponse =
handle(req) {
val socialLoginRequest: SocialLoginRequest = req.awaitBodyOrNull() ?: throw ServerWebInputException("Invalid body")
userService.loginApple(socialLoginRequest)
}

suspend fun logout(req: ServerRequest): ServerResponse =
handle(req) {
val userId = req.userId
Expand Down
8 changes: 8 additions & 0 deletions api/src/main/kotlin/handler/TimetableThemeHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ class TimetableThemeHandler(
ListResponse(result)
}

suspend fun getTheme(req: ServerRequest) =
handle(req) {
val userId = req.userId
val themeId = req.pathVariable("themeId")

TimetableThemeDto(timetableThemeService.getTheme(userId, themeId))
}

suspend fun addTheme(req: ServerRequest) =
handle(req) {
val userId = req.userId
Expand Down
20 changes: 10 additions & 10 deletions api/src/main/kotlin/handler/UserHandler.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
package com.wafflestudio.snu4t.handler

import com.wafflestudio.snu4t.auth.SocialProvider
import com.wafflestudio.snu4t.auth.AuthProvider
import com.wafflestudio.snu4t.common.dto.OkResponse
import com.wafflestudio.snu4t.common.extension.toZonedDateTime
import com.wafflestudio.snu4t.middleware.SnuttRestApiDefaultMiddleware
import com.wafflestudio.snu4t.users.data.User
import com.wafflestudio.snu4t.users.dto.AuthProvidersCheckDto
import com.wafflestudio.snu4t.users.dto.EmailVerificationResultDto
import com.wafflestudio.snu4t.users.dto.LocalLoginRequest
import com.wafflestudio.snu4t.users.dto.PasswordChangeRequest
import com.wafflestudio.snu4t.users.dto.SendEmailRequest
import com.wafflestudio.snu4t.users.dto.SocialLoginRequest
import com.wafflestudio.snu4t.users.dto.SocialProvidersCheckDto
import com.wafflestudio.snu4t.users.dto.UserDto
import com.wafflestudio.snu4t.users.dto.UserLegacyDto
import com.wafflestudio.snu4t.users.dto.UserPatchRequest
Expand Down Expand Up @@ -106,46 +106,46 @@ class UserHandler(
handle(req) {
val user = req.getContext().user!!
val socialLoginRequest: SocialLoginRequest = req.awaitBody()
userService.attachSocial(user, socialLoginRequest, SocialProvider.FACEBOOK)
userService.attachSocial(user, socialLoginRequest, AuthProvider.FACEBOOK)
}

suspend fun attachGoogle(req: ServerRequest): ServerResponse =
handle(req) {
val user = req.getContext().user!!
val socialLoginRequest: SocialLoginRequest = req.awaitBody()
userService.attachSocial(user, socialLoginRequest, SocialProvider.GOOGLE)
userService.attachSocial(user, socialLoginRequest, AuthProvider.GOOGLE)
}

suspend fun attachKakao(req: ServerRequest): ServerResponse =
handle(req) {
val user = req.getContext().user!!
val socialLoginRequest: SocialLoginRequest = req.awaitBody()
userService.attachSocial(user, socialLoginRequest, SocialProvider.KAKAO)
userService.attachSocial(user, socialLoginRequest, AuthProvider.KAKAO)
}

suspend fun detachFacebook(req: ServerRequest): ServerResponse =
handle(req) {
val user = req.getContext().user!!
userService.detachSocial(user, SocialProvider.FACEBOOK)
userService.detachSocial(user, AuthProvider.FACEBOOK)
}

suspend fun detachGoogle(req: ServerRequest): ServerResponse =
handle(req) {
val user = req.getContext().user!!
userService.detachSocial(user, SocialProvider.GOOGLE)
userService.detachSocial(user, AuthProvider.GOOGLE)
}

suspend fun detachKakao(req: ServerRequest): ServerResponse =
handle(req) {
val user = req.getContext().user!!
userService.detachSocial(user, SocialProvider.KAKAO)
userService.detachSocial(user, AuthProvider.KAKAO)
}

suspend fun checkSocialProviders(req: ServerRequest): ServerResponse =
suspend fun checkAuthProviders(req: ServerRequest): ServerResponse =
handle(req) {
val user = req.getContext().user!!

SocialProvidersCheckDto(
AuthProvidersCheckDto(
local = user.credential.localId != null,
facebook = user.credential.fbName != null,
google = user.credential.googleSub != null,
Expand Down
5 changes: 4 additions & 1 deletion api/src/main/kotlin/router/MainRouter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ class MainRouter(
POST("/login/facebook", authHandler::loginFacebook)
POST("/login/google", authHandler::loginGoogle)
POST("/login/kakao", authHandler::loginKakao)
POST("/login/apple", authHandler::loginApple)
POST("/logout", authHandler::logout)
POST("/password/reset/email/check", authHandler::getMaskedEmail)
POST("/password/reset/email/send", authHandler::sendResetPasswordCode)
Expand Down Expand Up @@ -120,7 +121,8 @@ class MainRouter(
"/users".nest {
GET("/me", userHandler::getUserMe)
PATCH("/me", userHandler::patchUserInfo)
GET("/me/social_providers", userHandler::checkSocialProviders)
GET("/me/social_providers", userHandler::checkAuthProviders)
GET("/me/auth-providers", userHandler::checkAuthProviders)
}
}

Expand Down Expand Up @@ -263,6 +265,7 @@ class MainRouter(
GET("/best", timetableThemeHandler::getBestThemes)
GET("/friends", timetableThemeHandler::getFriendsThemes)
POST("", timetableThemeHandler::addTheme)
GET("/{themeId}", timetableThemeHandler::getTheme)
PATCH("/{themeId}", timetableThemeHandler::modifyTheme)
DELETE("/{themeId}", timetableThemeHandler::deleteTheme)
POST("/{themeId}/copy", timetableThemeHandler::copyTheme)
Expand Down
11 changes: 11 additions & 0 deletions api/src/main/kotlin/router/docs/ThemeDocs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,17 @@ import org.springframework.web.bind.annotation.RequestMethod
],
),
),
RouterOperation(
path = "/v1/themes/{themeId}",
method = [RequestMethod.GET],
produces = [MediaType.APPLICATION_JSON_VALUE],
operation =
Operation(
operationId = "getTheme",
parameters = [Parameter(`in` = ParameterIn.PATH, name = "themeId", required = true)],
responses = [ApiResponse(responseCode = "200", content = [Content(schema = Schema())])],
),
),
RouterOperation(
path = "/v1/themes/{themeId}",
method = [RequestMethod.PATCH],
Expand Down
8 changes: 4 additions & 4 deletions api/src/main/kotlin/router/docs/UserDocs.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package com.wafflestudio.snu4t.router.docs

import com.wafflestudio.snu4t.common.dto.OkResponse
import com.wafflestudio.snu4t.users.dto.AuthProvidersCheckDto
import com.wafflestudio.snu4t.users.dto.EmailVerificationResultDto
import com.wafflestudio.snu4t.users.dto.LocalLoginRequest
import com.wafflestudio.snu4t.users.dto.PasswordChangeRequest
import com.wafflestudio.snu4t.users.dto.SendEmailRequest
import com.wafflestudio.snu4t.users.dto.SocialLoginRequest
import com.wafflestudio.snu4t.users.dto.SocialProvidersCheckDto
import com.wafflestudio.snu4t.users.dto.TokenResponse
import com.wafflestudio.snu4t.users.dto.UserDto
import com.wafflestudio.snu4t.users.dto.UserLegacyDto
Expand Down Expand Up @@ -81,16 +81,16 @@ import org.springframework.web.bind.annotation.RequestMethod
),
),
RouterOperation(
path = "/v1/users/me/social_providers",
path = "/v1/users/me/auth-providers",
method = [RequestMethod.GET],
produces = [MediaType.APPLICATION_JSON_VALUE],
operation =
Operation(
operationId = "getSocialProviders",
operationId = "getAuthProviders",
responses = [
ApiResponse(
responseCode = "200",
content = [Content(schema = Schema(implementation = SocialProvidersCheckDto::class))],
content = [Content(schema = Schema(implementation = AuthProvidersCheckDto::class))],
),
],
),
Expand Down
8 changes: 5 additions & 3 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import java.io.ByteArrayOutputStream

Expand All @@ -12,6 +13,7 @@ plugins {
group = "com.wafflestudio"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_17
java.targetCompatibility = JavaVersion.VERSION_17

allprojects {
repositories {
Expand Down Expand Up @@ -64,9 +66,9 @@ subprojects {
}

tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
jvmTarget = "17"
compilerOptions {
freeCompilerArgs.add("-Xjsr305=strict")
jvmTarget.set(JvmTarget.JVM_17)
}
}

Expand Down
1 change: 1 addition & 0 deletions core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ dependencies {
implementation("software.amazon.awssdk:sts:2.25.15")
implementation("software.amazon.awssdk:ses:2.25.15")
implementation("com.google.firebase:firebase-admin:9.3.0")
implementation("io.jsonwebtoken:jjwt:0.9.1")

testFixturesImplementation("org.testcontainers:mongodb:1.19.0")
testFixturesImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.wafflestudio.snu4t.auth

enum class SocialProvider(val value: String) {
enum class AuthProvider(val value: String) {
LOCAL("local"),
FACEBOOK("facebook"),
APPLE("apple"),
Expand All @@ -11,6 +11,6 @@ enum class SocialProvider(val value: String) {
companion object {
private val mapping = entries.associateBy { e -> e.value }

fun from(value: String): SocialProvider? = mapping[value]
fun from(value: String): AuthProvider? = mapping[value]
}
}
1 change: 1 addition & 0 deletions core/src/main/kotlin/auth/OAuth2UserResponse.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ data class OAuth2UserResponse(
val name: String?,
val email: String?,
val isEmailVerified: Boolean,
val transferInfo: String? = null,
)
67 changes: 67 additions & 0 deletions core/src/main/kotlin/auth/apple/AppleClient.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.wafflestudio.snu4t.auth.apple

import com.wafflestudio.snu4t.auth.OAuth2Client
import com.wafflestudio.snu4t.auth.OAuth2UserResponse
import com.wafflestudio.snu4t.common.extension.get
import io.jsonwebtoken.Jwts
import org.springframework.http.client.reactive.ReactorClientHttpConnector
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.client.WebClient
import reactor.netty.http.client.HttpClient
import java.math.BigInteger
import java.security.KeyFactory
import java.security.PublicKey
import java.security.spec.RSAPublicKeySpec
import java.time.Duration
import java.util.Base64

@Component("APPLE")
class AppleClient(
webClientBuilder: WebClient.Builder,
) : OAuth2Client {
private val webClient =
webClientBuilder.clientConnector(
ReactorClientHttpConnector(
HttpClient.create().responseTimeout(
Duration.ofSeconds(3),
),
),
).build()

companion object {
private const val APPLE_JWK_URI = "https://appleid.apple.com/auth/keys"
}

override suspend fun getMe(token: String): OAuth2UserResponse? {
val jwtHeader = extractJwtHeader(token)
val appleJwk =
webClient.get<List<AppleJwk>>(uri = APPLE_JWK_URI).getOrNull()
?.find {
it.kid == jwtHeader.keyId && it.alg == jwtHeader.algorithm
} ?: return null
val publicKey = convertJwkToPublicKey(appleJwk)
val jwtPayload = verifyAndDecodeToken(token, publicKey)
val appleUserInfo = AppleUserInfo(jwtPayload)
return OAuth2UserResponse(
socialId = appleUserInfo.sub,
name = null,
email = appleUserInfo.email,
isEmailVerified = appleUserInfo.emailVerified ?: true,
transferInfo = appleUserInfo.transferSub,
)
}

private suspend fun extractJwtHeader(token: String) = Jwts.parser().parseClaimsJws(token).header

private suspend fun convertJwkToPublicKey(jwk: AppleJwk): PublicKey {
val modulus = BigInteger(1, Base64.getUrlDecoder().decode(jwk.n))
val exponent = BigInteger(1, Base64.getUrlDecoder().decode(jwk.e))
val spec = RSAPublicKeySpec(modulus, exponent)
return KeyFactory.getInstance("RSA").generatePublic(spec)
}

private suspend fun verifyAndDecodeToken(
token: String,
publicKey: PublicKey,
) = Jwts.parser().setSigningKey(publicKey).parseClaimsJws(token).body
}
10 changes: 10 additions & 0 deletions core/src/main/kotlin/auth/apple/AppleJwk.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.wafflestudio.snu4t.auth.apple

data class AppleJwk(
val kty: String,
val kid: String,
val use: String,
val alg: String,
val n: String,
val e: String,
)
18 changes: 18 additions & 0 deletions core/src/main/kotlin/auth/apple/AppleUserInfo.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.wafflestudio.snu4t.auth.apple

import io.jsonwebtoken.Claims

data class AppleUserInfo(
val sub: String,
val email: String?,
val emailVerified: Boolean?,
val transferSub: String?,
)

fun AppleUserInfo(jwtPayload: Claims): AppleUserInfo =
AppleUserInfo(
sub = jwtPayload.subject,
email = jwtPayload["email"] as? String,
emailVerified = jwtPayload["email_verified"] as? Boolean,
transferSub = jwtPayload["transfer_sub"] as? String,
)
3 changes: 3 additions & 0 deletions core/src/main/kotlin/common/exception/ErrorType.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ enum class ErrorType(
INVALID_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, 40018, "인증 코드가 유효하지 않습니다.", "인증 코드가 유효하지 않습니다."),
ALREADY_LOCAL_ACCOUNT(HttpStatus.BAD_REQUEST, 40019, "이미 로컬 계정이 존재합니다.", "이미 로컬 계정이 존재합니다."),
ALREADY_SOCIAL_ACCOUNT(HttpStatus.BAD_REQUEST, 40020, "이미 소셜 계정이 존재합니다.", "이미 소셜 계정이 존재합니다."),
UPDATE_APP_VERSION(HttpStatus.BAD_REQUEST, 40021, "앱 버전을 업데이트해주세요.", "앱 버전을 업데이트해주세요."),

SOCIAL_CONNECT_FAIL(HttpStatus.UNAUTHORIZED, 40100, "소셜 로그인에 실패했습니다.", "소셜 로그인에 실패했습니다."),

Expand All @@ -65,6 +66,7 @@ enum class ErrorType(
EV_DATA_NOT_FOUND(HttpStatus.NOT_FOUND, 40407, "강의평 데이터를 찾을 수 없습니다.", "강의평 데이터를 찾을 수 없습니다."),
TAG_LIST_NOT_FOUND(HttpStatus.NOT_FOUND, 40408, "태그 리스트를 찾을 수 없습니다.", "태그 리스트를 찾을 수 없습니다."),
FRIEND_LINK_NOT_FOUND(HttpStatus.NOT_FOUND, 40409, "친구 링크가 유효하지 않습니다.", "친구 링크가 유효하지 않습니다."),
SOCIAL_PROVIDER_NOT_ATTACHED(HttpStatus.NOT_FOUND, 40410, "소셜 계정이 연동되지 않았습니다.", "소셜 계정이 연동되지 않았습니다."),

DUPLICATE_VACANCY_NOTIFICATION(HttpStatus.CONFLICT, 40900, "빈자리 알림 중복", "이미 등록된 빈자리 알림입니다."),
DUPLICATE_EMAIL(HttpStatus.CONFLICT, 40901, "이미 사용 중인 이메일입니다.", "이미 사용 중인 이메일입니다."),
Expand All @@ -75,6 +77,7 @@ enum class ErrorType(
DUPLICATE_POPUP_KEY(HttpStatus.CONFLICT, 40906, "중복된 팝업 키입니다.", "중복된 팝업 키입니다."),
ALREADY_DOWNLOADED_THEME(HttpStatus.CONFLICT, 40907, "이미 다운로드한 테마입니다.", "이미 다운로드한 테마입니다."),
DUPLICATE_SOCIAL_ACCOUNT(HttpStatus.CONFLICT, 40908, "이미 연결된 소셜 계정입니다.", "이미 연결된 소셜 계정입니다."),
CANNOT_REMOVE_LAST_AUTH_PROVIDER(HttpStatus.CONFLICT, 40909, "최소 한 개의 로그인 수단은 유지해야 합니다.", "최소 한 개의 로그인 수단은 유지해야 합니다."),

DYNAMIC_LINK_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 50001, "링크 생성 실패", "링크 생성에 실패했습니다. 잠시 후 다시 시도해주세요."),
}
Loading
Loading