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
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@
import com.indayvidual.server.domain.user.service.UserService.RefreshTokenService;
import com.indayvidual.server.domain.user.service.UserService.UserAuthService;
import com.indayvidual.server.global.api.response.ApiResponse;
import com.indayvidual.server.global.exception.GeneralException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import com.indayvidual.server.global.api.code.status.ErrorStatus;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
Expand All @@ -27,44 +30,72 @@ public class AuthController {
private final KakaoAuthService kakaoAuthService;
private final RefreshTokenService refreshTokenService;

@Operation(
summary = "회원가입",
description = "이메일과 비밀번호, 이름, 전화번호를 입력하여 회원가입을 진행합니다. 이미 가입된 이메일인 경우 실패합니다."
)
@PostMapping("/signup")
public ResponseEntity<ApiResponse<SignupResponseDTO>> signup(@RequestBody SignupRequestDTO request) {
public ResponseEntity<ApiResponse<SignupResponseDTO>> signup(@RequestBody @Valid SignupRequestDTO request) {
SignupResponseDTO response = userAuthService.signupWithEmail(request);
return ResponseEntity.ok(ApiResponse.onSuccess(response));
}

@Operation(
summary = "로그인",
description = "이메일과 비밀번호를 사용하여 로그인합니다. 성공 시 Access Token과 Refresh Token을 발급합니다."
)
@PostMapping("/login")
public ResponseEntity<ApiResponse<LoginResponseDTO>> login(@RequestBody LoginRequestDTO request) {
public ResponseEntity<ApiResponse<LoginResponseDTO>> login(@RequestBody @Valid LoginRequestDTO request) {
LoginResponseDTO response = userAuthService.loginWithEmailAndPassword(
request.getEmail(), request.getPassword()
);
return ResponseEntity.ok(ApiResponse.onSuccess(response));
}

@Operation(
summary = "카카오 로그인",
description = "카카오 OAuth 액세스 토큰을 이용해 로그인합니다. 기존 회원이면 로그인, 아니면 자동 회원가입 후 로그인 처리됩니다."
)
@PostMapping("/kakao")
public ResponseEntity<ApiResponse<LoginResponseDTO>> kakaoLogin(
@RequestBody KakaoLoginRequestDTO req) {
@RequestBody @Valid KakaoLoginRequestDTO req) {

LoginResponseDTO dto = kakaoAuthService.loginWithKakao(req.getAccessToken());
return ResponseEntity.ok(ApiResponse.onSuccess(dto));
}

@Operation(
summary = "Access Token 재발급",
description = "Refresh Token을 이용해 새로운 Access Token과 Refresh Token을 발급합니다. 만료되거나 유효하지 않은 Refresh Token은 거부됩니다."
)
@PostMapping("/refresh")
public ResponseEntity<ApiResponse<LoginResponseDTO>> refresh(
@RequestHeader("Refresh-Token") String refreshToken
@RequestHeader(value = "Refresh-Token", required = false) String refreshToken
) {
if (refreshToken == null || refreshToken.isBlank()) {
throw new GeneralException(ErrorStatus.AUTH_REFRESH_MISSING);
}
if (refreshToken.startsWith("Bearer ")) {
refreshToken = refreshToken.substring(7);
}
LoginResponseDTO dto = refreshTokenService.rotate(refreshToken);
return ResponseEntity.ok(ApiResponse.onSuccess(dto));
}

@Operation(
summary = "로그아웃",
description = "Refresh Token을 사용하여 로그아웃 처리합니다. 해당 토큰은 폐기되며 재사용할 수 없습니다."
)
@PostMapping("/logout")
public ResponseEntity<ApiResponse<String>> logout(
@RequestHeader("Authorization") String bearerToken
@RequestHeader(value = "Refresh-Token", required = false) String refreshToken
) {
String refreshToken = bearerToken.replace("Bearer ", "");
if (refreshToken == null || refreshToken.isBlank()) {
throw new GeneralException(ErrorStatus.AUTH_REFRESH_MISSING);
}
if (refreshToken.startsWith("Bearer ")) {
refreshToken = refreshToken.substring(7);
}
userAuthService.logoutWithRefreshToken(refreshToken);
return ResponseEntity.ok(ApiResponse.onSuccess("로그아웃 완료"));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package com.indayvidual.server.domain.user.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class KakaoLoginRequestDTO {

@Schema(description = "카카오 OAuth 액세스 토큰", example = "AAAAQwAAABBh...kaKaoAccessTokenSample", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "accessToken은 필수입니다.")
private String accessToken;
}

Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.indayvidual.server.domain.user.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
Expand All @@ -12,10 +13,13 @@
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequestDTO {

@Schema(description = "로그인 이메일", example = "user@example.com", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "올바른 이메일 형식이 아닙니다.")
private String email;

@Schema(description = "로그인 비밀번호", example = "P@ssw0rd123!", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "비밀번호는 필수입니다.")
private String password;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.indayvidual.server.domain.user.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Getter;
Expand All @@ -13,16 +15,22 @@
@NoArgsConstructor
@AllArgsConstructor
public class SignupRequestDTO {

@Schema(description = "회원가입 이메일", example = "user@example.com", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "올바른 이메일 형식이 아닙니다.")
private String email;

@Schema(description = "회원가입 비밀번호(8자 이상)", example = "P@ssw0rd123!", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "비밀번호는 필수입니다.")
@Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다.")
private String password;

@Schema(description = "사용자 이름", example = "박성준", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "이름은 필수입니다.")
private String username;

@Schema(description = "전화번호(선택)", example = "010-1234-5678")
@Pattern(regexp = "^01[0-9]-?\\d{3,4}-?\\d{4}$", message = "전화번호 형식이 올바르지 않습니다.")
private String phoneNumber;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.indayvidual.server.domain.user.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;

@Getter
Expand All @@ -8,10 +9,16 @@
@AllArgsConstructor
@NoArgsConstructor
public class LoginResponseDTO {
@Schema(description = "JWT 액세스 토큰", example = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.TOKEN_SAMPLE")
private String accessToken;
@Schema(description = "JWT 리프레시 토큰", example = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIxMjM0In0.REFRESH_SAMPLE")
private String refreshToken;
@Schema(description = "사용자 ID", example = "1")
private Long userId;
@Schema(description = "사용자 이메일", example = "user@example.com")
private String email;
@Schema(description = "사용자 이름", example = "박성준")
private String username;
@Schema(description = "역할(권한)", example = "ROLE_USER")
private String role;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.indayvidual.server.domain.user.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
Expand All @@ -8,8 +9,12 @@
@Builder
@AllArgsConstructor
public class SignupResponseDTO {
@Schema(description = "생성된 사용자 ID", example = "1")
private Long userId;
@Schema(description = "사용자 이메일", example = "user@example.com")
private String email;
@Schema(description = "사용자 이름", example = "박성준")
private String username;
@Schema(description = "결과 메시지", example = "회원가입이 완료되었습니다.")
private String message;
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@
import com.indayvidual.server.domain.user.repository.UserProviderRepository;
import com.indayvidual.server.domain.user.repository.UserRepository;
import com.indayvidual.server.domain.user.service.UserService.RefreshTokenService;
import com.indayvidual.server.global.api.code.status.ErrorStatus;
import com.indayvidual.server.global.client.KakaoApiClient;
import com.indayvidual.server.global.config.security.JwtTokenProvider;
import com.indayvidual.server.global.exception.GeneralException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;


@Slf4j
@Service
Expand All @@ -31,8 +35,15 @@ public class KakaoAuthService {

@Transactional
public LoginResponseDTO loginWithKakao(String kakaoAccessToken) {
// 1. 카카오 access token 검증 + 프로필 조회
KakaoProfile profile = kakaoApiClient.fetchProfile(kakaoAccessToken);
KakaoProfile profile;
try {
// 1. 카카오 access token 검증 + 프로필 조회
profile = kakaoApiClient.fetchProfile(kakaoAccessToken);
} catch (GeneralException e) {
throw e; // 이미 매핑된 ErrorStatus
} catch (Exception e) {
throw new GeneralException(ErrorStatus.AUTH_OAUTH_PROVIDER_ERROR);
}

// 2. 우리 서비스의 User 조회 or 신규 생성
User user = userProviderRepository
Expand All @@ -56,9 +67,17 @@ public LoginResponseDTO loginWithKakao(String kakaoAccessToken) {
}

private User signUpKakaoUser(KakaoProfile profile) {
String email = profile.getKakao_account().getEmail();
String nickname = profile.getKakao_account().getProfile().getNickname();
String image = profile.getKakao_account().getProfile().getProfile_image_url();
String email = Optional.ofNullable(profile.getKakao_account())
.map(KakaoProfile.KakaoAccount::getEmail)
.orElse(null); // 이메일 동의 X 케이스 고려
String nickname = Optional.ofNullable(profile.getKakao_account())
.map(KakaoProfile.KakaoAccount::getProfile)
.map(KakaoProfile.KakaoAccount.Profile::getNickname)
.orElse("카카오 사용자");
String image = Optional.ofNullable(profile.getKakao_account())
.map(KakaoProfile.KakaoAccount::getProfile)
.map(KakaoProfile.KakaoAccount.Profile::getProfile_image_url)
.orElse(null);

User newUser = User.builder()
.email(email)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
import com.indayvidual.server.domain.user.entity.User;
import com.indayvidual.server.domain.user.repository.RefreshTokenRepository;
import com.indayvidual.server.domain.user.repository.UserRepository;
import com.indayvidual.server.global.api.code.status.ErrorStatus;
import com.indayvidual.server.global.config.security.JwtTokenProvider;
import com.indayvidual.server.global.exception.GeneralException;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand Down Expand Up @@ -42,23 +44,28 @@ public String saveRefreshToken(User user) {

/** /api/auth/refresh 호출 */
public LoginResponseDTO rotate(String oldRefresh) {
Claims claims = jwt.parseClaims(oldRefresh);
log.info("claims.getId() = {}", claims.getId());
log.info("claims = {}", claims); // 전체 claims 로그
log.info("추출된 tokenId(jti): {}", claims.getId());
Claims claims;
try {
claims = jwt.parseClaims(oldRefresh);
} catch (io.jsonwebtoken.ExpiredJwtException e) {
throw new GeneralException(ErrorStatus.AUTH_REFRESH_EXPIRED);
} catch (io.jsonwebtoken.JwtException e) {
throw new GeneralException(ErrorStatus.AUTH_REFRESH_INVALID);
}

RefreshToken stored = repo.findByTokenId(claims.getId())
.orElseThrow(() -> new IllegalStateException("유효하지 않은 refreshToken"));
.orElseThrow(() -> new GeneralException(ErrorStatus.AUTH_REFRESH_NOT_FOUND));

if (!stored.match(oldRefresh) || stored.isRevoked() || stored.getExpiredAt().isBefore(LocalDateTime.now())) {
throw new IllegalStateException("만료되었거나 취소된 refreshToken");
throw new GeneralException(ErrorStatus.AUTH_REFRESH_REVOKED);
}

// 1) 현재 refreshToken 사용 종료
stored.revoke();

// 2) 새 토큰 발급
User user = userRepo.findById(Long.parseLong(claims.getSubject()))
.orElseThrow(() -> new IllegalStateException("존재하지 않는 유저"));
.orElseThrow(() -> new GeneralException(ErrorStatus.AUTH_USER_NOT_FOUND));

String newAccess = jwt.generateAccessToken(user);
String newRefresh = saveRefreshToken(user); // 위 메서드 재사용
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,18 @@
import com.indayvidual.server.domain.user.repository.RefreshTokenRepository;
import com.indayvidual.server.domain.user.repository.UserProviderRepository;
import com.indayvidual.server.domain.user.repository.UserRepository;
import com.indayvidual.server.global.api.code.status.ErrorStatus;
import com.indayvidual.server.global.config.security.JwtTokenProvider;
import com.indayvidual.server.global.config.security.JwtValidationType;
import com.indayvidual.server.global.config.security.UserAuthentication;
import com.indayvidual.server.global.exception.GeneralException;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Duration;
import java.time.Instant;
import java.util.List;


@Service
@RequiredArgsConstructor
Expand All @@ -41,7 +40,7 @@ public class UserAuthServiceImpl implements UserAuthService {
public SignupResponseDTO signupWithEmail(SignupRequestDTO request) {

if (userRepository.existsByEmail(request.getEmail())) {
throw new IllegalArgumentException("이미 존재하는 이메일입니다.");
throw new GeneralException(ErrorStatus.AUTH_EMAIL_DUPLICATED);
}
String encodedPassword = passwordEncoder.encode(request.getPassword());

Expand Down Expand Up @@ -74,27 +73,23 @@ public SignupResponseDTO signupWithEmail(SignupRequestDTO request) {
}

@Override
@Transactional
public LoginResponseDTO loginWithEmailAndPassword(String email, String rawPassword) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다."));
.orElseThrow(() -> new GeneralException(ErrorStatus.AUTH_INVALID_CREDENTIALS));

boolean hasLocalProvider = userProviderRepository
.findByUserAndProvider(user, Provider.LOCAL)
.filter(UserProvider::getIsActive)
.isPresent();

// UserProvider에서 LOCAL provider 확인
if (!hasLocalProvider) {
throw new IllegalArgumentException("소셜 로그인 유저입니다. 이메일 로그인 불가");
}

// 비밀번호가 null인 경우 (소셜 로그인만 있는 경우)
if (user.getPassword() == null) {
throw new IllegalArgumentException("소셜 로그인 유저입니다. 이메일 로그인 불가");
if (!hasLocalProvider || user.getPassword() == null) {
throw new GeneralException(ErrorStatus.AUTH_LOCAL_NOT_AVAILABLE);
}

if (!passwordEncoder.matches(rawPassword, user.getPassword())) {
throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
throw new GeneralException(ErrorStatus.AUTH_INVALID_CREDENTIALS);
}

return createLoginResponse(user);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ public enum ErrorStatus implements BaseErrorCode {
USER_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "USER4013", "토큰이 만료되었습니다."),
USER_INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "USER4014", "아이디 또는 비밀번호가 올바르지 않습니다."),

// ===== AUTH =====
AUTH_EMAIL_DUPLICATED(HttpStatus.CONFLICT, "AUTH4091", "이미 가입된 이메일입니다."),
AUTH_INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH4010", "이메일 또는 비밀번호가 올바르지 않습니다."),
AUTH_LOCAL_NOT_AVAILABLE(HttpStatus.BAD_REQUEST, "AUTH4001", "소셜 전용 계정으로 이메일 로그인이 불가합니다."),
AUTH_REFRESH_MISSING(HttpStatus.BAD_REQUEST, "AUTH4002", "Refresh-Token 헤더가 없습니다."),
AUTH_REFRESH_INVALID(HttpStatus.UNAUTHORIZED, "AUTH4011", "유효하지 않은 리프레시 토큰입니다."),
AUTH_REFRESH_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH4012", "만료된 리프레시 토큰입니다."),
AUTH_REFRESH_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH4013", "저장된 리프레시 토큰이 없습니다."),
AUTH_REFRESH_REVOKED(HttpStatus.UNAUTHORIZED, "AUTH4014", "이미 사용되었거나 취소된 리프레시 토큰입니다."),
AUTH_USER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH4015", "존재하지 않는 사용자입니다."),
AUTH_OAUTH_INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH4016", "소셜 액세스 토큰이 유효하지 않습니다."),
AUTH_OAUTH_PROVIDER_ERROR(HttpStatus.BAD_GATEWAY, "AUTH5021", "소셜 인증 제공자와의 통신에 실패했습니다."),

// 계정 관련
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER4041", "사용자를 찾을 수 없습니다."),
USER_ALREADY_EXISTS(HttpStatus.CONFLICT, "USER4091", "이미 존재하는 사용자입니다."),
Expand Down
Loading