4.2.2 Spring Security - JWT

yeonseong Jo·2023년 8월 3일
0

SEB_BE_45

목록 보기
45/47
post-thumbnail

JWT를 처음 알게 되었을 때 한창 JMT가 유행이었다...

JWT는 이전에 사용해 본 적이 있다.
django를 사용하여 이메일 기반 인증시스템을 만들 때
이메일로 JWT를 담은 url을 보내 해당 url을 클릭하면
인증이 성공하도록 했었고,

어느 기업에 입사하기 위해 과제전형을 한 적이 있는데,
그 때 로그인 인증을 JWT로 진행했었다.

그래서, JWT와 세션방식의 차이는 알고 있었고
그나마 spring에서 JWT를 배우는데 어려움이 없었다.


세션 vs JWT

세션 방식은 인증이 완료된 세션(사용자의 정보)를
서버의 redis같은 inMemory DB나 일반적인 DB에
저장하고 관리하는 방식이다.

  • 세션의 고유 ID는 클라이언트의 쿠키에 저장됨
  • request 전송시 세션 ID값을 DB에 조회하여
    인증된 사용자인지 판단함
  • 세션의 ID만 클라이언트에서 보내기 때문에
    상대적으로 적은 네트워크 트래픽을 사용함
  • 서버측에서 세션을 관리하기 때문에 보안에 조금 더 유리
  • 서버 확장 시 세션 인증에 문제가 발생
  • 세션이 많을 수록 서버에 부담
  • SSR 방식에 적합함

JWT 방식은 토큰에 사용자의 정보를 담아 암호화하여
로그인 시 토큰을 사용자에게 주고,
로그인을 완료한 사용자는
다른 요청을 할 때 마다 토큰을 같이 전송하고,
서버가 토큰을 검사하여 인증, 인가를 완료하는 방식이다.

  • 토큰에 포함된 사용자 정보는 서버측에서 관리를 하지 않음
  • 사용자는 생성된 토큰을 헤더에 포함해 request를 보냄
  • 세션 방식에 비해 많은 네트워크 트래픽을 사용
  • 서버에서 토큰을 관리하지 않기 때문에 세션보단 약한 보안성
  • 서버를 확장하더라도 토큰을 검사하는 방식만 같으면 상관없음
  • 토큰이 탈취당할 위험이 있기 때문에 민감한 정보는 지양
  • 토큰이 만료되기 전까지는 무효화 불가
  • CSR 방식에 적합함

JWT

Json Web Token의 준말로
JSON 포맷으로 토큰 정보를 인코딩 후,
인코딩 된 토큰 정보를 Secret Key로 서명한 메시지를
Web 토큰으로써 인증 과정에 사용된다.

보통 Access 토큰과 Refresh 토큰이 있으며

Access

인증용 토큰이며
유효기간을 짧게 설정하여 탈취가 되더라도
큰 피해를 방지할 수 있다.
만약 유효기간이 만료되면, Refresh 토큰을 사용해
새로운 Access 토큰을 발급 받는다.

Refresh

Access 토큰을 재발급하기 위한 토큰이며
Access 토큰보다는 유효기간을 길게 설정한다.
Refresh 토큰도 탈취당할 위험이 있기 때문에,
보안이 중요한 경우 사용하지 않는다고 한다.

구조


JWT는 Header, Payload, Signature로 이루어져 있으며
각 부분은 JSON 형식으로 이루어져 있다.

보통은
typ을 통해 해당 토큰의 종류를 설명하고,
alg를 통해 어떤 알고리즘으로 Sign할지 정의한다.

Payload

Claim이라는 사용자나 토큰에 대한 property를 저장하며,
개발자에 의해 다양한 key가 들어갈 수 있다.

  • iss (issuer): 토큰 발급자
  • sub (subject): 토큰 제목 (사용자에 대한 식별 값)
  • aud (audience): 토큰 대상자
  • exp (expiration time): 토큰 만료 기한
  • nbf (not before): 토큰 활성 날짜
  • iat (issued at): 토큰 발급 시간
  • jti (JWT id): JWT 토큰 식별자 (iss가 여럿일 때 구분하기 위한 값)

이외에도 상황에 맞게 개발자 임의대로 권한 등을 key로 추가하거나
다른 key를 삭제할 수 있다.

Signature

개발자가 정한 Secret Key와 Header에서 지정한 alg를 사용하여
Header와 Payload에 대해 단방향 암호화를 수행한다.
암호화된 메시지를 통해 토큰의 위변조를 검증할 수 있다.

Spring boot에 적용하기

JWT를 활용한 로그인 인증 흐름은
이전에 Spring Security의
로그인 인증 흐름 절차에 약간의 변형되었기 때문에 거의 비슷하다.

  1. 클라이언트가 서버에 로그인 인증 요청
  2. JwtAuthenticationFilter가 클라이언트의 로그인 인증 정보를 수신
  3. 수신한 로그인 정보를 AuthenticationManager에 전달해 인증 처리를 위임
  4. Manager는 UserDetailsService에 UserDetails 조회를 위임
  5. UserDetailsService에서 UserDetails를 조회한 이후
    Manager에게 UserDetails를 전달
  6. Manager는 로그인 인증 정보와 UserDetails를 비교해 인증 처리
  7. 인증 정보를 바탕으로 JWT를 생성하고 클라이언트에게 response로 전달

이 과정를 구현 하려면,
JwtAuthenticationFilter, JWT를 발급하는 JwtTokenizer와
UserDetailsService,
JWT를 검사하는 JwtVerifcationFilter를 구현해야 한다.

우선
gradle에 라이브러리를 추가

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

JwtTokenzier

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.security.Keys;

import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Calendar;
import java.util.Date;
import java.util.Map;
...

@Component
public class JwtTokenizer {
	@Value("${jwt.secretKey}")
    private String secretKey;
    @Value("${jwt.accessToken-exp}")
    private int accessTokenExp;
    @Value("${jwt.refreshToken-exp}")
    private int refreshTokenExp;
    
    public String generateAccessToken(UserEntity user){
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", user.getUsername());
        claims.put("roles", user.getRoles);
        
        return Jwts.builder()
        		.setClaims(claims)
                .setSubject(user.getUsername())
                .setIssuedAt(Calendar.getInstance().getTime())
                .setExpiration(getTokenExpiration(accessTokenExp))
                .signWith(getKey())
                .compact();
    }
    public String generateRefreshToken(UserEntity user) {
    	return Jwts.builder()
        		.setSubject(user.getUsername())
                .setIssuedAt(Calendar.getInstance().getTime())
                .setExpiration(getTokenExpiration(refreshTokenExp))
                .signWith(key)
                .compact();
    }
    public Jws<Claims> getClaims(String jws) {
    	return Jwts.parserBuilder()
        		.setSigningKey(getKey())
                .build()
                .pareClaimsJws(jws);
    }
    
    private Key getKey() {
    	byte[] keyBytes = secretKey.getBytes(StandardCharset.UTF_8);
        return Keys.hmacShaKeyFor(keyBytes);
    }
    private Date getTokenExpiration(int exp) {
    	Calendar calendar = Calendar.getInstance();
        calendar.add(Calendar.MINUTE, exp);
        Date expiration = calendar.getTime();
        return expiration;
    }
}

secretKey나 access, refresh 토큰의 만료기한은
application.yml에 설정한 값을 가져왔다.

JwtAuthenticationFilter

public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
	private final AuthenticationManager manager;
    private final JwtTokenizer tokenizer;
    private final PasswordEncoder passwordEncoder
    // DI
    public JwtAuthenticationFilter(AuthenticationManager manager, JwtTokenizer tokenizer, PasswordEncoder encoder){
    	this.manager = manager;
        this.tokenizer = tokenizer;
        this.passwordEncoder = encoder;
    }
    
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
    											HttpServletResponse response) throws AuthenticationExeption, IOException {
        String username = request.getParameter("username");
        String password = passwordEncoder.encode(request.getParameter("password"));
    	return manager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
	}
    @Override
    protected void successfulAuthentication(HttpServletRequest request,
    										HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authResult) {
    	UserEntity user = (UserEntity) authResult.getPrincipal();
    	String accessToken = tokenizer.generateAccessToken(user);
    	String refreshToken = tokenizer.generateRefreshToken(user);
    
    	response.setHeader("Authorization", "Bearer " + accessToken);
    	response.setHeader("Refresh", refreshToken");
    }
}

attemptAuthenticationOverride해서
사용자의 로그인 정보를 바탕으로 비교용 (인증이 안된)토큰을 만들어
AuthenticationManager에 전달을 하고,
successfulAuthenticationOverride해서
DI 받은 JwtTokenzier를 통해 access, refresh 토큰을 생성해
response의 header에 담아 클라이언트에 전달하게 된다.

보통 JWT 인증 방식의 경우 access 토큰을 header에 담을 때
토큰 앞에 "Bearer "를 붙혀 전달한다고 한다.

JwtAuthenticationFilter 적용

@Configuration
public class SecurityConfiguration {
	private final JwtTokenizer jwtTokenizer;
    public SecurityConfiguration(JwtTokenizer jwtTokenizer){
    	this.jwtTokenizer = jwtTokenizer;
    }
	...
    // 기본 security 설정..
    ...
    
    public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
    	@Override
        public void configure(HttpSecurity builder) throws Exception {
        	AuthenticationManager manager = builder.getSharedObject(AuthenticationManager.class);
            // JwtAuthenticationFilter를 따로 생성해서,
            // 인증이 성공, 실패했을 때의 조치를 추가할 수 있다.
            builder.addFilter(new JwtAuthenticationFilter(manager, jwtTokenizer));
        }
    }
}

Custom UserDetailsService

@Component
public class CustomDetailsService implements UserDetailsService {
	private final UserEntityRepository repository;
    // DI
    public CustomDetailsService(UserEntityRepository repo){
    	this.repository = repo;
    }
    
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    	...
        // username을 바탕으로 db에서 user를 조회
        ...
        return createUserDetails(UserEntity user);
    }
    private UserDetails createUserDetails(UserEntity user){
    	return User.builder()
        		.username(user.getUsername())
                .password(user.getPassword())
                .roles(user.getRoles())
                .build();
    }
}

JWT를 활용한 로그인 인증 과정 중 6에서
로그인 인증 정보와 UserDetails를 비교할 때
로그인 인증 정보의 password는 PasswordEncoder로 암호화 되어 있고,
db에서 또한 User의 password는 암호화 되어 저장 되어 있기 때문에
UserDetails를 생성할 때에 그냥 getPassword를 사용했다.

JwtVerificationFilter

public class JwtVerificationFilter extends OncePerRequestFilter {
	private final JwtTokenizer jwtTokenizer;
    private final CustomAuthorityUtils authorityUtils;
    
    public JwtVerificationFilter(JwtTokenizer jwtTokenizer, CustomAuthorityUtils authorityUtils) {
    	this.jwtTokenizer = jwtTokneizer;
        this.authorityUtils = authorityUtils;
    }
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
    								HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        try {
        	Map<String, Object> claims = verifyJws(request);
        	setAuthenticationToContext(claims);
        } catch (Exception e) {
        	request.setAttribute("exception", e);
        }
    	
        filterChain.doFilter(request, response);
    }
    // header에 토큰의 유무에 따라 Filter를 건너 뛰도록 함
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
    	String authorization = request.getHeader("Authorization");
        return authorization == null || ! authorization.startsWith("Bearer");
    }
    // JWT 검증 메서드
    private Map<String, Object> verifyJws(HttpServletRequest request) {
		String jws = request.getHeader("Authorization").replace("Bearer ", "");
        Map<String, Object> claims = jwtTokenizer.getClaims(jws).getBody();
        return claims;
    }
    // Authentication 객체를 SecurityContext에 저장
    private void setAuthenticationToContext(Map<String, Object> claims) {
    	String username = (String) claims.get("username");
        List<GrantedAuthority> authorities = authorityUtils.createAuthorities((List)claims.get("roles"));
        Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
}

CustomAuthorityUtils에서 createAuthorities
List<String>으로 되어있는 roles를 받아
GrantedAuthority를 생성하는 메서드이다.
setAuthenticationToContext메서드를 통해
ContextHolder에 세션을 저장하는 것 처럼 보이지만,
Filter를 적용하는 부분에서 세션 저장을 할지 말지 설정이 가능하다.

JwtVerificationFilter 적용

@Configuration 
public class SecurityConfiguration {
	private final CustomAuthorityUtils authorityUtils;
	...
    // JwtAuthenticationFilter를 적용하는 SecurityConfiguration에서 이어짐
    ...
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .headers().frameOptions().sameOrigin()
            .and()
            ...
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)  
            .and()
            ...;
            
        return http.build();
    }
    
    public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
    	@Override
        public void configure(HttpSecurity builder) throws Exception {
        	...
            // JwtAuthenticationFilter 적용 부분
            ...
            builder.addFilterAfter(
            	new JwtVerficationFilter(jwtTokenizer, authorityUtils), JwtAuthenticationFilter.class);
        }
    }
    
}

SecurityFilterChain을 설정하는 부분에서
sessionManagement().sessionCreationPolicy(SessionCreationPolicy.~) 부분을 통해
세션을 생성할 지 말지 설정한다.

  • ALWAYS
    항상 생성
  • NEVER
    세션을 생성하지 않지만, 이미 생성된 세션이 있다면 사용
  • If_REQUIRED
    필요한 경우에만 생성
  • STATELESS
    생성하지도 않으며, SecurityContext에서 정보를 얻기 위해 세션을 사용하지 않음

이외에도 인증에 실패하거나
권한이 없는 사용자가 리소스에 대해 작업을 시도할 때
처리하는 handler를 적용하지만,

후에 좀 더 알아보도록 하자...

profile
뒤(back)끝(end)있는 개발자

0개의 댓글