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 @@ -3,15 +3,10 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.UUID;
import kr.java.documind.domain.auth.model.enums.GlobalRole;
import kr.java.documind.domain.member.model.entity.Member;
import kr.java.documind.domain.member.service.MemberService;
import kr.java.documind.domain.auth.service.AuthService;
import kr.java.documind.global.config.JwtProperties;
import kr.java.documind.global.response.ApiResponse;
import kr.java.documind.global.response.ErrorResponse;
import kr.java.documind.global.security.RedisTokenService;
import kr.java.documind.global.security.jwt.CustomUserDetails;
import kr.java.documind.global.security.jwt.TokenProvider;
import kr.java.documind.global.util.CookieUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -27,80 +22,19 @@
@RequiredArgsConstructor
public class AuthApiController {

private final TokenProvider jwtProvider;
private final AuthService authService;
private final JwtProperties jwtProperties;
private final CookieUtil cookieUtil;
private final RedisTokenService redisTokenService;
private final MemberService memberService;

@PostMapping("/refresh")
public ResponseEntity<ApiResponse<Void>> refresh(
HttpServletRequest request, HttpServletResponse response) {

String refreshToken =
cookieUtil
.getCookieValue(request, jwtProperties.getRefreshCookieName())
.orElse(null);

if (refreshToken == null) {
return ResponseEntity.status(401)
.body(ApiResponse.error(ErrorResponse.of("Refresh Token이 없습니다.")));
}

if (!jwtProvider.validateToken(refreshToken)) {
log.debug("만료 또는 유효하지 않은 Refresh Token");
deleteAuthCookies(response);
return ResponseEntity.status(401)
.body(
ApiResponse.error(
ErrorResponse.of("Refresh Token이 만료되었습니다. 다시 로그인하세요.")));
}

UUID memberId = jwtProvider.getMemberId(refreshToken);
GlobalRole globalRole = jwtProvider.getGlobalRole(refreshToken);

String storedToken = redisTokenService.consumeRefreshToken(memberId);
if (!refreshToken.equals(storedToken)) {
log.warn("Refresh Token 불일치 — 탈취 가능성: memberId={}", memberId);
deleteAuthCookies(response);
return ResponseEntity.status(401)
.body(
ApiResponse.error(
ErrorResponse.of("유효하지 않은 Refresh Token입니다. 다시 로그인하세요.")));
}

Member member = memberService.getMemberWithCompany(memberId);
if (!member.isActive()) {
log.warn(
"[AuthApiController] 비활성 계정의 토큰 갱신 시도: memberId={} status={}",
memberId,
member.getAccountStatus());
deleteAuthCookies(response);
return ResponseEntity.status(401)
.body(ApiResponse.error(ErrorResponse.of("계정이 비활성화되었습니다. 다시 로그인하세요.")));
}

String newAccessToken = jwtProvider.generateAccessToken(memberId, globalRole);
String newRefreshToken = jwtProvider.generateRefreshToken(memberId, globalRole);
String refreshToken = getCookieValue(request, jwtProperties.getRefreshCookieName());

boolean secure = jwtProperties.isCookieSecure();
cookieUtil.addCookie(
response,
jwtProperties.getAccessCookieName(),
newAccessToken,
jwtProperties.getAccessExpirationSeconds(),
secure);
cookieUtil.addCookie(
response,
jwtProperties.getRefreshCookieName(),
newRefreshToken,
jwtProperties.getRefreshExpirationSeconds(),
secure);

redisTokenService.saveRefreshToken(
memberId, newRefreshToken, jwtProperties.getRefreshExpirationSeconds());
AuthService.AuthTokens newTokens = authService.refresh(refreshToken);
addAuthCookies(response, newTokens);

log.debug("[AuthApiController] Access Token 재발급 완료: memberId={}", memberId);
return ResponseEntity.ok(ApiResponse.success(null));
}

Expand All @@ -110,46 +44,38 @@ public ResponseEntity<ApiResponse<Void>> logout(
HttpServletRequest request,
HttpServletResponse response) {

if (authMember != null) {
String accessToken =
cookieUtil
.getCookieValue(request, jwtProperties.getAccessCookieName())
.orElse(null);

if (accessToken != null) {
long remainingMillis = jwtProvider.getRemainingMillis(accessToken);
if (remainingMillis > 0) {
redisTokenService.addToBlacklist(accessToken, remainingMillis);
}
}

redisTokenService.deleteRefreshToken(authMember.getMemberId());
log.info("[AuthApiController] 로그아웃: memberId={}", authMember.getMemberId());

} else {
String refreshToken =
cookieUtil
.getCookieValue(request, jwtProperties.getRefreshCookieName())
.orElse(null);

if (refreshToken != null) {
try {
UUID memberId = jwtProvider.getMemberIdFromExpiredToken(refreshToken);
redisTokenService.deleteRefreshToken(memberId);
log.info(
"[AuthApiController] 만료된 토큰으로 로그아웃 처리 (Refresh Token 정리): memberId={}",
memberId);
} catch (Exception e) {
log.debug(
"Refresh Token에서 memberId 추출 실패 (이미 정리되었거나 서명 오류): {}", e.getMessage());
}
}
}
String accessToken = getCookieValue(request, jwtProperties.getAccessCookieName());
String refreshToken = getCookieValue(request, jwtProperties.getRefreshCookieName());
UUID memberId = authMember != null ? authMember.getMemberId() : null;

authService.logout(accessToken, refreshToken, memberId);

deleteAuthCookies(response);
return ResponseEntity.ok(ApiResponse.success(null));
}

private String getCookieValue(HttpServletRequest request, String cookieName) {
return cookieUtil.getCookieValue(request, cookieName).orElse(null);
}

private void addAuthCookies(HttpServletResponse response, AuthService.AuthTokens authTokens) {
boolean secure = jwtProperties.isCookieSecure();

cookieUtil.addCookie(
response,
jwtProperties.getAccessCookieName(),
authTokens.accessToken(),
jwtProperties.getAccessExpirationSeconds(),
secure);

cookieUtil.addCookie(
response,
jwtProperties.getRefreshCookieName(),
authTokens.refreshToken(),
jwtProperties.getRefreshExpirationSeconds(),
secure);
}

private void deleteAuthCookies(HttpServletResponse response) {
boolean secure = jwtProperties.isCookieSecure();
cookieUtil.deleteCookie(response, jwtProperties.getAccessCookieName(), secure);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,28 @@
import kr.java.documind.global.security.jwt.CustomUserDetails;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
@RequestMapping("/auth")
public class AuthViewController {

@GetMapping("/login")
public String loginPage(@AuthenticationPrincipal CustomUserDetails authMember) {
public String loginPage(
@AuthenticationPrincipal CustomUserDetails authMember,
@RequestParam(required = false) String flow,
@RequestParam(required = false) String redirect,
Model model) {

if (authMember != null) {
return "redirect:/member/dashboard";
}

model.addAttribute("isInviteFlow", "invite".equals(flow));
model.addAttribute("redirectUrl", redirect);
return "auth/login";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package kr.java.documind.domain.auth.exception;

import jakarta.servlet.http.HttpServletResponse;
import kr.java.documind.domain.auth.controller.AuthApiController;
import kr.java.documind.global.config.JwtProperties;
import kr.java.documind.global.exception.BusinessException;
import kr.java.documind.global.exception.ForbiddenException;
import kr.java.documind.global.exception.UnauthorizedException;
import kr.java.documind.global.response.ApiResponse;
import kr.java.documind.global.response.ErrorResponse;
import kr.java.documind.global.util.CookieUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice(assignableTypes = AuthApiController.class)
@RequiredArgsConstructor
public class AuthApiExceptionHandler {

private final JwtProperties jwtProperties;
private final CookieUtil cookieUtil;

@ExceptionHandler({UnauthorizedException.class, ForbiddenException.class})
public ResponseEntity<ApiResponse<Void>> handleAuthException(
BusinessException e, HttpServletResponse response) {

deleteAuthCookies(response);

return ResponseEntity.status(resolveStatus(e))
.body(ApiResponse.error(ErrorResponse.of(e.getMessage())));
}

private int resolveStatus(BusinessException e) {
if (e instanceof UnauthorizedException) {
return HttpServletResponse.SC_UNAUTHORIZED;
}
if (e instanceof ForbiddenException) {
return HttpServletResponse.SC_FORBIDDEN;
}
return HttpServletResponse.SC_BAD_REQUEST;
}

private void deleteAuthCookies(HttpServletResponse response) {
boolean secure = jwtProperties.isCookieSecure();
cookieUtil.deleteCookie(response, jwtProperties.getAccessCookieName(), secure);
cookieUtil.deleteCookie(response, jwtProperties.getRefreshCookieName(), secure);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package kr.java.documind.domain.auth.exception;

import kr.java.documind.global.exception.UnauthorizedException;

public class InvalidTokenException extends UnauthorizedException {
public InvalidTokenException() {
super("유효하지 않은 토큰입니다.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package kr.java.documind.domain.auth.exception;

import kr.java.documind.global.exception.UnauthorizedException;

public class TokenExpiredException extends UnauthorizedException {

public TokenExpiredException() {
super("토큰이 만료되었습니다.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ public interface ProjectRepository extends JpaRepository<Project, UUID> {

Optional<Project> findByPublicId(String publicId);

@Query("SELECT p FROM Project p JOIN FETCH p.company WHERE p.publicId = :publicId")
Optional<Project> findByPublicIdWithCompany(@Param("publicId") String publicId);

@Query(
"""
SELECT p.id AS projectId,
Expand Down
Loading
Loading