Spring Security [JWT 인증]

손정훈·2023년 3월 20일
0

토큰기반 인증 절차

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

JWT 생성

public class JwtTokenizer {
	public String encodeBase64SecretKey(String secretkey) {	// Secret Key의 byte[]를 Base64형식의 문자열로 인코딩
    	return Encoders.BASE64.encode(secretkey.getBytes(StandardCharsets.UTF_8));
    }
    
    public String generateAccessToken(Map<String, object> claims,	// 인증된 사용자에게 JWT를 최초로 발급해주기 위한 JWT 생성
    									String subject,
                                        Data expiration,
                                        String base64EncodedSecretKey) {
    	Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);	// Key 객체를 얻어옴
        
        return Jwts.builder()	
        		.setClaims(claims)	// JWT에 포함 시킬 Custom Claims를 추가
                .setSubject(subject)	// JWT에 대한 제목 추가
                .setIssuedAt(Calendar.getInstance().getTime())	// JWT 발행 일자 설정
                .setExpiration(expiration)	// JWT 만료일시 지정
                .signWith(key)	// 서명을 위한 Key 객체 설정
                .compact();	// JWT 생성 및 직렬화
    }
    
    public String generateRefreshToken(String subject, Date expiration, String base64EncodedSecretKey) {	// Access Token이 만료될 경우 Access Token 새로 생성
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);

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

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

        return key;
    }
}

JWT 적용을 위한 사전 작업

[ SecurityConfiguration 추가 ]

@Configuration
public class SecurityConfiguration {
	@Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    	http
        	.headers().frameOptions().sameOrigin()	// H2 개발활경 설정
            .and()
            .csrf().disable()	// csrf 공격에 대한 설정 비활성화
            .cors(withDefaults())	// CORS 설정 추가
            .formLogin().disable()	// 폼 로그인 방식 비활성화
            .httpBasic().disable()	// Http Basic 인증 비화성화
            .authorizeHttpRequests(authorize -> authorize	
            				.anyRequest().permitAll()	// HTTP request 요청에 대한 접급 허용
            );
        return http.build();
    }
    
    @Bean
    public PasswordEncoder passwordEncoder() {	// PasswordEncoder Bean 객체 생성
    	return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
    
    @Bean
    CorsConfigurationSource corsConfigurationSource() {	// CORS 정책 설정
    	CorsConfiguration configuration = CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("*"));	// 모든 출처에 대해 스크립트 기반의 HTTP 통신 허용
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE"));	// 파라미터로 지정한 HTTP Method에 대한 HTTP 통신 허용
        
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigyrationSource();	// CorsCofigurationSource 인터페이스 구현 클래스인 UrlBasedCorsConfigyrationSource 객체 생성
        source.registerCorsConfiguration("\/**", configuration);	// CORS 정책 적용
        return source;
    }
}

로그인 인증 정보 역직렬화를 위한 LoginDTO 클래스 생성

LoginDto

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

JwtTokenizer

// spring bean 등록
@Component
public class JwtTokenizer {
    @Getter
    @Value("${jwt.key}")
    private String secretKey;       // JWT 생성 및 검증 시 사용되는 Secret Key

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

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

    ...
    ...

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

        return expiration;
    }

    private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);
        Key key = Keys.hmacShaKeyFor(keyBytes);

        return key;
    }
}

application.yml

...
...
jwt:
	key: ${JWT_SECRET_KEY}		// 민감한 정보는 시스템 환경 변수에 로드
    access-token-expritation-minutes: 30
    refresh-token-expritation-minutes: 420

Custom Security Filter

public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {  // Username/Password 기반의 인증을 처리하기 위해 UsernamePasswordAuthenticationFilter 상속
    private final AuthenticationManager authenticationManager;
    private final JwtTokenizer jwtTokenizer;

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

    // 메서드 내부에서 인증을 시도하는 로직 구현
    @SneakyThrows
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {

        ObjectMapper objectMapper = new ObjectMapper();    // DTO 클래스로 역직렬화 하기 위해 ObjectMapper 인스턴스를 생성
        LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class); // 역직렬화

        // UsernamePasswordAuthenticationToken 생성
        UsernamePasswordAuthenticationToken authenticationToken =
                                                new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());

        return authenticationManager.authenticate(authenticationToken);  // UsernamePasswordAuthenticationToken을 전달하면서 인증처리
    }

    // 클라이언트의 인증 정보를 이용해 인증에 성공할 경우 호출
    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authResult) {
        Member member = (Member) authResult.getPrincipal();  // Member 엔티티 클래스의 객체를 얻음

        String accessToken = delegateAccessToken(member);   // Access Token 생성
        String refreshToken = delegateRefreshToken(member); // Refresh Token 생성

        response.setHeader("Authorization", "Bearer " + accessToken);  // respone header에 Access Token 추가
        response.setHeader("Refresh", refreshToken);                   // response header에 Refresh Token 추가
    }

    // 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;
    }
}

SecurityConfiguration

@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를 추가해 커스터마이징된  Configuration 추가
            .and()
            .authorizeHttpRequests(authorize -> authorize
                    .anyRequest().permitAll()
            );
        return http.build();
    }

    ...
    ...

    // 구현된 JwtAuthenticationFilter를 등록하는 역할
    public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> { 
        @Override
        public void configure(HttpSecurity builder) throws Exception {  // Configuration 커스터마이징
            AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);  // AuthenticationManager 객체를 얻어옴

            JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer);  // AuthenticationManager와 JwtTokenizer를 DI
            jwtAuthenticationFilter.setFilterProcessesUrl("/v11/auth/login");          // request URL인 /login을 /v11/auth/login으로 변경

            builder.addFilter(jwtAuthenticationFilter);  // JwtAuthenticationFilter를 Spring Security Filter Chain에 추가
        }
    }
}

0개의 댓글