본문 바로가기

Backend/Spring

[Spring] Spring Security와 JWT로 구현하는 인증 시스템

Why?

  1. Spring Security
    - 강력한 인증 및 인가를 내장하여 로그인, 권한 검증, CSRF 보호, 세션 관리 등 여러 보안 기능을 쉽게 구현 가능하다.
    - 커스텀 필터, 핸들러 추가로 세밀한 보안 정책을 추가, 관리할 수 있다.
  2. Jwt
    - 서버 측에 별도의 세션 정보를 저장하지 않는 토큰 기반 인증 방식으로 서버 확장이 용이해짐.
    - Jwt 토큰 자체에 필요한 사용자 정보와 권한 정보 (Claim) 이 포함되어 있어, 별도의 데이터베이스 조회 없이 토큰만으로 인증 및   
       인가 처리가 가능
    - 여러 서버나 마이크로서비스 환경에서 인증 상태를 공유해야 할 때 중앙의 세션 저장소가 필요없으므로 간편하게 인증처리가 가능
    - 토큰에 서명(Signature)을 포함하여 변조 방지, 만료 시간(expiration)을 설정해 재사용 공격 방지

이 장점들을 결합해 강력하고 유연한 인증 및 인가 시스템을 구현할 수 있다. 데이터 흐름부터 보고 코드로 들어가보자.

 

How?

(Spring Security + Jwt) 흐름도

흐름을 간단하게 요약해보자면

1. JWT Token 발급 이전 로직

  • 첫 로그인 요청 : 사용자가 아이디와 비밀번호로 로그인 요청

  • 초기 인증 : Spring Security의 필터가 이 요청을 가로채고, AuthenticationManagerAuthenticationProvider를 통해 데이터베이스에서 사용자를 조회하여 인증을 진행

  • JWT Token 발급 : 인증이 성공하면, 서버가 사용자 정보를 기반으로 JWT 토큰을 생성하여 발급

 

2. JWT Token 발급 이후 로직

  • 요청 시 토큰 전송 : 사용자는 JWT 토큰을 포함해 API 요청

  • JWT 검증 : 서버는 JWT 토큰의 서명과 만료 시간을 확인하여 토큰의 유효성을 검증

  • 추가 DB 호출 불필요 : JWT가 자체적으로 사용자 식별자와 권한 등의 정보를 담고 있으므로, 일반적인 요청의 경우 추가적인 DB 호출 없이 인증이 이루어짐

  • 단, 주의사항 : 사용자 정보(예: 권한)가 변경된 경우, 토큰에 반영되지 않으므로 재인증이나 토큰 갱신 등의 추가 처리가 필요하다


1. build.gradle 의존성 주입

dependencies {
    // 인증 JWT + Security
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
}

2. SecurityConfig

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    
    // White List 정의로 손쉽게 요청 URL 제한이 가능하다.
    private final static String[] WHITELIST = {
            "/**"
    };

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable()) // csrf 설정
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> {
                    auth.requestMatchers(WHITELIST).permitAll()
                            .anyRequest().authenticated();
                })
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);


        return http.build();
    }
}

Security FilterChain 정의

- API 요청 URL 화이트리스트를 간단하게 정의하여 올바르지 않은 요청을 쉽게 필터링 가능하다. ( 임시로 모든 요청 허용 /** )

- JwtAuthenticationFilter (정의로 Jwt 인증 처리를 먼)

 

3. JwtAuthenticationFilter

@NoArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private JwtUtil jwtUtil;
    private MemberDetailService memberDetailService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        final String authHeader = request.getHeader("Authentication");
        String userEmail = null;
        String token = null;

        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            token = authHeader.substring(7);
            userEmail = jwtUtil.extractEmail(token);
        }

        if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            CustomMemberDetails userDetails = memberDetailService.loadUserByUsername(userEmail);
            if (jwtUtil.validateToken(token, userDetails.getEmail())) {
                UsernamePasswordAuthenticationToken authToken =
                        new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }

        filterChain.doFilter(request, response);
    }
}

- JwtAuthenticationFilter는 Token이 request Header에 존재 여부로 분기하여 처리한다.

1. Token이 있을때는 Token 정보를 기반으로 사용자 정보 인증

2. Token이 미존재 시 DB 조회를 통해 인증

 

CustomUserDetails

public class CustomMemberDetails implements UserDetails {
    private String email;
    private String password;
    private boolean enabled;
    private Collection<? extends GrantedAuthority> authorities;

    public CustomMemberDetails(String email, String password, boolean enabled, Collection<? extends GrantedAuthority> authorities) {
        this.email = email;
        this.password = password;
        this.enabled = enabled;
        this.authorities = authorities;
    }
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

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

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

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

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

 

UserDetailService

 public CustomMemberDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        Member user = userRepository.findUserByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));

        return new CustomMemberDetails(user.getEmail(), user.getPassword(), true, user.getAuthorities());
    }

- 실제 DB에서 사용자를 가져와서 CustomMemberDetails로 매핑

 

Jwt Filter 등록

@Configuration
public class FilterConfig {

    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() {
        return new JwtAuthenticationFilter();
    }
}

 

 

 

JWT Util 정의

public class JwtUtil {
    private Key secretKey;
    private long EXPIRATION_TIME; // 24시간

    public JwtUtil(@Value("${jwt.secret}") String secretKey,
                   @Value("${jwt.expiration}") long expirationTime) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.secretKey = Keys.hmacShaKeyFor(keyBytes);
        this.EXPIRATION_TIME = expirationTime;
    }


    public String generateToken(String email) {
        return Jwts.builder()
                .subject(email)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
                .signWith(secretKey)
                .compact();
    }

    public String extractEmail(String token) {
        return Jwts.parser()
                .verifyWith((SecretKey) secretKey)
                .build()
                .parseSignedClaims(token).getPayload().get("sub")
                .toString();
    }

    public boolean validateToken(String token, String email) {
        return email.equals(extractEmail(token)) && !isTokenExpired(token);
    }

    private boolean isTokenExpired(String token) {
        Claims claims = Jwts.parser()
                .verifyWith((SecretKey) secretKey)
                .build()
                .parseClaimsJws(token)
                .getBody();

        Date expiration = claims.getExpiration();
        return expiration.before(new Date());
    }
}

- 생성자에서 가져오는 application.yml에 선언한 Secret Key는 임시로 사용하는 키

- 배포시에는 서버에서 환경 변수로 설정하여 주입받거나 비밀 관리 시스템(예: Vault, AWS Secrets Manager)를 이용하는 것이 안전

 

일단 임시 키는 터미널 명령어로 만들어 넣어놓았습니다.

openssl rand -base64 32

 

application.yml 설정값

jwt:
  expiration : 86400000
  secret: 위 명령어로 만든 key를 입력

Test

 

class JwtTokenProviderTest {
    private JwtUtil jwtUtil;
    private final String secretKey = "uMWSG55ZD61gPsgB9U8PvTUVRXBG2wOvI/U70I++6DE=";
    private final long expirationTime = 3600000;

    @BeforeEach
    void setup() {
        jwtUtil = new JwtUtil(secretKey, expirationTime);
    }

    @Test
    void testGenerateAndExtractEmail() {
        String email = "test@example.com";
        String token = jwtUtil.generateToken(email);
        assertNotNull(token, "토큰 값이 있어야합니다.");

        String extractedEmail = jwtUtil.extractEmail(token);
        assertEquals(email, extractedEmail, "토큰 값에서 추출한 이메일이 같아야합니다.");
    }

    @Test
    void testValidateToken() {
        String email = "user@example.com";
        String token = jwtUtil.generateToken(email);

        // 올바른 이메일로 검증 시 true 반환
        assertTrue(jwtUtil.validateToken(token, email), "올바른 이메일입니다.");
        // 잘못된 이메일로 검증 시 false 반환
        assertFalse(jwtUtil.validateToken(token, "other@example.com"), "올바르지 않은 이메일입니다.");
    }

    @Test
    void testTokenExpiration() throws InterruptedException {
        // 만료시간을 1초로 설정한 JwtUtil 인스턴스 생성
        long shortExpiration = 1000; // 1초
        JwtUtil shortLivedJwtUtil = new JwtUtil(secretKey, shortExpiration);
        String email = "expire@example.com";
        String token = shortLivedJwtUtil.generateToken(email);

        // 2초 대기하여 토큰이 만료되도록 함
        Thread.sleep(2000);

        // 만료된 토큰은 validateToken()에서 false를 반환해야 함
        assertFalse(shortLivedJwtUtil.validateToken(token, email), "만료된 토큰입니다.");
    }
}