diff --git a/src/main/java/com/somemore/auth/cookie/CookieService.java b/src/main/java/com/somemore/auth/cookie/CookieService.java new file mode 100644 index 000000000..f68080cca --- /dev/null +++ b/src/main/java/com/somemore/auth/cookie/CookieService.java @@ -0,0 +1,36 @@ +package com.somemore.auth.cookie; + +import com.somemore.auth.jwt.domain.TokenType; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CookieService implements CookieUseCase { + + @Override + public void setAccessToken(HttpServletResponse response, String value) { + ResponseCookie cookie = generateCookie(TokenType.ACCESS, value); + response.addHeader("Set-Cookie", cookie.toString()); + } + + @Override + public void deleteAccessToken(HttpServletResponse response) { + ResponseCookie cookie = generateCookie(TokenType.SIGNOUT, TokenType.SIGNOUT.name()); + response.addHeader("Set-Cookie", cookie.toString()); + } + + private static ResponseCookie generateCookie(TokenType tokenType, String value) { + return ResponseCookie.from(TokenType.ACCESS.name(), value) // 덮어쓰기 위해서 고정 값 + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(tokenType.getPeriodInSeconds()) + .sameSite("Lax") + .build(); + } +} diff --git a/src/main/java/com/somemore/auth/cookie/CookieUseCase.java b/src/main/java/com/somemore/auth/cookie/CookieUseCase.java new file mode 100644 index 000000000..40f5e04f5 --- /dev/null +++ b/src/main/java/com/somemore/auth/cookie/CookieUseCase.java @@ -0,0 +1,9 @@ +package com.somemore.auth.cookie; + +import jakarta.servlet.http.HttpServletResponse; + +public interface CookieUseCase { + void setAccessToken(HttpServletResponse response, String value); + + void deleteAccessToken(HttpServletResponse response); +} diff --git a/src/main/java/com/somemore/auth/cookie/SetCookieService.java b/src/main/java/com/somemore/auth/cookie/SetCookieService.java deleted file mode 100644 index f728dac31..000000000 --- a/src/main/java/com/somemore/auth/cookie/SetCookieService.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.somemore.auth.cookie; - -import com.somemore.auth.jwt.domain.TokenType; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseCookie; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class SetCookieService implements SetCookieUseCase { - - @Override - public void setToken(HttpServletResponse response, String value, TokenType tokenType) { - ResponseCookie cookie = generateCookie(tokenType.name(), value, tokenType.getPeriodInSeconds()); - response.addHeader("Set-Cookie", cookie.toString()); - } - - private static ResponseCookie generateCookie(String name, String value, int time) { - return ResponseCookie.from(name, value) - .httpOnly(true) - .secure(true) - .path("/") - .maxAge(time) - .sameSite("Lax") - .build(); - } -} diff --git a/src/main/java/com/somemore/auth/cookie/SetCookieUseCase.java b/src/main/java/com/somemore/auth/cookie/SetCookieUseCase.java deleted file mode 100644 index 148e2e013..000000000 --- a/src/main/java/com/somemore/auth/cookie/SetCookieUseCase.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.somemore.auth.cookie; - -import com.somemore.auth.jwt.domain.TokenType; -import jakarta.servlet.http.HttpServletResponse; - -public interface SetCookieUseCase { - void setToken(HttpServletResponse response, String value, TokenType tokenType); -} diff --git a/src/main/java/com/somemore/auth/jwt/domain/TokenType.java b/src/main/java/com/somemore/auth/jwt/domain/TokenType.java index 55a4e88f4..e6ea4638e 100644 --- a/src/main/java/com/somemore/auth/jwt/domain/TokenType.java +++ b/src/main/java/com/somemore/auth/jwt/domain/TokenType.java @@ -5,7 +5,8 @@ @Getter public enum TokenType { ACCESS(1000 * 60 * 30), - REFRESH(1000 * 60 * 60 * 24 * 7); + REFRESH(1000 * 60 * 60 * 24 * 7), + SIGNOUT(0); private final int period; diff --git a/src/main/java/com/somemore/auth/jwt/filter/JwtAuthFilter.java b/src/main/java/com/somemore/auth/jwt/filter/JwtAuthFilter.java index 3f9a2c677..08ced03a6 100644 --- a/src/main/java/com/somemore/auth/jwt/filter/JwtAuthFilter.java +++ b/src/main/java/com/somemore/auth/jwt/filter/JwtAuthFilter.java @@ -1,6 +1,5 @@ package com.somemore.auth.jwt.filter; -import com.somemore.auth.UserRole; import com.somemore.auth.jwt.domain.EncodedToken; import com.somemore.auth.jwt.exception.JwtErrorType; import com.somemore.auth.jwt.exception.JwtException; diff --git a/src/main/java/com/somemore/auth/UserRole.java b/src/main/java/com/somemore/auth/jwt/filter/UserRole.java similarity index 62% rename from src/main/java/com/somemore/auth/UserRole.java rename to src/main/java/com/somemore/auth/jwt/filter/UserRole.java index 0e4d9ed8e..857ee5c8a 100644 --- a/src/main/java/com/somemore/auth/UserRole.java +++ b/src/main/java/com/somemore/auth/jwt/filter/UserRole.java @@ -1,4 +1,4 @@ -package com.somemore.auth; +package com.somemore.auth.jwt.filter; public enum UserRole { VOLUNTEER, diff --git a/src/main/java/com/somemore/auth/jwt/refresh/manager/RedisRefreshTokenManager.java b/src/main/java/com/somemore/auth/jwt/refresh/manager/RedisRefreshTokenManager.java index 0a2d57e64..9f717056c 100644 --- a/src/main/java/com/somemore/auth/jwt/refresh/manager/RedisRefreshTokenManager.java +++ b/src/main/java/com/somemore/auth/jwt/refresh/manager/RedisRefreshTokenManager.java @@ -26,10 +26,8 @@ public void save(RefreshToken refreshToken) { } @Override - public void removeRefreshToken(EncodedToken accessToken) { - RefreshToken refreshToken = refreshTokenRepository.findByAccessToken(accessToken.value()) - .orElseThrow(() -> new JwtException(JwtErrorType.EXPIRED_TOKEN)); - - refreshTokenRepository.delete(refreshToken); + public void removeRefreshToken(String userId) { + refreshTokenRepository.findByUserId(userId) + .ifPresent(refreshTokenRepository::delete); } } diff --git a/src/main/java/com/somemore/auth/jwt/refresh/manager/RefreshTokenManager.java b/src/main/java/com/somemore/auth/jwt/refresh/manager/RefreshTokenManager.java index eb27a6582..ec2c0b1a7 100644 --- a/src/main/java/com/somemore/auth/jwt/refresh/manager/RefreshTokenManager.java +++ b/src/main/java/com/somemore/auth/jwt/refresh/manager/RefreshTokenManager.java @@ -8,5 +8,5 @@ public interface RefreshTokenManager { void save(RefreshToken refreshToken); - void removeRefreshToken(EncodedToken accessToken); + void removeRefreshToken(String userId); } diff --git a/src/main/java/com/somemore/auth/jwt/refresh/repository/RefreshTokenRepository.java b/src/main/java/com/somemore/auth/jwt/refresh/repository/RefreshTokenRepository.java index 9eb75e24a..31a566751 100644 --- a/src/main/java/com/somemore/auth/jwt/refresh/repository/RefreshTokenRepository.java +++ b/src/main/java/com/somemore/auth/jwt/refresh/repository/RefreshTokenRepository.java @@ -10,4 +10,6 @@ public interface RefreshTokenRepository extends CrudRepository { Optional findByAccessToken(String accessToken); + + Optional findByUserId(String userId); } diff --git a/src/main/java/com/somemore/auth/jwt/service/command/GenerateTokensOnLoginService.java b/src/main/java/com/somemore/auth/jwt/service/GenerateTokensOnLoginService.java similarity index 93% rename from src/main/java/com/somemore/auth/jwt/service/command/GenerateTokensOnLoginService.java rename to src/main/java/com/somemore/auth/jwt/service/GenerateTokensOnLoginService.java index 4bbbbd74d..a0cd82ee3 100644 --- a/src/main/java/com/somemore/auth/jwt/service/command/GenerateTokensOnLoginService.java +++ b/src/main/java/com/somemore/auth/jwt/service/GenerateTokensOnLoginService.java @@ -1,4 +1,4 @@ -package com.somemore.auth.jwt.service.command; +package com.somemore.auth.jwt.service; import com.somemore.auth.jwt.domain.UserRole; import com.somemore.auth.jwt.domain.EncodedToken; @@ -6,7 +6,7 @@ import com.somemore.auth.jwt.generator.JwtGenerator; import com.somemore.auth.jwt.refresh.domain.RefreshToken; import com.somemore.auth.jwt.refresh.manager.RefreshTokenManager; -import com.somemore.auth.jwt.usecase.command.GenerateTokensOnLoginUseCase; +import com.somemore.auth.jwt.usecase.GenerateTokensOnLoginUseCase; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/somemore/auth/jwt/service/JwtService.java b/src/main/java/com/somemore/auth/jwt/service/JwtService.java index dd84f0b20..6bf18ba98 100644 --- a/src/main/java/com/somemore/auth/jwt/service/JwtService.java +++ b/src/main/java/com/somemore/auth/jwt/service/JwtService.java @@ -1,6 +1,6 @@ package com.somemore.auth.jwt.service; -import com.somemore.auth.cookie.SetCookieUseCase; +import com.somemore.auth.cookie.CookieUseCase; import com.somemore.auth.jwt.domain.EncodedToken; import com.somemore.auth.jwt.domain.TokenType; import com.somemore.auth.jwt.exception.JwtErrorType; @@ -24,7 +24,7 @@ public class JwtService implements JwtUseCase { private final JwtParser jwtParser; private final JwtValidator jwtValidator; private final JwtRefresher jwtRefresher; - private final SetCookieUseCase setCookieUseCase; + private final CookieUseCase cookieUseCase; @Override public EncodedToken generateToken(String userId, String role, TokenType tokenType) { @@ -48,7 +48,7 @@ public Claims getClaims(EncodedToken token) { private void handleJwtExpiredException(JwtException e, EncodedToken accessToken, HttpServletResponse response) { if (e.getErrorType() == JwtErrorType.EXPIRED_TOKEN) { EncodedToken refreshedToken = jwtRefresher.refreshAccessToken(accessToken); - setCookieUseCase.setToken(response, refreshedToken.value(), TokenType.ACCESS); + cookieUseCase.setAccessToken(response, refreshedToken.value()); return; } throw e; diff --git a/src/main/java/com/somemore/auth/jwt/usecase/command/GenerateTokensOnLoginUseCase.java b/src/main/java/com/somemore/auth/jwt/usecase/GenerateTokensOnLoginUseCase.java similarity index 80% rename from src/main/java/com/somemore/auth/jwt/usecase/command/GenerateTokensOnLoginUseCase.java rename to src/main/java/com/somemore/auth/jwt/usecase/GenerateTokensOnLoginUseCase.java index 912f205f0..0c48a4cce 100644 --- a/src/main/java/com/somemore/auth/jwt/usecase/command/GenerateTokensOnLoginUseCase.java +++ b/src/main/java/com/somemore/auth/jwt/usecase/GenerateTokensOnLoginUseCase.java @@ -1,4 +1,4 @@ -package com.somemore.auth.jwt.usecase.command; +package com.somemore.auth.jwt.usecase; import com.somemore.auth.jwt.domain.EncodedToken; diff --git a/src/main/java/com/somemore/auth/oauth/handler/success/CustomOAuthSuccessHandler.java b/src/main/java/com/somemore/auth/oauth/handler/success/CustomOAuthSuccessHandler.java index 5f389367b..b090b8db4 100644 --- a/src/main/java/com/somemore/auth/oauth/handler/success/CustomOAuthSuccessHandler.java +++ b/src/main/java/com/somemore/auth/oauth/handler/success/CustomOAuthSuccessHandler.java @@ -1,13 +1,12 @@ package com.somemore.auth.oauth.handler.success; -import com.somemore.auth.cookie.SetCookieUseCase; +import com.somemore.auth.cookie.CookieUseCase; import com.somemore.auth.jwt.domain.EncodedToken; -import com.somemore.auth.jwt.domain.TokenType; -import com.somemore.auth.jwt.usecase.command.GenerateTokensOnLoginUseCase; +import com.somemore.auth.jwt.usecase.GenerateTokensOnLoginUseCase; import com.somemore.auth.oauth.OAuthProvider; import com.somemore.auth.oauth.naver.service.query.ProcessNaverOAuthUserService; import com.somemore.auth.redirect.RedirectUseCase; -import com.somemore.volunteer.usecase.query.FindVolunteerIdUseCase; +import com.somemore.volunteer.usecase.FindVolunteerIdUseCase; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -29,10 +28,10 @@ public class CustomOAuthSuccessHandler extends SimpleUrlAuthenticationSuccessHan private final ProcessNaverOAuthUserService processNaverOAuthService; private final FindVolunteerIdUseCase findVolunteerIdUseCase; private final GenerateTokensOnLoginUseCase generateTokensOnLoginUseCase; - private final SetCookieUseCase setCookieUseCase; + private final CookieUseCase cookieUseCase; private final RedirectUseCase redirectUseCase; - @Value("${frontend.url}") + @Value("${app.front-url}") private String frontendRootUrl; @Override @@ -49,7 +48,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo UUID volunteerId = findVolunteerIdUseCase.findVolunteerIdByOAuthId(oAuthId); EncodedToken accessToken = generateTokensOnLoginUseCase.saveRefreshTokenAndReturnAccessToken(volunteerId); - setCookieUseCase.setToken(response, accessToken.value(), TokenType.ACCESS); + cookieUseCase.setAccessToken(response, accessToken.value()); redirectUseCase.redirect(request, response, frontendRootUrl); } diff --git a/src/main/java/com/somemore/auth/oauth/naver/service/command/NaverOAuth2UserInfoService.java b/src/main/java/com/somemore/auth/oauth/naver/service/command/NaverOAuth2UserInfoService.java index 496dcf1f3..cf6f92d02 100644 --- a/src/main/java/com/somemore/auth/oauth/naver/service/command/NaverOAuth2UserInfoService.java +++ b/src/main/java/com/somemore/auth/oauth/naver/service/command/NaverOAuth2UserInfoService.java @@ -4,7 +4,7 @@ import com.somemore.auth.oauth.naver.usecase.query.CheckNaverUserUseCase; import com.somemore.auth.oauth.naver.usecase.command.RegisterNaverUserUseCase; import com.somemore.auth.oauth.naver.util.OAuthResponseConverter; -import com.somemore.volunteer.usecase.command.RegisterVolunteerUseCase; +import com.somemore.volunteer.usecase.RegisterVolunteerUseCase; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.oauth2.core.user.OAuth2User; diff --git a/src/main/java/com/somemore/community/service/CommunityBoardQueryService.java b/src/main/java/com/somemore/community/service/CommunityBoardQueryService.java index 00aeb74b8..aca97f3f7 100644 --- a/src/main/java/com/somemore/community/service/CommunityBoardQueryService.java +++ b/src/main/java/com/somemore/community/service/CommunityBoardQueryService.java @@ -9,7 +9,7 @@ import com.somemore.community.usecase.CommunityBoardQueryUseCase; import com.somemore.global.exception.BadRequestException; import com.somemore.volunteer.dto.response.VolunteerForCommunityResponseDto; -import com.somemore.volunteer.usecase.query.FindVolunteerIdUseCase; +import com.somemore.volunteer.usecase.FindVolunteerIdUseCase; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/com/somemore/global/common/response/ApiResponse.java b/src/main/java/com/somemore/global/common/response/ApiResponse.java index 3b2b32ef5..ebdd60c3a 100644 --- a/src/main/java/com/somemore/global/common/response/ApiResponse.java +++ b/src/main/java/com/somemore/global/common/response/ApiResponse.java @@ -12,26 +12,20 @@ public class ApiResponse { private T data; public static ApiResponse ok(int status, T data, String message) { - return new ApiResponse<>(status, message, data); } public static ApiResponse ok(String message) { - return new ApiResponse<>(200, message, ""); } public static ApiResponse error(int code, String message) { - return new ApiResponse<>(code, message, ""); } - - public ApiResponse(int code, String message, T data) { this.code = code; this.message = message; this.data = data; } - } diff --git a/src/main/java/com/somemore/volunteer/controller/VolunteerSignController.java b/src/main/java/com/somemore/volunteer/controller/VolunteerSignController.java new file mode 100644 index 000000000..9d7f3445a --- /dev/null +++ b/src/main/java/com/somemore/volunteer/controller/VolunteerSignController.java @@ -0,0 +1,53 @@ +package com.somemore.volunteer.controller; + +import com.somemore.global.common.response.ApiResponse; +import com.somemore.global.exception.BadRequestException; +import com.somemore.volunteer.usecase.SignOutVolunteerUseCase; +import com.somemore.volunteer.usecase.GenerateOAuthUrlUseCase; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.view.RedirectView; + +@RestController +@Slf4j +@RequiredArgsConstructor +@RequestMapping("/api/volunteer") +@Tag(name = "Volunteer OAuth API", description = "Handles Volunteer OAuth Sign-in, Sign-out") +public class VolunteerSignController { + + private final GenerateOAuthUrlUseCase generateOAuthUrlUseCase; + private final SignOutVolunteerUseCase signOutVolunteerUseCase; + + @PostMapping("/sign-in/oauth/{oauthProvider}") + public RedirectView signIn( + @Parameter(name = "oauthProvider", description = "OAuth 제공자 선택", example = "naver", required = true, schema = @Schema(allowableValues = {"naver"})) + @PathVariable("oauthProvider") String oauthProvider) { + + String redirectUrl = switch (oauthProvider.toLowerCase()) { + case "naver" -> generateOAuthUrlUseCase.generateUrl(oauthProvider); + + default -> throw new BadRequestException("지원되지 않는 OAuth 제공자: " + oauthProvider); + }; + + return new RedirectView(redirectUrl); + } + + @PostMapping("/sign-out") + public ApiResponse signOut( + HttpServletResponse response, + @AuthenticationPrincipal String userId) { + + signOutVolunteerUseCase.signOut(response, userId); + + return ApiResponse.ok("로그아웃되었습니다"); + } +} diff --git a/src/main/java/com/somemore/volunteer/service/query/FindVolunteerIdService.java b/src/main/java/com/somemore/volunteer/service/FindVolunteerIdService.java similarity index 91% rename from src/main/java/com/somemore/volunteer/service/query/FindVolunteerIdService.java rename to src/main/java/com/somemore/volunteer/service/FindVolunteerIdService.java index 970a643d4..7825aa367 100644 --- a/src/main/java/com/somemore/volunteer/service/query/FindVolunteerIdService.java +++ b/src/main/java/com/somemore/volunteer/service/FindVolunteerIdService.java @@ -1,9 +1,9 @@ -package com.somemore.volunteer.service.query; +package com.somemore.volunteer.service; import com.somemore.volunteer.domain.Volunteer; import com.somemore.volunteer.dto.response.VolunteerForCommunityResponseDto; import com.somemore.volunteer.repository.VolunteerRepository; -import com.somemore.volunteer.usecase.query.FindVolunteerIdUseCase; +import com.somemore.volunteer.usecase.FindVolunteerIdUseCase; import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/somemore/volunteer/service/GenerateOAuthUrlService.java b/src/main/java/com/somemore/volunteer/service/GenerateOAuthUrlService.java new file mode 100644 index 000000000..8e179e5d4 --- /dev/null +++ b/src/main/java/com/somemore/volunteer/service/GenerateOAuthUrlService.java @@ -0,0 +1,32 @@ +package com.somemore.volunteer.service; + +import com.somemore.volunteer.usecase.GenerateOAuthUrlUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GenerateOAuthUrlService implements GenerateOAuthUrlUseCase { + + @Value("${app.back-url}") + private String backendRootUrl; + + @Override + public String generateUrl(String oAuthProvider) { + return UriComponentsBuilder.fromHttpUrl(generateBaseUrl()) + .pathSegment(oAuthProvider) + .build() + .toUriString(); + } + + private String generateBaseUrl() { + return UriComponentsBuilder.fromHttpUrl(backendRootUrl) + .path("/oauth2/authorization") + .build() + .toUriString(); + } +} diff --git a/src/main/java/com/somemore/volunteer/service/command/RegisterVolunteerService.java b/src/main/java/com/somemore/volunteer/service/RegisterVolunteerService.java similarity index 90% rename from src/main/java/com/somemore/volunteer/service/command/RegisterVolunteerService.java rename to src/main/java/com/somemore/volunteer/service/RegisterVolunteerService.java index 8ea14dea0..8feb930c1 100644 --- a/src/main/java/com/somemore/volunteer/service/command/RegisterVolunteerService.java +++ b/src/main/java/com/somemore/volunteer/service/RegisterVolunteerService.java @@ -1,11 +1,11 @@ -package com.somemore.volunteer.service.command; +package com.somemore.volunteer.service; import com.somemore.volunteer.domain.Volunteer; import com.somemore.volunteer.domain.VolunteerDetail; import com.somemore.volunteer.dto.request.VolunteerRegisterRequestDto; import com.somemore.volunteer.repository.VolunteerDetailRepository; import com.somemore.volunteer.repository.VolunteerRepository; -import com.somemore.volunteer.usecase.command.RegisterVolunteerUseCase; +import com.somemore.volunteer.usecase.RegisterVolunteerUseCase; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/somemore/volunteer/service/SignOutVolunteerService.java b/src/main/java/com/somemore/volunteer/service/SignOutVolunteerService.java new file mode 100644 index 000000000..95e015d0f --- /dev/null +++ b/src/main/java/com/somemore/volunteer/service/SignOutVolunteerService.java @@ -0,0 +1,29 @@ +package com.somemore.volunteer.service; + +import com.somemore.auth.cookie.CookieUseCase; +import com.somemore.auth.jwt.refresh.manager.RefreshTokenManager; +import com.somemore.volunteer.usecase.SignOutVolunteerUseCase; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class SignOutVolunteerService implements SignOutVolunteerUseCase { + + private final CookieUseCase cookieUseCase; + private final RefreshTokenManager refreshTokenManager; + + @Override + public void signOut( + HttpServletResponse response, + String volunteerId) { + + cookieUseCase.deleteAccessToken(response); + refreshTokenManager.removeRefreshToken(volunteerId); + } +} diff --git a/src/main/java/com/somemore/volunteer/usecase/query/FindVolunteerIdUseCase.java b/src/main/java/com/somemore/volunteer/usecase/FindVolunteerIdUseCase.java similarity index 87% rename from src/main/java/com/somemore/volunteer/usecase/query/FindVolunteerIdUseCase.java rename to src/main/java/com/somemore/volunteer/usecase/FindVolunteerIdUseCase.java index cf1775afb..e23eaa20f 100644 --- a/src/main/java/com/somemore/volunteer/usecase/query/FindVolunteerIdUseCase.java +++ b/src/main/java/com/somemore/volunteer/usecase/FindVolunteerIdUseCase.java @@ -1,4 +1,4 @@ -package com.somemore.volunteer.usecase.query; +package com.somemore.volunteer.usecase; import com.somemore.volunteer.dto.response.VolunteerForCommunityResponseDto; diff --git a/src/main/java/com/somemore/volunteer/usecase/GenerateOAuthUrlUseCase.java b/src/main/java/com/somemore/volunteer/usecase/GenerateOAuthUrlUseCase.java new file mode 100644 index 000000000..1a91f69c8 --- /dev/null +++ b/src/main/java/com/somemore/volunteer/usecase/GenerateOAuthUrlUseCase.java @@ -0,0 +1,5 @@ +package com.somemore.volunteer.usecase; + +public interface GenerateOAuthUrlUseCase { + String generateUrl(String oAuthProvider); +} diff --git a/src/main/java/com/somemore/volunteer/usecase/command/RegisterVolunteerUseCase.java b/src/main/java/com/somemore/volunteer/usecase/RegisterVolunteerUseCase.java similarity index 78% rename from src/main/java/com/somemore/volunteer/usecase/command/RegisterVolunteerUseCase.java rename to src/main/java/com/somemore/volunteer/usecase/RegisterVolunteerUseCase.java index 45e06dcf4..0e90d980d 100644 --- a/src/main/java/com/somemore/volunteer/usecase/command/RegisterVolunteerUseCase.java +++ b/src/main/java/com/somemore/volunteer/usecase/RegisterVolunteerUseCase.java @@ -1,4 +1,4 @@ -package com.somemore.volunteer.usecase.command; +package com.somemore.volunteer.usecase; import com.somemore.volunteer.dto.request.VolunteerRegisterRequestDto; diff --git a/src/main/java/com/somemore/volunteer/usecase/SignOutVolunteerUseCase.java b/src/main/java/com/somemore/volunteer/usecase/SignOutVolunteerUseCase.java new file mode 100644 index 000000000..b5a236ed6 --- /dev/null +++ b/src/main/java/com/somemore/volunteer/usecase/SignOutVolunteerUseCase.java @@ -0,0 +1,8 @@ +package com.somemore.volunteer.usecase; + +import jakarta.servlet.http.HttpServletResponse; + +public interface SignOutVolunteerUseCase { + + void signOut(HttpServletResponse response, String volunteerId); +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e6943650b..73394baa0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,7 @@ +app: + front-url: ${FRONT_URL} + back-url: ${BACK_URL} + spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver @@ -28,7 +32,7 @@ spring: naver: client-id: ${NAVER_CLIENT_ID} client-secret: ${NAVER_CLIENT_SECRET} - redirect-uri: "http://localhost:8080/login/oauth2/code/naver" + redirect-uri: ${NAVER_REDIRECT_URL} authorization-grant-type: authorization_code scope: ${NAVER_SCOPE} @@ -39,6 +43,10 @@ spring: user-info-uri: https://openapi.naver.com/v1/nid/me user-name-attribute: response # 네이버 API가 사용자 정보를 "response" 객체 안에 넣어 반환 + web: + locale: ko_KR + locale-resolver: fixed + #swagger springdoc: swagger-ui: @@ -58,12 +66,16 @@ springdoc: paths-to-match: - /api/** -frontend: - url: localhost - jwt: secret: ${JWT_SECRET} logging: level: org.springframework.security: DEBUG + +server: + servlet: + encoding: + charset: UTF-8 + enabled: true + force: true diff --git a/src/test/java/com/somemore/auth/cookie/CookieServiceTest.java b/src/test/java/com/somemore/auth/cookie/CookieServiceTest.java new file mode 100644 index 000000000..6dbab7163 --- /dev/null +++ b/src/test/java/com/somemore/auth/cookie/CookieServiceTest.java @@ -0,0 +1,44 @@ +package com.somemore.auth.cookie; + +import com.somemore.auth.jwt.domain.TokenType; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; + +class CookieServiceTest { + + private final CookieService cookieService = new CookieService(); + + @Test + void setAccessToken_ShouldSetCookie() { + // Given + MockHttpServletResponse response = new MockHttpServletResponse(); + String accessToken = "test-access-token"; + + // When + cookieService.setAccessToken(response, accessToken); + + // Then + String setCookieHeader = response.getHeader("Set-Cookie"); + assertThat(setCookieHeader).contains("ACCESS=" + accessToken); + assertThat(setCookieHeader).contains("HttpOnly"); + assertThat(setCookieHeader).contains("Secure"); + assertThat(setCookieHeader).contains("Path=/"); + } + + @Test + void deleteAccessToken_ShouldRemoveCookie() { + // Given + MockHttpServletResponse response = new MockHttpServletResponse(); + + // When + cookieService.deleteAccessToken(response); + + // Then + String setCookieHeader = response.getHeader("Set-Cookie"); + assertThat(setCookieHeader).contains("ACCESS=" + TokenType.SIGNOUT.name()); // 빈 값 + assertThat(setCookieHeader).contains("Max-Age=0"); // 삭제 + assertThat(setCookieHeader).contains("Path=/"); + } +} \ No newline at end of file diff --git a/src/test/java/com/somemore/auth/jwt/service/JwtServiceTest.java b/src/test/java/com/somemore/auth/jwt/service/JwtServiceTest.java index 509c513ce..0519bcd3a 100644 --- a/src/test/java/com/somemore/auth/jwt/service/JwtServiceTest.java +++ b/src/test/java/com/somemore/auth/jwt/service/JwtServiceTest.java @@ -11,9 +11,11 @@ import com.somemore.auth.jwt.validator.JwtValidator; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.mock.web.MockHttpServletResponse; import javax.crypto.SecretKey; @@ -34,6 +36,15 @@ class JwtServiceTest extends IntegrationTestSupport { private SecretKey secretKey; @Autowired private RefreshTokenManager refreshTokenManager; + @Autowired + private RedisTemplate redisTemplate; + + + @AfterEach + void tearDown() { + redisTemplate.keys("*") + .forEach(redisTemplate::delete); + } @DisplayName("토큰이 올바르게 생성된다") @Test diff --git a/src/test/java/com/somemore/volunteer/controller/VolunteerSignControllerTest.java b/src/test/java/com/somemore/volunteer/controller/VolunteerSignControllerTest.java new file mode 100644 index 000000000..1af24e071 --- /dev/null +++ b/src/test/java/com/somemore/volunteer/controller/VolunteerSignControllerTest.java @@ -0,0 +1,66 @@ +package com.somemore.volunteer.controller; + +import com.somemore.IntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class VolunteerSignControllerTest extends IntegrationTestSupport { + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("유효한 OAuth 제공자로 로그인 URL을 생성한다.") + void signInWithValidProvider() throws Exception { + // Given + String oauthProvider = "naver"; + + // When + // Then + mockMvc.perform(post("/api/volunteer/sign-in/oauth/{oauthProvider}", oauthProvider)) + .andExpect(status().is3xxRedirection()) + .andExpect(result -> { + MockHttpServletResponse response = result.getResponse(); + String redirectedUrl = response.getRedirectedUrl(); + assertThat(redirectedUrl).isNotNull(); + assertThat(redirectedUrl).contains("oauth2/authorization/naver"); + }); + } + + @Test + @DisplayName("지원되지 않는 OAuth 제공자로 로그인 시 400 에러를 반환한다.") + void signInWithInvalidProvider() throws Exception { + // Given + String invalidProvider = "unsupported-provider"; + + // When + // Then + mockMvc.perform(post("/api/volunteer/sign-in/oauth/{oauthProvider}", invalidProvider)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("로그아웃 요청 시 성공 메시지를 반환한다.") + void signOut() throws Exception { + // When + // Then + mockMvc.perform(post("/api/volunteer/sign-out")) + .andExpect(status().isOk()) + .andExpect(result -> { + MockHttpServletResponse response = result.getResponse(); + String responseBody = response.getContentAsString(); + assertThat(responseBody).contains("로그아웃되었습니다"); + }); + } +} \ No newline at end of file diff --git a/src/test/java/com/somemore/volunteer/service/query/FindVolunteerIdServiceTest.java b/src/test/java/com/somemore/volunteer/service/FindVolunteerIdServiceTest.java similarity index 98% rename from src/test/java/com/somemore/volunteer/service/query/FindVolunteerIdServiceTest.java rename to src/test/java/com/somemore/volunteer/service/FindVolunteerIdServiceTest.java index 4bd0ec03b..831348722 100644 --- a/src/test/java/com/somemore/volunteer/service/query/FindVolunteerIdServiceTest.java +++ b/src/test/java/com/somemore/volunteer/service/FindVolunteerIdServiceTest.java @@ -1,4 +1,4 @@ -package com.somemore.volunteer.service.query; +package com.somemore.volunteer.service; import com.somemore.IntegrationTestSupport; import com.somemore.auth.oauth.OAuthProvider; diff --git a/src/test/java/com/somemore/volunteer/service/GenerateOAuthUrlServiceTest.java b/src/test/java/com/somemore/volunteer/service/GenerateOAuthUrlServiceTest.java new file mode 100644 index 000000000..be5d20140 --- /dev/null +++ b/src/test/java/com/somemore/volunteer/service/GenerateOAuthUrlServiceTest.java @@ -0,0 +1,46 @@ +package com.somemore.volunteer.service; + +import com.somemore.IntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; + +import static org.assertj.core.api.Assertions.assertThat; + +class GenerateOAuthUrlServiceTest extends IntegrationTestSupport { + + @Autowired + private GenerateOAuthUrlService generateOAuthUrlService; + + @Value("${app.back-url}") + private String backendRootUrl; + + + @Test + void generateUrl_ShouldReturnCorrectUrl_ForNaver() { + // Given + String oAuthProvider = "naver"; + + // When + String result = generateOAuthUrlService.generateUrl(oAuthProvider); + + // Then + String expectedUrl = backendRootUrl + "/oauth2/authorization/naver"; + + assertThat(result).isEqualTo(expectedUrl); + } + + @Test + void generateUrl_ShouldReturnCorrectUrl_ForGoogle() { + // Given + String oAuthProvider = "google"; + + // When + String result = generateOAuthUrlService.generateUrl(oAuthProvider); + + // Then + String expectedUrl = backendRootUrl + "/oauth2/authorization/google"; + + assertThat(result).isEqualTo(expectedUrl); + } +} \ No newline at end of file diff --git a/src/test/java/com/somemore/volunteer/service/command/RegisterVolunteerServiceTest.java b/src/test/java/com/somemore/volunteer/service/RegisterVolunteerServiceTest.java similarity index 98% rename from src/test/java/com/somemore/volunteer/service/command/RegisterVolunteerServiceTest.java rename to src/test/java/com/somemore/volunteer/service/RegisterVolunteerServiceTest.java index 8454a470d..543718d3a 100644 --- a/src/test/java/com/somemore/volunteer/service/command/RegisterVolunteerServiceTest.java +++ b/src/test/java/com/somemore/volunteer/service/RegisterVolunteerServiceTest.java @@ -1,4 +1,4 @@ -package com.somemore.volunteer.service.command; +package com.somemore.volunteer.service; import com.somemore.IntegrationTestSupport; import com.somemore.auth.oauth.OAuthProvider; diff --git a/src/test/java/com/somemore/volunteer/service/SignOutVolunteerServiceTest.java b/src/test/java/com/somemore/volunteer/service/SignOutVolunteerServiceTest.java new file mode 100644 index 000000000..c6c9efd15 --- /dev/null +++ b/src/test/java/com/somemore/volunteer/service/SignOutVolunteerServiceTest.java @@ -0,0 +1,91 @@ +package com.somemore.volunteer.service; + +import com.somemore.IntegrationTestSupport; +import com.somemore.auth.cookie.CookieUseCase; +import com.somemore.auth.jwt.domain.EncodedToken; +import com.somemore.auth.jwt.domain.TokenType; +import com.somemore.auth.jwt.exception.JwtErrorType; +import com.somemore.auth.jwt.exception.JwtException; +import com.somemore.auth.jwt.filter.UserRole; +import com.somemore.auth.jwt.generator.JwtGenerator; +import com.somemore.auth.jwt.refresh.domain.RefreshToken; +import com.somemore.auth.jwt.refresh.manager.RefreshTokenManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.*; + +class SignOutVolunteerServiceTest extends IntegrationTestSupport { + + @Autowired + private SignOutVolunteerService signOutVolunteerService; + @Autowired + private CookieUseCase cookieUseCase; + @Autowired + private RefreshTokenManager refreshTokenManager; + @Autowired + private JwtGenerator jwtGenerator; + @Autowired + private RedisTemplate redisTemplate; + + private MockHttpServletResponse response; + private String volunteerId; + private UserRole role; + + + @BeforeEach + void setUp() { + response = new MockHttpServletResponse(); + volunteerId = "test-volunteer"; + role = UserRole.VOLUNTEER; + } + + @AfterEach + void tearDown() { + redisTemplate.keys("*") + .forEach(redisTemplate::delete); + } + + @Test + @DisplayName("로그아웃 시 액세스 토큰 쿠키를 삭제하고 리프레시 토큰을 제거해야 한다.") + void signOutDeletesTokens() { + // Given + EncodedToken accessToken = jwtGenerator.generateToken(volunteerId, role.name(), TokenType.ACCESS); + + RefreshToken refreshToken = new RefreshToken( + volunteerId, + accessToken, + jwtGenerator.generateToken(volunteerId, role.name(), TokenType.REFRESH)); + + refreshTokenManager.save(refreshToken); + cookieUseCase.setAccessToken(response, accessToken.value()); + + // When + signOutVolunteerService.signOut(response, volunteerId); + + // Then + assertThatThrownBy(() -> refreshTokenManager.findRefreshToken(accessToken)) + .isInstanceOf(JwtException.class) + .hasMessage(JwtErrorType.EXPIRED_TOKEN.getMessage()); + + assertThat(Arrays.toString(response.getCookies())).contains(TokenType.SIGNOUT.name()); + } + + @Test + @DisplayName("로그아웃 시 리프레시 토큰이 없어도 예외가 발생하지 않는다.") + void signOutWithoutRefreshToken() { + // When + signOutVolunteerService.signOut(response, volunteerId); + + // Then + assertThatNoException().isThrownBy(() -> signOutVolunteerService.signOut(response, volunteerId)); + assertThat(Arrays.toString(response.getCookies())).contains(TokenType.SIGNOUT.name()); + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index a80bba3cc..fab7dd965 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -1,3 +1,7 @@ +app: + front-url: "http://localhost:3000" + back-url: "http://localhost:8080" + spring: config: activate: @@ -27,9 +31,16 @@ spring: port: 6379 password: # 테스트에서는 비밀번호 없이 연결 -frontend: - url: http://localhost:3000 # 테스트용 프론트엔드 주소 + web: + locale: ko_KR + locale-resolver: fixed jwt: secret: 63bf2c80266cd25072e53b3482e318c30d1cd18d8c98d0f5d278530a94fe28d9fbbec531e5ccb58c725c125738182357786b71f43a7172c5d0c94a17f0da44f2 # 테스트용 JWT 시크릿 키 +server: + servlet: + encoding: + charset: UTF-8 + enabled: true + force: true