Spring Security + Jwt 를 프로젝트에 적용 시키기
- 왜 적용 시켜야 하는가?
- 어떻게 적용 시키는가?
1. 왜 적용 시켜야 하는가?
Spring Security 는
Spring 기반의 애플리케이션의 보안(인증과 권한, 인가 등)을 담당하는 스프링 하위 프레임워크.
Spring Security는 '인증'과 '권한'에 대한 부분을 Filter의 흐름에 따라 처리한다.
Filter이기 때문에 Dispatcher Servlet으로 가기 전에 적용 된다.
- 인증 Authentication: 해당 사용자가 본인이 맞는지를 확인하는 절차 ( 나야 나 )
- 인가 Authorization: 인증된 사용자가 요청한 자원에 접근 가능한지를 결정하는 절차 ( 넌 계급이 뭐니 )
더보기
스프링 시큐리티 가이드에서는 8가지 이유에 대해 스프링 시큐리티의 특징을 적어놓았다.
https://spring.io/projects/spring-security
- 인증과 인가 모두에 대한 포괄적이고 확장 가능한 지원
- 세션 고정, 클릭잭킹, 사이트 간 요청 위조 등의 공격으로부터 보호
- Servlet API 통합
- Spring Web MVC와의 통합(옵션)
- 그 밖에도...
- 즉슨.. 정말 보안이 좋고 간단히 사용할 수 있으니 하는게 좋다! ,, 라는 의미로 느껴짐
2. 어떻게 적용 시키는가?
- Spring Security 아키텍쳐
- 처음 이거보고 머리아파서 솔직히 포기하고 싶었지만 대충 전체적인 흐름을 이해해보자면
- 사용자 접근 > 거름망(필터체인) > 인증, 인가 여부 결정
프로젝트에 구현 해보자
사용환경
IDE : InteliJ
Language : Java
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.7'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}
group = 'teamproject'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.projectlombok:lombok:1.18.22'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// Security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.security:spring-security-test'
// JWT
implementation 'io.jsonwebtoken:jjwt:0.9.1'
}
tasks.named('test') {
useJUnitPlatform()
}
주석으로 처리된 Security 와 Jwt 관련해서 참고하면 됨
- 위 3개 클래스 관련해서 주의 깊게 보자
WebSecurityConfig.java
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtProvider jwtProvider;
public static final String[] GET_AUTHENTICATED_REGEX_LIST = {
"^/api/v1/test1$"
};
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable();
http.cors();
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
//jwt 사용 간 설정
http.authorizeHttpRequests()
.regexMatchers(HttpMethod.GET, GET_AUTHENTICATED_REGEX_LIST).permitAll()
http.exceptionHandling()
.authenticationEntryPoint(new CustomAuthenticationEntryPointHandler())
.accessDeniedHandler(new CustomAccessDeniedHandler());
http.addFilterBefore(new JwtTokenFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
http.addFilterBefore(new ExceptionHandlerFilter(), JwtTokenFilter.class);
return http.build();
}
}
1. csrf().disable
악용할 수 있는 가능성을 차단하는걸 푸는 기능이지만, rest API 와 토큰을 사용한다면 문제 없어서 품
참고자료
https://velog.io/@woohobi/Spring-security-csrf%EB%9E%80
2. cors();
다른 도메인에서 요청이 가능한가 에 대한 설정이다 .다양한 설정에 관련해서는 속성값을 설정 해줄 수 있음.
https://toycoms.tistory.com/37
참고자료
3. authorizeHttpRequest 와 authorizeRequest
authorizeHttpRequests(AuthorizationFilter) 가 authorizeRequests(FilterSecurityInterceptor) 를
대체한다 정도로만 정리하겠습니다.
* regex 외에 authorizeRequest 의 autMatcher 를 사용해도 무방함.
4. exceptionHandling()
예외처리 기능이 작동함 - custom 으로 예외를 만들어 처리하게끔 구현함
.authenticationEntryPoint(authenticationEntryPoint()) - 인증실패시 처리
.accessDeniedHandler(accessDeniedHandler()) - 인가실패시 처리
5. addfilterBefore()
지정된 필터 앞에 커스텀 필터를 추가 (UsernamePasswordAuthenticationFilter 보다 먼저 실행된다)
왜 사용해야될지에 대해서는 해결점이 풀리진 않음 - 알려주세요...!
에 대한 내용
스프링시큐리티의 기본 인증 처리 담당 필터인 UsernamePasswordAuthenticationFilter 앞에 커스텀 필터를 추가합니다.
UsernamePasswordAuthenticationFilter필터는 Override되지 않고 CustomAuthenticationProcessingFilter 인증 처리가 되면 자연스럽게 로직을 통과하는 구조입니다.
인증로직이 포함된 인증매니저 인증성공/실패 처리 핸들러 등등 기타 추가 설정 이나 기능을 대체 하는 의존성을 주입 할 수 있습니다.
JwtProvider.java
@Component
public class JwtProvider {
private final String SECRET = "asdfknsadfksadnvckasdncksnadkcnklvnzldkknvklxnzsdnnbvklzdfkzfkdnfvk";
private final long EXPIRATION = 6000 * 10;
private final String USERNAME_KEY = "username";
private final String ID_KEY = "id";
private final String ROLE_KEY = "role";
public JwtProvider() {
//secret 과 만기일 재설정
}
public String generateToken(User user) {
Claims claims = Jwts.claims();
claims.put(ID_KEY, user.getId());
claims.put(USERNAME_KEY, user.getUsername());
claims.put(ROLE_KEY, user.getRole().name());
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
.signWith(SignatureAlgorithm.HS256, SECRET)
.compact();
}
public boolean validateToken(String token) {
try {
Jwts.parser()
.setSigningKey(SECRET)
.parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.SignatureException | MalformedJwtException exception) { // 잘못된 jwt signature
} catch (io.jsonwebtoken.ExpiredJwtException exception) { // jwt 만료
} catch (io.jsonwebtoken.UnsupportedJwtException exception) { // 지원하지 않는 jwt
} catch (IllegalArgumentException exception) { // 잘못된 jwt 토큰
}
return false;
}
public Authentication getAuthentication(String token) {
Claims claims = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
Long id = Long.parseLong(claims.get(ID_KEY).toString());
String username = claims.get(USERNAME_KEY).toString();
String roleName = claims.get(ROLE_KEY).toString();
User user = User.builder()
.id(id)
.userName(username)
.role(UserRole.ROLE_USER)
.build();
return new UsernamePasswordAuthenticationToken(user, token, user.getAuthorities());
}
}
1. generateToken : 토큰 생산 - 로그인을 하면 메소드 호출
2. validateToken : 토큰이 유효한가 체크
3. getAuthentication : 암호화된 토큰을 꺼내서 복호화 후 정보 저장 용
JwtTokenFilter.java
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
/**
* request 에서 전달받은 Jwt 토큰을 확인
*/
private final String BEARER = "Bearer ";
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader(HttpHeaders.AUTHORIZATION);
request.setAttribute("existsToken", true); // 토큰 존재 여부 초기화
if (isEmptyToken(token)) request.setAttribute("existsToken", false); // 토큰이 없는 경우 false로 변경
if (token == null || !token.startsWith(BEARER)) {
filterChain.doFilter(request, response);
return;
}
token = parseBearer(token);
if (jwtProvider.validateToken(token)) {
Authentication authentication = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private boolean isEmptyToken(String token) {
return token == null || "".equals(token);
}
private String parseBearer(String token) {
return token.substring(BEARER.length());
}
/**
* Security Chain 에서 발생하는 에러 응답 구성
*/
public static void MakeError(HttpServletResponse response , ErrorCode errorCode) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(errorCode.getHttpStatus().value());
ObjectMapper objectMapper = new ObjectMapper();
ErrorResponse errorResponse = new ErrorResponse
(errorCode, errorCode.getMessage());
Response<ErrorResponse> resultResponse = Response.error(errorResponse);
// 한글 출력을 위해 getWriter()
response.getWriter().write(objectMapper.writeValueAsString(resultResponse));
}
}
필터 동작과정 관련해서 다음과 같은 자료를 참고해서 이해하자!
https://javacan.tistory.com/entry/58
doFilter 는.. FilterChain인터페이스 메소드인데 구현하지 않고도 어떻게 doFilter() 메소드가 동작하는 건지 궁금하다.
Custom Error 코드
Custom Error
CustomAuthenticationEntryPointHandler.java
@Slf4j
@Component
public class CustomAuthenticationEntryPointHandler implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
// 1. 토큰 없음 2. 시그니처 불일치
if (authorization == null || !authorization.startsWith("Bearer ")) {
log.error("토큰이 존재하지 않거나 Bearer로 시작하지 않는 경우");
ErrorCode errorCode = ErrorCode.INVALID_TOKEN;
JwtTokenFilter.MakeError(response, errorCode);
// 3. 토큰 만료
} else if (authorization.equals(ErrorCode.EXPIRED_TOKEN)) {
log.error("토큰이 만료된 경우");
ErrorCode errorCode = ErrorCode.EXPIRED_TOKEN;
JwtTokenFilter.MakeError(response,errorCode);
}
}
}
CustomAccessDeniedHandler.java
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
// 4. 토큰 인증 후 권한 거부
ErrorCode errorCode = ErrorCode.FORBIDDEN_REQUEST;
JwtTokenFilter.MakeError(response, errorCode);
}
}
ExceptionHandlerFilter.java
@Slf4j
public class ExceptionHandlerFilter extends OncePerRequestFilter {
/**
* 토큰 관련 에러 핸들링
* JwtTokenFilter 에서 발생하는 에러를 핸들링
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (ExpiredJwtException e) {
//토큰의 유효기간 만료
log.error("만료된 토큰입니다");
MakeError(response, ErrorCode.EXPIRED_TOKEN);
} catch (JwtException | IllegalArgumentException e) {
//유효하지 않은 토큰
log.error("유효하지 않은 토큰이 입력되었습니다.");
MakeError(response, ErrorCode.INVALID_TOKEN);
} catch (NoSuchElementException e) {
//사용자 찾을 수 없음
log.error("사용자를 찾을 수 없습니다.");
MakeError(response, ErrorCode.USERID_NOT_FOUND);
} catch (ArrayIndexOutOfBoundsException e) {
log.error("토큰을 추출할 수 없습니다.");
MakeError(response, ErrorCode.INVALID_TOKEN);
} catch (NullPointerException e) {
filterChain.doFilter(request, response);
}
}
}
발견된 이슈.. 팀프로젝트 간 OncePerRequestFilter 가 두번 extends 되고 있었다..
ExceptionHandlerFilter / JwtTokenFilter 이 부분을 좀더 챙겨보고, + doFilter 메소드의 흐름을 이해하면 좀더 좋을거같다는 생각이 듬
팀원 도움 덕에 dofilter의 역할을 이해했다.
WebConfig에서 addFilterBefore 에 매개변수로 넣은 의미는
ExceptionHandlerFilter > JwtTokenFilter > UsernamePasswordAuthenticationFilter => Dispatcher Servlet
각 필터에서 각 로직에 맞게 dofilter로 다음 단계의 필터를 거쳐서 흐름이 생겨 넘겨지는 것으로 이해했다.
결론
참고자료
https://kimchanjung.github.io/programming/2020/07/02/spring-security-02/
'2023년 > 멋쟁이사자처럼 팀프로젝트' 카테고리의 다른 글
[팀프로젝트] 비동기 방식 통신 + 스프링부트 RestController 활용한 웹 페이지 (0) | 2023.01.26 |
---|---|
[팀프로젝트] 깃허브 워크플로우 (2) | 2023.01.20 |
[팀프로젝트] Springboot 사용하면서 UI 화면은 어떻게 처리하면 좋을까? (1) | 2023.01.19 |
[팀프로젝트] SpringBoot + webSocket 으로 간단한 채팅창 만들기 (0) | 2023.01.19 |
[팀프로젝트] SpringBoot 좋아요 기능 구현 ( UI 는 없음 ) (2) | 2023.01.18 |