JWT

심규환·2022년 2월 21일
0

Spring Security

목록 보기
1/3

JWT에 대해 항상 공부하고 나중에 돌아서면 까먹고 하기 때문에 대략적인 시나리오와 구현을 적어두고 필요할 때마다 원하는 부분을 가져다 쓰려고 작성합니다.

기초 설정, 첫 로그인(JWT x), 로그인 후(JWT o)로 분류하겠습니다.

기초 설정

의존성 추가

제일 위에 spring-boot-configuration-processor를 제외하고 그 아래는 다 JWT를 위한 의존성입니다. spring-boot-configuration-processor는 JWT의 시크릿 키를 properties 에 넣어서 사용하기 위해서 추가 했습니다.

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-configuration-processor</artifactId>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt-api</artifactId>
			<version>0.11.2</version>
		</dependency>
		<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt-impl</artifactId>
			<version>0.11.2</version>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt-jackson</artifactId>
			<version>0.11.2</version>
			<scope>runtime</scope>
		</dependency>

application.properties

Jwt를 만들때 사용할 시크릿 키 입니다.

jwt.secret-key=n2r5u8x/A%DG-KaPdSgVkYp3s6v9y$B&E(H+MbQeThWmZq4t7w!z%CF-J@NcRf

ShopProperties.Java

@Component 를 꼭 해줘야 하며 @ConfigurationProperties("jwt") 안에 'jwt'로 prefix를 설정하면 바로 위에 있는 'application.properties'의 jwt.[secret-key] 값을 알아서 필드 값에 매핑됩니다.

@Getter
@Setter
@Component
@ConfigurationProperties("jwt")
public class ShopProperties {
    private String secretKey;
}

SecurityConstants

토큰 만들때 사용할 상수값을 클래스로 정의합니다.

public class SecurityConstants {
    // 로그인 인증 URL 정의
    public static final String AUTH_LOGIN_URL = "/api/authenticate";

    // JWT 토큰 기본 상수값 정의
    public static final String TOKEN_HEADER = "Authorization";
    public static final String TOKEN_PREFIX = "Bearer ";
    public static final String TOKEN_TYPE = "JWT";
}

User 설정

먼저 Member에 대한 객체는 따로 구현해 놨습니다. Security에는 따로 User(interface)가 사용되니 구현한 뒤 Member를 내부 필드로 넣어서 감쌌습니다.

public class CustomUser extends User {

    private static final long serialVersionUID = 1L;
    private Member member;

    public CustomUser(String username, String password, Collection<? extends GrantedAuthority> authorities){
        super(username, password, authorities);
    }

    public CustomUser(Member member){
        super(member.getUserName(), member.getUserPw(),
                member.getAuthList().stream().map(auth -> new SimpleGrantedAuthority(auth.getAuth())).collect(Collectors.toList()));
        this.member = member;
    }

    public CustomUser(Member member,Collection<? extends GrantedAuthority> authorities){
        super(member.getUserName(), member.getUserPw(), authorities);
        this.member = member;
    }

    public long getUserNo(){
        return member.getUserNo();
    }

    public String getUserId(){
        return member.getUserId();
    }

}

CustomUserDetailService.Java

UserDetailservice에서는 Spring Security에서 request에서 빼 온 로그인한 계정의 아이디 값을 사용합니다. 이 아이디 값을 사용해서 repository에 member를 요청하고 request의 password 값과 memeber의 password 값을 비교하도록 합니다. 이를 위해 사용하는 메소드가 loadUserByUserName(..) 입니다.

@Slf4j
public class CustomUserDetailService implements UserDetailsService {
    @Autowired
    private MemberRepository repository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = repository.findByUserId(username);

        return member == null? null: new CustomUser(member);
    }
}

SecurityConfig.Java

JWT와 REST API로 구현하기 때문에 필요없는 formLogin, httpBasic, csrf는 다 disable() 했습니다.

@Slf4j
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        log.info("security config..");

        http.formLogin().disable()
                .httpBasic().disable();

        http.cors();

        http.csrf().disable();

        // JWT 관련 필터 보안 컨텍스트에 추가와 세션 STATELESS 설정
        http.addFilterAt(new JwtAuthenticationFilter(authenticationManager(), jwtTokenProvider),
                        UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new JwtRequestFilter(jwtTokenProvider),UsernamePasswordAuthenticationFilter.class)
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

Client와의 원활한 통신을 위해 cors를 설정합니다.

@Bean
    public CorsConfigurationSource corsConfigurationSource(){
        final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("OPTIONS");
        config.addAllowedMethod("HEAD");
        config.addAllowedMethod("GET");
        config.addAllowedMethod("PUT");
        config.addAllowedMethod("POST");
        config.addAllowedMethod("DELETE");
        config.addAllowedMethod("PATCH");
        config.setExposedHeaders(Arrays.asList("Authorization", "Content-Disposition"));

        source.registerCorsConfiguration("/**", config);

        return source;
    }

만들어 놓은 CustomUserDetailServicePasswordEncoder를 Bean으로 추가합니다. AuthenticationManagerBuiler에 추가해줍니다. 이 곳에 이 둘이 쓰여질 겁니다.

   @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(customUserDetailService())
                .passwordEncoder(passwordEncoder());
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    // 스프링 시큐리티의 UserDetailService 를 구현한 service
    @Bean
    public UserDetailsService customUserDetailService(){
        return new CustomUserDetailService();
    }

여기까지가 기본 설정입니다. 그러면 이제 다음 시나리오로 고고

첫 로그인(JWT x)

처음 클라이언트에서 로그인 정보(ID,PW)를 받을 때, 필터를 통해서 처리하도록 하겠습니다.
먼저 필터는 ID,PW의 유효성을 검증합니다.(AuthenticationManager) 인증이 성공하면 토큰 값을 만들고 response Header에 추가해줍니다.

JwtAuthenticationFilter

UsernamePasswordAuthenticationFiler가 원래 ID,PW의 검증을 하는데. SecurityConfig를 보면 이 자리를 요녀석이 뺐었습니다. AuthenticationManager는 스프링 빈에서 알아서 넣어줄 것입니다.(DI)
setFilterProcessesUrl(SecurityConstants.AUTH_LOGIN_URL) 를 통해서 이 url을 통한 접근 시에 이 필터를 적용하게 합니다.

public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider jwtTokenProvider;

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtTokenProvider jwtTokenProvider){
        this.authenticationManager= authenticationManager;
        this.jwtTokenProvider = jwtTokenProvider;

        setFilterProcessesUrl(SecurityConstants.AUTH_LOGIN_URL);
    }

ID,PW의 유효성 검증

   @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        String username = request.getParameter("username");
        String password = request.getParameter("password");

        // Authentication 이 UsernamePasswordAuthenticationToken 의 조상이기 때문에 담을 수 있다.
        Authentication authenticationToken = new UsernamePasswordAuthenticationToken(username, password);

        // 매니저에 위에 받아온 토큰을 전달해서 인증을 시도한다. 만약 잘못된 계정이라면 에러를 던지고 올바른 계정이라면 다시 반환해준다. 여기서 PasswordEncoder를 사용한다.
        return authenticationManager.authenticate(authenticationToken);
    }

토큰 값 생성 및 헤더 추가

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        // 성공적인 인증 후의 authResult.getPrincipal 에는 위에서 CustomUser 값이 들어있게 된다. 그 안에는 Member 를 필드값으로 넣어줬다.
        CustomUser user = ((CustomUser)authResult.getPrincipal());
        long userNo = user.getUserNo();
        String userId = user.getUserId();

        List<String> roles = user.getAuthorities()
                .stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList());

        // userNo,userId, roles 값을 사용해서 Token 을 생성한다.
        String token = jwtTokenProvider.createToken(userNo, userId, roles);

        // 이제 토큰 값을 응답 헤더의 Bearer 뒤에 넣어준당
        response.addHeader(TOKEN_HEADER, TOKEN_PREFIX + token);
    }

로그인 후(JWT o)

Stateless 상태로 바꿔 놨기 때문에 모든 요청에는 JWT를 확인하는 과정을 거치게 됩니다.
처음에 토큰 값이 있는지 확인 후 없으면 다음 필터로 넘기고 만약 있다면 토큰에 대한 유효성을 검증합니다.
토큰 값을 사용해서 사용자 정보를 추출하고 SecurityContextHolder에 저장해 놓습니다.

  • SecurityContextHolder에 저장됐다는 것은 인증된 객체라는 뜻입니다.
@RequiredArgsConstructor
public class JwtRequestFilter extends OncePerRequestFilter {
    private final JwtTokenProvider jwtTokenProvider;

    // request 의 header 값을 가져와 Token 헤더값이 맞는지 확인하고 맞다면 다음 필터로 전달한다.
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 헤더의 "Authorization" 값을 가져온다.
        String header = request.getHeader(SecurityConstants.TOKEN_HEADER);

        // 헤더 값이 비어있는지 올바른 JWT TOKEN 이 맞는지 유효성 검사를 한다.
        // 토큰이 없다면 다음 필터로 GO
        if(isEmpty(header) || !header.startsWith(SecurityConstants.TOKEN_PREFIX)){
            filterChain.doFilter(request, response);
            return;
        }

        // 검증
        Authentication authentication = jwtTokenProvider.getAuthentication(header);

        SecurityContextHolder.getContext().setAuthentication(authentication);
        filterChain.doFilter(request, response);
    }

    private boolean isEmpty(final CharSequence cs){
        return cs == null || cs.length() == 0;
    }
}

JwtTokenProvider

토큰 관리의 책임을 가지는 객체입니다. 토큰을 만들거나 검증을 하는 역할을 가집니다. 설명은 코드 안에 작성했습니다.

@Slf4j
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
    private final ShopProperties shopProperties;

    // 토큰을 통해서 userNo 를 가져온다.
    public long getUserNo(String header) throws Exception{
        String token = header.substring(7);

        byte[] signingKey = getSigningKey();

        Jws<Claims> parsedToken = Jwts.parserBuilder()
                .setSigningKey(signingKey)
                .build().parseClaimsJws(token);

        Claims claims = parsedToken.getBody();
        long userNo  = Long.parseLong((String)claims.get("uno"));

        return userNo;
    }
    public String createToken(long userNo, String userId, List<String> roles){
        byte[] signingKey = getSigningKey();

        String token = Jwts.builder()
                .signWith(Keys.hmacShaKeyFor(signingKey), SignatureAlgorithm.HS512)
                .setHeaderParam("typ", SecurityConstants.TOKEN_TYPE)
                .setExpiration(new Date(System.currentTimeMillis() + 864000000))
                .claim("uno",""+userNo)
                .claim("uid", userId)
                .claim("rol", roles)
                .compact();
        return token;
    }

    public UsernamePasswordAuthenticationToken getAuthentication(String tokenHeader){
        if(isNotEmpty(tokenHeader)){
            try{
                // Key 값을 가져옵니다.
                byte[] signingKey = getSigningKey();

                // parserBuilder 를 생성한 뒤, 토큰 값을 넣어서 Jws 를 생성합니다.
                // Jws 를 생성하면 메소드를 이용해서 토큰 내부의 값을 parse 해서 가져올 수 있습니다.
                Jws<Claims> parsedToken = Jwts.parserBuilder()
                        .setSigningKey(signingKey)
                        .build().parseClaimsJws(tokenHeader.replace("Bearer ", ""));

                // 토큰 내부에 있는 정보를 추출합니다.
                // Jwt 값에서 Body 부분만 가져옵니다.
                Claims claims = parsedToken.getBody();

                // 처음에 넣어뒀던 userNo, userId 값을 가져옵니다.
                String userNo = (String)claims.get("uno");
                String userId = (String)claims.get("uid");

                // 권한도 가져옵니다.
                List<SimpleGrantedAuthority> authorities = ((List<?>) claims.get("rol")).stream()
                        .map(auth -> new SimpleGrantedAuthority((String) auth))
                        .collect(Collectors.toList());

                // userId 값이 있는지 확인합니다.
                if(isNotEmpty(userId)){
                    Member member = new Member();
                    member.setUserNo(Long.parseLong(userNo));
                    member.setUserId(userId);
                    member.setUserPw("");

                    // 토큰에 있던 값들로 UserDetail 만듭니다.(비밀번호 값을 안넣는 이유는 이 메소드는 토큰이 존재하는 경우에 사용되기 때문이다.
                    // 토큰이 잘못되면 아래의 catch 와 같이 다 필터를 해준다.
                    UserDetails userDetail = new CustomUser(member, authorities);

                    // 바로 Authentication 으로 반환해도 되지만 구현 클래스인 UsernamePasswordAuthenticationToken 로 반환해준다.
                    return new UsernamePasswordAuthenticationToken(userDetail, null, authorities);
                }
            }catch (ExpiredJwtException exception){
                log.warn("Request to parse expired JWT : {} failed : {} ", tokenHeader, exception.getMessage());
            }catch (UnsupportedJwtException exception){
                log.warn("Request to parse unsupported JWT : {} failed : {}", tokenHeader, exception.getMessage());
            }catch (MalformedJwtException exception){
                log.warn("Request to parse invalid JWT : {} failed : {} ", tokenHeader, exception.getMessage());
            }catch (IllegalStateException exception){
                log.warn("Request to parse empty or null JWT : {} failed : {}", tokenHeader, exception.getMessage());
            }
        }
        return null;
    }

    private byte[] getSigningKey(){
        return shopProperties.getSecretKey().getBytes();
    }

    private boolean isNotEmpty(final CharSequence cs){
        return !isEmpty(cs);
    }

    private boolean isEmpty(final CharSequence cs){
        return cs == null || cs.length() ==0;
    }

    public boolean validateToken(String jwtToken){
        try{
            Jws<Claims> claims = Jwts.parserBuilder()
                    .setSigningKey(shopProperties.getSecretKey()).build().parseClaimsJws(jwtToken);
            return claims.getBody().getExpiration().after(new Date());
        }catch(ExpiredJwtException exception){
            log.error("Token Expired");
            return false;
        }catch (JwtException exception){
            log.error("Token Tampered");
            return false;
        }catch (NullPointerException exception){
            log.error("Token is null");
            return false;
        }catch(Exception e){
            return false;
        }
    }
}

잘못된 정보가 있다면 멘트 언제든 환영입니당.

profile
장생농씬가?

0개의 댓글