diff --git a/src/main/java/DiffLens/back_end/domain/library/controller/LibraryController.java b/src/main/java/DiffLens/back_end/domain/library/controller/LibraryController.java index 0b81749..8f40614 100644 --- a/src/main/java/DiffLens/back_end/domain/library/controller/LibraryController.java +++ b/src/main/java/DiffLens/back_end/domain/library/controller/LibraryController.java @@ -3,11 +3,13 @@ import DiffLens.back_end.domain.library.dto.LibraryResponseDTO; import DiffLens.back_end.global.responses.exception.ApiResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "라이브러리 API") @RestController @RequestMapping("/libraries") @RequiredArgsConstructor diff --git a/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AbstractSocialAuthStrategy.java b/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AbstractSocialAuthStrategy.java index 28c0a75..aa3de53 100644 --- a/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AbstractSocialAuthStrategy.java +++ b/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AbstractSocialAuthStrategy.java @@ -27,7 +27,7 @@ public abstract class AbstractSocialAuthStrategy implements AuthStrategy { protected final JwtTokenProvider jwtTokenProvider; @Override - public Boolean signUp(AuthRequestDTO.SignUpDto request) { + public Boolean signUp(AuthRequestDTO.SignUp request) { // 소셜 로그인은 보통 signUp 단독 호출 필요 없음 return true; } @@ -35,7 +35,7 @@ public Boolean signUp(AuthRequestDTO.SignUpDto request) { @Override public TokenResponseDTO login(Object request) { - AuthRequestDTO.SocialLoginDto body = (AuthRequestDTO.SocialLoginDto) request; + AuthRequestDTO.SocialLogin body = (AuthRequestDTO.SocialLogin) request; String code = body.getCode(); String decoded = URLDecoder.decode(code, StandardCharsets.UTF_8); diff --git a/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AuthGeneralStrategy.java b/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AuthGeneralStrategy.java index de0d3e7..8942bff 100644 --- a/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AuthGeneralStrategy.java +++ b/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AuthGeneralStrategy.java @@ -22,7 +22,7 @@ public class AuthGeneralStrategy implements AuthStrategy { private final JwtTokenProvider jwtTokenProvider; @Override - public Boolean signUp(AuthRequestDTO.SignUpDto request) { + public Boolean signUp(AuthRequestDTO.SignUp request) { // 요청 정보 추출 String email = request.getEmail(); @@ -49,7 +49,7 @@ public Boolean signUp(AuthRequestDTO.SignUpDto request) { @Override public TokenResponseDTO login(Object request) { - AuthRequestDTO.LoginDto body = (AuthRequestDTO.LoginDto) request; + AuthRequestDTO.Login body = (AuthRequestDTO.Login) request; // 유저 가져옴 Member member = memberRepository.findByEmail(body.getEmail()) diff --git a/src/main/java/DiffLens/back_end/domain/members/auth/strategy/interfaces/AuthStrategy.java b/src/main/java/DiffLens/back_end/domain/members/auth/strategy/interfaces/AuthStrategy.java index a04406e..2576657 100644 --- a/src/main/java/DiffLens/back_end/domain/members/auth/strategy/interfaces/AuthStrategy.java +++ b/src/main/java/DiffLens/back_end/domain/members/auth/strategy/interfaces/AuthStrategy.java @@ -10,7 +10,7 @@ public interface AuthStrategy { * @param request 회원가입 시 클라이언트에서 보내는 Request Body * @return 회원가입 성공 여부 */ - public Boolean signUp(AuthRequestDTO.SignUpDto request); + public Boolean signUp(AuthRequestDTO.SignUp request); /** diff --git a/src/main/java/DiffLens/back_end/domain/members/controller/AuthController.java b/src/main/java/DiffLens/back_end/domain/members/controller/AuthController.java index 40bc4eb..a20b168 100644 --- a/src/main/java/DiffLens/back_end/domain/members/controller/AuthController.java +++ b/src/main/java/DiffLens/back_end/domain/members/controller/AuthController.java @@ -6,14 +6,12 @@ import DiffLens.back_end.global.responses.exception.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; -@Tag(name = "로그인 관련 API") +@Tag(name = "인증 API") @RestController @RequestMapping("/auth") @RequiredArgsConstructor @@ -35,7 +33,7 @@ public class AuthController { 로그인 성공 여부를 반환합니다. """) - public ApiResponse signUp(@RequestBody @Valid AuthRequestDTO.SignUpDto request){ + public ApiResponse signUp(@RequestBody @Valid AuthRequestDTO.SignUp request){ AuthResponseDTO.SignUpDto signUpDto = authService.signUp(request); return ApiResponse.onSuccess(signUpDto); } @@ -55,7 +53,7 @@ public ApiResponse signUp(@RequestBody @Valid AuthReq 인증에 필요한 토큰정보를 포함합니다. """) - public ApiResponse localLogin(@RequestBody @Valid AuthRequestDTO.LoginDto request){ + public ApiResponse localLogin(@RequestBody @Valid AuthRequestDTO.Login request){ AuthResponseDTO.LoginDto login = authService.login(request); return ApiResponse.onSuccess(login); } @@ -80,9 +78,33 @@ public ApiResponse localLogin(@RequestBody @Valid Auth """) - public ApiResponse googleLogin(@RequestBody @Valid AuthRequestDTO.SocialLoginDto request){ + public ApiResponse googleLogin(@RequestBody @Valid AuthRequestDTO.SocialLogin request){ AuthResponseDTO.LoginDto login = authService.login(request); return ApiResponse.onSuccess(login); } + @PostMapping("/reissue") + @Operation(summary = "인증 토큰 재발급", + description = """ + + ## 개요 + 기존 토큰 만료 시 해당 API를 호출하여 새로운 인증토큰을 발급 받습니다.
+ 재발급 시 기존 토큰은 만료됩니다. + + ## Request Header + Authorization 헤더에 refresh token 을 담아 호출하세요.
+ ex) Bearer eyJhbGciOi...re7neDrYl9gJM6c + + ## 응답 + AccessToken과 RefreshToken 이 반환됩니다. + - 재발급 시 기존 AccessToken은 사용 불가능합니다. + - Refresh Token은 계정마다 14일 유효합니다. + + """) + public ApiResponse reIssue(HttpServletRequest request) { + AuthResponseDTO.LoginDto loginDto = authService.reIssue(request); + return ApiResponse.onSuccess(loginDto); + } + + } diff --git a/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthRequestDTO.java b/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthRequestDTO.java index bb6bdb0..ef5718b 100644 --- a/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthRequestDTO.java +++ b/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthRequestDTO.java @@ -15,7 +15,7 @@ public class AuthRequestDTO { @Getter @NoArgsConstructor @AllArgsConstructor - public static class SignUpDto { + public static class SignUp { @Schema(description = "email 형식으로 입력해야 합니다.") @NotBlank(message = "이메일은 필수 입력 항목입니다.") @@ -42,25 +42,28 @@ public static class SignUpDto { @Getter @NoArgsConstructor @AllArgsConstructor - public static class LoginDto implements LoginRequest { + public static class Login implements LoginRequest { - @NotBlank(message = "이메일은 필수 입력 항목입니다.") - @Email(message = "올바른 이메일 형식이 아닙니다.") + @Schema(description = "이메일", example = "example@gmail.com") + @NotBlank + @Email private String email; - @NotBlank(message = "비밀번호는 필수 입력 항목입니다.") - @Size(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하로 입력해주세요.") + @Schema(description = "비밀번호", example = "password123") + @NotBlank + @Size(min = 8, max = 20) private String password; - @NotNull(message = "로그인 타입은 필수 항목입니다.") + @Schema(description = "로그인 타입", example = "GENERAL") + @NotNull private LoginType loginType; - } + @Getter @NoArgsConstructor @AllArgsConstructor - public static class SocialLoginDto implements LoginRequest { + public static class SocialLogin implements LoginRequest { @NotBlank(message = "code 는 필수 입력 항목입니다.") private String code; diff --git a/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthResponseDTO.java b/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthResponseDTO.java index e8340dd..4235d19 100644 --- a/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthResponseDTO.java +++ b/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthResponseDTO.java @@ -1,5 +1,6 @@ package DiffLens.back_end.domain.members.dto.auth; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -13,7 +14,9 @@ public class AuthResponseDTO { @NoArgsConstructor public static class LoginDto{ + @JsonProperty(value = "access_token") private String accessToken; + @JsonProperty(value = "refresh_token") private String refreshToken; } @@ -24,8 +27,20 @@ public static class LoginDto{ @NoArgsConstructor public static class SignUpDto{ + @JsonProperty(value = "is_success") private Boolean isSuccess; } + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class ReIssue{ + + @JsonProperty(value = "access_token") + private String accessToken; + + } + } diff --git a/src/main/java/DiffLens/back_end/domain/members/repository/AccessTokenRepository.java b/src/main/java/DiffLens/back_end/domain/members/repository/AccessTokenRepository.java new file mode 100644 index 0000000..9d1fae0 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/members/repository/AccessTokenRepository.java @@ -0,0 +1,46 @@ +package DiffLens.back_end.domain.members.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class AccessTokenRepository { + + private final StringRedisTemplate redisTemplate; + + private final String ACCESS_TOKEN_KEY = "accessToken:"; + private final String MEMBER_ACCESS_TOKEN_KEY = "memberAccessToken:"; + + public void saveToken(Long memberId, String accessToken, long expirationMillis) { + // 기존 memberAccessToken 삭제 + String oldToken = redisTemplate.opsForValue().get(MEMBER_ACCESS_TOKEN_KEY + memberId); + if (oldToken != null) { + redisTemplate.delete(ACCESS_TOKEN_KEY + oldToken); + } + + // 새 토큰 저장 + redisTemplate.opsForValue().set(ACCESS_TOKEN_KEY + accessToken, memberId.toString(), expirationMillis, TimeUnit.MILLISECONDS); + redisTemplate.opsForValue().set(MEMBER_ACCESS_TOKEN_KEY + memberId, accessToken, expirationMillis, TimeUnit.MILLISECONDS); + } + + public Optional getCurrentToken(Long memberId) { + return Optional.ofNullable(redisTemplate.opsForValue().get(MEMBER_ACCESS_TOKEN_KEY + memberId)); + } + + public void deleteToken(String accessToken) { + String memberId = redisTemplate.opsForValue().get(ACCESS_TOKEN_KEY + accessToken); + if (memberId != null) { + redisTemplate.delete(MEMBER_ACCESS_TOKEN_KEY + memberId); + } + redisTemplate.delete(ACCESS_TOKEN_KEY + accessToken); + } + + public boolean exists(String accessToken) { + return redisTemplate.hasKey(ACCESS_TOKEN_KEY + accessToken); + } +} \ No newline at end of file diff --git a/src/main/java/DiffLens/back_end/domain/members/repository/RefreshTokenRepository.java b/src/main/java/DiffLens/back_end/domain/members/repository/RefreshTokenRepository.java index 9b01387..beb5c95 100644 --- a/src/main/java/DiffLens/back_end/domain/members/repository/RefreshTokenRepository.java +++ b/src/main/java/DiffLens/back_end/domain/members/repository/RefreshTokenRepository.java @@ -4,12 +4,13 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Repository; +import java.util.Optional; import java.util.concurrent.TimeUnit; @Repository public class RefreshTokenRepository { - private final String REFRESH_TOKEN_REDIS_KEY = "refreshToken: "; + private final String REFRESH_TOKEN_REDIS_KEY = "refreshToken:"; private final StringRedisTemplate redisTemplate; @@ -18,18 +19,17 @@ public RefreshTokenRepository(StringRedisTemplate redisTemplate){ this.redisTemplate = redisTemplate; } - public void saveToken(Long memberId, String refreshToken, long expiration){ - String key = REFRESH_TOKEN_REDIS_KEY + refreshToken; - redisTemplate - .opsForValue() - .set(key, memberId.toString(), expiration / 1000, TimeUnit.SECONDS); + public void saveToken(Long memberId, String refreshToken, long expiration) { + String key = REFRESH_TOKEN_REDIS_KEY + refreshToken; + redisTemplate.opsForValue().set(key, memberId.toString(), expiration / 1000, TimeUnit.SECONDS); + redisTemplate.opsForValue().set("member:" + memberId + ":refresh", refreshToken, expiration / 1000, TimeUnit.SECONDS); } // RefreshToken 으로 memberId 가져오기 - public Long getMemberIdByToken(String token){ + public Optional getMemberIdByToken(String token) { String key = REFRESH_TOKEN_REDIS_KEY + token; String memberId = redisTemplate.opsForValue().get(key); - return memberId != null ? Long.parseLong(memberId) : null; + return memberId != null ? Optional.of(Long.parseLong(memberId)) : Optional.empty(); } // RefreshToken 삭제 @@ -43,4 +43,38 @@ public boolean existsRefreshToken(String token){ return redisTemplate.hasKey(key); } + // memberId로 Redis에 저장된 토큰 조회 (단순 스캔, 소규모 시스템에서만 추천) + public String getTokenByMemberId(Long memberId) { + for (String key : redisTemplate.keys(REFRESH_TOKEN_REDIS_KEY + "*")) { + String value = redisTemplate.opsForValue().get(key); + if (value != null && value.equals(memberId.toString())) { + return key.replace(REFRESH_TOKEN_REDIS_KEY, ""); + } + } + return null; + } + + // memberId로 Redis에 저장된 토큰의 남은 만료시간 반환 + public Optional getExpirationByMemberId(Long memberId) { + String token = getTokenByMemberId(memberId); + if (token == null) return Optional.empty(); + Long expiration = getExpirationByToken(token); + return Optional.of(expiration); + } + + public Long getExpirationByToken(String token){ + + String key = REFRESH_TOKEN_REDIS_KEY + token; + return redisTemplate.getExpire(key, TimeUnit.MILLISECONDS); + } + + public void deleteByMemberId(Long memberId) { + String refreshToken = redisTemplate.opsForValue().get("member:" + memberId + ":refresh"); + if (refreshToken != null) { + redisTemplate.delete(REFRESH_TOKEN_REDIS_KEY + refreshToken); + redisTemplate.delete("member:" + memberId + ":refresh"); + } + } + + } diff --git a/src/main/java/DiffLens/back_end/domain/members/service/auth/AuthService.java b/src/main/java/DiffLens/back_end/domain/members/service/auth/AuthService.java index 9f822ac..778dc8d 100644 --- a/src/main/java/DiffLens/back_end/domain/members/service/auth/AuthService.java +++ b/src/main/java/DiffLens/back_end/domain/members/service/auth/AuthService.java @@ -6,18 +6,26 @@ import DiffLens.back_end.domain.members.dto.auth.AuthResponseDTO; import DiffLens.back_end.domain.members.dto.auth.TokenResponseDTO; import DiffLens.back_end.domain.members.enums.LoginType; +import DiffLens.back_end.global.responses.code.status.error.AuthStatus; +import DiffLens.back_end.global.responses.exception.handler.ErrorHandler; +import DiffLens.back_end.global.security.JwtTokenProvider; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 로그인과 회원가입 처리 + */ @Service @RequiredArgsConstructor public class AuthService { private final StrategyFactory strategyFactory; + private final JwtTokenProvider jwtTokenProvider; @Transactional - public AuthResponseDTO.SignUpDto signUp(AuthRequestDTO.SignUpDto request) { + public AuthResponseDTO.SignUpDto signUp(AuthRequestDTO.SignUp request) { // 요청 정보에서 loginType 추출 LoginType loginType = request.getLoginType(); @@ -51,5 +59,29 @@ public AuthResponseDTO.LoginDto login(T .build(); } + // 토큰 재발급 + @Transactional + public AuthResponseDTO.LoginDto reIssue(HttpServletRequest request) { + + // 헤더 추출 + String header = request.getHeader("Authorization"); + + // 헤더 없으면 + if (header == null) + throw new ErrorHandler(AuthStatus.TOKEN_NOT_FOUND); + + // 헤더에서 토큰 추출 + String refreshToken = header.substring(7); + + // 재발급 + TokenResponseDTO tokenDTO = jwtTokenProvider.reissueTokens(refreshToken); + + // 응답 반환 + return AuthResponseDTO.LoginDto.builder() + .accessToken(tokenDTO.getAccessToken()) + .refreshToken(tokenDTO.getRefreshToken()) + .build(); + } + } diff --git a/src/main/java/DiffLens/back_end/domain/members/service/auth/TokenBlackListService.java b/src/main/java/DiffLens/back_end/domain/members/service/auth/TokenBlackListService.java new file mode 100644 index 0000000..11c4674 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/members/service/auth/TokenBlackListService.java @@ -0,0 +1,48 @@ +package DiffLens.back_end.domain.members.service.auth; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DigestUtils; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class TokenBlackListService { + + private final RedisTemplate redisTemplate; + + private static final String BLACKLIST_PREFIX = "auth:blacklist:"; + + /** + * BlackList 내에 토큰 추가합니다. + */ + public void addTokenToList(String token, long remainMillis) { + String key = getKey(token); + redisTemplate.opsForValue().set(key, true, remainMillis, TimeUnit.MILLISECONDS); + } + + /** + * BlackList 내에 토큰이 존재하는지 여부 확인 + * @param token accessToken + * @return 토큰 존재여부 Boolean + */ + public boolean isContainToken(String token) { + String key = getKey(token); + return redisTemplate.hasKey(key); + } + + /** + * 블랙리스트에서 제거 (필요한 경우만) + */ + public void removeToken(String token) { + String key = getKey(token); + redisTemplate.delete(key); + } + + private String getKey(String token) { + return BLACKLIST_PREFIX + DigestUtils.sha1DigestAsHex(token); + } + +} diff --git a/src/main/java/DiffLens/back_end/domain/panel/controller/PanelController.java b/src/main/java/DiffLens/back_end/domain/panel/controller/PanelController.java index dbdfabc..3f16a43 100644 --- a/src/main/java/DiffLens/back_end/domain/panel/controller/PanelController.java +++ b/src/main/java/DiffLens/back_end/domain/panel/controller/PanelController.java @@ -4,10 +4,12 @@ import DiffLens.back_end.domain.panel.dto.PanelResponseDTO; import DiffLens.back_end.global.responses.exception.ApiResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +@Tag(name = "패널 API") @RestController @RequestMapping("/panels") @RequiredArgsConstructor diff --git a/src/main/java/DiffLens/back_end/domain/search/controller/SearchController.java b/src/main/java/DiffLens/back_end/domain/search/controller/SearchController.java index 01cbecd..97c7025 100644 --- a/src/main/java/DiffLens/back_end/domain/search/controller/SearchController.java +++ b/src/main/java/DiffLens/back_end/domain/search/controller/SearchController.java @@ -4,10 +4,12 @@ import DiffLens.back_end.domain.search.dto.SearchResponseDTO; import DiffLens.back_end.global.responses.exception.ApiResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +@Tag(name = "검색 API") @RestController @RequestMapping("/search") @RequiredArgsConstructor @@ -27,6 +29,13 @@ public ApiResponse refine(@RequestBody @Valid Se return ApiResponse.onSuccess(result); } + @GetMapping("/{searchId}/each-responses") + @Operation(summary = "개별 응답 데이터 ( 미구현 )") + public ApiResponse eachResponses(@PathVariable("searchId") Long searchId, Integer pageNum, Integer size){ + SearchResponseDTO.EachResponses result = new SearchResponseDTO.EachResponses(); + return ApiResponse.onSuccess(result); + } + @GetMapping("/recommended") @Operation(summary = "맞춤 검색 추천 ( 미구현 )") public ApiResponse refine(@RequestBody @Valid SearchRequestDTO.SearchFilters request) { diff --git a/src/main/java/DiffLens/back_end/domain/search/dto/SearchResponseDTO.java b/src/main/java/DiffLens/back_end/domain/search/dto/SearchResponseDTO.java index 0c20f60..1eadf84 100644 --- a/src/main/java/DiffLens/back_end/domain/search/dto/SearchResponseDTO.java +++ b/src/main/java/DiffLens/back_end/domain/search/dto/SearchResponseDTO.java @@ -64,5 +64,18 @@ public static class AppliedFilter { } + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class EachResponses { + + private List keys; + private List> values; + private Integer page; + private Integer size; + + } + } diff --git a/src/main/java/DiffLens/back_end/global/responses/code/status/error/AuthStatus.java b/src/main/java/DiffLens/back_end/global/responses/code/status/error/AuthStatus.java index 372d9db..9879599 100644 --- a/src/main/java/DiffLens/back_end/global/responses/code/status/error/AuthStatus.java +++ b/src/main/java/DiffLens/back_end/global/responses/code/status/error/AuthStatus.java @@ -15,6 +15,10 @@ public enum AuthStatus implements BaseErrorCode { USER_NOT_FOUND(HttpStatus.NOT_FOUND, "AUTH401", "존재하지 않는 사용자입니다."), ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "AUTH402", "이미 존재하는 사용자입니다."), INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "AUTH403", "올바르지 않은 비밀번호입니다."), + TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "AUTH404", "인증 헤더가 존재하지 않습니다."), + EXPIRED_TOKEN(HttpStatus.BAD_REQUEST, "AUTH405", "만료된 토큰입니다."), + INVALID_TOKEN(HttpStatus.BAD_REQUEST, "AUTH406", "유효하지 않은 토큰입니다."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "AUTH407", "올바르지 않은 refresh token 입니다."), ERROR_IN_SIGNUP(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH500", "회원가입 중 알 수 없는 오류가 발생했습니다."), diff --git a/src/main/java/DiffLens/back_end/global/responses/code/status/success/AuthSuccessStatus.java b/src/main/java/DiffLens/back_end/global/responses/code/status/success/AuthSuccessStatus.java new file mode 100644 index 0000000..6b03c32 --- /dev/null +++ b/src/main/java/DiffLens/back_end/global/responses/code/status/success/AuthSuccessStatus.java @@ -0,0 +1,36 @@ +package DiffLens.back_end.global.responses.code.status.success; + +import DiffLens.back_end.global.responses.code.BaseCode; +import DiffLens.back_end.global.responses.code.ReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum AuthSuccessStatus implements BaseCode { + + LOGIN_SUCCESS(HttpStatus.OK, "AUTH200", "로그인 성공입니다."), + LOGOUT_SUCCESS(HttpStatus.OK, "AUTH205", "로그아웃 성공입니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ReasonDto getReason() { + return ReasonDto.builder().message(message).code(code).isSuccess(true).build(); + } + + @Override + public ReasonDto getReasonHttpStatus() { + return ReasonDto.builder() + .message(message) + .code(code) + .isSuccess(true) + .httpStatus(httpStatus) + .build(); + } + +} diff --git a/src/main/java/DiffLens/back_end/global/security/CustomLogoutHandler.java b/src/main/java/DiffLens/back_end/global/security/CustomLogoutHandler.java new file mode 100644 index 0000000..60681ac --- /dev/null +++ b/src/main/java/DiffLens/back_end/global/security/CustomLogoutHandler.java @@ -0,0 +1,49 @@ +package DiffLens.back_end.global.security; + +import DiffLens.back_end.domain.members.repository.RefreshTokenRepository; +import DiffLens.back_end.domain.members.service.auth.CurrentUserService; +import DiffLens.back_end.domain.members.service.auth.TokenBlackListService; +import DiffLens.back_end.global.responses.code.status.error.AuthStatus; +import DiffLens.back_end.global.responses.exception.handler.ErrorHandler; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CustomLogoutHandler implements LogoutHandler { + + private final TokenBlackListService tokenBlackListService; + private final RefreshTokenRepository refreshTokenRepository; + private final CurrentUserService currentUserService; + + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + + // 요청 정보에서 토큰 추출 + String headerToken = request.getHeader("Authorization"); + + // 토큰이 없을 경우 처리 + if(headerToken == null) { + throw new ErrorHandler(AuthStatus.TOKEN_NOT_FOUND); + } + + // 헤더에서 토큰 추출 + String token = headerToken.substring(7); + + // Redis 내에 토큰이 존재하지 않는 경우 + if (!tokenBlackListService.isContainToken(token)) { + tokenBlackListService.addTokenToList(token, JwtTokenExpirationTime.ACCESS_TOKEN.getExpirationMillis()); // BlackList 추가 + } + + Long memberId = currentUserService.getCurrentUserId(); + refreshTokenRepository.deleteByMemberId(memberId); + + } + +} diff --git a/src/main/java/DiffLens/back_end/global/security/JwtAuthenticationFilter.java b/src/main/java/DiffLens/back_end/global/security/JwtAuthenticationFilter.java index 816217a..478c570 100644 --- a/src/main/java/DiffLens/back_end/global/security/JwtAuthenticationFilter.java +++ b/src/main/java/DiffLens/back_end/global/security/JwtAuthenticationFilter.java @@ -1,25 +1,29 @@ package DiffLens.back_end.global.security; +import DiffLens.back_end.domain.members.service.auth.TokenBlackListService; +import DiffLens.back_end.global.responses.code.status.error.AuthStatus; +import DiffLens.back_end.global.responses.exception.handler.ErrorHandler; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.Collections; +@Component +@RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; - - public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) { - this.jwtTokenProvider = jwtTokenProvider; - } + private final TokenBlackListService blackListService; @Override protected void doFilterInternal( @@ -29,17 +33,15 @@ protected void doFilterInternal( try { if (token != null) { -// System.out.println("Token found: " + token); - // if (blacklistRepository.isBlacklisted(token)) { - // response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - // response.getWriter().write("로그아웃된 토큰입니다."); - // return; - // } + + // token 이 BlackList에 있을 경우 + if (blackListService.isContainToken(token)) { + throw new ErrorHandler(AuthStatus.EXPIRED_TOKEN); + } + if (jwtTokenProvider.validateToken(token)) { Authentication authentication = jwtTokenProvider.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(authentication); -// System.out.println( -// "Authentication set in SecurityContext: " + authentication.getName()); // System.out.println("authentication = " + authentication.getAuthorities()); } else { // System.out.println("Invalid or expired token."); @@ -57,7 +59,6 @@ protected void doFilterInternal( SecurityContextHolder.getContext().setAuthentication(anonymousAuth); } } catch (Exception ex) { - response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); response.getWriter().write("Internal server error occurred."); } diff --git a/src/main/java/DiffLens/back_end/global/security/JwtTokenExpirationTime.java b/src/main/java/DiffLens/back_end/global/security/JwtTokenExpirationTime.java new file mode 100644 index 0000000..cd083ba --- /dev/null +++ b/src/main/java/DiffLens/back_end/global/security/JwtTokenExpirationTime.java @@ -0,0 +1,16 @@ +package DiffLens.back_end.global.security; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum JwtTokenExpirationTime { + + ACCESS_TOKEN(1000L * 60 * 30), // 30분 + REFRESH_TOKEN(1000L * 60 * 60 * 24 * 14) + ; + + private final long expirationMillis; + +} diff --git a/src/main/java/DiffLens/back_end/global/security/JwtTokenProvider.java b/src/main/java/DiffLens/back_end/global/security/JwtTokenProvider.java index b9fd928..accc841 100644 --- a/src/main/java/DiffLens/back_end/global/security/JwtTokenProvider.java +++ b/src/main/java/DiffLens/back_end/global/security/JwtTokenProvider.java @@ -1,5 +1,7 @@ package DiffLens.back_end.global.security; +import DiffLens.back_end.domain.members.repository.AccessTokenRepository; +import DiffLens.back_end.domain.members.service.auth.TokenBlackListService; import DiffLens.back_end.global.responses.code.status.error.AuthStatus; import DiffLens.back_end.domain.members.dto.auth.TokenResponseDTO; import DiffLens.back_end.domain.members.dto.auth.TokenWithRolesResponseDTO; @@ -31,15 +33,17 @@ public class JwtTokenProvider { private final RefreshTokenRepository refreshTokenRepository; + private final AccessTokenRepository accessTokenRepository; private final MemberRepository memberRepository; + private final TokenBlackListService tokenBlackListService; private Key key; @Value("${spring.jwt.secret}") private String secretKey; - private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000L * 60 * 60 * 15 * 24; - private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000L * 60 * 60 * 24 * 30; + private static final long ACCESS_TOKEN_EXPIRE_TIME = JwtTokenExpirationTime.ACCESS_TOKEN.getExpirationMillis(); + private static final long REFRESH_TOKEN_EXPIRE_TIME = JwtTokenExpirationTime.REFRESH_TOKEN.getExpirationMillis(); @PostConstruct public void init() { @@ -47,34 +51,67 @@ public void init() { } public TokenResponseDTO createToken(Member member) { - Claims claims = Jwts.claims().setSubject(member.getEmail()); - Date now = new Date(); -// List roles = extractRoles(member); - String accessToken = - Jwts.builder() - .setClaims(claims) -// .claim("roles", roles) - .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + ACCESS_TOKEN_EXPIRE_TIME)) - .signWith(key, SignatureAlgorithm.HS256) - .compact(); + // 항상 새로운 access token 발급 + String accessToken = createAccessToken(member); + String refreshToken; - String refreshToken = - Jwts.builder() - .setClaims(claims) - .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + REFRESH_TOKEN_EXPIRE_TIME)) - .claim("random", UUID.randomUUID().toString()) - .signWith(key, SignatureAlgorithm.HS256) - .compact(); + Long memberId = member.getId(); + Optional existing = refreshTokenRepository.getExpirationByMemberId(memberId); // memberId로 refresh token 조회 - long expiration = getExpiration(refreshToken); - refreshTokenRepository.saveToken(member.getId(), refreshToken, expiration); + // 새로운 유저이거나 만료가 얼마 남지 않았으면 새로 발급 + if (existing.isEmpty() || existing.get() < (REFRESH_TOKEN_EXPIRE_TIME / 3)){ + refreshToken = createRefreshToken(member); + refreshTokenRepository.saveToken(memberId, refreshToken, getExpiration(refreshToken)); + } else { + refreshToken = refreshTokenRepository.getTokenByMemberId(memberId); + } + + // accessToken Redis 저장 + accessTokenRepository.saveToken(memberId, accessToken, ACCESS_TOKEN_EXPIRE_TIME); return TokenResponseDTO.of(accessToken, refreshToken); } + public TokenResponseDTO reissueTokens(String refreshToken) { + + if (!validateToken(refreshToken)) { + throw new ErrorHandler(AuthStatus.INVALID_TOKEN); + } + + Long memberId = refreshTokenRepository.getMemberIdByToken(refreshToken) // memberId 조회 + .orElseThrow(() -> new ErrorHandler(AuthStatus.REFRESH_TOKEN_NOT_FOUND)); + + Member member = memberRepository.findById(memberId) // member 조회 + .orElseThrow(() -> new ErrorHandler(AuthStatus.USER_NOT_FOUND)); + + Long remainingTime = refreshTokenRepository.getExpirationByMemberId(memberId) // 남은기간 조회 + .orElseThrow(() -> new ErrorHandler(AuthStatus.REFRESH_TOKEN_NOT_FOUND)); + + accessTokenRepository.getCurrentToken(memberId) + .ifPresent(oldToken -> tokenBlackListService.addTokenToList(oldToken, remainingTime)); + + accessTokenRepository.getCurrentToken(memberId) + .ifPresent(accessTokenRepository::deleteToken); + + + String newAccessToken = createAccessToken(member); + accessTokenRepository.saveToken(memberId, newAccessToken, ACCESS_TOKEN_EXPIRE_TIME); + + String newRefreshToken; + + // RefreshToken 남은 기간이 1/3 이하이면 새로 발급 + if (remainingTime < (REFRESH_TOKEN_EXPIRE_TIME / 3)) { + newRefreshToken = createRefreshToken(member); + refreshTokenRepository.saveToken(memberId, newRefreshToken, getExpiration(newRefreshToken)); + } else { + newRefreshToken = refreshToken; + } + + return TokenResponseDTO.of(newAccessToken, newRefreshToken); + } + + public boolean validateToken(String token) { try { Jws claims = @@ -193,6 +230,7 @@ public long getExpiration(String token) { } catch (ExpiredJwtException e) { return 0; } catch (Exception e) { + System.out.println(e.getMessage()); throw new IllegalArgumentException("Invalid JWT Token"); } } diff --git a/src/main/java/DiffLens/back_end/global/security/SecurityAllowOrigins.java b/src/main/java/DiffLens/back_end/global/security/SecurityAllowOrigins.java new file mode 100644 index 0000000..edce61b --- /dev/null +++ b/src/main/java/DiffLens/back_end/global/security/SecurityAllowOrigins.java @@ -0,0 +1,16 @@ +package DiffLens.back_end.global.security; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "spring.security") +public class SecurityAllowOrigins { + private List allowedOrigins; +} diff --git a/src/main/java/DiffLens/back_end/global/security/SecurityConfig.java b/src/main/java/DiffLens/back_end/global/security/SecurityConfig.java index 4b950ec..026bb09 100644 --- a/src/main/java/DiffLens/back_end/global/security/SecurityConfig.java +++ b/src/main/java/DiffLens/back_end/global/security/SecurityConfig.java @@ -1,7 +1,11 @@ package DiffLens.back_end.global.security; +import DiffLens.back_end.global.responses.code.status.error.AuthStatus; +import DiffLens.back_end.global.responses.code.status.success.AuthSuccessStatus; +import DiffLens.back_end.global.responses.exception.ApiResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -9,6 +13,7 @@ import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.config.annotation.web.configurers.LogoutConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @@ -18,19 +23,14 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.List; - @EnableWebSecurity @Configuration @RequiredArgsConstructor -@ConfigurationProperties(prefix = "spring.security") public class SecurityConfig { - private List allowedOrigins; - - public void setAllowedOrigins(List allowedOrigins) { - this.allowedOrigins = allowedOrigins; - } + private final CustomLogoutHandler customLogoutHandler; + private final SecurityAllowOrigins securityProperties; + private final JwtAuthenticationFilter jwtAuthenticationFilter; @Bean public WebSecurityCustomizer webSecurityCustomizer() { @@ -38,48 +38,57 @@ public WebSecurityCustomizer webSecurityCustomizer() { } @Bean - public SecurityFilterChain securityFilterChain( - HttpSecurity http, JwtTokenProvider jwtTokenProvider) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) .cors(cors -> cors.configurationSource(corsConfigurationSource())) - .headers( - headers -> - headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) - .sessionManagement( - session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests( - request -> - request.requestMatchers( - "/images/**", - "/swagger-ui/**", - "/v3/api-docs/**", - "/auth/signup/**", - "/auth/login/**", - "/oauth2/**" - ) - .permitAll() - .anyRequest() - .authenticated() + .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(request -> request + .requestMatchers( + "/images/**", + "/swagger-ui/**", + "/v3/api-docs/**", + "/auth/signup/**", + "/auth/login/**", + "/oauth2/**" + ).permitAll() + .anyRequest().authenticated() ) - // .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, - // blacklistRepository), UsernamePasswordAuthenticationFilter.class); - .addFilterBefore( - new JwtAuthenticationFilter(jwtTokenProvider), - UsernamePasswordAuthenticationFilter.class); + .addFilterBefore(jwtAuthenticationFilter, + UsernamePasswordAuthenticationFilter.class) + .logout(this::configureLogout); return http.build(); } + // 로그아웃 처리 + private void configureLogout(LogoutConfigurer logout) { + + ObjectMapper objectMapper = new ObjectMapper(); + + logout + .logoutUrl("/auth/logout") + .addLogoutHandler(customLogoutHandler) + .logoutSuccessHandler((request, response, authentication) -> { + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json;charset=UTF-8"); + + ApiResponse apiResponse = ApiResponse.onSuccess(AuthSuccessStatus.LOGOUT_SUCCESS); + response.getWriter().write(objectMapper.writeValueAsString(apiResponse)); + }); + } + @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - allowedOrigins.forEach(configuration::addAllowedOriginPattern); - configuration.setAllowCredentials(true); // 인증 정보 포함 허용 + securityProperties.getAllowedOrigins().forEach(configuration::addAllowedOriginPattern); + configuration.setAllowCredentials(true); configuration.addAllowedMethod("*"); - configuration.setMaxAge(3600L); // CORS 요청 캐싱 시간 (1시간) + configuration.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); @@ -90,5 +99,4 @@ public CorsConfigurationSource corsConfigurationSource() { public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } - }