From 1c53aaaebfa1fe6b42c44898bafb85f7d4462f41 Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 8 Jun 2026 10:23:33 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20AdminRefreshToken=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=9E=91=EC=84=B1=20=EB=B0=8F=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/domain/AdminRefreshToken.java | 7 +++++++ .../auth/token/config/TokenProperties.java | 3 +++ src/main/resources/config/application-variable.yml | 12 ++++++++++++ src/test/resources/application.yml | 4 ++++ 4 files changed, 26 insertions(+) create mode 100644 src/main/java/com/example/solidconnection/auth/domain/AdminRefreshToken.java diff --git a/src/main/java/com/example/solidconnection/auth/domain/AdminRefreshToken.java b/src/main/java/com/example/solidconnection/auth/domain/AdminRefreshToken.java new file mode 100644 index 000000000..ae1788e43 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/domain/AdminRefreshToken.java @@ -0,0 +1,7 @@ +package com.example.solidconnection.auth.domain; + +public record AdminRefreshToken( + String token +) implements Token { + +} diff --git a/src/main/java/com/example/solidconnection/auth/token/config/TokenProperties.java b/src/main/java/com/example/solidconnection/auth/token/config/TokenProperties.java index ba239dc9a..e200c15b5 100644 --- a/src/main/java/com/example/solidconnection/auth/token/config/TokenProperties.java +++ b/src/main/java/com/example/solidconnection/auth/token/config/TokenProperties.java @@ -1,6 +1,7 @@ package com.example.solidconnection.auth.token.config; import com.example.solidconnection.auth.domain.AccessToken; +import com.example.solidconnection.auth.domain.AdminRefreshToken; import com.example.solidconnection.auth.domain.RefreshToken; import com.example.solidconnection.auth.domain.SignUpToken; import com.example.solidconnection.auth.domain.Token; @@ -13,6 +14,7 @@ public record TokenProperties( TokenConfig access, TokenConfig refresh, + TokenConfig adminRefresh, TokenConfig signUp, TokenConfig blackList ) { @@ -32,6 +34,7 @@ public void init() { tokenConfigs = Map.of( AccessToken.class, access, RefreshToken.class, refresh, + AdminRefreshToken.class, adminRefresh, SignUpToken.class, signUp ); } diff --git a/src/main/resources/config/application-variable.yml b/src/main/resources/config/application-variable.yml index 629beb305..b037c3386 100644 --- a/src/main/resources/config/application-variable.yml +++ b/src/main/resources/config/application-variable.yml @@ -42,6 +42,9 @@ token: refresh: storage-key-prefix: "REFRESH" expire-time: 90d + admin-refresh: + storage-key-prefix: "ADMIN_REFRESH" + expire-time: 90d sign-up: storage-key-prefix: "SIGN_UP" expire-time: 10m @@ -84,6 +87,9 @@ token: refresh: cookie-name: "prodRefreshToken" cookie-domain: ".solid-connection.com" + admin-refresh: + cookie-name: "adminProdRefreshToken" + cookie-domain: ".solid-connection.com" --- spring: @@ -122,6 +128,9 @@ token: refresh: cookie-name: "stageRefreshToken" cookie-domain: ".stage.solid-connection.com" + admin-refresh: + cookie-name: "adminStageRefreshToken" + cookie-domain: ".stage.solid-connection.com" --- spring: @@ -157,3 +166,6 @@ token: refresh: cookie-name: "refreshToken" cookie-domain: "localhost" + admin-refresh: + cookie-name: "adminRefreshToken" + cookie-domain: "localhost" diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 165e12a53..7181db744 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -92,6 +92,10 @@ token: cookie-domain: "test.domain.com" storage-key-prefix: "REFRESH" expire-time: 10m + admin-refresh: + cookie-domain: "test.domain.com" + storage-key-prefix: "ADMIN_REFRESH" + expire-time: 10m sign-up: storage-key-prefix: "SIGN_UP" expire-time: 10m From c9ed44fdb16b76ae8957e14aecef474bc366191b Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 8 Jun 2026 10:30:38 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20cookie=20manager=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminRefreshTokenCookieManager.java | 69 +++++++++++++++++++ .../AdminRefreshTokenCookieProperties.java | 11 +++ 2 files changed, 80 insertions(+) create mode 100644 src/main/java/com/example/solidconnection/admin/auth/controller/AdminRefreshTokenCookieManager.java create mode 100644 src/main/java/com/example/solidconnection/admin/auth/controller/config/AdminRefreshTokenCookieProperties.java diff --git a/src/main/java/com/example/solidconnection/admin/auth/controller/AdminRefreshTokenCookieManager.java b/src/main/java/com/example/solidconnection/admin/auth/controller/AdminRefreshTokenCookieManager.java new file mode 100644 index 000000000..598394c3d --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/auth/controller/AdminRefreshTokenCookieManager.java @@ -0,0 +1,69 @@ +package com.example.solidconnection.admin.auth.controller; + +import static com.example.solidconnection.common.exception.ErrorCode.REFRESH_TOKEN_NOT_EXISTS; + +import com.example.solidconnection.admin.auth.controller.config.AdminRefreshTokenCookieProperties; +import com.example.solidconnection.auth.token.config.TokenProperties; +import com.example.solidconnection.common.exception.CustomException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.time.Duration; +import java.util.Arrays; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.web.server.Cookie.SameSite; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AdminRefreshTokenCookieManager { + + private static final String PATH = "/"; + + private final AdminRefreshTokenCookieProperties properties; + private final TokenProperties tokenProperties; + + public void setCookie(HttpServletResponse response, String adminRefreshToken) { + Duration tokenExpireTime = tokenProperties.adminRefresh().expireTime(); + long cookieMaxAge = tokenExpireTime.toSeconds(); + setAdminRefreshTokenCookie(response, adminRefreshToken, cookieMaxAge); + } + + public void deleteCookie(HttpServletResponse response) { + setAdminRefreshTokenCookie(response, "", 0); + } + + private void setAdminRefreshTokenCookie( + HttpServletResponse response, String adminRefreshToken, long maxAge + ) { + ResponseCookie cookie = ResponseCookie.from(properties.cookieName(), adminRefreshToken) + .httpOnly(true) + .secure(true) + .path(PATH) + .maxAge(maxAge) + .domain(properties.cookieDomain()) + .sameSite(SameSite.LAX.attributeValue()) + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + public String getAdminRefreshToken(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null || cookies.length == 0) { + throw new CustomException(REFRESH_TOKEN_NOT_EXISTS); + } + + Cookie adminRefreshTokenCookie = Arrays.stream(cookies) + .filter(cookie -> properties.cookieName().equals(cookie.getName())) + .findFirst() + .orElseThrow(() -> new CustomException(REFRESH_TOKEN_NOT_EXISTS)); + + String adminRefreshToken = adminRefreshTokenCookie.getValue(); + if (adminRefreshToken == null || adminRefreshToken.isBlank()) { + throw new CustomException(REFRESH_TOKEN_NOT_EXISTS); + } + return adminRefreshToken; + } +} diff --git a/src/main/java/com/example/solidconnection/admin/auth/controller/config/AdminRefreshTokenCookieProperties.java b/src/main/java/com/example/solidconnection/admin/auth/controller/config/AdminRefreshTokenCookieProperties.java new file mode 100644 index 000000000..492756542 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/auth/controller/config/AdminRefreshTokenCookieProperties.java @@ -0,0 +1,11 @@ +package com.example.solidconnection.admin.auth.controller.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "token.admin-refresh") +public record AdminRefreshTokenCookieProperties( + String cookieName, + String cookieDomain +) { + +} From a08cef1fdb9a08981befd74f690487d101e0a95b Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 8 Jun 2026 10:32:23 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20refre?= =?UTF-8?q?sh=20token=20=EA=B4=80=EB=A0=A8=20error=20code=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AdminRefreshTokenCookieManager.java | 8 ++++---- .../common/exception/CustomExceptionHandler.java | 6 ++++++ .../solidconnection/common/exception/ErrorCode.java | 3 +++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/example/solidconnection/admin/auth/controller/AdminRefreshTokenCookieManager.java b/src/main/java/com/example/solidconnection/admin/auth/controller/AdminRefreshTokenCookieManager.java index 598394c3d..9f167bf68 100644 --- a/src/main/java/com/example/solidconnection/admin/auth/controller/AdminRefreshTokenCookieManager.java +++ b/src/main/java/com/example/solidconnection/admin/auth/controller/AdminRefreshTokenCookieManager.java @@ -1,6 +1,6 @@ package com.example.solidconnection.admin.auth.controller; -import static com.example.solidconnection.common.exception.ErrorCode.REFRESH_TOKEN_NOT_EXISTS; +import static com.example.solidconnection.common.exception.ErrorCode.ADMIN_REFRESH_TOKEN_NOT_EXISTS; import com.example.solidconnection.admin.auth.controller.config.AdminRefreshTokenCookieProperties; import com.example.solidconnection.auth.token.config.TokenProperties; @@ -52,17 +52,17 @@ private void setAdminRefreshTokenCookie( public String getAdminRefreshToken(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); if (cookies == null || cookies.length == 0) { - throw new CustomException(REFRESH_TOKEN_NOT_EXISTS); + throw new CustomException(ADMIN_REFRESH_TOKEN_NOT_EXISTS); } Cookie adminRefreshTokenCookie = Arrays.stream(cookies) .filter(cookie -> properties.cookieName().equals(cookie.getName())) .findFirst() - .orElseThrow(() -> new CustomException(REFRESH_TOKEN_NOT_EXISTS)); + .orElseThrow(() -> new CustomException(ADMIN_REFRESH_TOKEN_NOT_EXISTS)); String adminRefreshToken = adminRefreshTokenCookie.getValue(); if (adminRefreshToken == null || adminRefreshToken.isBlank()) { - throw new CustomException(REFRESH_TOKEN_NOT_EXISTS); + throw new CustomException(ADMIN_REFRESH_TOKEN_NOT_EXISTS); } return adminRefreshToken; } diff --git a/src/main/java/com/example/solidconnection/common/exception/CustomExceptionHandler.java b/src/main/java/com/example/solidconnection/common/exception/CustomExceptionHandler.java index 09057005f..de281a7af 100644 --- a/src/main/java/com/example/solidconnection/common/exception/CustomExceptionHandler.java +++ b/src/main/java/com/example/solidconnection/common/exception/CustomExceptionHandler.java @@ -1,5 +1,6 @@ package com.example.solidconnection.common.exception; +import static com.example.solidconnection.common.exception.ErrorCode.ADMIN_REFRESH_TOKEN_EXPIRED; import static com.example.solidconnection.common.exception.ErrorCode.DATA_INTEGRITY_VIOLATION; import static com.example.solidconnection.common.exception.ErrorCode.INVALID_INPUT; import static com.example.solidconnection.common.exception.ErrorCode.JSON_PARSING_FAILED; @@ -7,6 +8,7 @@ import static com.example.solidconnection.common.exception.ErrorCode.NOT_DEFINED_ERROR; import static com.example.solidconnection.common.exception.ErrorCode.REFRESH_TOKEN_EXPIRED; +import com.example.solidconnection.admin.auth.controller.AdminRefreshTokenCookieManager; import com.example.solidconnection.auth.controller.RefreshTokenCookieManager; import com.example.solidconnection.auth.exception.AuthException; import com.example.solidconnection.common.response.ErrorResponse; @@ -30,6 +32,7 @@ public class CustomExceptionHandler { private final RefreshTokenCookieManager refreshTokenCookieManager; + private final AdminRefreshTokenCookieManager adminRefreshTokenCookieManager; @ExceptionHandler(AuthException.class) protected ResponseEntity handleAuthException( @@ -40,6 +43,9 @@ protected ResponseEntity handleAuthException( if (ex.getErrorCode().equals(REFRESH_TOKEN_EXPIRED)) { refreshTokenCookieManager.deleteCookie(response); } + if (ex.getErrorCode().equals(ADMIN_REFRESH_TOKEN_EXPIRED)) { + adminRefreshTokenCookieManager.deleteCookie(response); + } ErrorResponse errorResponse = new ErrorResponse(ex); return ResponseEntity .status(ex.getCode()) diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index 78b653da8..fd08f34d7 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -67,8 +67,11 @@ public enum ErrorCode { AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED.value(), "인증이 필요한 접근입니다."), ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "액세스 토큰이 만료되었습니다. 재발급 api를 호출해주세요."), REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "리프레시 토큰이 만료되었습니다. 다시 로그인을 진행해주세요."), + ADMIN_REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "어드민 리프레시 토큰이 만료되었습니다. 다시 로그인을 진행해주세요."), ACCESS_DENIED(HttpStatus.FORBIDDEN.value(), "접근 권한이 없습니다."), + NOT_ADMIN_USER(HttpStatus.FORBIDDEN.value(), "어드민 권한이 없는 사용자입니다."), REFRESH_TOKEN_NOT_EXISTS(HttpStatus.BAD_REQUEST.value(), "리프레시 토큰이 존재하지 않습니다."), + ADMIN_REFRESH_TOKEN_NOT_EXISTS(HttpStatus.BAD_REQUEST.value(), "어드민 리프레시 토큰이 존재하지 않습니다."), PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST.value(), "비밀번호가 일치하지 않습니다."), PASSWORD_NOT_CHANGED(HttpStatus.BAD_REQUEST.value(), "현재 비밀번호와 새 비밀번호가 동일합니다."), PASSWORD_NOT_CONFIRMED(HttpStatus.BAD_REQUEST.value(), "새 비밀번호가 일치하지 않습니다."), From 8db2d89bbceb34d5a5b41b319699125be3153b86 Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 8 Jun 2026 10:36:17 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20provider=EC=97=90=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - redis에 ADMIN_REFRESH:{userId} 형태로 토큰 저장 --- .../auth/service/AuthTokenProvider.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java index 0f5e46c23..363ec4dd5 100644 --- a/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java +++ b/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java @@ -3,6 +3,7 @@ import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; import com.example.solidconnection.auth.domain.AccessToken; +import com.example.solidconnection.auth.domain.AdminRefreshToken; import com.example.solidconnection.auth.domain.RefreshToken; import com.example.solidconnection.auth.domain.Subject; import com.example.solidconnection.auth.token.config.TokenProperties; @@ -54,6 +55,16 @@ public RefreshToken generateAndSaveRefreshToken(SiteUser siteUser) { return tokenStorage.saveToken(subject, refreshToken); } + public AdminRefreshToken generateAndSaveAdminRefreshToken(SiteUser siteUser) { + Subject subject = toSubject(siteUser); + String token = tokenProvider.generateToken( + subject, + tokenProperties.adminRefresh().expireTime() + ); + AdminRefreshToken adminRefreshToken = new AdminRefreshToken(token); + return tokenStorage.saveToken(subject, adminRefreshToken); + } + /* * 유효한 리프레시 토큰인지 확인한다. * - 요청된 토큰과 같은 subject 의 리프레시 토큰을 조회한다. @@ -66,11 +77,23 @@ public boolean isValidRefreshToken(String requestedRefreshToken) { .orElse(false); } + public boolean isValidAdminRefreshToken(String requestedAdminRefreshToken) { + Subject subject = tokenProvider.parseSubject(requestedAdminRefreshToken); + return tokenStorage.findToken(subject, AdminRefreshToken.class) + .map(foundToken -> Objects.equals(foundToken, requestedAdminRefreshToken)) + .orElse(false); + } + public void deleteRefreshTokenByAccessToken(String accessToken) { Subject subject = tokenProvider.parseSubject(accessToken); tokenStorage.deleteToken(subject, RefreshToken.class); } + public void deleteAdminRefreshTokenByAccessToken(String accessToken) { + Subject subject = tokenProvider.parseSubject(accessToken); + tokenStorage.deleteToken(subject, AdminRefreshToken.class); + } + public SiteUser parseSiteUser(String token) { Subject subject = tokenProvider.parseSubject(token); long siteUserId = Long.parseLong(subject.value()); From 644dd3eef3bbbc96e3d57fa60acb92dbe3d3d982 Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 8 Jun 2026 10:44:44 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B4=80=EB=A0=A8=20=EB=B9=84?= =?UTF-8?q?=EC=A6=88=EB=8B=88=EC=A6=88=20=EB=A1=9C=EC=A7=81,=20DTO=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관련 테스트 구현 --- .../admin/auth/dto/AdminReissueResponse.java | 12 ++ .../admin/auth/dto/AdminSignInRequest.java | 10 ++ .../admin/auth/dto/AdminSignInResponse.java | 10 ++ .../admin/auth/dto/AdminSignInResult.java | 17 +++ .../admin/auth/service/AdminAuthService.java | 82 ++++++++++ .../auth/service/AdminAuthServiceTest.java | 143 ++++++++++++++++++ 6 files changed, 274 insertions(+) create mode 100644 src/main/java/com/example/solidconnection/admin/auth/dto/AdminReissueResponse.java create mode 100644 src/main/java/com/example/solidconnection/admin/auth/dto/AdminSignInRequest.java create mode 100644 src/main/java/com/example/solidconnection/admin/auth/dto/AdminSignInResponse.java create mode 100644 src/main/java/com/example/solidconnection/admin/auth/dto/AdminSignInResult.java create mode 100644 src/main/java/com/example/solidconnection/admin/auth/service/AdminAuthService.java create mode 100644 src/test/java/com/example/solidconnection/admin/auth/service/AdminAuthServiceTest.java diff --git a/src/main/java/com/example/solidconnection/admin/auth/dto/AdminReissueResponse.java b/src/main/java/com/example/solidconnection/admin/auth/dto/AdminReissueResponse.java new file mode 100644 index 000000000..8a9ceea38 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/auth/dto/AdminReissueResponse.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.admin.auth.dto; + +import com.example.solidconnection.auth.domain.AccessToken; + +public record AdminReissueResponse( + String accessToken +) { + + public static AdminReissueResponse from(AccessToken accessToken) { + return new AdminReissueResponse(accessToken.token()); + } +} diff --git a/src/main/java/com/example/solidconnection/admin/auth/dto/AdminSignInRequest.java b/src/main/java/com/example/solidconnection/admin/auth/dto/AdminSignInRequest.java new file mode 100644 index 000000000..a88ab2b16 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/auth/dto/AdminSignInRequest.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.admin.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +public record AdminSignInRequest( + @NotBlank String email, + @NotBlank String password +) { + +} diff --git a/src/main/java/com/example/solidconnection/admin/auth/dto/AdminSignInResponse.java b/src/main/java/com/example/solidconnection/admin/auth/dto/AdminSignInResponse.java new file mode 100644 index 000000000..97ec0de09 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/auth/dto/AdminSignInResponse.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.admin.auth.dto; + +public record AdminSignInResponse( + String accessToken +) { + + public static AdminSignInResponse from(String accessToken) { + return new AdminSignInResponse(accessToken); + } +} diff --git a/src/main/java/com/example/solidconnection/admin/auth/dto/AdminSignInResult.java b/src/main/java/com/example/solidconnection/admin/auth/dto/AdminSignInResult.java new file mode 100644 index 000000000..562b6af7f --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/auth/dto/AdminSignInResult.java @@ -0,0 +1,17 @@ +package com.example.solidconnection.admin.auth.dto; + +import com.example.solidconnection.auth.domain.AccessToken; +import com.example.solidconnection.auth.domain.AdminRefreshToken; + +public record AdminSignInResult( + String accessToken, + String adminRefreshToken +) { + + public static AdminSignInResult of( + AccessToken accessToken, + AdminRefreshToken adminRefreshToken + ) { + return new AdminSignInResult(accessToken.token(), adminRefreshToken.token()); + } +} diff --git a/src/main/java/com/example/solidconnection/admin/auth/service/AdminAuthService.java b/src/main/java/com/example/solidconnection/admin/auth/service/AdminAuthService.java new file mode 100644 index 000000000..91b340db4 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/auth/service/AdminAuthService.java @@ -0,0 +1,82 @@ +package com.example.solidconnection.admin.auth.service; + +import static com.example.solidconnection.common.exception.ErrorCode.ADMIN_REFRESH_TOKEN_EXPIRED; +import static com.example.solidconnection.common.exception.ErrorCode.NOT_ADMIN_USER; +import static com.example.solidconnection.common.exception.ErrorCode.SIGN_IN_FAILED; + +import com.example.solidconnection.admin.auth.dto.AdminReissueResponse; +import com.example.solidconnection.admin.auth.dto.AdminSignInRequest; +import com.example.solidconnection.admin.auth.dto.AdminSignInResult; +import com.example.solidconnection.auth.domain.AccessToken; +import com.example.solidconnection.auth.domain.AdminRefreshToken; +import com.example.solidconnection.auth.exception.AuthException; +import com.example.solidconnection.auth.service.AuthTokenProvider; +import com.example.solidconnection.auth.token.TokenBlackListService; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.Role; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminAuthService { + + private final AuthTokenProvider authTokenProvider; + private final TokenBlackListService tokenBlackListService; + private final SiteUserRepository siteUserRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public AdminSignInResult signIn(AdminSignInRequest request) { + SiteUser siteUser = getEmailMatchingUserOrThrow(request.email()); + validatePassword(request.password(), siteUser.getPassword()); + validateAdminRole(siteUser); + resetQuitedAt(siteUser); + AccessToken accessToken = authTokenProvider.generateAccessToken(siteUser); + AdminRefreshToken adminRefreshToken = authTokenProvider.generateAndSaveAdminRefreshToken(siteUser); + return AdminSignInResult.of(accessToken, adminRefreshToken); + } + + private SiteUser getEmailMatchingUserOrThrow(String email) { + return siteUserRepository.findByEmailAndAuthType(email, AuthType.EMAIL) + .orElseThrow(() -> new CustomException(SIGN_IN_FAILED)); + } + + private void validatePassword(String rawPassword, String encodedPassword) { + if (!passwordEncoder.matches(rawPassword, encodedPassword)) { + throw new CustomException(SIGN_IN_FAILED); + } + } + + private void validateAdminRole(SiteUser siteUser) { + if (!Role.ADMIN.equals(siteUser.getRole())) { + throw new CustomException(NOT_ADMIN_USER); + } + } + + private void resetQuitedAt(SiteUser siteUser) { + if (siteUser.getQuitedAt() == null) { + return; + } + siteUser.setQuitedAt(null); + } + + public AdminReissueResponse reissue(String requestedAdminRefreshToken) { + if (!authTokenProvider.isValidAdminRefreshToken(requestedAdminRefreshToken)) { + throw new AuthException(ADMIN_REFRESH_TOKEN_EXPIRED); + } + SiteUser siteUser = authTokenProvider.parseSiteUser(requestedAdminRefreshToken); + AccessToken newAccessToken = authTokenProvider.generateAccessToken(siteUser); + return AdminReissueResponse.from(newAccessToken); + } + + public void signOut(String accessToken) { + tokenBlackListService.addToBlacklist(accessToken); + authTokenProvider.deleteAdminRefreshTokenByAccessToken(accessToken); + } +} diff --git a/src/test/java/com/example/solidconnection/admin/auth/service/AdminAuthServiceTest.java b/src/test/java/com/example/solidconnection/admin/auth/service/AdminAuthServiceTest.java new file mode 100644 index 000000000..ded695bf1 --- /dev/null +++ b/src/test/java/com/example/solidconnection/admin/auth/service/AdminAuthServiceTest.java @@ -0,0 +1,143 @@ +package com.example.solidconnection.admin.auth.service; + +import static com.example.solidconnection.common.exception.ErrorCode.ADMIN_REFRESH_TOKEN_EXPIRED; +import static com.example.solidconnection.common.exception.ErrorCode.NOT_ADMIN_USER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.admin.auth.dto.AdminReissueResponse; +import com.example.solidconnection.admin.auth.dto.AdminSignInRequest; +import com.example.solidconnection.admin.auth.dto.AdminSignInResult; +import com.example.solidconnection.auth.domain.AdminRefreshToken; +import com.example.solidconnection.auth.domain.Subject; +import com.example.solidconnection.auth.exception.AuthException; +import com.example.solidconnection.auth.service.AuthTokenProvider; +import com.example.solidconnection.auth.service.TokenProvider; +import com.example.solidconnection.auth.service.TokenStorage; +import com.example.solidconnection.auth.token.TokenBlackListService; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("어드민 인증 서비스 테스트") +@TestContainerSpringBootTest +class AdminAuthServiceTest { + + @Autowired + private AdminAuthService adminAuthService; + + @Autowired + private AuthTokenProvider authTokenProvider; + + @Autowired + private TokenProvider tokenProvider; + + @Autowired + private TokenStorage tokenStorage; + + @Autowired + private TokenBlackListService tokenBlackListService; + + @Autowired + private SiteUserFixture siteUserFixture; + + private SiteUser adminUser; + private SiteUser regularUser; + + @BeforeEach + void setUp() { + adminUser = siteUserFixture.관리자(); + regularUser = siteUserFixture.사용자(); + } + + @Nested + class 어드민_로그인 { + + @Test + void 어드민_사용자가_로그인하면_어드민_리프레시_토큰이_저장된다() { + // given + AdminSignInRequest request = new AdminSignInRequest("admin@example.com", "admin123"); + + // when + AdminSignInResult result = adminAuthService.signIn(request); + + // then + Subject subject = new Subject(adminUser.getId().toString()); + assertAll( + () -> assertThat(result.accessToken()).isNotNull(), + () -> assertThat(result.adminRefreshToken()).isNotNull(), + () -> assertThat(tokenStorage.findToken(subject, AdminRefreshToken.class)) + .hasValue(result.adminRefreshToken()) + ); + } + + @Test + void 어드민_권한이_없는_사용자가_로그인하면_예외가_발생한다() { + // given + AdminSignInRequest request = new AdminSignInRequest("test@example.com", "password123"); + + // when & then + assertThatCode(() -> adminAuthService.signIn(request)) + .isInstanceOf(CustomException.class) + .hasMessage(NOT_ADMIN_USER.getMessage()); + } + } + + @Nested + class 어드민_토큰_재발급 { + + @Test + void 저장된_어드민_리프레시_토큰으로_액세스_토큰을_재발급한다() { + // given + AdminRefreshToken adminRefreshToken = authTokenProvider.generateAndSaveAdminRefreshToken(adminUser); + + // when + AdminReissueResponse response = adminAuthService.reissue(adminRefreshToken.token()); + + // then - 재발급된 액세스 토큰과 어드민 리프레시 토큰의 주체가 동일해야 한다 + SiteUser tokenSiteUser = authTokenProvider.parseSiteUser(adminRefreshToken.token()); + SiteUser reissuedSiteUser = authTokenProvider.parseSiteUser(response.accessToken()); + assertThat(tokenSiteUser.getId()).isEqualTo(reissuedSiteUser.getId()); + } + + @Test + void 저장되지_않은_어드민_리프레시_토큰으로_재발급하면_예외가_발생한다() { + // given + AdminRefreshToken adminRefreshToken = authTokenProvider.generateAndSaveAdminRefreshToken(adminUser); + tokenStorage.deleteToken(new Subject(adminUser.getId().toString()), AdminRefreshToken.class); + + // when & then + assertThatCode(() -> adminAuthService.reissue(adminRefreshToken.token())) + .isInstanceOf(AuthException.class) + .hasMessage(ADMIN_REFRESH_TOKEN_EXPIRED.getMessage()); + } + } + + @Nested + class 어드민_로그아웃 { + + @Test + void 로그아웃하면_어드민_리프레시_토큰이_삭제되고_액세스_토큰이_블랙리스트에_추가된다() { + // given + String accessToken = authTokenProvider.generateAccessToken(adminUser).token(); + authTokenProvider.generateAndSaveAdminRefreshToken(adminUser); + Subject subject = new Subject(adminUser.getId().toString()); + + // when + adminAuthService.signOut(accessToken); + + // then + assertAll( + () -> assertThat(tokenStorage.findToken(subject, AdminRefreshToken.class)).isEmpty(), + () -> assertThat(tokenBlackListService.isTokenBlacklisted(accessToken)).isTrue() + ); + } + } +} From bf17d4918320d5b88ab81821c43e369cfc4c9ad0 Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 8 Jun 2026 10:48:39 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B4=80=EB=A0=A8=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AdminAuthController.java | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/main/java/com/example/solidconnection/admin/auth/controller/AdminAuthController.java diff --git a/src/main/java/com/example/solidconnection/admin/auth/controller/AdminAuthController.java b/src/main/java/com/example/solidconnection/admin/auth/controller/AdminAuthController.java new file mode 100644 index 000000000..364889646 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/auth/controller/AdminAuthController.java @@ -0,0 +1,63 @@ +package com.example.solidconnection.admin.auth.controller; + +import com.example.solidconnection.admin.auth.dto.AdminReissueResponse; +import com.example.solidconnection.admin.auth.dto.AdminSignInRequest; +import com.example.solidconnection.admin.auth.dto.AdminSignInResponse; +import com.example.solidconnection.admin.auth.dto.AdminSignInResult; +import com.example.solidconnection.admin.auth.service.AdminAuthService; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/admin/auth") +@RequiredArgsConstructor +public class AdminAuthController { + + private final AdminAuthService adminAuthService; + private final AdminRefreshTokenCookieManager adminRefreshTokenCookieManager; + + @PostMapping("/sign-in") + public ResponseEntity signIn( + @RequestBody @Valid AdminSignInRequest request, + HttpServletResponse response + ) { + AdminSignInResult result = adminAuthService.signIn(request); + adminRefreshTokenCookieManager.setCookie(response, result.adminRefreshToken()); + return ResponseEntity.ok(AdminSignInResponse.from(result.accessToken())); + } + + @PostMapping("/reissue") + public ResponseEntity reissue(HttpServletRequest request) { + String adminRefreshToken = adminRefreshTokenCookieManager.getAdminRefreshToken(request); + AdminReissueResponse reissueResponse = adminAuthService.reissue(adminRefreshToken); + return ResponseEntity.ok(reissueResponse); + } + + @PostMapping("/sign-out") + public ResponseEntity signOut( + Authentication authentication, + HttpServletResponse response + ) { + String accessToken = getAccessToken(authentication); + adminAuthService.signOut(accessToken); + adminRefreshTokenCookieManager.deleteCookie(response); + return ResponseEntity.ok().build(); + } + + private String getAccessToken(Authentication authentication) { + if (authentication == null || !(authentication.getCredentials() instanceof String accessToken)) { + throw new CustomException(ErrorCode.AUTHENTICATION_FAILED, "엑세스 토큰이 없습니다."); + } + return accessToken; + } +} From 563c0f1f94318d59597406c592da803bcdb7bc91 Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 8 Jun 2026 10:53:18 +0900 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B4=80=EB=A0=A8=EC=9D=80=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=97=86=EC=9D=B4=20=EC=A0=91=EA=B7=BC=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=8A=A4?= =?UTF-8?q?=ED=94=84=EB=A7=81=20=EC=8B=9C=ED=81=90=EB=A6=AC=ED=8B=B0=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../solidconnection/security/config/SecurityConfiguration.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/example/solidconnection/security/config/SecurityConfiguration.java b/src/main/java/com/example/solidconnection/security/config/SecurityConfiguration.java index 706fedd52..26acbe59a 100644 --- a/src/main/java/com/example/solidconnection/security/config/SecurityConfiguration.java +++ b/src/main/java/com/example/solidconnection/security/config/SecurityConfiguration.java @@ -63,6 +63,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/connect/**").authenticated() + .requestMatchers("/admin/auth/**").permitAll() .requestMatchers("/admin/**").hasRole(ADMIN.name()) .anyRequest().permitAll() )