Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -94,35 +96,49 @@ 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
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);
Expand All @@ -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),
Expand All @@ -163,4 +187,8 @@ private String getBlacklistKey(String accessToken) {
return BLACKLIST_PREFIX + accessToken;
}

private String getReissueLockKey(Long userId) {
return REISSUE_LOCK_PREFIX + userId;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -102,7 +102,7 @@ public ApiResponse<JobPostingResponse> createJobPosting(
}

@Operation(summary = "채용 공고 수정", description = "기존 채용 공고를 수정합니다. 회사명이 없으면 회사를 새로 생성합니다.")
@PutMapping("/{jobPostingId}")
@PatchMapping("/{jobPostingId}")
public ApiResponse<JobPostingResponse> updateJobPosting(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@PathVariable Long jobPostingId,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,39 @@
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;

import java.util.List;

public interface JobPostingRepository extends JpaRepository<JobPosting, Long> {
@EntityGraph(attributePaths = {
"company",
"user",
"detailClassification",
"detailClassification.middleClassification",
"detailClassification.middleClassification.classification"
})
List<JobPosting> findAllByCompanyId(Long companyId);

@EntityGraph(attributePaths = {
"company",
"user",
"detailClassification",
"detailClassification.middleClassification",
"detailClassification.middleClassification.classification"
})
List<JobPosting> findAllByUserId(Long userId);

@EntityGraph(attributePaths = {
"company",
"user",
"detailClassification",
"detailClassification.middleClassification",
"detailClassification.middleClassification.classification"
})
List<JobPosting> findAllByUserIdAndCompanyId(Long userId, Long companyId);
List<JobPosting> findTop5ByDetailClassificationIdOrderByIdDesc(Long detailClassificationId);
List<JobPosting> findTop5ByCompanyIdOrderByIdDesc(Long companyId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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-");
Expand All @@ -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;
}
Expand All @@ -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-");
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) ->
Expand Down
30 changes: 14 additions & 16 deletions src/main/java/com/jobdri/jobdri_api/global/jwt/JwtUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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() {
Expand Down Expand Up @@ -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);
Expand All @@ -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 서명 입니다.");
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/application-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Loading