Skip to content

Commit c816e14

Browse files
authored
feat: 사용자를 추가하고, 소셜 로그인 기능을 만들었어요 (#157)
* feat: JWT 인증/인가 부분 관련 작업을 진행해요 * feat: 사용자 도메인을 만들어요 * feat: 소셜 로그인 기능을 추가해요 * chore: 임시 코드들을 정리해요 * refactor: ConfigurationProperties 적용 * fix: id 기본값 null 주입 * feat: RuntimeException 던지는 구간에 에러로깅 추가 * chore: Swagger 문서를 추가해요 * feat: HttpExchange를 사용하도록 변경 * refactor: MemberService -> AuthService * feat: 리뷰 반영 * feat: 에러코드 반영
1 parent 821060a commit c816e14

37 files changed

+729
-96
lines changed

build.gradle.kts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ dependencies {
2929
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
3030
testImplementation("org.springframework.security:spring-security-test")
3131
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
32+
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0")
33+
34+
val jjwtVersion = "0.12.5"
35+
implementation("io.jsonwebtoken:jjwt-api:$jjwtVersion")
36+
runtimeOnly("io.jsonwebtoken:jjwt-impl:$jjwtVersion")
37+
runtimeOnly("io.jsonwebtoken:jjwt-jackson:$jjwtVersion")
38+
implementation("com.nimbusds:nimbus-jose-jwt:9.37.2")
3239
}
3340

3441
kotlin {
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package com.depromeet.makers
22

33
import org.springframework.boot.autoconfigure.SpringBootApplication
4+
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
45
import org.springframework.boot.runApplication
56

7+
@ConfigurationPropertiesScan
68
@SpringBootApplication
79
class DepromeetMakersBeApplication
810

911
fun main(args: Array<String>) {
10-
runApplication<DepromeetMakersBeApplication>(*args)
12+
runApplication<DepromeetMakersBeApplication>(*args)
1113
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package com.depromeet.makers.components
2+
3+
import com.depromeet.makers.config.properties.AppProperties
4+
import com.depromeet.makers.domain.exception.DomainException
5+
import com.depromeet.makers.domain.exception.ErrorCode
6+
import com.depromeet.makers.domain.vo.TokenPair
7+
import com.depromeet.makers.util.logger
8+
import io.jsonwebtoken.Jwts
9+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
10+
import org.springframework.security.core.Authentication
11+
import org.springframework.security.core.authority.SimpleGrantedAuthority
12+
import org.springframework.stereotype.Component
13+
import java.util.Date
14+
import javax.crypto.SecretKey
15+
import javax.crypto.spec.SecretKeySpec
16+
17+
@Component
18+
class JWTTokenProvider(
19+
private val appProperties: AppProperties,
20+
) {
21+
private val logger = logger()
22+
private final val signKey: SecretKey = SecretKeySpec(appProperties.token.secretKey.toByteArray(), "AES")
23+
private val jwtParser = Jwts
24+
.parser()
25+
.decryptWith(signKey)
26+
.build()
27+
28+
fun generateTokenPair(authentication: Authentication): TokenPair {
29+
val accessToken = generateAccessToken(authentication)
30+
val refreshToken = generateRefreshToken(authentication)
31+
return TokenPair(accessToken, refreshToken)
32+
}
33+
34+
private fun generateAccessToken(authentication: Authentication): String {
35+
val authorities = authentication.authorities.joinToString(",") {
36+
it.authority
37+
}
38+
return Jwts.builder()
39+
.header()
40+
.add(TOKEN_TYPE_HEADER_KEY, ACCESS_TOKEN_TYPE_VALUE)
41+
.and()
42+
.claims()
43+
.add(USER_ID_CLAIM_KEY, authentication.name)
44+
.add(AUTHORITIES_CLAIM_KEY, authorities)
45+
.and()
46+
.expiration(generateAccessTokenExpiration())
47+
.encryptWith(signKey, Jwts.ENC.A128CBC_HS256)
48+
.compact()
49+
}
50+
51+
private fun generateRefreshToken(authentication: Authentication): String = Jwts.builder()
52+
.header()
53+
.add(TOKEN_TYPE_HEADER_KEY, REFRESH_TOKEN_TYPE_VALUE)
54+
.and()
55+
.claims()
56+
.add(USER_ID_CLAIM_KEY, authentication.name)
57+
.and()
58+
.expiration(generateRefreshTokenExpiration())
59+
.encryptWith(signKey, Jwts.ENC.A128CBC_HS256)
60+
.compact()
61+
62+
fun parseAuthentication(accessToken: String): Authentication {
63+
val claims = runCatching {
64+
jwtParser.parseEncryptedClaims(accessToken)
65+
}.getOrElse {
66+
throw DomainException(ErrorCode.TOKEN_EXPIRED)
67+
}
68+
val tokenType = claims.header[TOKEN_TYPE_HEADER_KEY] ?: run {
69+
logger.error("Token type not found in header - claims($claims)")
70+
throw RuntimeException("Invalid token type!")
71+
}
72+
if (tokenType != ACCESS_TOKEN_TYPE_VALUE) {
73+
logger.error("Token is not an access token - tokenType($tokenType)")
74+
throw RuntimeException()
75+
}
76+
77+
val userId = claims.payload[USER_ID_CLAIM_KEY] as? String? ?: run {
78+
logger.error("Cannot parse userId from claims - claims($claims)")
79+
throw RuntimeException()
80+
}
81+
val authoritiesStr = claims.payload[AUTHORITIES_CLAIM_KEY] as? String?
82+
val authorities = if (authoritiesStr.isNullOrEmpty()) {
83+
emptyList()
84+
} else {
85+
claims.payload[AUTHORITIES_CLAIM_KEY]?.toString()
86+
?.split(",")
87+
?.map { SimpleGrantedAuthority("ROLE_$it") }
88+
?: emptyList()
89+
}
90+
91+
return UsernamePasswordAuthenticationToken(
92+
userId,
93+
accessToken,
94+
authorities,
95+
)
96+
}
97+
98+
fun getMemberIdFromRefreshToken(refreshToken: String): String {
99+
val claims = jwtParser.parseEncryptedClaims(refreshToken)
100+
val tokenType = claims.header[TOKEN_TYPE_HEADER_KEY] ?: run {
101+
logger.error("Token type not found in header - claims($claims)")
102+
throw RuntimeException()
103+
}
104+
if (tokenType != REFRESH_TOKEN_TYPE_VALUE) {
105+
logger.error("Token is not an refresh token - tokenType($tokenType)")
106+
throw RuntimeException()
107+
}
108+
109+
return claims.payload[USER_ID_CLAIM_KEY] as? String ?: run {
110+
logger.error("Cannot parse userId from claims - claims($claims)")
111+
throw RuntimeException()
112+
}
113+
}
114+
115+
private fun generateAccessTokenExpiration() = Date(System.currentTimeMillis() + appProperties.token.expiration.access * 1000)
116+
117+
private fun generateRefreshTokenExpiration() = Date(System.currentTimeMillis() + appProperties.token.expiration.refresh * 1000)
118+
119+
companion object {
120+
const val USER_ID_CLAIM_KEY = "user_id"
121+
const val AUTHORITIES_CLAIM_KEY = "authorities"
122+
123+
const val TOKEN_TYPE_HEADER_KEY = "token_type"
124+
const val ACCESS_TOKEN_TYPE_VALUE = "access_token"
125+
const val REFRESH_TOKEN_TYPE_VALUE = "refresh_token"
126+
}
127+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package com.depromeet.makers.components
2+
3+
import com.depromeet.makers.domain.exception.DomainException
4+
import com.depromeet.makers.domain.exception.ErrorCode
5+
import com.depromeet.makers.presentation.web.dto.response.AppleKeyResponse
6+
import com.depromeet.makers.presentation.web.dto.response.KakaoAccountResponse
7+
import com.depromeet.makers.repository.client.AppleAuthClient
8+
import com.depromeet.makers.repository.client.KakaoAuthClient
9+
import com.depromeet.makers.util.logger
10+
import com.fasterxml.jackson.databind.ObjectMapper
11+
import com.nimbusds.jose.jwk.JWK
12+
import io.jsonwebtoken.Jwts
13+
import org.springframework.stereotype.Component
14+
import java.util.Base64
15+
16+
@Component
17+
class SocialLoginProvider(
18+
private val objectMapper: ObjectMapper,
19+
private val appleAuthClient: AppleAuthClient,
20+
private val kakaoAuthClient: KakaoAuthClient,
21+
) {
22+
private val logger = logger()
23+
24+
fun authenticateFromKakao(accessToken: String): KakaoAccountResponse = try {
25+
kakaoAuthClient.getKakaoAccount(mapOf("Authorization" to "Bearer $accessToken"))
26+
} catch(e: Exception) {
27+
logger.error("[SocialLoginProvider] failed kakao with error - ${e.message}")
28+
throw DomainException(ErrorCode.INTERNAL_ERROR)
29+
}
30+
31+
32+
fun authenticateFromApple(identityToken: String): String {
33+
val pubKeys = try {
34+
appleAuthClient.getApplyKeys()
35+
} catch(e: Exception) {
36+
logger.error("[SocialLoginProvider] failed applekey with error - ${e.message}")
37+
throw DomainException(ErrorCode.INTERNAL_ERROR)
38+
}
39+
return getUserIdFromAppleIdentity(pubKeys.keys.toTypedArray(), identityToken)
40+
}
41+
42+
43+
private fun getUserIdFromAppleIdentity(keys: Array<AppleKeyResponse>, identityToken: String): String {
44+
val tokenParts = identityToken.split("\\.")
45+
val headerPart = String(Base64.getDecoder().decode(tokenParts[0]))
46+
val headerNode = objectMapper.readTree(headerPart)
47+
val kid = headerNode.get("kid").asText()
48+
49+
val keyStr = keys.firstOrNull { it.kid == kid }
50+
?: throw RuntimeException("Apple Key not found")
51+
52+
val pubKey = JWK.parse(objectMapper.writeValueAsString(keyStr))
53+
.toRSAKey()
54+
.toRSAPublicKey()
55+
56+
return Jwts.parser()
57+
.verifyWith(pubKey)
58+
.build()
59+
.parseSignedClaims(identityToken)
60+
.payload
61+
.get("sub", String::class.java)
62+
}
63+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.depromeet.makers.config
2+
3+
import io.swagger.v3.oas.models.Components
4+
import io.swagger.v3.oas.models.OpenAPI
5+
import io.swagger.v3.oas.models.info.Info
6+
import io.swagger.v3.oas.models.security.SecurityRequirement
7+
import io.swagger.v3.oas.models.security.SecurityScheme
8+
import io.swagger.v3.oas.models.servers.Server
9+
import org.springframework.context.annotation.Bean
10+
import org.springframework.context.annotation.Configuration
11+
12+
@Configuration
13+
class SwaggerConfig {
14+
@Bean
15+
fun openAPI(): OpenAPI = OpenAPI()
16+
.servers(
17+
listOf(
18+
Server()
19+
.description("API 서버 URL")
20+
.url("http://localhost:8080"),
21+
),
22+
)
23+
.info(
24+
Info()
25+
.title("Depromeet Makers BE API")
26+
.version("1.0"),
27+
)
28+
.addSecurityItem(SecurityRequirement().addList("JWT 토큰"))
29+
.components(
30+
Components().addSecuritySchemes(
31+
"JWT 토큰",
32+
SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("Bearer").bearerFormat("JWT"),
33+
),
34+
)
35+
}
36+
Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,36 @@
11
package com.depromeet.makers.config
22

3+
import com.depromeet.makers.repository.client.AppleAuthClient
4+
import com.depromeet.makers.repository.client.KakaoAuthClient
5+
import org.springframework.context.annotation.Bean
36
import org.springframework.context.annotation.Configuration
7+
import org.springframework.web.client.RestClient
8+
import org.springframework.web.client.support.RestClientAdapter
9+
import org.springframework.web.service.invoker.HttpServiceProxyFactory
410
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
511

612
@Configuration
7-
class WebConfig: WebMvcConfigurer {
13+
class WebConfig : WebMvcConfigurer {
14+
@Bean
15+
fun restClient(): RestClient {
16+
return RestClient.create()
17+
}
18+
19+
@Bean
20+
fun appleAuthClient(restClient: RestClient): AppleAuthClient {
21+
val restClientAdapter = RestClientAdapter.create(restClient)
22+
return HttpServiceProxyFactory
23+
.builderFor(restClientAdapter)
24+
.build()
25+
.createClient(AppleAuthClient::class.java)
26+
}
27+
28+
@Bean
29+
fun kakaoAuthClient(restClient: RestClient): KakaoAuthClient {
30+
val restClientAdapter = RestClientAdapter.create(restClient)
31+
return HttpServiceProxyFactory
32+
.builderFor(restClientAdapter)
33+
.build()
34+
.createClient(KakaoAuthClient::class.java)
35+
}
836
}

src/main/kotlin/com/depromeet/makers/config/WebExceptionHandler.kt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.depromeet.makers.config
22

33
import com.depromeet.makers.domain.exception.DomainException
4+
import com.depromeet.makers.domain.exception.ErrorCode
45
import com.depromeet.makers.presentation.web.dto.response.ErrorResponse
56
import org.springframework.http.HttpStatus
67
import org.springframework.http.ResponseEntity
@@ -13,7 +14,7 @@ class WebExceptionHandler {
1314
fun handleDomainException(e: DomainException): ResponseEntity<ErrorResponse> {
1415
val errorCode = e.errorCode
1516
if (errorCode.isCriticalError()) {
16-
// return handleUnhandledException(e)
17+
return handleUnhandledException(e)
1718
}
1819

1920
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
@@ -22,8 +23,9 @@ class WebExceptionHandler {
2223

2324
@ExceptionHandler
2425
fun handleUnhandledException(e: Exception): ResponseEntity<ErrorResponse> {
25-
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
26-
.body(ErrorResponse(0, ""))
26+
e.printStackTrace()
27+
return ResponseEntity
28+
.status(HttpStatus.INTERNAL_SERVER_ERROR)
29+
.body(ErrorResponse(ErrorCode.INTERNAL_ERROR.code, ErrorCode.INTERNAL_ERROR.message))
2730
}
28-
2931
}

0 commit comments

Comments
 (0)