Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BE] feat: Refresh Token, Logout API 구현 #923

Merged
merged 6 commits into from
Nov 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.stampcrush.backend.auth.api;

import com.stampcrush.backend.auth.application.ManagerLogoutService;
import com.stampcrush.backend.config.resolver.OwnerAuth;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/admin")
public class ManagerLogoutController {

private final ManagerLogoutService managerLogoutService;

@PostMapping("/logout")
public ResponseEntity<Void> logout(
OwnerAuth owner,
@RequestHeader("Refresh") String refreshToken
gitchannn marked this conversation as resolved.
Show resolved Hide resolved
) {
managerLogoutService.logout(owner.getId(), refreshToken);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.stampcrush.backend.auth.api;

import com.stampcrush.backend.auth.api.response.AuthTokensResponse;
import com.stampcrush.backend.auth.application.ManagerReissueTokenService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/admin/auth")
public class ManagerReissueTokenController {

private final ManagerReissueTokenService managerReissueTokenService;

@GetMapping("/reissue-token")
public ResponseEntity<AuthTokensResponse> reissueToken(
@RequestHeader("Refresh") String refreshToken
) {
return ResponseEntity.ok(
managerReissueTokenService.reissueToken(refreshToken)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.stampcrush.backend.auth.application;

import com.stampcrush.backend.auth.entity.BlackList;
import com.stampcrush.backend.auth.repository.BlackListRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
@Transactional
public class ManagerLogoutService {

private final BlackListRepository blackListRepository;
private final RefreshTokenValidator refreshTokenValidator;

public void logout(Long id, String refreshToken) {
refreshTokenValidator.validateToken(refreshToken);
refreshTokenValidator.validateTokenOwnerId(refreshToken, id);
refreshTokenValidator.validateLogoutToken(refreshToken);
blackListRepository.save(new BlackList(refreshToken));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.stampcrush.backend.auth.application;

import com.stampcrush.backend.auth.api.response.AuthTokensResponse;
import com.stampcrush.backend.auth.application.util.AuthTokensGenerator;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
@Transactional
public class ManagerReissueTokenService {

private final AuthTokensGenerator authTokensGenerator;
private final RefreshTokenValidator refreshTokenValidator;

public AuthTokensResponse reissueToken(final String refreshToken) {
refreshTokenValidator.validateToken(refreshToken);
refreshTokenValidator.validateLogoutToken(refreshToken);
final Long memberId = authTokensGenerator.extractMemberId(refreshToken);
return authTokensGenerator.generate(memberId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.stampcrush.backend.auth.application;

import com.stampcrush.backend.auth.application.util.AuthTokensGenerator;
import com.stampcrush.backend.auth.repository.BlackListRepository;
import com.stampcrush.backend.exception.UnAuthorizationException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@RequiredArgsConstructor
@Component
public class RefreshTokenValidator {

private final AuthTokensGenerator authTokensGenerator;
private final BlackListRepository blackListRepository;

public void validateToken(String refreshToken) {
if (!authTokensGenerator.isValidToken(refreshToken)) {
throw new UnAuthorizationException("[ERROR] 유효하지 않은 Refresh Token입니다!");
}
}

public void validateTokenOwnerId(String refreshToken, Long id) {
final Long ownerId = authTokensGenerator.extractMemberId(refreshToken);
if (!ownerId.equals(id)) {
throw new UnAuthorizationException("[ERROR] 로그인한 사용자의 Refresh Token이 아닙니다!");
}
}

public void validateLogoutToken(String refreshToken) {
if (blackListRepository.existsByInvalidRefreshToken(refreshToken)) {
throw new UnAuthorizationException("[ERROR] 이미 로그아웃된 사용자입니다!");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ public AuthTokensResponse generate(Long memberId) {
return AuthTokensResponse.of(accessToken, refreshToken, BEARER_TYPE, ACCESS_TOKEN_EXPIRE_TIME / 1000L);
}

public boolean isValidToken(String accessToken) {
return jwtTokenProvider.isValidToken(accessToken);
public boolean isValidToken(String token) {
return jwtTokenProvider.isValidToken(token);
}

public Long extractMemberId(String accessToken) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.stampcrush.backend.auth.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

import static lombok.AccessLevel.PROTECTED;

@AllArgsConstructor
@NoArgsConstructor(access = PROTECTED)
@Getter
@Entity
public class BlackList {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "invalid_refresh_token")
gitchannn marked this conversation as resolved.
Show resolved Hide resolved
private String invalidRefreshToken;

public BlackList(String invalidRefreshToken) {
this.invalidRefreshToken = invalidRefreshToken;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.stampcrush.backend.auth.repository;

import com.stampcrush.backend.auth.entity.BlackList;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface BlackListRepository extends JpaRepository<BlackList, Long> {

default boolean existsByInvalidRefreshToken(String refreshToken) {
return findByInvalidRefreshToken(refreshToken).isPresent();
}

Optional<BlackList> findByInvalidRefreshToken(String refreshToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(ownerAuthInterceptor)
.addPathPatterns("/api/admin/**")
.excludePathPatterns("/api/admin/login/**")
.excludePathPatterns("/api/admin/owners");
.excludePathPatterns("/api/admin/owners")
.excludePathPatterns("/api/admin/auth/**");

registry.addInterceptor(customerAuthInterceptor)
.addPathPatterns("/api/**")
Expand Down
6 changes: 6 additions & 0 deletions backend/src/main/resources/db/migration/V12__black_list.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
create table black_list
(
id bigint not null auto_increment,
invalid_refresh_token varchar(255),
primary key (id)
) engine=InnoDB;
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.stampcrush.backend.acceptance;

import io.restassured.response.ExtractableResponse;
import io.restassured.response.Response;
import org.junit.jupiter.api.Test;

import static com.stampcrush.backend.acceptance.step.ManagerJoinStep.OWNER_CREATE_REQUEST;
import static com.stampcrush.backend.acceptance.step.ManagerJoinStep.카페_사장_회원_가입_요청;
import static com.stampcrush.backend.acceptance.step.ManagerJoinStep.카페_사장_회원_가입_요청하고_Refresh_토큰_반환;
import static com.stampcrush.backend.acceptance.step.ManagerLogoutStep.카페_사장_로그_아웃_요청;
import static com.stampcrush.backend.acceptance.step.ManagerReissueTokenStep.Refresh_토큰으로_토큰_재발급_요청;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.http.HttpStatus.OK;
import static org.springframework.http.HttpStatus.UNAUTHORIZED;

class ManagerReissueTokenAcceptanceTest extends AcceptanceTest {

@Test
void 카페_사장의_토큰을_재발급한다() {
String refreshToken = 카페_사장_회원_가입_요청하고_Refresh_토큰_반환(OWNER_CREATE_REQUEST);
final ExtractableResponse<Response> response = Refresh_토큰으로_토큰_재발급_요청(refreshToken);

assertThat(response.statusCode()).isEqualTo(OK.value());
}

@Test
void 조작된_Refresh_토큰으로는_토큰_재발급을_받을_수_없다() {
String refreshToken = 카페_사장_회원_가입_요청하고_Refresh_토큰_반환(OWNER_CREATE_REQUEST);
String invalidRefreshToken = "zozakHatZiRong" + refreshToken;

final ExtractableResponse<Response> response = Refresh_토큰으로_토큰_재발급_요청(invalidRefreshToken);

assertThat(response.statusCode()).isEqualTo(UNAUTHORIZED.value());
}

@Test
void 로그아웃_이후에_해당_Refresh_Token을_사용해서_토큰_재발급을_받을_수_없다() {
final ExtractableResponse<Response> authResponse = 카페_사장_회원_가입_요청(OWNER_CREATE_REQUEST);
final String accessToken = authResponse.jsonPath().getString("accessToken");
final String refreshToken = authResponse.jsonPath().getString("refreshToken");

카페_사장_로그_아웃_요청(accessToken, refreshToken);

final ExtractableResponse<Response> response = Refresh_토큰으로_토큰_재발급_요청(refreshToken);

assertThat(response.statusCode()).isEqualTo(UNAUTHORIZED.value());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ public class ManagerJoinStep {
"깃짱", OAuthProvider.KAKAO, 123L
);

public static String 카페_사장_회원_가입_요청하고_Refresh_토큰_반환(OAuthRegisterOwnerCreateRequest request) {
ExtractableResponse<Response> response = 카페_사장_회원_가입_요청(request);
return response.jsonPath().getString("refreshToken");
}

public static String 카페_사장_회원_가입_요청하고_액세스_토큰_반환(OAuthRegisterOwnerCreateRequest request) {
ExtractableResponse<Response> response = 카페_사장_회원_가입_요청(request);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.stampcrush.backend.acceptance.step;

import io.restassured.RestAssured;
import io.restassured.response.ExtractableResponse;
import io.restassured.response.Response;

public class ManagerLogoutStep {

public static ExtractableResponse<Response> 카페_사장_로그_아웃_요청(final String accessToken, final String refreshToken) {
return RestAssured.given()
.log().all()
.auth().preemptive()
.oauth2(accessToken)
.header("Refresh", refreshToken)

.when()
.post("/api/admin/logout")

.then()
.log().all()
.extract();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.stampcrush.backend.acceptance.step;

import io.restassured.RestAssured;
import io.restassured.response.ExtractableResponse;
import io.restassured.response.Response;

import static io.restassured.http.ContentType.JSON;

public class ManagerReissueTokenStep {

public static ExtractableResponse<Response> Refresh_토큰으로_토큰_재발급_요청(final String refreshToken) {
return RestAssured.given()
.log().all()
.header("Refresh", refreshToken)
.contentType(JSON)

.when()
.get("/api/admin/auth/reissue-token")

.then()
.log().all()
.extract();
}
}