From 4e71e7ac32009dbc12c79c0e8693631d5e51e1f6 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 19 May 2026 16:42:07 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[Fix]=20security=20=EB=B0=8F=20auth=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=88=98=EC=A0=95=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 6 +- .../domain/auth/service/AuthService.java | 62 ++++++++++++++----- .../repository/JobPostingRepository.java | 24 +++++++ .../jobdri_api/global/config/AsyncConfig.java | 12 ++-- .../global/config/SecurityConfig.java | 2 +- .../jobdri/jobdri_api/global/jwt/JwtUtil.java | 30 +++++---- src/main/resources/application-dev.yaml | 2 +- 7 files changed, 94 insertions(+), 44 deletions(-) diff --git a/build.gradle b/build.gradle index 64c74a0..80620c7 100644 --- a/build.gradle +++ b/build.gradle @@ -35,7 +35,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' //jwt - implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-api:0.12.7' //swagger @@ -53,8 +53,8 @@ dependencies { compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'org.postgresql:postgresql' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.7' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.7' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' diff --git a/src/main/java/com/jobdri/jobdri_api/domain/auth/service/AuthService.java b/src/main/java/com/jobdri/jobdri_api/domain/auth/service/AuthService.java index 3f559bb..0b8b19a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/auth/service/AuthService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/auth/service/AuthService.java @@ -28,6 +28,8 @@ public class AuthService { private static final String REFRESH_TOKEN_PREFIX = "RefreshToken:"; private static final String BLACKLIST_PREFIX = "Blacklist:"; + private static final String REISSUE_LOCK_PREFIX = "ReissueLock:"; + private static final long REISSUE_LOCK_TIMEOUT_SECONDS = 3L; private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; @@ -94,23 +96,41 @@ public ReissueTokenResponse reissueToken(ReissueTokenRequest request) { throw new GeneralException(GeneralErrorCode.INVALID_TOKEN); } - String storedRefreshToken = redisTemplate.opsForValue().get(getRefreshTokenKey(accessUserId)); - if (storedRefreshToken == null || !storedRefreshToken.equals(request.refreshToken())) { - throw new GeneralException(GeneralErrorCode.INVALID_TOKEN, "토큰 정보가 일치하지 않습니다."); + String lockKey = getReissueLockKey(accessUserId); + Boolean locked = redisTemplate.opsForValue().setIfAbsent( + lockKey, + request.refreshToken(), + REISSUE_LOCK_TIMEOUT_SECONDS, + TimeUnit.SECONDS + ); + if (!Boolean.TRUE.equals(locked)) { + throw new GeneralException( + GeneralErrorCode.SERVICE_UNAVAILABLE, + "토큰 재발급 요청이 처리 중입니다. 잠시 후 다시 시도해주세요." + ); } - User user = userRepository.findById(accessUserId) - .orElseThrow(() -> new GeneralException(GeneralErrorCode.USER_NOT_FOUND)); + try { + String storedRefreshToken = redisTemplate.opsForValue().get(getRefreshTokenKey(accessUserId)); + if (storedRefreshToken == null || !storedRefreshToken.equals(request.refreshToken())) { + throw new GeneralException(GeneralErrorCode.INVALID_TOKEN, "토큰 정보가 일치하지 않습니다."); + } - String newAccessToken = jwtUtil.createAccessToken(user.getEmail(), user.getId()); - String newRefreshToken = jwtUtil.createRefreshToken(user.getEmail()); + User user = userRepository.findById(accessUserId) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.USER_NOT_FOUND)); - saveRefreshToken(user.getId(), newRefreshToken); + String newAccessToken = jwtUtil.createAccessToken(user.getEmail(), user.getId()); + String newRefreshToken = jwtUtil.createRefreshToken(user.getEmail()); - return ReissueTokenResponse.builder() - .accessToken(newAccessToken) - .refreshToken(newRefreshToken) - .build(); + saveRefreshToken(user.getId(), newRefreshToken); + + return ReissueTokenResponse.builder() + .accessToken(newAccessToken) + .refreshToken(newRefreshToken) + .build(); + } finally { + redisTemplate.delete(lockKey); + } } @Transactional @@ -118,11 +138,7 @@ public void logout(LogoutRequest request) { String accessToken = request.accessToken(); String refreshToken = request.refreshToken(); - if (!jwtUtil.validateToken(accessToken)) { - throw new GeneralException(GeneralErrorCode.INVALID_TOKEN, "유효하지 않은 액세스 토큰입니다."); - } - - Claims claims = jwtUtil.getClaimsFromToken(accessToken); + Claims claims = extractLogoutClaims(accessToken); Long userId = claims.get("userId", Long.class); if (userId == null) { throw new GeneralException(GeneralErrorCode.INVALID_TOKEN); @@ -146,6 +162,14 @@ public void logout(LogoutRequest request) { } } + private Claims extractLogoutClaims(String accessToken) { + try { + return jwtUtil.getClaimsFromToken(accessToken); + } catch (GeneralException exception) { + return jwtUtil.getClaimsFromExpiredToken(accessToken); + } + } + private void saveRefreshToken(Long userId, String refreshTokenValue) { redisTemplate.opsForValue().set( getRefreshTokenKey(userId), @@ -163,4 +187,8 @@ private String getBlacklistKey(String accessToken) { return BLACKLIST_PREFIX + accessToken; } + private String getReissueLockKey(Long userId) { + return REISSUE_LOCK_PREFIX + userId; + } + } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingRepository.java index 4e4a9a6..2b58fe1 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/repository/JobPostingRepository.java @@ -1,6 +1,7 @@ package com.jobdri.jobdri_api.domain.jobposting.repository; import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.repository.query.Param; @@ -8,8 +9,31 @@ import java.util.List; public interface JobPostingRepository extends JpaRepository { + @EntityGraph(attributePaths = { + "company", + "user", + "detailClassification", + "detailClassification.middleClassification", + "detailClassification.middleClassification.classification" + }) List findAllByCompanyId(Long companyId); + + @EntityGraph(attributePaths = { + "company", + "user", + "detailClassification", + "detailClassification.middleClassification", + "detailClassification.middleClassification.classification" + }) List findAllByUserId(Long userId); + + @EntityGraph(attributePaths = { + "company", + "user", + "detailClassification", + "detailClassification.middleClassification", + "detailClassification.middleClassification.classification" + }) List findAllByUserIdAndCompanyId(Long userId, Long companyId); List findTop5ByDetailClassificationIdOrderByIdDesc(Long detailClassificationId); List findTop5ByCompanyIdOrderByIdDesc(Long companyId); diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/AsyncConfig.java b/src/main/java/com/jobdri/jobdri_api/global/config/AsyncConfig.java index fce29e5..510fe54 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/config/AsyncConfig.java +++ b/src/main/java/com/jobdri/jobdri_api/global/config/AsyncConfig.java @@ -13,8 +13,8 @@ public class AsyncConfig { @Bean(name = "jobPostingAsyncExecutor") public ThreadPoolTaskExecutor jobPostingAsyncExecutor( @Value("${async.job-posting.core-pool-size:2}") int corePoolSize, - @Value("${async.job-posting.max-pool-size:4}") int maxPoolSize, - @Value("${async.job-posting.queue-capacity:20}") int queueCapacity + @Value("${async.job-posting.max-pool-size:50}") int maxPoolSize, + @Value("${async.job-posting.queue-capacity:100}") int queueCapacity ) { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setThreadNamePrefix("job-posting-async-"); @@ -23,7 +23,7 @@ public ThreadPoolTaskExecutor jobPostingAsyncExecutor( executor.setQueueCapacity(queueCapacity); executor.setAllowCoreThreadTimeOut(true); executor.setWaitForTasksToCompleteOnShutdown(false); - executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } @@ -49,8 +49,8 @@ public ThreadPoolTaskExecutor mailAsyncExecutor( @Bean(name = "llmAsyncExecutor") public ThreadPoolTaskExecutor llmAsyncExecutor( @Value("${async.llm.core-pool-size:2}") int corePoolSize, - @Value("${async.llm.max-pool-size:4}") int maxPoolSize, - @Value("${async.llm.queue-capacity:20}") int queueCapacity + @Value("${async.llm.max-pool-size:100}") int maxPoolSize, + @Value("${async.llm.queue-capacity:200}") int queueCapacity ) { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setThreadNamePrefix("llm-async-"); @@ -59,7 +59,7 @@ public ThreadPoolTaskExecutor llmAsyncExecutor( executor.setQueueCapacity(queueCapacity); executor.setAllowCoreThreadTimeOut(true); executor.setWaitForTasksToCompleteOnShutdown(false); - executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/SecurityConfig.java b/src/main/java/com/jobdri/jobdri_api/global/config/SecurityConfig.java index 090319f..e741d23 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/config/SecurityConfig.java +++ b/src/main/java/com/jobdri/jobdri_api/global/config/SecurityConfig.java @@ -47,7 +47,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti http.csrf((csrf) -> csrf.disable()); http.sessionManagement((session) -> - session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ); http.securityContext((context) -> diff --git a/src/main/java/com/jobdri/jobdri_api/global/jwt/JwtUtil.java b/src/main/java/com/jobdri/jobdri_api/global/jwt/JwtUtil.java index d56a321..8064a5e 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/jwt/JwtUtil.java +++ b/src/main/java/com/jobdri/jobdri_api/global/jwt/JwtUtil.java @@ -7,16 +7,15 @@ import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.MalformedJwtException; -import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.UnsupportedJwtException; -import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.SecurityException; import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import java.security.Key; +import javax.crypto.SecretKey; +import io.jsonwebtoken.security.Keys; import java.util.Base64; import java.util.Date; @@ -36,8 +35,7 @@ public class JwtUtil { @Value("${jwt.secret.key}") private String secretKey; - private Key key; - private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; + private SecretKey key; @PostConstruct public void init() { @@ -67,10 +65,10 @@ private String createToken(String email, Long userId, long expireTime) { Date expireDate = new Date(now.getTime() + expireTime); JwtBuilder builder = Jwts.builder() - .setSubject(email) - .setIssuedAt(now) - .setExpiration(expireDate) - .signWith(key, signatureAlgorithm); + .subject(email) + .issuedAt(now) + .expiration(expireDate) + .signWith(key); if (userId != null) { builder.claim("userId", userId); @@ -89,7 +87,7 @@ public String substringToken(String tokenValue) { public boolean validateToken(String token) { try { - Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + Jwts.parser().verifyWith(key).build().parseSignedClaims(token); return true; } catch (SecurityException | MalformedJwtException e) { log.error("Invalid JWT signature, 유효하지 않은 JWT 서명 입니다."); @@ -104,8 +102,8 @@ public boolean validateToken(String token) { } public Claims getClaimsFromToken(String token) { - return Jwts.parserBuilder().setSigningKey(key).build() - .parseClaimsJws(token).getBody(); + return Jwts.parser().verifyWith(key).build() + .parseSignedClaims(token).getPayload(); } public String getEmailFromToken(Claims claims) { @@ -114,11 +112,11 @@ public String getEmailFromToken(Claims claims) { public Claims getClaimsFromExpiredToken(String token) { try { - return Jwts.parserBuilder() - .setSigningKey(key) + return Jwts.parser() + .verifyWith(key) .build() - .parseClaimsJws(token) - .getBody(); + .parseSignedClaims(token) + .getPayload(); } catch (ExpiredJwtException e) { return e.getClaims(); } catch (Exception e) { diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 23b5d28..3a0975f 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -62,7 +62,7 @@ server: jwt: secret: - key: ${JWT_SECRET_KEY:am9iZHJpLWxvY2FsLXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50LTIwMjYtam9iZHJp} + key: ${JWT_SECRET_KEY} expiration: access-token: ${JWT_ACCESS_TOKEN_EXPIRATION:3600000} refresh-token: ${JWT_REFRESH_TOKEN_EXPIRATION:1209600000} From 559dc0d87f8db20354a5174d3286b01a1d1be119 Mon Sep 17 00:00:00 2001 From: shinae1023 Date: Tue, 19 May 2026 16:45:23 +0900 Subject: [PATCH 2/2] [Fix] put -> patch (#28) --- .../domain/jobposting/controller/JobPostingController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingController.java index 78823a8..a902bcf 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingController.java @@ -22,12 +22,12 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.bind.annotation.PutMapping; import java.util.List; @@ -102,7 +102,7 @@ public ApiResponse createJobPosting( } @Operation(summary = "채용 공고 수정", description = "기존 채용 공고를 수정합니다. 회사명이 없으면 회사를 새로 생성합니다.") - @PutMapping("/{jobPostingId}") + @PatchMapping("/{jobPostingId}") public ApiResponse updateJobPosting( @AuthenticationPrincipal UserDetailsImpl userDetails, @PathVariable Long jobPostingId,