From 2be8ac08b5ace480d649563744b60e42f000f20d Mon Sep 17 00:00:00 2001 From: Oh-Jisong Date: Fri, 19 Jun 2026 15:46:48 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=9D=BC=EB=B0=98=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=201=EC=B0=A8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 25 ++++++++- .../ipx/domain/auth/dto/LoginRequest.java | 22 ++++++++ .../ipx/domain/auth/dto/LoginResponse.java | 20 +++++++ .../domain/auth/dto/LoginUserResponse.java | 42 +++++++++++++++ .../ipx/domain/auth/service/AuthService.java | 29 +++++++++- .../ceos/ipx/global/exception/ErrorCode.java | 5 +- .../global/security/jwt/JwtTokenProvider.java | 54 +++++++++++++++++++ src/main/resources/application-local.yml | 6 ++- 8 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 src/main/java/ceos/ipx/domain/auth/dto/LoginRequest.java create mode 100644 src/main/java/ceos/ipx/domain/auth/dto/LoginResponse.java create mode 100644 src/main/java/ceos/ipx/domain/auth/dto/LoginUserResponse.java create mode 100644 src/main/java/ceos/ipx/global/security/jwt/JwtTokenProvider.java diff --git a/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java b/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java index fe12a8d..fbf8ed7 100644 --- a/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java +++ b/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java @@ -1,5 +1,7 @@ package ceos.ipx.domain.auth.controller; +import ceos.ipx.domain.auth.dto.LoginRequest; +import ceos.ipx.domain.auth.dto.LoginResponse; import ceos.ipx.domain.auth.service.AuthService; import ceos.ipx.domain.user.dto.SignUpRequest; import ceos.ipx.domain.user.dto.SignUpResponse; @@ -33,11 +35,30 @@ public class AuthController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") }) @PostMapping("/signup") - public ResponseEntity> signUp(@Valid @RequestBody SignUpRequest request) { + public ResponseEntity> signUp( + @Valid @RequestBody SignUpRequest request + ) { SignUpResponse response = authService.signUp(request); return ResponseEntity .status(HttpStatus.CREATED) .body(ApiResponse.ok(response)); } -} + + @Operation(summary = "일반 로그인", description = "이메일과 비밀번호로 로그인하고 JWT AccessToken을 발급합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "로그인 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "입력값 검증 실패"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "이메일 또는 비밀번호 불일치"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "비활성화된 계정"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + @PostMapping("/login") + public ResponseEntity> login( + @Valid @RequestBody LoginRequest request + ) { + LoginResponse response = authService.login(request); + + return ResponseEntity.ok(ApiResponse.ok(response)); + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/auth/dto/LoginRequest.java b/src/main/java/ceos/ipx/domain/auth/dto/LoginRequest.java new file mode 100644 index 0000000..2a37085 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/auth/dto/LoginRequest.java @@ -0,0 +1,22 @@ +package ceos.ipx.domain.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "일반 로그인 요청") +public record LoginRequest( + + @Schema(description = "이메일", example = "test@example.com") + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email, + + @Schema(description = "비밀번호", example = "Password123!") + @NotBlank(message = "비밀번호는 필수입니다.") + String password, + + @Schema(description = "로그인 유지 여부", example = "false") + boolean rememberMe +) { +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/auth/dto/LoginResponse.java b/src/main/java/ceos/ipx/domain/auth/dto/LoginResponse.java new file mode 100644 index 0000000..2afb9f2 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/auth/dto/LoginResponse.java @@ -0,0 +1,20 @@ +package ceos.ipx.domain.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "일반 로그인 응답") +public record LoginResponse( + + @Schema(description = "JWT AccessToken") + String accessToken, + + @Schema(description = "토큰 타입", example = "Bearer") + String tokenType, + + @Schema(description = "AccessToken 만료 시간(초)", example = "3600") + long expiresIn, + + @Schema(description = "로그인한 사용자 정보") + LoginUserResponse user +) { +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/auth/dto/LoginUserResponse.java b/src/main/java/ceos/ipx/domain/auth/dto/LoginUserResponse.java new file mode 100644 index 0000000..8e607c0 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/auth/dto/LoginUserResponse.java @@ -0,0 +1,42 @@ +package ceos.ipx.domain.auth.dto; + +import ceos.ipx.domain.user.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "로그인 사용자 정보 응답") +public record LoginUserResponse( + + @Schema(description = "사용자 ID", example = "1") + Long userId, + + @Schema(description = "이메일", example = "test@example.com") + String email, + + @Schema(description = "이름", example = "홍길동") + String name, + + @Schema(description = "회사명", example = "IPX") + String company, + + @Schema(description = "가입 제공자", example = "LOCAL") + String provider, + + @Schema(description = "프로필 완료 여부", example = "true") + boolean profileCompleted +) { + + public static LoginUserResponse from(User user) { + return new LoginUserResponse( + user.getId(), + user.getEmail(), + user.getName(), + user.getCompany(), + user.getProvider().name(), + isProfileCompleted(user) + ); + } + + private static boolean isProfileCompleted(User user) { + return user.getName() != null && !user.getName().isBlank(); + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/auth/service/AuthService.java b/src/main/java/ceos/ipx/domain/auth/service/AuthService.java index 6145f15..2a95b67 100644 --- a/src/main/java/ceos/ipx/domain/auth/service/AuthService.java +++ b/src/main/java/ceos/ipx/domain/auth/service/AuthService.java @@ -1,11 +1,15 @@ package ceos.ipx.domain.auth.service; +import ceos.ipx.domain.auth.dto.LoginRequest; +import ceos.ipx.domain.auth.dto.LoginResponse; +import ceos.ipx.domain.auth.dto.LoginUserResponse; import ceos.ipx.domain.user.dto.SignUpRequest; import ceos.ipx.domain.user.dto.SignUpResponse; import ceos.ipx.domain.user.entity.User; import ceos.ipx.domain.user.repository.UserRepository; import ceos.ipx.global.exception.BusinessException; import ceos.ipx.global.exception.ErrorCode; +import ceos.ipx.global.security.jwt.JwtTokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -18,6 +22,7 @@ public class AuthService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; @Transactional public SignUpResponse signUp(SignUpRequest request) { @@ -54,4 +59,26 @@ public SignUpResponse signUp(SignUpRequest request) { true ); } -} + + public LoginResponse login(LoginRequest request) { + User user = userRepository.findByEmail(request.email()) + .orElseThrow(() -> new BusinessException(ErrorCode.LOGIN_FAILED)); + + if (!user.isActive()) { + throw new BusinessException(ErrorCode.INACTIVE_USER); + } + + if (user.getPasswordHash() == null || !passwordEncoder.matches(request.password(), user.getPasswordHash())) { + throw new BusinessException(ErrorCode.LOGIN_FAILED); + } + + String accessToken = jwtTokenProvider.createAccessToken(user); + + return new LoginResponse( + accessToken, + jwtTokenProvider.getTokenType(), + jwtTokenProvider.getAccessTokenExpirationSeconds(), + LoginUserResponse.from(user) + ); + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/global/exception/ErrorCode.java b/src/main/java/ceos/ipx/global/exception/ErrorCode.java index b5d98b4..32b4d7d 100644 --- a/src/main/java/ceos/ipx/global/exception/ErrorCode.java +++ b/src/main/java/ceos/ipx/global/exception/ErrorCode.java @@ -7,11 +7,14 @@ @Getter @RequiredArgsConstructor public enum ErrorCode { + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", "잘못된 입력값입니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C002", "서버 내부 오류가 발생했습니다."), EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "AU001", "이미 사용 중인 이메일입니다."), PASSWORD_CONFIRM_MISMATCH(HttpStatus.BAD_REQUEST, "AU002", "비밀번호와 비밀번호 확인이 일치하지 않습니다."), + LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "AU003", "이메일 또는 비밀번호가 일치하지 않습니다."), + INACTIVE_USER(HttpStatus.FORBIDDEN, "AU004", "비활성화된 계정입니다."), UNAUTHORIZED_USER(HttpStatus.UNAUTHORIZED, "SC001", "인증이 필요합니다."), ACCESS_DENIED(HttpStatus.FORBIDDEN, "SC002", "해당 요청에 권한이 없습니다."); @@ -19,4 +22,4 @@ public enum ErrorCode { private final HttpStatus status; private final String code; private final String message; -} +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/global/security/jwt/JwtTokenProvider.java b/src/main/java/ceos/ipx/global/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..6e538bb --- /dev/null +++ b/src/main/java/ceos/ipx/global/security/jwt/JwtTokenProvider.java @@ -0,0 +1,54 @@ +package ceos.ipx.global.security.jwt; + +import ceos.ipx.domain.user.entity.User; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; + +@Component +public class JwtTokenProvider { + + private static final String TOKEN_TYPE = "Bearer"; + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.access-token-expiration-seconds}") + private long accessTokenExpirationSeconds; + + private SecretKey secretKey; + + @PostConstruct + void init() { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + public String createAccessToken(User user) { + Instant now = Instant.now(); + Instant expiration = now.plusSeconds(accessTokenExpirationSeconds); + + return Jwts.builder() + .subject(String.valueOf(user.getId())) + .claim("email", user.getEmail()) + .claim("provider", user.getProvider().name()) + .issuedAt(Date.from(now)) + .expiration(Date.from(expiration)) + .signWith(secretKey) + .compact(); + } + + public String getTokenType() { + return TOKEN_TYPE; + } + + public long getAccessTokenExpirationSeconds() { + return accessTokenExpirationSeconds; + } +} \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index c133397..850d2c1 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -2,7 +2,7 @@ spring: datasource: url: jdbc:postgresql://localhost:5432/patent_db username: ${POSTGRES_USER:ipx_patent_user} - password: ${POSTGRES_PASSWORD} + password: ${POSTGRES_PASSWORD:local_dev_postgres_password} driver-class-name: org.postgresql.Driver jpa: @@ -31,3 +31,7 @@ springdoc: swagger-ui: enabled: true try-it-out-enabled: true + +jwt: + secret: ${JWT_SECRET:ipx-local-jwt-secret-key-for-development-1234567890} + access-token-expiration-seconds: ${JWT_ACCESS_TOKEN_EXPIRATION_SECONDS:3600} From 860b7fdd2b84689ca9bc1482db066bed9baf6ab1 Mon Sep 17 00:00:00 2001 From: Oh-Jisong Date: Sun, 21 Jun 2026 08:33:58 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20AccessToken=20=EC=9E=AC=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 27 ++++++- .../ipx/domain/auth/dto/ReissueResponse.java | 20 +++++ .../ipx/domain/auth/service/AuthService.java | 74 ++++++++++++++++++- .../auth/service/RefreshTokenService.java | 45 +++++++++++ .../ceos/ipx/global/exception/ErrorCode.java | 3 + .../security/config/SecurityWhitelist.java | 1 + .../global/security/cookie/CookieUtils.java | 43 +++++++++++ .../global/security/jwt/JwtTokenProvider.java | 44 +++++++++++ src/main/resources/application-local.yml | 1 + 9 files changed, 253 insertions(+), 5 deletions(-) create mode 100644 src/main/java/ceos/ipx/domain/auth/dto/ReissueResponse.java create mode 100644 src/main/java/ceos/ipx/domain/auth/service/RefreshTokenService.java create mode 100644 src/main/java/ceos/ipx/global/security/cookie/CookieUtils.java diff --git a/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java b/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java index fbf8ed7..95025df 100644 --- a/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java +++ b/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java @@ -17,6 +17,10 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import jakarta.servlet.http.HttpServletResponse; +import ceos.ipx.domain.auth.dto.ReissueResponse; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.bind.annotation.CookieValue; @Tag(name = "Auth API", description = "인증/인가 관련 API") @RestController @@ -55,9 +59,28 @@ public ResponseEntity> signUp( }) @PostMapping("/login") public ResponseEntity> login( - @Valid @RequestBody LoginRequest request + @Valid @RequestBody LoginRequest request, + HttpServletResponse httpServletResponse ) { - LoginResponse response = authService.login(request); + LoginResponse response = authService.login(request, httpServletResponse); + + return ResponseEntity.ok(ApiResponse.ok(response)); + } + + @Operation(summary = "AccessToken 재발급", description = "HttpOnly Cookie에 담긴 RefreshToken을 검증하여 새로운 AccessToken을 발급합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "AccessToken 재발급 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "RefreshToken 누락 또는 유효하지 않음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "비활성화된 계정"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + @PostMapping("/reissue") + public ResponseEntity> reissue( + @CookieValue(name = "refreshToken", required = false) String refreshToken, + HttpServletResponse httpServletResponse + ) { + ReissueResponse response = authService.reissue(refreshToken, httpServletResponse); return ResponseEntity.ok(ApiResponse.ok(response)); } diff --git a/src/main/java/ceos/ipx/domain/auth/dto/ReissueResponse.java b/src/main/java/ceos/ipx/domain/auth/dto/ReissueResponse.java new file mode 100644 index 0000000..a11b41e --- /dev/null +++ b/src/main/java/ceos/ipx/domain/auth/dto/ReissueResponse.java @@ -0,0 +1,20 @@ +package ceos.ipx.domain.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@Schema(description = "AccessToken 재발급 응답") +public class ReissueResponse { + + @Schema(description = "새로 발급된 AccessToken", example = "eyJhbGciOiJIUzI1NiJ9...") + private String accessToken; + + @Schema(description = "토큰 타입", example = "Bearer") + private String tokenType; + + @Schema(description = "AccessToken 만료 시간, 초 단위", example = "3600") + private Long expiresIn; +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/auth/service/AuthService.java b/src/main/java/ceos/ipx/domain/auth/service/AuthService.java index 2a95b67..61667aa 100644 --- a/src/main/java/ceos/ipx/domain/auth/service/AuthService.java +++ b/src/main/java/ceos/ipx/domain/auth/service/AuthService.java @@ -14,7 +14,10 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; - +import ceos.ipx.global.security.cookie.CookieUtils; +import jakarta.servlet.http.HttpServletResponse; +import ceos.ipx.domain.auth.dto.ReissueResponse; +import jakarta.servlet.http.HttpServletResponse; @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -23,6 +26,8 @@ public class AuthService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenService refreshTokenService; + private final CookieUtils cookieUtils; @Transactional public SignUpResponse signUp(SignUpRequest request) { @@ -60,7 +65,7 @@ public SignUpResponse signUp(SignUpRequest request) { ); } - public LoginResponse login(LoginRequest request) { + public LoginResponse login(LoginRequest request, HttpServletResponse httpServletResponse) { User user = userRepository.findByEmail(request.email()) .orElseThrow(() -> new BusinessException(ErrorCode.LOGIN_FAILED)); @@ -73,6 +78,19 @@ public LoginResponse login(LoginRequest request) { } String accessToken = jwtTokenProvider.createAccessToken(user); + String refreshToken = jwtTokenProvider.createRefreshToken(user); + + refreshTokenService.saveRefreshToken( + user.getId(), + refreshToken, + jwtTokenProvider.getRefreshTokenExpirationSeconds() + ); + + cookieUtils.addRefreshTokenCookie( + httpServletResponse, + refreshToken, + jwtTokenProvider.getRefreshTokenExpirationSeconds() + ); return new LoginResponse( accessToken, @@ -81,4 +99,54 @@ public LoginResponse login(LoginRequest request) { LoginUserResponse.from(user) ); } -} \ No newline at end of file + + public ReissueResponse reissue(String refreshToken, HttpServletResponse httpServletResponse) { + if (refreshToken == null || refreshToken.isBlank()) { + throw new BusinessException(ErrorCode.REFRESH_TOKEN_NOT_FOUND); + } + + if (!jwtTokenProvider.validateToken(refreshToken)) { + throw new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN); + } + + Long userId; + try { + userId = jwtTokenProvider.getUserIdFromToken(refreshToken); + } catch (RuntimeException e) { + throw new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN); + } + + if (!refreshTokenService.matches(userId, refreshToken)) { + throw new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN); + } + + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + if (!user.isActive()) { + throw new BusinessException(ErrorCode.INACTIVE_USER); + } + + String newAccessToken = jwtTokenProvider.createAccessToken(user); + String newRefreshToken = jwtTokenProvider.createRefreshToken(user); + + refreshTokenService.saveRefreshToken( + user.getId(), + newRefreshToken, + jwtTokenProvider.getRefreshTokenExpirationSeconds() + ); + + cookieUtils.addRefreshTokenCookie( + httpServletResponse, + newRefreshToken, + jwtTokenProvider.getRefreshTokenExpirationSeconds() + ); + + return ReissueResponse.builder() + .accessToken(newAccessToken) + .tokenType(jwtTokenProvider.getTokenType()) + .expiresIn(jwtTokenProvider.getAccessTokenExpirationSeconds()) + .build(); + } +} + diff --git a/src/main/java/ceos/ipx/domain/auth/service/RefreshTokenService.java b/src/main/java/ceos/ipx/domain/auth/service/RefreshTokenService.java new file mode 100644 index 0000000..2539037 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/auth/service/RefreshTokenService.java @@ -0,0 +1,45 @@ +package ceos.ipx.domain.auth.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + + private static final String REFRESH_TOKEN_KEY_PREFIX = "refreshToken:"; + + private final StringRedisTemplate stringRedisTemplate; + + public void saveRefreshToken(Long userId, String refreshToken, long expirationSeconds) { + String key = createKey(userId); + + stringRedisTemplate.opsForValue() + .set(key, refreshToken, Duration.ofSeconds(expirationSeconds)); + } + + public String getRefreshToken(Long userId) { + String key = createKey(userId); + + return stringRedisTemplate.opsForValue().get(key); + } + + public boolean matches(Long userId, String refreshToken) { + String savedRefreshToken = getRefreshToken(userId); + + return savedRefreshToken != null && savedRefreshToken.equals(refreshToken); + } + + public void deleteRefreshToken(Long userId) { + String key = createKey(userId); + + stringRedisTemplate.delete(key); + } + + private String createKey(Long userId) { + return REFRESH_TOKEN_KEY_PREFIX + userId; + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/global/exception/ErrorCode.java b/src/main/java/ceos/ipx/global/exception/ErrorCode.java index 32b4d7d..89fbda7 100644 --- a/src/main/java/ceos/ipx/global/exception/ErrorCode.java +++ b/src/main/java/ceos/ipx/global/exception/ErrorCode.java @@ -15,6 +15,9 @@ public enum ErrorCode { PASSWORD_CONFIRM_MISMATCH(HttpStatus.BAD_REQUEST, "AU002", "비밀번호와 비밀번호 확인이 일치하지 않습니다."), LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "AU003", "이메일 또는 비밀번호가 일치하지 않습니다."), INACTIVE_USER(HttpStatus.FORBIDDEN, "AU004", "비활성화된 계정입니다."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AU005", "RefreshToken이 존재하지 않습니다."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "AU006", "유효하지 않은 RefreshToken입니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "AU007", "사용자를 찾을 수 없습니다."), UNAUTHORIZED_USER(HttpStatus.UNAUTHORIZED, "SC001", "인증이 필요합니다."), ACCESS_DENIED(HttpStatus.FORBIDDEN, "SC002", "해당 요청에 권한이 없습니다."); diff --git a/src/main/java/ceos/ipx/global/security/config/SecurityWhitelist.java b/src/main/java/ceos/ipx/global/security/config/SecurityWhitelist.java index b539772..8a20942 100644 --- a/src/main/java/ceos/ipx/global/security/config/SecurityWhitelist.java +++ b/src/main/java/ceos/ipx/global/security/config/SecurityWhitelist.java @@ -16,6 +16,7 @@ private SecurityWhitelist() { "/api/auth/login", // 로그인 "/api/auth/logout", // 로그아웃 "/api/auth/refresh", // 토큰 재발급 + "/api/auth/reissue", // AccessToken 재발급 "/api/auth/email/send-otp", // 이메일 OTP 발송 "/api/auth/email/verify-otp", // 이메일 OTP 인증 "/api/auth/password/reset", // 비밀번호 재설정 요청 diff --git a/src/main/java/ceos/ipx/global/security/cookie/CookieUtils.java b/src/main/java/ceos/ipx/global/security/cookie/CookieUtils.java new file mode 100644 index 0000000..5f0402a --- /dev/null +++ b/src/main/java/ceos/ipx/global/security/cookie/CookieUtils.java @@ -0,0 +1,43 @@ +package ceos.ipx.global.security.cookie; + +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +public class CookieUtils { + + private static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken"; + + public void addRefreshTokenCookie( + HttpServletResponse response, + String refreshToken, + long maxAgeSeconds + ) { + ResponseCookie cookie = ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, refreshToken) + .httpOnly(true) + .secure(false) + .sameSite("Lax") + .path("/api/auth/reissue") + .maxAge(maxAgeSeconds) + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + public void deleteRefreshTokenCookie(HttpServletResponse response) { + ResponseCookie cookie = ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, "") + .httpOnly(true) + .secure(false) + .sameSite("Lax") + .path("/api/auth/reissue") + .maxAge(0) + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + public String getRefreshTokenCookieName() { + return REFRESH_TOKEN_COOKIE_NAME; + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/global/security/jwt/JwtTokenProvider.java b/src/main/java/ceos/ipx/global/security/jwt/JwtTokenProvider.java index 6e538bb..1a1d5f5 100644 --- a/src/main/java/ceos/ipx/global/security/jwt/JwtTokenProvider.java +++ b/src/main/java/ceos/ipx/global/security/jwt/JwtTokenProvider.java @@ -1,6 +1,7 @@ package ceos.ipx.global.security.jwt; import ceos.ipx.domain.user.entity.User; +import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import jakarta.annotation.PostConstruct; @@ -23,6 +24,9 @@ public class JwtTokenProvider { @Value("${jwt.access-token-expiration-seconds}") private long accessTokenExpirationSeconds; + @Value("${jwt.refresh-token-expiration-seconds}") + private long refreshTokenExpirationSeconds; + private SecretKey secretKey; @PostConstruct @@ -44,6 +48,42 @@ public String createAccessToken(User user) { .compact(); } + public String createRefreshToken(User user) { + Instant now = Instant.now(); + Instant expiration = now.plusSeconds(refreshTokenExpirationSeconds); + + return Jwts.builder() + .subject(String.valueOf(user.getId())) + .issuedAt(Date.from(now)) + .expiration(Date.from(expiration)) + .signWith(secretKey) + .compact(); + } + + public boolean validateToken(String token) { + try { + Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token); + + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + public Long getUserIdFromToken(String token) { + String subject = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload() + .getSubject(); + + return Long.valueOf(subject); + } + public String getTokenType() { return TOKEN_TYPE; } @@ -51,4 +91,8 @@ public String getTokenType() { public long getAccessTokenExpirationSeconds() { return accessTokenExpirationSeconds; } + + public long getRefreshTokenExpirationSeconds() { + return refreshTokenExpirationSeconds; + } } \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 850d2c1..e44bf1e 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -35,3 +35,4 @@ springdoc: jwt: secret: ${JWT_SECRET:ipx-local-jwt-secret-key-for-development-1234567890} access-token-expiration-seconds: ${JWT_ACCESS_TOKEN_EXPIRATION_SECONDS:3600} + refresh-token-expiration-seconds: ${JWT_REFRESH_TOKEN_EXPIRATION_SECONDS:1209600} \ No newline at end of file