From d788a111c475fbb348bb482fb94c1407657eda58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EC=88=98?= Date: Wed, 28 Aug 2024 18:00:47 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20JWT=20=ED=86=A0=ED=81=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20S11P21S002-16?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/attribute/OAuth2Attribute.java | 1 + .../auth/controller/AuthController.java | 7 +- ...Auth2Member.java => CustomOAuth2User.java} | 24 ++-- .../auth/{ => entity}/dto/AuthMemberDto.java | 2 +- .../domain/auth/entity/dto/JwtToken.java | 11 ++ ...vice.java => CustomOAuth2UserService.java} | 32 +++-- .../domain/auth/service/JwtTokenService.java | 117 +++++++++++++++++- .../worlabel/domain/member/entity/Member.java | 12 +- .../member/repository/MemberRepository.java | 2 - .../global/config/SecurityConfig.java | 80 ++++++------ .../worlabel/global/exception/ErrorCode.java | 3 +- .../com/worlabel/global/filter/JWTFilter.java | 53 -------- .../filter/JwtAuthenticationFilter.java | 63 ++++++++++ .../CustomAuthenticationDeniedHandler.java | 29 +++++ .../CustomAuthenticationEntryPoint.java | 32 +++++ .../global/handler/OAuth2SuccessHandler.java | 51 ++++---- .../resolver/CurrentUserArgumentResolver.java | 6 +- 17 files changed, 369 insertions(+), 156 deletions(-) rename backend/src/main/java/com/worlabel/domain/auth/entity/{CustomOAuth2Member.java => CustomOAuth2User.java} (62%) rename backend/src/main/java/com/worlabel/domain/auth/{ => entity}/dto/AuthMemberDto.java (91%) create mode 100644 backend/src/main/java/com/worlabel/domain/auth/entity/dto/JwtToken.java rename backend/src/main/java/com/worlabel/domain/auth/service/{CustomOAuth2MemberService.java => CustomOAuth2UserService.java} (58%) delete mode 100644 backend/src/main/java/com/worlabel/global/filter/JWTFilter.java create mode 100644 backend/src/main/java/com/worlabel/global/filter/JwtAuthenticationFilter.java create mode 100644 backend/src/main/java/com/worlabel/global/handler/CustomAuthenticationDeniedHandler.java create mode 100644 backend/src/main/java/com/worlabel/global/handler/CustomAuthenticationEntryPoint.java diff --git a/backend/src/main/java/com/worlabel/domain/auth/attribute/OAuth2Attribute.java b/backend/src/main/java/com/worlabel/domain/auth/attribute/OAuth2Attribute.java index d2f52f1..24935f1 100644 --- a/backend/src/main/java/com/worlabel/domain/auth/attribute/OAuth2Attribute.java +++ b/backend/src/main/java/com/worlabel/domain/auth/attribute/OAuth2Attribute.java @@ -19,4 +19,5 @@ public abstract class OAuth2Attribute { public abstract String getEmail(); public abstract String getProfileImage(); + } 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 802feb7..4a04948 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,6 +1,5 @@ package com.worlabel.domain.auth.controller; -import com.worlabel.domain.auth.dto.AuthMemberDto; import com.worlabel.global.annotation.CurrentUser; import com.worlabel.global.response.SuccessResponse; import lombok.RequiredArgsConstructor; @@ -16,9 +15,13 @@ import org.springframework.web.bind.annotation.RestController; public class AuthController { // TODO: 리이슈 처리 + @GetMapping("/") + public String login() { + return "로그인 성공"; + } @GetMapping("/user-info") - public SuccessResponse getMemberInfo(@CurrentUser AuthMemberDto currentMember){ + public SuccessResponse getMemberInfo(@CurrentUser Integer currentMember){ return SuccessResponse.of(currentMember); } } diff --git a/backend/src/main/java/com/worlabel/domain/auth/entity/CustomOAuth2Member.java b/backend/src/main/java/com/worlabel/domain/auth/entity/CustomOAuth2User.java similarity index 62% rename from backend/src/main/java/com/worlabel/domain/auth/entity/CustomOAuth2Member.java rename to backend/src/main/java/com/worlabel/domain/auth/entity/CustomOAuth2User.java index d48235e..908ade4 100644 --- a/backend/src/main/java/com/worlabel/domain/auth/entity/CustomOAuth2Member.java +++ b/backend/src/main/java/com/worlabel/domain/auth/entity/CustomOAuth2User.java @@ -1,6 +1,6 @@ package com.worlabel.domain.auth.entity; -import com.worlabel.domain.auth.dto.AuthMemberDto; +import com.worlabel.domain.auth.entity.dto.AuthMemberDto; import com.worlabel.domain.member.entity.Member; import lombok.Getter; import org.springframework.security.core.GrantedAuthority; @@ -11,34 +11,38 @@ import java.util.Collection; import java.util.List; import java.util.Map; -public class CustomOAuth2Member implements OAuth2User { +public class CustomOAuth2User implements OAuth2User { @Getter - private final AuthMemberDto authMemberDto; + private transient final AuthMemberDto authMember; - public CustomOAuth2Member(Member member) { - authMemberDto = AuthMemberDto.of(member); + public CustomOAuth2User(Member member) { + authMember = AuthMemberDto.of(member); } @Override public Map getAttributes() { // OAuth2 제공자로부터 받은 사용자 속성 데이터를 반환합니다. return Map.of( - "id", authMemberDto.getId(), - "email", authMemberDto.getEmail(), - "role", authMemberDto.getRole() + "id", authMember.getId(), + "email", authMember.getEmail(), + "role", authMember.getRole() ); } @Override public Collection getAuthorities() { // 사용자의 역할(RoleType)을 권한으로 변환하여 반환합니다. - return List.of(new SimpleGrantedAuthority(authMemberDto.getRole())); + return List.of(new SimpleGrantedAuthority(authMember.getRole())); } @Override public String getName() { // 사용자의 고유 식별자를 반환합니다. 여기서는 이메일을 사용합니다. - return authMemberDto.getEmail(); + return authMember.getEmail(); + } + + public int getId(){ + return authMember.getId(); } } diff --git a/backend/src/main/java/com/worlabel/domain/auth/dto/AuthMemberDto.java b/backend/src/main/java/com/worlabel/domain/auth/entity/dto/AuthMemberDto.java similarity index 91% rename from backend/src/main/java/com/worlabel/domain/auth/dto/AuthMemberDto.java rename to backend/src/main/java/com/worlabel/domain/auth/entity/dto/AuthMemberDto.java index 4b81590..8373354 100644 --- a/backend/src/main/java/com/worlabel/domain/auth/dto/AuthMemberDto.java +++ b/backend/src/main/java/com/worlabel/domain/auth/entity/dto/AuthMemberDto.java @@ -1,4 +1,4 @@ -package com.worlabel.domain.auth.dto; +package com.worlabel.domain.auth.entity.dto; import com.worlabel.domain.member.entity.Member; import lombok.Getter; 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 new file mode 100644 index 0000000..b5b0e7a --- /dev/null +++ b/backend/src/main/java/com/worlabel/domain/auth/entity/dto/JwtToken.java @@ -0,0 +1,11 @@ +package com.worlabel.domain.auth.entity.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class JwtToken { + private String accessToken; + private String refreshToken; +} diff --git a/backend/src/main/java/com/worlabel/domain/auth/service/CustomOAuth2MemberService.java b/backend/src/main/java/com/worlabel/domain/auth/service/CustomOAuth2UserService.java similarity index 58% rename from backend/src/main/java/com/worlabel/domain/auth/service/CustomOAuth2MemberService.java rename to backend/src/main/java/com/worlabel/domain/auth/service/CustomOAuth2UserService.java index 427313b..31087ef 100644 --- a/backend/src/main/java/com/worlabel/domain/auth/service/CustomOAuth2MemberService.java +++ b/backend/src/main/java/com/worlabel/domain/auth/service/CustomOAuth2UserService.java @@ -2,7 +2,7 @@ package com.worlabel.domain.auth.service; import com.worlabel.domain.auth.attribute.OAuth2Attribute; import com.worlabel.domain.auth.attribute.OAuth2AttributeFactory; -import com.worlabel.domain.auth.entity.CustomOAuth2Member; +import com.worlabel.domain.auth.entity.CustomOAuth2User; import com.worlabel.domain.auth.entity.ProviderType; import com.worlabel.domain.member.entity.Member; import com.worlabel.domain.member.repository.MemberRepository; @@ -13,6 +13,7 @@ import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; /** * OAuth2 사용자 서비스 클래스 @@ -21,27 +22,34 @@ import org.springframework.stereotype.Service; @Slf4j @Service @RequiredArgsConstructor -public class CustomOAuth2MemberService extends DefaultOAuth2UserService { +public class CustomOAuth2UserService extends DefaultOAuth2UserService { private final MemberRepository memberRepository; @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + // OAuth2 인증을 통해 사용자 정보를 가져온다. OAuth2User user = super.loadUser(userRequest); - log.debug("oAuth2User: {}", user.getAttributes()); + // OAuth2 제공자 정보 가져오기 ProviderType provider = ProviderType.valueOf(userRequest.getClientRegistration().getRegistrationId().toUpperCase()); + + // Provider 사용자 속성 파싱 OAuth2Attribute attribute = OAuth2AttributeFactory.parseAttribute(provider, user.getAttributes()); - log.debug("provider: {}, user: {}", provider, user); + log.debug("OAuth2 -> provider: {}, user: {}", provider, user); - Member member = memberRepository.findByProviderMemberId(attribute.getId()) - .orElseGet(() -> { - Member newMember = Member.of(attribute.getId(), attribute.getEmail(), attribute.getName(), attribute.getProfileImage()); - memberRepository.save(newMember); - return newMember; - }); + // 이메일 기반으로 기존 사용자를 찾는다. + Member findMember = memberRepository.findByEmail(attribute.getEmail()) + .orElseGet(() -> createMember(attribute, provider)); + log.debug("Loaded member : {}", findMember); - log.debug("member : {}", member); - return new CustomOAuth2Member(member); + return new CustomOAuth2User(findMember); + } + + @Transactional + protected Member createMember(OAuth2Attribute attribute, ProviderType provider) { + Member newMember = Member.create(attribute, provider); + memberRepository.save(newMember); + return newMember; } } 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 c4dfcfa..03073cd 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 @@ -1,9 +1,122 @@ package com.worlabel.domain.auth.service; -import lombok.extern.slf4j.Slf4j; +import com.worlabel.domain.auth.entity.CustomOAuth2User; +import com.worlabel.domain.auth.entity.dto.JwtToken; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -@Slf4j +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Date; + @Service public class JwtTokenService { + + private final SecretKey secretKey; + private final Long tokenExpiration; + private final Long refreshTokenExpiration; + + public JwtTokenService( + @Value("${spring.jwt.secret}") String key, + @Value("${auth.tokenExpiry}") long tokenExpiry, + @Value("${auth.refreshTokenExpiry}") long refreshExpiry + ) { + secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm()); + tokenExpiration = tokenExpiry; + refreshTokenExpiration = refreshExpiry; + } + + public JwtToken generateToken(int memberId){ + long now = System.currentTimeMillis(); + + Date accessTokenExpire = new Date(now + tokenExpiration); + Date refreshTokenExpire = new Date(now + refreshTokenExpiration); + + String accessToken = Jwts.builder() + .claim("id",memberId) + .claim("type","access") + .expiration(accessTokenExpire) + .signWith(secretKey) + .compact(); + + String refreshToken = Jwts.builder() + .claim("id",memberId) + .claim("type","refresh") + .expiration(refreshTokenExpire) + .signWith(secretKey) + .compact(); + + return new JwtToken(accessToken, refreshToken); + } + + public JwtToken generateTokenByRefreshToken(String refreshToken) throws Exception{ + if(isTokenExpired(refreshToken) && isRefreshToken(refreshToken)){ + return generateToken(Integer.parseInt(refreshToken)); + } + throw new Exception("유효하지 않은 토큰입니다."); + } + + public int parseId(String token) throws Exception { + return parseClaims(token).get("id", Integer.class); + } + + // 토큰 만료 여부 확인 + 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 isRefreshToken(String token) { + return isTokenType(token, "refresh"); + } + + public boolean isAccessToken(String token) { + return isTokenType(token, "access"); + } + + private boolean isTokenType(String token, String expectedType) { + try { + Claims claims = parseClaims(token); + String tokenType = claims.get("type", String.class); + return expectedType.equals(tokenType); + } catch (Exception e) { + return false; + } + } + + public long getRefreshTokenExpiration(){ + return refreshTokenExpiration; + } + + private Claims parseClaims(String token) throws Exception { + String message; + try{ + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + }catch (ExpiredJwtException e){ + message = "유효기간이 만료된 토큰입니다."; + }catch (MalformedJwtException e){ + message = "잘못된 형식의 토큰입니다."; + }catch (IllegalArgumentException e) { + message = "잘못된 인자입니다."; + }catch (Exception e){ + message = "토큰 파싱 중 에러가 발생했습니다."; + } + throw new Exception(message); + } + } diff --git a/backend/src/main/java/com/worlabel/domain/member/entity/Member.java b/backend/src/main/java/com/worlabel/domain/member/entity/Member.java index aed0952..6a75ff0 100644 --- a/backend/src/main/java/com/worlabel/domain/member/entity/Member.java +++ b/backend/src/main/java/com/worlabel/domain/member/entity/Member.java @@ -1,5 +1,6 @@ package com.worlabel.domain.member.entity; +import com.worlabel.domain.auth.attribute.OAuth2Attribute; import com.worlabel.domain.auth.entity.ProviderType; import com.worlabel.domain.comment.entity.Comment; import com.worlabel.global.common.BaseEntity; @@ -69,12 +70,13 @@ public class Member extends BaseEntity { @OneToMany(mappedBy = "member", fetch = FetchType.LAZY,cascade = CascadeType.ALL, orphanRemoval = true) private List commentList = new ArrayList<>(); - public static Member of(String providerMemberId, String email, String nickname, String profileImage) { + public static Member create(OAuth2Attribute attribute, ProviderType provider) { Member member = new Member(); - member.providerMemberId = providerMemberId; - member.email = email; - member.nickname = nickname; - member.profileImage = profileImage; + member.providerMemberId = attribute.getId(); + member.email = attribute.getEmail(); + member.nickname = attribute.getName(); + member.profileImage = attribute.getProfileImage(); + member.provider = provider; return member; } } diff --git a/backend/src/main/java/com/worlabel/domain/member/repository/MemberRepository.java b/backend/src/main/java/com/worlabel/domain/member/repository/MemberRepository.java index 4b4442e..b5ecfe9 100644 --- a/backend/src/main/java/com/worlabel/domain/member/repository/MemberRepository.java +++ b/backend/src/main/java/com/worlabel/domain/member/repository/MemberRepository.java @@ -6,7 +6,5 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface MemberRepository extends JpaRepository { - Optional findByProviderMemberId(String providerMemberId); - Optional findByEmail(String email); } 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 af97fe0..e20e61e 100644 --- a/backend/src/main/java/com/worlabel/global/config/SecurityConfig.java +++ b/backend/src/main/java/com/worlabel/global/config/SecurityConfig.java @@ -1,10 +1,10 @@ package com.worlabel.global.config; -import com.worlabel.domain.auth.service.CustomOAuth2MemberService; -import com.worlabel.domain.member.repository.MemberRepository; -import com.worlabel.global.filter.JWTFilter; +import com.worlabel.domain.auth.service.CustomOAuth2UserService; +import com.worlabel.global.filter.JwtAuthenticationFilter; +import com.worlabel.global.handler.CustomAuthenticationDeniedHandler; +import com.worlabel.global.handler.CustomAuthenticationEntryPoint; import com.worlabel.global.handler.OAuth2SuccessHandler; -import com.worlabel.global.util.JWTUtil; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -18,73 +18,81 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.Collections; +import java.util.List; @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { - - private final CustomOAuth2MemberService customOAuth2UserService; + private final CustomAuthenticationDeniedHandler authenticationDeniedHandler; + private final CustomAuthenticationEntryPoint authenticationEntryPoint; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CustomOAuth2UserService customOAuth2UserService; private final OAuth2SuccessHandler oAuth2SuccessHandler; - private final JWTUtil jwtUtil; @Value("${frontend.url}") private String frontend; @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http, MemberRepository memberRepository) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // HTTP Basic 인증 방식 비활성화 + http. + httpBasic((auth) -> auth.disable()); // CSRF 비활성화 http .csrf((auth) -> auth.disable()); - - // Form 로그인 방식 Disable + // Form 로그인 방식 비활성화 http .formLogin((auth) -> auth.disable()); - // HTTP Basic 인증 방식 disable - http. - httpBasic((auth) -> auth.disable()); + // 세션 설정 비활성화 + http.sessionManagement((session)->session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); - // OAuth2 + // CORS 설정 http - .oauth2Login(oauth2 -> oauth2 - .authorizationEndpoint(authorizationEndpoint -> - authorizationEndpoint.baseUri("/api/oauth2/authorization")) - .redirectionEndpoint(redirectionEndpoint -> - redirectionEndpoint.baseUri("/api/login/oauth2/code/*")) - .userInfoEndpoint(userInfoEndpointConfig -> - userInfoEndpointConfig.userService(customOAuth2UserService)) - .successHandler(oAuth2SuccessHandler) - ); + .cors(configurer -> configurer.configurationSource(corsConfigurationSource())); + + http + .exceptionHandling(configurer -> configurer + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(authenticationDeniedHandler) + ); // 경로별 인가 작업 http - .authorizeHttpRequests((auth)->auth - .requestMatchers("/favicon.ico").permitAll() // Allow access to favicon.ico - .requestMatchers("/").permitAll() + .authorizeHttpRequests(auth->auth + .requestMatchers("/api/**").authenticated() .anyRequest().authenticated()); - // 세션 설정: STATELESS - http.sessionManagement((session)->session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + // OAuth2 + http + .oauth2Login(oauth2 -> oauth2 + .authorizationEndpoint(authorization -> authorization.baseUri("/api/login/oauth2/authorization")) + .redirectionEndpoint(redirection -> redirection .baseUri("/api/login/oauth2/code/*")) + .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) + .successHandler(oAuth2SuccessHandler) + ); + + + + // JWT 필터 추가 http - .addFilterAfter(new JWTFilter(jwtUtil, memberRepository), UsernamePasswordAuthenticationFilter.class); + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - - configuration.setAllowedOrigins(Collections.singletonList(frontend)); // 프론트엔드 URL 사용 - configuration.setAllowedMethods(Collections.singletonList("*")); configuration.setAllowCredentials(true); - configuration.setAllowedHeaders(Collections.singletonList("*")); + configuration.setAllowedOrigins(List.of(frontend)); // 프론트엔드 URL 사용 + configuration.setAllowedMethods(List.of("GET","POST","PUT","PATCH","DELETE","OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); configuration.setMaxAge(3600L); configuration.addExposedHeader("Authorization"); @@ -93,6 +101,4 @@ public class SecurityConfig { source.registerCorsConfiguration("/**", configuration); return source; } - - } diff --git a/backend/src/main/java/com/worlabel/global/exception/ErrorCode.java b/backend/src/main/java/com/worlabel/global/exception/ErrorCode.java index c18226d..a0bf0a2 100644 --- a/backend/src/main/java/com/worlabel/global/exception/ErrorCode.java +++ b/backend/src/main/java/com/worlabel/global/exception/ErrorCode.java @@ -22,8 +22,9 @@ public enum ErrorCode { REFRESH_TOKEN_EXPIRED(HttpStatus.BAD_REQUEST, 2003, "리프레시 토큰이 만료되었습니다."), INVALID_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, 2004, "유효하지 않은 리프레시 토큰입니다."), UNAUTHORIZED(HttpStatus.UNAUTHORIZED, 2005, "인증에 실패하였습니다."), + Access_DENIED(HttpStatus.FORBIDDEN, 2006, "접근 권한이 없습니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, 2000, "올바르지 않는 인증 토큰입니다. 다시 확인 해주세요"); - ; private final HttpStatus status; private final int code; diff --git a/backend/src/main/java/com/worlabel/global/filter/JWTFilter.java b/backend/src/main/java/com/worlabel/global/filter/JWTFilter.java deleted file mode 100644 index a618d44..0000000 --- a/backend/src/main/java/com/worlabel/global/filter/JWTFilter.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.worlabel.global.filter; - -import com.worlabel.domain.auth.entity.CustomOAuth2Member; -import com.worlabel.domain.member.repository.MemberRepository; -import com.worlabel.global.util.JWTUtil; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.util.Arrays; -import java.util.Optional; - -@Slf4j -@RequiredArgsConstructor -public class JWTFilter extends OncePerRequestFilter { - - private final JWTUtil jwtUtil; - private final MemberRepository memberRepository; - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - Cookie[] cookies = request.getCookies(); - String token = Arrays.stream(Optional.ofNullable(cookies).orElse(new Cookie[0])) - .filter(cookie -> "Authorization".equals(cookie.getName())) - .map(Cookie::getValue) - .findFirst() - .orElse(null); - - if(token == null || jwtUtil.isExpired(token)){ - log.debug("토큰 X"); - filterChain.doFilter(request, response); - return; - } - - String username = jwtUtil.getUsername(token); - memberRepository.findByEmail(username).ifPresent(member -> { - CustomOAuth2Member customOAuth2Member = new CustomOAuth2Member(member); - Authentication authToken = new UsernamePasswordAuthenticationToken(customOAuth2Member.getAuthMemberDto(), null, customOAuth2Member.getAuthorities()); - SecurityContextHolder.getContext().setAuthentication(authToken); - }); - - filterChain.doFilter(request, response); - } -} diff --git a/backend/src/main/java/com/worlabel/global/filter/JwtAuthenticationFilter.java b/backend/src/main/java/com/worlabel/global/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..133da98 --- /dev/null +++ b/backend/src/main/java/com/worlabel/global/filter/JwtAuthenticationFilter.java @@ -0,0 +1,63 @@ +package com.worlabel.global.filter; + +import com.worlabel.domain.auth.entity.CustomOAuth2User; +import com.worlabel.domain.member.repository.MemberRepository; +import com.worlabel.global.util.JWTUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JWTUtil jwtUtil; + private final MemberRepository memberRepository; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String token = getToken(request); + try { + if (StringUtils.hasText(token) && !jwtUtil.isExpired(token)) { + String username = jwtUtil.getUsername(token); + memberRepository.findByEmail(username).ifPresent(member -> { + CustomOAuth2User customOAuth2User = new CustomOAuth2User(member); + Authentication authToken = new UsernamePasswordAuthenticationToken(customOAuth2User.getAuthMember(), null, customOAuth2User.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authToken); + }); + } else { + throw new JwtException("유효한 JWT 토큰이 없습니다."); + } + } catch (Exception e) { + log.debug("message: {}",e.getMessage()); + SecurityContextHolder.clearContext(); + request.setAttribute("error-message", e.getMessage()); + } + filterChain.doFilter(request, response); + } + + private static String getToken(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + return Arrays.stream(Optional.ofNullable(cookies).orElse(new Cookie[0])) + .filter(cookie -> "Authorization".equals(cookie.getName())) + .map(Cookie::getValue) + .findFirst() + .orElse(null); + } +} diff --git a/backend/src/main/java/com/worlabel/global/handler/CustomAuthenticationDeniedHandler.java b/backend/src/main/java/com/worlabel/global/handler/CustomAuthenticationDeniedHandler.java new file mode 100644 index 0000000..9a6e8a0 --- /dev/null +++ b/backend/src/main/java/com/worlabel/global/handler/CustomAuthenticationDeniedHandler.java @@ -0,0 +1,29 @@ +package com.worlabel.global.handler; + +import com.worlabel.global.exception.CustomException; +import com.worlabel.global.exception.ErrorCode; +import com.worlabel.global.response.ErrorResponse; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@Component +public class CustomAuthenticationDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + log.debug("오류 : {}", request.getAttribute("error-message")); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + CustomException exception = new CustomException(ErrorCode.Access_DENIED); + ErrorResponse errorResponse = new ErrorResponse(exception, request.getAttribute("error-message").toString()); + response.setContentType("application/json;charset=utf-8"); + response.getWriter().write(errorResponse.toJson()); + } +} diff --git a/backend/src/main/java/com/worlabel/global/handler/CustomAuthenticationEntryPoint.java b/backend/src/main/java/com/worlabel/global/handler/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..ab22f4b --- /dev/null +++ b/backend/src/main/java/com/worlabel/global/handler/CustomAuthenticationEntryPoint.java @@ -0,0 +1,32 @@ +package com.worlabel.global.handler; + +import com.worlabel.global.exception.CustomException; +import com.worlabel.global.exception.ErrorCode; +import com.worlabel.global.response.ErrorResponse; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + log.debug("인증 실패 오류 : {} ", request.getAttribute("error-message")); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + CustomException exception = new CustomException(ErrorCode.INVALID_TOKEN); + if (request.getAttribute("error-message") == null) { + request.setAttribute("error-message", exception.getMessage()); + } + ErrorResponse errorResponse = new ErrorResponse(exception, request.getAttribute("error-message").toString()); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(errorResponse.toJson()); + } +} diff --git a/backend/src/main/java/com/worlabel/global/handler/OAuth2SuccessHandler.java b/backend/src/main/java/com/worlabel/global/handler/OAuth2SuccessHandler.java index c916e83..73a5b6d 100644 --- a/backend/src/main/java/com/worlabel/global/handler/OAuth2SuccessHandler.java +++ b/backend/src/main/java/com/worlabel/global/handler/OAuth2SuccessHandler.java @@ -1,6 +1,8 @@ package com.worlabel.global.handler; -import com.worlabel.domain.auth.entity.CustomOAuth2Member; +import com.worlabel.domain.auth.entity.CustomOAuth2User; +import com.worlabel.domain.auth.entity.dto.JwtToken; +import com.worlabel.domain.auth.service.JwtTokenService; import com.worlabel.global.util.JWTUtil; import jakarta.servlet.ServletException; import jakarta.servlet.http.Cookie; @@ -10,55 +12,50 @@ 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.core.GrantedAuthority; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.stereotype.Component; import java.io.IOException; -import java.util.stream.Collectors; @Slf4j @Component @RequiredArgsConstructor public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { - // TODO : 추후 도메인 혹은 배포시 설정 @Value("${frontend.url}") private String frontEnd; - private final JWTUtil jwtUtil; + private final JwtTokenService jwtTokenService; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { - // OAuth2User - CustomOAuth2Member customOAuth2Member = (CustomOAuth2Member) authentication.getPrincipal(); - log.debug("로그인 성공 : {}", customOAuth2Member); - - String username = customOAuth2Member.getName(); - String role = authentication.getAuthorities() - .stream() - .map(auth -> auth.getAuthority()) - .toList() - .get(0); + CustomOAuth2User customOAuth2User = (CustomOAuth2User) authentication.getPrincipal(); + log.debug("로그인 성공 : {}", customOAuth2User); // JWT 토큰 생성 - String token = jwtUtil.createJwtToken(username, role, 60 * 60 * 1000L, "access"); - // 쿠키에 JWT 토큰 추가 - response.addCookie(createCookie("Authorization", token)); + JwtToken jwtToken = jwtTokenService.generateToken(customOAuth2User.getId()); + + // 헤더에 액세스 토큰 추가 + response.setHeader("Authorization", jwtToken.getAccessToken()); + + // 쿠키에 리프레시 토큰 추가 + response.addCookie(createCookie(jwtToken.getRefreshToken())); // 성공 시 리다이렉트할 URL 설정 -// String redirectUrl = frontEnd + "/"; // 적절한 리다이렉트 URL로 수정 -// getRedirectStrategy().sendRedirect(request, response, redirectUrl); - super.onAuthenticationSuccess(request, response, authentication); + String redirectUrl = frontEnd + "/"; // TODO: 적절한 리다이렉트 URL로 수정 + getRedirectStrategy().sendRedirect(request, response, redirectUrl); } - private Cookie createCookie(String key, String value) { - Cookie cookie = new Cookie(key, value); - // TODO: 추후 변경 - cookie.setMaxAge(60 * 60 * 60); // 개발 단계에서는 유효기간 길게 - cookie.setPath("/"); // 쿠키 경로를 전체 경로로 설정 - cookie.setHttpOnly(true); // HttpOnly 설정, JavaScript 접근 불가 -// cookie.setSecure(true); // TODO: 배포시 HTTPS 환경에서 사용 + private Cookie createCookie(String value) { + Cookie cookie = new Cookie("refreshToken", value); + int refreshTokenExpiryInSeconds = (int) ((System.currentTimeMillis() + jwtTokenService.getRefreshTokenExpiration()) / 1000); // 리프레시 토큰 만료 시간과 일치 + cookie.setMaxAge(refreshTokenExpiryInSeconds); + cookie.setPath("/"); + cookie.setHttpOnly(true); + // cookie.setSecure(true); // 배포 시 HTTPS에서 사용 return cookie; } + } 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 cbe8676..4f9af3e 100644 --- a/backend/src/main/java/com/worlabel/global/resolver/CurrentUserArgumentResolver.java +++ b/backend/src/main/java/com/worlabel/global/resolver/CurrentUserArgumentResolver.java @@ -1,7 +1,6 @@ package com.worlabel.global.resolver; -import com.worlabel.domain.auth.dto.AuthMemberDto; -import com.worlabel.domain.auth.entity.CustomOAuth2Member; +import com.worlabel.domain.auth.entity.dto.AuthMemberDto; import com.worlabel.global.annotation.CurrentUser; import lombok.extern.slf4j.Slf4j; import org.springframework.core.MethodParameter; @@ -24,9 +23,8 @@ public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolve public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); if(principal instanceof AuthMemberDto){ - return principal; + return ((AuthMemberDto) principal).getId(); } - return null; } }