VERSION
springboot : '3.2.3'
java : '17'
1. build.gradle에 Dependecy 추가
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.3'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
configurations{
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
// JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// MySQL
runtimeOnly 'com.mysql:mysql-connector-j'
// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
tasks.named('test') {
useJUnitPlatform()
}
jwt, redis 의존성 추가
2. yml 파일 세팅
기존 Spring 프로젝트 만들듯이
+ Redis Host, port 설정
server:
port: 8080
servlet:
context-path: /
encoding:
charset: UTF-8
enabled: true
force: true
spring:
datasource:
url: jdbc:mysql://localhost:3306/security?serverTimezone=Asia/Seoul
username: "root"
password:
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update #create update none
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
show-sql: true
jwt:
# HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용
secret: testSecretKey20240316testSecretKey20240316testSecretKey20240316
token:
access-expiration-time: 3600000
refresh-expiration-time: 86400000
data:
redis:
host: localhost
port: 6379
client와 통신할 걸 가정 (Stateless 한 환경에서 사용하는 Token!!)
-> mustache 사용 x, @Controller -> @RestController
-> Postman으로 확인
3. User 엔티티 등 설정
해당 프로젝트에서 이어서 해서,
해당 프로젝트 초반에 Entity, Security Config를 그대로 쓰면 될 것 같다.
(이후 코드 작성 중 Config 는 수정될 수 있다.)
- 코드
User
@Data
@Entity
public class User {
@Id // primary key
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String username;
private String password;
private String email;
private String role; //ROLE_USER, ROLE_ADMIN
@CreationTimestamp
private Timestamp createDate;
public List<String> getRoleList() {
if(!this.role.isEmpty()) {
return Arrays.asList(this.role.split(","));
}
return new ArrayList<>();
}
}
SecurityConfig
@Configuration // IoC 빈(bean)을 등록
@EnableWebSecurity // 필터 체인 관리 시작 어노테이션
public class SecurityConfig {
@Bean
public BCryptPasswordEncoder encodePwd() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// cors 비활성화
http
.cors(cors -> cors
.configurationSource(CorsConfig.apiConfigurationSource()));
// csrf disable
http
.csrf(AbstractHttpConfigurer::disable);
// form 로그인 방식 disable
http
.formLogin(AbstractHttpConfigurer::disable);
// http basic 인증 방식 disable
http
.httpBasic(AbstractHttpConfigurer::disable);
// Session을 사용하지 않고, Stateless 서버를 만듬.
http
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// 경로별 인가
http.
authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers("/user/**","/reissue").authenticated()
.requestMatchers("/manager/**").hasAnyRole("ADMIN", "MANAGE")
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().permitAll()
);
return http.build();
}
}
CorsConfig
[개념] : 해당 Cors 부분 참고
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Bean
public static CorsConfigurationSource apiConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 확장성을 위해 다 ArrayList로 처리
ArrayList<String> allowedOriginPatterns = new ArrayList<>();
allowedOriginPatterns.add("*");
ArrayList<String> allowedHttpMethods = new ArrayList<>();
allowedHttpMethods.add("*");
configuration.setAllowCredentials(true); // 내 서버가 응답을 할 때 응답해준 json을 자바스크립트에서 처리할 수 있게 할지를 설정
configuration.setAllowedOrigins(allowedOriginPatterns); // 응답 허용할 ip
configuration.addAllowedHeader("*"); // 응답 허용할 header
configuration.setAllowedMethods(allowedHttpMethods); // 응답 허용할 HTTP Method
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration); // /api/** 로 들어오는 모든 요청들은 config를 따르도록 등록!
return source;
}
}
4. Filter 테스트 (Test용 추가 x)
4-1. Filter란?
사실 필터는 스프링부트에서 제공하는 기능이 아닌,
자바.서블릿 에서 자체적으로 제공하는 기능이다.
다만, 인증 등에서 스프링 부트에서 빼놓을 수 없는 개념이기 때문에 배워본다.
위 그림은 스프링 프레임워크에서 요청에 대한 라이프 사이클을 나타내는 그림이다.
간단히 도식화를 해보면, 다음과 같은 그림으로 이루어진다.
import javax.servlet.*; // 자바 서블릿에서 제공
public class MyFilter1 implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
System.out.println("Filter 1");
filterChain.doFilter(servletRequest, servletResponse); // 다음 필터로 넘어가라
}
}
4-2. Filter 등록하기
addFilter
http.addFilter(new MyFilter1());
우리가 만들어놨던, SecurityFilterChain에 작성해보자.
에러 메세지:
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'springSecurityFilterChain' defined in class path resource [org/springframework/security/config/annotation/web/configuration/WebSecurityConfiguration.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [javax.servlet.Filter]: Factory method 'springSecurityFilterChain' threw exception; nested exception is java.lang.IllegalArgumentException: The Filter class me.iseunghan.jwttutorial.filter.MyFilter1 does not have a registered order and cannot be added without a specified order. Consider using addFilterBefore or addFilterAfter instead.
MyFilter1은 SpringSecurityFilterChain에 등록되지 않았다는 에러를 띄워준다.
스프링 시큐리티 필터 체인에 등록되는 필터의 순서(Order)가 정의되지 않아 발생한 것이다.
등록하고 싶으면 addFilterBefore or addFilterAfter를 사용해라!
Ordering 해줘야 한다는 뜻!
(Ordering 후에, addFilter로 추가한다면 -> 필터의 순서는 기존 필터의 뒤에 추가된다.)
addFilterBefore
http.addFilterBefore(new MyFilter1(), UsernamePasswordAuthenticationFilter.class);
UsernamePasswordAuthenticationFilter 직전에(Before) MyFilter가 걸리도록 한다.
addFilterAfter
http.addFilterAfter(new MyFilter1(), UsernamePasswordAuthenticationFilter.class);
UsernamePasswordAuthenticationFilter 이후에(After) MyFilter가 걸리도록 한다.
* 명시적인 방법으로 Filter 걸기!
addFilterAt
.addFilterAt(MyFilter(), UsernamePasswordAuthenticationFilter.class);
이 메서드는 특정 필터를 특정 위치에 등록한다.
MyFIilter()를 UsernamePasswordAuthenticationFilter 앞에 추가한다는 의미.
즉, 기존의 UsernamePasswordAuthenticationFilter 앞에 새로운 필터를 추가한다는 뜻이다.
4-3. 독자적인 Filter를 만들고, FilterChain 걸어주기
기존의 SecurityFilterChain에 거는 것이 아니라,
따로 FilterConfig를 생성하여 등록해보자. (TEST)
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<MyFilter1> filter1() {
FilterRegistrationBean<MyFilter1> bean = new FilterRegistrationBean<>(new MyFilter1());
bean.addUrlPatterns("/*"); // 모든 요청에 대해서 필터 적용
bean.setOrder(0); // 우선 선위 설정
// 0부터 시작하여, 낮은 숫자부터 우선 순위 높음
return bean;
}
}
- FilterRegistrationBean을 생성하여 MyFilter1을 빈으로 등록시켜주면 끝!
4-4. Filter의 실행 순서
방금 Custom Filter를 만들고 우선수위를 0으로 세팅해줬다.
그렇다면 Spring Security 보다 먼저 실행될까, 이후에 실행될까?
-> Custom 필터보다 SpringSecurityFilter가 가장 먼저 실행된다!
*만일, SpringSecurityFilter보다 Custom Filter를 강제로 먼저 실행시키게 하려면,
addFilterBefore()
addFilterAfter()
를 사용하여, 명시적으로 필터의 Ordering을 해주면 된다.
5. JWT 임시 토큰 만들어서 테스트해보기
public class MyFilter1 implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) servletRequest;
HttpServletResponse res = (HttpServletResponse) servletResponse;
res.setCharacterEncoding("utf-8");
// 만약, token을 검증하여, Controller에 접근 여부 설정!
if (req.getMethod().equals("POST")) { // request가 POST 메소드라면
String auth_header = req.getHeader("Authorization"); // 헤더에서 Authorization 값을 가져온다.
if(auth_header.equals("secret")) {
filterChain.doFilter(req, res); // 만약에 토큰이 secret 이라면, 필터 이어가게
} else {
PrintWriter writer = res.getWriter();
writer.println("인증 안됨"); // filter 끊기고, Controller의 진입 조차 못하게 막는다.
}
} else {
filterChain.doFilter(servletRequest, servletResponse);
}
}
}
- POST로 요청이 왔다면
- 헤더에 Authorization 값을 가져온다.
- 해당 값이 secret이 맞는지 확인한다. (Test 용으로 "secret"을 임시로 키 값으로 설정)
- 맞다면, 계속 필터를 이어가게 하고
- 틀리다면, filter가 끊기고, Controller의 진입을 금지 / "인증 안됨" 이라는 메세지를 응답
- 해당 필터를 Security FilterChain에 등록해준다.
// securityFilter가 실행되기 전에!
http.addFilterBefore(new MyFilter1(), SecurityContextPersistenceFilter.class);