Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/13 스프링 시큐리티 및 JWT 적용 #21

Merged
merged 12 commits into from
Jan 9, 2024
16 changes: 14 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,26 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
// implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-security'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'io.rest-assured:rest-assured:5.3.2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.springframework.security:spring-security-test'

// jjwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2'

//oauth
// implementation("org.springframework.boot:spring-boot-starter-oauth2-client")

//redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

}

tasks.named('test') {
Expand Down
8 changes: 8 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
version : "3"
services :
redis:
hostname: yanabada
container_name: yanabada-redis
image: redis:latest
ports:
- "6379:6379"
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class AppConfig {
Expand All @@ -17,4 +19,9 @@ public ObjectMapper objectMapper() {
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return objectMapper;
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package kr.co.fastcampus.yanabada.common.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Slf4j
@Configuration
@EnableRedisRepositories
public class RedisConfig {

@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;

@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration redisStandaloneConfiguration
= new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName(host);
redisStandaloneConfiguration.setPort(port);
return new LettuceConnectionFactory(redisStandaloneConfiguration);
}

@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());

redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());

return redisTemplate;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package kr.co.fastcampus.yanabada.common.config;

import java.util.List;
import kr.co.fastcampus.yanabada.common.jwt.filter.JwtAuthFilter;
import kr.co.fastcampus.yanabada.common.jwt.filter.JwtExceptionFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

private final JwtAuthFilter jwtAuthFilter;
private final JwtExceptionFilter jwtExceptionFilter;

private static final String[] PERMIT_PATHS = {
"/",
"/**"
};

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

http.httpBasic(AbstractHttpConfigurer::disable);
http.cors(cors -> cors.configurationSource(corsConfigurationSource()));
http.csrf(AbstractHttpConfigurer::disable);
tjdtn0219 marked this conversation as resolved.
Show resolved Hide resolved
http.sessionManagement(
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);

http.authorizeHttpRequests(authorize -> authorize
.requestMatchers(PERMIT_PATHS).permitAll()
.anyRequest().authenticated()
);

//todo: oauth 설정 예정

http.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtExceptionFilter, JwtAuthFilter.class);

return http.build();
}

@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:5173", "https://api.weplanplans.site", "https://weplanplans.vercel.app", "https://dev-weplanplans.vercel.app", "http://localhost:8080")); // TODO: 5173 open
configuration.setAllowedMethods(List.of("*"));
configuration.setAllowedHeaders(List.of("*"));
configuration.addExposedHeader("Authorization");
configuration.setAllowCredentials(true); //todo : 쿠키를 포함한 크로스 도메인 요청을 허용? 확인필요
configuration.setMaxAge(3600L);

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}

@Bean
@ConditionalOnProperty(name = "spring.h2.console.enabled", havingValue = "true")
public WebSecurityCustomizer configureH2ConsoleEnable() { // h2-console 화면설정
return web -> web.ignoring()
.requestMatchers(PathRequest.toH2Console());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package kr.co.fastcampus.yanabada.common.exception;

import static kr.co.fastcampus.yanabada.common.response.ErrorCode.CLAIM_PARSE_FAILED;

public class ClaimParseFailedException extends BaseException {
public ClaimParseFailedException() {
super(CLAIM_PARSE_FAILED.getMessage());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package kr.co.fastcampus.yanabada.common.exception;

import static kr.co.fastcampus.yanabada.common.response.ErrorCode.TOKEN_EXPIRED;

public class TokenExpiredException extends BaseException {
public TokenExpiredException() {
super(TOKEN_EXPIRED.getMessage());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package kr.co.fastcampus.yanabada.common.exception;

import static kr.co.fastcampus.yanabada.common.response.ErrorCode.TOKEN_NOT_VALIDATED;

public class TokenNotValidatedException extends BaseException {
public TokenNotValidatedException() {
super(TOKEN_NOT_VALIDATED.getMessage());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package kr.co.fastcampus.yanabada.common.jwt.constant;

public class JwtConstant {
public static final String BEARER_TYPE = "Bearer";
public static final String BEARER_PREFIX = "Bearer ";
public static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30; //30min
public static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; //7days
public static final String AUTHORIZATION_HEADER = "Authorization";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package kr.co.fastcampus.yanabada.common.jwt.dto;

public record TokenIssueResponse(
String accessToken,
String refreshToken
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package kr.co.fastcampus.yanabada.common.jwt.filter;

import static kr.co.fastcampus.yanabada.common.jwt.constant.JwtConstant.AUTHORIZATION_HEADER;
import static kr.co.fastcampus.yanabada.common.jwt.constant.JwtConstant.BEARER_PREFIX;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import kr.co.fastcampus.yanabada.common.exception.MemberNotFoundException;
import kr.co.fastcampus.yanabada.common.exception.TokenExpiredException;
import kr.co.fastcampus.yanabada.common.exception.TokenNotValidatedException;
import kr.co.fastcampus.yanabada.common.jwt.util.JwtProvider;
import kr.co.fastcampus.yanabada.common.security.PrincipalDetails;
import kr.co.fastcampus.yanabada.domain.member.entity.Member;
import kr.co.fastcampus.yanabada.domain.member.entity.ProviderType;
import kr.co.fastcampus.yanabada.domain.member.repository.MemberRepository;
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.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

private final JwtProvider jwtProvider;
private final MemberRepository memberRepository;

@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
/* 토큰 로그인, 회원가입, 리프레시 토큰 재발급, 로그아웃일 경우 해당 필터 실행 안됨 */
return request.getRequestURI().contains("token/")
|| request.getRequestURI().contains("/sign-up")
|| request.getRequestURI().contains("/login");
}

@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {

String token = extractTokenFromRequest(request);

// 토큰 검사 생략(모두 허용 URL의 경우 토큰 검사 통과)
if (!StringUtils.hasText(token)) {
doFilter(request, response, filterChain);
return;
}

if (!jwtProvider.verifyToken(token)) {
throw new TokenExpiredException();
}

try {
String email = jwtProvider.getEmail(token);
ProviderType provider = ProviderType.valueOf(jwtProvider.getProvider(token));

Member findMember = memberRepository.getMember(email, provider);

PrincipalDetails principalDetails = PrincipalDetails.of(findMember);

// SecurityContext에 인증 객체를 등록
Authentication auth = getAuthentication(principalDetails);
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (MemberNotFoundException e) {
throw new TokenNotValidatedException();
}

filterChain.doFilter(request, response);
}

public Authentication getAuthentication(PrincipalDetails principal) {
return new UsernamePasswordAuthenticationToken(
principal, "", principal.getAuthorities()
);
}

private String extractTokenFromRequest(HttpServletRequest request) {
String token = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(token) && token.startsWith(BEARER_PREFIX)) {
return token.substring(7);
tjdtn0219 marked this conversation as resolved.
Show resolved Hide resolved
}
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package kr.co.fastcampus.yanabada.common.jwt.filter;

import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import kr.co.fastcampus.yanabada.common.exception.TokenExpiredException;
import kr.co.fastcampus.yanabada.common.response.ResponseBody;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtExceptionFilter extends OncePerRequestFilter {

private final ObjectMapper objectMapper;

@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
/* ControllerAdvice와 같은 ExHandler 역할 수행 */

String responseBody = "";
try {
filterChain.doFilter(request, response);
} catch (TokenExpiredException e) {
responseBody = objectMapper.writeValueAsString(ResponseBody.fail(e.getMessage()));
response.setStatus(401);
tjdtn0219 marked this conversation as resolved.
Show resolved Hide resolved
response.setContentType(APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(responseBody);
} catch (Exception e) {
responseBody = objectMapper.writeValueAsString(ResponseBody.fail(e.getMessage()));
response.setStatus(400);
response.setContentType(APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(responseBody);
tjdtn0219 marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
Loading
Loading