diff --git a/backend/src/main/java/com/worlabel/domain/auth/controller/AuthController.java b/backend/src/main/java/com/worlabel/domain/auth/controller/AuthController.java index dc2b081..c3dfab3 100644 --- a/backend/src/main/java/com/worlabel/domain/auth/controller/AuthController.java +++ b/backend/src/main/java/com/worlabel/domain/auth/controller/AuthController.java @@ -1,13 +1,17 @@ package com.worlabel.domain.auth.controller; import com.worlabel.domain.auth.entity.dto.JwtToken; -import com.worlabel.domain.auth.repository.AuthCacheRepository; +import com.worlabel.domain.auth.entity.dto.AccessTokenResponse; import com.worlabel.domain.auth.service.AuthService; import com.worlabel.domain.auth.service.JwtTokenService; import com.worlabel.global.annotation.CurrentUser; +import com.worlabel.global.config.swagger.SwaggerApiError; +import com.worlabel.global.config.swagger.SwaggerApiSuccess; import com.worlabel.global.exception.CustomException; import com.worlabel.global.exception.ErrorCode; import com.worlabel.global.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -21,35 +25,38 @@ import org.springframework.web.bind.annotation.RestController; import java.util.Arrays; + @Slf4j @RestController @RequiredArgsConstructor +@Tag(name = "인증/인가 관련 API") @RequestMapping("/api/auth") public class AuthController { - private final AuthService authService; - private final AuthCacheRepository authCacheRepository; - private final JwtTokenService jwtTokenService; - @Value("${auth.refreshTokenExpiry}") long refreshExpiry; - // TODO: 리이슈 처리, 액세스 어떻게 받았는지 물어보기 + private final AuthService authService; + private final JwtTokenService jwtTokenService; + + @Operation(summary = "JWT 토큰 재발급", description = "Refresh Token을 확인하여 JWT 토큰 재발급") + @SwaggerApiSuccess(description = "Return Access Token") + @SwaggerApiError({ErrorCode.INVALID_TOKEN, ErrorCode.USER_ALREADY_SIGN_OUT, ErrorCode.REFRESH_TOKEN_EXPIRED, ErrorCode.INVALID_REFRESH_TOKEN}) @PostMapping("/reissue") - public SuccessResponse reissue( + public SuccessResponse reissue( HttpServletRequest request, HttpServletResponse response ) { + log.debug("reissue request"); String refresh = parseRefreshCookie(request); - log.info("reissue :{}", refresh); try { JwtToken newToken = authService.reissue(refresh); - log.debug("새로운 토큰 발급 성공"); int id = jwtTokenService.parseId(newToken.getAccessToken()); - log.debug("{}",id); + response.addCookie(createCookie(newToken.getRefreshToken())); - authCacheRepository.save(id, newToken.getRefreshToken(), refreshExpiry); - return SuccessResponse.of(newToken.getAccessToken()); + authService.saveRefreshToken(id, newToken.getRefreshToken(),refreshExpiry); + + return SuccessResponse.of(AccessTokenResponse.from(newToken.getAccessToken())); } catch (CustomException e) { throw e; } catch (Exception e) { @@ -57,15 +64,10 @@ public class AuthController { } } - private Cookie createCookie(String value) { - Cookie cookie = new Cookie("refreshToken", value); - cookie.setMaxAge((int) (refreshExpiry / 1000)); - cookie.setPath("/"); - cookie.setHttpOnly(true); - // cookie.setSecure(true); // 배포 시 HTTPS에서 사용 - return cookie; - } - + // TODO: Member 완성 후 구현 + @Operation(summary = "로그인 중인 사용자 정보를 반환", description = "현재 로그인중인 사용자의 정보를 반환합니다.") + @SwaggerApiSuccess(description = "Return Member Info") + @SwaggerApiError({ErrorCode.INVALID_TOKEN, ErrorCode.USER_ALREADY_SIGN_OUT, ErrorCode.REFRESH_TOKEN_EXPIRED, ErrorCode.INVALID_REFRESH_TOKEN}) @GetMapping("/user-info") public SuccessResponse getMemberInfo(@CurrentUser Integer currentMember){ return SuccessResponse.of(currentMember); @@ -78,8 +80,18 @@ public class AuthController { .filter(cookie -> "refreshToken".equals(cookie.getName())) .findFirst() .map(Cookie::getValue) + .map(String::trim) .orElse(null); } return null; } + + private Cookie createCookie(String value) { + Cookie cookie = new Cookie("refreshToken", value); + cookie.setMaxAge((int) (refreshExpiry / 1000)); + cookie.setPath("/"); + cookie.setHttpOnly(true); + // cookie.setSecure(true); // 배포 시 HTTPS에서 사용 + return cookie; + } } diff --git a/backend/src/main/java/com/worlabel/domain/auth/entity/CustomOAuth2User.java b/backend/src/main/java/com/worlabel/domain/auth/entity/CustomOAuth2User.java index 908ade4..2df5769 100644 --- a/backend/src/main/java/com/worlabel/domain/auth/entity/CustomOAuth2User.java +++ b/backend/src/main/java/com/worlabel/domain/auth/entity/CustomOAuth2User.java @@ -17,7 +17,7 @@ public class CustomOAuth2User implements OAuth2User { private transient final AuthMemberDto authMember; public CustomOAuth2User(Member member) { - authMember = AuthMemberDto.of(member); + authMember = AuthMemberDto.from(member); } @Override diff --git a/backend/src/main/java/com/worlabel/domain/auth/entity/dto/AccessTokenResponse.java b/backend/src/main/java/com/worlabel/domain/auth/entity/dto/AccessTokenResponse.java new file mode 100644 index 0000000..c9092e0 --- /dev/null +++ b/backend/src/main/java/com/worlabel/domain/auth/entity/dto/AccessTokenResponse.java @@ -0,0 +1,19 @@ +package com.worlabel.domain.auth.entity.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Schema(name = "리프레시 토큰 응답 dto", description = "리프레시 토큰 응답 dto") +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class AccessTokenResponse { + + @Schema(description = "액세스 토큰", example = "") + private String accessToken; + + public static AccessTokenResponse from(String accessToken) { + return new AccessTokenResponse(accessToken); + } +} diff --git a/backend/src/main/java/com/worlabel/domain/auth/entity/dto/AuthMemberDto.java b/backend/src/main/java/com/worlabel/domain/auth/entity/dto/AuthMemberDto.java index 8373354..f7ed003 100644 --- a/backend/src/main/java/com/worlabel/domain/auth/entity/dto/AuthMemberDto.java +++ b/backend/src/main/java/com/worlabel/domain/auth/entity/dto/AuthMemberDto.java @@ -1,22 +1,24 @@ package com.worlabel.domain.auth.entity.dto; import com.worlabel.domain.member.entity.Member; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.ToString; @Getter @ToString +@AllArgsConstructor(access = AccessLevel.PRIVATE) public class AuthMemberDto { private int id; private String email; private String role; - public static AuthMemberDto of(Member member) { - AuthMemberDto authMemberDto = new AuthMemberDto(); - authMemberDto.id = member.getId(); - authMemberDto.email = member.getEmail(); - authMemberDto.role = member.getRole().toString(); + public static AuthMemberDto from(Member member) { + return new AuthMemberDto(member.getId(), member.getEmail(), member.getRole().toString()); + } - return authMemberDto; + public static AuthMemberDto of(int id, String email, String role) { + return new AuthMemberDto(id, email, role); } } diff --git a/backend/src/main/java/com/worlabel/domain/auth/entity/dto/JwtToken.java b/backend/src/main/java/com/worlabel/domain/auth/entity/dto/JwtToken.java index b5b0e7a..77f784b 100644 --- a/backend/src/main/java/com/worlabel/domain/auth/entity/dto/JwtToken.java +++ b/backend/src/main/java/com/worlabel/domain/auth/entity/dto/JwtToken.java @@ -1,11 +1,16 @@ package com.worlabel.domain.auth.entity.dto; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; @Getter -@AllArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) public class JwtToken { private String accessToken; private String refreshToken; + + public static JwtToken of(String accessToken, String refreshToken){ + return new JwtToken(accessToken, refreshToken); + } } diff --git a/backend/src/main/java/com/worlabel/domain/auth/entity/dto/LoginMember.java b/backend/src/main/java/com/worlabel/domain/auth/entity/dto/LoginMember.java deleted file mode 100644 index ac31244..0000000 --- a/backend/src/main/java/com/worlabel/domain/auth/entity/dto/LoginMember.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.worlabel.domain.auth.entity.dto; - -import lombok.Getter; - -@Getter -public class LoginMember { - private int id; - private String email; - - public static LoginMember of(int id, String email) { - LoginMember member = new LoginMember(); - member.id = id; - member.email = email; - return member; - } -} diff --git a/backend/src/main/java/com/worlabel/domain/auth/repository/AuthCacheRepository.java b/backend/src/main/java/com/worlabel/domain/auth/repository/AuthCacheRepository.java index 44bdf27..80fd499 100644 --- a/backend/src/main/java/com/worlabel/domain/auth/repository/AuthCacheRepository.java +++ b/backend/src/main/java/com/worlabel/domain/auth/repository/AuthCacheRepository.java @@ -15,14 +15,23 @@ public class AuthCacheRepository { private final RedisTemplate redisTemplate; - public void save(int memberId, String token, Long expiredTime) { - redisTemplate.opsForValue().set(CacheKey.authenticationKey(memberId), String.valueOf(token), expiredTime, TimeUnit.MILLISECONDS); + /** + * 리프레시 토큰 저장 + */ + public void save(int memberId, String refreshToken, Long expiredTime) { + redisTemplate.opsForValue().set(CacheKey.authenticationKey(memberId), String.valueOf(refreshToken), expiredTime, TimeUnit.MILLISECONDS); } + /** + * 리프레시 토큰 반환 + */ public String find(int memberId) { return (String) redisTemplate.opsForValue().get(CacheKey.authenticationKey(memberId)); } + /** + * 리프레시 토큰 삭제 + */ public void delete(int memberId){ redisTemplate.delete(CacheKey.authenticationKey(memberId)); } diff --git a/backend/src/main/java/com/worlabel/domain/auth/service/AuthService.java b/backend/src/main/java/com/worlabel/domain/auth/service/AuthService.java index 5f74a4a..a2dad4c 100644 --- a/backend/src/main/java/com/worlabel/domain/auth/service/AuthService.java +++ b/backend/src/main/java/com/worlabel/domain/auth/service/AuthService.java @@ -7,6 +7,7 @@ import com.worlabel.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; import java.util.Objects; @@ -14,17 +15,26 @@ import java.util.Objects; @Service @RequiredArgsConstructor public class AuthService { + private final JwtTokenService jwtTokenService; private final AuthCacheRepository authCacheRepository; + /** + * JWT 토큰 재발급 + */ public JwtToken reissue(String refreshToken) throws Exception { int id = jwtTokenService.parseId(refreshToken); - Object redisRefreshToken = authCacheRepository.find(id); - log.debug("{} == {} ",redisRefreshToken,refreshToken); - if(!Objects.equals(refreshToken, redisRefreshToken)){ + String redisRefreshToken = authCacheRepository.find(id); + if(!refreshToken.equals(redisRefreshToken)){ throw new CustomException(ErrorCode.USER_ALREADY_SIGN_OUT); } - return jwtTokenService.generateTokenByRefreshToken(refreshToken); } + + /** + * 레디에 리프레시 토큰 저장 + */ + public void saveRefreshToken(int id, String refreshToken,Long expiredTime) { + authCacheRepository.save(id, refreshToken, expiredTime); + } } diff --git a/backend/src/main/java/com/worlabel/domain/auth/service/JwtTokenService.java b/backend/src/main/java/com/worlabel/domain/auth/service/JwtTokenService.java index d9705d7..4bb8db8 100644 --- a/backend/src/main/java/com/worlabel/domain/auth/service/JwtTokenService.java +++ b/backend/src/main/java/com/worlabel/domain/auth/service/JwtTokenService.java @@ -2,10 +2,13 @@ package com.worlabel.domain.auth.service; import com.worlabel.domain.auth.entity.CustomOAuth2User; import com.worlabel.domain.auth.entity.dto.JwtToken; +import com.worlabel.global.exception.CustomException; +import com.worlabel.global.exception.ErrorCode; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.security.SignatureException; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.GrantedAuthority; @@ -15,8 +18,11 @@ import org.springframework.stereotype.Service; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; +import java.security.Key; import java.util.Date; import java.util.List; +import java.util.Map; +import java.util.UUID; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -27,6 +33,7 @@ public class JwtTokenService { private final SecretKey secretKey; private final Long tokenExpiration; private final Long refreshTokenExpiration; + private Key key ; public JwtTokenService( @Value("${spring.jwt.secret}") String key, @@ -45,43 +52,58 @@ public class JwtTokenService { return generateToken(user.getName(), user.getId(), authorities); } - public JwtToken generateTokenByRefreshToken(String refreshToken) throws Exception{ - if (!isTokenExpired(refreshToken) && isRefreshToken(refreshToken)) { - Claims claims = parseClaims(refreshToken); - String username = claims.getSubject(); - int memberId = claims.get("id", Integer.class); - List authorities = parseAuthorities(refreshToken).stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()); - return generateToken(username, memberId, authorities); + public JwtToken generateTokenByRefreshToken(String refreshToken) throws Exception { + log.debug("생성"); + + if (isTokenExpired(refreshToken)) { + throw new CustomException(ErrorCode.REFRESH_TOKEN_EXPIRED); } - throw new Exception("유효하지 않은 토큰입니다."); + + if (!isRefreshToken(refreshToken)) { + throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN); + } + + log.debug("유효성 통과"); + + Claims claims = parseClaims(refreshToken); + String username = claims.getSubject(); + int memberId = claims.get("id", Integer.class); + List authorities = parseAuthorities(refreshToken).stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()); + return generateToken(username, memberId, authorities); } - private JwtToken generateToken(String username, int memberId, List authorities){ + private JwtToken generateToken(String username, int memberId, List authorities) { long now = System.currentTimeMillis(); Date accessTokenExpire = new Date(now + tokenExpiration); Date refreshTokenExpire = new Date(now + refreshTokenExpiration); + log.debug("액세스 만료 시간 : {}", accessTokenExpire.getTime()); + log.debug("리프레시 만료 시간 : {}", refreshTokenExpire.getTime()); + String accessToken = Jwts.builder() .subject(username) .claim("type", "access") .claim("id", memberId) .claim("authorities", authorities) // 권한 정보 추가 + .issuedAt(new Date(now)) .expiration(accessTokenExpire) + .claim("jti", UUID.randomUUID().toString()) .signWith(secretKey) .compact(); String refreshToken = Jwts.builder() .subject(username) - .claim("type","refresh") + .claim("type", "refresh") .claim("id", memberId) .claim("authorities", authorities) // 권한 정보 추가 + .issuedAt(new Date(now)) .expiration(refreshTokenExpire) + .claim("jti", UUID.randomUUID().toString()) .signWith(secretKey) .compact(); - log.debug("액세스 발급: {}",accessToken); - return new JwtToken(accessToken, refreshToken); + return JwtToken.of(accessToken, refreshToken); } public String parseUsername(String token) throws Exception { @@ -101,15 +123,8 @@ public class JwtTokenService { } // 토큰 만료 여부 확인 - public boolean isTokenExpired(String token){ - try{ - Claims claims = parseClaims(token); - return claims.getExpiration().before(new Date()); - }catch (ExpiredJwtException e) { - return true; // 만료된 토큰 - } catch (Exception e) { - return false; // 다른 오류일 경우 - } + public boolean isTokenExpired(String token) throws Exception { + return parseClaims(token) == null; } public boolean isRefreshToken(String token) { @@ -123,7 +138,7 @@ public class JwtTokenService { private boolean isTokenType(String token, String expectedType) { try { Claims claims = parseClaims(token); - log.debug("claims : {}",claims); + log.debug("claims : {}", claims); String tokenType = claims.get("type", String.class); return expectedType.equals(tokenType); } catch (Exception e) { @@ -134,19 +149,20 @@ public class JwtTokenService { private Claims parseClaims(String token) throws Exception { String message; - try{ + try { return Jwts.parser() .verifyWith(secretKey) .build() .parseSignedClaims(token) .getPayload(); - }catch (ExpiredJwtException e){ + } catch (ExpiredJwtException e) { message = "유효기간이 만료된 토큰입니다."; - }catch (MalformedJwtException e){ + } catch (MalformedJwtException | SignatureException e) { message = "잘못된 형식의 토큰입니다."; - }catch (IllegalArgumentException e) { + } catch (IllegalArgumentException e) { message = "잘못된 인자입니다."; - }catch (Exception e){ + } catch (Exception e) { + e.printStackTrace(); message = "토큰 파싱 중 에러가 발생했습니다."; } throw new Exception(message); diff --git a/backend/src/main/java/com/worlabel/domain/workspace/entity/dto/WorkspaceResponses.java b/backend/src/main/java/com/worlabel/domain/workspace/entity/dto/WorkspaceResponses.java index 31c812e..8856340 100644 --- a/backend/src/main/java/com/worlabel/domain/workspace/entity/dto/WorkspaceResponses.java +++ b/backend/src/main/java/com/worlabel/domain/workspace/entity/dto/WorkspaceResponses.java @@ -7,9 +7,9 @@ import lombok.Getter; import java.util.List; -@Schema(name = "워크스페이스 목록 응답 dto", description = "워크스페이스 목록 응답 DTO") @Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) +@Schema(name = "워크스페이스 목록 응답 dto", description = "워크스페이스 목록 응답 DTO") public class WorkspaceResponses { @Schema(description = "워크스페이스 목록", example = "") diff --git a/backend/src/main/java/com/worlabel/global/config/SecurityConfig.java b/backend/src/main/java/com/worlabel/global/config/SecurityConfig.java index b2b85cd..7082e7f 100644 --- a/backend/src/main/java/com/worlabel/global/config/SecurityConfig.java +++ b/backend/src/main/java/com/worlabel/global/config/SecurityConfig.java @@ -35,13 +35,14 @@ public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - // HTTP Basic 인증 방식 비활성화 http. httpBasic((auth) -> auth.disable()); + // CSRF 비활성화 http .csrf((auth) -> auth.disable()); + // Form 로그인 방식 비활성화 http .formLogin((auth) -> auth.disable()); @@ -60,11 +61,10 @@ public class SecurityConfig { .accessDeniedHandler(authenticationDeniedHandler) ); - // 경로별 인가 작업 http .authorizeHttpRequests(auth->auth - .requestMatchers("/api/**").authenticated() + .requestMatchers("/api/auth/reissue").permitAll() .anyRequest().authenticated()); // OAuth2 diff --git a/backend/src/main/java/com/worlabel/global/filter/JwtAuthenticationFilter.java b/backend/src/main/java/com/worlabel/global/filter/JwtAuthenticationFilter.java index 0fae705..eb582e9 100644 --- a/backend/src/main/java/com/worlabel/global/filter/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/worlabel/global/filter/JwtAuthenticationFilter.java @@ -1,6 +1,6 @@ package com.worlabel.global.filter; -import com.worlabel.domain.auth.entity.dto.LoginMember; +import com.worlabel.domain.auth.entity.dto.AuthMemberDto; import com.worlabel.domain.auth.service.JwtTokenService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -36,9 +36,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { if (StringUtils.hasText(token) && !jwtTokenService.isTokenExpired(token) && jwtTokenService.isAccessToken(token)) { String name = jwtTokenService.parseUsername(token); int id = jwtTokenService.parseId(token); - List authorities = jwtTokenService.parseAuthorities(token); - Authentication authToken = new UsernamePasswordAuthenticationToken(LoginMember.of(id,name), null, authorities); + + Authentication authToken = new UsernamePasswordAuthenticationToken( + AuthMemberDto.of(id,name,authorities.getFirst().toString()), + null, + authorities + ); SecurityContextHolder.getContext().setAuthentication(authToken); } else { throw new JwtException("유효한 JWT 토큰이 없습니다."); diff --git a/backend/src/main/java/com/worlabel/global/resolver/CurrentUserArgumentResolver.java b/backend/src/main/java/com/worlabel/global/resolver/CurrentUserArgumentResolver.java index 99840bc..12febfb 100644 --- a/backend/src/main/java/com/worlabel/global/resolver/CurrentUserArgumentResolver.java +++ b/backend/src/main/java/com/worlabel/global/resolver/CurrentUserArgumentResolver.java @@ -1,6 +1,6 @@ package com.worlabel.global.resolver; -import com.worlabel.domain.auth.entity.dto.LoginMember; +import com.worlabel.domain.auth.entity.dto.AuthMemberDto; import com.worlabel.global.annotation.CurrentUser; import lombok.extern.slf4j.Slf4j; import org.springframework.core.MethodParameter; @@ -14,6 +14,7 @@ import org.springframework.web.method.support.ModelAndViewContainer; @Slf4j @Component public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { + @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.hasParameterAnnotation(CurrentUser.class); @@ -22,8 +23,8 @@ public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolve @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - if(principal instanceof LoginMember){ - return ((LoginMember) principal).getId(); + if(principal instanceof AuthMemberDto){ + return ((AuthMemberDto) principal).getId(); } return null; }