WY J
학습 공간
WY J
  • 분류 전체보기 (95)
    • Java (38)
      • 알고리즘 (5)
      • 자료구조 (4)
      • 기초 (9)
      • OOP (10)
      • Collection (3)
      • Effective (5)
      • reator (2)
    • HTML&CSS (5)
    • macOS (3)
    • Git (5)
    • Network (5)
    • MySQL (2)
    • Spring Boot (31)
      • Core (5)
      • MVC (15)
      • Security (10)
    • 알고리즘 (1)
    • Cloud (3)
      • AWS (3)
    • Docker (1)
    • Project (0)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

인기 글

최근 글

hELLO · Designed By 정상우.
WY J

학습 공간

Spring Boot/Security

[Spring Security] JWT 로그인 인증, 자격 증명 구현

2022. 9. 28. 20:54

SecurityContfiguration 클래스

JWT를 적용하기 전에 Spring Security를 이용한 보안 강화를 위해 최소한의 보안 구성

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.Arrays;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
public class SecurityConfiguration {

    /** HttpSecurity 를 통해 HTTP 요청에 대한 보안 설정을 구성 */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        /** HttpSecurity 를 파라미터로 가진 SecurityFilterChain 을 리턴하여 HTTP 보안 설정 구성 */
        http
                .headers().frameOptions().sameOrigin() // H2 웹 콘솔을 정상적으로 사용할 수 있도록 동일 출처로부터 들어오는 request만 페이지 렌더링을 허용한다.
                .and()
                .csrf().disable() // CSRF 공격에 대한 Spring Security 설정을 비활성화(로컬 환경이므로 비활성화)
                .cors(withDefaults()) // 해당 메서드로 corsConfigurationSource 라는 이름으로 등록된 Bean을 이용한다.
                .formLogin().disable() // CSR에서 주로 사용되는 JSON 포맷으로 Username/Password를 전송하는 방식을 사용할 것이므로 폼 로그인을 비활성화 한다.
                .httpBasic().disable() // HTTP Basic 인증은 request를 전송할 때 마다 Username/Password를 전송하는 방식이므로 비활성화 한다.
                .authorizeHttpRequests(authorize -> authorize
                        .anyRequest().permitAll() // JWT를 적용하기 전이므로 모든 HTTP request에 대한 접근을 허용한다.
                );

        return http.build();
    }

    /** PasswordEncoder Bean 객체를 생성 */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    /** CorsConfigurationSource Bean 객체를 생성 */
    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("*")); // 모든 출처(Origin)에 대해 스크립트 기반의 HTTP 통신을 허용하도록 설정한다.
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE")); // 해당 HTTP Method들에 대한 HTTP 통신을 허용한다.

        /** CorsConfigurationSource 인터페이스의 구현 클래스인 UrlBasedCorsConfigurationSource 클래스의 객체를 생성한다. */
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration); // 모든 URL에 앞에서 구성한 CORS 정책을 적용한다.

        return source;
    }
}

 

CORS(Cross-Origin Resource Sharing)
애플리케이션 간에 출처(Origin)가 다를 경우 스크립트 기반의 HTTP 통신(XMLHttpRequest, Fetch API)을 통한 리소스 접근이 제한되는데, CORS는 출처가 다른 스크립트 기반 HTTP 통신을 하더라도 선택적으로 리소스에 접근할 수 있는 권한을 부여하도록 한다.

JWT 자격 증명을 위한 로그인 인증 구현

사용자의 Username과 Password로 로그인 인증에 성공하면 로그인 인증에 성공한 사용자에게 JWT를 생성 및 발급한다.

 

로그인 인증 흐름

  1. 클라이언트가 서버 측에 로그인 인증 요청(Username/Password를 서버에 전송)
  2. 로그인 인증을 담당하는 Security Filter(JwtAuthenticationFilter)가 클라이언트의 로그인 인증 정보 수신
  3. Security Filter가 수신한 로그인 인증 정보를 AuthenticationManager에게 전달해 인증 처리를 위임
  4. AuthenticationManager가 Custom UserDetailsService(MemberDetailsService)에게 사용자의 UserDetails 조회를 위임
  5. Custom UserDetailsService(MemberDetailsService)가 사용자의 크리덴셜을 DB에서 조회한 후, AuthenticationManager에게 사용자의 UserDetails를 전달
  6. AuthenticationManager가 로그인 인증 정보와 UserDetails의 정보를 비교해 인증 처리
  7. JWT 생성 후, 클라이언트의 응답으로 전달

 

1. Custom UserDetailsService 구현

데이터베이스에서 사용자의 크리덴셜을 조회한 후, 조회한 크리덴셜을 AuthenticationManager에게 전달하는 Custom UserDetailsService 구현

@Component
public class MemberDetailsService implements UserDetailsService { // UserDetailsService 인터페이스 구현
    private final MemberRepository memberRepository;
    private final CustomAuthorityUtils authorityUtils;

    public MemberDetailsService(MemberRepository mmemberRepository, CustomAuthorityUtils authorityUtils) {
        this.memberRepository = mmemberRepository;
        this.authorityUtils = authorityUtils;
    }

    /**
     * UserDetailsService를 구현하는 클래스는 UserDetails 타입의 loadUserByUsername 추상 메서드를 구현해야 한다.
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<Member> optionalMember = memberRepository.findByEmail(username);
        Member findMember = optionalMember.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));

        return new MemberDetails(findMember);
    }

    /**
     * Member를 상속받고 UserDetails를 구현함으로써, 데이터베이스에서 조회한 정보를 사용하여
     * Spring Security의 User 정보로 변환하는 과정과 User의 권한 정보를 생성하는 과정을 캡슐화 할 수 있다.
     */
    private final class MemberDetails extends Member implements UserDetails {
        MemberDetails(Member member) {
            setMemberId(member.getMemberId());
            setEmail(member.getEmail());
            setPassword(member.getPassword());
            setRoles(member.getRoles());
        }

        /** DB에 저장된 Role 정보로 User 권한 목록 생성 */
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            return authorityUtils.createAuthorities(this.getRoles());
        }

        @Override
        public String getUsername() {
            return getEmail();
        }

        @Override
        public boolean isAccountNonExpired() {
            return true;
        }

        @Override
        public boolean isAccountNonLocked() {
            return true;
        }

        @Override
        public boolean isCredentialsNonExpired() {
            return true;
        }

        @Override
        public boolean isEnabled() {
            return true;
        }
    }
}

 

2. LoginDTO 클래스 생성

LoginDTO 클래스는 클라이언트가 전송한 Username/Password 정보를 Security Filter에서 사용할 수 있도록 역직렬화(Deserialization)하기 위한 DTO 클래스이다.

@Getter
public class LoginDto {
    private String username;
    private String password;
}

 

3. JwtTokenizer 클래스 생성

JwtTokenizer 클래스는 로그인 인증에 성공한 클라이언트에게 JWT를 생성 및 발급하고 클라이언트의 요청이 들어올 때마다 전달된 JWT를 검증하는 역할을 한다.

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 lombok.Getter;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Value;

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


@Component // Bean 객체로 등록
public class JwtTokenizer {
    @Getter
    @Value("${jwt.secret-key}")
    private String secretKey; // JWT 생성 및 검증 시 사용되는 Secret Key 정보. application.yml에서 load

    @Getter
    @Value("${jwt.access-token-expiration-minutes}")
    private int accessTokenExpirationMinutes; // Access Token 만료 시간 정보. application.yml에서 load

    @Getter
    @Value("${jwt.refresh-token-expiration-minutes}")
    private int refreshTokenExpirationMinutes; // Refresh Token 만료 시간 정보. application.yml에서 load

    /** Plain Text 형태인 Secret Key의 byte[]를 Base64 형식의 문자열로 인코딩 해주는 메서드 */
    public String encodeBase64SecretKey(String secretKey) {
        return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
    }

    /** 인증된 사용자에게 JWT를 최초로 발급해주기 위한 JWT 생성 메서드 */
    public String generateAccessToken(Map<String, Object> claims,
                                      String subject,
                                      Date expiration,
                                      String base64EncodedSecretKey) {

        // Base64 형식 Secret Key 문자열을 이용해 Key 객체를 얻는다.
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);

        return Jwts.builder()
                .setClaims(claims) // JWT에 포함 시킬 Custom Claims(인증된 사용자의 정보)를 추가
                .setSubject(subject) // JWT에 대한 제목 추가
                .setIssuedAt(Calendar.getInstance().getTime()) // JWT의 발행 일자 설정
                .setExpiration(expiration) // JWT 만료 일시 지정
                .signWith(key) // 서명을 위한 Key 객체 설정
                .compact(); // JWT를 생성하고 직렬화
    }

    /** Access Token이 만료되었을 때, 새로 생성할 수 있게 해주는 Refresh Token을 생성하는 메서드 */
    public String generateRefreshToken(String subject,
                                       Date expiration,
                                       String base64EncodedSecretKey) {
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);

        return Jwts.builder()
                .setSubject(subject)
                .setIssuedAt(Calendar.getInstance().getTime())
                .setExpiration(expiration)
                .signWith(key)
                .compact();
    }

    public Jws<Claims> getClaims(String jws, String base64EncodedSecretKey) {
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);

        Jws<Claims> claims = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(jws);
        return claims;
    }

    public void verifySignature(String jws, String base64EncodedSecretKey) {
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);

        Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(jws);
    }

    /** JWT 만료 일시를 지정하기 위한 메서드 */
    public Date getTokenExpiration(int expirationMinutes) {
        Calendar calendar = Calendar.getInstance();
        calendar.add(Calendar.MINUTE, expirationMinutes);
        Date expiration = calendar.getTime();

        return expiration;
    }

    /** JWT 서명에 사용할 Secret Key를 생성하는 메서드 */
    private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey); // Base64 형식으로 인코딩 된 Secret Key를 디코딩한 후 byte array로 생성
        Key key = Keys.hmacShaKeyFor(keyBytes); // key byte array를 기반으로 HMAC 알고리즘을 적용한 Key 객체를 생성한다.

        return key;
    }
}

 

4. application.yml

jwt:
  secret-key: ${JWT_SECRET_KEY}
  access-token-expiration-minutes: 30 // Access Token 만료 시간
  refresh-token-expiration-minutes: 420 // Refresh Token 만료 시간

 

5. 로그인 인증 요청을 처리하는 Custom Security Filter 구현

클라이언트의 로그인 인증 정보를 직접적으로 수신하여 인증 처리의 엔트리포인트 역할을 한다.

 

import com.codestates.auth.dto.LoginDto;
import com.codestates.auth.jwt.JwtTokenizer;
import com.codestates.member.entity.Member;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.*;

/**
 * UsernamePasswordAuthenticationFilter는 폼로그인 방식에서 사용하는 디폴트 Security Filter로써,
 * 폼로그인이 아니더라도 Username/Password 기반의 인증을 처리하기 위해 상속하여 구현할 수 있다.
 */
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final AuthenticationManager authenticationManager;
    private final JwtTokenizer jwtTokenizer;

    /**
     * AuthenticationManager는 로그인 인증 정보를 전달 받고, UserDetailsService와 인터랙션한 뒤 인증 여부를 판단하는 역할을 한다.
     * JwtTokenizer는 클라이언트가 인증에 성공할 경우, JWT를 생성 및 발급하는 역할을 한다.
     */
    public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtTokenizer jwtTokenizer) {
        this.authenticationManager = authenticationManager;
        this.jwtTokenizer = jwtTokenizer;
    }

    /**
     * attempAuthentication() 메서드는 인증 시도하는 로직을 구현한다.
     */
    @SneakyThrows
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {

        // 클라이언트에서 전송한 로그인 정보를 DTO 클래스로 역직렬화하기 위한 ObjectMapper 인스턴스를 생성한다.
        ObjectMapper objectMapper = new ObjectMapper();
        // ServletInputStream을 LoginDto 클래스의 객체로 역직렬화 한다.
        LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class);

        // 역직렬화한 User 정보를 포함한 authenticationToken 객체 생성
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());

        // UsernamePasswordAuthenticationToken을 AuthenticationManager에게 전달하면서 인증 처리를 위임한다.
        return authenticationManager.authenticate(authenticationToken);
    }

    /** 클라이언트의 인증 정보를 이용해 인증에 성공할 경우 호출되는 메서드 */
    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authResult) throws ServletException, IOException {
        Member member = (Member) authResult.getPrincipal();  // 해당 메서드로 Member 엔티티 객체를 얻는다.

        String accessToken = delegateAccessToken(member);   // 해당 메서드로 Access Token을 생성
        String refreshToken = delegateRefreshToken(member); // 해당 메서드로 Refresh Token을 생성

        response.setHeader("Authorization", "Bearer " + accessToken);
        response.setHeader("Refresh", refreshToken);

        this.getSuccessHandler().onAuthenticationSuccess(request, response, authResult);
    }

    /** Access Token을 생성하는 메서드*/
    private String delegateAccessToken(Member member) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", member.getEmail());
        claims.put("roles", member.getRoles());

        String subject = member.getEmail();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());

        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);

        return accessToken;
    }

    /** Refresh Token을 생성하는 메서드 */
    private String delegateRefreshToken(Member member) {
        String subject = member.getEmail();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);

        return refreshToken;
    }
}

 

6. CustomFilter 추가를 위한 SecurityConfiguration 설정 추가

CustomFilter를 Filter Chain에 추가하여 로그인 인증을 처리하도록 구현

import com.codestates.auth.filter.JwtAuthenticationFilter;
import com.codestates.auth.jwt.JwtTokenizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.Arrays;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
public class SecurityConfiguration {
    private final JwtTokenizer jwtTokenizer;

    public SecurityConfiguration(JwtTokenizer jwtTokenizer) {
        this.jwtTokenizer = jwtTokenizer;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .headers().frameOptions().sameOrigin()
                .and()
                .csrf().disable()
                .cors(withDefaults())
                .formLogin().disable()
                .httpBasic().disable()
                .apply(new CustomFilterConfigurer()) // Custom Configurer를 apply 하여 사용
                .and()
                .authorizeHttpRequests(authorize -> authorize
                        .anyRequest().permitAll()
                );

        return http.build();
    }

	...

    /** JwtAuthenticationFilter를 등록하는 역할을 하는 Custom Configurer 클래스.
     *  HttpSecurityBuilder를 상속하는 AbstractHttpConfigurer를 상속함으로써 해당 클래스 타입들을 제네릭 타입으로 지정할 수 있다.*/
    public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
        public void configure(HttpSecurity builder) throws Exception { // configure()를 오버라이드하여 Configuration을 커스텀할 수 있다.

            // getSharedObject()를 통해 Spring Security 설정을 구성하는 AuthenticationManager의 객체를 얻을 수 있다.
            AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
            // JwtAuthenticationFilter 객체를 생성하고 AuthenticationManager와 JwtTokenizer를 DI 해준다.
            JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer);
            // setFilterProcessesUrl() 메서드로 디폴트 request URL인 /login을 다른 URL로 변경한다.
            jwtAuthenticationFilter.setFilterProcessesUrl("/v11/auth/login");

            // addFilter() 메서드로 JwtAuthenticationFilter를 Filter Chain에 추가한다.
            builder.addFilter(jwtAuthenticationFilter);
        }
    }
}

로그인 인증 성공 및 실패에 따른 추가 처리

Spring Security에서는 Username/Password 기반의 로그인 인증에 성공했을 때, 로그를 기록한다거나 로그인에 성공한 사용자 정보를 response로 전송하는 등의 추가 처리를 할 수 있는 핸들러(AuthenticationSuccessHandler)를 지원하며, 로그인 인증 실패 시에도 마찬가지로 인증 실패에 대해 추가 처리를 할 수 있는 핸들러(AuthenticationFailureHandler)를 지원한다.

 

1. AuthenticationSuccessHandler 구현

로그인 인증 성공 시 추가 작업을 할 수 있는 MemberAuthenticationSeccessHandler 클래스

 

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Slf4j // logging 추상 레이어 인터페이스 모음
@Component // Bean 객체로 등록
public class MemberAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    /** onAuthenticationSuccess() 메서드를 오버라이드하여 기능 추가 */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        log.info("# Authenticated successfully!");
    }
}

 

2. AuthenticationFailureHandler 구현

로그인 인증 실패 시 추가 작업을 할 수 있는 MemberAuthenticationFailureHandler 클래스

 

import com.codestates.response.ErrorResponse;
import com.google.gson.Gson;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Slf4j
public class MemberAuthenticationFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {

        log.error("# Authentication failed: {}", exception.getMessage());

        sendErrorResponse(response); // 해당 메서드를 호출하여 출력 스트림에 Error 정보를 담는다.
    }

    private void sendErrorResponse(HttpServletResponse response) throws IOException {
        Gson gson = new Gson(); // Error 정보를 담아 JSON으로 변환해줄 객체

        ErrorResponse errorResponse = ErrorResponse.of(HttpStatus.UNAUTHORIZED); // ErrorResponse.of() 메서드로 상태 코드를 담는다.

        response.setContentType(MediaType.APPLICATION_JSON_VALUE); // Content Type을 HTTP Header에 추가
        response.setStatus(HttpStatus.UNAUTHORIZED.value()); // response의 Status를 HTTP Header에 추가
        response.getWriter().write(gson.toJson(errorResponse, ErrorResponse.class)); // Gson으로 ErrorResponse를 JSON으로 변환 후, 출력 스트림 생성
    }
}

 

3. SecurityConfiguration에 AuthenticationSuccessHandler/FailureHandler 기능 추가

...
import com.codestates.auth.handler.MemberAuthenticationFailureHandler;
import com.codestates.auth.handler.MemberAuthenticationSuccessHandler;

import java.util.Arrays;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
public class SecurityConfiguration {

	...

    public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {

        @Override
        public void configure(HttpSecurity builder) throws Exception { // configure()를 오버라이드하여 Configuration을 커스텀할 수 있다.

            AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
            JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer);
            jwtAuthenticationFilter.setFilterProcessesUrl("/v11/auth/login");

			// jwtAuthenticationFilter에 추가하여 사용한다.
            jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler());
            jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler());

            builder.addFilter(jwtAuthenticationFilter);
        }
    }
}

DI를 받지 않는 이유는, 일반적으로 인증을 위한 Security Filter마다 AuthenticationSuccessHandler와 AuthenticationFailureHandler의 구현 클래스를 각각 생성할 것이므로 new 키워드를 사용해서 객체를 생성해도 무방하기 때문이다.

 

로그인 인증 기능은 OncePerRequestFilter 같은 Filter를 이용해서 구현할 수도 있으며, Controller에서 API 엔드포인트로 구현하는 방법도 많이 사용하는 방법이다.


JWT를 이용한 자격 증명 및 검증 기능 구현

 

1. JwtVerificationFilter

JWT를 검증하는 Security Filter 역할의 JwtVerificationFilter 클래스 구현

 

import com.codestates.auth.jwt.JwtTokenizer;
import com.codestates.auth.utils.CustomAuthorityUtils;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.Map;

/** OncePerRequestFilter를 상속하여 request 당 한번만 실행되는 Security Filter를 구현 */
public class JwtVerificationFilter extends OncePerRequestFilter {
    private final JwtTokenizer jwtTokenizer;
    private final CustomAuthorityUtils authorityUtils;

    /** JwtTokenizer는 Claims를 얻는데 사용하고,
     * CustomAuthorityUtils는 JWT 검증에 성공하면 Authentication 객체에 채울 사용자의 권한을 생성하는데 사용 */
    public JwtVerificationFilter(JwtTokenizer jwtTokenizer,
                                 CustomAuthorityUtils authorityUtils) {
        this.jwtTokenizer = jwtTokenizer;
        this.authorityUtils = authorityUtils;
    }

    /**  */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        Map<String, Object> claims = verifyJws(request); // 해당 메서드로 JWT를 검증
        setAuthenticationToContext(claims); // 해당 메서드로 Authentication 객체를 SecurityContext에 저장

        filterChain.doFilter(request, response); // 두개의 메서드로 JWT 서명 검증에 성공하면 다음 Security Filter를 호출한다.
    }

    /** OncePerRequestFilter의 shouldNotFilter() 메서드 오버라이드 
     * 조건에 true이면 해당 Filter의 동작을 수행하지 않고 다음 Filter로 건너뛴다. */
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        String authorization = request.getHeader("Authorization"); // request의 Header에서 Authorization을 얻는다.

        // JWT가 Authorization header에 포함되지 않았다면, JWT 자격증명이 필요하지 않은 리소스에 대한 요청으로 판단하고 다음 Filter로 넘긴다.
        return authorization == null || !authorization.startsWith("Bearer");
    }

    /** JWT를 검증하는데 사용하는 메서드 */
    private Map<String, Object> verifyJws(HttpServletRequest request) {
        // request의 Header에서 JWT를 얻는다. 서명된 JWT를 JWS(JSON Web Token Signed)라고 부르기 때문에 변수명을 jws로 지정했다.
        String jws = request.getHeader("Authorization").replace("Bearer ", "");
        // JWT 서명(Signature)을 검증하기 위한 Secret Key를 얻는다.
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
        // JWT에서 Claims를 파싱한다. (서명 검증 성공했다는 의미)
        Map<String, Object> claims = jwtTokenizer.getClaims(jws, base64EncodedSecretKey).getBody();

        return claims;
    }

    /** Authentication 객체를 SecurityContext에 저장하기 위한 메서드 */
    private void setAuthenticationToContext(Map<String, Object> claims) {
        // JWT에서 파싱한 Claims에서 username을 얻는다.
        String username = (String) claims.get("username");
        // JWT의 Claims에서 얻은 권한 정보를 기반으로 List<GrantedAhority>를 생성한다.
        List<GrantedAuthority> authorities = authorityUtils.createAuthorities((List)claims.get("roles"));
        // username과 List<GrantedAuthority>를 포함한 Authentication 객체를 생성한다.
        Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);
        // SpringContext에 Authentication 객체를 저장한다.
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
}

 

2. SecurityConfiguration 설정 업데이트

JwtVerificationFilter를 사용하기 위해 SecurityConfiguration에 세션 정책 설정과 JwtVerificationFilter를 추가한다.

JwtVerificationFilter에서 JWT의 자격 검증에 성공하게 되면 인증된 Authentication 객체를 SecurityContext에 저장한다.

추가로 stateless한 애플리케이션을 유지하기 위해 세션 유지 시간을 아주 짧게 설정할 필요가 있다.

 

import com.codestates.auth.filter.JwtAuthenticationFilter;
import com.codestates.auth.filter.JwtVerificationFilter;
import com.codestates.auth.handler.MemberAuthenticationFailureHandler;
import com.codestates.auth.handler.MemberAuthenticationSuccessHandler;
import com.codestates.auth.jwt.JwtTokenizer;
import com.codestates.auth.utils.CustomAuthorityUtils;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.Arrays;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
public class SecurityConfiguration {
    private final JwtTokenizer jwtTokenizer;
    private final CustomAuthorityUtils authorityUtils;

    public SecurityConfiguration(JwtTokenizer jwtTokenizer, CustomAuthorityUtils authorityUtils) {
        this.jwtTokenizer = jwtTokenizer;
        this.authorityUtils = authorityUtils;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .headers().frameOptions().sameOrigin()
                .and()
                .csrf().disable()
                .cors(withDefaults())
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 해당 메서드로 세션을 생성하지 않도록 설정
                .and()
                .formLogin().disable()
                .httpBasic().disable()
                .apply(new CustomFilterConfigurer())
                .and()
                .authorizeHttpRequests(authorize -> authorize
                        .anyRequest().permitAll()
                );

        return http.build();
    }

	...

    public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {

        @Override
        public void configure(HttpSecurity builder) throws Exception {

            AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);

            JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer);
            jwtAuthenticationFilter.setFilterProcessesUrl("/v11/auth/login");

            jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler());
            jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler());

            // JwtAuthenticationFilter 인스턴스를 생성하면서 여기에 사용되는 객체들을 DI 해준다.
            JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer, authorityUtils);

            builder
                    .addFilter(jwtAuthenticationFilter)
                    .addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class); // jwtAuthenticationFilter가 수행된 이후 동작하도록 추가
        }
    }
}

 

3. 서버 측 리소스에 역할(Role) 기반 권한 적용

로그인 인증 후, JWT 발급과 클라이언트의 자격 증명에 대한 검증을 마친 뒤 Spring Security에서 서버 측 리소스에 접근 권한을 설정해주어야 한다.

 

@Configuration
public class SecurityConfiguration {

	...
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .headers().frameOptions().sameOrigin()
                .and()
                .csrf().disable()
                .cors(withDefaults())
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .formLogin().disable()
                .httpBasic().disable()
                .apply(new CustomFilterConfigurer())
                .and()
                .authorizeHttpRequests(authorize -> authorize
                        .antMatchers(HttpMethod.POST, "/*/members").permitAll()         // 회원가입은 누구나 접근 가능
                        .antMatchers(HttpMethod.PATCH, "/*/members/**").hasRole("USER")  // 회원정보 수정은 USER만 접근 가능
                        .antMatchers(HttpMethod.GET, "/*/members").hasRole("ADMIN")     // 모든 회원 정보는 ADMIN만 접근 가능
                        .antMatchers(HttpMethod.GET, "/*/members/**").hasAnyRole("USER", "ADMIN")  // 개인 정보는 USER와 ADMIN 접근 가능
                        .antMatchers(HttpMethod.DELETE, "/*/members/**").hasRole("USER")  // 회원정보 삭제는 USER만 가능
                        .anyRequest().permitAll()
                );

        return http.build();
    }

	...
    
}

예외 처리

1. JwtVerificationFilter에 예외 처리 로직 추가

JwtVerificationFilter 의 경우, 클라이언트로부터 전달 받은 JWT의 Claims를 얻는 과정에서 내부적으로 JWT에 대한 서명(Signature)을 검증한다.

 

하지만 현재 JwtVerificationFilter 에서는 JWT에 대한 서명(Signature) 검증에 실패할 경우 throw되는 SignatureException에 대해서 어떤 처리도 하지 않고 있다.

 

JWT 검증 과정에서 발생할 수 있는 Exception을 처리할 수 있는 예외 처리 로직을 JwtVerificationFilter에 추가해 보도록 한다.

import com.codestates.auth.jwt.JwtTokenizer;
import com.codestates.auth.utils.CustomAuthorityUtils;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.security.SignatureException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.Map;

/** OncePerRequestFilter를 상속하여 request 당 한번만 실행되는 Security Filter를 구현 */
public class JwtVerificationFilter extends OncePerRequestFilter {
    private final JwtTokenizer jwtTokenizer;
    private final CustomAuthorityUtils authorityUtils;

    /** JwtTokenizer는 Claims를 얻는데 사용하고,
     * CustomAuthorityUtils는 JWT 검증에 성공하면 Authentication 객체에 채울 사용자의 권한을 생성하는데 사용 */
    public JwtVerificationFilter(JwtTokenizer jwtTokenizer,
                                 CustomAuthorityUtils authorityUtils) {
        this.jwtTokenizer = jwtTokenizer;
        this.authorityUtils = authorityUtils;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            Map<String, Object> claims = verifyJws(request); // verifyJws() 메서드로 JWT를 검증
            setAuthenticationToContext(claims); // setAuthenticationToContext() 메서드로 Authentication 객체를 SecurityContext에 저장
        } catch (SignatureException se) { // exception이 catch되면 해당 exception을 HttpServletRequest의 Attribute로 추가한다.
            request.setAttribute("exception", se);
        } catch (ExpiredJwtException ee) {
            request.setAttribute("exception", ee);
        } catch (Exception e) {
            request.setAttribute("exception", e);
        }

        filterChain.doFilter(request, response); // 두개의 메서드로 JWT 서명 검증에 성공하면 다음 Security Filter를 호출한다.
    }

    /** OncePerRequestFilter의 shouldNotFilter() 메서드 오버라이드
     * 조건에 true이면 해당 Filter의 동작을 수행하지 않고 다음 Filter로 건너뛴다. */
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        String authorization = request.getHeader("Authorization"); // request의 Header에서 Authorization을 얻는다.

        // JWT가 Authorization header에 포함되지 않았다면, JWT 자격증명이 필요하지 않은 리소스에 대한 요청으로 판단하고 다음 Filter로 넘긴다.
        return authorization == null || !authorization.startsWith("Bearer");
    }

    /** JWT를 검증하는데 사용하는 메서드 */
    private Map<String, Object> verifyJws(HttpServletRequest request) {
        // request의 Header에서 JWT를 얻는다. 서명된 JWT를 JWS(JSON Web Token Signed)라고 부르기 때문에 변수명을 jws로 지정했다.
        String jws = request.getHeader("Authorization").replace("Bearer ", "");
        // JWT 서명(Signature)을 검증하기 위한 Secret Key를 얻는다.
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
        // JWT에서 Claims를 파싱한다. (서명 검증 성공했다는 의미)
        Map<String, Object> claims = jwtTokenizer.getClaims(jws, base64EncodedSecretKey).getBody();

        return claims;
    }

    /** Authentication 객체를 SecurityContext에 저장하기 위한 메서드 */
    private void setAuthenticationToContext(Map<String, Object> claims) {
        // JWT에서 파싱한 Claims에서 username을 얻는다.
        String username = (String) claims.get("username");
        // JWT의 Claims에서 얻은 권한 정보를 기반으로 List<GrantedAhority>를 생성한다.
        List<GrantedAuthority> authorities = authorityUtils.createAuthorities((List)claims.get("roles"));
        // username과 List<GrantedAuthority>를 포함한 Authentication 객체를 생성한다.
        Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);
        // SpringContext에 Authentication 객체를 저장한다.
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
}

JwtVerificationFilter 예외 처리의 키포인트는 일반적인 예외 처리 방식과는 다르게 Exception을 catch한 후에 Exception을 다시 throw 하지 않고, 단순히 request.setAttribute()를 설정하는 일 밖에 하지 않는다는 것이다.

예외가 발생하게되면 SecurityContext에 클라이언트의 인증 정보(Authentication 객체)가 저장되지 않게 된다. SecurityContext에 클라이언트의 인증 정보(Authentication 객체)가 저장되지 않은 상태로 다음(next) Security Filter 로직을 수행하다보면 결국에는 AuthenticationException 이 발생하게 되고, 이 AuthenticationException은 AuthenticationEntryPoint가 처리하게 된다.

'Spring Boot > Security' 카테고리의 다른 글

[Spring Security] OAuth2 인증(Authentication) 구현  (0) 2022.09.29
[Spring Security] OAuth2란?  (1) 2022.09.29
[Spring Security] JWT 인증(JSON Web Token Authentication)  (0) 2022.09.26
[Spring Security] 권한 부여(Authorization) 구성 요소  (0) 2022.09.26
[Spring Security] 인증(Authentication) 구성 요소  (0) 2022.09.22
    'Spring Boot/Security' 카테고리의 다른 글
    • [Spring Security] OAuth2 인증(Authentication) 구현
    • [Spring Security] OAuth2란?
    • [Spring Security] JWT 인증(JSON Web Token Authentication)
    • [Spring Security] 권한 부여(Authorization) 구성 요소
    WY J
    WY J

    티스토리툴바