Skip to content
Merged
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,18 @@ class NotificationService(

return NotificationFindAllResponse(
notifications
.map { NotificationDetail.from(it) })
.map { NotificationDetail.from(it) }
)
}

@Transactional
fun readNotification(memberId: Long?, notificationId: Long) {
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)
}

Expand All @@ -58,7 +61,7 @@ class NotificationService(
member: Member,
status: NotificationStatus,
formatValues: List<String>,
needPush: Boolean = false
needPush: Boolean = false,
) {
val notification = Notification(
member = member,
Expand All @@ -69,32 +72,50 @@ 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<String>,
) {
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<String>,
needPush: Boolean = false
) {
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)
}
}
}

Expand All @@ -103,4 +124,6 @@ class NotificationService(

return NotificationCountResponse(count)
}

private fun Notification.isAdminStatus(): Boolean = status.name.contains("ADMIN", true)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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()
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,22 @@ 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<ErrorResponse> {
val errorCode = exception.errorCode
val errorResponse = ErrorResponse.from(errorCode, Instant.now())
val errorResponse = ErrorResponse.from(errorCode)
val httpStatus = errorCode.httpStatus

return ResponseEntity(errorResponse, httpStatus)
}

@ExceptionHandler(AuthorizationDeniedException::class)
fun handleAuthorizationDeniedException(exception: AuthorizationDeniedException): ResponseEntity<ErrorResponse> {
val errorResponse = ErrorResponse.from(GlobalErrorCode.FORBIDDEN, Instant.now())
val errorResponse = ErrorResponse.from(GlobalErrorCode.FORBIDDEN)

return ResponseEntity
.status(HttpStatus.FORBIDDEN)
Expand All @@ -29,7 +30,7 @@ class GlobalExceptionHandler {

@ExceptionHandler
fun handleDefaultException(exception: Exception): ResponseEntity<ErrorResponse> {
val errorResponse = ErrorResponse.from(exception, Instant.now())
val errorResponse = ErrorResponse.from(exception)

exception.printStackTrace()

Expand Down
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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))
}
}