Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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,11 @@
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.domain.auth.service.AuthService.AuthTokens;
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,11 +23,9 @@
@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(
Expand All @@ -42,66 +36,30 @@ public ResponseEntity<ApiResponse<Void>> refresh(
.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");
try {
AuthTokens newTokens = authService.refresh(refreshToken);

boolean secure = jwtProperties.isCookieSecure();
cookieUtil.addCookie(
response,
jwtProperties.getAccessCookieName(),
newTokens.accessToken(),
jwtProperties.getAccessExpirationSeconds(),
secure);
cookieUtil.addCookie(
response,
jwtProperties.getRefreshCookieName(),
newTokens.refreshToken(),
jwtProperties.getRefreshExpirationSeconds(),
secure);

return ResponseEntity.ok(ApiResponse.success(null));

} catch (RuntimeException e) {
log.debug("[AuthApiController] 토큰 갱신 실패 - 쿠키 삭제: {}", e.getMessage());
deleteAuthCookies(response);
return ResponseEntity.status(401)
.body(
ApiResponse.error(
ErrorResponse.of("Refresh Token이 만료되었습니다. 다시 로그인하세요.")));
throw e;
}

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);

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());

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

@PostMapping("/logout")
Expand All @@ -110,41 +68,17 @@ 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);
String accessToken =
cookieUtil
.getCookieValue(request, jwtProperties.getAccessCookieName())
.orElse(null);
String refreshToken =
cookieUtil
.getCookieValue(request, jwtProperties.getRefreshCookieName())
.orElse(null);
UUID memberId = (authMember != null) ? authMember.getMemberId() : 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());
}
}
}
authService.logout(accessToken, refreshToken, memberId);

deleteAuthCookies(response);
return ResponseEntity.ok(ApiResponse.success(null));
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
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package kr.java.documind.domain.auth.service;

import io.jsonwebtoken.ExpiredJwtException;
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.global.config.JwtProperties;
import kr.java.documind.global.exception.ForbiddenException;
import kr.java.documind.global.exception.UnauthorizedException;
import kr.java.documind.global.security.RedisTokenService;
import kr.java.documind.global.security.jwt.TokenProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {

private final TokenProvider jwtProvider;
private final RedisTokenService redisTokenService;
private final MemberService memberService;
private final JwtProperties jwtProperties;

@Transactional
public AuthTokens refresh(String refreshToken) {
if (refreshToken == null) {
throw new UnauthorizedException("Refresh Token이 없습니다.");
}

try {
jwtProvider.validateToken(refreshToken);
} catch (ExpiredJwtException e) {
throw new UnauthorizedException("Refresh Token이 만료되었습니다. 다시 로그인하세요.", e);
} catch (UnauthorizedException e) {
log.warn("[AuthService] Refresh Token 검증 실패: {}", e.getMessage());
throw new UnauthorizedException("유효하지 않은 Refresh Token입니다. 다시 로그인하세요.", e);
}

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);
throw new UnauthorizedException("유효하지 않은 Refresh Token입니다. 다시 로그인하세요.");
}

Member member = memberService.getMemberWithCompany(memberId);
if (!member.isActive()) {
log.warn(
"[AuthService] 비활성 계정의 토큰 갱신 시도: memberId={} status={}",
memberId,
member.getAccountStatus());
throw new ForbiddenException("계정이 비활성화되었습니다. 다시 로그인하세요.");
}

String newAccessToken = jwtProvider.generateAccessToken(memberId, globalRole);
String newRefreshToken = jwtProvider.generateRefreshToken(memberId, globalRole);

redisTokenService.saveRefreshToken(
memberId, newRefreshToken, jwtProperties.getRefreshExpirationSeconds());

log.debug("[AuthService] Access Token 재발급 완료: memberId={}", memberId);

return new AuthTokens(newAccessToken, newRefreshToken);
}

@Transactional
public void logout(String accessToken, String refreshToken, UUID memberId) {
if (memberId != null) {
// 인증된 상태에서의 로그아웃
if (accessToken != null) {
long remainingMillis = jwtProvider.getRemainingMillis(accessToken);
if (remainingMillis > 0) {
redisTokenService.addToBlacklist(accessToken, remainingMillis);
}
}
redisTokenService.deleteRefreshToken(memberId);
log.info("[AuthService] 로그아웃: memberId={}", memberId);
} else if (refreshToken != null) {
// 만료된 토큰 등으로 인증 정보가 없을 때 Refresh Token을 이용한 로그아웃 시도
try {
UUID extractedMemberId = jwtProvider.getMemberIdFromExpiredToken(refreshToken);
redisTokenService.deleteRefreshToken(extractedMemberId);
log.info(
"[AuthService] 만료된 토큰으로 로그아웃 처리 (Refresh Token 정리): memberId={}",
extractedMemberId);
} catch (Exception e) {
log.debug("Refresh Token에서 memberId 추출 실패 (이미 정리되었거나 서명 오류): {}", e.getMessage());
}
}
}

public record AuthTokens(String accessToken, String refreshToken) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package kr.java.documind.domain.member.controller;

import kr.java.documind.domain.auth.model.enums.GlobalRole;
import kr.java.documind.domain.member.service.CompanyService;
import kr.java.documind.domain.member.service.CompanyService.AdminPageData;
import kr.java.documind.global.security.jwt.CustomUserDetails;
import lombok.RequiredArgsConstructor;
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;

@Controller
@RequestMapping("/admin")
@RequiredArgsConstructor
public class AdminViewController {

private final CompanyService companyService;

@GetMapping("/companies")
public String companyAdmin(@AuthenticationPrincipal CustomUserDetails authMember, Model model) {

if (authMember.getGlobalRole() != GlobalRole.ADMIN) {
return "redirect:/my/company";
}

AdminPageData pageData = companyService.getAdminCompanyPageData(authMember.getMemberId());

model.addAttribute("headerInfo", pageData.headerInfo());
model.addAttribute("pending", pageData.pendingCompanies());
model.addAttribute("approved", pageData.approvedCompanies());
model.addAttribute("suspended", pageData.suspendedCompanies());
model.addAttribute("pendingCount", pageData.pendingCount());
model.addAttribute("approvedCount", pageData.approvedCount());
model.addAttribute("suspendedCount", pageData.suspendedCount());
return "member/company-admin";
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package kr.java.documind.domain.member.controller;

import jakarta.servlet.http.HttpServletResponse;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import kr.java.documind.domain.auth.exception.AlreadyProjectMemberException;
import kr.java.documind.domain.member.exception.InvalidInviteTokenException;
import kr.java.documind.domain.member.exception.InviteEmailMismatchException;
Expand Down Expand Up @@ -38,14 +40,17 @@ public String showInvite(
Model model) {

if (auth == null) {
String redirectUrl = "/invite?token=" + token;
cookieUtil.addCookie(
response,
HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_AFTER_LOGIN_COOKIE,
"/invite?token=" + token,
redirectUrl,
600L,
jwtProperties.isCookieSecure());
log.info("[InviteViewController] 미인증 접근 → 로그인 후 복귀 경로 저장: /invite?token=...");
return "redirect:/auth/login";
log.info("[InviteViewController] 미인증 접근 → 로그인 후 복귀 경로 저장: {}", redirectUrl);

String encodedRedirect = URLEncoder.encode(redirectUrl, StandardCharsets.UTF_8);
return "redirect:/auth/login?flow=invite&redirect=" + encodedRedirect;
}

try {
Expand All @@ -60,10 +65,15 @@ public String showInvite(

} catch (AlreadyProjectMemberException e) {
return "redirect:/projects/" + e.getProjectPublicId() + "/groups";
} catch (InvalidInviteTokenException | InviteEmailMismatchException e) {
} catch (InviteEmailMismatchException e) {
model.addAttribute(
"errorMessage",
"초대받은 이메일(" + e.getExpectedEmail() + ")과 현재 로그인된 계정의 이메일이 다릅니다.");
model.addAttribute("mismatch", true);
return "member/invite-error";
} catch (InvalidInviteTokenException e) {
model.addAttribute("errorMessage", e.getMessage());
return "member/invite-error";

} catch (BusinessException e) {
// 삭제된 프로젝트 등 기타 BusinessException
model.addAttribute("errorMessage", e.getMessage());
Expand Down
Loading
Loading