SOLUX-완숙이

spring security+jwt 회원가입, 로그인 #3

leeeehhjj 2022. 1. 17. 15:07

2022.01.12 - [SOLUX-완숙이] - spring security+jwt 회원가입, 로그인 #2

 

spring security+jwt 회원가입, 로그인 #2

2022.01.12 - [SOLUX-완숙이] - spring security+jwt 회원가입, 로그인 #1 spring security+jwt 회원가입, 로그인 #1 security 적용 전 회원가입 코드 1. Member 클래스 package solux.wansuki.OurNeighbor_BE.dom..

leeeehhjj.tistory.com


JWT 를 활용해서 AccessToken과 RefreshToken을 발급하고, 프론트에서 헤더에 access token을 붙여 보내면 이를 검증하여 권한에 따라 접근을 허용해주는 코드이다.

 

1. MemberService 클래스

@Transactional
    public TokenInfoResponseDto login(LoginDto loginDto) {
        if (memberRepository.findByLoginId(loginDto.getLoginId()).orElse(null) == null) {
            return null;
        }

        UsernamePasswordAuthenticationToken authenticationToken = loginDto.toAuthentication();

        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

        TokenInfoResponseDto tokenInfo = jwtTokenProvider.generateToken(authentication);

        return tokenInfo;
    }

인증이 완료된 정보가 authentication 변수에 담기고 이를 jwtTokenProvider의 generateToken 메소드로 넘겨서 accessToken을 생성한다.

 

2. application properties 파일

#Jwt
jwt.secret=자신이 원하는 값 입력

 JwtTokenProvider

import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SecurityException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import solux.wansuki.OurNeighbor_BE.dto.Member.TokenInfoResponseDto;

import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;

@Slf4j
@Component
public class JwtTokenProvider {

    private static final String AUTHORITIES_KEY = "auth";
    private static final String BEARER_TYPE = "Bearer";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 30*60*1000L; //30분
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 30 * 24 * 60 * 60 * 1000L; //30일

    private final Key key;

    public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public TokenInfoResponseDto generateToken(Authentication authentication) {
        //권한 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        //AccessToken 생성
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        //RefreshToken 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        return TokenInfoResponseDto.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .refreshTokenExpirationTime(REFRESH_TOKEN_EXPIRE_TIME)
                .build();
    }

}

- accessToken, refreshToken을 생성해주는 클래스이다.

- @Value("${jwt.secret}") : application.properties 파일에서 jwt.secret 값을 찾아서 secretKey 변수에넣어준다.

- service에서 가져온 authentication에서 권한을 가져온 후 claim으로 넣어준다. 이 정보를 통해 spring security가 회원의 접근 권한을 확인한다.

* accessToken은 탈취 시 토큰의 유효시간이 끝날때까지 자유롭게 인증이 가능하므로 accessToken의 유효시간은 짧게, refreshToken의 유효시간은 그보다 길게 설정한다. refreshToken은 회원 정보를 담고 있지 않아 탈취 당해도 위험하지 않다.

 

3. WebSecurityConfig

@RequiredArgsConstructor
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .httpBasic().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/login","/signup","/schedules").permitAll()
                .antMatchers("/**").hasAnyRole("ADMIN","USER")
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

}
  • .httpBasic().disable() : rest api 이므로 basic auth 인증을 사용하지 않는다
  • .csrf().disable() : rest api 이므로 csrf 보안을 사용하지 않는다.
  • .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) : JWT를 사용하기 때문에 세션을 사용하지 않는다
  • .antMatchers().permitAll() : 모든 요청을 허가한다
  • .antMatchers().hasRole("USER") : USER 권한이 있어야 요청가능
  • .antMatchers().hasRole("ADMIN") : ADMIN 권한이 있어야 요청가능
  • .addFilterBefore(new JwtAUthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class) : Jwt 인증을 위하여 직접 구현한 JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 실행하겠다는 설정

4. JwtTokenProvider

...
//토큰 정보 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException| MalformedJwtException e) {
            log.info("Invalid JWT token", e);
        } catch (ExpiredJwtException e) {
            log.info("Expired jwt token", e);
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported jwt token", e);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty", e);
        }
        return false;
    }
   
   //jwt 토큰을 복호화해 그 안의 정보를 꺼내는 메소드
    public Authentication getAuthentication(String accessToken) {
        //토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get(AUTHORITIES_KEY) == null) {
            throw new RuntimeException("권한 정보가 없는 토큰");
        }
        //클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
        //userDetails 객체 만들어 Authentication 리턴
        UserDetails principal = new User(claims.getSubject(), "",authorities);
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }
    
     private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        }catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }

JwtTokenProvider 클래스에 @Slf4j 어노테이션을 쓰는데 이 어노테이션을 통해 log.info를 활용하여 간단하게 log를 찍을 수 있다.

 

JwtAuthenticationFilter

import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String BEARER_TYPE = "Bearer";

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = resolveToken((HttpServletRequest) request);

        if (token != null && jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) {
            return bearerToken.substring(7);  //"Bearer "을 잘라냄
        }
        return null;
    }
}

- JWT 토큰을 Header에 담아 API 요청이 왔을 때 해당 토큰을 검사하고, 토큰에서 인증 정보를 가져오기 위해 생성하는 필터

- resolveToken을 통해 authorization 이라는 이름의 header를 가져온다. `Bearer ${accessToken}` 형태로 들어오므로 "Bearer "을 잘라준다.

- jwtTokenProvider의 validateToken 메소드를 통해 토큰을 검증한 후 getAuthentication 메소드를 통해 그 안의 권한 정보를 가져온다.

이후 chain.doFilter(request, response); 을 통해 chain이 동작 완료되고 api 요청에 대한 응답을 한다.


참고

spring security + JWT 로그인 기능 파헤치기 - 1 (tistory.com)

 

spring security + JWT 로그인 기능 파헤치기 - 1

로그인 기능은 거의 대부분의 애플리케이션에서 기본적으로 사용됩니다. 추가로 요즘은 웹이 아닌 모바일에서도 사용 가능하다는 장점과 Stateless 한 서버 구현을 위해 JWT를 사용하는 경우를 많

wildeveloperetrain.tistory.com

SPRING SECURITY + JWT 회원가입, 로그인 기능 구현 (tistory.com)