프로젝트 환경
- Springboot 2.7.5
- Maven
- jwt 4.2.1
- Spring Data JPA
- MySQL
- SpringSecurity
프로젝트 예제
인프런강의 - 스프링부트 시큐리티 & JWT(최주호강사님)
예제의 대부분 코드는 강의내에 있는 코드이고 그것을 기반으로 조금씩 수정한 코드입니다.
강의에서는 ResponseHeader에 담아 응답하고 RequestHeader에 담아 요청하는 코드이지만 페이지 이동 시 RequestHeader에 토큰을 담는 방법을 해결하지 못해 Cookie에 담아 응답하고 요청하는 코드로 수정했습니다.
JWT Token을 생성하는것은 라이브러리를 사용하고 SpringSecurity와 같이 JWT Token을 생성하고 처리하는 예제입니다.
필요한 포인트가 있는게 아니라 jwt를 공부하시는 것이라면 강의를 보시는것을 추천드립니다.
예제
1. Maven Dependency
- SpringDataJPA
- thymeleaf
- Springboot DevTools
- Lombok
- Spring Web
- SpringSecurity
- MySQL Driver
- jwt 4.2.1
2. 코드
// CorsConfig
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter(){
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);// 서버가 응답할 때 json을 자바스크립트에서 처리할 수 있도록 설정
config.addAllowedOrigin("*"); // 모든 ip에 응답을 허용
config.addAllowedHeader("*"); // 모든 header에 응답을 허용
config.addAllowedMethod("*"); // 모든 post, get, put, delete, patch 요청을 허용
// /api/**로 들어오는 모든 요청은 이 설정을 따라야 한다.
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}
CORS는 Cross-Origin Resource Sharing의 약자로 특정 헤더를 통해 브라우저에게 Origin에서 실행되고 있는 웹 애플리케이션이 Cross-Origin에 리소스에 접근할 수 있는 권한이 있는지 없는지에 대한 것을 확인하는 필터다.
설정한 코드를 보면 setAllowedCredentials로 서버가 응답할 때 json을 자바스크립트에서 처리할 수 있도록 설정을 해주고
모든 ip, header, 요청 타입에 대한 것을 허용해서 요청이 들어오게 되면 corsFilter를 타도록 설정을 해주는 것이다.
그리고 registerCorsConfiguration으로 path, config를 담아 등록을 해줘야 하는데 /api/** 라는 주소로 들어오는 모든 요청은 이 설정을 따르도록 해준 뒤 이 설정을 리턴해주면 된다.
이 필터는 securityConfig에 등록해 처리할 수 있도록 한다.
필터 추가는 좀 더 하단에 securityConfig 코드에서.
// JwtProperties
public interface JwtProperties {
String SECRET = "cos"; // 토큰 생성 시 사용할 고유값. sign에 사용
int EXPIRATION_TIME = 60000*10; // 토큰 유효시간
String TOKEN_PREFIX = "Bearer"; // 토큰값에 앞에 들어갈 인증 방법 값
String HEADER_STRING = "Authorization"; // 헤더명
}
JwtProperties는 토큰 생성시에 작성해야 하는 값들을 정리해놓은 인터페이스일 뿐이다.
이렇게 사용하면 추후 수정이 필요할 때 좀 더 편하게 수정하고 여러 부분에서 사용하기 때문에 오타로 인한 실수를 방지할 수 있다.
// User(Entity)
import lombok.Data;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@Entity
@Data
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
private String username;
private String password;
// 권한은 ROLE_USER, ROLE_MANAGER 이런 형태로 저장할것이기 때문에
// Stirng 타입으로 생성
private String roles;
public List<String> getRoleList() {
if(this.roles.length() > 0)
return Arrays.asList(this.roles.split(","));
return new ArrayList<>();
}
}
//UserRepository
import com.example.jwt_testproject.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
public User findByUsername(String username);
}
// PrincipalDetails(UserDetails)
import com.example.jwt_testproject.model.User;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.ArrayList;
import java.util.Collection;
@Data
public class PrincipalDetails implements UserDetails {
private User user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
//User의 role을 String 타입으로 만들기 때문에 Collection 타입으로 변환해야 한다.
Collection<GrantedAuthority> authorities = new ArrayList<>();
user.getRoleList().forEach(r -> {
authorities.add(() -> r);
});
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpried() {
// 계정 만료 여부에 대한 처리 메소드다
// 메소드명 그대로 계정이 만료되지 않았는가에 대한 리턴이기 때문에
// true는 만료되지 않았다는 의미이고 false는 만료되었다는 의미다.
return true;
}
@Override
public boolean isAccountNonLocked() {
// 계정이 잠겨있는지에 대한 여부를 리턴.
// true면 계정이 잠겨있지 않다는 것이고 false면 잠겨있다는 의미다.
return true;
}
@Override
public boolean isCredentialsNonExpired() {
// 비밀번호 유지 기간에 대한 처리 메소드
// 비밀번호를 지정한 기간 이상으로 사용했다면 false를 리턴하고
// 지정 기간을 넘기지 않았다면 true를 리턴한다
return true;
}
@Override
public boolean isEnabled() {
// 계정활성화에 대한 메소드
// 휴면계정이라면 false
// 휴면계정이 아니라면 true를 리턴
return true;
}
}
UserDetails를 만들어야 하는 이유는 SecurityContextHolder에 사용자 정보를 담아줄 때 Authentication객체로 담아줘야 하는데 이 Authentication 객체에 담아주는 User 오브젝트의 타입이 UserDetails 타입이어야 하기 때문이다.
// PrincipalDetailsService(UserDetailsService)
import com.example.jwt_testproject.model.User;
import com.example.jwt_testproject.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
System.out.println("loadUserByUsername");
User userEntity = userRepository.findByUsername(username);
return new PrincipalDetails(userEntity);
}
}
PrincipalDetailsService에서는 로그인 요청을 처리한다.
// JwtAuthenticatoinFilter
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.example.jwt_testproject.config.auth.PrincipalDetails;
import com.example.jwt_testproject.model.User;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Date;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
@Override
public Authentication attemptAuthentication(HttpServletRequest request
, HttpServletResponse response)
throws AuthenticationException {
System.out.println("JwtAuthenticationFilter's attemptAuthentication");
try {
//1. username과 password를 받아서 parsing
ObjectMapper om = new ObjectMapper();
User user = om.readValue(request.getInputStream(), User.class);
System.out.println("attemptAuthentication user : " + user);
//2. 로그인 시도를 위한 UsernamePasswordAuthenticationToken 생성
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
//3. authenticationManager.authenticate()를 통해 로그인 시도
//로그인 시도로 인해 PrincipalDetailsService가 호출되어 loadUserByUsername이 실행
Authentication authentication =
authenticationManager.authenticate(authenticationToken);
//로그인이 정상적으로 처리되었다면 getUsername이 정상적으로 출력
PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
System.out.println("login Success : " + principalDetails.getUsername());
return authenticaiton;
}catch (IOException e) {
e.printStackTrace();
}
return null;
}
// attemptAuthentication 처리 후 호출되는 메소드
// 여기서 토큰을 생성
@Override
protected void successfulAuthentication(HttpServletReqeust
, HttpServletResponse response
, FilterChain chain
, Authentication authResult)
throws IOExceptoin, ServletException {
System.out.println("JwtAuthenticationFilter's successfulAuthentication");
// 토큰 생성을 위한 정보 get
PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();
// 가장 많이 사용하는 알고리즘인 HMAC512로 암호화해 토큰 생성
// 토큰 생성은 jwt 라이브러리로 생성
String jwtToken = JWT.create()
.withSubject("cocoToken") //토큰이름. 크게 의미없다.
.withExpriresAt(new Date(System.currentTimeMillis() + JwtProperties.EXPIRATION_TIME)) // 토큰 만료시간
.withClaim("id", principalDetails.getUser().getId())
.withClaim("username", principalDetails.getUser().getUsername()) //토큰에 저장하는 데이터. 여기서는 id와 username만 저장한다.
.sign(Algorithm.HMAC512(JwtProperties.SECRET)); //토큰 서명. 알고리즘과 secret을 담아주면 된다.
System.out.println("AuthenticationFilter token : " + jwtToken);
//응답 헤더(Response Header)에 토큰 담기
//(헤더명, 헤더값(토큰값))
response.addHeader(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX + jwtToken);
//토큰을 localStorage에 담지 않고 쿠키에 담는 경우
Cookie cookie = new Cookie(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX + jwtToken);
cookie.setPath("/");
cookie.setMaxAge(60 * 60);// 토큰 유효시간과 동일하게 설정해 토큰이 만료될때 쿠키도 삭제되도록
cookie.isHttpOnly();// HttpOnly로 설정해 클라이언트에서 쿠키값을 알 수 없도록 설정(보안설정)
response.addCookie(cookie);
}
}
SpringSecurity를 사용하면서 username과 password를 post로 /login에 요청을 보내게 되면 UsernamePasswordAuthenticationFilter가 동작하게 된다.
따로 설정하지 않는다면 알아서 동작하는 필터인데 예제에서는 formLogin을 막아둘것이기 때문에 로그인 요청이 오더라도 이 필터를 타지 않는다.
그래서 JwtAuthenticationFilter에서 이 필터를 상속받아 구현해두고 securityConfig에서 필터를 등록해 로그인 요청시에 처리할 수 있도록 구현해야 한다.
이 필터를 securityConfig에 등록할때는 AuthenticationManager를 꼭 같이 넘겨줘야 하기 때문에 JwtAuthenticationFilter에서는 생성자를 만들어 AuthenticationManager를 받을 수 있도록 처리하거나 위와 같이 @RequiredArgsConstructor 어노테이션을 걸어주고 final 로 받아주면 된다.
필터를 securityConfig에 등록하게 되면 로그인 요청이 왔을 때 JwtAuthenticationFilter의 attemptAuthentication이 동작하게 되며 처리과정은 아래와 같다.
- username과 password를 받아 User Object에 담아준다
- 로그인 시도를 하기 위해 UsernamePasswordAuthenticationToken을 생성한다.
- AuthenticatonManger를 통해 로그인을 시도한다. 이때 UserDetailsService를 구현한 PrincipalDetailsService가 호출되어 loadUserByUsername을 실행한다.
- 로그인이 정상적으로 처리되면 authentication 객체에 로그인의 정보가 담긴다. 즉, 사용자 인증이 완료된다.
- return authentication으로 세션에 authentication 객체를 저장한다.
ObjectMapper는 json을 파싱해주는데 readValue를 통해 request의 데이터를 User 오브젝트에 담아준다.
그 다음 UsernamePasswordAuthenticationToken을 생성하는데 로그인 시도를 위해 만드는 토큰이다.
이 토큰은 formLogin을 사용하게 되면 알아서 생성되어 처리해주는 토큰인데 예제에서는 formLogin을 사용하지 않도록 막아두었기 때문에 직접 생성해줘야 한다.
이 토큰을 생성할때 넣어줘야 하는 값으로는 principal, credentials, roles가 존재한다.
principal은 username을 넣어주면 되고 credentails는 password, roles는 권한을 넣어주면 되는데 principal과 password와는 다르게 Collection 타입으로 받는다.
여기서는 로그인에 권한까지는 필요하지 않기 때문에 roles는 굳이 넘겨줄 필요가 없이 username과 password만 넘겨주면 된다.
다음으로 authenticationManger를 통해 UserDetailsService를 구현한 PrincipalDetailsService를 호출하게 되고 그 안에 있는 loadUserByUsername을 실행해 처리한다.
정상적으로 처리가되어 리턴된 정보는 Authentication 객체에 담기게 되는데 로그인의 정보가 그대로 담기는 것이고 사용자 인증이 되었다는 의미이다.
마지막으로 return authentication으로 세션에 authentication 객체를 저장해 세션에 로그인 정보를 담아준다.
jwt 토큰에 대해 찾아보다보면 세션을 사용하지 않는 stateless로 처리한다는 것을 볼 수 있는데 굳이 세션에 담는 이유는 권한처리를 SpringSecurity가 하도록 하기 위해서다.
SpringSecurity는 세션에 값이 있어야 권한관리를 할 수 있기 때문에 처리된 데이터를 세션에 담아줘야 한다.
그렇기 때문에 권한관리가 필요없는 프로젝트라면 굳이 세션에 정보를 담아줄 필요는 없다.
attemptAuthentication에서는 username과 password를 받는데 위 코드는 ObjectMapper를 통해 json을 받아 파싱한 케이스이고 좀 원시적인 방법으로 BufferedReader를 통해 받는 방법도 있다.
// username과 password를 BufferedReader로 받는 방법
@Override
public Authentication attemptAuthentication(HttpServletRequest request
, HttpServletResponse response)
throws AuthenticationException {
System.out.println("JwtAuthenticationFilter's attemptAuthentication");
try {
BufferedReader br = request.getReader();
String input = null
while((input = br.readLine()) != null) {
System.out.println(input);
}
....
}catch (IOException e) {
e.printStackTrace();
}
return null;
}
프론트단에서 데이터를 보내는 방식에 따라 달라질 수 있겠지만 보통 웹에서는 x-www-form-urlencoded 방식으로 보내기 때문에 이렇게 받아 처리가 가능하고 이걸 파싱해 처리해주면 된다.
이렇게 받게 되면 "username=coco&password=1234" 이런 형태로 받게 된다.
attemptAuthentication 메소드 수행이 모두 끝나면 그 다음 호출되는 메소드는 successfulAuthentication이다.
jwt 토큰은 여기서 생성한다.
attemptAuthentication에서 생성해도 되지만 끝나고 successfulAuthentication이 호출되기 때문에 굳이 attemptAuthentication에서 생성할 필요는 없다.
successfulAuthentication에서는 토큰 생성을 위한 정보를 먼저 받아온다.
id와 username(사용자 아이디)가 필요하기 때문이다.
그 외에 토큰에 더 담고자 하는 정보가 있다면 그 정보역시 토큰 생성전에 받아와야 한다.
예제에서 토큰은 가장 흔히 사용한다는 HMAC512 알고리즘을 사용하고 jwt 라이브러리를 통해 생성한다.
토큰에 담아줘야 하는 데이터는 withClaim으로 이름과 데이터를 넣어주면 된다.
들어가는 데이터로는 List, String, long, double, int, map 등등 다양하다.
그리고 마지막으로 sign()을 통해 서명을 해준다.
여기에 사용할 알고리즘과 secretKey를 넣어주면 된다.
이제 그 다음은 선택사항이다.
강의에서는 responseHeader에 담아 넘겨주고 클라이언트는 그 토큰값을 요청할때 다시 requestHeader에 담아 요청하는 방식으로 진행되었는데 postman을 통해 테스트할때는 문제가 없지만 html파일까지 만들어서 하다보니 권한이 필요한 페이지로 이동시에 requestHeader에 토큰을 담아야할 방법을 찾지 못해 쿠키에 담는 방법을 택했다.
토큰 저장방식에 있어서 보통 localStorage에 담는 방법과 cookie에 담는 방법이 많이 보이는데 각각 장단점이 있다.
의견이 분분해 정답은 없는것으로 보인다.
헤더에 담아 요청, 응답하는 방법을 택한다면 response.addHeader를 통해 responseHeader에 토큰을 담아주면 되고 쿠키에 담아 요청, 응답하는 방법을 택한다면 아래 쿠키 생성 코드를 사용하면 된다.
토큰이 만료되면 쿠키역시 굳이 살아있을 필요가 없다고 생각해 쿠키 만료시간 역시 토큰 생성시간과 동일하게 처리했다.
그리고 isHttpOnly를 설정해주었는데 이걸 설정해줘서 프론트에서 쿠키값을 볼 수 없도록 보안설정을 해줘야 한다.
그래야 XSS 공격에 대한 보안이 가능하다.
그리고 addCookie로 토큰을 담은 쿠키를 돌려주면 된다.
둘다 알고있긴 해야된다고 생각하는게 아직 공부하지 못했지만 accessToken과 refreshToken을 만들어 보안을 더 강화해야 하는데 이때 accessToken은 localStorage에 담고 refreshToken은 쿠키에 담아 처리한다는 포스팅을 본 적이 있어 추후 두 방법 모두 사용하게 될 것 같기 때문이다.
그리고 쿠키를 생성할때의 문제점이 하나 있다.
원래 토큰값을 생성할때 Bearer tokenvalue 이렇게 만들어줘야 한다고 강의에서 설명해주셨는데
쿠키에는 공백이 포함될 수 없다. 그래서 쿠키로만 구현을 하다보니 저 공백을 없애버렸는데 헤더에 담아 처리하게 되었을때 문제점이 발생하지 않을런지 공백이 꼭 필요하다면 쿠키에 담을때는 어떻게 처리를 할지에 대해서도 학습이 좀 필요하다.
토큰을 생성한뒤에 응답까지 완료했따면 인증이 필요한 경우에 처리할 필터가 필요한데 그 역할을 하는 필터가 JwtAuthorizationFilter다.
// JwtAuthorizationFilter
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.example.jwt_testproject.config.auth.PrincipalDetails;
import com.example.jwt_testproject.model.User;
import com.example.jwt_testproject.repository.UserRepository;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.web.util.WebUtils;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
private UserRepository userRepository;
public JwtAuthorizationFilter(AuthenticationManager authenticationManager
, UserRepository userRepository) {
super(authenticationManager);
this.userRepository = userRepository;
}
@Override
protected void doFilterInternal(HttpServletRequest request
, HttpServletResponse response
, FilterChain chain)
throws IOException, ServletException {
System.out.println("Authorities request");
// 헤더에서 Authorization의 값을 가져온다.
// 강의 예제에서는 헤더에서 가져오는 방법을 사용했으나
// 쿠키로 변경했기 때문에 주석처리.
// String jwtHeader = request.getHeader(JwtProperties.HEADER_STRING);
// System.out.println("jwtHeader : " + jwtHeader);
//쿠키에서 Authorization 값을 가져온다.
Cookie jwtCookie = WebUtils.getCookie(request, JwtProperties.HEADER_STRING);
System.out.println("jwtCookie : " + jwtCookie);
// Authorization이라는 쿠키(혹은 헤더)가 존재하는지,
// value가 Bearer로 시작하는지를 검증.
// Authorization이라는 쿠키(헤더)가 존재하지 않거나 Bearer로 시작하지 않는다면
// 서버에서 만든 토큰이 아니기 때문에 토큰 검증을 할 필요가 없어 다음 필터로 넘긴다.
if(jwtCookie == null || !jwtCookie.getValue().startsWith(JwtProperties.TOKEN_PREFIX)) {
System.out.println("token is null");
chain.doFilter(request, response);
return;
}
System.out.println("token is not null");
// 토큰 검증을 하기 위해 value에서 Bearer 뒤에 있는 값만 빼준다.
// 헤더방식
// String jwtToken = request
// .getHeader(JwtProperties.HEADER_STRING)
// .replace(JwtProperties.TOKEN_PREFIX, "");
//쿠키 방식
String jwtToken = jwtCookie
.getValue()
.replace(JwtProperties.TOKEN_PREFIX, "");
System.out.println("AuthorizationFilter jwtToken : " + jwtToken);
// verify로 토큰을 서명한다.
// 서명이 정상적으로 처리된다면 getClaim을 통해 username 정보를 가져오고
// String 으로 캐스팅해준다.
// username이 정상적으로 들어온다는 것은 서명이 정상적으로 처리 되었다는것을 의미한다.
String username = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET))
.build()
.verify(jwtToken)
.getClaim("username")
.asString();
System.out.println("AuthorizationFilter username : " + username);
if(username != null) { //서명이 정상적으로 처리되어 username을 받았다면
System.out.println("username is nomal state");
User userEntity = userRepository.findByUsername(username);
PrincipalDetails principalDetails = new PrincipalDetails(userEntity);
Authentication authentication =
new UsernamePasswordAuthenticationToken(principalDetails
, null
, principalDetails.getAuthorities());
// securityContextHolder는 security를 저장할 수 있는 세션공간.
// 강제로 security 세션에 접근해 authentication 객체를 저장.
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
}
}
이 필터는 SpringSecurity가 갖고 있는 필터 중 BasicAuthenticationFilter를 extends 했다.
BasicAuthenticationFilter는 권한이나 인증이 필요한 특정 주소를 요청했을 때 동작하는 필터다.
그렇기 때문에 권한이나 인증이 필요하지 않은 주소를 접근하는 경우에는 동작하지 않는 필터이기 때문에 여기서 토큰을 검증하도록 구현한다.
JwtAuthorizationFilter를 securityConfig에 추가해 권한이나 인증이 필요한 주소로 접근 시 이 필터를 타도록 해주면 된다.
이 필터에서는 쿠키(혹은 헤더)에서 Authorization의 값을 찾고 존재한다면 그 토큰 값을 검증하고 세션에 정보를 넘겨주는 역할을 한다.
넘어온 쿠키나 헤더의 값을 확인할때는 그 값이 존재하는지, Bearer로 시작을 하는 값인지를 먼저 체크해줘야 한다.
값이 존재하지 않는다면 생성해준 토큰을 클라이언트가 넘겨주지 않았거나 갖고 있지 않다는 의미이기 떄문에 더이상 토큰을 검증할 필요가 없기 때문에 거기서 바로 넘겨버리면 되기 때문이다.
정상적인 토큰값을 받았다면 토큰을 검증하게되고 그 검증을 위해 Bearer를 제외한 나머지 값들을 뽑아 검증한다.
verify로 토큰을 서명하고 이 서명이 정상적으로 처리가 된다면 토큰 생성시에 넣어준 정보인 username을 getClaim으로 가져올 수 있다.
그럼 username값이 있다는것은 정상적인 토큰이라는 것이 검증이 되었다는 것이다.
if문 내부를 보면 username을 갖고 사용자 정보를 가져온 뒤 그 정보를 PrincipalDetails(UserDetails)에 담아준다.
그리고 authentication 객체를 만들고 그 객체를 SecurityContextHolder라는 security의 세션 공간에 저장을 해준다.
JwtAuthenticationFilter에서도 authentcation 객체를 만들었는데 이때와 다른점은 AuthenticatoinFilter에서는 로그인 요청을 해서 만드는것 이었다면 여기서는 로그인 요청을 할 것이 아니라 세션에 저장하기 위한 객체를 만드는 것이라는 차이가 있다.
그래서 객체 생성시에 UsernamePasswordAuthenticationToken으로 생성을 해주면 되고 credentials 부분을 보면 null로 되어있는데 로그인 요청을 하는것이 아니기 때문에 비밀번호는 굳이 넣어줄 필요가 없어서 null로 처리하는 것이다.
그리고 roles를 getAuthorities로 넣어주는데 이전에는 로그인 요청이기 때문에 권한까지 담아서 요청할 필요가 없이 아이디, 비밀번호만 있으면 되지만 SpringSecurity가 권한 관리를 하도록 해야 하기 때문에 권한을 같이 담아줘야 한다.
마지막 SecurityContextHolder.getContext().setAuthentication()은 SpringSecurity를 저장할 수 있는 Security의 세션 공간인 SecurityContextHolder에 authentication을 저장해주는 코드인데
이렇게 세션에 담아주지 않는다면 Security에서는 권한 관리를 할 수 없기 때문에 담아줘야 한다.
그럼 이제 필터 작성은 모두 끝났고 SecurityConfig를 작성하기 전에 하나만 더 만들어주면 된다.
강의에서는 postman으로만 테스트했기 때문에 이정도만 하고 SecurityConfig를 작성해 테스트해봐도 되지만
html파일을 만들고 처리하다보니 로그아웃되었을때도 쿠키가 남아있어 로그아웃 시 쿠키를 삭제해주도록 LogoutSuccessHandler를 따로 만들었다.
import com.example.jwt_testproject.config.jwt.JwtProperties;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.web.util.WebUtils;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class CustomLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request
, HttpServletResponse response
, Authenticatoin authentication)
throws IOException, ServletException {
Cookie cookie = WebUtils.getCookie(request, JwtProperties.HEADER_STRING);
cookie.setValue(null);
cookie.setMaxAge(0);
cookie.setPath("/");
response.addCookie(cookie);
response.sendRedirect("/home");
}
}
로그아웃이 정상적으로 처리되었다면 쿠키의 값을 null로 만들어주고 maxAge를 0으로 세팅해 삭제되도록 설정했다.
그럼 마지막 설정인 SecurityConfig다.
강의 예제와는 좀 다르게 되어있는데 이 강의 뿐만 아니라 많은 시큐리티 강의에서는 WebSecurityConfigurerAdapter를 extends해준다.
근데 현재 시점으로 Deprecated 상태이기 때문에 강의 종료 후 이 부분을 수정했다.
// SecurityConfig
import com.example.jwt_testproject.config.auth.CustomLogoutSuccessHandler;
import com.example.jwt_testproject.config.jwt.JwtAuthenticationFilter;
import com.example.jwt_testproject.config.jwt.JwtAuthorizationFilter;
import com.example.jwt_testproject.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.filter.CorsFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnagleGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class SecurityConfig {
private final CorsFilter corsFilter;
private final UserRepository userRepository;
private AuthenticationManager authenticationManager;
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public CustomLogoutSuccessHandler logoutSuccessHandler() {
return new CustomLogoutSuccessHandler();
}
@Bean
protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable();
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)// 세션을 사용하지 않는다(STATELESS)
.and()
.addFilter(corsFilter) // CorsFilter 추가
.formLogin().disable() // formLogin 사용안함.
.httpBasic().disable()
.logout().logoutSuccessHandler(logoutSuccessHandler()) // CustomLogoutSuccessHandler 등록
.and()
.addFilter(new JwtAuthenticationFilter(authenticationManager())// JwtAuthenticationFilter 추가
.addFilter(new JwtAuthorizationFilter(authenticationManager(), userRepository))// JwtAuthorizationFilter 추가
.authorizeRequests()
.antMatchers("/api/v1/user/**")
.access("hasRole('ROLE_USER') or hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
.antMatchers("/api/v1/manager/**")
.access("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
.antMatchers("/api/v1/admin/**")
.access("hasRole('ROLE_ADMIN')")
.anyRequest()
.permitAll();
return http().build();
}
private final AuthenticationManagerBuilder localConfigureAuthenticationBldr;
private boolean authenticationManagerInitialized;
private boolean disableLocalConfigureAuthenticationBldr;
private fianl AuthenticationConfiguration authenticationConfiguration;
protected AuthenticationManager authenticationManager() throws Exception {
if(!this.authenticationManagerInitialized) {
this.configure(this.localConfigureAuthenticationBldr);
if(this.disableLocalConfigureAuthenticationBldr) {
this.authenticationManager = this.authenticationConfiguration.getAuthenticationManger();
} else {
this.authenticationManager = (AuthenticationManager) this.localConfigureAuthenticationBldr.build();
}
this.authenticationManagerInitialized = true;
}
return this.authenticationManager;
}
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
this.disableLocalConfigureAuthenticatoinBldr = true;
}
}
authenticatoinManger는 WebSecurityConfigurerAdapter 안에 들어가있기 때문에 extends해서 사용한다면 아래 코드가 필요없이 바로 사용이 가능하다.
하지만 Deprecated 상태라 제거하다보니 바로 갖다가 사용하는게 불가능해졌다.
그래서 생각한 방법이 security Config에 만들어주는 방법을 택했다.
WebSecurityConfigurerAdapter에서 그대로 긁어온 뒤에 새로 가져와야 하는 코드들을 더 찾아 추가해주는 형태로 수정했다.
정상적으로 잘 동작하긴하지만 WebSecurityConfigurerAdapter를 Deprecated 상태로 만들었으면 다른 방법으로 authenticationManger를 사용하도록 만들어뒀을텐데 아니면 이거 말고 다른걸 사용하도록 만들어뒀을텐데 아직 못찾았다.
이게 완전 맞는 방법인지는 좀 더 확인이 필요하다.
일단은 문제는 발생하지 않으니 jwt를 테스트하는데 있어서는 문제가 없다.
테스트
테스트는 postman으로 테스트하는 경우 헤더에 담아테스트하는게 가장 편하다고 생각하니 주석처리 해놨던 Header에서 토큰값을 꺼내오는 코드들을 활성화 시켜주고 쿠키에서 꺼내오는 코드들은 주석처리.
쿠키 방식으로 테스트 하는 경우는 로그인하면 서버에서 쿠키를 보내주니 따로 손대는것 없이 권한이 필요한 /api/user/** 페이지 등에 접근하면서 테스트를 해볼 수 있다.
JQuery만 써봤기 때문에 JQuery 기준으로 requet header에 토큰 값을 담는 방법을 정리.
토큰을 헤더로 넘겨받았을 때 어떻게 관리할것인가에 대한 방법도 여러가지가 있었다.
localStorage에 담아 필요할때마다 꺼낸다거나 정적변수에 담아 사용한다는 내용이 대부분이었다.
localStorage에 매번 접근하면 과도한 오버헤드가 발생하니 정적변수에 담아 처리하는게 효율적이다 라는 의견이었다.
아쉽게도 이에 대한 언급만 있을 뿐이라 근본적인 문제를 해결하지는 못했다.
일단 JQuery만 사용해봤기 때문에 아래처럼 처리는 가능했다.
// responseHeader에서 토큰 값 꺼내오기
var token = request.getResponseHeader("Authorization");
// 로컬 스토리지에 토큰 저장
localStorage.setItem("Authorization", token);
// 로컬 스토리지에서 토큰 꺼내오기
var token = localStorage.getItem("Authorization");
// RequestHeader에 토큰을 담아 요청하기
var hReq = new XMLHttpRequest();
hReq.open("get", "/api/v1/user", false);
hReq.setRequestHeader("Authorization", token);
hReq.send();
// ajax로 토큰을 담아 요청하기
$.ajax({
type: "post",
url: "/api/v1/user",
data: data,
beforeSend: function(xhr) {
xhr.setRequestHeader("Authorization", token);
},
success: function(data) {
}
});
여기서 이 두가지 요청 케이스들은 정상적으로 처리가 가능하지만 문제점은 페이지 이동에 있어서 문제가 있다는 것이었다.
예를들어 ajax로 처리하면서 requestHeader에 토큰을 담아 보내서 처리한 뒤에 페이지 이동을 해야 하는 상황인데
페이지 이동시에는 토큰이 담기지 않아 권한이 필요한 페이지는 접근이 불가능하다는 문제다.
이 내용에 대해 여러 포스팅을 찾아봤지만 물어보는 분들은 있는데 명확한 답은 찾지 못했다.
jwt토큰을 공부하면서 아직 남은부분이 accessToken과 refreshToken에 대한 학습인데 accessToken은 localStorage에 refreshToken은 쿠키에 담는다 라는 포스팅을 봤다.
아마 여기에 힌트가 있지 않을까 생각한다...
'Web' 카테고리의 다른 글
JWT 1. JWT란? (0) | 2022.11.25 |
---|---|
CI/CD란? (0) | 2021.12.28 |
Jenkins github webhook 연동 (0) | 2021.12.28 |
Jenkins로 Spring Boot 프로젝트 빌드&배포하기 (0) | 2021.12.24 |