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

feat: 레디스 적용 및 로그아웃 api 추가 #40

Merged
merged 27 commits into from
Jul 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d9647af
refactor: 기존에 한 클래스에서 사용하던 기능 분리 작업 진행
eom-tae-in Jul 10, 2024
bd442bf
feat: 로그아웃 기능 추가
eom-tae-in Jul 10, 2024
756096a
refactor: 쿠키 헤더 설정 변경
eom-tae-in Jul 10, 2024
522fecd
feat: AdminAuthService 로직 수정 및 로그아웃 기능 구현
eom-tae-in Jul 10, 2024
252bc20
feat: 관리자 RT가 유효하지 않을 때 발생시킬 예외 클래스 추가
eom-tae-in Jul 10, 2024
6595bc6
feat: 관리자 리프레쉬 토큰의 정보를 저장하고 관리하는 도메인 클래스 생성
eom-tae-in Jul 10, 2024
7d608b1
feat: 관리자 리프레쉬 토큰을 저장하기 위한 Repository 구현
eom-tae-in Jul 10, 2024
2c829ff
refactor: TokenProvider의 기능 분리로 인한 코드 수정
eom-tae-in Jul 10, 2024
d364000
remove: 불필요한 클래스 제거
eom-tae-in Jul 10, 2024
ed7f2f3
feat: redis를 추가하기 위한 설정파일 추가
eom-tae-in Jul 10, 2024
05423dc
test: AdminRedisRefreshTokenRepository를 테스트하기 위한 Fake Repository 구현
eom-tae-in Jul 10, 2024
f4389a2
test: AdminRedisRefreshTokenRepository 테스트 클래스 작성
eom-tae-in Jul 10, 2024
fe68ceb
test: 관리자 RT를 테스트에서 쉽게 사용하기 위해 Fixture 작성
eom-tae-in Jul 10, 2024
b0e0d5f
test: 관리자 로그아웃 기능 테스트 추가
eom-tae-in Jul 10, 2024
2ebf1ee
test: 프로덕션 코드 수정으로 인한 테스트 수정
eom-tae-in Jul 10, 2024
95cf36a
docs: build.gradle에 redis 설정 추가
eom-tae-in Jul 10, 2024
a4df6e8
docs: application.yml에 redis 설정 추가
eom-tae-in Jul 10, 2024
fbeb794
test: application.yml에 redis 설정 추가
eom-tae-in Jul 10, 2024
3d29b7c
docs: 관리자 명세서 최신화
eom-tae-in Jul 10, 2024
6ec0f14
docs: 토큰 저장시간 암호화
eom-tae-in Jul 10, 2024
407ea48
docs: CI 구성에 레디스 추가
eom-tae-in Jul 11, 2024
f463404
refactor: 메서드명 변경
eom-tae-in Jul 11, 2024
953e5a4
refactor: admin 생성하는 로직 메서드 분리
eom-tae-in Jul 11, 2024
ce8714e
refactor: 도메인 객체에 불필요하게 존재했던 @Id 어노테이션 제거
eom-tae-in Jul 11, 2024
508194d
refactor: 공백 제거
eom-tae-in Jul 11, 2024
a3fb9f4
refactor: 클래스명 변경
eom-tae-in Jul 11, 2024
b80f3f8
refactor: AdminRefreshToken 키 값에 @Id 추가
eom-tae-in Jul 11, 2024
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
12 changes: 12 additions & 0 deletions .github/workflows/backend-test-when-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
10 changes: 10 additions & 0 deletions src/docs/asciidoc/admin.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.atwoz.admin.application.auth;

public interface AdminAccessTokenProvider {

String createAccessToken(Long id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,52 +21,65 @@
@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) {
return adminRepository.findAdminByEmail(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);
}
Comment on lines +82 to 84
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

프론트엔드에서 헤더에서 토큰을 제거해주고 동시에 로그아웃 api를 요청해서 리프레쉬 토큰까지 제거해서 로그아웃을 시켜준다라는 의미로 작성하신 코드가 맞을까요?!

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 맞습니다!
프론트에서 로그아웃 처리시 로그아웃 api를 요청해서 서버에 있는 로그인 했던 유저의 RT를 삭제합니다!

}
21 changes: 21 additions & 0 deletions src/main/java/com/atwoz/admin/domain/admin/AdminRefreshToken.java
Original file line number Diff line number Diff line change
@@ -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,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Id가 들어간 이유가 궁금한데, 혹시 고유 식별자 역할로 쓰이게 하시기 위함일까요?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

레디스에 사용할 도메인 객체에 키로 사용되는 값을 지정해야 하는 걸로 알고 있었는데 로컬에서 제거해서 테스트를 진행해도 잘 되네요! @id를 지정해줘야되는지 알고 사용한 이유 외에 다른 이유는 없었습니다. @id는 Redis Repository로 구현했을 때만 사용하는 게 맞을까요?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확인해보니 레디스로 관리할 경우에는 말씀하신 것 처럼 @Id (springframework.data의 어노테이션)가 필요한 것으로 나오네요..! 그대로 남겨두시는게 맞지 않을까 생각됩니다. 참고하실만한 공유드리겠습니다!

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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.atwoz.admin.domain.admin;

import java.util.Optional;

public interface AdminRefreshTokenRepository {

void save(AdminRefreshToken adminRefreshToken);

Optional<AdminRefreshToken> findById(String refreshToken);

void delete(String refreshToken);
}
10 changes: 0 additions & 10 deletions src/main/java/com/atwoz/admin/domain/admin/AdminTokenProvider.java

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.atwoz.admin.domain.admin.service;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AT 발급 인터페이스는 application/auth, RT 발급 인터페이스는 domain/admin/service에 작성하신 이유가 궁금합니다. 궁극적으로 모두 토큰을 발급하기도 하고, 구현체 또한 AdminJwtAccessTokenProvider에서 모두 구현한만큼 이를 추상화한 인터페이스들의 위치도 통일시키는 게 좋을 것 같은데 어떻게 생각하시나요?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희가 RT만 레디스에 저장하기 때문에 RT 도메인 객체는 도메인에 위치해야합니다. 하지만 RT의 암호화된 토큰은 JwtTokenProvider에서 만들어집니다. 만약 JwtTokenProvider 통해 서비스 레이어에서 생성해준다면 RT 도메인 객체는 응용 계층에서 만든 암호화된 토큰을 받게 됩니다. 이로 인한 응용 계층의 의존이 생기기 때문에 RTProvider를 팩토리 메서드 인자로 넘겨줘서 내부에서 암호화된 토큰이 만들어지도록 구현해야한다고 생각했습니다. RTProvider는 도메인 서비스이기에 도메인 레이어에 위치해야합니다. 하지만 ATProvider는 도메인에 어떠한 영향도 미치지 않기 때문에 응용 계층에서 생성해서 응답 바디에 넘겨주면 됩니다. 제가 AT, RT 생성 기능을 모두 추상화한 인터페이스로 둔 이유가 바로 이 이유 때문이었습니다. 추가적인 의견 및 제가 잘못 이해해서 설계한 부분이 있다면 댓글로 의견 남겨주시면 감사하겠습니다!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 이해했습니다. AT 발급 (ATProvider)은 도메인에 영향을 미치지 않고 응용 계층에서만 이용되기 때문에 응용 계층에 인터페이스를 위치시키고, RT 발급 (RTPRovider)을 AT 발급처럼 응용 계층에 둔다면 도메인 계층에 있는 AdminRefreshToken에서 응용 계층에 대한 의존이 발생되기 때문에 도메인 패키지 하위에 서비스 패키지를 별도로 만들어주신거군요.
네 저도 이 방식이 더 나은 방식 같습니다! 태인님의 의견을 들으면서 인터페이스를 둘 위치를 더 깊게 고민할 수 있어서 좋았습니다 :)


public interface AdminRefreshTokenProvider {

String createRefreshToken(String email);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -38,6 +39,11 @@ public ResponseEntity<String> handleAdminLoginInvalidException(final AdminLoginI
return getUnauthorized(e);
}

@ExceptionHandler(InvalidRefreshTokenException.class)
public ResponseEntity<String> handleRefreshTokenInvalidException(final InvalidRefreshTokenException e) {
return getUnauthorized(e);
}

private ResponseEntity<String> getNotFoundResponse(final Exception e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(e.getMessage());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.atwoz.admin.exception.exceptions;

public class InvalidRefreshTokenException extends RuntimeException {

public InvalidRefreshTokenException() {
super("리프레쉬 토큰이 유효하지 않습니다");
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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";
Expand Down Expand Up @@ -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())
Expand All @@ -94,7 +100,9 @@ private Date expiredAt(final int expirationPeriod) {
}

@Override
public <T> T extract(final String token, final String claimName, final Class<T> classType) {
public <T> T extract(final String token,
final String claimName,
final Class<T> classType) {
try {
return Jwts.parserBuilder()
.setSigningKey(secret.getBytes())
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Long> redisTemplate;

@Override
public void save(final AdminRefreshToken adminRefreshToken) {
ValueOperations<String, Long> valueOperations = redisTemplate.opsForValue();
valueOperations.set(adminRefreshToken.refreshToken(), adminRefreshToken.memberId());
redisTemplate.expire(adminRefreshToken.refreshToken(), expirationPeriod, TimeUnit.DAYS);
}

@Override
public Optional<AdminRefreshToken> findById(final String refreshToken) {
ValueOperations<String, Long> 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);
}
}
11 changes: 10 additions & 1 deletion src/main/java/com/atwoz/admin/ui/auth/AdminAuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -56,6 +57,14 @@ public ResponseEntity<AdminAccessTokenResponse> reGenerateAccessToken(
return ResponseEntity.ok(adminAuthService.reGenerateAccessToken(refreshToken));
}

@DeleteMapping("/logout")
public ResponseEntity<Void> 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)
Expand All @@ -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;
}
Expand Down
Loading
Loading