diff --git a/api/api-member/build.gradle b/api/api-member/build.gradle index 4c0d53d8..f92e3056 100644 --- a/api/api-member/build.gradle +++ b/api/api-member/build.gradle @@ -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' diff --git a/api/api-member/http/test.http b/api/api-member/http/test.http index 185e22f6..0aecfb18 100644 --- a/api/api-member/http/test.http +++ b/api/api-member/http/test.http @@ -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 diff --git a/api/api-member/src/main/java/com/pgms/apimember/controller/AuthController.java b/api/api-member/src/main/java/com/pgms/apimember/controller/AuthController.java index d043a6a5..859d7371 100644 --- a/api/api-member/src/main/java/com/pgms/apimember/controller/AuthController.java +++ b/api/api-member/src/main/java/com/pgms/apimember/controller/AuthController.java @@ -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; @@ -22,23 +23,27 @@ public class AuthController { private final AuthService authService; /** - * 로그인, 토큰 발급 + * 로그인 */ @PostMapping("/admin/login") - public ResponseEntity> adminLogin(@Valid @RequestBody LoginRequest request) { - LoginResponse response = authService.login(request, "admin"); + public ResponseEntity> adminLogin(@Valid @RequestBody LoginRequest request) { + AuthResponse response = authService.login(request, "admin"); return ResponseEntity.ok(ApiResponse.ok(response)); } @PostMapping("/members/login") - public ResponseEntity> memberLogin(@Valid @RequestBody LoginRequest request) { + public ResponseEntity> 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> refresh(@RequestBody RefreshTokenRequest request) { + AuthResponse response = authService.refresh(request); + return ResponseEntity.ok(ApiResponse.ok(response)); + } } diff --git a/api/api-member/src/main/java/com/pgms/apimember/dto/request/RefreshTokenRequest.java b/api/api-member/src/main/java/com/pgms/apimember/dto/request/RefreshTokenRequest.java new file mode 100644 index 00000000..4d7a9d68 --- /dev/null +++ b/api/api-member/src/main/java/com/pgms/apimember/dto/request/RefreshTokenRequest.java @@ -0,0 +1,6 @@ +package com.pgms.apimember.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record RefreshTokenRequest(@NotNull String refreshToken) { +} diff --git a/api/api-member/src/main/java/com/pgms/apimember/dto/response/AuthResponse.java b/api/api-member/src/main/java/com/pgms/apimember/dto/response/AuthResponse.java new file mode 100644 index 00000000..0a22c05d --- /dev/null +++ b/api/api-member/src/main/java/com/pgms/apimember/dto/response/AuthResponse.java @@ -0,0 +1,4 @@ +package com.pgms.apimember.dto.response; + +public record AuthResponse(String accessToken, String refreshToken) { +} diff --git a/api/api-member/src/main/java/com/pgms/apimember/dto/response/LoginResponse.java b/api/api-member/src/main/java/com/pgms/apimember/dto/response/LoginResponse.java deleted file mode 100644 index 1bf055a4..00000000 --- a/api/api-member/src/main/java/com/pgms/apimember/dto/response/LoginResponse.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.pgms.apimember.dto.response; - -public record LoginResponse(String jwtToken, Long id, String email, String roleName) { -} diff --git a/api/api-member/src/main/java/com/pgms/apimember/exception/CustomErrorCode.java b/api/api-member/src/main/java/com/pgms/apimember/exception/CustomErrorCode.java index ae703258..28ed61c1 100644 --- a/api/api-member/src/main/java/com/pgms/apimember/exception/CustomErrorCode.java +++ b/api/api-member/src/main/java/com/pgms/apimember/exception/CustomErrorCode.java @@ -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; diff --git a/api/api-member/src/main/java/com/pgms/apimember/exception/SecurityException.java b/api/api-member/src/main/java/com/pgms/apimember/exception/SecurityException.java new file mode 100644 index 00000000..b80f930b --- /dev/null +++ b/api/api-member/src/main/java/com/pgms/apimember/exception/SecurityException.java @@ -0,0 +1,7 @@ +package com.pgms.apimember.exception; + +public class SecurityException extends CustomException { + public SecurityException(CustomErrorCode errorCode) { + super(errorCode); + } +} diff --git a/api/api-member/src/main/java/com/pgms/apimember/redis/RedisConfig.java b/api/api-member/src/main/java/com/pgms/apimember/redis/RedisConfig.java new file mode 100644 index 00000000..d4c9fa59 --- /dev/null +++ b/api/api-member/src/main/java/com/pgms/apimember/redis/RedisConfig.java @@ -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(); + } +} diff --git a/api/api-member/src/main/java/com/pgms/apimember/redis/RefreshToken.java b/api/api-member/src/main/java/com/pgms/apimember/redis/RefreshToken.java new file mode 100644 index 00000000..a313e05b --- /dev/null +++ b/api/api-member/src/main/java/com/pgms/apimember/redis/RefreshToken.java @@ -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; +} diff --git a/api/api-member/src/main/java/com/pgms/apimember/redis/RefreshTokenRepository.java b/api/api-member/src/main/java/com/pgms/apimember/redis/RefreshTokenRepository.java new file mode 100644 index 00000000..f0cb990d --- /dev/null +++ b/api/api-member/src/main/java/com/pgms/apimember/redis/RefreshTokenRepository.java @@ -0,0 +1,6 @@ +package com.pgms.apimember.redis; + +import org.springframework.data.repository.CrudRepository; + +public interface RefreshTokenRepository extends CrudRepository { +} diff --git a/api/api-member/src/main/java/com/pgms/apimember/service/AuthService.java b/api/api-member/src/main/java/com/pgms/apimember/service/AuthService.java index 5abc3f38..1e8f51b1 100644 --- a/api/api-member/src/main/java/com/pgms/apimember/service/AuthService.java +++ b/api/api-member/src/main/java/com/pgms/apimember/service/AuthService.java @@ -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; @@ -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(), @@ -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); + } } } diff --git a/core/core-security/src/main/java/com/pgms/coresecurity/security/jwt/JwtAuthenticationEntryPoint.java b/core/core-security/src/main/java/com/pgms/coresecurity/security/jwt/JwtAuthenticationEntryPoint.java index f2d46706..87a9a527 100644 --- a/core/core-security/src/main/java/com/pgms/coresecurity/security/jwt/JwtAuthenticationEntryPoint.java +++ b/core/core-security/src/main/java/com/pgms/coresecurity/security/jwt/JwtAuthenticationEntryPoint.java @@ -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()); diff --git a/core/core-security/src/main/java/com/pgms/coresecurity/security/jwt/JwtAuthenticationFilter.java b/core/core-security/src/main/java/com/pgms/coresecurity/security/jwt/JwtAuthenticationFilter.java index 62ff13ed..fb41e5d3 100644 --- a/core/core-security/src/main/java/com/pgms/coresecurity/security/jwt/JwtAuthenticationFilter.java +++ b/core/core-security/src/main/java/com/pgms/coresecurity/security/jwt/JwtAuthenticationFilter.java @@ -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; @@ -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); } diff --git a/core/core-security/src/main/java/com/pgms/coresecurity/security/jwt/JwtUtils.java b/core/core-security/src/main/java/com/pgms/coresecurity/security/jwt/JwtTokenProvider.java similarity index 81% rename from core/core-security/src/main/java/com/pgms/coresecurity/security/jwt/JwtUtils.java rename to core/core-security/src/main/java/com/pgms/coresecurity/security/jwt/JwtTokenProvider.java index dc33d68c..843992b6 100644 --- a/core/core-security/src/main/java/com/pgms/coresecurity/security/jwt/JwtUtils.java +++ b/core/core-security/src/main/java/com/pgms/coresecurity/security/jwt/JwtTokenProvider.java @@ -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; @@ -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; @@ -29,8 +29,8 @@ 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; @@ -38,19 +38,17 @@ public class JwtUtils { @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) @@ -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 authorities = @@ -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) { @@ -95,4 +91,8 @@ public boolean validateJwtToken(String authToken) { return false; } + + public String generateRefreshToken() { + return UUID.randomUUID().toString(); + } } diff --git a/core/core-security/src/main/java/com/pgms/coresecurity/security/service/OAuth2AuthenticationSuccessHandler.java b/core/core-security/src/main/java/com/pgms/coresecurity/security/service/OAuth2AuthenticationSuccessHandler.java index ca770a25..9eb05eac 100644 --- a/core/core-security/src/main/java/com/pgms/coresecurity/security/service/OAuth2AuthenticationSuccessHandler.java +++ b/core/core-security/src/main/java/com/pgms/coresecurity/security/service/OAuth2AuthenticationSuccessHandler.java @@ -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; @@ -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 @@ -57,7 +57,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo // 토큰 생성 후 반환 Map body = new HashMap<>(); - body.put("accessToken", jwtUtils.generateJwtToken(authenticated)); + body.put("accessToken", jwtTokenProvider.generateAccessToken((UserDetailsImpl)authenticated.getPrincipal())); HttpResponseUtil.setSuccessResponse(response, HttpStatus.OK, body); } diff --git a/core/core-security/src/main/resources/application-security.yml b/core/core-security/src/main/resources/application-security.yml index 67091d6f..31725b3f 100644 --- a/core/core-security/src/main/resources/application-security.yml +++ b/core/core-security/src/main/resources/application-security.yml @@ -26,4 +26,4 @@ spring: jwt: secret-key: EENY5W0eegTf1naQB2eDeyCLl5kRS2b8xa5c4qLdS0hmVjtbvo8tOyhPMcAmtPuQ # TODO : 시크릿 키 따로 관리 - expiry-seconds: 360000000 + expiry-seconds: 1800 #30분