From 16c5c5f6b66c99d815d716aa87d7c35764a0660d Mon Sep 17 00:00:00 2001
From: Klaus <klaus@vividnext.co.kr>
Date: Wed, 2 Aug 2023 15:46:02 +0900
Subject: [PATCH] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20?=
 =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EC=84=9C=EB=B8=94=EB=A6=BF=20=ED=95=84?=
 =?UTF-8?q?=ED=84=B0=EC=97=90=EC=84=9C=20Exception=20=EB=B0=9C=EC=83=9D?=
 =?UTF-8?q?=EC=8B=9C=20=EC=B2=98=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../sodalive/common/ExceptionHandlerFilter.kt | 26 ++++++++++++++++
 .../sodalive/configs/SecurityConfig.kt        |  4 +++
 .../sodalive/member/MemberController.kt       | 11 +++++++
 .../sodalive/member/MemberService.kt          | 30 +++++++++++++++++++
 4 files changed, 71 insertions(+)
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/common/ExceptionHandlerFilter.kt

diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/common/ExceptionHandlerFilter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/common/ExceptionHandlerFilter.kt
new file mode 100644
index 0000000..a8ab7ee
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/common/ExceptionHandlerFilter.kt
@@ -0,0 +1,26 @@
+package kr.co.vividnext.sodalive.common
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import org.springframework.web.filter.OncePerRequestFilter
+import javax.servlet.FilterChain
+import javax.servlet.http.HttpServletRequest
+import javax.servlet.http.HttpServletResponse
+
+class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRequestFilter() {
+    override fun doFilterInternal(
+        request: HttpServletRequest,
+        response: HttpServletResponse,
+        filterChain: FilterChain
+    ) {
+        try {
+            filterChain.doFilter(request, response)
+        } catch (e: Exception) {
+            response.status = 401
+            response.contentType = "application/json"
+            response.characterEncoding = "UTF-8"
+
+            val json = objectMapper.writeValueAsString(ApiResponse.error("로그인 정보를 확인해주세요."))
+            response.writer.write(json)
+        }
+    }
+}
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt
index aae4472..172a24a 100644
--- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt
@@ -1,5 +1,7 @@
 package kr.co.vividnext.sodalive.configs
 
+import com.fasterxml.jackson.databind.ObjectMapper
+import kr.co.vividnext.sodalive.common.ExceptionHandlerFilter
 import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
 import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
 import kr.co.vividnext.sodalive.jwt.JwtFilter
@@ -21,6 +23,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
 @EnableWebSecurity
 @EnableGlobalMethodSecurity(prePostEnabled = true)
 class SecurityConfig(
+    private val objectMapper: ObjectMapper,
     private val tokenProvider: TokenProvider,
     private val accessDeniedHandler: JwtAccessDeniedHandler,
     private val authenticationEntryPoint: JwtAuthenticationEntryPoint
@@ -69,6 +72,7 @@ class SecurityConfig(
             .anyRequest().authenticated()
             .and()
             .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java)
+            .addFilterBefore(ExceptionHandlerFilter(objectMapper), JwtFilter::class.java)
             .build()
     }
 }
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt
index 5979817..6d12515 100644
--- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.GetMapping
 import org.springframework.web.bind.annotation.PostMapping
 import org.springframework.web.bind.annotation.PutMapping
 import org.springframework.web.bind.annotation.RequestBody
+import org.springframework.web.bind.annotation.RequestHeader
 import org.springframework.web.bind.annotation.RequestMapping
 import org.springframework.web.bind.annotation.RequestParam
 import org.springframework.web.bind.annotation.RequestPart
@@ -29,6 +30,16 @@ class MemberController(private val service: MemberService) {
     @PostMapping("/login")
     fun login(@RequestBody loginRequest: LoginRequest) = service.login(loginRequest)
 
+    @PostMapping("/logout")
+    fun logout(
+        @RequestHeader("Authorization") token: String,
+        @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
+    ) = run {
+        if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
+
+        ApiResponse.ok(service.logout(token.removePrefix("Bearer "), member.id!!))
+    }
+
     @GetMapping("/info")
     fun getMemberInfo(
         @RequestParam container: String?,
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt
index b7feb09..1b59996 100644
--- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt
@@ -25,6 +25,7 @@ import kr.co.vividnext.sodalive.member.stipulation.StipulationAgree
 import kr.co.vividnext.sodalive.member.stipulation.StipulationAgreeRepository
 import kr.co.vividnext.sodalive.member.stipulation.StipulationIds
 import kr.co.vividnext.sodalive.member.stipulation.StipulationRepository
+import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
 import kr.co.vividnext.sodalive.utils.generateFileName
 import org.springframework.beans.factory.annotation.Value
 import org.springframework.data.repository.findByIdOrNull
@@ -38,11 +39,14 @@ import org.springframework.security.crypto.password.PasswordEncoder
 import org.springframework.stereotype.Service
 import org.springframework.transaction.annotation.Transactional
 import org.springframework.web.multipart.MultipartFile
+import java.util.concurrent.locks.ReentrantReadWriteLock
+import kotlin.concurrent.write
 
 @Service
 @Transactional(readOnly = true)
 class MemberService(
     private val repository: MemberRepository,
+    private val tokenRepository: MemberTokenRepository,
     private val stipulationRepository: StipulationRepository,
     private val stipulationAgreeRepository: StipulationAgreeRepository,
     private val creatorFollowingRepository: CreatorFollowingRepository,
@@ -64,6 +68,9 @@ class MemberService(
     @Value("\${cloud.aws.cloud-front.host}")
     private val cloudFrontHost: String
 ) : UserDetailsService {
+
+    private val tokenLocks: MutableMap<Long, ReentrantReadWriteLock> = mutableMapOf()
+
     @Transactional
     fun signUp(
         profileImage: MultipartFile?,
@@ -349,4 +356,27 @@ class MemberService(
             }
             .toList()
     }
+
+    @Transactional
+    fun logout(token: String, memberId: Long) {
+        val member = repository.findByIdOrNull(memberId)
+            ?: throw SodaException("로그인 정보를 확인해주세요.")
+
+        member.pushToken = null
+
+        val lock = getOrCreateLock(memberId = memberId)
+        lock.write {
+            val memberToken = tokenRepository.findByIdOrNull(memberId)
+                ?: throw SodaException("로그인 정보를 확인해주세요.")
+
+            val memberTokenSet = memberToken.tokenList.toMutableSet()
+            memberTokenSet.remove(token)
+            memberToken.tokenList = memberTokenSet.toList()
+            tokenRepository.save(memberToken)
+        }
+    }
+
+    private fun getOrCreateLock(memberId: Long): ReentrantReadWriteLock {
+        return tokenLocks.computeIfAbsent(memberId) { ReentrantReadWriteLock() }
+    }
 }