diff --git a/build.gradle b/build.gradle index d49c9074..b75f73db 100644 --- a/build.gradle +++ b/build.gradle @@ -31,6 +31,7 @@ dependencies { // 데이터베이스 implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'mysql:mysql-connector-java:8.0.33' // MySQL 의존성 추가 (MySQL JDBC 드라이버) + runtimeOnly 'com.h2database:h2' // 보안 implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' @@ -63,6 +64,9 @@ dependencies { //POI implementation 'org.apache.poi:poi-ooxml:5.3.0' implementation 'org.apache.commons:commons-compress:1.27.1' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' } tasks.named('test') { diff --git a/src/main/java/dbdr/domain/careworker/controller/CareworkerController.java b/src/main/java/dbdr/domain/careworker/controller/CareworkerController.java index 916a281c..03932900 100644 --- a/src/main/java/dbdr/domain/careworker/controller/CareworkerController.java +++ b/src/main/java/dbdr/domain/careworker/controller/CareworkerController.java @@ -15,6 +15,15 @@ import java.net.URI; import java.util.List; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; @Tag(name = "[관리자] 요양보호사 (Careworker)", description = "요양보호사 정보 조회, 수정, 삭제, 추가") @RestController diff --git a/src/main/java/dbdr/domain/chart/controller/CareWorkerChartController.java b/src/main/java/dbdr/domain/chart/controller/CareWorkerChartController.java index 4d85997d..2e002b49 100644 --- a/src/main/java/dbdr/domain/chart/controller/CareWorkerChartController.java +++ b/src/main/java/dbdr/domain/chart/controller/CareWorkerChartController.java @@ -62,7 +62,7 @@ public ResponseEntity> saveChart(@Reques @Operation(summary = "차트 아이디로 차트 수정") @PutMapping("/{chartId}") public ResponseEntity> updateChart(@PathVariable("chartId") Long chartId, - @RequestBody ChartDetailRequest request) { + @RequestBody ChartDetailRequest request) { // 환자 정보 접근 권한 확인 로직 필요 -> 요양사가 맡은 환자 정보만 수정 가능 ChartDetailResponse chart = chartService.updateChart(chartId, request); return ResponseEntity.ok(ApiUtils.success(chart)); diff --git a/src/main/java/dbdr/global/configuration/RedisConfig.java b/src/main/java/dbdr/global/configuration/RedisConfig.java new file mode 100644 index 00000000..5f2ab732 --- /dev/null +++ b/src/main/java/dbdr/global/configuration/RedisConfig.java @@ -0,0 +1,59 @@ +package dbdr.global.configuration; + + +import io.lettuce.core.ClientOptions; +import io.lettuce.core.SocketOptions; +import java.time.Duration; +import org.springframework.beans.factory.annotation.Value; +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.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redisConfig = new RedisStandaloneConfiguration(); + redisConfig.setHostName(host); + redisConfig.setPort(port); + return new LettuceConnectionFactory(redisConfig, clientConfig()); + } + + @Bean + public LettuceClientConfiguration clientConfig() { + return LettuceClientConfiguration.builder() + // 서버 배포시 ssl 사용으로 돌려야함 + //.useSsl() + //.and() + .clientOptions( + ClientOptions.builder() + .socketOptions(SocketOptions.builder() + .connectTimeout(Duration.ofSeconds(10)) // 연결 타임아웃 설정 + .build() + ) + .build() + ) + .build(); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + return template; + } +} diff --git a/src/main/java/dbdr/global/exception/ApplicationError.java b/src/main/java/dbdr/global/exception/ApplicationError.java index a88007a0..01817756 100644 --- a/src/main/java/dbdr/global/exception/ApplicationError.java +++ b/src/main/java/dbdr/global/exception/ApplicationError.java @@ -13,6 +13,7 @@ public enum ApplicationError { ACCESS_NOT_ALLOWED(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), PASSWORD_NOT_MATCH(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."), + REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "유효하지 않은 리프레시 토큰입니다. 재로그인 해주세요."), // Guardian (보호자) GUARDIAN_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 보호자를 찾을 수가 없습니다."), diff --git a/src/main/java/dbdr/global/util/api/JwtUtils.java b/src/main/java/dbdr/global/util/api/JwtUtils.java new file mode 100644 index 00000000..74039c3a --- /dev/null +++ b/src/main/java/dbdr/global/util/api/JwtUtils.java @@ -0,0 +1,11 @@ +package dbdr.global.util.api; + +public class JwtUtils { + public static final String ISSUER = "CareBridge"; + public static final String TOKEN_PREFIX = "Bearer "; + public static final long REFRESH_TOKEN_EXPIRATION_TIME = 60 * 60 * 24 * 30L; // 30일 + public static final long ACCESS_TOKEN_EXPIRATION_TIME = 60 * 60; // 1시간 + + private JwtUtils() { + } +} diff --git a/src/main/java/dbdr/security/config/ExceptionHandlingFilter.java b/src/main/java/dbdr/security/config/ExceptionHandlingFilter.java new file mode 100644 index 00000000..d848c366 --- /dev/null +++ b/src/main/java/dbdr/security/config/ExceptionHandlingFilter.java @@ -0,0 +1,70 @@ +package dbdr.security.config; + + +import com.fasterxml.jackson.databind.ObjectMapper; +import dbdr.global.util.api.ApiUtils; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.SignatureException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@Slf4j +public class ExceptionHandlingFilter extends OncePerRequestFilter { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (Exception ex) { + handleException(request, response, ex); + } + } + + private void handleException(HttpServletRequest request, HttpServletResponse response, Exception ex) + throws IOException { + ApiUtils.ApiResult apiResult; + HttpStatus status = HttpStatus.UNAUTHORIZED; + + if (ex instanceof ExpiredJwtException) { + apiResult = ApiUtils.error(status, "토큰이 만료되었습니다."); + } else if (ex instanceof UnsupportedJwtException) { + apiResult = ApiUtils.error(status, "지원하지 않는 토큰 형식입니다."); + } else if (ex instanceof MalformedJwtException) { + apiResult = ApiUtils.error(status, "토큰의 형식이 올바르지 않습니다."); + } else if (ex instanceof SignatureException || ex instanceof SecurityException) { + apiResult = ApiUtils.error(status, "토큰의 서명이 유효하지 않습니다."); + } else if (ex instanceof IllegalArgumentException) { + apiResult = ApiUtils.error(status, "토큰이 제공되지 않았습니다."); + } else if (ex instanceof JwtException) { + apiResult = ApiUtils.error(status, "유효하지 않은 토큰입니다."); + } else { + status = HttpStatus.INTERNAL_SERVER_ERROR; + apiResult = ApiUtils.error(status, "서버 오류가 발생했습니다."); + } + + log.error("Security Exception 발생: [{}] {}", request.getRequestURI(), ex.getMessage()); + + response.setStatus(status.value()); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + String jsonResponse = objectMapper.writeValueAsString(apiResult); + response.getWriter().write(jsonResponse); + } +} diff --git a/src/main/java/dbdr/security/config/JwtFilter.java b/src/main/java/dbdr/security/config/JwtFilter.java index 07f22caa..62e86dfd 100644 --- a/src/main/java/dbdr/security/config/JwtFilter.java +++ b/src/main/java/dbdr/security/config/JwtFilter.java @@ -7,33 +7,26 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; @RequiredArgsConstructor -@Slf4j public class JwtFilter extends OncePerRequestFilter { private final JwtProvider jwtProvider; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { + FilterChain filterChain) throws ServletException, IOException { - String token = request.getHeader("Authorization"); + String token = jwtProvider.extractToken(request); - if (token != null) { - try { - //jwtProvider.validateToken(token); - Authentication authentication = jwtProvider.getAuthentication(token); - SecurityContextHolder.getContext().setAuthentication(authentication); - } catch (Exception e) { - log.debug("토큰 유저 정보 추출 실패 : {}", e.getMessage()); - } + if (StringUtils.hasText(token)) { + Authentication authentication = jwtProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); } - filterChain.doFilter(request, response); } } diff --git a/src/main/java/dbdr/security/config/SecurityConfig.java b/src/main/java/dbdr/security/config/SecurityConfig.java index 9e3ffab8..ad653f66 100644 --- a/src/main/java/dbdr/security/config/SecurityConfig.java +++ b/src/main/java/dbdr/security/config/SecurityConfig.java @@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @@ -23,7 +24,7 @@ @RequiredArgsConstructor @Configuration -@EnableWebSecurity(debug = true) +@EnableWebSecurity @EnableMethodSecurity @Slf4j public class SecurityConfig { @@ -34,7 +35,7 @@ public class SecurityConfig { @Bean public AuthenticationManager authenticationManager( - AuthenticationConfiguration authenticationConfiguration) throws Exception { + AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); } @@ -46,26 +47,30 @@ public BaseAuthenticationProvider baseAuthenticationProvider() { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable) - .cors(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - .formLogin(AbstractAuthenticationFilterConfigurer::disable) - .sessionManagement( - (session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .cors(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractAuthenticationFilterConfigurer::disable) + .sessionManagement( + (session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .addFilterBefore(new JwtFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class) - .authenticationProvider(baseAuthenticationProvider()) - .authorizeHttpRequests((authorize) -> { - authorize - .requestMatchers("/*/login/*").permitAll() - .anyRequest().authenticated(); - }) - .exceptionHandling((exception) -> exception - .accessDeniedHandler((request, response, accessDeniedException) -> { - response.sendError(HttpServletResponse.SC_FORBIDDEN, "접근 거부"); - }) - .authenticationEntryPoint((request, response, authException) -> { - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "인증 실패"); - })); + .addFilterBefore(new ExceptionHandlingFilter(), UsernamePasswordAuthenticationFilter.class) + .authenticationProvider(baseAuthenticationProvider()) + .authorizeHttpRequests((authorize) -> { + authorize + .requestMatchers(HttpMethod.POST, + "/*/auth/login/*", + "/*/auth/renew") + .permitAll() + .anyRequest().authenticated(); + }) + .addFilterBefore(new JwtFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class) + .exceptionHandling((exception) -> exception + .accessDeniedHandler((request, response, accessDeniedException) -> { + response.sendError(HttpServletResponse.SC_FORBIDDEN, "접근 거부"); + }) + .authenticationEntryPoint((request, response, authException) -> { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "인증 실패"); + })); return http.build(); } diff --git a/src/main/java/dbdr/security/controller/LoginController.java b/src/main/java/dbdr/security/controller/LoginController.java index 4dbeed4f..9599179f 100644 --- a/src/main/java/dbdr/security/controller/LoginController.java +++ b/src/main/java/dbdr/security/controller/LoginController.java @@ -4,6 +4,7 @@ import dbdr.global.exception.ApplicationException; import dbdr.security.Role; import dbdr.security.dto.LoginRequest; +import dbdr.security.dto.TokenDTO; import dbdr.security.service.LoginService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -13,30 +14,45 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; 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.RestController; @Tag(name = "로그인", description = "로그인하기") @RestController -@RequestMapping("/${spring.app.version}/login") +@RequestMapping("/${spring.app.version}/auth") public class LoginController { private final LoginService loginService; private final String authHeader; public LoginController(LoginService loginService, - @Value("${spring.jwt.authheader}") String authHeader) { + @Value("${spring.jwt.authheader}") String authHeader) { this.loginService = loginService; this.authHeader = authHeader; } @Operation(summary = "해당 역할로 로그인") - @PostMapping("/{role}") - public ResponseEntity login(@PathVariable("role") String role, - @RequestBody @Valid LoginRequest loginRequest) { + @PostMapping("/login/{role}") + public ResponseEntity login(@PathVariable("role") String role, + @RequestBody @Valid LoginRequest loginRequest) { Role roleEnum = roleCheck(role); - String token = loginService.login(roleEnum, loginRequest); - return ResponseEntity.ok().header(authHeader, token).build(); + TokenDTO token = loginService.login(roleEnum, loginRequest); + return ResponseEntity.ok().header(authHeader, token.accessToken()).body(token); + } + + @Operation(summary = "리프레시 토큰으로 액세스 토큰 재발급") + @PostMapping("/renew") + public ResponseEntity renewAccessToken(@RequestBody String refreshToken) { + TokenDTO token = loginService.renewAccessToken(refreshToken); + return ResponseEntity.ok().header(authHeader, token.accessToken()).body(token); + } + + @Operation(summary = "로그아웃") + @PostMapping("/logout") + public ResponseEntity logout(@RequestHeader("Authorization") String accessToken) { + loginService.logout(accessToken); + return ResponseEntity.ok().build(); } private Role roleCheck(String role) { diff --git a/src/main/java/dbdr/security/dto/TokenDTO.java b/src/main/java/dbdr/security/dto/TokenDTO.java new file mode 100644 index 00000000..0a34f0db --- /dev/null +++ b/src/main/java/dbdr/security/dto/TokenDTO.java @@ -0,0 +1,11 @@ +package dbdr.security.dto; + +import lombok.Builder; + +@Builder +public record TokenDTO( + String refreshToken, + String accessToken, + String username +) { +} diff --git a/src/main/java/dbdr/security/service/JwtProvider.java b/src/main/java/dbdr/security/service/JwtProvider.java index 8df8347b..d027d12e 100644 --- a/src/main/java/dbdr/security/service/JwtProvider.java +++ b/src/main/java/dbdr/security/service/JwtProvider.java @@ -1,11 +1,19 @@ package dbdr.security.service; -import dbdr.global.exception.ApplicationError; +import static dbdr.global.exception.ApplicationError.REFRESH_TOKEN_EXPIRED; +import static dbdr.global.exception.ApplicationError.TOKEN_EXPIRED; +import static dbdr.global.util.api.JwtUtils.ACCESS_TOKEN_EXPIRATION_TIME; +import static dbdr.global.util.api.JwtUtils.REFRESH_TOKEN_EXPIRATION_TIME; +import static dbdr.global.util.api.JwtUtils.TOKEN_PREFIX; + import dbdr.global.exception.ApplicationException; +import dbdr.global.util.api.JwtUtils; import dbdr.security.Role; import dbdr.security.dto.BaseUserDetails; +import dbdr.security.dto.TokenDTO; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; +import jakarta.servlet.http.HttpServletRequest; import java.nio.charset.StandardCharsets; import java.util.Date; import javax.crypto.SecretKey; @@ -14,17 +22,29 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; @Component public class JwtProvider { + private static final String HMAC_ALGORITHM = "HmacSHA256"; private final SecretKey secretKey; private final BaseUserDetailsService baseUserDetailsService; + private final RedisService redisService; public JwtProvider(@Value("${spring.jwt.secret}") String secret, - BaseUserDetailsService baseUserDetailsService) { - this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + BaseUserDetailsService baseUserDetailsService, RedisService redisService) { + this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM); this.baseUserDetailsService = baseUserDetailsService; + this.redisService = redisService; + } + + public String extractToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(TOKEN_PREFIX)) { + return bearerToken; + } + return null; } public String getUserName(String token) { @@ -39,28 +59,65 @@ public boolean isExpired(String token) { return getJwtsBody(token).getExpiration().before(new Date()); } - public String createToken(String username, String role, Long expireTime) { + public TokenDTO createAllToken(String username, String role) { + TokenDTO token = TokenDTO.builder() + .refreshToken(createToken(username, role, REFRESH_TOKEN_EXPIRATION_TIME)) + .accessToken(createToken(username, role, ACCESS_TOKEN_EXPIRATION_TIME)) + .build(); + redisService.saveRefreshToken(role + username, token.refreshToken()); + return token; + } + + private String createToken(String username, String role, Long expireTime) { return Jwts.builder().claim("username", username).claim("role", role) - .setIssuedAt(new Date(System.currentTimeMillis())) - .setExpiration(new Date(System.currentTimeMillis() + expireTime)) - .signWith(secretKey).compact(); + .setIssuer(JwtUtils.ISSUER) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + expireTime)) + .signWith(secretKey).compact(); } public Authentication getAuthentication(String token) { - BaseUserDetails userDetails = baseUserDetailsService.loadUserByUsernameAndRole( - getUserName(token), Role.valueOf(getRole(token))); + BaseUserDetails userDetails = baseUserDetailsService.loadUserByUsernameAndRole(getUserName(token), + Role.valueOf(getRole(token))); + validateBlackListToken(token); return new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), - userDetails.getAuthorities()); + userDetails.getAuthorities()); + } + + private void validateBlackListToken(String token) { + if (redisService.isBlackList(getRedisCode(token), token)) { + throw new ApplicationException(TOKEN_EXPIRED); + } + } + + public TokenDTO renewTokens(String refreshToken) { + if (!isValidRedisRefreshToken(getRedisCode(refreshToken), refreshToken)) { + redisService.deleteRefreshToken(getRedisCode(refreshToken)); + throw new ApplicationException(REFRESH_TOKEN_EXPIRED); + } + return createAllToken(getUserName(refreshToken), getRole(refreshToken)); } - public void validateToken(String token) { - if(isExpired(token)) { - throw new ApplicationException(ApplicationError.TOKEN_EXPIRED); + public void deleteRefreshToken(String accessToken) { + String redisCode = getRedisCode(accessToken); + redisService.deleteRefreshToken(redisCode); + redisService.saveBlackList(redisCode, accessToken); + } + + private Boolean isValidRedisRefreshToken(String code, String refreshToken) { + String token = redisService.getRefreshToken(code); + if (token == null) { + return false; } + return token.equals(refreshToken); + } + + private String getRedisCode(String token) { + return getRole(token) + getUserName(token); } private Claims getJwtsBody(String token) { return Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token) - .getBody(); + .getBody(); } } diff --git a/src/main/java/dbdr/security/service/LoginService.java b/src/main/java/dbdr/security/service/LoginService.java index 19e3a8d1..2ef5f359 100644 --- a/src/main/java/dbdr/security/service/LoginService.java +++ b/src/main/java/dbdr/security/service/LoginService.java @@ -3,8 +3,8 @@ import dbdr.security.Role; import dbdr.security.dto.BaseUserDetails; import dbdr.security.dto.LoginRequest; +import dbdr.security.dto.TokenDTO; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.core.Authentication; @@ -19,26 +19,32 @@ public class LoginService { private final JwtProvider jwtProvider; - @Value("${spring.jwt.expiration}") - private Long jwtExpiration; - public LoginService(AuthenticationManagerBuilder authenticationManagerBuilder, JwtProvider jwtProvider) { this.authenticationManagerBuilder = authenticationManagerBuilder; this.jwtProvider = jwtProvider; } @Transactional - public String login(Role role, LoginRequest loginRequest) { + public TokenDTO login(Role role, LoginRequest loginRequest) { BaseUserDetails userDetails = BaseUserDetails.builder() - .userLoginId(loginRequest.userId()) - .password(loginRequest.password()) - .role(role.name()) - .build(); + .userLoginId(loginRequest.userId()) + .password(loginRequest.password()) + .role(role.name()) + .build(); log.debug("로그인 서비스 접근 시작"); - UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, loginRequest.password()); + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, + loginRequest.password()); Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); - return jwtProvider.createToken(authentication.getName(), role.name(), jwtExpiration); + return jwtProvider.createAllToken(authentication.getName(), role.name()); + } + + public TokenDTO renewAccessToken(String refreshToken) { + return jwtProvider.renewTokens(refreshToken); + } + + public void logout(String accessToken) { + jwtProvider.deleteRefreshToken(accessToken); } } diff --git a/src/main/java/dbdr/security/service/RedisService.java b/src/main/java/dbdr/security/service/RedisService.java new file mode 100644 index 00000000..223293c4 --- /dev/null +++ b/src/main/java/dbdr/security/service/RedisService.java @@ -0,0 +1,55 @@ +package dbdr.security.service; + +import dbdr.global.util.api.JwtUtils; +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RedisService { + + private static final String REFRESH_TOKEN_PREFIX = "refresh-token"; + private static final String BLACK_LIST_PREFIX = "black-list"; + private final RedisTemplate redisTemplate; + + public void saveRefreshToken(String code, String refreshToken) { + ValueOperations valueOperations = redisTemplate.opsForValue(); + Duration duration = Duration.ofSeconds(JwtUtils.REFRESH_TOKEN_EXPIRATION_TIME); + valueOperations.set(getRefreshCodeKey(code), refreshToken, duration); + } + + public String getRefreshToken(String code) { + ValueOperations valueOperations = redisTemplate.opsForValue(); + return (String) valueOperations.get(getRefreshCodeKey(code)); + } + + public void deleteRefreshToken(String code) { + redisTemplate.delete(getRefreshCodeKey(code)); + } + + private String getRefreshCodeKey(String code) { + return REFRESH_TOKEN_PREFIX + code; + } + + public void saveBlackList(String code, String accessToken) { + ValueOperations valueOperations = redisTemplate.opsForValue(); + Duration duration = Duration.ofSeconds(JwtUtils.ACCESS_TOKEN_EXPIRATION_TIME); + valueOperations.set(getBlackListKey(code), accessToken, duration); + } + + public boolean isBlackList(String code, String accessToken) { + ValueOperations valueOperations = redisTemplate.opsForValue(); + String token = (String) valueOperations.get(getBlackListKey(code)); + if (token == null) { + return true; + } + return !token.equals(accessToken); + } + + private String getBlackListKey(String token) { + return BLACK_LIST_PREFIX + token; + } +} diff --git a/src/test/java/dbdr/global/RedisTest.java b/src/test/java/dbdr/global/RedisTest.java new file mode 100644 index 00000000..5eb19240 --- /dev/null +++ b/src/test/java/dbdr/global/RedisTest.java @@ -0,0 +1,26 @@ +package dbdr.global; + + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; + +@SpringBootTest +public class RedisTest { + + @Autowired + RedisTemplate redisTemplate; + + @Test + public void testRedisConnection() { + // 키 저장 + redisTemplate.opsForValue().set("testKey", "Hello Redis!"); + + // 키 검색 + String value = (String) redisTemplate.opsForValue().get("testKey"); + assertEquals("Hello Redis!", value); + } +} \ No newline at end of file