diff --git a/common/src/main/java/com/nalssilog/common/exception/GlobalExceptionHandler.java b/app/src/main/java/com/nalssilog/app/api/GlobalExceptionHandler.java similarity index 57% rename from common/src/main/java/com/nalssilog/common/exception/GlobalExceptionHandler.java rename to app/src/main/java/com/nalssilog/app/api/GlobalExceptionHandler.java index c97d992..ce3a02f 100644 --- a/common/src/main/java/com/nalssilog/common/exception/GlobalExceptionHandler.java +++ b/app/src/main/java/com/nalssilog/app/api/GlobalExceptionHandler.java @@ -1,8 +1,14 @@ -package com.nalssilog.common.exception; +package com.nalssilog.app.api; +import com.nalssilog.auth.config.AuthCookieManager; +import com.nalssilog.auth.domain.RefreshRejectedException; +import com.nalssilog.common.exception.ErrorCode; +import com.nalssilog.common.exception.ErrorResponse; +import com.nalssilog.common.exception.NalssiLogException; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -12,23 +18,32 @@ @Slf4j @RestControllerAdvice +@RequiredArgsConstructor public class GlobalExceptionHandler { + private final AuthCookieManager authCookieManager; + @ExceptionHandler(NalssiLogException.class) - public ResponseEntity handleNalssiLogException(NalssiLogException e) { - ErrorCode errorCode = e.getErrorCode(); + public ErrorResponse handleNalssiLogException( + NalssiLogException exception, + HttpServletResponse response + ) { + if (exception instanceof RefreshRejectedException) { + authCookieManager.clearAuthCookies(response); + } + ErrorCode errorCode = exception.getErrorCode(); + response.setStatus(errorCode.getStatus().value()); log.warn("NalssiLogException [{}] {} (status={})", - errorCode.getCode(), e.getMessage(), errorCode.getStatus().value()); + errorCode.getCode(), exception.getMessage(), errorCode.getStatus().value()); - return ResponseEntity.status(errorCode.getStatus()) - .body(new ErrorResponse(errorCode.getCode(), e.getMessage())); + return new ErrorResponse(errorCode.getCode(), exception.getMessage()); } @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(MethodArgumentNotValidException.class) - public ErrorResponse handleValidationException(MethodArgumentNotValidException e) { - String message = e.getBindingResult().getFieldErrors().stream() + public ErrorResponse handleValidationException(MethodArgumentNotValidException exception) { + String message = exception.getBindingResult().getFieldErrors().stream() .findFirst() .map(error -> error.getField() + ": " + error.getDefaultMessage()) .orElse("잘못된 요청입니다."); @@ -38,20 +53,20 @@ public ErrorResponse handleValidationException(MethodArgumentNotValidException e @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(HttpMessageNotReadableException.class) - public ErrorResponse handleNotReadable(HttpMessageNotReadableException e) { + public ErrorResponse handleNotReadable(HttpMessageNotReadableException exception) { return new ErrorResponse("INVALID_REQUEST", "요청 본문을 해석할 수 없습니다."); } @ResponseStatus(HttpStatus.NOT_FOUND) @ExceptionHandler(NoResourceFoundException.class) - public ErrorResponse handleNoResourceFoundException(NoResourceFoundException e) { + public ErrorResponse handleNoResourceFoundException(NoResourceFoundException exception) { return new ErrorResponse("NOT_FOUND", "요청한 리소스를 찾을 수 없습니다."); } @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ExceptionHandler(Exception.class) - public ErrorResponse handleUnexpectedException(Exception e) { - log.error("Unexpected exception", e); + public ErrorResponse handleUnexpectedException(Exception exception) { + log.error("Unexpected exception", exception); return new ErrorResponse("INTERNAL_ERROR", "서버 오류가 발생했습니다."); } diff --git a/app/src/test/java/com/nalssilog/app/api/GlobalExceptionHandlerTest.java b/app/src/test/java/com/nalssilog/app/api/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..d24656d --- /dev/null +++ b/app/src/test/java/com/nalssilog/app/api/GlobalExceptionHandlerTest.java @@ -0,0 +1,68 @@ +package com.nalssilog.app.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.nalssilog.auth.config.AuthCookieManager; +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.domain.AuthErrorCode; +import com.nalssilog.auth.domain.RefreshRejectedException; +import com.nalssilog.common.exception.ErrorResponse; +import com.nalssilog.common.exception.NalssiLogException; +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletResponse; + +@SuppressWarnings("java:S5960") +class GlobalExceptionHandlerTest { + + private final AuthCookieManager cookieManager = new AuthCookieManager(properties()); + private final GlobalExceptionHandler exceptionHandler = new GlobalExceptionHandler(cookieManager); + + @Test + void refreshRejectionDeletesBothAuthenticationCookies() { + RefreshRejectedException exception = new RefreshRejectedException( + new NalssiLogException(AuthErrorCode.AUTH_SESSION_EXPIRED)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + ErrorResponse errorResponse = + exceptionHandler.handleNalssiLogException(exception, response); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); + assertThat(errorResponse).isEqualTo(new ErrorResponse( + AuthErrorCode.AUTH_SESSION_EXPIRED.getCode(), + AuthErrorCode.AUTH_SESSION_EXPIRED.getMessage())); + assertThat(response.getHeaders(HttpHeaders.SET_COOKIE)) + .anySatisfy(header -> assertThat(header) + .contains("access_token=") + .contains("Path=/") + .contains("Max-Age=0") + .contains("Expires=Thu, 1 Jan 1970 00:00:00 GMT") + .contains("Secure") + .contains("HttpOnly") + .contains("SameSite=Lax") + .doesNotContain("Domain=")) + .anySatisfy(header -> assertThat(header) + .contains("refresh_token=") + .contains("Path=/") + .contains("Max-Age=0") + .contains("Expires=Thu, 1 Jan 1970 00:00:00 GMT") + .contains("Secure") + .contains("HttpOnly") + .contains("SameSite=Lax") + .doesNotContain("Domain=")); + } + + private AuthProperties properties() { + return new AuthProperties( + new AuthProperties.Jwt( + "test-secret-must-be-at-least-thirty-two-bytes", + Duration.ofMinutes(30), + Duration.ofDays(14)), + new AuthProperties.Cookie(true), + new AuthProperties.Ticket(Duration.ofMinutes(10)), + new AuthProperties.Csrf("XSRF-TOKEN", null), + new AuthProperties.Refresh(Duration.ofSeconds(5))); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/api/AuthController.java b/auth/src/main/java/com/nalssilog/auth/api/AuthController.java index d77e714..d4e00f0 100644 --- a/auth/src/main/java/com/nalssilog/auth/api/AuthController.java +++ b/auth/src/main/java/com/nalssilog/auth/api/AuthController.java @@ -1,33 +1,8 @@ package com.nalssilog.auth.api; -import com.nalssilog.auth.api.dto.LinkConsentResponse; -import com.nalssilog.auth.api.dto.MeResponse; -import com.nalssilog.auth.api.dto.SessionResponse; -import com.nalssilog.auth.api.dto.SignupRequest; -import com.nalssilog.auth.application.AuthSessionService; -import com.nalssilog.auth.application.AuthTokenService; -import com.nalssilog.auth.application.TokenPair; -import com.nalssilog.auth.client.MemberClient; -import com.nalssilog.auth.client.OAuthUserInfo; -import com.nalssilog.auth.config.AuthCookieManager; -import com.nalssilog.auth.config.AuthProperties; -import com.nalssilog.auth.config.DeviceInfoResolver; -import com.nalssilog.auth.domain.AuthErrorCode; -import com.nalssilog.auth.domain.LinkTicket; -import com.nalssilog.auth.domain.SignupTicket; -import com.nalssilog.auth.repository.AuthTicketStore; -import com.nalssilog.common.exception.NalssiLogException; -import com.nalssilog.member.application.dto.MemberInfo; -import com.nalssilog.member.domain.Provider; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.validation.Valid; import java.io.IOException; import java.util.List; -import java.util.Locale; -import java.util.Optional; -import java.util.UUID; -import lombok.RequiredArgsConstructor; + import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -39,200 +14,169 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import com.nalssilog.auth.api.dto.LinkConsentResponse; +import com.nalssilog.auth.api.dto.MeResponse; +import com.nalssilog.auth.api.dto.SessionResponse; +import com.nalssilog.auth.api.dto.SignupRequest; +import com.nalssilog.auth.application.AuthService; +import com.nalssilog.auth.application.AuthService.SignupResult; +import com.nalssilog.auth.application.AuthService.SocialLinkStart; +import com.nalssilog.auth.application.TokenPair; +import com.nalssilog.auth.config.AuthCookieManager; +import com.nalssilog.auth.config.DeviceInfoResolver; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor public class AuthController { - private final MemberClient memberClient; - private final AuthTokenService authTokenService; - private final AuthSessionService authSessionService; - private final AuthCookieManager cookieManager; - private final AuthTicketStore ticketStore; - private final AuthProperties properties; - private final DeviceInfoResolver deviceInfoResolver; - - /** 소셜 로그인 진입. 내부 Spring OAuth 경로를 은닉하고 302 시킨다. */ - @GetMapping("/login/{provider}") - public void login(@PathVariable String provider, HttpServletResponse response) throws IOException { - redirectToOAuth(response, provider); - } - - /** 인증 상태 조회(stateless — AT/티켓 쿠키로 판단). */ - @GetMapping("/me") - public MeResponse me(@AuthenticationPrincipal Long memberId, HttpServletRequest request) { - if (memberId != null) { - return MeResponse.authenticated(memberClient.getMemberInfo(memberId)); - } - - Optional signup = cookieManager.readSignupTicket(request).flatMap(ticketStore::findSignup); - if (signup.isPresent()) { - return MeResponse.signupRequired(signup.get().provider(), signup.get().email()); - } - - Optional link = cookieManager.readLinkTicket(request).flatMap(ticketStore::findLink); - if (link.isPresent()) { - return MeResponse.linkRequired(link.get().provider(), link.get().email(), link.get().existingProviders()); - } - - // AT 쿠키는 JWT와 같은 시점에 브라우저에서 사라질 수 있다. RT가 남아 있으면 로그아웃(NONE)이 아니라 - // refresh가 필요한 상태이므로 401로 명확히 알린다. NONE은 인증 쿠키가 정말 하나도 없을 때만 반환한다. - if (cookieManager.readAccessToken(request).isPresent() - || cookieManager.readRefreshToken(request).isPresent()) { - throw new NalssiLogException(AuthErrorCode.AUTH_ACCESS_TOKEN_EXPIRED); - } - - return MeResponse.none(); - } - - /** 회원가입 확정. OAuth 정보는 signup 티켓에서 읽고, Member 는 여기서 처음 생성된다. */ - @PostMapping("/signup") - public MeResponse signup(@Valid @RequestBody SignupRequest request, - HttpServletRequest httpRequest, HttpServletResponse response) { - String ticketId = cookieManager.readSignupTicket(httpRequest) - .orElseThrow(() -> new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND)); - SignupTicket ticket = ticketStore.findSignup(ticketId) - .orElseThrow(() -> new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND)); - - MemberInfo member = memberClient.registerMember( - new OAuthUserInfo(ticket.provider(), ticket.providerUserId(), ticket.email(), ticket.socialName()), - request.agreedTerms()); - TokenPair tokens = authTokenService.issue( - member.id(), member.status(), ticket.provider(), deviceInfoResolver.resolve(httpRequest)); - cookieManager.addAuthCookies( - response, tokens.accessToken(), tokens.refreshToken(), tokens.refreshTokenMaxAge()); - ticketStore.deleteSignup(ticketId); - cookieManager.clearSignupTicketCookie(response); - - return MeResponse.authenticated(member); - } - - @PostMapping("/refresh") - public void refresh(HttpServletRequest request, HttpServletResponse response) { - try { - String refreshToken = cookieManager.readRefreshToken(request) - .orElseThrow(() -> new NalssiLogException(AuthErrorCode.AUTH_SESSION_EXPIRED)); - TokenPair tokens = authTokenService.refresh(refreshToken, deviceInfoResolver.resolve(request)); - - cookieManager.addAuthCookies( - response, tokens.accessToken(), tokens.refreshToken(), tokens.refreshTokenMaxAge()); - } catch (NalssiLogException exception) { - if (isTerminalRefreshError(exception)) { - cookieManager.clearAuthCookies(response); - } - throw exception; - } - } - - @PostMapping("/logout") - public void logout(HttpServletRequest request, HttpServletResponse response) { - cookieManager.readRefreshToken(request).ifPresent(authTokenService::revoke); - cookieManager.clearAuthCookies(response); - } - - /** 회원 탈퇴: 익명화(member) + 제보 익명화(event) + 전 기기 세션 만료 + 쿠키 정리. 세션·쿠키 때문에 auth 소유. */ - @DeleteMapping("/withdraw") - @ResponseStatus(HttpStatus.NO_CONTENT) - public void withdraw(@AuthenticationPrincipal Long memberId, HttpServletResponse response) { - memberClient.withdraw(memberId); - authTokenService.revokeAllSessions(memberId); - cookieManager.clearAuthCookies(response); - } - - /** 로그인된 기기 목록(current=이 기기). */ - @GetMapping("/sessions") - public List sessions(@AuthenticationPrincipal Long memberId, HttpServletRequest request) { - String currentHash = cookieManager.readRefreshToken(request) - .map(authTokenService::tokenHash) - .orElse(null); - - return authSessionService.listSessions(memberId, currentHash).stream() - .map(SessionResponse::from) - .toList(); - } - - /** 특정 기기 로그아웃. 대상이 현재 기기면 인증 쿠키까지 정리한다. */ - @DeleteMapping("/sessions/{sessionId}") - @ResponseStatus(HttpStatus.NO_CONTENT) - public void revokeSession(@AuthenticationPrincipal Long memberId, @PathVariable String sessionId, - HttpServletRequest request, HttpServletResponse response) { - String currentHash = cookieManager.readRefreshToken(request) - .map(authTokenService::tokenHash) - .orElse(null); - boolean revokedCurrent = authSessionService.revokeSession(memberId, sessionId, currentHash); - - if (revokedCurrent) { - cookieManager.clearAuthCookies(response); - } - } - - /** 설정에서 소셜 추가 연동 시작. intent 세팅 후 OAuth 진입 URL 반환(실제 연동은 성공 핸들러). */ - @PostMapping("/link/social/{provider}") - public LinkConsentResponse startSocialLink(@AuthenticationPrincipal Long memberId, @PathVariable String provider, - HttpServletResponse response) { - validateProvider(provider); - Provider target = Provider.from(provider); - - MemberInfo member = memberClient.getMemberInfo(memberId); - if (member.connectedProviders().contains(target)) { - throw new NalssiLogException(AuthErrorCode.ALREADY_LINKED_PROVIDER); - } - - String intentId = UUID.randomUUID().toString(); - ticketStore.saveLinkIntent(intentId, memberId, properties.ticket().ttl()); - cookieManager.addLinkIntentCookie(response, intentId); - - return new LinkConsentResponse("/api/auth/login/" + provider.toLowerCase(Locale.ROOT)); - } - - /** 로그인-시점 연동 동의 + 기존 수단 재인증 URL 반환. */ - @PostMapping("/link/consent") - public LinkConsentResponse consentLink(HttpServletRequest request) { - String ticketId = cookieManager.readLinkTicket(request) - .orElseThrow(() -> new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND)); - LinkTicket ticket = ticketStore.findLink(ticketId) - .orElseThrow(() -> new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND)); - - ticketStore.markLinkConsented(ticketId, properties.ticket().ttl()); - - MemberInfo target = memberClient.getMemberInfo(ticket.targetMemberId()); - Provider reauthProvider = target.lastLoginProvider() != null - ? target.lastLoginProvider() - : target.connectedProviders().get(0); - - return new LinkConsentResponse("/api/auth/link/reauth/" + reauthProvider.name().toLowerCase(Locale.ROOT)); - } - - @GetMapping("/link/reauth/{provider}") - public void linkReauth(@PathVariable String provider, HttpServletResponse response) throws IOException { - redirectToOAuth(response, provider); - } - - @PostMapping("/link/cancel") - public void cancelLink(HttpServletRequest request, HttpServletResponse response) { - cookieManager.readLinkTicket(request).ifPresent(ticketId -> { - ticketStore.deleteLink(ticketId); - ticketStore.deleteLinkConsent(ticketId); - }); - cookieManager.clearLinkTicketCookie(response); - } - - private void redirectToOAuth(HttpServletResponse response, String provider) throws IOException { - validateProvider(provider); - - response.sendRedirect("/oauth2/authorization/" + provider.toLowerCase(Locale.ROOT)); - } - - private void validateProvider(String provider) { - try { - Provider.from(provider); - } catch (IllegalArgumentException _) { - throw new NalssiLogException(AuthErrorCode.UNSUPPORTED_PROVIDER); - } - } - - private boolean isTerminalRefreshError(NalssiLogException exception) { - return exception.getErrorCode() == AuthErrorCode.AUTH_SESSION_EXPIRED - || exception.getErrorCode() == AuthErrorCode.AUTH_REFRESH_REUSED; - } + private final AuthService authService; + private final AuthCookieManager cookieManager; + private final DeviceInfoResolver deviceInfoResolver; + + /** 소셜 로그인 진입. 내부 Spring OAuth 경로를 은닉하고 302 시킨다. */ + @GetMapping("/login/{provider}") + public void login( + @PathVariable String provider, + HttpServletResponse response + ) throws IOException { + response.sendRedirect(authService.oauthAuthorizationUrl(provider)); + } + + /** 인증 상태 조회(stateless — AT/티켓 쿠키로 판단). */ + @GetMapping("/me") + public MeResponse me( + @AuthenticationPrincipal Long memberId, + HttpServletRequest request + ) { + boolean hasAuthenticationCookie = cookieManager.readAccessToken(request).isPresent() + || cookieManager.readRefreshToken(request).isPresent(); + + return MeResponse.from(authService.me( + memberId, + cookieManager.readSignupTicket(request), + cookieManager.readLinkTicket(request), + hasAuthenticationCookie)); + } + + /** 회원가입 확정. OAuth 정보는 signup 티켓에서 읽고, Member 는 여기서 처음 생성된다. */ + @PostMapping("/signup") + public MeResponse signup( + @Valid @RequestBody SignupRequest request, + HttpServletRequest httpRequest, + HttpServletResponse response + ) { + SignupResult result = authService.signup(cookieManager.readSignupTicket(httpRequest).orElse(null), + request.agreedTerms(), deviceInfoResolver.resolve(httpRequest)); + TokenPair tokens = result.tokens(); + + cookieManager.addAuthCookies(response, tokens.accessToken(), tokens.refreshToken(), + tokens.refreshTokenMaxAge()); + cookieManager.clearSignupTicketCookie(response); + + return MeResponse.authenticated(result.member()); + } + + @PostMapping("/refresh") + public void refresh( + HttpServletRequest request, + HttpServletResponse response + ) { + TokenPair tokens = authService.refresh( + cookieManager.readRefreshToken(request).orElse(null), + deviceInfoResolver.resolve(request)); + + cookieManager.addAuthCookies( + response, tokens.accessToken(), tokens.refreshToken(), tokens.refreshTokenMaxAge()); + } + + @PostMapping("/logout") + public void logout( + HttpServletRequest request, + HttpServletResponse response + ) { + authService.logout(cookieManager.readRefreshToken(request)); + cookieManager.clearAuthCookies(response); + } + + /** 회원 탈퇴: 익명화(member) + 제보 익명화(event) + 전 기기 세션 만료 + 쿠키 정리. 세션·쿠키 때문에 auth 소유. */ + @DeleteMapping("/withdraw") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void withdraw( + @AuthenticationPrincipal Long memberId, + HttpServletResponse response + ) { + authService.withdraw(memberId); + cookieManager.clearAuthCookies(response); + } + + /** 로그인된 기기 목록(current=이 기기). */ + @GetMapping("/sessions") + public List sessions( + @AuthenticationPrincipal Long memberId, + HttpServletRequest request + ) { + return authService.sessions(memberId, cookieManager.readRefreshToken(request)).stream() + .map(SessionResponse::from) + .toList(); + } + + /** 특정 기기 로그아웃. 대상이 현재 기기면 인증 쿠키까지 정리한다. */ + @DeleteMapping("/sessions/{sessionId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void revokeSession( + @AuthenticationPrincipal Long memberId, + @PathVariable String sessionId, + HttpServletRequest request, + HttpServletResponse response + ) { + boolean revokedCurrent = authService.revokeSession( + memberId, sessionId, cookieManager.readRefreshToken(request)); + + if (revokedCurrent) { + cookieManager.clearAuthCookies(response); + } + } + + /** 설정에서 소셜 추가 연동 시작. intent 세팅 후 OAuth 진입 URL 반환(실제 연동은 성공 핸들러). */ + @PostMapping("/link/social/{provider}") + public LinkConsentResponse startSocialLink( + @AuthenticationPrincipal Long memberId, + @PathVariable String provider, + HttpServletResponse response + ) { + SocialLinkStart link = authService.startSocialLink(memberId, provider); + cookieManager.addLinkIntentCookie(response, link.intentId()); + + return new LinkConsentResponse(link.authorizationUrl()); + } + + /** 로그인-시점 연동 동의 + 기존 수단 재인증 URL 반환. */ + @PostMapping("/link/consent") + public LinkConsentResponse consentLink(HttpServletRequest request) { + return new LinkConsentResponse(authService.consentLink( + cookieManager.readLinkTicket(request).orElse(null))); + } + + @GetMapping("/link/reauth/{provider}") + public void linkReauth( + @PathVariable String provider, + HttpServletResponse response + ) throws IOException { + response.sendRedirect(authService.oauthAuthorizationUrl(provider)); + } + + @PostMapping("/link/cancel") + public void cancelLink( + HttpServletRequest request, + HttpServletResponse response + ) { + authService.cancelLink(cookieManager.readLinkTicket(request)); + cookieManager.clearLinkTicketCookie(response); + } } diff --git a/auth/src/main/java/com/nalssilog/auth/api/dto/MeResponse.java b/auth/src/main/java/com/nalssilog/auth/api/dto/MeResponse.java index ab67ad6..a632b52 100644 --- a/auth/src/main/java/com/nalssilog/auth/api/dto/MeResponse.java +++ b/auth/src/main/java/com/nalssilog/auth/api/dto/MeResponse.java @@ -1,5 +1,6 @@ package com.nalssilog.auth.api.dto; +import com.nalssilog.auth.application.AuthService.MeState; import com.nalssilog.member.application.dto.MemberInfo; import com.nalssilog.member.domain.AvatarType; import com.nalssilog.member.domain.Provider; @@ -48,4 +49,14 @@ public static MeResponse linkRequired(Provider provider, String email, List authenticated(state.member()); + case SIGNUP_REQUIRED -> signupRequired(state.provider(), state.email()); + case LINK_REQUIRED -> linkRequired( + state.provider(), state.email(), state.existingProviders()); + case NONE -> none(); + }; + } } diff --git a/auth/src/main/java/com/nalssilog/auth/application/AuthService.java b/auth/src/main/java/com/nalssilog/auth/application/AuthService.java new file mode 100644 index 0000000..6ed0ea3 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/application/AuthService.java @@ -0,0 +1,238 @@ +package com.nalssilog.auth.application; + +import com.nalssilog.auth.application.dto.DeviceInfo; +import com.nalssilog.auth.application.dto.SessionView; +import com.nalssilog.auth.client.MemberClient; +import com.nalssilog.auth.client.OAuthUserInfo; +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.domain.AuthErrorCode; +import com.nalssilog.auth.domain.LinkTicket; +import com.nalssilog.auth.domain.RefreshRejectedException; +import com.nalssilog.auth.domain.SignupTicket; +import com.nalssilog.auth.repository.AuthTicketStore; +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.member.application.dto.MemberInfo; +import com.nalssilog.member.application.dto.TermsAgreement; +import com.nalssilog.member.domain.Provider; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * 인증 API 유스케이스 조정자. + * 컨트롤러는 쿠키와 리다이렉트 같은 HTTP 입출력만 처리하고, 인증 상태·티켓·세션 흐름은 여기서 결정한다. + */ +@Service +@RequiredArgsConstructor +public class AuthService { + + private final MemberClient memberClient; + private final AuthTokenService authTokenService; + private final AuthSessionService authSessionService; + private final AuthTicketStore ticketStore; + private final AuthProperties properties; + + public MeState me( + Long memberId, + Optional signupTicketId, + Optional linkTicketId, + boolean hasAuthenticationCookie + ) { + if (memberId != null) { + return MeState.authenticated(memberClient.getMemberInfo(memberId)); + } + + Optional signup = signupTicketId.flatMap(ticketStore::findSignup); + if (signup.isPresent()) { + SignupTicket ticket = signup.get(); + return MeState.signupRequired(ticket.provider(), ticket.email()); + } + + Optional link = linkTicketId.flatMap(ticketStore::findLink); + if (link.isPresent()) { + LinkTicket ticket = link.get(); + return MeState.linkRequired(ticket.provider(), ticket.email(), ticket.existingProviders()); + } + + if (hasAuthenticationCookie) { + throw new NalssiLogException(AuthErrorCode.AUTH_ACCESS_TOKEN_EXPIRED); + } + + return MeState.none(); + } + + public SignupResult signup(String ticketId, List agreedTerms, DeviceInfo device) { + SignupTicket ticket = findSignupTicket(ticketId); + MemberInfo member = memberClient.registerMember( + new OAuthUserInfo(ticket.provider(), ticket.providerUserId(), ticket.email(), ticket.socialName()), + agreedTerms); + TokenPair tokens = authTokenService.issue( + member.id(), member.status(), ticket.provider(), device); + + ticketStore.deleteSignup(ticketId); + + return new SignupResult(member, tokens); + } + + public TokenPair refresh(String refreshToken, DeviceInfo device) { + try { + if (refreshToken == null || refreshToken.isBlank()) { + throw new NalssiLogException(AuthErrorCode.AUTH_SESSION_EXPIRED); + } + + return authTokenService.refresh(refreshToken, device); + } catch (NalssiLogException exception) { + if (isTerminalRefreshError(exception)) { + throw new RefreshRejectedException(exception); + } + + throw exception; + } + } + + public void logout(Optional refreshToken) { + refreshToken.ifPresent(authTokenService::revoke); + } + + public void withdraw(Long memberId) { + memberClient.withdraw(memberId); + authTokenService.revokeAllSessions(memberId); + } + + public List sessions(Long memberId, Optional refreshToken) { + return authSessionService.listSessions(memberId, currentTokenHash(refreshToken)); + } + + public boolean revokeSession( + Long memberId, + String sessionId, + Optional refreshToken + ) { + return authSessionService.revokeSession(memberId, sessionId, currentTokenHash(refreshToken)); + } + + public SocialLinkStart startSocialLink(Long memberId, String provider) { + Provider target = provider(provider); + MemberInfo member = memberClient.getMemberInfo(memberId); + + if (member.connectedProviders().contains(target)) { + throw new NalssiLogException(AuthErrorCode.ALREADY_LINKED_PROVIDER); + } + + String intentId = UUID.randomUUID().toString(); + ticketStore.saveLinkIntent(intentId, memberId, properties.ticket().ttl()); + + return new SocialLinkStart(intentId, loginUrl(target)); + } + + public String consentLink(String ticketId) { + LinkTicket ticket = findLinkTicket(ticketId); + ticketStore.markLinkConsented(ticketId, properties.ticket().ttl()); + + MemberInfo target = memberClient.getMemberInfo(ticket.targetMemberId()); + Provider reauthenticationProvider = target.lastLoginProvider() != null + ? target.lastLoginProvider() + : target.connectedProviders().getFirst(); + + return reauthenticationUrl(reauthenticationProvider); + } + + public void cancelLink(Optional ticketId) { + ticketId.ifPresent(id -> { + ticketStore.deleteLink(id); + ticketStore.deleteLinkConsent(id); + }); + } + + public String oauthAuthorizationUrl(String provider) { + return "/oauth2/authorization/" + provider(provider).name().toLowerCase(Locale.ROOT); + } + + private SignupTicket findSignupTicket(String ticketId) { + if (ticketId == null || ticketId.isBlank()) { + throw new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND); + } + + return ticketStore.findSignup(ticketId) + .orElseThrow(() -> new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND)); + } + + private LinkTicket findLinkTicket(String ticketId) { + if (ticketId == null || ticketId.isBlank()) { + throw new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND); + } + + return ticketStore.findLink(ticketId) + .orElseThrow(() -> new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND)); + } + + private String currentTokenHash(Optional refreshToken) { + return refreshToken.map(authTokenService::tokenHash).orElse(null); + } + + private boolean isTerminalRefreshError(NalssiLogException exception) { + return exception.getErrorCode() == AuthErrorCode.AUTH_SESSION_EXPIRED + || exception.getErrorCode() == AuthErrorCode.AUTH_REFRESH_REUSED; + } + + private Provider provider(String provider) { + try { + return Provider.from(provider); + } catch (IllegalArgumentException _) { + throw new NalssiLogException(AuthErrorCode.UNSUPPORTED_PROVIDER); + } + } + + private String loginUrl(Provider provider) { + return "/api/auth/login/" + provider.name().toLowerCase(Locale.ROOT); + } + + private String reauthenticationUrl(Provider provider) { + return "/api/auth/link/reauth/" + provider.name().toLowerCase(Locale.ROOT); + } + + public enum MeStatus { + AUTHENTICATED, + SIGNUP_REQUIRED, + LINK_REQUIRED, + NONE + } + + public record MeState( + MeStatus status, + MemberInfo member, + Provider provider, + String email, + List existingProviders + ) { + + public static MeState authenticated(MemberInfo member) { + return new MeState(MeStatus.AUTHENTICATED, member, null, null, List.of()); + } + + public static MeState signupRequired(Provider provider, String email) { + return new MeState(MeStatus.SIGNUP_REQUIRED, null, provider, email, List.of()); + } + + public static MeState linkRequired( + Provider provider, + String email, + List existingProviders + ) { + return new MeState(MeStatus.LINK_REQUIRED, null, provider, email, List.copyOf(existingProviders)); + } + + public static MeState none() { + return new MeState(MeStatus.NONE, null, null, null, List.of()); + } + } + + public record SignupResult(MemberInfo member, TokenPair tokens) { + } + + public record SocialLinkStart(String intentId, String authorizationUrl) { + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/config/JwtAuthenticationFilter.java b/auth/src/main/java/com/nalssilog/auth/config/JwtAuthenticationFilter.java index 8f4ccf5..7910d91 100644 --- a/auth/src/main/java/com/nalssilog/auth/config/JwtAuthenticationFilter.java +++ b/auth/src/main/java/com/nalssilog/auth/config/JwtAuthenticationFilter.java @@ -1,45 +1,48 @@ package com.nalssilog.auth.config; +import java.io.IOException; +import java.util.List; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + import com.nalssilog.auth.application.JwtTokenProvider; import com.nalssilog.auth.application.SocialAuthPrincipal; -import com.nalssilog.common.web.RequestLoggingFilter; +import com.nalssilog.common.filter.RequestLoggingFilter; import com.nalssilog.member.domain.MemberStatus; + import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.List; import lombok.RequiredArgsConstructor; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; @Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { - private final AuthCookieManager cookieManager; - private final JwtTokenProvider jwtTokenProvider; + private final AuthCookieManager cookieManager; + private final JwtTokenProvider jwtTokenProvider; - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - cookieManager.readAccessToken(request) - .flatMap(jwtTokenProvider::parse) - .filter(payload -> payload.status() != MemberStatus.WITHDRAWN) - .ifPresent(payload -> { - var authentication = UsernamePasswordAuthenticationToken.authenticated( - payload.memberId(), - null, - List.of(new SimpleGrantedAuthority(SocialAuthPrincipal.roleOf(payload.status())))); - authentication.setDetails(payload.provider()); - SecurityContextHolder.getContext().setAuthentication(authentication); - // STATELESS 라 체인 종료 후 SecurityContext 가 비므로, 액세스 로그용으로 memberId 를 남겨둔다. - request.setAttribute(RequestLoggingFilter.ACTOR_MEMBER_ID, payload.memberId()); - }); - filterChain.doFilter(request, response); - } + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + cookieManager.readAccessToken(request) + .flatMap(jwtTokenProvider::parse) + .filter(payload -> payload.status() != MemberStatus.WITHDRAWN) + .ifPresent(payload -> { + var authentication = UsernamePasswordAuthenticationToken.authenticated( + payload.memberId(), + null, + List.of(new SimpleGrantedAuthority(SocialAuthPrincipal.roleOf(payload.status())))); + authentication.setDetails(payload.provider()); + SecurityContextHolder.getContext().setAuthentication(authentication); + // STATELESS 라 체인 종료 후 SecurityContext 가 비므로, 액세스 로그용으로 memberId 를 남겨둔다. + request.setAttribute(RequestLoggingFilter.ACTOR_MEMBER_ID, payload.memberId()); + }); + filterChain.doFilter(request, response); + } } diff --git a/auth/src/main/java/com/nalssilog/auth/domain/RefreshRejectedException.java b/auth/src/main/java/com/nalssilog/auth/domain/RefreshRejectedException.java new file mode 100644 index 0000000..c31ef7a --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/domain/RefreshRejectedException.java @@ -0,0 +1,14 @@ +package com.nalssilog.auth.domain; + +import com.nalssilog.common.exception.NalssiLogException; + +/** + * refresh 실패로 브라우저의 인증 쿠키까지 폐기해야 하는 경우를 HTTP 계층에 전달한다. + */ +public final class RefreshRejectedException extends NalssiLogException { + + public RefreshRejectedException(NalssiLogException cause) { + super(cause.getErrorCode(), cause.getMessage()); + initCause(cause); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/api/AuthControllerTest.java b/auth/src/test/java/com/nalssilog/auth/api/AuthControllerTest.java index d9dc2d1..58f3416 100644 --- a/auth/src/test/java/com/nalssilog/auth/api/AuthControllerTest.java +++ b/auth/src/test/java/com/nalssilog/auth/api/AuthControllerTest.java @@ -8,6 +8,7 @@ import com.nalssilog.auth.api.dto.AuthResult; import com.nalssilog.auth.api.dto.MeResponse; +import com.nalssilog.auth.application.AuthService; import com.nalssilog.auth.application.AuthSessionService; import com.nalssilog.auth.application.AuthTokenService; import com.nalssilog.auth.application.TokenPair; @@ -16,6 +17,7 @@ import com.nalssilog.auth.config.AuthProperties; import com.nalssilog.auth.config.DeviceInfoResolver; import com.nalssilog.auth.domain.AuthErrorCode; +import com.nalssilog.auth.domain.RefreshRejectedException; import com.nalssilog.auth.repository.AuthTicketStore; import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.member.application.dto.MemberInfo; @@ -46,13 +48,15 @@ class AuthControllerTest { @BeforeEach void setUp() { - controller = new AuthController( + AuthService authService = new AuthService( memberClient, tokenService, sessionService, - cookieManager, ticketStore, - properties, + properties); + controller = new AuthController( + authService, + cookieManager, deviceInfoResolver); } @@ -104,7 +108,7 @@ void meRequestsRefreshWhenOnlyRefreshCookieRemains() { } @Test - void terminalRefreshErrorDeletesBothAuthenticationCookies() { + void terminalRefreshErrorIsClassifiedByAuthService() { MockHttpServletRequest request = new MockHttpServletRequest(); request.setCookies(new Cookie(AuthCookieManager.REFRESH_TOKEN_COOKIE, "expired-token")); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -112,30 +116,11 @@ void terminalRefreshErrorDeletesBothAuthenticationCookies() { when(tokenService.refresh("expired-token", null)) .thenThrow(new NalssiLogException(AuthErrorCode.AUTH_SESSION_EXPIRED)); - NalssiLogException exception = catchThrowableOfType( - NalssiLogException.class, + RefreshRejectedException exception = catchThrowableOfType( + RefreshRejectedException.class, () -> controller.refresh(request, response)); assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.AUTH_SESSION_EXPIRED); - assertThat(response.getHeaders(HttpHeaders.SET_COOKIE)) - .anySatisfy(header -> assertThat(header) - .contains("access_token=") - .contains("Path=/") - .contains("Max-Age=0") - .contains("Expires=Thu, 1 Jan 1970 00:00:00 GMT") - .contains("Secure") - .contains("HttpOnly") - .contains("SameSite=Lax") - .doesNotContain("Domain=")) - .anySatisfy(header -> assertThat(header) - .contains("refresh_token=") - .contains("Path=/") - .contains("Max-Age=0") - .contains("Expires=Thu, 1 Jan 1970 00:00:00 GMT") - .contains("Secure") - .contains("HttpOnly") - .contains("SameSite=Lax") - .doesNotContain("Domain=")); } @Test diff --git a/common/src/main/java/com/nalssilog/common/filter/RequestLoggingFilter.java b/common/src/main/java/com/nalssilog/common/filter/RequestLoggingFilter.java new file mode 100644 index 0000000..dcfd21c --- /dev/null +++ b/common/src/main/java/com/nalssilog/common/filter/RequestLoggingFilter.java @@ -0,0 +1,100 @@ +package com.nalssilog.common.filter; + +import java.io.IOException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * 요청마다 액세스 로그 한 줄(method·path·status·소요시간·actor). {@link TraceIdFilter} 다음 순서라 traceId 가 이미 MDC 에 있다. + * prod JSON 용으로 필드를 MDC 에 잠시 싣는다. 작성자: 인증 필터가 심은 request attribute({@link #ACTOR_MEMBER_ID}), 없으면 쿠키. + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 1) +public class RequestLoggingFilter extends OncePerRequestFilter { + + /** 인증 필터가 인증된 회원의 memberId 를 실어두는 request attribute 키. */ + public static final String ACTOR_MEMBER_ID = "actorMemberId"; + + /** report 모듈의 익명 식별 쿠키명과의 계약(모듈 역의존 회피용 상수 복제). */ + private static final String ANONYMOUS_COOKIE = "anonymous_id"; + + private static final Logger log = LoggerFactory.getLogger("http.access"); + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + long startNanos = System.nanoTime(); + + try { + filterChain.doFilter(request, response); + } finally { + long durationMs = (System.nanoTime() - startNanos) / 1_000_000; + String actor = resolveActor(request); + String query = request.getQueryString(); + + MDC.put("method", request.getMethod()); + MDC.put("path", request.getRequestURI()); + MDC.put("status", String.valueOf(response.getStatus())); + MDC.put("durationMs", String.valueOf(durationMs)); + MDC.put("actor", actor); + + try { + log.info("{} {}{} -> {} ({}ms) actor={}", + request.getMethod(), + request.getRequestURI(), + query == null ? "" : "?" + query, + response.getStatus(), + durationMs, + actor); + } finally { + MDC.remove("method"); + MDC.remove("path"); + MDC.remove("status"); + MDC.remove("durationMs"); + MDC.remove("actor"); + } + } + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String uri = request.getRequestURI(); + + return uri.equals("/api/health") || uri.startsWith("/error"); + } + + private String resolveActor(HttpServletRequest request) { + Object memberId = request.getAttribute(ACTOR_MEMBER_ID); + + if (memberId != null) { + return "member:" + memberId; + } + + Cookie[] cookies = request.getCookies(); + + if (cookies != null) { + for (Cookie cookie : cookies) { + if (ANONYMOUS_COOKIE.equals(cookie.getName())) { + String value = cookie.getValue(); + + return "anon:" + (value.length() > 8 ? value.substring(0, 8) : value); + } + } + } + + return "guest"; + } +} diff --git a/common/src/main/java/com/nalssilog/common/web/TraceIdFilter.java b/common/src/main/java/com/nalssilog/common/filter/TraceIdFilter.java similarity index 50% rename from common/src/main/java/com/nalssilog/common/web/TraceIdFilter.java rename to common/src/main/java/com/nalssilog/common/filter/TraceIdFilter.java index af7cc2c..373cb64 100644 --- a/common/src/main/java/com/nalssilog/common/web/TraceIdFilter.java +++ b/common/src/main/java/com/nalssilog/common/filter/TraceIdFilter.java @@ -1,11 +1,8 @@ -package com.nalssilog.common.web; +package com.nalssilog.common.filter; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.UUID; + import org.slf4j.MDC; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; @@ -13,6 +10,11 @@ import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + /** * 요청마다 correlation-id(traceId)를 MDC 에 심어 로그 전 구간에서 추적 가능하게 한다. * 상류(게이트웨이·다른 서비스)에서 전달한 X-Trace-Id 가 있으면 이어받고, 없으면 새로 발급한다. @@ -22,32 +24,32 @@ @Order(Ordered.HIGHEST_PRECEDENCE) public class TraceIdFilter extends OncePerRequestFilter { - public static final String TRACE_ID = "traceId"; - private static final String TRACE_ID_HEADER = "X-Trace-Id"; + public static final String TRACE_ID = "traceId"; + private static final String TRACE_ID_HEADER = "X-Trace-Id"; - @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - String traceId = resolveTraceId(request); + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String traceId = resolveTraceId(request); - MDC.put(TRACE_ID, traceId); - response.setHeader(TRACE_ID_HEADER, traceId); + MDC.put(TRACE_ID, traceId); + response.setHeader(TRACE_ID_HEADER, traceId); - try { - filterChain.doFilter(request, response); - } finally { - MDC.remove(TRACE_ID); - } - } + try { + filterChain.doFilter(request, response); + } finally { + MDC.remove(TRACE_ID); + } + } - private String resolveTraceId(HttpServletRequest request) { - String inbound = request.getHeader(TRACE_ID_HEADER); + private String resolveTraceId(HttpServletRequest request) { + String inbound = request.getHeader(TRACE_ID_HEADER); - if (StringUtils.hasText(inbound)) { - return inbound; - } + if (StringUtils.hasText(inbound)) { + return inbound; + } - return UUID.randomUUID().toString().substring(0, 8); - } + return UUID.randomUUID().toString().substring(0, 8); + } } diff --git a/common/src/main/java/com/nalssilog/common/web/RequestLoggingFilter.java b/common/src/main/java/com/nalssilog/common/web/RequestLoggingFilter.java deleted file mode 100644 index 0d8e098..0000000 --- a/common/src/main/java/com/nalssilog/common/web/RequestLoggingFilter.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.nalssilog.common.web; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.slf4j.MDC; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -/** - * 요청마다 액세스 로그 한 줄(method·path·status·소요시간·actor). {@link TraceIdFilter} 다음 순서라 traceId 가 이미 MDC 에 있다. - * prod JSON 용으로 필드를 MDC 에 잠시 싣는다. 작성자: 인증 필터가 심은 request attribute({@link #ACTOR_MEMBER_ID}), 없으면 쿠키. - */ -@Component -@Order(Ordered.HIGHEST_PRECEDENCE + 1) -public class RequestLoggingFilter extends OncePerRequestFilter { - - /** 인증 필터가 인증된 회원의 memberId 를 실어두는 request attribute 키. */ - public static final String ACTOR_MEMBER_ID = "actorMemberId"; - - /** report 모듈의 익명 식별 쿠키명과의 계약(모듈 역의존 회피용 상수 복제). */ - private static final String ANONYMOUS_COOKIE = "anonymous_id"; - - private static final Logger log = LoggerFactory.getLogger("http.access"); - - @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - long startNanos = System.nanoTime(); - - try { - filterChain.doFilter(request, response); - } finally { - long durationMs = (System.nanoTime() - startNanos) / 1_000_000; - String actor = resolveActor(request); - String query = request.getQueryString(); - - MDC.put("method", request.getMethod()); - MDC.put("path", request.getRequestURI()); - MDC.put("status", String.valueOf(response.getStatus())); - MDC.put("durationMs", String.valueOf(durationMs)); - MDC.put("actor", actor); - - try { - log.info("{} {}{} -> {} ({}ms) actor={}", - request.getMethod(), - request.getRequestURI(), - query == null ? "" : "?" + query, - response.getStatus(), - durationMs, - actor); - } finally { - MDC.remove("method"); - MDC.remove("path"); - MDC.remove("status"); - MDC.remove("durationMs"); - MDC.remove("actor"); - } - } - } - - @Override - protected boolean shouldNotFilter(HttpServletRequest request) { - String uri = request.getRequestURI(); - - return uri.equals("/api/health") || uri.startsWith("/error"); - } - - private String resolveActor(HttpServletRequest request) { - Object memberId = request.getAttribute(ACTOR_MEMBER_ID); - - if (memberId != null) { - return "member:" + memberId; - } - - Cookie[] cookies = request.getCookies(); - - if (cookies != null) { - for (Cookie cookie : cookies) { - if (ANONYMOUS_COOKIE.equals(cookie.getName())) { - String value = cookie.getValue(); - - return "anon:" + (value.length() > 8 ? value.substring(0, 8) : value); - } - } - } - - return "guest"; - } -} diff --git a/location/src/main/java/com/nalssilog/location/api/LocationController.java b/location/src/main/java/com/nalssilog/location/api/LocationController.java index 433c636..ebcb865 100644 --- a/location/src/main/java/com/nalssilog/location/api/LocationController.java +++ b/location/src/main/java/com/nalssilog/location/api/LocationController.java @@ -26,7 +26,10 @@ public List search(@RequestParam @NotBlank String keyword) { } @GetMapping("/reverse-geocode") - public LocationResponse reverseGeocode(@RequestParam double lat, @RequestParam double lng) { + public LocationResponse reverseGeocode( + @RequestParam double lat, + @RequestParam double lng + ) { return LocationResponse.from(locationService.reverseGeocode(lat, lng)); } diff --git a/location/src/main/java/com/nalssilog/location/api/LocationFavoriteController.java b/location/src/main/java/com/nalssilog/location/api/LocationFavoriteController.java index 6872127..62f0f71 100644 --- a/location/src/main/java/com/nalssilog/location/api/LocationFavoriteController.java +++ b/location/src/main/java/com/nalssilog/location/api/LocationFavoriteController.java @@ -33,13 +33,19 @@ public List myFavorites(@AuthenticationPrincipal Long memberId @PostMapping @ResponseStatus(HttpStatus.CREATED) - public void addFavorite(@AuthenticationPrincipal Long memberId, @Valid @RequestBody FavoriteRequest request) { + public void addFavorite( + @AuthenticationPrincipal Long memberId, + @Valid @RequestBody FavoriteRequest request + ) { locationFavoriteService.addFavorite(memberId, request.locationId()); } @DeleteMapping("/{locationId}") @ResponseStatus(HttpStatus.NO_CONTENT) - public void removeFavorite(@AuthenticationPrincipal Long memberId, @PathVariable Long locationId) { + public void removeFavorite( + @AuthenticationPrincipal Long memberId, + @PathVariable Long locationId + ) { locationFavoriteService.removeFavorite(memberId, locationId); } } diff --git a/location/src/main/java/com/nalssilog/location/application/LocationService.java b/location/src/main/java/com/nalssilog/location/application/LocationService.java index 8ee490e..091207f 100644 --- a/location/src/main/java/com/nalssilog/location/application/LocationService.java +++ b/location/src/main/java/com/nalssilog/location/application/LocationService.java @@ -25,6 +25,11 @@ public class LocationService { private static final int POPULAR_SIZE = 5; + private static final String FORMER_JEONBUK = "전라북도"; + private static final String CURRENT_JEONBUK = "전북특별자치도"; + private static final String FORMER_JEONNAM = "전라남도"; + private static final String FORMER_GWANGJU = "광주광역시"; + private static final String CURRENT_JEONNAM_GWANGJU = "전남광주통합특별시"; private final LocationRepository locationRepository; private final PopularLocationSource popularLocationSource; @@ -32,7 +37,7 @@ public class LocationService { private final KakaoMapClient kakaoMapClient; public List search(String keyword) { - return locationRepository.searchByKeyword(keyword.strip()); + return locationRepository.searchByKeyword(normalizeLegacyRegionName(keyword.strip())); } public LocationInfo getLocation(Long locationId) { @@ -85,4 +90,11 @@ private static void validateCoordinates(double latitude, double longitude) { throw new NalssiLogException(LocationErrorCode.INVALID_COORDINATES); } } + + private static String normalizeLegacyRegionName(String keyword) { + return keyword + .replace(FORMER_JEONBUK, CURRENT_JEONBUK) + .replace(FORMER_JEONNAM, CURRENT_JEONNAM_GWANGJU) + .replace(FORMER_GWANGJU, CURRENT_JEONNAM_GWANGJU); + } } diff --git a/location/src/test/java/com/nalssilog/location/application/LocationServiceTest.java b/location/src/test/java/com/nalssilog/location/application/LocationServiceTest.java index 104d9aa..47ac6a7 100644 --- a/location/src/test/java/com/nalssilog/location/application/LocationServiceTest.java +++ b/location/src/test/java/com/nalssilog/location/application/LocationServiceTest.java @@ -55,6 +55,20 @@ void rejectsInvalidCoordinatesBeforeCallingKakao() { verifyNoInteractions(kakaoMapClient); } + @Test + void searchesJeonbukWithItsCurrentOfficialNameWhenLegacyNameIsEntered() { + service.search("전라북도 전주시"); + + verify(locationRepository).searchByKeyword("전북특별자치도 전주시"); + } + + @Test + void searchesIntegratedJeonnamGwangjuWhenLegacyNameIsEntered() { + service.search("전라남도 순천시"); + + verify(locationRepository).searchByKeyword("전남광주통합특별시 순천시"); + } + @Test void fillsPopularLocationsToFiveWithNonDuplicateFeaturedLocations() { List featuredCodes = List.of("code-2", "code-3", "code-4", "code-5", "code-6"); diff --git a/member/src/main/java/com/nalssilog/member/api/FeedbackController.java b/member/src/main/java/com/nalssilog/member/api/FeedbackController.java index 034aebc..76fbc30 100644 --- a/member/src/main/java/com/nalssilog/member/api/FeedbackController.java +++ b/member/src/main/java/com/nalssilog/member/api/FeedbackController.java @@ -25,10 +25,11 @@ public class FeedbackController { @PostMapping @ResponseStatus(HttpStatus.CREATED) - public FeedbackResponse submit(@AuthenticationPrincipal Long memberId, - HttpServletRequest httpRequest, - @Valid @RequestBody CreateFeedbackRequest request) { - return FeedbackResponse.from( - feedbackService.submit(memberId, clientIpResolver.resolve(httpRequest), request.content())); + public FeedbackResponse submit( + @AuthenticationPrincipal Long memberId, + HttpServletRequest httpRequest, + @Valid @RequestBody CreateFeedbackRequest request + ) { + return FeedbackResponse.from(feedbackService.submit(memberId, clientIpResolver.resolve(httpRequest), request.content())); } } diff --git a/member/src/main/java/com/nalssilog/member/api/MemberController.java b/member/src/main/java/com/nalssilog/member/api/MemberController.java index 1992cfe..6f92430 100644 --- a/member/src/main/java/com/nalssilog/member/api/MemberController.java +++ b/member/src/main/java/com/nalssilog/member/api/MemberController.java @@ -16,8 +16,8 @@ import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; -import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.annotation.CurrentSecurityContext; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -42,8 +42,11 @@ public NicknameAvailabilityResponse checkNickname(@RequestParam @NotBlank String } @GetMapping("/me") - public MemberMeResponse me(@AuthenticationPrincipal Long memberId, Authentication authentication) { - return MemberMeResponse.from(memberProfileService.getMe(memberId), currentProvider(authentication)); + public MemberMeResponse me( + @AuthenticationPrincipal Long memberId, + @CurrentSecurityContext(expression = "authentication.details") Provider currentProvider + ) { + return MemberMeResponse.from(memberProfileService.getMe(memberId), currentProvider); } @GetMapping("/{id}") @@ -52,38 +55,38 @@ public MemberPublicProfileResponse publicProfile(@PathVariable Long id) { } @PatchMapping("/me/name") - public MemberMeResponse changeName(@AuthenticationPrincipal Long memberId, - Authentication authentication, - @Valid @RequestBody ChangeNameRequest request) { - return MemberMeResponse.from( - memberProfileService.changeName(memberId, request.name()), currentProvider(authentication)); + public MemberMeResponse changeName( + @AuthenticationPrincipal Long memberId, + @CurrentSecurityContext(expression = "authentication.details") Provider currentProvider, + @Valid @RequestBody ChangeNameRequest request + ) { + return MemberMeResponse.from(memberProfileService.changeName(memberId, request.name()), currentProvider); } @PatchMapping("/me/nickname") - public MemberMeResponse changeNickname(@AuthenticationPrincipal Long memberId, - Authentication authentication, - @Valid @RequestBody ChangeNicknameRequest request) { - return MemberMeResponse.from( - memberProfileService.changeNickname(memberId, request.nickname()), currentProvider(authentication)); + public MemberMeResponse changeNickname( + @AuthenticationPrincipal Long memberId, + @CurrentSecurityContext(expression = "authentication.details") Provider currentProvider, + @Valid @RequestBody ChangeNicknameRequest request + ) { + return MemberMeResponse.from(memberProfileService.changeNickname(memberId, request.nickname()), currentProvider); } @PatchMapping("/me/avatar") - public MemberMeResponse changeAvatar(@AuthenticationPrincipal Long memberId, - Authentication authentication, - @Valid @RequestBody ChangeAvatarRequest request) { - return MemberMeResponse.from( - memberProfileService.changeAvatar(memberId, request.type(), request.value()), - currentProvider(authentication)); + public MemberMeResponse changeAvatar( + @AuthenticationPrincipal Long memberId, + @CurrentSecurityContext(expression = "authentication.details") Provider currentProvider, + @Valid @RequestBody ChangeAvatarRequest request + ) { + return MemberMeResponse.from(memberProfileService.changeAvatar(memberId, request.type(), request.value()), currentProvider); } - /** - * 커스텀 아바타 업로드용 presigned URL 발급. 업로드 후 PATCH /me/avatar 로 {type:CUSTOM, value:storageKey} 전송. - */ @PostMapping("/me/avatar/presign") - public AvatarPresignResponse presignAvatar(@AuthenticationPrincipal Long memberId, - @Valid @RequestBody AvatarPresignRequest request) { - return AvatarPresignResponse.from( - memberProfileService.presignAvatar(memberId, request.contentType(), request.size())); + public AvatarPresignResponse presignAvatar( + @AuthenticationPrincipal Long memberId, + @Valid @RequestBody AvatarPresignRequest request + ) { + return AvatarPresignResponse.from(memberProfileService.presignAvatar(memberId, request.contentType(), request.size())); } @GetMapping("/me/social-accounts") @@ -95,17 +98,11 @@ public List socialAccounts(@AuthenticationPrincipal Long @DeleteMapping("/me/social-accounts/{provider}") @ResponseStatus(HttpStatus.NO_CONTENT) - public void unlinkSocial(@AuthenticationPrincipal Long memberId, Authentication authentication, - @PathVariable String provider) { - memberProfileService.unlinkSocial( - memberId, Provider.from(provider), currentProvider(authentication)); - } - - private Provider currentProvider(Authentication authentication) { - if (authentication.getDetails() instanceof Provider provider) { - return provider; - } - - throw new IllegalStateException("Authenticated provider is missing"); + public void unlinkSocial( + @AuthenticationPrincipal Long memberId, + @CurrentSecurityContext(expression = "authentication.details") Provider currentProvider, + @PathVariable String provider + ) { + memberProfileService.unlinkSocial(memberId, Provider.from(provider), currentProvider); } } diff --git a/report/src/main/java/com/nalssilog/report/api/ReportController.java b/report/src/main/java/com/nalssilog/report/api/ReportController.java index c7253d8..919c0f4 100644 --- a/report/src/main/java/com/nalssilog/report/api/ReportController.java +++ b/report/src/main/java/com/nalssilog/report/api/ReportController.java @@ -34,91 +34,97 @@ public class ReportController { @PostMapping @ResponseStatus(HttpStatus.CREATED) - public ReportResponse create(@AuthenticationPrincipal Long memberId, - @Valid @RequestBody CreateReportRequest request, - HttpServletRequest httpRequest, HttpServletResponse httpResponse) { + public ReportResponse create( + @AuthenticationPrincipal Long memberId, + @Valid @RequestBody CreateReportRequest request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse + ) { ReportActor actor = actorResolver.resolveForWrite(memberId, httpRequest, httpResponse); return reportService.create(actor, request.toCommand()); } @GetMapping - public CursorPage list(@RequestParam Long locationId, - @RequestParam(required = false) String cursor, - @AuthenticationPrincipal Long memberId, - HttpServletRequest httpRequest) { + public CursorPage list( + @RequestParam Long locationId, + @RequestParam(required = false) String cursor, + @AuthenticationPrincipal Long memberId, + HttpServletRequest httpRequest + ) { ReportActor viewer = actorResolver.resolveForRead(memberId, httpRequest); - return reportService.list( - locationId, cursor, viewer, actorResolver.resolveForOwnership(memberId, httpRequest)); + return reportService.list(locationId, cursor, viewer, actorResolver.resolveForOwnership(memberId, httpRequest)); } - /** - * 내 제보 목록(로그인 회원 전용). /{id} 보다 먼저 매칭되도록 위에 둔다. - */ @GetMapping("/me") - public CursorPage myReports(@AuthenticationPrincipal Long memberId, - @RequestParam(required = false) String cursor, - HttpServletRequest httpRequest) { + public CursorPage myReports( + @AuthenticationPrincipal Long memberId, + @RequestParam(required = false) String cursor, + HttpServletRequest httpRequest + ) { ReportActor viewer = actorResolver.resolveForRead(memberId, httpRequest); - return reportService.listByMember( - memberId, cursor, viewer, actorResolver.resolveForOwnership(memberId, httpRequest)); + return reportService.listByMember(memberId, cursor, viewer, actorResolver.resolveForOwnership(memberId, httpRequest)); } - /** - * 특정 회원의 제보 목록(공개). 탈퇴 회원이면 작성자는 "익명 이웃"으로 렌더된다. - */ @GetMapping("/members/{memberId}") - public CursorPage memberReports(@PathVariable Long memberId, - @RequestParam(required = false) String cursor, - @AuthenticationPrincipal Long viewerMemberId, - HttpServletRequest httpRequest) { + public CursorPage memberReports( + @PathVariable Long memberId, + @RequestParam(required = false) String cursor, + @AuthenticationPrincipal Long viewerMemberId, + HttpServletRequest httpRequest + ) { ReportActor viewer = actorResolver.resolveForRead(viewerMemberId, httpRequest); - return reportService.listByMember( - memberId, cursor, viewer, actorResolver.resolveForOwnership(viewerMemberId, httpRequest)); + return reportService.listByMember(memberId, cursor, viewer, actorResolver.resolveForOwnership(viewerMemberId, httpRequest)); } - /** - * 지역 날씨 통계(최근 24시간 3축 분포 + 제보 수). /{id} 보다 먼저 매칭되도록 위에 둔다. - */ @GetMapping("/stats") public WeatherStatsResponse stats(@RequestParam Long locationId) { return reportService.stats(locationId); } @GetMapping("/{id}") - public ReportResponse detail(@PathVariable Long id, - @AuthenticationPrincipal Long memberId, - HttpServletRequest httpRequest) { + public ReportResponse detail( + @PathVariable Long id, + @AuthenticationPrincipal Long memberId, + HttpServletRequest httpRequest + ) { ReportActor viewer = actorResolver.resolveForRead(memberId, httpRequest); - return reportService.get( - id, viewer, actorResolver.resolveForOwnership(memberId, httpRequest)); + return reportService.get(id, viewer, actorResolver.resolveForOwnership(memberId, httpRequest)); } @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) - public void delete(@PathVariable Long id, - @AuthenticationPrincipal Long memberId, - HttpServletRequest httpRequest) { + public void delete( + @PathVariable Long id, + @AuthenticationPrincipal Long memberId, + HttpServletRequest httpRequest + ) { reportService.delete(id, actorResolver.resolveForOwnership(memberId, httpRequest)); } @PostMapping("/{id}/thanks") - public ThanksResponse addThanks(@PathVariable Long id, - @AuthenticationPrincipal Long memberId, - HttpServletRequest httpRequest, HttpServletResponse httpResponse) { + public ThanksResponse addThanks( + @PathVariable Long id, + @AuthenticationPrincipal Long memberId, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse + ) { ReportActor actor = actorResolver.resolveForWrite(memberId, httpRequest, httpResponse); return reportService.addThanks(id, actor); } @DeleteMapping("/{id}/thanks") - public ThanksResponse removeThanks(@PathVariable Long id, - @AuthenticationPrincipal Long memberId, - HttpServletRequest httpRequest, HttpServletResponse httpResponse) { + public ThanksResponse removeThanks( + @PathVariable Long id, + @AuthenticationPrincipal Long memberId, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse + ) { ReportActor actor = actorResolver.resolveForWrite(memberId, httpRequest, httpResponse); return reportService.removeThanks(id, actor); diff --git a/report/src/main/java/com/nalssilog/report/application/ReportService.java b/report/src/main/java/com/nalssilog/report/application/ReportService.java index 4a216ca..5f887b8 100644 --- a/report/src/main/java/com/nalssilog/report/application/ReportService.java +++ b/report/src/main/java/com/nalssilog/report/application/ReportService.java @@ -38,7 +38,7 @@ public class ReportService { private static final int PAGE_SIZE = 20; - private static final Duration STATS_WINDOW = Duration.ofHours(24); + private static final Duration STATS_WINDOW = Duration.ofHours(3); private final WeatherReportRepository reportRepository; private final ThanksRepository thanksRepository; diff --git a/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java b/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java index e8ffc7b..453fc3a 100644 --- a/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java +++ b/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; @@ -15,6 +16,7 @@ import com.nalssilog.report.application.dto.LocationSummary; import com.nalssilog.report.application.dto.ReportActor; import com.nalssilog.report.application.dto.ReportData; +import com.nalssilog.report.application.dto.WeatherStatsData; import com.nalssilog.report.client.ImageStorageClient; import com.nalssilog.report.client.LocationClient; import com.nalssilog.report.client.MemberClient; @@ -143,6 +145,22 @@ void listIncludesOwnershipCalculatedFromAllAvailableActors() { .isEqualTo(true); } + @Test + void statsAggregatesReportsFromTheLastThreeHours() { + WeatherStatsData stats = new WeatherStatsData(0L, Map.of(), Map.of(), Map.of()); + when(locationClient.getLocation(1L)).thenReturn(location()); + when(reportRepository.statsSince(eq(1L), any(Instant.class))) + .thenReturn(stats); + Instant lowerBound = Instant.now().minusSeconds(3 * 60 * 60); + + service.stats(1L); + + ArgumentCaptor sinceCaptor = ArgumentCaptor.forClass(Instant.class); + verify(reportRepository).statsSince(eq(1L), sinceCaptor.capture()); + assertThat(sinceCaptor.getValue()) + .isBetween(lowerBound, Instant.now().minusSeconds(3 * 60 * 60)); + } + private WeatherReport memberReport(Long memberId) { return WeatherReport.ofMember( 1L, memberId, Temperature.FRESH, Precipitation.NONE, Sunlight.MODERATE, "맑아요");