From 24593af680ca3e4850cc6fbf9ecebb6a56d8c2aa Mon Sep 17 00:00:00 2001 From: yeseul106 <20191037@sungshin.ac.kr> Date: Tue, 5 Dec 2023 17:04:36 +0900 Subject: [PATCH] =?UTF-8?q?[#89]=20add:=20Spring=20Security=20=EC=84=B8?= =?UTF-8?q?=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/build.gradle | 6 ++ .../main/common/config/SecurityConfig.java | 61 ++++++++++++ .../jwt/JwtAuthenticationEntryPoint.java | 33 +++++++ .../config/jwt/JwtAuthenticationFilter.java | 49 ++++++++++ .../common/config/jwt/JwtExceptionType.java | 10 ++ .../common/config/jwt/JwtTokenProvider.java | 98 +++++++++++++++++++ .../common/config/jwt/UserAuthentication.java | 16 +++ .../main/common/response/ErrorStatus.java | 25 +++-- .../crew/main/health/v1/HealthController.java | 6 +- 9 files changed, 292 insertions(+), 12 deletions(-) create mode 100644 main/src/main/java/org/sopt/makers/crew/main/common/config/SecurityConfig.java create mode 100644 main/src/main/java/org/sopt/makers/crew/main/common/config/jwt/JwtAuthenticationEntryPoint.java create mode 100644 main/src/main/java/org/sopt/makers/crew/main/common/config/jwt/JwtAuthenticationFilter.java create mode 100644 main/src/main/java/org/sopt/makers/crew/main/common/config/jwt/JwtExceptionType.java create mode 100644 main/src/main/java/org/sopt/makers/crew/main/common/config/jwt/JwtTokenProvider.java create mode 100644 main/src/main/java/org/sopt/makers/crew/main/common/config/jwt/UserAuthentication.java diff --git a/main/build.gradle b/main/build.gradle index 88e9e725..d4b31465 100644 --- a/main/build.gradle +++ b/main/build.gradle @@ -29,11 +29,17 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-webflux:3.1.5' implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' implementation group: 'org.postgresql', name: 'postgresql', version: '42.6.0' + + implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' + implementation group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' + implementation group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' + implementation 'com.auth0:java-jwt:4.4.0' } tasks.named('test') { diff --git a/main/src/main/java/org/sopt/makers/crew/main/common/config/SecurityConfig.java b/main/src/main/java/org/sopt/makers/crew/main/common/config/SecurityConfig.java new file mode 100644 index 00000000..83a6243e --- /dev/null +++ b/main/src/main/java/org/sopt/makers/crew/main/common/config/SecurityConfig.java @@ -0,0 +1,61 @@ +package org.sopt.makers.crew.main.common.config; + +import lombok.RequiredArgsConstructor; +import org.sopt.makers.crew.main.common.config.jwt.JwtAuthenticationEntryPoint; +import org.sopt.makers.crew.main.common.config.jwt.JwtAuthenticationFilter; +import org.sopt.makers.crew.main.common.config.jwt.JwtTokenProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@RequiredArgsConstructor +@EnableWebSecurity +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + + private static final String[] SWAGGER_URL = { + "/swagger-resources/**", + "/favicon.ico", + "/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/swagger-ui/index.html", + "/docs/swagger-ui/index.html", + "/swagger-ui/swagger-ui.css", + }; + + private static final String[] AUTH_WHITELIST = { + "/health" + }; + + @Bean + @Profile("dev") + SecurityFilterChain prodSecurityFilterChain(HttpSecurity http) throws Exception { + return http.csrf((csrfConfig) -> + csrfConfig.disable() + ) + .sessionManagement((sessionManagement) -> + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests( + authorize -> authorize.requestMatchers(AUTH_WHITELIST).permitAll() + .requestMatchers(SWAGGER_URL).permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, jwtAuthenticationEntryPoint), + UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(exceptionHandling -> + exceptionHandling.authenticationEntryPoint(jwtAuthenticationEntryPoint) + ) + .build(); + } + +} diff --git a/main/src/main/java/org/sopt/makers/crew/main/common/config/jwt/JwtAuthenticationEntryPoint.java b/main/src/main/java/org/sopt/makers/crew/main/common/config/jwt/JwtAuthenticationEntryPoint.java new file mode 100644 index 00000000..4fc968cc --- /dev/null +++ b/main/src/main/java/org/sopt/makers/crew/main/common/config/jwt/JwtAuthenticationEntryPoint.java @@ -0,0 +1,33 @@ +package org.sopt.makers.crew.main.common.config.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.sopt.makers.crew.main.common.response.CommonResponseDto; +import org.sopt.makers.crew.main.common.response.ErrorStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + setResponse(response, ErrorStatus.UNAUTHORIZED_TOKEN); + } + + + public void setResponse(HttpServletResponse response, ErrorStatus status) throws IOException { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + + CommonResponseDto apiResponse = CommonResponseDto.fail(status.getErrorCode()); + response.getWriter().println(mapper.writeValueAsString(apiResponse)); + } + +} diff --git a/main/src/main/java/org/sopt/makers/crew/main/common/config/jwt/JwtAuthenticationFilter.java b/main/src/main/java/org/sopt/makers/crew/main/common/config/jwt/JwtAuthenticationFilter.java new file mode 100644 index 00000000..bd28aee6 --- /dev/null +++ b/main/src/main/java/org/sopt/makers/crew/main/common/config/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,49 @@ +package org.sopt.makers.crew.main.common.config.jwt; + +import static org.sopt.makers.crew.main.common.config.jwt.JwtExceptionType.VALID_JWT_TOKEN; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.sopt.makers.crew.main.common.response.ErrorStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain chain) throws ServletException, IOException { + String accessToken = jwtTokenProvider.resolveToken(request); + + if (accessToken != null) { + // 토큰 검증 + if (jwtTokenProvider.validateToken(accessToken) + == VALID_JWT_TOKEN) { // 토큰이 존재하고 유효한 토큰일 때만 + Integer userId = jwtTokenProvider.getAccessTokenPayload(accessToken); + UserAuthentication authentication = new UserAuthentication(userId, null, + null); //사용자 객체 생성 + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails( + request)); // request 정보로 사용자 객체 디테일 설정 + SecurityContextHolder.getContext().setAuthentication(authentication); + } else { + jwtAuthenticationEntryPoint.commence(request, response, + new AuthenticationException(ErrorStatus.UNAUTHORIZED_TOKEN.getErrorCode()) { + }); + return; + } + } + chain.doFilter(request, response); + } +} diff --git a/main/src/main/java/org/sopt/makers/crew/main/common/config/jwt/JwtExceptionType.java b/main/src/main/java/org/sopt/makers/crew/main/common/config/jwt/JwtExceptionType.java new file mode 100644 index 00000000..043887ea --- /dev/null +++ b/main/src/main/java/org/sopt/makers/crew/main/common/config/jwt/JwtExceptionType.java @@ -0,0 +1,10 @@ +package org.sopt.makers.crew.main.common.config.jwt; + +public enum JwtExceptionType { + VALID_JWT_TOKEN, // 유효한 JWT + INVALID_JWT_SIGNATURE, // 유효하지 않은 서명 + INVALID_JWT_TOKEN, // 유효하지 않은 토큰 + EXPIRED_JWT_TOKEN, // 만료된 토큰 + UNSUPPORTED_JWT_TOKEN, // 지원하지 않는 형식의 토큰 + EMPTY_JWT // 빈 JWT +} diff --git a/main/src/main/java/org/sopt/makers/crew/main/common/config/jwt/JwtTokenProvider.java b/main/src/main/java/org/sopt/makers/crew/main/common/config/jwt/JwtTokenProvider.java new file mode 100644 index 00000000..76274161 --- /dev/null +++ b/main/src/main/java/org/sopt/makers/crew/main/common/config/jwt/JwtTokenProvider.java @@ -0,0 +1,98 @@ +package org.sopt.makers.crew.main.common.config.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Header; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.UnsupportedJwtException; +import jakarta.servlet.http.HttpServletRequest; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Date; +import javax.crypto.spec.SecretKeySpec; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sopt.makers.crew.main.entity.user.UserRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private final UserRepository userRepository; + + @Value("${JWT_SECRET}") + private String secretKey; + + @Value("${ACCESS_TOKEN_EXPIRED_TIME}") + private Long accessTokenExpireLength; + + private static final String AUTHORIZATION_HEADER = "Authorization"; + + public String generateAccessToken(Authentication authentication) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + accessTokenExpireLength); + + final Claims claims = Jwts.claims() + .setIssuedAt(now) + .setExpiration(expiration); + + claims.put("id", authentication.getPrincipal()); + + return Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setClaims(claims) + .signWith(getSignKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public Integer getAccessTokenPayload(String token) { + return Integer.parseInt( + Jwts.parserBuilder().setSigningKey(getSignKey()).build().parseClaimsJws(token) + .getBody().get("id").toString()); + } + + public String resolveToken(HttpServletRequest request) { + + String header = request.getHeader(AUTHORIZATION_HEADER); + + if (header == null || !header.startsWith("Bearer ")) { + return null; + } else { + return header.split(" ")[1]; + } + } + + public JwtExceptionType validateToken(String token) { + try { + Jwts.parserBuilder().setSigningKey(getSignKey()).build().parseClaimsJws(token) + .getBody(); + return JwtExceptionType.VALID_JWT_TOKEN; + } catch (io.jsonwebtoken.security.SignatureException exception) { + log.error("잘못된 JWT 서명을 가진 토큰입니다."); + return JwtExceptionType.INVALID_JWT_SIGNATURE; + } catch (MalformedJwtException exception) { + log.error("잘못된 JWT 토큰입니다."); + return JwtExceptionType.INVALID_JWT_TOKEN; + } catch (ExpiredJwtException exception) { + log.error("만료된 JWT 토큰입니다."); + return JwtExceptionType.EXPIRED_JWT_TOKEN; + } catch (UnsupportedJwtException exception) { + log.error("지원하지 않는 JWT 토큰입니다."); + return JwtExceptionType.UNSUPPORTED_JWT_TOKEN; + } catch (IllegalArgumentException exception) { + log.error("JWT Claims가 비어있습니다."); + return JwtExceptionType.EMPTY_JWT; + } + } + + private Key getSignKey() { + byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8); + return new SecretKeySpec(keyBytes, "HmacSHA256"); + } +} diff --git a/main/src/main/java/org/sopt/makers/crew/main/common/config/jwt/UserAuthentication.java b/main/src/main/java/org/sopt/makers/crew/main/common/config/jwt/UserAuthentication.java new file mode 100644 index 00000000..1c6cea33 --- /dev/null +++ b/main/src/main/java/org/sopt/makers/crew/main/common/config/jwt/UserAuthentication.java @@ -0,0 +1,16 @@ +package org.sopt.makers.crew.main.common.config.jwt; + + +import java.util.Collection; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +// UsernamePasswordAuthenticationToken: 사용자의 인증 정보 저장하고 전달 +public class UserAuthentication extends UsernamePasswordAuthenticationToken { + + // 사용자 인증 객체 생성 + public UserAuthentication(Object principal, Object credentials, + Collection authorities) { + super(principal, credentials, authorities); + } +} diff --git a/main/src/main/java/org/sopt/makers/crew/main/common/response/ErrorStatus.java b/main/src/main/java/org/sopt/makers/crew/main/common/response/ErrorStatus.java index eb9b0c80..be76a915 100644 --- a/main/src/main/java/org/sopt/makers/crew/main/common/response/ErrorStatus.java +++ b/main/src/main/java/org/sopt/makers/crew/main/common/response/ErrorStatus.java @@ -8,17 +8,22 @@ @RequiredArgsConstructor(access = AccessLevel.PROTECTED) public enum ErrorStatus { - /** - * 400 BAD_REQUEST - */ - VALIDATION_EXCEPTION("CR-001"), // errorCode는 예시, 추후 변경 예정 -> 잘못된 요청입니다. - VALIDATION_REQUEST_MISSING_EXCEPTION("요청값이 입력되지 않았습니다."), + /** + * 400 BAD_REQUEST + */ + VALIDATION_EXCEPTION("CR-001"), // errorCode는 예시, 추후 변경 예정 -> 잘못된 요청입니다. + VALIDATION_REQUEST_MISSING_EXCEPTION("요청값이 입력되지 않았습니다."), - /** - * 500 SERVER_ERROR - */ - INTERNAL_SERVER_ERROR("예상치 못한 서버 에러가 발생했습니다."); + /** + * 401 UNAUTHORIZED + */ + UNAUTHORIZED_TOKEN("유효하지 않은 토큰입니다."), - private final String errorCode; + /** + * 500 SERVER_ERROR + */ + INTERNAL_SERVER_ERROR("예상치 못한 서버 에러가 발생했습니다."); + + private final String errorCode; } \ No newline at end of file diff --git a/main/src/main/java/org/sopt/makers/crew/main/health/v1/HealthController.java b/main/src/main/java/org/sopt/makers/crew/main/health/v1/HealthController.java index da6d08bf..34816630 100644 --- a/main/src/main/java/org/sopt/makers/crew/main/health/v1/HealthController.java +++ b/main/src/main/java/org/sopt/makers/crew/main/health/v1/HealthController.java @@ -2,17 +2,19 @@ import lombok.RequiredArgsConstructor; import org.sopt.makers.crew.main.health.v1.dtos.service.get_health.response.HealthServiceGetHealthResponseDto; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequiredArgsConstructor +@RequestMapping("/health") public class HealthController { + private final HealthService healthService; - @GetMapping("/") + @GetMapping("") public ResponseEntity getHealth() { return this.healthService.getHealth(); }