diff --git a/.github/workflows/backend-test-when-pr.yml b/.github/workflows/backend-test-when-pr.yml index a9a8a7a9..72c737b2 100644 --- a/.github/workflows/backend-test-when-pr.yml +++ b/.github/workflows/backend-test-when-pr.yml @@ -18,6 +18,18 @@ jobs: defaults: run: working-directory: ./ + + services: + redis: + image: redis + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + steps: - uses: actions/checkout@v3 - name: Set up JDK 17 diff --git a/build.gradle b/build.gradle index df9723ad..f9978c7b 100644 --- a/build.gradle +++ b/build.gradle @@ -42,6 +42,7 @@ dependencies { // db runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' // flyway implementation 'org.flywaydb:flyway-core' diff --git a/src/docs/asciidoc/admin.adoc b/src/docs/asciidoc/admin.adoc index 07e7ef82..b88dfe38 100644 --- a/src/docs/asciidoc/admin.adoc +++ b/src/docs/asciidoc/admin.adoc @@ -40,3 +40,13 @@ include::{snippets}/admin-auth-controller-test/관리자_액세스_토큰_재발 ==== 응답 include::{snippets}/admin-auth-controller-test/관리자_액세스_토큰_재발행/http-response.adoc[] include::{snippets}/admin-auth-controller-test/관리자_액세스_토큰_재발행/response-fields.adoc[] + + +=== 로그아웃 (DELETE api/admins/auth/logout) + +==== 요청 +include::{snippets}/admin-auth-controller-test/관리자_로그아웃/http-request.adoc[] +include::{snippets}/admin-auth-controller-test/관리자_로그아웃/request-headers.adoc[] + +==== 응답 +include::{snippets}/admin-auth-controller-test/관리자_로그아웃/http-response.adoc[] diff --git a/src/main/java/com/atwoz/admin/application/auth/AdminAccessTokenProvider.java b/src/main/java/com/atwoz/admin/application/auth/AdminAccessTokenProvider.java new file mode 100644 index 00000000..1efea379 --- /dev/null +++ b/src/main/java/com/atwoz/admin/application/auth/AdminAccessTokenProvider.java @@ -0,0 +1,6 @@ +package com.atwoz.admin.application.auth; + +public interface AdminAccessTokenProvider { + + String createAccessToken(Long id); +} diff --git a/src/main/java/com/atwoz/admin/application/auth/AdminAuthService.java b/src/main/java/com/atwoz/admin/application/auth/AdminAuthService.java index cc9ba182..80f63736 100644 --- a/src/main/java/com/atwoz/admin/application/auth/AdminAuthService.java +++ b/src/main/java/com/atwoz/admin/application/auth/AdminAuthService.java @@ -6,9 +6,12 @@ import com.atwoz.admin.application.auth.dto.AdminSignUpRequest; import com.atwoz.admin.application.auth.dto.AdminTokenResponse; import com.atwoz.admin.domain.admin.Admin; +import com.atwoz.admin.domain.admin.AdminRefreshToken; +import com.atwoz.admin.domain.admin.AdminRefreshTokenRepository; import com.atwoz.admin.domain.admin.AdminRepository; -import com.atwoz.admin.domain.admin.AdminTokenProvider; +import com.atwoz.admin.domain.admin.service.AdminRefreshTokenProvider; import com.atwoz.admin.exception.exceptions.AdminNotFoundException; +import com.atwoz.admin.exception.exceptions.InvalidRefreshTokenException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,43 +21,48 @@ @Transactional public class AdminAuthService { - private static final String ID = "id"; - private final AdminRepository adminRepository; - private final AdminTokenProvider adminTokenProvider; + private final AdminAccessTokenProvider adminAccessTokenProvider; + private final AdminRefreshTokenProvider adminRefreshTokenProvider; + private final AdminRefreshTokenRepository adminRefreshTokenRepository; public AdminTokenResponse signUp(final AdminSignUpRequest adminSignUpRequest) { + Admin admin = createAdmin(adminSignUpRequest); + Admin savedAdmin = adminRepository.save(admin); + AdminRefreshToken adminRefreshToken = createAdminRefreshToken(savedAdmin); + adminRefreshTokenRepository.save(adminRefreshToken); + String accessToken = adminAccessTokenProvider.createAccessToken(savedAdmin.getId()); + + return new AdminTokenResponse(accessToken, adminRefreshToken.refreshToken()); + } + + private Admin createAdmin(final AdminSignUpRequest adminSignUpRequest) { AdminProfileSignUpRequest adminProfileSignUpRequest = adminSignUpRequest.adminProfileSignUpRequest(); - Admin admin = Admin.createWith( + return Admin.createWith( adminSignUpRequest.email(), adminSignUpRequest.password(), adminSignUpRequest.confirmPassword(), adminProfileSignUpRequest.name(), adminProfileSignUpRequest.phoneNumber() ); - Admin savedAdmin = adminRepository.save(admin); - - return createAdminTokenResponse(savedAdmin.getId()); } - private AdminTokenResponse createAdminTokenResponse(final Long id) { - return new AdminTokenResponse( - adminTokenProvider.createAccessToken(id), - adminTokenProvider.createRefreshToken(id) + private AdminRefreshToken createAdminRefreshToken(final Admin savedAdmin) { + return AdminRefreshToken.createWith( + adminRefreshTokenProvider, + savedAdmin.getEmail(), + savedAdmin.getId() ); } public AdminTokenResponse login(final AdminLoginRequest adminLoginRequest) { Admin foundAdmin = findAdminByEmail(adminLoginRequest.email()); foundAdmin.validatePassword(adminLoginRequest.password()); + AdminRefreshToken adminRefreshToken = createAdminRefreshToken(foundAdmin); + adminRefreshTokenRepository.save(adminRefreshToken); + String accessToken = adminAccessTokenProvider.createAccessToken(foundAdmin.getId()); - return createAdminTokenResponse(foundAdmin.getId()); - } - - public AdminAccessTokenResponse reGenerateAccessToken(final String refreshToken) { - Admin foundAdmin = findAdminById(adminTokenProvider.extract(refreshToken, ID, Long.class)); - - return new AdminAccessTokenResponse(adminTokenProvider.createAccessToken(foundAdmin.getId())); + return new AdminTokenResponse(accessToken, adminRefreshToken.refreshToken()); } private Admin findAdminByEmail(final String email) { @@ -62,8 +70,16 @@ private Admin findAdminByEmail(final String email) { .orElseThrow(AdminNotFoundException::new); } - private Admin findAdminById(final Long id) { - return adminRepository.findAdminById(id) - .orElseThrow(AdminNotFoundException::new); + public AdminAccessTokenResponse reGenerateAccessToken(final String refreshToken) { + AdminRefreshToken foundAdminRefreshToken = adminRefreshTokenRepository.findById(refreshToken) + .orElseThrow(InvalidRefreshTokenException::new); + Long memberId = foundAdminRefreshToken.memberId(); + String createdAccessToken = adminAccessTokenProvider.createAccessToken(memberId); + + return new AdminAccessTokenResponse(createdAccessToken); + } + + public void logout(final String refreshToken) { + adminRefreshTokenRepository.delete(refreshToken); } } diff --git a/src/main/java/com/atwoz/admin/domain/admin/AdminRefreshToken.java b/src/main/java/com/atwoz/admin/domain/admin/AdminRefreshToken.java new file mode 100644 index 00000000..1e375147 --- /dev/null +++ b/src/main/java/com/atwoz/admin/domain/admin/AdminRefreshToken.java @@ -0,0 +1,21 @@ +package com.atwoz.admin.domain.admin; + +import com.atwoz.admin.domain.admin.service.AdminRefreshTokenProvider; +import lombok.Builder; +import org.springframework.data.annotation.Id; + +@Builder +public record AdminRefreshToken( + @Id String refreshToken, + Long memberId +) { + + public static AdminRefreshToken createWith(final AdminRefreshTokenProvider adminRefreshTokenProvider, + final String email, + final Long memberId) { + return AdminRefreshToken.builder() + .refreshToken(adminRefreshTokenProvider.createRefreshToken(email)) + .memberId(memberId) + .build(); + } +} diff --git a/src/main/java/com/atwoz/admin/domain/admin/AdminRefreshTokenRepository.java b/src/main/java/com/atwoz/admin/domain/admin/AdminRefreshTokenRepository.java new file mode 100644 index 00000000..a1f870ba --- /dev/null +++ b/src/main/java/com/atwoz/admin/domain/admin/AdminRefreshTokenRepository.java @@ -0,0 +1,12 @@ +package com.atwoz.admin.domain.admin; + +import java.util.Optional; + +public interface AdminRefreshTokenRepository { + + void save(AdminRefreshToken adminRefreshToken); + + Optional findById(String refreshToken); + + void delete(String refreshToken); +} diff --git a/src/main/java/com/atwoz/admin/domain/admin/AdminTokenProvider.java b/src/main/java/com/atwoz/admin/domain/admin/AdminTokenProvider.java deleted file mode 100644 index 427d63b5..00000000 --- a/src/main/java/com/atwoz/admin/domain/admin/AdminTokenProvider.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.atwoz.admin.domain.admin; - -public interface AdminTokenProvider { - - String createAccessToken(Long id); - - String createRefreshToken(Long id); - - T extract(String token, String claimName, Class classType); -} diff --git a/src/main/java/com/atwoz/admin/domain/admin/service/AdminRefreshTokenProvider.java b/src/main/java/com/atwoz/admin/domain/admin/service/AdminRefreshTokenProvider.java new file mode 100644 index 00000000..e3f1c09b --- /dev/null +++ b/src/main/java/com/atwoz/admin/domain/admin/service/AdminRefreshTokenProvider.java @@ -0,0 +1,6 @@ +package com.atwoz.admin.domain.admin.service; + +public interface AdminRefreshTokenProvider { + + String createRefreshToken(String email); +} diff --git a/src/main/java/com/atwoz/admin/exception/AdminExceptionHandler.java b/src/main/java/com/atwoz/admin/exception/AdminExceptionHandler.java index 29f8bd93..6c7b8883 100644 --- a/src/main/java/com/atwoz/admin/exception/AdminExceptionHandler.java +++ b/src/main/java/com/atwoz/admin/exception/AdminExceptionHandler.java @@ -3,6 +3,7 @@ import com.atwoz.admin.exception.exceptions.AdminLoginInvalidException; import com.atwoz.admin.exception.exceptions.AdminNotFoundException; import com.atwoz.admin.exception.exceptions.InvalidPasswordException; +import com.atwoz.admin.exception.exceptions.InvalidRefreshTokenException; import com.atwoz.admin.exception.exceptions.PasswordMismatchException; import com.atwoz.admin.exception.exceptions.UnauthorizedAccessToAdminException; import org.springframework.http.HttpStatus; @@ -38,6 +39,11 @@ public ResponseEntity handleAdminLoginInvalidException(final AdminLoginI return getUnauthorized(e); } + @ExceptionHandler(InvalidRefreshTokenException.class) + public ResponseEntity handleRefreshTokenInvalidException(final InvalidRefreshTokenException e) { + return getUnauthorized(e); + } + private ResponseEntity getNotFoundResponse(final Exception e) { return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(e.getMessage()); diff --git a/src/main/java/com/atwoz/admin/exception/exceptions/InvalidRefreshTokenException.java b/src/main/java/com/atwoz/admin/exception/exceptions/InvalidRefreshTokenException.java new file mode 100644 index 00000000..0af8851e --- /dev/null +++ b/src/main/java/com/atwoz/admin/exception/exceptions/InvalidRefreshTokenException.java @@ -0,0 +1,8 @@ +package com.atwoz.admin.exception.exceptions; + +public class InvalidRefreshTokenException extends RuntimeException { + + public InvalidRefreshTokenException() { + super("리프레쉬 토큰이 유효하지 않습니다"); + } +} diff --git a/src/main/java/com/atwoz/admin/infrastructure/auth/AdminJwtTokenProvider.java b/src/main/java/com/atwoz/admin/infrastructure/auth/AdminJwtTokenProvider.java index 0ee81c0c..6c2f07d8 100644 --- a/src/main/java/com/atwoz/admin/infrastructure/auth/AdminJwtTokenProvider.java +++ b/src/main/java/com/atwoz/admin/infrastructure/auth/AdminJwtTokenProvider.java @@ -1,6 +1,8 @@ package com.atwoz.admin.infrastructure.auth; -import com.atwoz.admin.domain.admin.AdminTokenProvider; +import com.atwoz.admin.application.auth.AdminAccessTokenProvider; +import com.atwoz.admin.domain.admin.service.AdminRefreshTokenProvider; +import com.atwoz.admin.ui.auth.support.AdminTokenExtractor; import com.atwoz.member.exception.exceptions.auth.ExpiredTokenException; import com.atwoz.member.exception.exceptions.auth.SignatureInvalidException; import com.atwoz.member.exception.exceptions.auth.TokenFormInvalidException; @@ -24,9 +26,12 @@ @NoArgsConstructor @Component -public class AdminJwtTokenProvider implements AdminTokenProvider { +public class AdminJwtTokenProvider implements AdminAccessTokenProvider, + AdminRefreshTokenProvider, + AdminTokenExtractor { private static final String ID = "id"; + private static final String EMAIL = "email"; private static final String TOKEN_TYPE = "token type"; private static final String REFRESH_TOKEN = "refresh token"; private static final String ACCESS_TOKEN = "access token"; @@ -60,16 +65,17 @@ public String createAccessToken(final Long id) { } @Override - public String createRefreshToken(final Long id) { + public String createRefreshToken(final String email) { Claims claims = Jwts.claims(); - claims.put(ID, id); + claims.put(EMAIL, email); claims.put(TOKEN_TYPE, REFRESH_TOKEN); claims.put(ROLE, ADMIN); return createToken(claims, refreshTokenExpirationPeriod); } - private String createToken(final Claims claims, final int expirationPeriod) { + private String createToken(final Claims claims, + final int expirationPeriod) { return Jwts.builder() .setClaims(claims) .setIssuedAt(issuedAt()) @@ -94,7 +100,9 @@ private Date expiredAt(final int expirationPeriod) { } @Override - public T extract(final String token, final String claimName, final Class classType) { + public T extract(final String token, + final String claimName, + final Class classType) { try { return Jwts.parserBuilder() .setSigningKey(secret.getBytes()) diff --git a/src/main/java/com/atwoz/admin/infrastructure/auth/AdminRedisRefreshTokenRepository.java b/src/main/java/com/atwoz/admin/infrastructure/auth/AdminRedisRefreshTokenRepository.java new file mode 100644 index 00000000..66703c5c --- /dev/null +++ b/src/main/java/com/atwoz/admin/infrastructure/auth/AdminRedisRefreshTokenRepository.java @@ -0,0 +1,45 @@ +package com.atwoz.admin.infrastructure.auth; + +import com.atwoz.admin.domain.admin.AdminRefreshToken; +import com.atwoz.admin.domain.admin.AdminRefreshTokenRepository; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Repository; + +@RequiredArgsConstructor +@Repository +public class AdminRedisRefreshTokenRepository implements AdminRefreshTokenRepository { + + @Value("${redis.expiration-period}") + private int expirationPeriod; + + private final RedisTemplate redisTemplate; + + @Override + public void save(final AdminRefreshToken adminRefreshToken) { + ValueOperations valueOperations = redisTemplate.opsForValue(); + valueOperations.set(adminRefreshToken.refreshToken(), adminRefreshToken.memberId()); + redisTemplate.expire(adminRefreshToken.refreshToken(), expirationPeriod, TimeUnit.DAYS); + } + + @Override + public Optional findById(final String refreshToken) { + ValueOperations valueOperations = redisTemplate.opsForValue(); + Long memberId = valueOperations.get(refreshToken); + if (Objects.isNull(memberId)) { + return Optional.empty(); + } + + return Optional.of(new AdminRefreshToken(refreshToken, memberId)); + } + + @Override + public void delete(final String refreshToken) { + redisTemplate.delete(refreshToken); + } +} diff --git a/src/main/java/com/atwoz/admin/ui/auth/AdminAuthController.java b/src/main/java/com/atwoz/admin/ui/auth/AdminAuthController.java index 8f162f8a..5bb8438b 100644 --- a/src/main/java/com/atwoz/admin/ui/auth/AdminAuthController.java +++ b/src/main/java/com/atwoz/admin/ui/auth/AdminAuthController.java @@ -12,6 +12,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -56,6 +57,14 @@ public ResponseEntity reGenerateAccessToken( return ResponseEntity.ok(adminAuthService.reGenerateAccessToken(refreshToken)); } + @DeleteMapping("/logout") + public ResponseEntity logout(@AdminRefreshToken final String refreshToken) { + adminAuthService.logout(refreshToken); + + return ResponseEntity.ok() + .build(); + } + private HttpHeaders createCookieHeaders(final String refreshToken) { ResponseCookie cookie = ResponseCookie.from(COOKIE_NAME, refreshToken) @@ -65,7 +74,7 @@ private HttpHeaders createCookieHeaders(final String refreshToken) { .maxAge(maxAge) .build(); HttpHeaders httpHeaders = new HttpHeaders(); - httpHeaders.add(HttpHeaders.COOKIE, cookie.toString()); + httpHeaders.add(HttpHeaders.SET_COOKIE, cookie.toString()); return httpHeaders; } diff --git a/src/main/java/com/atwoz/admin/ui/auth/interceptor/AdminLoginValidCheckerInterceptor.java b/src/main/java/com/atwoz/admin/ui/auth/interceptor/AdminLoginValidCheckerInterceptor.java index d8785050..00756c92 100644 --- a/src/main/java/com/atwoz/admin/ui/auth/interceptor/AdminLoginValidCheckerInterceptor.java +++ b/src/main/java/com/atwoz/admin/ui/auth/interceptor/AdminLoginValidCheckerInterceptor.java @@ -1,10 +1,10 @@ package com.atwoz.admin.ui.auth.interceptor; -import com.atwoz.admin.domain.admin.AdminTokenProvider; import com.atwoz.admin.exception.exceptions.AdminLoginInvalidException; import com.atwoz.admin.exception.exceptions.UnauthorizedAccessToAdminException; import com.atwoz.admin.ui.auth.support.AdminAuthenticationContext; import com.atwoz.admin.ui.auth.support.AdminAuthenticationExtractor; +import com.atwoz.admin.ui.auth.support.AdminTokenExtractor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -21,7 +21,7 @@ public class AdminLoginValidCheckerInterceptor implements HandlerInterceptor { private final AdminAuthenticationContext adminAuthenticationContext; private final AdminAuthenticationExtractor adminAuthenticationExtractor; - private final AdminTokenProvider adminTokenProvider; + private final AdminTokenExtractor adminTokenExtractor; @Override public boolean preHandle(final HttpServletRequest request, @@ -29,12 +29,12 @@ public boolean preHandle(final HttpServletRequest request, final Object handler) throws Exception { String token = adminAuthenticationExtractor.extractFromRequest(request) .orElseThrow(AdminLoginInvalidException::new); - String extractedRole = adminTokenProvider.extract(token, ROLE, String.class); + String extractedRole = adminTokenExtractor.extract(token, ROLE, String.class); if (!extractedRole.equals(ADMIN)) { throw new UnauthorizedAccessToAdminException(); } - Long extractedId = adminTokenProvider.extract(token, ADMIN_ID, Long.class); + Long extractedId = adminTokenExtractor.extract(token, ADMIN_ID, Long.class); adminAuthenticationContext.setAuthentication(extractedId); return true; diff --git a/src/main/java/com/atwoz/admin/ui/auth/support/AdminTokenExtractor.java b/src/main/java/com/atwoz/admin/ui/auth/support/AdminTokenExtractor.java new file mode 100644 index 00000000..174da045 --- /dev/null +++ b/src/main/java/com/atwoz/admin/ui/auth/support/AdminTokenExtractor.java @@ -0,0 +1,6 @@ +package com.atwoz.admin.ui.auth.support; + +public interface AdminTokenExtractor { + + T extract(String token, String claimName, Class classType); +} diff --git a/src/main/java/com/atwoz/admin/ui/auth/support/resolver/AdminRefreshTokenExtractionArgumentResolver.java b/src/main/java/com/atwoz/admin/ui/auth/support/resolver/AdminRefreshTokenExtractionArgumentResolver.java index e137c71e..ec029a27 100644 --- a/src/main/java/com/atwoz/admin/ui/auth/support/resolver/AdminRefreshTokenExtractionArgumentResolver.java +++ b/src/main/java/com/atwoz/admin/ui/auth/support/resolver/AdminRefreshTokenExtractionArgumentResolver.java @@ -1,9 +1,9 @@ package com.atwoz.admin.ui.auth.support.resolver; -import com.atwoz.admin.domain.admin.AdminTokenProvider; import com.atwoz.admin.exception.exceptions.AdminLoginInvalidException; import com.atwoz.admin.exception.exceptions.UnauthorizedAccessToAdminException; import com.atwoz.admin.ui.auth.support.AdminRefreshToken; +import com.atwoz.admin.ui.auth.support.AdminTokenExtractor; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import java.util.Arrays; @@ -24,7 +24,7 @@ public class AdminRefreshTokenExtractionArgumentResolver implements HandlerMetho private static final String ROLE = "role"; private static final String ADMIN = "admin"; - private final AdminTokenProvider adminTokenProvider; + private final AdminTokenExtractor adminTokenExtractor; @Override public boolean supportsParameter(final MethodParameter parameter) { @@ -43,7 +43,7 @@ public Object resolveArgument(final MethodParameter parameter, } String refreshToken = findRefreshToken(request.getCookies()); - String role = adminTokenProvider.extract(refreshToken, ROLE, String.class); + String role = adminTokenExtractor.extract(refreshToken, ROLE, String.class); if (!Objects.equals(role, ADMIN)) { throw new UnauthorizedAccessToAdminException(); } diff --git a/src/main/java/com/atwoz/global/config/RedisConfig.java b/src/main/java/com/atwoz/global/config/RedisConfig.java new file mode 100644 index 00000000..01e70cee --- /dev/null +++ b/src/main/java/com/atwoz/global/config/RedisConfig.java @@ -0,0 +1,32 @@ +package com.atwoz.global.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@RequiredArgsConstructor +@Configuration +public class RedisConfig { + + private final RedisProperties redisProperties; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort()); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Long.class)); + return redisTemplate; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 74125ae3..d46d1a2a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,13 +4,16 @@ server: spring: profiles: active: local + datasource: hikari: maximum-pool-size: 5 + task: scheduling: pool: size: 10 + servlet: multipart: max-file-size: 10MB @@ -29,6 +32,9 @@ jwt: access-token-expiration-period: ENC(46U7S/gH1lH6kpaOwhVaow==) refresh-token-expiration-period: ENC(kb9YVXMB9HqUuxoxcnMHXw==) +redis: + expiration-period: ENC(wFlCe4YyS4e4YGsFGvYAkA==) + mail: host: ENC(2PviuayFL6dKe91WydIBx81bpSBoI3FU) username: ENC(Hj0Qwzuifugz4sfvDCq9H0u7+WZC4nL+) diff --git a/src/test/java/com/atwoz/admin/application/auth/AdminAuthServiceTest.java b/src/test/java/com/atwoz/admin/application/auth/AdminAuthServiceTest.java index eff1aafb..acea84cf 100644 --- a/src/test/java/com/atwoz/admin/application/auth/AdminAuthServiceTest.java +++ b/src/test/java/com/atwoz/admin/application/auth/AdminAuthServiceTest.java @@ -4,11 +4,16 @@ import com.atwoz.admin.application.auth.dto.AdminLoginRequest; import com.atwoz.admin.application.auth.dto.AdminSignUpRequest; import com.atwoz.admin.application.auth.dto.AdminTokenResponse; +import com.atwoz.admin.domain.admin.AdminRefreshToken; +import com.atwoz.admin.domain.admin.AdminRefreshTokenRepository; import com.atwoz.admin.domain.admin.AdminRepository; -import com.atwoz.admin.domain.admin.AdminTokenProvider; +import com.atwoz.admin.domain.admin.service.AdminRefreshTokenProvider; import com.atwoz.admin.exception.exceptions.AdminNotFoundException; import com.atwoz.admin.exception.exceptions.InvalidPasswordException; +import com.atwoz.admin.exception.exceptions.InvalidRefreshTokenException; +import com.atwoz.admin.infrastructure.admin.AdminFakeRefreshTokenRepository; import com.atwoz.admin.infrastructure.admin.AdminFakeRepository; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; @@ -19,6 +24,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import static com.atwoz.admin.fixture.AdminFixture.관리자_생성; +import static com.atwoz.admin.fixture.AdminRefreshTokenFixture.관리자_리프레쉬_토큰_생성; import static com.atwoz.admin.fixture.AdminRequestFixture.관리자_로그인_요청; import static com.atwoz.admin.fixture.AdminRequestFixture.관리자_회원_가입_요청; import static com.atwoz.admin.fixture.AdminTokenResponseFixture.관리자_액세스_토큰_생성_응답; @@ -36,17 +42,25 @@ class AdminAuthServiceTest { private static final String EMAIL = "email@email.com"; private static final String PASSWORD = "password"; - private static final String ID = "id"; @Mock - private AdminTokenProvider adminTokenProvider; + private AdminAccessTokenProvider adminAccessTokenProvider; + @Mock + private AdminRefreshTokenProvider adminRefreshTokenProvider; private AdminAuthService adminAuthService; private AdminRepository adminRepository; + private AdminRefreshTokenRepository adminRefreshTokenRepository; @BeforeEach void setup() { adminRepository = new AdminFakeRepository(); - adminAuthService = new AdminAuthService(adminRepository, adminTokenProvider); + adminRefreshTokenRepository = new AdminFakeRefreshTokenRepository(); + adminAuthService = new AdminAuthService( + adminRepository, + adminAccessTokenProvider, + adminRefreshTokenProvider, + adminRefreshTokenRepository + ); } @Test @@ -54,8 +68,8 @@ void setup() { // given AdminSignUpRequest adminSignUpRequest = 관리자_회원_가입_요청(); AdminTokenResponse adminTokenResponse = 관리자_토큰_생성_응답(); - when(adminTokenProvider.createAccessToken(any())).thenReturn(adminTokenResponse.accessToken()); - when(adminTokenProvider.createRefreshToken(any())).thenReturn(adminTokenResponse.refreshToken()); + when(adminAccessTokenProvider.createAccessToken(any())).thenReturn(adminTokenResponse.accessToken()); + when(adminRefreshTokenProvider.createRefreshToken(any())).thenReturn(adminTokenResponse.refreshToken()); // when AdminTokenResponse response = adminAuthService.signUp(adminSignUpRequest); @@ -76,8 +90,8 @@ class 로그인 { adminRepository.save(관리자_생성()); AdminLoginRequest adminLoginRequest = 관리자_로그인_요청(); AdminTokenResponse adminTokenResponse = 관리자_토큰_생성_응답(); - when(adminTokenProvider.createAccessToken(any())).thenReturn(adminTokenResponse.accessToken()); - when(adminTokenProvider.createRefreshToken(any())).thenReturn(adminTokenResponse.refreshToken()); + when(adminAccessTokenProvider.createAccessToken(any())).thenReturn(adminTokenResponse.accessToken()); + when(adminRefreshTokenProvider.createRefreshToken(any())).thenReturn(adminTokenResponse.refreshToken()); // when AdminTokenResponse response = adminAuthService.login(adminLoginRequest); @@ -114,20 +128,50 @@ class 로그인 { } } + @Nested + class 리프레쉬_토큰_재생성 { + + @Test + void 리프레쉬_토큰이_올바르면_액세스_토큰을_재생성한다() { + // given + adminRefreshTokenRepository.save(관리자_리프레쉬_토큰_생성()); + String refreshToken = "refreshToken"; + AdminAccessTokenResponse adminAccessTokenResponse = 관리자_액세스_토큰_생성_응답(); + when(adminAccessTokenProvider.createAccessToken(any())).thenReturn(adminAccessTokenResponse.accessToken()); + + // when + AdminAccessTokenResponse response = adminAuthService.reGenerateAccessToken(refreshToken); + + // then + assertThat(response.accessToken()).isEqualTo(adminAccessTokenResponse.accessToken()); + } + + @Test + void 리프레쉬_토큰이_존재하지_않으면_예외가_발생한다() { + // given + String refreshToken = "refreshToken"; + + // when & then + assertThatThrownBy(() -> adminAuthService.reGenerateAccessToken(refreshToken)) + .isInstanceOf(InvalidRefreshTokenException.class); + } + } + @Test - void 리프레쉬_토큰으로_액세스_토큰을_재생성한다() { + void 로그아웃시_리프레쉬_토큰을_삭제한다() { // given - adminRepository.save(관리자_생성()); - Long expectedId = 1L; - String refreshToken = "refreshToken"; - AdminAccessTokenResponse adminAccessTokenResponse = 관리자_액세스_토큰_생성_응답(); - when(adminTokenProvider.extract(refreshToken, ID, Long.class)).thenReturn(expectedId); - when(adminTokenProvider.createAccessToken(any())).thenReturn(adminAccessTokenResponse.accessToken()); + AdminRefreshToken adminRefreshToken = 관리자_리프레쉬_토큰_생성(); + adminRefreshTokenRepository.save(adminRefreshToken); + Optional before = adminRefreshTokenRepository.findById(adminRefreshToken.refreshToken()); // when - AdminAccessTokenResponse response = adminAuthService.reGenerateAccessToken(refreshToken); + adminAuthService.logout(adminRefreshToken.refreshToken()); + Optional after = adminRefreshTokenRepository.findById(adminRefreshToken.refreshToken()); // then - assertThat(response.accessToken()).isEqualTo(adminAccessTokenResponse.accessToken()); + assertSoftly(softly -> { + softly.assertThat(before).isNotEmpty(); + softly.assertThat(after).isEmpty(); + }); } } diff --git a/src/test/java/com/atwoz/admin/fixture/AdminRefreshTokenFixture.java b/src/test/java/com/atwoz/admin/fixture/AdminRefreshTokenFixture.java new file mode 100644 index 00000000..f2b79399 --- /dev/null +++ b/src/test/java/com/atwoz/admin/fixture/AdminRefreshTokenFixture.java @@ -0,0 +1,17 @@ +package com.atwoz.admin.fixture; + +import com.atwoz.admin.domain.admin.AdminRefreshToken; + +@SuppressWarnings("NonAsciiCharacters") +public class AdminRefreshTokenFixture { + + private static final String REFRESH_TOKEN = "refreshToken"; + private static final Long DEFAULT_MEMBER_ID = 1L; + + public static AdminRefreshToken 관리자_리프레쉬_토큰_생성() { + return new AdminRefreshToken( + REFRESH_TOKEN, + DEFAULT_MEMBER_ID + ); + } +} diff --git a/src/test/java/com/atwoz/admin/infrastructure/admin/AdminFakeRefreshTokenRepository.java b/src/test/java/com/atwoz/admin/infrastructure/admin/AdminFakeRefreshTokenRepository.java new file mode 100644 index 00000000..98a7c9af --- /dev/null +++ b/src/test/java/com/atwoz/admin/infrastructure/admin/AdminFakeRefreshTokenRepository.java @@ -0,0 +1,31 @@ +package com.atwoz.admin.infrastructure.admin; + +import com.atwoz.admin.domain.admin.AdminRefreshToken; +import com.atwoz.admin.domain.admin.AdminRefreshTokenRepository; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +public class AdminFakeRefreshTokenRepository implements AdminRefreshTokenRepository { + + private final Map map = new HashMap<>(); + private Long id = 1L; + + @Override + public void save(final AdminRefreshToken adminRefreshToken) { + map.put(adminRefreshToken, id++); + } + + @Override + public Optional findById(final String refreshToken) { + return map.keySet().stream() + .filter(adminRefreshToken -> refreshToken.equals(adminRefreshToken.refreshToken())) + .findAny(); + } + + @Override + public void delete(final String refreshToken) { + map.keySet() + .removeIf(adminRefreshToken -> refreshToken.equals(adminRefreshToken.refreshToken())); + } +} diff --git a/src/test/java/com/atwoz/admin/infrastructure/auth/AdminRedisRefreshTokenRepositoryTest.java b/src/test/java/com/atwoz/admin/infrastructure/auth/AdminRedisRefreshTokenRepositoryTest.java new file mode 100644 index 00000000..893f230d --- /dev/null +++ b/src/test/java/com/atwoz/admin/infrastructure/auth/AdminRedisRefreshTokenRepositoryTest.java @@ -0,0 +1,54 @@ +package com.atwoz.admin.infrastructure.auth; + +import com.atwoz.admin.domain.admin.AdminRefreshToken; +import com.atwoz.helper.IntegrationHelper; +import java.util.Optional; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static com.atwoz.admin.fixture.AdminRefreshTokenFixture.관리자_리프레쉬_토큰_생성; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AdminRedisRefreshTokenRepositoryTest extends IntegrationHelper { + + @Autowired + private AdminRedisRefreshTokenRepository adminRedisRefreshTokenRepository; + + @Test + void 관리자_토큰_저장_및_조회() { + // given + AdminRefreshToken adminRefreshToken = 관리자_리프레쉬_토큰_생성(); + + // when + adminRedisRefreshTokenRepository.save(adminRefreshToken); + Optional foundAdminRefreshToken = + adminRedisRefreshTokenRepository.findById(adminRefreshToken.refreshToken()); + + // then + assertSoftly(softly -> { + softly.assertThat(foundAdminRefreshToken).isNotEmpty(); + AdminRefreshToken refreshToken = foundAdminRefreshToken.get(); + softly.assertThat(refreshToken.memberId()).isEqualTo(adminRefreshToken.memberId()); + softly.assertThat(refreshToken.refreshToken()).isEqualTo(adminRefreshToken.refreshToken()); + }); + } + + @Test + void 관리자_토큰_삭제() { + // given + AdminRefreshToken adminRefreshToken = 관리자_리프레쉬_토큰_생성(); + adminRedisRefreshTokenRepository.save(adminRefreshToken); + + // when + adminRedisRefreshTokenRepository.delete(adminRefreshToken.refreshToken()); + Optional after = adminRedisRefreshTokenRepository.findById(adminRefreshToken.refreshToken()); + + // then + assertThat(after).isEmpty(); + } +} diff --git a/src/test/java/com/atwoz/admin/ui/auth/AdminAuthControllerTest.java b/src/test/java/com/atwoz/admin/ui/auth/AdminAuthControllerTest.java index 84ea99d4..1dd3251d 100644 --- a/src/test/java/com/atwoz/admin/ui/auth/AdminAuthControllerTest.java +++ b/src/test/java/com/atwoz/admin/ui/auth/AdminAuthControllerTest.java @@ -24,13 +24,15 @@ import static com.atwoz.helper.RestDocsHelper.customDocument; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; -import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.HttpHeaders.COOKIE; +import static org.springframework.http.HttpHeaders.SET_COOKIE; import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -73,7 +75,7 @@ class AdminAuthControllerTest extends MockBeanInjection { fieldWithPath("adminProfileSignUpRequest.phoneNumber").description("전화번호") ), responseHeaders( - headerWithName("Cookie").description("발급된 리프레쉬 토큰") + headerWithName(SET_COOKIE).description("발급된 리프레쉬 토큰") ), responseFields( fieldWithPath("accessToken").description("발급된 액세스 토큰") @@ -101,7 +103,7 @@ class AdminAuthControllerTest extends MockBeanInjection { fieldWithPath("password").description("비밀번호") ), responseHeaders( - headerWithName("Cookie").description("발급된 리프레쉬 토큰") + headerWithName(SET_COOKIE).description("발급된 리프레쉬 토큰") ), responseFields( fieldWithPath("accessToken").description("발급된 액세스 토큰") @@ -119,14 +121,12 @@ class AdminAuthControllerTest extends MockBeanInjection { // when & then mockMvc.perform(post("/api/admins/auth/access-token-regeneration") - .header(AUTHORIZATION, BEARER_TOKEN) .header(HttpHeaders.COOKIE, refreshToken) ).andExpect(status().isOk()) .andDo(print()) .andDo(customDocument("관리자_액세스_토큰_재발행", requestHeaders( - headerWithName(AUTHORIZATION).description("유저 토큰 정보"), - headerWithName("Cookie").description("이전에 발급받은 리프레쉬 토큰") + headerWithName(COOKIE).description("이전에 발급받은 리프레쉬 토큰") ), responseFields( fieldWithPath("accessToken").description("지금 발급된 액세스 토큰") @@ -134,4 +134,22 @@ class AdminAuthControllerTest extends MockBeanInjection { ) ); } + + @Test + void 로그아웃을_진행한다() throws Exception{ + // given + String refreshToken = "refreshToken"; + + // when & then + mockMvc.perform(delete("/api/admins/auth/logout") + .header(HttpHeaders.COOKIE, refreshToken) + ).andExpect(status().isOk()) + .andDo(print()) + .andDo(customDocument("관리자_로그아웃", + requestHeaders( + headerWithName(COOKIE).description("이전에 발급받은 리프레쉬 토큰") + ) + ) + ); + } } diff --git a/src/test/java/com/atwoz/admin/ui/auth/interceptor/AdminLoginValidCheckerInterceptorTest.java b/src/test/java/com/atwoz/admin/ui/auth/interceptor/AdminLoginValidCheckerInterceptorTest.java index 03ce531b..b1c1a36b 100644 --- a/src/test/java/com/atwoz/admin/ui/auth/interceptor/AdminLoginValidCheckerInterceptorTest.java +++ b/src/test/java/com/atwoz/admin/ui/auth/interceptor/AdminLoginValidCheckerInterceptorTest.java @@ -1,9 +1,9 @@ package com.atwoz.admin.ui.auth.interceptor; -import com.atwoz.admin.domain.admin.AdminTokenProvider; import com.atwoz.admin.exception.exceptions.AdminLoginInvalidException; import com.atwoz.admin.ui.auth.support.AdminAuthenticationContext; import com.atwoz.admin.ui.auth.support.AdminAuthenticationExtractor; +import com.atwoz.admin.ui.auth.support.AdminTokenExtractor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.DisplayNameGeneration; @@ -22,7 +22,7 @@ class AdminLoginValidCheckerInterceptorTest { private final HttpServletResponse res = mock(HttpServletResponse.class); private final AdminAuthenticationContext adminAuthenticationContext = mock(AdminAuthenticationContext.class); private final AdminAuthenticationExtractor adminAuthenticationExtractor = mock(AdminAuthenticationExtractor.class); - private final AdminTokenProvider adminTokenProvider = mock(AdminTokenProvider.class); + private final AdminTokenExtractor adminTokenExtractor = mock(AdminTokenExtractor.class); @Test void token이_없다면_예외를_발생한다() { @@ -30,7 +30,7 @@ class AdminLoginValidCheckerInterceptorTest { AdminLoginValidCheckerInterceptor adminLoginValidCheckerInterceptor = new AdminLoginValidCheckerInterceptor( adminAuthenticationContext, adminAuthenticationExtractor, - adminTokenProvider + adminTokenExtractor ); when(req.getHeader("any")).thenReturn(null); diff --git a/src/test/java/com/atwoz/helper/MockBeanInjection.java b/src/test/java/com/atwoz/helper/MockBeanInjection.java index 9eb8413f..bfdb248c 100644 --- a/src/test/java/com/atwoz/helper/MockBeanInjection.java +++ b/src/test/java/com/atwoz/helper/MockBeanInjection.java @@ -1,7 +1,6 @@ package com.atwoz.helper; import com.atwoz.admin.application.auth.AdminAuthService; -import com.atwoz.admin.domain.admin.AdminTokenProvider; import com.atwoz.admin.ui.auth.interceptor.AdminLoginValidCheckerInterceptor; import com.atwoz.admin.ui.auth.support.AdminAuthenticationContext; import com.atwoz.admin.ui.auth.support.AdminAuthenticationExtractor; @@ -91,10 +90,6 @@ public class MockBeanInjection { @MockBean protected AdminAuthService adminAuthService; - @MockBean - protected AdminTokenProvider adminTokenProvider; - - // Mission @MockBean protected MissionService missionService; diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 20c85494..3fcd4e75 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -4,6 +4,12 @@ spring: username: sa password: sa driver-class-name: org.h2.Driver + + data: + redis: + host: localhost + port: 6379 + jpa: hibernate: ddl-auto: create @@ -11,6 +17,7 @@ spring: hibernate: show_sql: true format_sql: true + flyway: enabled: false @@ -27,6 +34,9 @@ jasypt: cookie: max-age: 10000 +redis: + expiration-period: 1000 + jwt: secret: fortestfortestfortestfortestfortestfortestfortestfortestfortestfortestfortestfortestfortestfortestfortest access-token-expiration-period: 10000