diff --git a/build.gradle.kts b/build.gradle.kts index 2c4aef1..7a63b51 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { kapt("jakarta.annotation:jakarta.annotation-api") kapt("jakarta.persistence:jakarta.persistence-api") + implementation("io.github.oshai:kotlin-logging-jvm:7.0.3") implementation("com.google.firebase:firebase-admin:9.4.3") diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/entity/Notification.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/entity/Notification.kt index 7415bcf..1b4fb3d 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/notification/entity/Notification.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/entity/Notification.kt @@ -11,9 +11,9 @@ import java.time.LocalDateTime @Table(name = "notifications") @EntityListeners(AuditingEntityListener::class) class Notification( - @JoinColumn(name = "member_id", nullable = false) + @JoinColumn(name = "member_id", nullable = true) @ManyToOne - val member: Member, + val member: Member? = null, @Enumerated(EnumType.STRING) @Column(name = "type", nullable = false) diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationService.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationService.kt index 854cdc5..ff9b66d 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationService.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationService.kt @@ -30,7 +30,8 @@ class NotificationService( return NotificationFindAllResponse( notifications - .map { NotificationDetail.from(it) }) + .map { NotificationDetail.from(it) } + ) } @Transactional @@ -38,7 +39,9 @@ class NotificationService( val notification = notificationRepository.findById(notificationId) .orElseThrow { ApiException(NotificationErrorCode.NOTIFICATION_NOT_FOUND) } - if (notification.member.id != memberId) { + if (notification.isAdminStatus()) return; + + if (notification.member?.id != memberId) { throw ApiException(NotificationErrorCode.NOTIFICATION_ACCESS_DENIED) } @@ -58,7 +61,7 @@ class NotificationService( member: Member, status: NotificationStatus, formatValues: List, - needPush: Boolean = false + needPush: Boolean = false, ) { val notification = Notification( member = member, @@ -69,23 +72,32 @@ class NotificationService( notificationRepository.save(notification) if (needPush) { - val studentId = member.studentId + sendPushNotification(member, status, formatValues) + } + } - if (member.fcmToken == null) { - log.warn { "(studentId=${studentId}) FCM Token is null" } - return - } + private fun sendPushNotification( + member: Member, + status: NotificationStatus, + formatValues: List, + ) { + val studentId = member.studentId - fcmService.sendPushNotification( - member.fcmToken!!, - status.title, - status.formattedMessage(*formatValues.toTypedArray()), - status.link, - studentId - ) + if (member.fcmToken == null) { + log.warn { "(studentId=${studentId}) FCM Token is null" } + return } + + fcmService.sendPushNotification( + member.fcmToken!!, + status.title, + status.formattedMessage(*formatValues.toTypedArray()), + status.link, + studentId + ) } + @Transactional fun sendNotificationToAdmin( type: NotificationStatus, formatValues: List, @@ -93,8 +105,17 @@ class NotificationService( ) { val admins = memberRepository.findAllByRole(Role.ADMIN) - admins.forEach { admin -> - sendNotification(admin, type, formatValues, needPush) + val notification = Notification( + status = type, + formatValues = formatValues.joinToString(",") + ) + + notificationRepository.save(notification) + + if (needPush) { + admins.forEach { admin -> + sendPushNotification(admin, type, formatValues) + } } } @@ -103,4 +124,6 @@ class NotificationService( return NotificationCountResponse(count) } + + private fun Notification.isAdminStatus(): Boolean = status.name.contains("ADMIN", true) } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/rental/exception/RentalErrorCode.kt b/src/main/kotlin/site/billilge/api/backend/domain/rental/exception/RentalErrorCode.kt index e93d8e7..069ac6c 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/rental/exception/RentalErrorCode.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/rental/exception/RentalErrorCode.kt @@ -16,5 +16,5 @@ enum class RentalErrorCode( INVALID_RENTAL_TIME_WEEKEND("주말에는 대여가 불가능합니다.", HttpStatus.BAD_REQUEST), RENTAL_NOT_FOUND("대여 기록을 찾을 수 없습니다", HttpStatus.NOT_FOUND), MEMBER_IS_NOT_PAYER("복지물품을 대여하려면 먼저 학생회비를 납부해주세요.", HttpStatus.FORBIDDEN), - TODAY_IS_IN_EXAM_PERIOD("시험기간(04.14.~04.28.)에는 대여가 불가능합니다.\n양해 부탁드립니다.", HttpStatus.BAD_REQUEST), + TODAY_IS_IN_EXAM_PERIOD("시험기간(04.14.~04.28.)에는\n대여가 불가능합니다.\n양해 부탁드립니다.", HttpStatus.BAD_REQUEST), } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/global/exception/ErrorResponse.kt b/src/main/kotlin/site/billilge/api/backend/global/exception/ErrorResponse.kt index 3ce344b..1931de1 100644 --- a/src/main/kotlin/site/billilge/api/backend/global/exception/ErrorResponse.kt +++ b/src/main/kotlin/site/billilge/api/backend/global/exception/ErrorResponse.kt @@ -1,7 +1,8 @@ package site.billilge.api.backend.global.exception +import com.fasterxml.jackson.annotation.JsonFormat import io.swagger.v3.oas.annotations.media.Schema -import java.time.Instant +import java.time.LocalDateTime @Schema data class ErrorResponse( @@ -10,30 +11,26 @@ data class ErrorResponse( @field:Schema(description = "오류 메시지", example = "오류 메시지입니다.") val message: String, @field:Schema(description = "HTTP 응답 코드", example = "500") - val status: Int, - @field:Schema(description = "발생 시각") - val timestamp: Instant + val status: Int ) { companion object { @JvmStatic - fun from(errorCode: ErrorCode, now: Instant): ErrorResponse { + fun from(errorCode: ErrorCode): ErrorResponse { return ErrorResponse( code = errorCode.name, message = errorCode.message, - status = errorCode.httpStatus.value(), - timestamp = now + status = errorCode.httpStatus.value() ) } @JvmStatic - fun from(exception: Exception, now: Instant): ErrorResponse { + fun from(exception: Exception): ErrorResponse { val errorCode = GlobalErrorCode.INTERNAL_SERVER_ERROR return ErrorResponse( code = errorCode.name, message = exception.message ?: errorCode.message, - status = errorCode.httpStatus.value(), - timestamp = now + status = errorCode.httpStatus.value() ) } } diff --git a/src/main/kotlin/site/billilge/api/backend/global/exception/GlobalErrorCode.kt b/src/main/kotlin/site/billilge/api/backend/global/exception/GlobalErrorCode.kt index 3ca3942..d98be27 100644 --- a/src/main/kotlin/site/billilge/api/backend/global/exception/GlobalErrorCode.kt +++ b/src/main/kotlin/site/billilge/api/backend/global/exception/GlobalErrorCode.kt @@ -13,4 +13,5 @@ enum class GlobalErrorCode( IMAGE_NOT_FOUND("이미지 파일을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), IMAGE_DELETE_FAILED("이미지 삭제에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), INVALID_OAUTH2_PROVIDER("유효하지 않은 OAuth2 로그인 인증기관입니다.", HttpStatus.BAD_REQUEST), + EXPIRED_ACCESS_TOKEN("만료된 액세스 토큰입니다.", HttpStatus.UNAUTHORIZED), } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/global/exception/GlobalExceptionHandler.kt b/src/main/kotlin/site/billilge/api/backend/global/exception/GlobalExceptionHandler.kt index 1ad0082..a122000 100644 --- a/src/main/kotlin/site/billilge/api/backend/global/exception/GlobalExceptionHandler.kt +++ b/src/main/kotlin/site/billilge/api/backend/global/exception/GlobalExceptionHandler.kt @@ -6,13 +6,14 @@ import org.springframework.security.authorization.AuthorizationDeniedException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice import java.time.Instant +import java.time.LocalDateTime @RestControllerAdvice class GlobalExceptionHandler { @ExceptionHandler(ApiException::class) fun handleApiException(exception: ApiException): ResponseEntity { val errorCode = exception.errorCode - val errorResponse = ErrorResponse.from(errorCode, Instant.now()) + val errorResponse = ErrorResponse.from(errorCode) val httpStatus = errorCode.httpStatus return ResponseEntity(errorResponse, httpStatus) @@ -20,7 +21,7 @@ class GlobalExceptionHandler { @ExceptionHandler(AuthorizationDeniedException::class) fun handleAuthorizationDeniedException(exception: AuthorizationDeniedException): ResponseEntity { - val errorResponse = ErrorResponse.from(GlobalErrorCode.FORBIDDEN, Instant.now()) + val errorResponse = ErrorResponse.from(GlobalErrorCode.FORBIDDEN) return ResponseEntity .status(HttpStatus.FORBIDDEN) @@ -29,7 +30,7 @@ class GlobalExceptionHandler { @ExceptionHandler fun handleDefaultException(exception: Exception): ResponseEntity { - val errorResponse = ErrorResponse.from(exception, Instant.now()) + val errorResponse = ErrorResponse.from(exception) exception.printStackTrace() diff --git a/src/main/kotlin/site/billilge/api/backend/global/security/jwt/TokenAuthenticationFilter.kt b/src/main/kotlin/site/billilge/api/backend/global/security/jwt/TokenAuthenticationFilter.kt index 7697042..1b9bbae 100644 --- a/src/main/kotlin/site/billilge/api/backend/global/security/jwt/TokenAuthenticationFilter.kt +++ b/src/main/kotlin/site/billilge/api/backend/global/security/jwt/TokenAuthenticationFilter.kt @@ -1,14 +1,18 @@ package site.billilge.api.backend.global.security.jwt +import com.fasterxml.jackson.databind.ObjectMapper import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.springframework.security.core.context.SecurityContextHolder import org.springframework.web.filter.OncePerRequestFilter import site.billilge.api.backend.global.exception.ApiException +import site.billilge.api.backend.global.exception.ErrorResponse import site.billilge.api.backend.global.exception.GlobalErrorCode +import java.io.IOException import java.util.* + class TokenAuthenticationFilter( private val tokenProvider: TokenProvider ): OncePerRequestFilter() { @@ -18,14 +22,34 @@ class TokenAuthenticationFilter( authorizationHeader?.let { val accessToken = getAccessToken(authorizationHeader); - if (tokenProvider.validToken(accessToken)) { - SecurityContextHolder.getContext().authentication = tokenProvider.getAuthentication(accessToken) + kotlin.runCatching { + if (tokenProvider.validToken(accessToken)) { + SecurityContextHolder.getContext().authentication = tokenProvider.getAuthentication(accessToken) + } + }.onFailure { exception -> + if (exception !is ApiException) return + + handleException(response, exception) + return } } filterChain.doFilter(request, response) } + @Throws(IOException::class) + private fun handleException(response: HttpServletResponse, exception: ApiException) { + val errorResponse = ErrorResponse.from(exception.errorCode) + + val content = ObjectMapper().writeValueAsString(errorResponse) + + response.addHeader("Content-Type", "application/json; charset=utf-8") + response.status = exception.errorCode.httpStatus.value() + response.writer.write(content) + response.writer.flush() + } + + override fun shouldNotFilter(request: HttpServletRequest): Boolean { val excludes = arrayOf("/auth/") val path = request.requestURI diff --git a/src/main/kotlin/site/billilge/api/backend/global/security/jwt/TokenProvider.kt b/src/main/kotlin/site/billilge/api/backend/global/security/jwt/TokenProvider.kt index 5dcc478..645ad33 100644 --- a/src/main/kotlin/site/billilge/api/backend/global/security/jwt/TokenProvider.kt +++ b/src/main/kotlin/site/billilge/api/backend/global/security/jwt/TokenProvider.kt @@ -1,6 +1,7 @@ package site.billilge.api.backend.global.security.jwt import io.jsonwebtoken.Claims +import io.jsonwebtoken.ExpiredJwtException import io.jsonwebtoken.Header import io.jsonwebtoken.Jwts import io.jsonwebtoken.SignatureAlgorithm @@ -11,6 +12,8 @@ import org.springframework.security.core.Authentication import org.springframework.stereotype.Service import site.billilge.api.backend.domain.member.entity.Member import site.billilge.api.backend.domain.member.enums.Role +import site.billilge.api.backend.global.exception.ApiException +import site.billilge.api.backend.global.exception.GlobalErrorCode import site.billilge.api.backend.global.security.UserAuthInfoService import java.security.Key import java.time.Duration @@ -62,7 +65,9 @@ class TokenProvider( .parseClaimsJws(token) return true - } catch (e: Exception) { + } catch (e: ExpiredJwtException) { + throw ApiException(GlobalErrorCode.EXPIRED_ACCESS_TOKEN) + } catch (e1: Exception) { return false } } diff --git a/src/main/kotlin/site/billilge/api/backend/global/security/oauth2/CustomAuthenticationEntryPoint.kt b/src/main/kotlin/site/billilge/api/backend/global/security/oauth2/CustomAuthenticationEntryPoint.kt index 7fec31b..536bd75 100644 --- a/src/main/kotlin/site/billilge/api/backend/global/security/oauth2/CustomAuthenticationEntryPoint.kt +++ b/src/main/kotlin/site/billilge/api/backend/global/security/oauth2/CustomAuthenticationEntryPoint.kt @@ -8,6 +8,7 @@ import org.springframework.security.core.AuthenticationException import org.springframework.security.web.AuthenticationEntryPoint import site.billilge.api.backend.global.exception.ErrorResponse import java.time.Instant +import java.time.LocalDateTime class CustomAuthenticationEntryPoint: AuthenticationEntryPoint { override fun commence(request: HttpServletRequest, response: HttpServletResponse, authException: AuthenticationException) { @@ -18,7 +19,7 @@ class CustomAuthenticationEntryPoint: AuthenticationEntryPoint { val objectMapper = ObjectMapper() response.status = HttpServletResponse.SC_UNAUTHORIZED // 401 Unauthorized response.contentType = MediaType.APPLICATION_JSON_VALUE - val error: ErrorResponse = ErrorResponse.from(exception, Instant.now()) + val error: ErrorResponse = ErrorResponse.from(exception) response.writer.write(objectMapper.writeValueAsString(error)) } } \ No newline at end of file