Skip to content
Open
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
48 changes: 46 additions & 2 deletions src/main/java/ceos/ipx/domain/auth/controller/AuthController.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand All @@ -33,11 +39,49 @@ public class AuthController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@PostMapping("/signup")
public ResponseEntity<ApiResponse<SignUpResponse>> signUp(@Valid @RequestBody SignUpRequest request) {
public ResponseEntity<ApiResponse<SignUpResponse>> 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<ApiResponse<LoginResponse>> 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<ApiResponse<ReissueResponse>> reissue(
@CookieValue(name = "refreshToken", required = false) String refreshToken,
HttpServletResponse httpServletResponse
) {
ReissueResponse response = authService.reissue(refreshToken, httpServletResponse);

return ResponseEntity.ok(ApiResponse.ok(response));
}
}
22 changes: 22 additions & 0 deletions src/main/java/ceos/ipx/domain/auth/dto/LoginRequest.java
Original file line number Diff line number Diff line change
@@ -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
) {
}
20 changes: 20 additions & 0 deletions src/main/java/ceos/ipx/domain/auth/dto/LoginResponse.java
Original file line number Diff line number Diff line change
@@ -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
) {
}
42 changes: 42 additions & 0 deletions src/main/java/ceos/ipx/domain/auth/dto/LoginUserResponse.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
20 changes: 20 additions & 0 deletions src/main/java/ceos/ipx/domain/auth/dto/ReissueResponse.java
Original file line number Diff line number Diff line change
@@ -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;
}
97 changes: 96 additions & 1 deletion src/main/java/ceos/ipx/domain/auth/service/AuthService.java
Original file line number Diff line number Diff line change
@@ -1,23 +1,33 @@
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)
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) {
Expand Down Expand Up @@ -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();
}
}

Original file line number Diff line number Diff line change
@@ -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;
}
}
8 changes: 7 additions & 1 deletion src/main/java/ceos/ipx/global/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,22 @@
@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", "해당 요청에 권한이 없습니다.");

private final HttpStatus status;
private final String code;
private final String message;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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", // 비밀번호 재설정 요청
Expand Down
Loading