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..95025df 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; @@ -15,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 @@ -33,11 +39,49 @@ 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, + HttpServletResponse httpServletResponse + ) { + 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)); + } +} \ 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/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 6145f15..61667aa 100644 --- a/src/main/java/ceos/ipx/domain/auth/service/AuthService.java +++ b/src/main/java/ceos/ipx/domain/auth/service/AuthService.java @@ -1,16 +1,23 @@ 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; 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) @@ -18,6 +25,9 @@ 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) { @@ -54,4 +64,89 @@ public SignUpResponse signUp(SignUpRequest request) { true ); } + + public LoginResponse login(LoginRequest request, HttpServletResponse httpServletResponse) { + 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); + String refreshToken = jwtTokenProvider.createRefreshToken(user); + + refreshTokenService.saveRefreshToken( + user.getId(), + refreshToken, + jwtTokenProvider.getRefreshTokenExpirationSeconds() + ); + + cookieUtils.addRefreshTokenCookie( + httpServletResponse, + refreshToken, + jwtTokenProvider.getRefreshTokenExpirationSeconds() + ); + + return new LoginResponse( + accessToken, + jwtTokenProvider.getTokenType(), + jwtTokenProvider.getAccessTokenExpirationSeconds(), + LoginUserResponse.from(user) + ); + } + + 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 b5d98b4..89fbda7 100644 --- a/src/main/java/ceos/ipx/global/exception/ErrorCode.java +++ b/src/main/java/ceos/ipx/global/exception/ErrorCode.java @@ -7,11 +7,17 @@ @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", "비활성화된 계정입니다."), + 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", "해당 요청에 권한이 없습니다."); @@ -19,4 +25,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/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 new file mode 100644 index 0000000..1a1d5f5 --- /dev/null +++ b/src/main/java/ceos/ipx/global/security/jwt/JwtTokenProvider.java @@ -0,0 +1,98 @@ +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; +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; + + @Value("${jwt.refresh-token-expiration-seconds}") + private long refreshTokenExpirationSeconds; + + 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 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; + } + + 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 c133397..e44bf1e 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,8 @@ 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} + refresh-token-expiration-seconds: ${JWT_REFRESH_TOKEN_EXPIRATION_SECONDS:1209600} \ No newline at end of file