Skip to content

Commit

Permalink
Merge pull request #217 from bucket-back/OMCT-411-refresh-token
Browse files Browse the repository at this point in the history
[OMCT-411] 로그인 연장 기능 추가 - Refresh token으로 Access token 재발급
  • Loading branch information
Yiseull authored Jan 5, 2024
2 parents 905d439 + 3266bee commit a908fa4
Show file tree
Hide file tree
Showing 17 changed files with 268 additions and 111 deletions.
4 changes: 4 additions & 0 deletions bucketback-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ dependencies {
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

// Cache
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation "com.github.ben-manes.caffeine:caffeine:3.1.8"
}

jar {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
package com.programmers.bucketback.domains.member.api;

import static org.springframework.http.HttpHeaders.*;

import java.io.IOException;

import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
Expand All @@ -21,6 +26,7 @@
import com.programmers.bucketback.domains.member.api.dto.request.MemberSignupRequest;
import com.programmers.bucketback.domains.member.api.dto.request.MemberUpdatePasswordRequest;
import com.programmers.bucketback.domains.member.api.dto.request.MemberUpdateProfileRequest;
import com.programmers.bucketback.domains.member.api.dto.response.AccessTokenResponse;
import com.programmers.bucketback.domains.member.api.dto.response.MemberCheckEmailResponse;
import com.programmers.bucketback.domains.member.api.dto.response.MemberCheckJwtResponse;
import com.programmers.bucketback.domains.member.api.dto.response.MemberCheckNicknameResponse;
Expand All @@ -33,6 +39,7 @@

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

Expand All @@ -42,6 +49,8 @@
@RequestMapping("/api/members")
public class MemberController {

public static final int COOKIE_AGE_SECONDS = 1209600;

private final MemberService memberService;

@Operation(summary = "JWT 토큰 유효성 체크")
Expand All @@ -63,17 +72,49 @@ public ResponseEntity<Void> signup(@Valid @RequestBody final MemberSignupRequest

@Operation(summary = "로그인", description = "MemberLoginRequest 을 이용하여 로그인을 합니다.")
@PostMapping("/login")
public ResponseEntity<MemberLoginResponse> login(@Valid @RequestBody final MemberLoginRequest request) {
public ResponseEntity<MemberLoginResponse> login(
HttpServletResponse httpServletResponse,
@Valid @RequestBody final MemberLoginRequest request
) {
final MemberLoginServiceResponse serviceResponse = memberService.login(request.toLoginInfo());
final MemberLoginResponse response = MemberLoginResponse.from(serviceResponse);

final ResponseCookie cookie = ResponseCookie.from("refresh-token", serviceResponse.refreshToken())
.maxAge(COOKIE_AGE_SECONDS)
.secure(true)
.httpOnly(true)
.sameSite("None")
.path("/")
.build();
httpServletResponse.addHeader(SET_COOKIE, cookie.toString());

return ResponseEntity.ok(response);
}

@Operation(summary = "로그인 연장", description = "Refresh Token 을 이용하여 Access Token 을 재발급 받습니다.")
@PostMapping("/refresh")
public ResponseEntity<AccessTokenResponse> extendLogin(
@CookieValue("refresh-token") final String refreshToken,
@RequestHeader("Authorization") final String authorizationHeader
) {
final String accessToken = memberService.extendLogin(refreshToken, authorizationHeader);
final AccessTokenResponse response = new AccessTokenResponse(accessToken);

return ResponseEntity.ok(response);
}

@Operation(summary = "로그아웃")
@DeleteMapping("/logout")
public ResponseEntity<Void> logout(@CookieValue("refresh-token") final String refreshToken) {
memberService.logout(refreshToken);

return ResponseEntity.ok().build();
}

@Operation(summary = "회원 탈퇴")
@DeleteMapping("/delete")
public ResponseEntity<Void> deleteMember() {
memberService.deleteMember();
public ResponseEntity<Void> deleteMember(@CookieValue("refresh-token") final String refreshToken) {
memberService.deleteMember(refreshToken);

return ResponseEntity.ok().build();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.programmers.bucketback.domains.member.api.dto.response;

public record AccessTokenResponse(
String accessToken
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
public record MemberLoginResponse(
Long memberId,
String nickname,
String jwtToken
String accessToken
) {
public static MemberLoginResponse from(final MemberLoginServiceResponse serviceResponse) {
return new MemberLoginResponse(
serviceResponse.memberId(),
serviceResponse.nickname(),
serviceResponse.jwtToken()
serviceResponse.accessToken()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,25 @@ public MemberLoginServiceResponse login(
final String nickname = member.getNickname();

securityManager.authenticate(memberId, rawPassword);
final String jwtToken = securityManager.generateToken(member);
final String accessToken = securityManager.generateAccessToken(memberId);
final String refreshToken = securityManager.generateRefreshToken(memberId);

return new MemberLoginServiceResponse(memberId, nickname, jwtToken);
return new MemberLoginServiceResponse(memberId, nickname, accessToken, refreshToken);
}

public String encodePassword(final String password) {
Member.validatePassword(password);
return passwordEncoder.encode(password);
}

public void removeRefreshToken(final String refreshToken) {
securityManager.removeRefreshToken(refreshToken);
}

public String reissueAccessToken(
final String refreshToken,
final String authorizationHeader
) {
return securityManager.reissueAccessToken(refreshToken, authorizationHeader);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,21 @@ public MemberLoginServiceResponse login(final LoginInfo loginInfo) {
return memberSecurityManager.login(rawPassword, member);
}

public void deleteMember() {
final Member member = memberUtils.getCurrentMember();
public String extendLogin(
final String refreshToken,
final String authorizationHeader
) {
return memberSecurityManager.reissueAccessToken(refreshToken, authorizationHeader);
}

public void logout(final String refreshToken) {
memberSecurityManager.removeRefreshToken(refreshToken);
}

public void deleteMember(final String refreshToken) {
final Member member = memberUtils.getCurrentMember();
memberRemover.remove(member);
memberSecurityManager.removeRefreshToken(refreshToken);
}

public void updateProfile(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
public record MemberLoginServiceResponse(
Long memberId,
String nickname,
String jwtToken
String accessToken,
String refreshToken
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.programmers.bucketback.global.cache;

import java.util.Arrays;
import java.util.List;
import java.util.concurrent.TimeUnit;

import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import com.github.benmanes.caffeine.cache.Caffeine;

@EnableCaching
@Configuration
public class CacheConfiguration {
@Bean
public CacheManager cacheManager() {
final List<CaffeineCache> caffeineCaches = Arrays.stream(CacheType.values())
.map(cache -> new CaffeineCache(cache.getCacheName(), Caffeine.newBuilder().recordStats()
.expireAfterWrite(cache.getExpireAfterWrite(), TimeUnit.SECONDS)
.maximumSize(cache.getEntryMaxSize())
.build()))
.toList();

final SimpleCacheManager cacheManager = new SimpleCacheManager();
cacheManager.setCaches(caffeineCaches);

return cacheManager;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.programmers.bucketback.global.cache;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum CacheType {

REFRESH_TOKEN("refreshToken", 1209600, 10000)
;

private final String cacheName;
private final int expireAfterWrite;
private final int entryMaxSize;
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws E
.requestMatchers("/api/members/check/nickname").permitAll()
.requestMatchers("/api/members/check/email").permitAll()
.requestMatchers("/api/members/{nickname}").permitAll()
.requestMatchers("/api/members/refresh").permitAll()

.requestMatchers(HttpMethod.GET, "/api/votes").permitAll()
.requestMatchers(HttpMethod.GET, "/api/votes/{voteId}").permitAll()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
package com.programmers.bucketback.global.config.security;

import org.springframework.cache.CacheManager;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.stereotype.Component;

import com.programmers.bucketback.domains.member.domain.Member;
import com.programmers.bucketback.global.config.security.jwt.JwtService;

import io.jsonwebtoken.JwtException;
import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class SecurityManager {

public static final String REFRESH_TOKEN_CACHE = "refreshToken";

private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
private final CacheManager cacheManager;

public void authenticate(
final Long memberId,
Expand All @@ -26,9 +30,34 @@ public void authenticate(
authenticationManager.authenticate(authenticationToken);
}

public String generateToken(final Member member) {
final MemberSecurity memberSecurity = new MemberSecurity(member);
public String generateAccessToken(final Long memberId) {
return jwtService.generateAccessToken(memberId.toString());
}

public String generateRefreshToken(final Long memberId) {
final String refreshToken = jwtService.generateRefreshToken();
cacheManager.getCache(REFRESH_TOKEN_CACHE).put(refreshToken, memberId);

return refreshToken;
}

public void removeRefreshToken(final String refreshToken) {
cacheManager.getCache(REFRESH_TOKEN_CACHE).evict(refreshToken);
}

public String reissueAccessToken(final String refreshToken, final String authorizationHeader) {
final String accessToken = authorizationHeader.substring(7);

if (jwtService.isRefreshValidAndAccessInValid(refreshToken, accessToken)) {
final Long memberId = cacheManager.getCache(REFRESH_TOKEN_CACHE).get(refreshToken, Long.class);

return generateAccessToken(memberId);
}

if (jwtService.isAccessTokenValid(accessToken)) {
return accessToken;
}

return jwtService.generateToken(memberSecurity);
throw new JwtException("Refresh Token이 유효하지 않습니다.");
}
}
Loading

0 comments on commit a908fa4

Please sign in to comment.