From cf8d9e48f1b358c7ae2f0c650523b5717a75c3f2 Mon Sep 17 00:00:00 2001 From: drunkenhw Date: Fri, 29 Sep 2023 11:51:54 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20refresh=20token=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [#815] --- .../carffeine/auth/domain/RefreshToken.java | 31 +++++++++++++++++++ .../auth/domain/RefreshTokenRepository.java | 14 +++++++++ 2 files changed, 45 insertions(+) create mode 100644 backend/src/main/java/com/carffeine/carffeine/auth/domain/RefreshToken.java create mode 100644 backend/src/main/java/com/carffeine/carffeine/auth/domain/RefreshTokenRepository.java diff --git a/backend/src/main/java/com/carffeine/carffeine/auth/domain/RefreshToken.java b/backend/src/main/java/com/carffeine/carffeine/auth/domain/RefreshToken.java new file mode 100644 index 000000000..c5a276df3 --- /dev/null +++ b/backend/src/main/java/com/carffeine/carffeine/auth/domain/RefreshToken.java @@ -0,0 +1,31 @@ +package com.carffeine.carffeine.auth.domain; + +import com.carffeine.carffeine.common.domain.BaseEntity; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; + +@Getter +@Builder +@EqualsAndHashCode(of = "id", callSuper = false) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class RefreshToken extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long memberId; + + private String tokenId; +} diff --git a/backend/src/main/java/com/carffeine/carffeine/auth/domain/RefreshTokenRepository.java b/backend/src/main/java/com/carffeine/carffeine/auth/domain/RefreshTokenRepository.java new file mode 100644 index 000000000..0b659cfe1 --- /dev/null +++ b/backend/src/main/java/com/carffeine/carffeine/auth/domain/RefreshTokenRepository.java @@ -0,0 +1,14 @@ +package com.carffeine.carffeine.auth.domain; + +import org.springframework.data.repository.Repository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends Repository { + + RefreshToken save(RefreshToken refreshToken); + + Optional findByTokenId(String refreshToken); + + void deleteByMemberId(Long loginMember); +} From 6c81c252049fdbc92d2b7c38858197e96ae756ee Mon Sep 17 00:00:00 2001 From: drunkenhw Date: Fri, 29 Sep 2023 11:52:38 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20refresh=20token=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EB=B0=8F=20access=20token=20=EC=9E=AC=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [#815] --- .../interceptor/LoginInterceptor.java | 2 +- .../carffeine/auth/domain/TokenProvider.java | 4 +- .../auth/infrastructure/JwtProvider.java | 29 +++++++----- .../carffeine/auth/service/AuthService.java | 38 ++++++++++++++-- .../carffeine/auth/service/dto/Tokens.java | 7 +++ backend/src/main/resources/application.yml | 3 +- .../domain/FakeRefreshTokenRepository.java | 39 ++++++++++++++++ .../auth/service/AuthServiceTest.java | 45 +++++++++++++++---- backend/src/test/resources/application.yml | 3 +- 9 files changed, 145 insertions(+), 25 deletions(-) create mode 100644 backend/src/main/java/com/carffeine/carffeine/auth/service/dto/Tokens.java create mode 100644 backend/src/test/java/com/carffeine/carffeine/auth/domain/FakeRefreshTokenRepository.java diff --git a/backend/src/main/java/com/carffeine/carffeine/auth/controller/interceptor/LoginInterceptor.java b/backend/src/main/java/com/carffeine/carffeine/auth/controller/interceptor/LoginInterceptor.java index e84e328b2..252d4aaa4 100644 --- a/backend/src/main/java/com/carffeine/carffeine/auth/controller/interceptor/LoginInterceptor.java +++ b/backend/src/main/java/com/carffeine/carffeine/auth/controller/interceptor/LoginInterceptor.java @@ -23,7 +23,7 @@ public class LoginInterceptor implements HandlerInterceptor { public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = AuthenticationExtractor.extract(request) .orElseThrow(() -> new AuthException(AuthExceptionType.UNAUTHORIZED)); - Long memberId = tokenProvider.extract(token); + Long memberId = Long.parseLong(tokenProvider.extract(token)); authenticationContext.setAuthentication(memberId); return true; } diff --git a/backend/src/main/java/com/carffeine/carffeine/auth/domain/TokenProvider.java b/backend/src/main/java/com/carffeine/carffeine/auth/domain/TokenProvider.java index c6bfb64cc..8283bebea 100644 --- a/backend/src/main/java/com/carffeine/carffeine/auth/domain/TokenProvider.java +++ b/backend/src/main/java/com/carffeine/carffeine/auth/domain/TokenProvider.java @@ -4,5 +4,7 @@ public interface TokenProvider { String create(Long id); - Long extract(String token); + String extract(String token); + + String createRefreshToken(String id); } diff --git a/backend/src/main/java/com/carffeine/carffeine/auth/infrastructure/JwtProvider.java b/backend/src/main/java/com/carffeine/carffeine/auth/infrastructure/JwtProvider.java index a6f03c1ed..4ce71c248 100644 --- a/backend/src/main/java/com/carffeine/carffeine/auth/infrastructure/JwtProvider.java +++ b/backend/src/main/java/com/carffeine/carffeine/auth/infrastructure/JwtProvider.java @@ -33,8 +33,10 @@ public class JwtProvider implements TokenProvider { @Value("${jwt.secret}") private String secret; - @Value("${jwt.expiration-period}") - private int expirationPeriod; + @Value("${jwt.access-token-expiration-period}") + private long accessTokenExpirationPeriod; + @Value("${jwt.refresh-token-expiration-period}") + private long refreshTokenExpirationPeriod; private Key key; @PostConstruct @@ -45,15 +47,15 @@ private void init() { @Override public String create(Long id) { Claims claims = Jwts.claims(); - claims.put("id", id); - return createToken(claims); + claims.put("id", String.valueOf(id)); + return createToken(claims, accessTokenExpirationPeriod); } - private String createToken(Claims claims) { + private String createToken(Claims claims, long expiredPeriod) { return Jwts.builder() .setClaims(claims) .setIssuedAt(issuedAt()) - .setExpiration(expiredAt()) + .setExpiration(expiredAt(expiredPeriod)) .signWith(key, SignatureAlgorithm.HS256) .compact(); } @@ -63,19 +65,19 @@ private Date issuedAt() { return Date.from(now.atZone(ZoneId.systemDefault()).toInstant()); } - private Date expiredAt() { + private Date expiredAt(long expiredPeriod) { LocalDateTime now = LocalDateTime.now(); - return Date.from(now.plusHours(expirationPeriod).atZone(ZoneId.systemDefault()).toInstant()); + return Date.from(now.plusHours(expiredPeriod).atZone(ZoneId.systemDefault()).toInstant()); } @Override - public Long extract(String token) { + public String extract(String token) { try { return Jwts.parser() .setSigningKey(secret.getBytes()) .parseClaimsJws(token) .getBody() - .get("id", Long.class); + .get("id", String.class); } catch (SecurityException e) { throw new AuthException(SIGNITURE_NOT_FOUND); } catch (MalformedJwtException e) { @@ -88,4 +90,11 @@ public Long extract(String token) { throw new AuthException(INVALID_TOKEN); } } + + @Override + public String createRefreshToken(String tokenId) { + Claims claims = Jwts.claims(); + claims.put("id", tokenId); + return createToken(claims, refreshTokenExpirationPeriod); + } } diff --git a/backend/src/main/java/com/carffeine/carffeine/auth/service/AuthService.java b/backend/src/main/java/com/carffeine/carffeine/auth/service/AuthService.java index 69c9d0a41..6a3a4334e 100644 --- a/backend/src/main/java/com/carffeine/carffeine/auth/service/AuthService.java +++ b/backend/src/main/java/com/carffeine/carffeine/auth/service/AuthService.java @@ -2,7 +2,10 @@ import com.carffeine.carffeine.auth.domain.OAuthMember; import com.carffeine.carffeine.auth.domain.Provider; +import com.carffeine.carffeine.auth.domain.RefreshToken; +import com.carffeine.carffeine.auth.domain.RefreshTokenRepository; import com.carffeine.carffeine.auth.domain.TokenProvider; +import com.carffeine.carffeine.auth.service.dto.Tokens; import com.carffeine.carffeine.member.domain.Member; import com.carffeine.carffeine.member.domain.MemberRepository; import com.carffeine.carffeine.member.domain.MemberRole; @@ -10,12 +13,15 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.UUID; + @RequiredArgsConstructor @Service public class AuthService { private final TokenProvider tokenProvider; private final MemberRepository memberRepository; + private final RefreshTokenRepository refreshTokenRepository; private final OAuthRequester oAuthRequester; public String loginUri(String redirectUri, String provider) { @@ -23,15 +29,41 @@ public String loginUri(String redirectUri, String provider) { } @Transactional - public String generateToken(OAuthMember oAuthMember) { + public Tokens generateTokens(OAuthMember oAuthMember) { + Member member = getMember(oAuthMember); + String tokenId = UUID.randomUUID().toString(); + + String accessToken = tokenProvider.create(member.getId()); + String refreshToken = tokenProvider.createRefreshToken(tokenId); + + refreshTokenRepository.save(RefreshToken.builder() + .tokenId(tokenId) + .memberId(member.getId()) + .build()); + + return new Tokens(accessToken, refreshToken); + } + + private Member getMember(OAuthMember oAuthMember) { Member newMember = Member.builder() .email(oAuthMember.email()) .name(oAuthMember.nickname()) .imageUrl(oAuthMember.imageUrl()) .memberRole(MemberRole.USER) .build(); - Member member = memberRepository.findByEmail(oAuthMember.email()) + return memberRepository.findByEmail(oAuthMember.email()) .orElseGet(() -> memberRepository.save(newMember)); - return tokenProvider.create(member.getId()); + } + + @Transactional(readOnly = true) + public String renewAccessToken(String refreshToken) { + String tokenId = tokenProvider.extract(refreshToken); + return refreshTokenRepository.findByTokenId(tokenId) + .map(it -> tokenProvider.create(it.getMemberId())) + .orElseThrow(); + } + + public void logout(Long loginMember) { + refreshTokenRepository.deleteByMemberId(loginMember); } } diff --git a/backend/src/main/java/com/carffeine/carffeine/auth/service/dto/Tokens.java b/backend/src/main/java/com/carffeine/carffeine/auth/service/dto/Tokens.java new file mode 100644 index 000000000..71e325461 --- /dev/null +++ b/backend/src/main/java/com/carffeine/carffeine/auth/service/dto/Tokens.java @@ -0,0 +1,7 @@ +package com.carffeine.carffeine.auth.service.dto; + +public record Tokens( + String accessToken, + String refreshToken +) { +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index d8a575bb6..cc4813126 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -40,7 +40,8 @@ management: jwt: secret: ENC(t6iKCv1Nj+ygRxG2oZCw3l4v9y0ALS2GfQwYIKvO0tRuPzFp4UYRZYfBFdBW+xQl77rOLbfIPJ13tys/tXj3Qw==) - expiration-period: ENC(Ua9DcFiPKX/5r0/WGkN5JQ==) + access-token-expiration-period: ENC(Ua9DcFiPKX/5r0/WGkN5JQ==) + refresh-token-expiration-period: ENC(Ua9DcFiPKX/5r0/WGkN5JQ==) oauth2: provider: diff --git a/backend/src/test/java/com/carffeine/carffeine/auth/domain/FakeRefreshTokenRepository.java b/backend/src/test/java/com/carffeine/carffeine/auth/domain/FakeRefreshTokenRepository.java new file mode 100644 index 000000000..ec8a42cc4 --- /dev/null +++ b/backend/src/test/java/com/carffeine/carffeine/auth/domain/FakeRefreshTokenRepository.java @@ -0,0 +1,39 @@ +package com.carffeine.carffeine.auth.domain; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +public class FakeRefreshTokenRepository implements RefreshTokenRepository { + + private final Map database = new HashMap<>(); + private final AtomicLong id = new AtomicLong(1L); + + @Override + public RefreshToken save(RefreshToken refreshToken) { + long id = this.id.getAndIncrement(); + RefreshToken savedRefreshToken = RefreshToken.builder() + .id(id) + .tokenId(refreshToken.getTokenId()) + .memberId(refreshToken.getMemberId()) + .build(); + database.put(id, savedRefreshToken); + return savedRefreshToken; + } + + @Override + public Optional findByTokenId(String tokenId) { + return database.values().stream() + .filter(it -> it.getTokenId().equals(tokenId)) + .findFirst(); + } + + @Override + public void deleteByMemberId(Long loginMember) { + database.values().stream() + .filter(it -> it.getMemberId().equals(loginMember)) + .findFirst() + .ifPresent(it -> database.remove(it.getId())); + } +} diff --git a/backend/src/test/java/com/carffeine/carffeine/auth/service/AuthServiceTest.java b/backend/src/test/java/com/carffeine/carffeine/auth/service/AuthServiceTest.java index 9e4747dbe..c4227b37d 100644 --- a/backend/src/test/java/com/carffeine/carffeine/auth/service/AuthServiceTest.java +++ b/backend/src/test/java/com/carffeine/carffeine/auth/service/AuthServiceTest.java @@ -1,11 +1,16 @@ package com.carffeine.carffeine.auth.service; +import com.carffeine.carffeine.auth.domain.FakeRefreshTokenRepository; import com.carffeine.carffeine.auth.domain.GoogleMember; import com.carffeine.carffeine.auth.domain.OAuthMember; +import com.carffeine.carffeine.auth.domain.RefreshToken; +import com.carffeine.carffeine.auth.domain.RefreshTokenRepository; import com.carffeine.carffeine.auth.domain.TokenProvider; import com.carffeine.carffeine.auth.service.dto.OAuthLoginRequest; +import com.carffeine.carffeine.auth.service.dto.Tokens; import com.carffeine.carffeine.fixture.oauth.OAuthFixture; import com.carffeine.carffeine.member.domain.FakeMemberRepository; +import com.carffeine.carffeine.member.domain.MemberRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator; @@ -25,17 +30,18 @@ class AuthServiceTest { @Mock private OAuthRequester oAuthRequester; - - private FakeMemberRepository memberRepository; - - private AuthService authService; @Mock private TokenProvider tokenProvider; + private MemberRepository memberRepository; + private RefreshTokenRepository refreshTokenRepository; + private AuthService authService; + @BeforeEach void setUp() { + refreshTokenRepository = new FakeRefreshTokenRepository(); memberRepository = new FakeMemberRepository(); - authService = new AuthService(tokenProvider, memberRepository, oAuthRequester); + authService = new AuthService(tokenProvider, memberRepository, refreshTokenRepository, oAuthRequester); } @Test @@ -44,15 +50,38 @@ void setUp() { given(oAuthRequester.login(any(), any())) .willReturn(new GoogleMember(OAuthFixture.구글_회원_정보)); given(tokenProvider.create(any())) - .willReturn("token"); + .willReturn("access token"); + given(tokenProvider.createRefreshToken(any())) + .willReturn("refresh token"); OAuthLoginRequest request = new OAuthLoginRequest("http://localhost:8080/", "carffeine"); String provider = "google"; // when OAuthMember oAuthMember = oAuthRequester.login(request, provider); - String token = authService.generateToken(oAuthMember); + Tokens token = authService.generateTokens(oAuthMember); + + // then + assertThat(token).isEqualTo(new Tokens("access token", "refresh token")); + } + + @Test + void access_token을_반환한다() { + // given + given(tokenProvider.create(any())) + .willReturn("access token"); + given(tokenProvider.extract(any())) + .willReturn("refresh token"); + + RefreshToken refreshToken = refreshTokenRepository.save(RefreshToken.builder() + .tokenId("refresh token") + .memberId(1L) + .build() + ); + + // when + String renewAccessToken = authService.renewAccessToken(refreshToken.getTokenId()); // then - assertThat(token).isEqualTo("token"); + assertThat(renewAccessToken).isEqualTo("access token"); } } diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index c6057e30c..957f513d6 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -34,4 +34,5 @@ oauth2: jwt: secret: qwertyuiioplkjhgfdsazxcvbnmkjhgfdqwertyuiioplkjhgfdsazxcvbnmkjhgfdqwertyuiioplkjhgfdsazxcvbnmkjhgfd - expiration-period: 10000 + access-token-expiration-period: 10000 + refresh-token-expiration-period: 10000 From f44fa29dd489d42654a7b08023fba1698badb2a5 Mon Sep 17 00:00:00 2001 From: drunkenhw Date: Fri, 29 Sep 2023 11:52:52 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20refresh=20token=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EB=B0=8F=20access=20token=20=EC=9E=AC=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [#815] --- backend/src/docs/asciidoc/auth.adoc | 21 ++++++- .../auth/controller/AuthController.java | 28 ++++++++- .../support/RefreshTokenCookieGenerator.java | 35 +++++++++++ .../auth/controller/AuthControllerTest.java | 61 ++++++++++++++++++- .../carffeine/helper/MockBeanInjection.java | 3 + 5 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 backend/src/main/java/com/carffeine/carffeine/auth/controller/support/RefreshTokenCookieGenerator.java diff --git a/backend/src/docs/asciidoc/auth.adoc b/backend/src/docs/asciidoc/auth.adoc index 440cf1cd7..63b491e7a 100644 --- a/backend/src/docs/asciidoc/auth.adoc +++ b/backend/src/docs/asciidoc/auth.adoc @@ -5,7 +5,7 @@ :toc: left :toclevels: 3 -== 충전소의 고장을 신고한다 (/oauth/{provider}/login-uri) +== 소셜 로그인 주소를 발급받는다 (/oauth/{provider}/login-uri) === Request @@ -26,3 +26,22 @@ include::{snippets}/auth-controller-test/login/request-fields.adoc[] include::{snippets}/auth-controller-test/login/http-response.adoc[] +== 새로운 access token을 발급받는다 (/renew) + +=== Request + +include::{snippets}/auth-controller-test/renew-access-token/http-request.adoc[] + +=== Response + +include::{snippets}/auth-controller-test/renew-access-token/http-response.adoc[] + +== 로그아웃을 한다 (/logout) + +=== Request + +include::{snippets}/auth-controller-test/logout/http-request.adoc[] + +=== Response + +include::{snippets}/auth-controller-test/logout/http-response.adoc[] diff --git a/backend/src/main/java/com/carffeine/carffeine/auth/controller/AuthController.java b/backend/src/main/java/com/carffeine/carffeine/auth/controller/AuthController.java index e79ec1a2a..f6973b612 100644 --- a/backend/src/main/java/com/carffeine/carffeine/auth/controller/AuthController.java +++ b/backend/src/main/java/com/carffeine/carffeine/auth/controller/AuthController.java @@ -2,12 +2,16 @@ import com.carffeine.carffeine.auth.controller.dto.LoginUriResponse; import com.carffeine.carffeine.auth.controller.dto.TokenResponse; +import com.carffeine.carffeine.auth.controller.support.AuthMember; +import com.carffeine.carffeine.auth.controller.support.RefreshTokenCookieGenerator; import com.carffeine.carffeine.auth.domain.OAuthMember; import com.carffeine.carffeine.auth.service.AuthService; import com.carffeine.carffeine.auth.service.OAuthRequester; import com.carffeine.carffeine.auth.service.dto.OAuthLoginRequest; +import com.carffeine.carffeine.auth.service.dto.Tokens; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -15,12 +19,15 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import static org.springframework.http.HttpHeaders.SET_COOKIE; + @RestController @RequiredArgsConstructor public class AuthController { private final AuthService authService; private final OAuthRequester oAuthRequester; + private final RefreshTokenCookieGenerator refreshTokenCookieGenerator; @GetMapping("/oauth/{provider}/login-uri") public ResponseEntity getRedirectUri( @@ -37,7 +44,24 @@ public ResponseEntity login( @PathVariable String provider ) { OAuthMember oAuthMember = oAuthRequester.login(request, provider); - String token = authService.generateToken(oAuthMember); - return ResponseEntity.ok(new TokenResponse(token)); + Tokens tokens = authService.generateTokens(oAuthMember); + return ResponseEntity.ok() + .header(SET_COOKIE, refreshTokenCookieGenerator.createCookie(tokens.refreshToken()).toString()) + .body(new TokenResponse(tokens.accessToken())); + } + + @PostMapping("/renew") + public ResponseEntity renewAccessToken(@CookieValue(value = "refresh-token") String refreshToken) { + String accessToken = authService.renewAccessToken(refreshToken); + return ResponseEntity.ok(new TokenResponse(accessToken)); } + + @PostMapping("/logout") + public ResponseEntity logOut(@AuthMember Long loginMember) { + authService.logout(loginMember); + return ResponseEntity.ok() + .header(SET_COOKIE, refreshTokenCookieGenerator.createLogoutCookie().toString()) + .build(); + } + } diff --git a/backend/src/main/java/com/carffeine/carffeine/auth/controller/support/RefreshTokenCookieGenerator.java b/backend/src/main/java/com/carffeine/carffeine/auth/controller/support/RefreshTokenCookieGenerator.java new file mode 100644 index 000000000..5e731b81a --- /dev/null +++ b/backend/src/main/java/com/carffeine/carffeine/auth/controller/support/RefreshTokenCookieGenerator.java @@ -0,0 +1,35 @@ +package com.carffeine.carffeine.auth.controller.support; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +public class RefreshTokenCookieGenerator { + + private static final String REFRESH_TOKEN = "refreshToken"; + private static final String VALID_COOKIE_PATH = "/"; + private static final String LOGOUT_COOKIE_VALUE = ""; + private static final int LOGOUT_COOKIE_AGE = 0; + + @Value("${jwt.refresh-token-expiration-period}") + private long expireLength; + + public ResponseCookie createCookie(String refreshToken) { + return ResponseCookie.from(REFRESH_TOKEN, refreshToken) + .maxAge(Duration.ofMillis(expireLength)) + .path(VALID_COOKIE_PATH) + .secure(true) + .httpOnly(true) + .build(); + } + + public ResponseCookie createLogoutCookie() { + return ResponseCookie.from(REFRESH_TOKEN, LOGOUT_COOKIE_VALUE) + .maxAge(LOGOUT_COOKIE_AGE) + .build(); + } + +} diff --git a/backend/src/test/java/com/carffeine/carffeine/auth/controller/AuthControllerTest.java b/backend/src/test/java/com/carffeine/carffeine/auth/controller/AuthControllerTest.java index 26b9df3a2..424f0b540 100644 --- a/backend/src/test/java/com/carffeine/carffeine/auth/controller/AuthControllerTest.java +++ b/backend/src/test/java/com/carffeine/carffeine/auth/controller/AuthControllerTest.java @@ -1,6 +1,7 @@ package com.carffeine.carffeine.auth.controller; import com.carffeine.carffeine.auth.service.dto.OAuthLoginRequest; +import com.carffeine.carffeine.auth.service.dto.Tokens; import com.carffeine.carffeine.helper.MockBeanInjection; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayNameGeneration; @@ -9,12 +10,20 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; import org.springframework.test.web.servlet.MockMvc; +import javax.servlet.http.Cookie; +import java.time.Duration; + import static com.carffeine.carffeine.helper.RestDocsHelper.customDocument; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; @@ -61,8 +70,15 @@ public class AuthControllerTest extends MockBeanInjection { String provider = "google"; // when - when(authService.generateToken(any())) - .thenReturn("access token"); + when(authService.generateTokens(any())) + .thenReturn(new Tokens("access token", "refreshToken")); + when(refreshTokenCookieGenerator.createCookie(anyString())) + .thenReturn(ResponseCookie.from("refresh-token", "refreshToken") + .maxAge(Duration.ofMillis(10000)) + .path("/") + .secure(true) + .httpOnly(true) + .build()); // then mockMvc.perform(post("/oauth/{provider}/login", provider) @@ -77,9 +93,50 @@ public class AuthControllerTest extends MockBeanInjection { ), responseFields( fieldWithPath("token").description("Access token") + ), + responseHeaders( //응답 헤더 문서화 + headerWithName(HttpHeaders.SET_COOKIE).description("refresh token") + ) + )); + } + + @Test + void refresh_token으로_access_token을_반환한다() throws Exception { + // given + String refreshToken = "your-refresh-token"; + String accessToken = "new-access-token"; + when(authService.renewAccessToken(refreshToken)).thenReturn(accessToken); + + // when & then + mockMvc.perform(post("/renew") + .cookie(new Cookie("refresh-token", refreshToken))) + .andExpect(status().isOk()) + .andDo(customDocument("renew-access-token", + responseFields( + fieldWithPath("token").description("Renewed access token") ) )); } + + @Test + public void logout을_한다() throws Exception { + // given + when(refreshTokenCookieGenerator.createLogoutCookie()) + .thenReturn(ResponseCookie.from("refreshToke", "") + .maxAge(0) + .build()); + + // when & then + mockMvc.perform(post("/logout") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer token~~")) + .andExpect(status().isOk()) + .andDo(customDocument("logout", + responseHeaders( + headerWithName("Set-Cookie") + .description("Logout cookie containing refreshed token") + ))); + } } diff --git a/backend/src/test/java/com/carffeine/carffeine/helper/MockBeanInjection.java b/backend/src/test/java/com/carffeine/carffeine/helper/MockBeanInjection.java index b8a5fbe99..9fee64588 100644 --- a/backend/src/test/java/com/carffeine/carffeine/helper/MockBeanInjection.java +++ b/backend/src/test/java/com/carffeine/carffeine/helper/MockBeanInjection.java @@ -5,6 +5,7 @@ import com.carffeine.carffeine.admin.service.AdminStationService; import com.carffeine.carffeine.auth.controller.AuthArgumentResolver; import com.carffeine.carffeine.auth.controller.support.AuthenticationContext; +import com.carffeine.carffeine.auth.controller.support.RefreshTokenCookieGenerator; import com.carffeine.carffeine.auth.domain.TokenProvider; import com.carffeine.carffeine.auth.service.AuthService; import com.carffeine.carffeine.auth.service.OAuthRequester; @@ -75,4 +76,6 @@ public class MockBeanInjection { protected FilterQueryService filterQueryService; @MockBean protected StationQueryService stationQueryService; + @MockBean + protected RefreshTokenCookieGenerator refreshTokenCookieGenerator; } From 2925286589dbc77a3ecd27f2eb27a4313e754a05 Mon Sep 17 00:00:00 2001 From: drunkenhw Date: Fri, 29 Sep 2023 12:04:43 +0900 Subject: [PATCH 4/7] =?UTF-8?q?chore:=20flyway=20script=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 [#815] --- .../resources/db/migration/V10__add_refresh_token.sql | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V10__add_refresh_token.sql diff --git a/backend/src/main/resources/db/migration/V10__add_refresh_token.sql b/backend/src/main/resources/db/migration/V10__add_refresh_token.sql new file mode 100644 index 000000000..fa1a3a80c --- /dev/null +++ b/backend/src/main/resources/db/migration/V10__add_refresh_token.sql @@ -0,0 +1,10 @@ + +create table if not exists refresh_token +( + id bigint not null auto_increment, + created_at timestamp not null, + updated_at timestamp not null, + member_id bigint not null, + token_id varchar(255) not null, + primary key (id) +) engine=InnoDB; From 5a47f9ad7bef3b3146e2e0094416c61f949839d9 Mon Sep 17 00:00:00 2001 From: drunkenhw Date: Fri, 29 Sep 2023 13:27:08 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=EC=BF=A0=ED=82=A4=20=EC=84=A4?= =?UTF-8?q?=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 [#815] --- .../support/RefreshTokenCookieGenerator.java | 4 +++ .../carffeine/auth/service/AuthService.java | 3 +- .../carffeine/carffeine/config/WebConfig.java | 2 ++ .../carffeine/carffeine/web/CorsFilter.java | 35 ++++++++----------- backend/src/test/resources/application.yml | 2 ++ 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/backend/src/main/java/com/carffeine/carffeine/auth/controller/support/RefreshTokenCookieGenerator.java b/backend/src/main/java/com/carffeine/carffeine/auth/controller/support/RefreshTokenCookieGenerator.java index 5e731b81a..8f98ca7af 100644 --- a/backend/src/main/java/com/carffeine/carffeine/auth/controller/support/RefreshTokenCookieGenerator.java +++ b/backend/src/main/java/com/carffeine/carffeine/auth/controller/support/RefreshTokenCookieGenerator.java @@ -21,6 +21,7 @@ public ResponseCookie createCookie(String refreshToken) { return ResponseCookie.from(REFRESH_TOKEN, refreshToken) .maxAge(Duration.ofMillis(expireLength)) .path(VALID_COOKIE_PATH) + .sameSite("None") .secure(true) .httpOnly(true) .build(); @@ -29,6 +30,9 @@ public ResponseCookie createCookie(String refreshToken) { public ResponseCookie createLogoutCookie() { return ResponseCookie.from(REFRESH_TOKEN, LOGOUT_COOKIE_VALUE) .maxAge(LOGOUT_COOKIE_AGE) + .sameSite("None") + .secure(true) + .httpOnly(true) .build(); } diff --git a/backend/src/main/java/com/carffeine/carffeine/auth/service/AuthService.java b/backend/src/main/java/com/carffeine/carffeine/auth/service/AuthService.java index 6a3a4334e..269696b0e 100644 --- a/backend/src/main/java/com/carffeine/carffeine/auth/service/AuthService.java +++ b/backend/src/main/java/com/carffeine/carffeine/auth/service/AuthService.java @@ -15,6 +15,7 @@ import java.util.UUID; +@Transactional @RequiredArgsConstructor @Service public class AuthService { @@ -28,7 +29,6 @@ public String loginUri(String redirectUri, String provider) { return oAuthRequester.loginUri(Provider.from(provider), redirectUri); } - @Transactional public Tokens generateTokens(OAuthMember oAuthMember) { Member member = getMember(oAuthMember); String tokenId = UUID.randomUUID().toString(); @@ -55,7 +55,6 @@ private Member getMember(OAuthMember oAuthMember) { .orElseGet(() -> memberRepository.save(newMember)); } - @Transactional(readOnly = true) public String renewAccessToken(String refreshToken) { String tokenId = tokenProvider.extract(refreshToken); return refreshTokenRepository.findByTokenId(tokenId) diff --git a/backend/src/main/java/com/carffeine/carffeine/config/WebConfig.java b/backend/src/main/java/com/carffeine/carffeine/config/WebConfig.java index c894e7215..c09bc0a04 100644 --- a/backend/src/main/java/com/carffeine/carffeine/config/WebConfig.java +++ b/backend/src/main/java/com/carffeine/carffeine/config/WebConfig.java @@ -4,10 +4,12 @@ import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import javax.servlet.Filter; +@Profile("!test") @Configuration public class WebConfig implements WebMvcConfigurer { diff --git a/backend/src/main/java/com/carffeine/carffeine/web/CorsFilter.java b/backend/src/main/java/com/carffeine/carffeine/web/CorsFilter.java index 144b5f4fa..d61e4bb8b 100644 --- a/backend/src/main/java/com/carffeine/carffeine/web/CorsFilter.java +++ b/backend/src/main/java/com/carffeine/carffeine/web/CorsFilter.java @@ -1,6 +1,5 @@ package com.carffeine.carffeine.web; -import org.springframework.http.HttpMethod; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; @@ -8,12 +7,16 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; -import java.util.Objects; public class CorsFilter extends OncePerRequestFilter { + + private static final String CARFFEIN_DOMAIN_SUFFIX = ".carffe.in"; + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - response.setHeader("Access-Control-Allow-Origin", "*"); + String origin = request.getHeader("Origin"); + + setOriginHeader(response, origin); response.setHeader("Access-Control-Allow-Credentials", "true"); response.setHeader("Access-Control-Allow-Methods", "*"); response.setHeader("Access-Control-Max-Age", "3600"); @@ -22,23 +25,13 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(request, response); } - private boolean isPreflightRequest(HttpServletRequest request) { - return isOptions(request) && hasHeaders(request) && hasMethod(request) && hasOrigin(request); - } - - private boolean isOptions(HttpServletRequest request) { - return request.getMethod().equalsIgnoreCase(HttpMethod.OPTIONS.toString()); - } - - private boolean hasHeaders(HttpServletRequest request) { - return Objects.nonNull(request.getHeader("Access-Control-Request-Headers")); - } - - private boolean hasMethod(HttpServletRequest request) { - return Objects.nonNull(request.getHeader("Access-Control-Request-Method")); - } - - private boolean hasOrigin(HttpServletRequest request) { - return Objects.nonNull(request.getHeader("Origin")); + private void setOriginHeader(HttpServletResponse response, String origin) { + if (origin == null) { + response.setHeader("Access-Control-Allow-Origin", "*"); + return; + } + if (origin.endsWith(CARFFEIN_DOMAIN_SUFFIX)) { + response.setHeader("Access-Control-Allow-Origin", origin); + } } } diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index 957f513d6..e8de7428d 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -13,6 +13,8 @@ spring: format_sql: true flyway: enabled: false + profiles: + active: test jasypt: encryptor: From 1814339628204a4f12f3ca547c0c04aaf5cfe4c1 Mon Sep 17 00:00:00 2001 From: drunkenhw Date: Fri, 29 Sep 2023 13:40:22 +0900 Subject: [PATCH 6/7] chore: stop congestion calculating [#815] --- .../station/controller/station/StationController.java | 2 +- .../carffeine/station/service/station/StationService.java | 5 ++--- .../station/controller/station/StationControllerTest.java | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/com/carffeine/carffeine/station/controller/station/StationController.java b/backend/src/main/java/com/carffeine/carffeine/station/controller/station/StationController.java index 6f0941547..ff39ca7a8 100644 --- a/backend/src/main/java/com/carffeine/carffeine/station/controller/station/StationController.java +++ b/backend/src/main/java/com/carffeine/carffeine/station/controller/station/StationController.java @@ -56,7 +56,7 @@ public ResponseEntity getStationsSummary(@RequestParam return ResponseEntity.ok(new StationsSummaryResponse(stations)); } - @GetMapping("/stations/regions") + @GetMapping("/stations/markers/regions") public ResponseEntity> getMarkerByRegions(@RequestParam List regions) { return ResponseEntity.ok(stationQueryService.findMarkersByRegions(regions)); } diff --git a/backend/src/main/java/com/carffeine/carffeine/station/service/station/StationService.java b/backend/src/main/java/com/carffeine/carffeine/station/service/station/StationService.java index a9b388a1c..b6003575b 100644 --- a/backend/src/main/java/com/carffeine/carffeine/station/service/station/StationService.java +++ b/backend/src/main/java/com/carffeine/carffeine/station/service/station/StationService.java @@ -8,7 +8,6 @@ import com.carffeine.carffeine.station.infrastructure.repository.charger.dto.ChargerStatusResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import java.time.DayOfWeek; @@ -26,7 +25,7 @@ public class StationService { private final PeriodicCongestionCustomRepository periodicCongestionCustomRepository; private final AtomicBoolean isRunning = new AtomicBoolean(false); - @Scheduled(cron = "0 0/10 * * * *") + // @Scheduled(cron = "0 0/10 * * * *") public void calculateCongestion() { if (isRunning.compareAndSet(false, true)) { LocalDateTime now = LocalDateTime.now(); @@ -36,7 +35,7 @@ public void calculateCongestion() { String stationId = null; String chargerId = null; - long limit = 1000; + long limit = 10000; long size = limit; while (limit == size) { diff --git a/backend/src/test/java/com/carffeine/carffeine/station/controller/station/StationControllerTest.java b/backend/src/test/java/com/carffeine/carffeine/station/controller/station/StationControllerTest.java index c78ec4e9e..277a9ae15 100644 --- a/backend/src/test/java/com/carffeine/carffeine/station/controller/station/StationControllerTest.java +++ b/backend/src/test/java/com/carffeine/carffeine/station/controller/station/StationControllerTest.java @@ -294,9 +294,9 @@ class StationControllerTest extends MockBeanInjection { // when when(stationQueryService.findMarkersByRegions(List.of("seoul"))) .thenReturn(List.of(new RegionMarker("서울", BigDecimal.valueOf(37.540705), BigDecimal.valueOf(126.956764), 1))); - + // then - mockMvc.perform(RestDocumentationRequestBuilders.get("/stations/regions").queryParam("regions", "seoul")) + mockMvc.perform(RestDocumentationRequestBuilders.get("/stations/markers/regions").queryParam("regions", "seoul")) .andExpect(status().isOk()) .andDo(customDocument("findMarkerByRegion", requestParameters( From d4167fd4f0d4c610322352956a36ac55f55d4993 Mon Sep 17 00:00:00 2001 From: drunkenhw Date: Fri, 29 Sep 2023 13:46:56 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20refresh=20token=20=EA=B8=B0=EA=B0=84?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [#815] --- backend/src/main/resources/application.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index cc4813126..99fbeafae 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -9,7 +9,6 @@ server: enabled: true mime-types: application/json min-response-size: 500 - spring: profiles: active: local @@ -21,6 +20,8 @@ spring: scheduling: pool: size: 10 + jpa: + open-in-view: false jasypt: encryptor: bean: jasyptEncryptor @@ -41,7 +42,7 @@ management: jwt: secret: ENC(t6iKCv1Nj+ygRxG2oZCw3l4v9y0ALS2GfQwYIKvO0tRuPzFp4UYRZYfBFdBW+xQl77rOLbfIPJ13tys/tXj3Qw==) access-token-expiration-period: ENC(Ua9DcFiPKX/5r0/WGkN5JQ==) - refresh-token-expiration-period: ENC(Ua9DcFiPKX/5r0/WGkN5JQ==) + refresh-token-expiration-period: ENC(DTClD6YHtMsb1xAL6ZbQcA==) oauth2: provider: