Skip to content

Commit

Permalink
feat: Refresh Token 구현 (#143)
Browse files Browse the repository at this point in the history
* build: redis 의존성 추가

* feat: accessToken 재발급 기능 구현

* refactor: 주석 수정, 메소드 분리

* chore: TODO 주석 추가

* refactor: 메소드명 변경

* fix: refreshToken 재발급 로직 추가

* fix: 토큰 만료 / 미인증 응답 구분 추가, 메소드명 변경
  • Loading branch information
eunbc committed Jan 7, 2024
1 parent 26e891e commit fba3290
Show file tree
Hide file tree
Showing 17 changed files with 167 additions and 47 deletions.
1 change: 1 addition & 0 deletions api/api-member/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies {

implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

runtimeOnly 'com.h2database:h2'
Expand Down
12 changes: 12 additions & 0 deletions api/api-member/http/test.http
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ Content-Type: application/json
"password": "user1234"
}

### 회원 목록 조회
GET http://localhost:8081/api/v1/admin/members
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Miwic3ViIjoiYWRtaW5AZXhhbXBsZS5jb20iLCJpYXQiOjE3MDQ2MDg3MzQsImV4cCI6MTcwNDYxMDUzNCwiYXV0aG9yaXR5IjoiUk9MRV9BRE1JTiJ9.3EHS2VF2XqW4xfE1W0iFMUaK1P3r2wpFgK9imzw3xp4

### 토큰 갱신
POST http://localhost:8081/api/v1/auth/refresh
Content-Type: application/json

{
"refreshToken": "a718e554-fad4-48c2-a131-d73228937605"
}

### 유저 일반 회원가입
POST http://localhost:8081/api/v1/members/signup
Content-Type: application/json
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
import org.springframework.web.bind.annotation.RestController;

import com.pgms.apimember.dto.request.LoginRequest;
import com.pgms.apimember.dto.response.LoginResponse;
import com.pgms.apimember.dto.request.RefreshTokenRequest;
import com.pgms.apimember.dto.response.AuthResponse;
import com.pgms.apimember.service.AuthService;
import com.pgms.coredomain.response.ApiResponse;

Expand All @@ -22,23 +23,27 @@ public class AuthController {
private final AuthService authService;

/**
* 로그인, 토큰 발급
* 로그인
*/
@PostMapping("/admin/login")
public ResponseEntity<ApiResponse<LoginResponse>> adminLogin(@Valid @RequestBody LoginRequest request) {
LoginResponse response = authService.login(request, "admin");
public ResponseEntity<ApiResponse<AuthResponse>> adminLogin(@Valid @RequestBody LoginRequest request) {
AuthResponse response = authService.login(request, "admin");
return ResponseEntity.ok(ApiResponse.ok(response));
}

@PostMapping("/members/login")
public ResponseEntity<ApiResponse<LoginResponse>> memberLogin(@Valid @RequestBody LoginRequest request) {
public ResponseEntity<ApiResponse<AuthResponse>> memberLogin(@Valid @RequestBody LoginRequest request) {
// TODO: 나중에 enum으로..?
LoginResponse response = authService.login(request, "member");
AuthResponse response = authService.login(request, "member");
return ResponseEntity.ok(ApiResponse.ok(response));
}

/**
* TODO 토큰 재발급
* 토큰 재발급
*/

@PostMapping("/refresh")
public ResponseEntity<ApiResponse<AuthResponse>> refresh(@RequestBody RefreshTokenRequest request) {
AuthResponse response = authService.refresh(request);
return ResponseEntity.ok(ApiResponse.ok(response));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.pgms.apimember.dto.request;

import jakarta.validation.constraints.NotNull;

public record RefreshTokenRequest(@NotNull String refreshToken) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.pgms.apimember.dto.response;

public record AuthResponse(String accessToken, String refreshToken) {
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ public enum CustomErrorCode {
MEMBER_ALREADY_DELETED("MEMBER ALREADY DELETED", HttpStatus.BAD_REQUEST, "이미 탈퇴한 회원입니다."),
PASSWORD_NOT_MATCHED("PASSWORD NOT MATCHED", HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다."),
PASSWORD_CONFIRM_NOT_MATCHED("PASSWORD CONFIRM NOT MATCHED", HttpStatus.BAD_REQUEST, "비밀번호 확인이 일치하지 않습니다."),
VALIDATION_FAILED("VALIDATION FAILED", HttpStatus.BAD_REQUEST, "입력값에 대한 검증에 실패했습니다.");
VALIDATION_FAILED("VALIDATION FAILED", HttpStatus.BAD_REQUEST, "입력값에 대한 검증에 실패했습니다."),

// security
UNAUTHORIZED("UNAUTHORIZED", HttpStatus.UNAUTHORIZED, "로그인 해주세요."),
REFRESH_TOKEN_EXPIRED("REFRESH TOKEN EXPIRED", HttpStatus.UNAUTHORIZED, "다시 로그인 해주세요.");

private final String errorCode;
private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.pgms.apimember.exception;

public class SecurityException extends CustomException {
public SecurityException(CustomErrorCode errorCode) {
super(errorCode);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.pgms.apimember.redis;

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;

@Configuration
public class RedisConfig {

@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.pgms.apimember.redis;

import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
@RedisHash(value = "token", timeToLive = 60 * 60 * 24 * 7) // 7일
public class RefreshToken {
@Id
private String refreshToken;
private String accessToken;
private String accountType; // admin, member -> TODO : enum으로 개선
private String email;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.pgms.apimember.redis;

import org.springframework.data.repository.CrudRepository;

public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@
import org.springframework.transaction.annotation.Transactional;

import com.pgms.apimember.dto.request.LoginRequest;
import com.pgms.apimember.dto.response.LoginResponse;
import com.pgms.coresecurity.security.jwt.JwtUtils;
import com.pgms.apimember.dto.request.RefreshTokenRequest;
import com.pgms.apimember.dto.response.AuthResponse;
import com.pgms.apimember.exception.CustomErrorCode;
import com.pgms.apimember.exception.SecurityException;
import com.pgms.apimember.redis.RefreshToken;
import com.pgms.apimember.redis.RefreshTokenRepository;
import com.pgms.coresecurity.security.jwt.JwtTokenProvider;
import com.pgms.coresecurity.security.service.AdminUserDetailsService;
import com.pgms.coresecurity.security.service.MemberUserDetailsService;
import com.pgms.coresecurity.security.service.UserDetailsImpl;

import lombok.RequiredArgsConstructor;
Expand All @@ -20,9 +27,12 @@
public class AuthService {

private final AuthenticationManager authenticationManager;
private final JwtUtils jwtUtils;
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
private final AdminUserDetailsService adminUserDetailsService;
private final MemberUserDetailsService memberUserDetailsService;

public LoginResponse login(LoginRequest request, String accountType) {
public AuthResponse login(LoginRequest request, String accountType) {
// 인증 전의 auth 객체
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
request.email(),
Expand All @@ -34,13 +44,40 @@ public LoginResponse login(LoginRequest request, String accountType) {
Authentication authenticated = authenticationManager.authenticate(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);

// jwt 생성
String jwt = jwtUtils.generateJwtToken(authenticated);
// accessToken, refreshToken 생성
String accessToken = jwtTokenProvider.generateAccessToken((UserDetailsImpl)authenticated.getPrincipal());
String refreshToken = jwtTokenProvider.generateRefreshToken();

UserDetailsImpl userDetails = (UserDetailsImpl)authenticated.getPrincipal();
return new LoginResponse(jwt,
userDetails.getId(),
userDetails.getEmail(),
userDetails.getAuthorities().stream().findFirst().get().getAuthority());
// redis에 토큰 정보 저장
refreshTokenRepository.save(new RefreshToken(refreshToken, accessToken, accountType,
((UserDetailsImpl)authenticated.getPrincipal()).getEmail()));
return new AuthResponse(accessToken, refreshToken);
}

public AuthResponse refresh(RefreshTokenRequest request) {
// refresh token이 만료됐는지 확인
RefreshToken refreshToken = refreshTokenRepository.findById(request.refreshToken())
.orElseThrow(() -> new SecurityException(CustomErrorCode.REFRESH_TOKEN_EXPIRED));

// 회원 정보 로드
UserDetailsImpl userDetails = loadUserDetails(refreshToken.getAccountType(), refreshToken.getEmail());

// 새로운 accessToken, refreshToken 발급
String newAccessToken = jwtTokenProvider.generateAccessToken(userDetails);
String newRefreshToken = jwtTokenProvider.generateRefreshToken();

// 기존 refreshToken 삭제, redis에 토큰 정보 저장
refreshTokenRepository.delete(refreshToken);
refreshTokenRepository.save(new RefreshToken(newRefreshToken, newAccessToken,
refreshToken.getAccountType(), refreshToken.getEmail()));
return new AuthResponse(newAccessToken, refreshToken.getRefreshToken());
}

private UserDetailsImpl loadUserDetails(String accountType, String email) {
if ("admin".equals(accountType)) {
return (UserDetailsImpl)adminUserDetailsService.loadUserByUsername(email);
} else {
return (UserDetailsImpl)memberUserDetailsService.loadUserByUsername(email);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ public void commence(HttpServletRequest request, HttpServletResponse response,
throws IOException {
log.warn("Unauthorized: ", authException);

ErrorResponse errorResponse = new ErrorResponse("UNAUTHORIZED", "로그인 해주세요.");
ErrorResponse errorResponse;
if (request.getAttribute("expired") != null) {
errorResponse = new ErrorResponse("ACCESS_TOKEN_EXPIRED", "토큰이 만료되었습니다.");
} else {
errorResponse = new ErrorResponse("UNAUTHORIZED", "로그인 해주세요.");
}

response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import io.jsonwebtoken.ExpiredJwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
Expand All @@ -17,17 +18,20 @@
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtils jwtUtils;
private final JwtTokenProvider jwtTokenProvider;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
String jwt = parseJwt(request);
if (jwt != null && jwtUtils.validateJwtToken(jwt)) {
Authentication authentication = jwtUtils.getAuthentication(jwt);
String accessToken = parseJwt(request);
if (accessToken != null && jwtTokenProvider.validateAccessToken(accessToken)) {
Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (ExpiredJwtException e) {
logger.warn("JWT token is expired: ", e);
request.setAttribute("expired", e.getMessage());
} catch (Exception e) {
logger.error("Cannot set user authentication: ", e);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.UUID;
import java.util.stream.Collectors;

import org.slf4j.Logger;
Expand All @@ -20,7 +21,6 @@
import com.pgms.coresecurity.security.service.UserDetailsImpl;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
Expand All @@ -29,28 +29,26 @@
import io.jsonwebtoken.security.Keys;

@Component
public class JwtUtils {
private static final Logger logger = LoggerFactory.getLogger(JwtUtils.class);
public class JwtTokenProvider {
private static final Logger logger = LoggerFactory.getLogger(JwtTokenProvider.class);

@Value("${jwt.secret-key}")
private String secretKey;

@Value("${jwt.expiry-seconds}")
private int expirySeconds;

public String generateJwtToken(Authentication authentication) {
UserDetailsImpl userPrincipal = (UserDetailsImpl)authentication.getPrincipal();

public String generateAccessToken(UserDetailsImpl userDetails) {
Instant now = Instant.now();
Instant expirationTime = now.plusSeconds(expirySeconds);

String authorities = userPrincipal.getAuthorities().stream()
String authorities = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));

return Jwts.builder()
.claim("id", userPrincipal.getId())
.setSubject((userPrincipal.getUsername()))
.claim("id", userDetails.getId())
.setSubject((userDetails.getUsername()))
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(expirationTime))
.claim("authority", authorities)
Expand All @@ -62,11 +60,11 @@ private Key key() {
return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
}

public Authentication getAuthentication(String token) {
public Authentication getAuthentication(String accessToken) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(key())
.build()
.parseClaimsJws(token)
.parseClaimsJws(accessToken)
.getBody();

Collection<? extends GrantedAuthority> authorities =
Expand All @@ -79,14 +77,12 @@ public Authentication getAuthentication(String token) {
return new UsernamePasswordAuthenticationToken(principal, null, authorities);
}

public boolean validateJwtToken(String authToken) {
public boolean validateAccessToken(String authToken) {
try {
Jwts.parserBuilder().setSigningKey(key()).build().parse(authToken);
return true;
} catch (MalformedJwtException e) {
logger.error("Invalid JWT token: {}", e.getMessage());
} catch (ExpiredJwtException e) {
logger.error("JWT token is expired: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
logger.error("JWT token is unsupported: {}", e.getMessage());
} catch (IllegalArgumentException e) {
Expand All @@ -95,4 +91,8 @@ public boolean validateJwtToken(String authToken) {

return false;
}

public String generateRefreshToken() {
return UUID.randomUUID().toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import com.pgms.coredomain.domain.member.enums.Provider;
import com.pgms.coredomain.domain.member.repository.MemberRepository;
import com.pgms.coredomain.domain.member.repository.RoleRepository;
import com.pgms.coresecurity.security.jwt.JwtUtils;
import com.pgms.coresecurity.security.jwt.JwtTokenProvider;
import com.pgms.coresecurity.security.util.HttpResponseUtil;

import jakarta.servlet.http.HttpServletRequest;
Expand All @@ -33,7 +33,7 @@ public class OAuth2AuthenticationSuccessHandler implements AuthenticationSuccess

private final MemberRepository memberRepository;
private final RoleRepository roleRepository;
private final JwtUtils jwtUtils;
private final JwtTokenProvider jwtTokenProvider;

@Override
@Transactional
Expand All @@ -57,7 +57,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo

// 토큰 생성 후 반환
Map<String, Object> body = new HashMap<>();
body.put("accessToken", jwtUtils.generateJwtToken(authenticated));
body.put("accessToken", jwtTokenProvider.generateAccessToken((UserDetailsImpl)authenticated.getPrincipal()));
HttpResponseUtil.setSuccessResponse(response, HttpStatus.OK, body);
}

Expand Down
Loading

0 comments on commit fba3290

Please sign in to comment.