diff --git a/src/main/java/com/example/umc9th/auth/annotation/AuthUser.java b/src/main/java/com/example/umc9th/auth/annotation/AuthUser.java new file mode 100644 index 0000000..01d1d82 --- /dev/null +++ b/src/main/java/com/example/umc9th/auth/annotation/AuthUser.java @@ -0,0 +1,11 @@ +package com.example.umc9th.auth.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthUser { +} diff --git a/src/main/java/com/example/umc9th/auth/annotation/CheckBlacklist.java b/src/main/java/com/example/umc9th/auth/annotation/CheckBlacklist.java new file mode 100644 index 0000000..37d16b1 --- /dev/null +++ b/src/main/java/com/example/umc9th/auth/annotation/CheckBlacklist.java @@ -0,0 +1,11 @@ +package com.example.umc9th.auth.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface CheckBlacklist { +} diff --git a/src/main/java/com/example/umc9th/auth/aspect/BlacklistAspect.java b/src/main/java/com/example/umc9th/auth/aspect/BlacklistAspect.java new file mode 100644 index 0000000..ae49b20 --- /dev/null +++ b/src/main/java/com/example/umc9th/auth/aspect/BlacklistAspect.java @@ -0,0 +1,42 @@ +package com.example.umc9th.auth.aspect; + +import com.myApp.global.apiPayload.code.status.AuthErrorCode; +import com.myApp.global.apiPayload.exception.GeneralException; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class BlacklistAspect { + + private final StringRedisTemplate redisTemplate; + + @Before("@annotation(com.myApp.auth.annotation.CheckBlacklist)") + public void checkBlacklist() { + HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()) + .getRequest(); + String bearerToken = request.getHeader("Authorization"); + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + String accessToken = bearerToken.substring(7); + + // Redis에 BlackList로 저장되어 있는지 확인 + String isLogout = redisTemplate.opsForValue().get("blacklist:" + accessToken); + + if (StringUtils.hasText(isLogout)) { + throw new GeneralException(AuthErrorCode.AUTH_TOKEN_INVALID); + } + } + } +} diff --git a/src/main/java/com/example/umc9th/auth/controller/AuthController.java b/src/main/java/com/example/umc9th/auth/controller/AuthController.java new file mode 100644 index 0000000..097fff1 --- /dev/null +++ b/src/main/java/com/example/umc9th/auth/controller/AuthController.java @@ -0,0 +1,59 @@ +package com.example.umc9th.auth.controller; + +import com.myApp.auth.dto.TokenDto; +import com.myApp.auth.service.AuthService; +import com.myApp.global.apiPayload.ApiResponse; +import com.myApp.global.apiPayload.code.status.GeneralSuccessCode; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseCookie; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class AuthController implements AuthControllerDocs { + + private final AuthService authService; + + @PostMapping("/reissue") + public ApiResponse reissue(@CookieValue("refresh_token") String refreshToken, + HttpServletResponse response) { + + TokenDto tokenDto = authService.reissue(refreshToken); + + // Refresh Token Cookie 설정 + ResponseCookie cookie = authService.createRefreshTokenCookie(tokenDto.getRefreshToken()); + response.addHeader("Set-Cookie", cookie.toString()); + + String accessToken = tokenDto.getAccessToken(); + response.setHeader("Authorization", "Bearer " + accessToken); + + return ApiResponse.onSuccess(GeneralSuccessCode._OK, accessToken); + } + + @PostMapping("/logout") + public ApiResponse logout(@RequestHeader("Authorization") String accessToken, + @CookieValue("refresh_token") String refreshToken, + HttpServletResponse response) { + + authService.logout(accessToken, refreshToken); + + // 쿠키 삭제 (빈 값으로 덮어쓰기) + ResponseCookie cookie = ResponseCookie.from("refresh_token", "") + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(0) // 만료 + .sameSite("None") + .build(); + response.addHeader("Set-Cookie", cookie.toString()); + + return ApiResponse.onSuccess(GeneralSuccessCode._OK, "로그아웃 되었습니다."); + } +} diff --git a/src/main/java/com/example/umc9th/auth/controller/AuthControllerDocs.java b/src/main/java/com/example/umc9th/auth/controller/AuthControllerDocs.java new file mode 100644 index 0000000..032c371 --- /dev/null +++ b/src/main/java/com/example/umc9th/auth/controller/AuthControllerDocs.java @@ -0,0 +1,25 @@ +package com.example.umc9th.auth.controller; + +import com.myApp.auth.dto.TokenDto; +import com.myApp.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.RequestHeader; + +@Tag(name = "Auth", description = "인증 관련 API") +public interface AuthControllerDocs { + + @Operation(summary = "토큰 재발급", description = "Cookie에 있는 Refresh Token을 이용하여 새로운 Access Token을 발급합니다.") + ApiResponse reissue( + @Parameter(description = "Refresh Token (HttpOnly Cookie)", required = true) @CookieValue("refresh_token") String refreshToken, + HttpServletResponse response); + + @Operation(summary = "로그아웃", description = "사용자를 로그아웃 처리하고 Refresh Token Cookie를 삭제합니다.") + ApiResponse logout( + @Parameter(description = "Access Token", required = true) @RequestHeader("Authorization") String accessToken, + @Parameter(description = "Refresh Token (HttpOnly Cookie)", required = true) @CookieValue("refresh_token") String refreshToken, + HttpServletResponse response); +} diff --git a/src/main/java/com/example/umc9th/auth/controller/AuthTestController.java b/src/main/java/com/example/umc9th/auth/controller/AuthTestController.java new file mode 100644 index 0000000..3091094 --- /dev/null +++ b/src/main/java/com/example/umc9th/auth/controller/AuthTestController.java @@ -0,0 +1,78 @@ +package com.example.umc9th.auth.controller; + +import com.myApp.auth.dto.TokenDto; +import com.myApp.auth.jwt.JwtTokenProvider; +import com.myApp.auth.redis.RefreshToken; +import com.myApp.auth.repository.RefreshTokenRepository; +import com.myApp.auth.entity.Member; +import com.myApp.auth.repository.MemberRepository; +import com.myApp.auth.entity.Role; +import com.myApp.global.apiPayload.ApiResponse; +import com.myApp.global.apiPayload.code.status.GeneralSuccessCode; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseCookie; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; + +@RestController +@RequestMapping("/api/v1/auth/test") +@RequiredArgsConstructor +@Profile("dev") +public class AuthTestController { + + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + private final MemberRepository memberRepository; + + @Operation(summary = "Dev용 로그인 (토큰 발급)", description = "개발 환경에서 OAuth2 로그인 없이 토큰을 발급받습니다.") + @GetMapping("/login") + public ApiResponse devLogin(@RequestParam String email, HttpServletResponse response) { + // 1. 사용자 확인 및 강제 생성 (테스트 편의성) + Member member = memberRepository.findByEmail(email) + .orElseGet(() -> memberRepository.save(Member.builder() + .email(email) + .name("Dev User") + .role(Role.USER) + .socialType("DEV") + .socialId("dev_" + email) + .build())); + + // 2. Authentication 객체 생성 + Authentication authentication = new UsernamePasswordAuthenticationToken( + member.getEmail(), + null, + Collections.singleton(new SimpleGrantedAuthority(member.getRoleKey()))); + + // 3. 토큰 생성 + TokenDto tokenDto = jwtTokenProvider.generateTokenDto(authentication); + + // 4. Refresh Token 저장 + RefreshToken refreshToken = RefreshToken.builder() + .id(member.getEmail()) + .token(tokenDto.getRefreshToken()) + .build(); + refreshTokenRepository.save(refreshToken); + + // 5. 쿠키 설정 + ResponseCookie cookie = ResponseCookie.from("refresh_token", tokenDto.getRefreshToken()) + .httpOnly(true) + .secure(false) // Dev 환경이므로 false + .path("/") + .maxAge(60 * 60 * 24 * 7) // 7일 + .sameSite("None") + .build(); + response.addHeader("Set-Cookie", cookie.toString()); + + return ApiResponse.onSuccess(GeneralSuccessCode._OK, tokenDto); + } +} diff --git a/src/main/java/com/example/umc9th/auth/dto/TokenDto.java b/src/main/java/com/example/umc9th/auth/dto/TokenDto.java new file mode 100644 index 0000000..ca350db --- /dev/null +++ b/src/main/java/com/example/umc9th/auth/dto/TokenDto.java @@ -0,0 +1,17 @@ +package com.example.umc9th.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TokenDto { + private String grantType; + private String accessToken; + private String refreshToken; + private Long accessTokenExpiresIn; +} diff --git a/src/main/java/com/example/umc9th/auth/entity/Member.java b/src/main/java/com/example/umc9th/auth/entity/Member.java new file mode 100644 index 0000000..6f8cdc1 --- /dev/null +++ b/src/main/java/com/example/umc9th/auth/entity/Member.java @@ -0,0 +1,46 @@ +package com.example.umc9th.auth.entity; + +import com.myApp.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "users") +public class Member extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false, unique = true) + private String email; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role; + + @Column(nullable = false) + private String socialId; // 소셜 로그인 제공자에서 주는 ID + + @Column(nullable = false) + private String socialType; // google, kakao, naver + + public Member update(String name) { + this.name = name; + return this; + } + + public String getRoleKey() { + return this.role.getKey(); + } +} diff --git a/src/main/java/com/example/umc9th/auth/entity/Role.java b/src/main/java/com/example/umc9th/auth/entity/Role.java new file mode 100644 index 0000000..a7a053f --- /dev/null +++ b/src/main/java/com/example/umc9th/auth/entity/Role.java @@ -0,0 +1,13 @@ +package com.example.umc9th.auth.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Role { + USER("ROLE_USER"), + ADMIN("ROLE_ADMIN"); + + private final String key; +} diff --git a/src/main/java/com/example/umc9th/auth/handler/AuthUserArgumentResolver.java b/src/main/java/com/example/umc9th/auth/handler/AuthUserArgumentResolver.java new file mode 100644 index 0000000..aff29d8 --- /dev/null +++ b/src/main/java/com/example/umc9th/auth/handler/AuthUserArgumentResolver.java @@ -0,0 +1,46 @@ +package com.example.umc9th.auth.handler; + +import com.myApp.auth.annotation.AuthUser; +import com.myApp.global.apiPayload.code.status.AuthErrorCode; +import com.myApp.global.apiPayload.exception.GeneralException; +import org.springframework.core.MethodParameter; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthUser.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 인증 정보가 없거나 익명 사용자인 경우 예외 발생 + if (authentication == null || authentication instanceof AnonymousAuthenticationToken + || !authentication.isAuthenticated()) { + throw new GeneralException(AuthErrorCode.UNAUTHORIZED); + } + + Object principal = authentication.getPrincipal(); + + // Principal이 UserDetails 타입인지 확인 + if (!(principal instanceof UserDetails)) { + throw new GeneralException(AuthErrorCode.UNAUTHORIZED); + } + + return principal; + } +} diff --git a/src/main/java/com/example/umc9th/auth/handler/OAuth2SuccessHandler.java b/src/main/java/com/example/umc9th/auth/handler/OAuth2SuccessHandler.java new file mode 100644 index 0000000..374dd2a --- /dev/null +++ b/src/main/java/com/example/umc9th/auth/handler/OAuth2SuccessHandler.java @@ -0,0 +1,84 @@ +package com.example.umc9th.auth.handler; + +import com.myApp.auth.dto.TokenDto; +import com.myApp.auth.jwt.JwtTokenProvider; +import com.myApp.auth.redis.RefreshToken; +import com.myApp.auth.repository.RefreshTokenRepository; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.http.ResponseCookie; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + + @Value("${spring.jwt.refresh-token-validity-in-seconds}") + private long refreshTokenValidityInSeconds; + + @Value("${spring.oauth2.redirect-url}") + private String redirectUrl; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + + // 1. 토큰 생성 + TokenDto tokenDto = jwtTokenProvider.generateTokenDto(authentication); + + // 2. Refresh Token 저장 + saveRefreshToken(authentication, tokenDto); + + // 3. Refresh Token을 HttpOnly Cookie로 설정 + setRefreshTokenCookie(response, tokenDto); + + // 4. Access Token만 담아 리다이렉트 + redirectWithAccessToken(request, response, tokenDto.getAccessToken()); + } + + private void saveRefreshToken(Authentication authentication, TokenDto tokenDto) { + + RefreshToken refreshToken = RefreshToken.builder() + .id(authentication.getName()) + .token(tokenDto.getRefreshToken()) + .build(); + + refreshTokenRepository.save(refreshToken); + } + + private void setRefreshTokenCookie(HttpServletResponse response, TokenDto tokenDto) { + + ResponseCookie cookie = ResponseCookie.from("refresh_token", tokenDto.getRefreshToken()) + .httpOnly(true) + .secure(true) // HTTPS 환경에서만 전송 (개발 환경에서는 false로 설정해야 할 수도 있음, 여기서는 true로 설정) + .path("/") + .maxAge(refreshTokenValidityInSeconds) + .sameSite("None") // Cross-Site 요청 허용 (필요에 따라 설정) + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + private void redirectWithAccessToken(HttpServletRequest request, HttpServletResponse response, + String accessToken) throws IOException { + String targetUrl = UriComponentsBuilder.fromUriString(redirectUrl) + .queryParam("accessToken", accessToken) + .build().toUriString(); + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } + +} diff --git a/src/main/java/com/example/umc9th/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/com/example/umc9th/auth/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..84fddaa --- /dev/null +++ b/src/main/java/com/example/umc9th/auth/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,69 @@ +package com.example.umc9th.auth.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.myApp.global.apiPayload.ApiResponse; +import com.myApp.global.apiPayload.exception.GeneralException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +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.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + public static final String AUTHORIZATION_HEADER = "Authorization"; + public static final String BEARER_PREFIX = "Bearer "; + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + try { + // 1. Request Header 에서 토큰을 꺼냄 + String jwt = resolveToken(request); + + // 2. validateToken 으로 토큰 유효성 검사 + // 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장 + if (StringUtils.hasText(jwt)) { + jwtTokenProvider.validateToken(jwt); + Authentication authentication = jwtTokenProvider.getAuthentication(jwt); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + + } catch (GeneralException e) { + log.error("JWT 인증 실패: {}", e.getMessage()); + + // JWT 검증 실패 시 직접 JSON 에러 응답 반환 + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(e.getCode().getHttpStatus().value()); + + ApiResponse.Body errorBody = ApiResponse.createFailureBody(e.getCode()); + + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()); + response.getWriter().write(objectMapper.writeValueAsString(errorBody)); + } + } + + // Request Header 에서 토큰 정보를 꺼내오기 + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/example/umc9th/auth/jwt/JwtTokenProvider.java b/src/main/java/com/example/umc9th/auth/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..a73020a --- /dev/null +++ b/src/main/java/com/example/umc9th/auth/jwt/JwtTokenProvider.java @@ -0,0 +1,150 @@ +package com.example.umc9th.auth.jwt; + +import com.myApp.auth.dto.TokenDto; +import com.myApp.global.apiPayload.code.status.AuthErrorCode; +import com.myApp.global.apiPayload.exception.GeneralException; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; // Key 대신 SecretKey 사용 권장 +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.stream.Collectors; + +@Slf4j +@Component +public class JwtTokenProvider { + + private static final String AUTHORITIES_KEY = "auth"; + private static final String BEARER_TYPE = "Bearer"; + private final long accessTokenValidityInMilliseconds; + private final long refreshTokenValidityInMilliseconds; + + // Key -> SecretKey 타입 변경 (0.12.x 권장) + private final SecretKey key; + + public JwtTokenProvider(@Value("${spring.jwt.secret}") String secretKey, + @Value("${spring.jwt.access-token-validity-in-seconds}") long accessTokenValidityInSeconds, + @Value("${spring.jwt.refresh-token-validity-in-seconds}") long refreshTokenValidityInSeconds) { + this.accessTokenValidityInMilliseconds = accessTokenValidityInSeconds * 1000; + this.refreshTokenValidityInMilliseconds = refreshTokenValidityInSeconds * 1000; + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public String generateAccessToken(Authentication authentication) { + String authorities = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + long now = (new Date()).getTime(); + Date accessTokenExpiresIn = new Date(now + accessTokenValidityInMilliseconds); + + return Jwts.builder() + .subject(authentication.getName()) + .claim(AUTHORITIES_KEY, authorities) + .expiration(accessTokenExpiresIn) + .signWith(key) + .compact(); + } + + public String generateRefreshToken(Authentication authentication) { + long now = (new Date()).getTime(); + return Jwts.builder() + .subject(authentication.getName()) // email 주소 + .expiration(new Date(now + refreshTokenValidityInMilliseconds)) + .signWith(key) + .compact(); + } + + public TokenDto generateTokenDto(Authentication authentication) { + String accessToken = generateAccessToken(authentication); + String refreshToken = generateRefreshToken(authentication); + + long now = (new Date()).getTime(); + Date accessTokenExpiresIn = new Date(now + accessTokenValidityInMilliseconds); + + return TokenDto.builder() + .grantType(BEARER_TYPE) + .accessToken(accessToken) + .accessTokenExpiresIn(accessTokenExpiresIn.getTime()) + .refreshToken(refreshToken) + .build(); + } + + public Authentication getAuthentication(String accessToken) { + // 토큰 복호화 + Claims claims = parseClaims(accessToken); + + if (claims.get(AUTHORITIES_KEY) == null) { + throw new GeneralException(AuthErrorCode.TOKEN_NOT_FOUND); + } + + Collection authorities = Arrays + .stream(claims.get(AUTHORITIES_KEY).toString().split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + UserDetails principal = new User(claims.getSubject(), "", authorities); + + return new UsernamePasswordAuthenticationToken(principal, "", authorities); + } + + public boolean validateToken(String token) { + try { + // [변경 5] parserBuilder() -> parser(), verifyWith(key), parseSignedClaims() + Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token); + return true; + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + log.error("잘못된 JWT 서명입니다.", e); + throw new GeneralException(AuthErrorCode.AUTH_TOKEN_INVALID); + } catch (ExpiredJwtException e) { + log.error("만료된 JWT 토큰입니다.", e); + throw new GeneralException(AuthErrorCode.AUTH_TOKEN_EXPIRED); + } catch (UnsupportedJwtException e) { + log.error("지원되지 않는 JWT 토큰입니다.", e); + throw new GeneralException(AuthErrorCode.AUTH_TOKEN_INVALID); + } catch (IllegalArgumentException e) { + log.error("JWT 토큰이 잘못되었습니다.", e); + throw new GeneralException(AuthErrorCode.AUTH_TOKEN_INVALID); + } + } + + public Long getExpiration(String accessToken) { + // accessToken 남은 유효시간 + Date expiration = parseClaims(accessToken).getExpiration(); + // 현재 시간 + Long now = new Date().getTime(); + return (expiration.getTime() - now); + } + + private Claims parseClaims(String accessToken) { + try { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(accessToken) + .getPayload(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } + } + + public String getSubject(String token) { + return parseClaims(token).getSubject(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc9th/auth/redis/RefreshToken.java b/src/main/java/com/example/umc9th/auth/redis/RefreshToken.java new file mode 100644 index 0000000..75f3c1a --- /dev/null +++ b/src/main/java/com/example/umc9th/auth/redis/RefreshToken.java @@ -0,0 +1,25 @@ +package com.example.umc9th.auth.redis; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +@Getter +@AllArgsConstructor +@Builder +@RedisHash(value = "refreshToken", timeToLive = 60 * 60 * 24 * 14) // 14일 +public class RefreshToken { + + @Id + private String id; // User ID (email or PK) + + @Indexed + private String token; + + public void updateToken(String token) { + this.token = token; + } +} diff --git a/src/main/java/com/example/umc9th/auth/repository/MemberRepository.java b/src/main/java/com/example/umc9th/auth/repository/MemberRepository.java new file mode 100644 index 0000000..466a46e --- /dev/null +++ b/src/main/java/com/example/umc9th/auth/repository/MemberRepository.java @@ -0,0 +1,10 @@ +package com.example.umc9th.auth.repository; + +import com.myApp.auth.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + Optional findByEmail(String email); +} diff --git a/src/main/java/com/example/umc9th/auth/repository/RefreshTokenRepository.java b/src/main/java/com/example/umc9th/auth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..06a6ee5 --- /dev/null +++ b/src/main/java/com/example/umc9th/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,7 @@ +package com.example.umc9th.auth.repository; + +import com.myApp.auth.redis.RefreshToken; +import org.springframework.data.repository.CrudRepository; + +public interface RefreshTokenRepository extends CrudRepository { +} diff --git a/src/main/java/com/example/umc9th/auth/service/AuthService.java b/src/main/java/com/example/umc9th/auth/service/AuthService.java new file mode 100644 index 0000000..39bc88b --- /dev/null +++ b/src/main/java/com/example/umc9th/auth/service/AuthService.java @@ -0,0 +1,98 @@ +package com.example.umc9th.auth.service; + +import com.myApp.auth.dto.TokenDto; +import com.myApp.auth.jwt.JwtTokenProvider; +import com.myApp.auth.redis.RefreshToken; +import com.myApp.auth.repository.RefreshTokenRepository; +import com.myApp.global.apiPayload.code.status.AuthErrorCode; + +import com.myApp.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + private final StringRedisTemplate redisTemplate; + private final CustomUserDetailsService customUserDetailsService; + + @org.springframework.beans.factory.annotation.Value("${spring.jwt.refresh-token-validity-in-seconds}") + private long refreshTokenValidityInSeconds; + + @Transactional + public TokenDto reissue(String refreshToken) { + // 1. Refresh Token 검증 + if (!jwtTokenProvider.validateToken(refreshToken)) { + throw new GeneralException(AuthErrorCode.INVALID_REFRESH_TOKEN); + } + + // 2. Refresh Token 에서 email 가져오기 + String email = jwtTokenProvider.getSubject(refreshToken); + + // 3. Redis 에서 id(email) 를 기반으로 저장된 Refresh Token 값을 가져옴 + RefreshToken redisRefreshToken = refreshTokenRepository.findById(email) + .orElseThrow(() -> new GeneralException(AuthErrorCode.INVALID_REFRESH_TOKEN)); + + // 4. Refresh Token 일치하는지 검사 + if (!redisRefreshToken.getToken().equals(refreshToken)) { + throw new GeneralException(AuthErrorCode.REFRESH_TOKEN_MISMATCH); + } + + // 5. Refresh Token & AccessToken + UserDetails userDetails = customUserDetailsService.loadUserByUsername(email); + Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + TokenDto tokenDto = jwtTokenProvider.generateTokenDto(authentication); + + // 6. 리프레시 토큰 갱신 (RTR 방식) + redisRefreshToken.updateToken(tokenDto.getRefreshToken()); + refreshTokenRepository.save(redisRefreshToken); + + return tokenDto; + } + + @Transactional + public void logout(String accessToken, String refreshToken) { + // Bearer 제거 + if (accessToken != null && accessToken.startsWith("Bearer ")) { + accessToken = accessToken.substring(7); + } + + // 1. Access Token 검증 + if (!jwtTokenProvider.validateToken(accessToken)) { + throw new GeneralException(AuthErrorCode.AUTH_TOKEN_INVALID); + } + + // 2. Access Token 에서 User ID 가져오기 + Authentication authentication = jwtTokenProvider.getAuthentication(accessToken); + + // 3. Redis 에서 해당 User ID 로 저장된 Refresh Token 이 있는지 여부를 확인 후 있을 경우 삭제 + if (refreshTokenRepository.findById(authentication.getName()).isPresent()) { + refreshTokenRepository.deleteById(authentication.getName()); + } + + // 4. Access Token 유효시간을 가져와서 BlackList로 저장 + Long expiration = jwtTokenProvider.getExpiration(accessToken); + redisTemplate.opsForValue() + .set("blacklist:" + accessToken, "logout", expiration, java.util.concurrent.TimeUnit.MILLISECONDS); + } + + public org.springframework.http.ResponseCookie createRefreshTokenCookie(String refreshToken) { + return org.springframework.http.ResponseCookie.from("refresh_token", refreshToken) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(refreshTokenValidityInSeconds) + .sameSite("None") + .build(); + } +} diff --git a/src/main/java/com/example/umc9th/auth/service/CustomOAuth2UserService.java b/src/main/java/com/example/umc9th/auth/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..15fbc69 --- /dev/null +++ b/src/main/java/com/example/umc9th/auth/service/CustomOAuth2UserService.java @@ -0,0 +1,58 @@ +package com.example.umc9th.auth.service; + +import com.myApp.auth.entity.Member; +import com.myApp.auth.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService implements OAuth2UserService { + + private final MemberRepository memberRepository; + + @Setter + private OAuth2UserService delegate = new DefaultOAuth2UserService(); + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = delegate.loadUser(userRequest); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails() + .getUserInfoEndpoint().getUserNameAttributeName(); + + OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, + oAuth2User.getAttributes()); + + Member member = saveOrUpdate(attributes); + + // 이메일을 Principal Name으로 사용하기 위해 attributes에 email 추가 및 nameAttributeKey 변경 + Map newAttributes = new java.util.HashMap<>(attributes.getAttributes()); + newAttributes.put("email", attributes.getEmail()); + + return new DefaultOAuth2User( + Collections.singleton(new SimpleGrantedAuthority(member.getRoleKey())), + newAttributes, + "email"); + } + + private Member saveOrUpdate(OAuthAttributes attributes) { + Member member = memberRepository.findByEmail(attributes.getEmail()) + .map(entity -> entity.update(attributes.getName())) + .orElse(attributes.toEntity()); + + return memberRepository.save(member); + } +} diff --git a/src/main/java/com/example/umc9th/auth/service/CustomUserDetailsService.java b/src/main/java/com/example/umc9th/auth/service/CustomUserDetailsService.java new file mode 100644 index 0000000..41bd4f1 --- /dev/null +++ b/src/main/java/com/example/umc9th/auth/service/CustomUserDetailsService.java @@ -0,0 +1,44 @@ +package com.example.umc9th.auth.service; + +import com.myApp.auth.entity.Member; +import com.myApp.auth.repository.MemberRepository; +import com.myApp.global.apiPayload.code.status.GeneralErrorCode; +import com.myApp.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + @Transactional + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + // username은 소셜로그인 이메일 + return memberRepository.findByEmail(username) + .map(this::createUserDetails) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.USER_NOT_FOUND)); + } + + // DB에서 가져온 Member 객체를 Spring Security의 UserDetails 객체로 변환 + private UserDetails createUserDetails(Member member) { + GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(member.getRole().toString()); + + return new User( + String.valueOf(member.getEmail()), + "", // 소셜로그인만 구현 -> 임의값 + Collections.singleton(grantedAuthority) + ); + } +} diff --git a/src/main/java/com/example/umc9th/auth/service/OAuthAttributes.java b/src/main/java/com/example/umc9th/auth/service/OAuthAttributes.java new file mode 100644 index 0000000..73c7bd8 --- /dev/null +++ b/src/main/java/com/example/umc9th/auth/service/OAuthAttributes.java @@ -0,0 +1,88 @@ +package com.example.umc9th.auth.service; + +import com.myApp.auth.entity.Role; +import com.myApp.auth.entity.Member; +import lombok.Builder; +import lombok.Getter; + +import java.util.Map; + +@Getter +public class OAuthAttributes { + private Map attributes; + private final String nameAttributeKey; + private final String name; + private final String email; + private final String socialType; + private final String socialId; + + @Builder + public OAuthAttributes(Map attributes, String nameAttributeKey, String name, String email, + String socialType, String socialId) { + this.attributes = attributes; + this.nameAttributeKey = nameAttributeKey; + this.name = name; + this.email = email; + this.socialType = socialType; + this.socialId = socialId; + } + + public static OAuthAttributes of(String registrationId, String userNameAttributeName, + Map attributes) { + + return switch (registrationId) { + case "google" -> ofGoogle(userNameAttributeName, attributes); + case "naver" -> ofNaver(userNameAttributeName, attributes); + case "kakao" -> ofKakao(userNameAttributeName, attributes); + default -> null; + }; + } + + private static OAuthAttributes ofGoogle(String userNameAttributeName, Map attributes) { + return OAuthAttributes.builder() + .name((String) attributes.get("name")) + .email((String) attributes.get("email")) + .socialType("google") + .socialId((String) attributes.get("sub")) + .attributes(attributes) + .nameAttributeKey(userNameAttributeName) + .build(); + } + + private static OAuthAttributes ofNaver(String userNameAttributeName, Map attributes) { + Map response = (Map) attributes.get("response"); + + return OAuthAttributes.builder() + .name((String) response.get("name")) + .email((String) response.get("email")) + .socialType("naver") + .socialId((String) response.get("id")) + .attributes(response) + .nameAttributeKey(userNameAttributeName) + .build(); + } + + private static OAuthAttributes ofKakao(String userNameAttributeName, Map attributes) { + Map kakaoAccount = (Map) attributes.get("kakao_account"); + Map profile = (Map) kakaoAccount.get("profile"); + + return OAuthAttributes.builder() + .name((String) profile.get("nickname")) + .email((String) kakaoAccount.get("email")) + .socialType("kakao") + .socialId(String.valueOf(attributes.get("id"))) + .attributes(attributes) + .nameAttributeKey(userNameAttributeName) + .build(); + } + + public Member toEntity() { + return Member.builder() + .name(name) + .email(email) + .role(Role.USER) + .socialType(socialType) + .socialId(socialId) + .build(); + } +}