Spring Boot JWT + Spring Security 01

Kang.__.Mingu·2025년 1월 13일

Spring Boot

목록 보기
7/8

JWT(Json Web Token)

  • 로그인 인증 정보를 토큰에 담아서 서버가 아닌 클라이언트(브라우저)가 보관하도록하는 방식이다.
  • 기존 방식(Form Login): 서버에서 세션을 관리
  • JWT 방식: 서버는 토큰만 발급하고, 클라이언트가 이를 저장 -> 서버는 상태를 유지하지 않음(무상태)

라이브러리 추가(Gradle)

// JWT 핵심 API
// jjwt-api -> JWT 토큰 생성 및 파싱 API
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'

// JWT 내부 구현체 (필수)
// jjwt-impl -> JWT 내부 동작 구현체
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'

// JSON 처리 (Jackson 기반)
// jjwt-jackson -> JWT에서 아용할 JSON 변환 처리 (Jackson 기반)
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'

// Spring Security 추가 되어있어야 됨

JWT 인증 방식 전체 흐름

✅ 기존 Form Login 방식

  1. 사용자가 아이디/비밀번호 입력
  2. 서버가 DB에서 사용자 정보를 조회
  3. 세션(Session)을 생성해서 사용자 정보를 저장
  4. 이후 요청 시 세션 ID로 인증

✅ JWT 인증 방식

  1. 사용자가 아이디/비밀번호 입력
  2. 서버가 사용자 정보를 확인한 후 JWT 토큰 발급
  3. 클라이언트가 토큰을 저장 (LocalStorage, SessionStorage, Cookie)
  4. 이후 요청마다 헤더에 토큰을 담아 서버에 전송
  5. 서버는 토큰을 검증하고 요청 처리

🔥 차이점

  • Form Login → 서버가 세션을 관리
  • JWT → 서버가 세션 없이 토큰만 발급하고, 클라이언트가 토큰을 관리 (Stateless)

JWT 구조 이해

JWT (Json Web Token)는 3부분으로 나뉩니다.

[Header].[Payload].[Signature]

✅ 구조

  1. Header (헤더) → 토큰의 타입과 알고리즘 정보
  2. Payload (내용) → 사용자 정보(id, role) 등 데이터
  3. Signature (서명) → 시크릿 키로 암호화한 서명

✅ 예시

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.   // Header
eyJzdWIiOiJ1c2VyMSIsInJvbGUiOiJVU0VSIn0.  // Payload
Rk8lwXwFErvRtL6bZ3eQwzYlQa0Ym4Dlybkj8oxAkgQ  // Signature

JWT 서비스 구현 단계

  1. JWT 설정 파일 만들기(JwtProperties)
  2. JWT 토큰 생성 및 검증 클래스 만들기(TokenProvider)
  3. Spring Security 설정 변경하기
  4. JWT 필터 추가 -> 요청마다 토큰을 검사하도록 필터 연결
  5. 로그인 시 JWT 토큰 발급

🔧 1단계: JWT 설정 파일 만들기

시크릿 키나 토큰 만료 시간을 관리하는 설정 파일

JwtProperties.java

@Getter
@Component
@ConfigurationProperties("jwt")
public class JwtProperties {
    private String issuer;      // 토큰 발급자
    private String secretKey;   // 시크릿 키
}

application.properties 또는 application.yml

# JWT 설정
jwt.issuer=원하는 이름
jwt.secret-key=원하는 키
  • issuer: 토큰 발급자(이메일, 이름 등)
  • secret-key: 토큰을 서명할 비밀 키(가장 중요함!!!!!!!!!!!!!)

secret-key가 중요한 이유는 해당 키를 뺏기는 순간 모든 권한이 넘어간다

맥북 기준 터미널에 해당 명령어 입력하면 토큰의 암복호화에 사용될 랜덤 암호가 나온다.
HS256 알고리즘을 사용하기 위해 32글자 이상으로 설정

openssl rand -hex 32

윈도우라면 새로고침 될 때마다 랜덤으로 키를 던저주는 사이트가 있음
https://randomkeygen.com/
사이트에 들어가 codelgniter Encryption Keys 부분 중 아무거나 사용하면 됨

🔧 2단계: JWT 토큰 생성 및 검증

TokenProvider.java

@RequiredArgsConstructor
@Service
public class TokenProvider {
    private final JwtProperties jwtProperties;

    /* 토큰 생성 */
    public String generateToken(UsersDTO usersDTO, Duration expiration) {
        Date now = new Date();
        return makeToken(new Date(now.getTime() + expiration.toMillis()), usersDTO);
    }

    /* 실제 토큰 생성 로직 */
    private String makeToken(Date expiry, UsersDTO usersDTO) {
        SecretKey key = Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8));

        return Jwts.builder()
                .setIssuer(jwtProperties.getIssuer())      // 발급자
                .setIssuedAt(new Date())                   // 발급 시간
                .setExpiration(expiry)                     // 만료 시간
                .setSubject(usersDTO.getUserid())          // 사용자 ID
                .signWith(key, SignatureAlgorithm.HS256)   // 서명
                .compact();
    }

    /* 토큰 유효성 검사 */
    public boolean validToken(String token) {
        try {
            SecretKey key = Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8));

            Jwts.parser()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token);

            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }

    /* 인증 객체 반환 */
    public Authentication getAuthentication(String token) {
        Claims claims = getClaims(token);
        Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));

        return new UsernamePasswordAuthenticationToken(claims.getSubject(), null, authorities);
    }

    /*토큰에서 Claims 추출*/
    private Claims getClaims(String token) {
        SecretKey key = Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8));

        return Jwts.parser()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
}

jjwt 라이브러리 Jwts.parse(), Jwts.parserBuilder()

0.12.* 버전부터는 parserBuilder() 이걸 사용하라고 되어있는데 아무리해도 찾을 수 없어서
Jwts 들어가서 관련된 메서드 찾아보니까

public static JwtParserBuilder parser() {
return (JwtParserBuilder)Classes.newInstance("io.jsonwebtoken.impl.DefaultJwtParserBuilder");
}

해당 메서드 하나 밖에 없다...책도 그렇고 gpt도 그렇고 아무도 안알려줘서 찾음

🔧 3단계: Spring Security 설정 변경

기존 시큐리티 설정에서 Form Login 비활성화 시켜줘야된다.
JWT 인증이 적용되도록 기존 Security 설정을 수정해야됨

@EnableWebSecurity
public class SecurityJwtConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable()) // JWT는 Stateless(무상태)이기 때문에 CSRF가 필요없음
                .authorizeHttpRequests(auth -> auth
						.requestMatchers("/auth/login", "/auth/signup").permitAll()
                        .anyRequest().permitAll()  // ✅ 모든 요청 허용
                )
                
        return http.build();
    }
}

CSRF를 비활성화 하는 이유

  • 앞에 작성한 Spring Security(form Login) 게시글에서는 개발하기 위해 비활성화 처리하고 배포할 때는 활성화 처리한다고 작성했다.
  • JWT 인증방식에서 비활성화 처리하는데 이유가 있다.

🔍 CSRF란?(재설명)

CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조)는
사용자의 인증 정보를 이용해 공격자가 의도하지 않은 요청을 보내는 보안 공격이다.

✅ CSRF 공격 예시

  1. 사용자가 사이트 A에 로그인한 상태에서
  2. 공격자가 만든 사이트 B에 접속합니다.
  3. 사이트 B가 사용자의 쿠키/세션을 이용해 사이트 A에 요청을 보냅니다.
  4. 사용자는 모르게 사이트 A에서 원하지 않는 동작이 실행됩니다.

🔒 Spring Security의 CSRF 기본 동작

  • Spring Security는 POST, PUT, DELETE 같은 데이터 변경 요청에서
    CSRF 토큰이 없으면 요청을 차단합니다.
  • Form Login 방식에서는 CSRF 토큰이 자동으로 발급되고,
    폼 제출 시 함께 전송되어 보안이 유지됩니다.
  • Spring에서는 <sec:csrfInput/> 이런식으로 CSRF를 form에 넣고 요청을 보냈다.

🔑 왜 JWT에서는 CSRF를 비활성화할까?

✅ JWT 인증의 특징

  1. JWT는 Stateless(무상태) → 서버가 세션/쿠키를 관리하지 않습니다.
  2. 클라이언트가 토큰을 직접 저장하고, 요청 헤더에 실어서 보냅니다.
  3. 서버는 오직 토큰 검증으로 인증을 처리합니다.

🔥 CSRF가 필요 없는 이유

  1. CSRF는 주로 세션 기반 인증에서 발생합니다.
  2. 하지만 JWT 인증은 세션을 사용하지 않기 때문에
  3. CSRF 공격 위험이 없습니다.

🔎 즉, JWT 기반 인증에서는 CSRF 토큰이 필요하지 않아 불필요한 검사를 비활성화 처리한다.

⚠️ 언제 CSRF를 활성화하고 비활성화 해야될까?

  • 폼 로그인(Form Login)기반 웹 서비스 -> CSRF 활성화 유지
  • 세션/쿠키를 사용하는 웹 서비스 -> CSRF 활성화 유지
  • REST API + JWT 서비스 -> CSRF 비활성화

🔧 4단계: JWT 인증 필터 추가(JwtAuthenticationFilter)

요청이 올 때마다 JWT 토큰을 검사하는 필터를 만듬

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final TokenProvider tokenProvider;

    public JwtAuthenticationFilter(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        // ✅ 요청 헤더에서 토큰 추출
        String token = resolveToken(request);

        // ✅ 토큰이 유효하면 인증 처리
        if (token != null && tokenProvider.validToken(token)) {
            Authentication authentication = tokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    // ✅ Authorization 헤더에서 Bearer 토큰 추출
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

🔎 설명

Authorization 헤더에서 Bearer 토큰을 꺼냅니다.
토큰 유효성 검사 후, 인증 정보를 SecurityContext에 저장합니다.

Bearer 토큰

토스페이먼츠의 Basic 인증과 Bearer 인증 설명

🔧 4단계: JWT 필터를 Security 설정에 추가

아까 만든 JwtAuthenticationFilter를 SecurityConfig에 연결.

@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final TokenProvider tokenProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())  // ✅ CSRF 완전 비활성화
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/auth/login", "/auth/signup").permitAll()
                        .anyRequest().permitAll()  // ✅ 모든 요청 허용
                )
                .addFilterBefore(new JwtAuthenticationFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class);  // ✅ JWT 필터 추가

        return http.build();
    }
}

🔎 설명

  • JWT 인증 필터를 UsernamePasswordAuthenticationFilter 앞에 추가
  • /auth/login, /auth/signup → 인증 없이 접근 가능
  • 그 외 모든 요청은 JWT 인증 필요

🔧 5단계: 로그인 시 JWT 토큰 발급(Controller)

사용자가 로그인하면 JWT 토큰을 발급해서 응답하는지 테스트

@Slf4j
@RestController
@RequiredArgsConstructor
public class SecurityController {
    private final UsersService usersService;
    private final TokenProvider tokenProvider;
    
        @PostMapping("/auth/login")
    public ResponseEntity<String> login(@RequestBody UsersDTO user) {
        System.out.println("로그인 요청 받음: " + user.getUserid() + " / " + user.getPassword());  // ✅ 요청 확인

        if ("user".equals(user.getUserid()) && "password".equals(user.getPassword())) {
            String token = tokenProvider.generateToken(user, Duration.ofHours(1));
            System.out.println("토큰 발급: " + token);  // ✅ 토큰 발급 확인
            return ResponseEntity.ok("Bearer " + token);
        } else {
            System.out.println("로그인 실패");  // ✅ 실패 확인
            return ResponseEntity.status(401).body("Invalid Credentials");
        }
    }
}

🔎 설명

  • 사용자가 로그인하면 JWT 토큰을 발급한다.
  • 이후 모든 요청은 발급받은 토큰을 Authorization 헤더에 담아서 보내야 한다다.

🔧 6단계: Postman으로 테스트

  1. POST → http://localhost:8080/auth/login
  2. Body → JSON 형식
{
  "username": "user",
  "password": "password"
}

성공적이라면 랜덤으로 저런식으로 나온다

Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

오늘은 일단 여기까지 지금까지 분량은 총 6시간짜리 공부

profile
최선을 다해 꾸준히 노력하는 개발자 망고입니당 :D

0개의 댓글