[Spring Boot 3.x] 3. JWT 토큰 인증(filter) & API 테스트

2025. 3. 1. 13:56·Spring Boot/Sprng Boot Sample Code

 

1. 개발환경

카테고리 도구/기술
프로그래밍 언어 Java 17
프레임워크 Spring Boot 3.4.2
빌드 도구 gradle
데이터베이스 MySQL
ORM 도구 Spring Data JPA
IDE IntelliJ
버전 관리 Git, GitHub, GitLab
패키지 관리 Maven Central
API 문서화 Swagger
로깅 SLF4J

 

2.build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.2'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    // JWT
    compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'

    // Security
    implementation 'org.springframework.boot:spring-boot-starter-security'

    // MySQL db
    runtimeOnly 'com.mysql:mysql-connector-j'

    // JPA 사용
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    // Web 스타터 - 아파치 톰켓, Web MVC, JSON 직렬화&역직렬화, validation
    implementation 'org.springframework.boot:spring-boot-starter-web'

    // getter, setter 등의 어노테이션
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    // 테스트 관련
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
    useJUnitPlatform()
}

 

3. 디렉토리 구조

📂 src
├── 📂 main
│ ├── 📂 java
│ │ └── 📂 com.example.springboot3template
│ │ ├── 📄 Springboot3templateApplication.java
│ │ │
│ │ ├── 📂 auth
│ │ │ ├── 📂 application # 🌟 애플리케이션 서비스 계층
│ │ │ │ ├── 📂 dto
│ │ │ │ │ ├── 📂 mapper
│ │ │ │ │ ├── 📂 req
│ │ │ │ │ │ ├── 📄 LoginReq.java
│ │ │ │ │ │ ├── 📄 SignUpReq.java
│ │ │ │ │ ├── 📂 res
│ │ │ │ │ ├── 📄 GetUserInfoRes.java
│ │ │ │ ├── 📂 service
│ │ │ │ ├── 📄 AuthService.java
│ │ │ │ ├── 📄 UserService.java
│ │ │
│ │ │ ├── 📂 domain # 🌟 도메인 계층 (엔티티)
│ │ │ │ ├── 📂 entity
│ │ │ │ │ ├── 📄 User.java
│ │ │ │ │ ├── 📄 UserRoleEnum.java
│ │ │
│ │ │ ├── 📂 infrastructure # 🌟 인프라 계층 (데이터 저장소)
│ │ │ │ ├── 📂 repository
│ │ │ │ │ ├── 📄 UserRepository.java
│ │ │ │ ├── 📂 security
│ │ │ │ ├── 📄 UserDetailsImpl.java
│ │ │ │ ├── 📄 UserDetailsServiceImpl.java
│ │ │
│ │ │ ├── 📂 presentation # 🌟 프레젠테이션 계층 (컨트롤러)
│ │ │ │ ├── 📂 controller
│ │ │ │ │ ├── 📄 AuthController.java
│ │ │ │ │ ├── 📄 UserController.java
│ │ │
│ │ ├── 📂 common
│ │ │ ├── 📂 security
│ │ │ │ ├── 📄 JwtFilter.java
│ │ │ │ ├── 📄 JwtProvider.java
│ │ │ │ ├── 📄 SecurityConfig.java
│ │
│ ├── 📂 resources
│ │ ├── 📄 application-local.yml
│ │ ├── 📄 application.yml
│ │ ├── 📂 static
│ │ ├── 📂 templates

 

4. 추가된 코드 및 흐름

포스트맨으로 로그인 후(쿠키에 JWT 토큰을 저장해) 다음과 같은 요청을 보낸다.

Get
http://localhost:8080/api/v1/user/info/me

 

1. SecurityConfig

모든 요청은 SecurityFilterChain이 가로채 허용되지 않은 요청에 대해 인증 과정을 거치는데 이 과정에서 추가된 JwtFilter가 실행된다.

@Configuration
@EnableWebSecurity // Spring Security 지원을 가능하게 함
//@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtFilter jwtFilter;

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

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

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // CSRF 설정
        http.csrf((csrf) -> csrf.disable());

        // Session 비활성화
        http.sessionManagement((sessionManagement) ->
            sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        );

        // 요청 권한 설정
        http.authorizeHttpRequests((authorizeHttpRequests) ->
            authorizeHttpRequests
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
                // api 요청 허가
                .requestMatchers("/api/v1/auth/login", "/api/v1/auth/sign-up").permitAll()
                // HTML 페이지 접근을 인증 없이 허용
//                .requestMatchers("/").permitAll()
                .anyRequest().authenticated() // 그 외 모든 요청 인증처리
        );

        // 필터 추가 (JWT 인증 필터를 UsernamePasswordAuthenticationFilter 앞에 추가)
        http.addFilterBefore(
            jwtFilter,
            UsernamePasswordAuthenticationFilter.class
        );

        return http.build();
    }

}

 

 

2. JwtFilter

쿠키에 담긴 Jwt 토큰을 추출해 검증하는 필터로 isAuthorizationPassRequest 에 허용된(예를들어 회원가입,로그인) 경로가 아니면 토큰의 유효성 및 변조등을 확인하고 문제가 없다면 userDetailsService 를 통해 해당 유저 정보를 db에서 조회하고 이를 userDetails 객체로 변환해 반환한다.

 

이렇게 반환된 객체를 기반으로  UsernamePasswordAuthenticationToken 이라는 스프링 시큐리티에서 사용자의 인증 정보를 담는 객체를 생성하고 이렇게 생성된 객체를 SecurityContext 라는 보안 컨텍스트에 저장 이 SecurityContext는 SecurityContextHolder 라는 컨테이너에 저장되 관리된다.(자세한 내용은 너무 길어지니 다음에 따로 설명하겠다.)

 

이렇게 저장된 현재 사용자의 인증정보는 @AuthenticationPrincipal 라는 어노테이션을 통해 SecurityContextHolder 에 저장된 Authentication 객체에서 principal을 가져와 자동으로 매핑해 사용할 수 있다. (다만 이런 방식은 모놀리식 어플리케이션에서는 사용 가능하지만 JWT의 장점을 살릴 수 있는 Micro-Service-Application 에서는 SecurityContext를 다른 서비스와 공유할 수 없어 사용할 수 없다.)

@Component
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {

    private final UserDetailsService userDetailsService;

    @Value("${jwt.secret.key}")
    private String secretKey;

    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain)
        throws ServletException, IOException {

        String url = request.getRequestURI();

        if (isAuthorizationPassRequest(url)) {
            log.info("인증 제외 API 요청: {}", url);
            filterChain.doFilter(request, response);
            return;
        }

        // 토큰 확인 및 검증 로직 유지
        String token = getTokenFromRequest(request);
        log.info("요청에서 추출된 토큰: {}", token);

        if (StringUtils.hasText(token)) {
            SecretKey key = getSecretKey();

            if (!validateToken(token, key)) {
                throw new IllegalArgumentException("Token Error");
            }

            Claims claims = getUserInfoFromToken(token, key);
            String username = claims.getSubject();
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authentication);

            log.info("인증 완료: {}", SecurityContextHolder.getContext().getAuthentication());
        } else {
            log.warn("토큰이 요청에 포함되지 않았습니다.");
        }

        filterChain.doFilter(request, response);
    }

    // 토큰 검증
    public boolean validateToken(String token, SecretKey key) {
        if (token == null || token.isEmpty()) {
            log.error("토큰 값이 null 또는 비어있습니다.");
            return false;
        }
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            log.info("토큰 검증 성공");
            return true;
        } catch (SecurityException | MalformedJwtException | SignatureException e) {
            log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
        } catch (ExpiredJwtException e) {
            log.error("Expired JWT token, 만료된 JWT token 입니다.");
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
        } catch (IllegalArgumentException e) {
            log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
        }
        return false;
    }

    // HttpServletRequest 에서 Cookie Value : JWT 가져오기
    public String getTokenFromRequest(HttpServletRequest req) {
        Cookie[] cookies = req.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                log.info("쿠키 이름: {}, 쿠키 값: {}", cookie.getName(), cookie.getValue());
                if (cookie.getName().equals(AUTHORIZATION_HEADER)) {
                    String cookieValue = cookie.getValue();
                    if (cookieValue != null) {
                        // 그냥 토큰을 그대로 사용합니다. URLDecoder.decode는 필요없음
                        log.info("디코딩된 토큰: {}", cookieValue);  // 디코딩 없이 그대로 사용
                        return cookieValue;
                    } else {
                        log.error("쿠키 값이 null 입니다.");
                    }
                }
            }
        }
        log.warn("Authorization 쿠키가 요청에 포함되지 않았습니다.");
        return null;
    }

    // 토큰에서 사용자 정보 가져오기
    public Claims getUserInfoFromToken(String token, SecretKey key) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
    }

    // 인증 통과 url
    private boolean isAuthorizationPassRequest(String path) {
        return path.startsWith("/api/v1/auth/login") || path.startsWith("/api/v1/auth/sign-up");
    }

    private SecretKey getSecretKey() {
        if (secretKey == null || secretKey.isEmpty()) {
            throw new IllegalStateException("JwtFilter에 secretKey가 null입니다.");
        }
        return Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));
    }

}

 

3. UserDetailsServiceImpl

username을 통해 db를 조회하고 해당 객체를 userDetailsImpl 객체로 변환(스프링 시큐리티가 사용할 수 있도록)하여 반환

@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("아이디를 찾을 수 없습니다. " + username));

        return new UserDetailsImpl(user);
    }

}

 

4. UserDeatilsImpl

 

@AllArgsConstructor
public class UserDetailsImpl implements UserDetails {

    private final User user;

    public User getUser() {
        return user;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        UserRoleEnum role = user.getRole();
        String authority = role.getAuthority();

        SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(simpleGrantedAuthority);

        return authorities;
    }

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

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

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

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

 

5. UserController

요청이 들어오면 @AuthenticationPrincipal 어노테이션을 통해 현재 인증된 사용자의 정보를 UserDeatilsImpl을 통해 가져오고 UserService의 해당 비즈니스 로직을 수행한다.. 

@RequestMapping("/api/v1/user")
@RestController
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    // 본인 정보 조회
    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/info/me")
    public ResponseEntity<GeuUserInfo> getMyInfo(@AuthenticationPrincipal UserDetailsImpl userDetails) {
        GeuUserInfo myInfo = userService.getMyInfo(userDetails);
        return ResponseEntity.ok(myInfo);  // 현재 인증된 사용자 정보 반환
    }

}

 

6. UserService

UserDeatilsImpl 를 통해 User객체에서 필요한 정보를 조회하고

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    // 본인 정보 조회
    @Transactional(readOnly = true)
    public ReadUserInfo getMyInfo(UserDetailsImpl userDetails) {
        Long userId = userDetails.getUser().getUserId();

        User user = userRepository.findById(userId)
            .orElseThrow(() -> new RuntimeException("사용자 정보를 찾을 수 없습니다."));

        GeuUserInfo getMyInfo = new GeuUserInfo();
        getMyInfo.setUsername(user.getUsername());

        return getMyInfo;
    }

}

 

7. GetUserInfoRes

DTO에 매핑해 반환한다.

@Data
public class GetUserInfoRes {

    private String username;

}

 

저작자표시 비영리 변경금지 (새창열림)

'Spring Boot > Sprng Boot Sample Code' 카테고리의 다른 글

Spring Boot 에서 네이버 클라우드 Object Storage 에 파일 업로드  (1) 2025.03.03
[SpringBoot 3.x] 4. @PreAuthorize 어노테이션을 이용한 API 인가 설정  (0) 2025.03.03
[Spring Boot 3.x] 2. JWT 토큰과 쿠키를 이용한 로그인 구현  (0) 2025.02.24
[Spring Boot 3.x] 1. 회원가입 구현  (1) 2025.02.02
build.gradle 이해하기  (1) 2025.01.30
'Spring Boot/Sprng Boot Sample Code' 카테고리의 다른 글
  • Spring Boot 에서 네이버 클라우드 Object Storage 에 파일 업로드
  • [SpringBoot 3.x] 4. @PreAuthorize 어노테이션을 이용한 API 인가 설정
  • [Spring Boot 3.x] 2. JWT 토큰과 쿠키를 이용한 로그인 구현
  • [Spring Boot 3.x] 1. 회원가입 구현
Yun-seul
Yun-seul
재능이 없으면 열심히라도 하자
  • Yun-seul
    윤슬
    Yun-seul
  • 전체
    오늘
    어제
    • 분류 전체보기 (22)
      • Java (1)
        • Java 기본 (1)
      • Spring Boot (9)
        • Sprng Boot Sample Code (9)
      • Docker (1)
      • Kubernetes (7)
        • Common (3)
        • GKE(Google Kubernetes Engin.. (4)
        • EKS(Elastic Kubernetes Serv.. (0)
      • Redis (0)
      • AWS (0)
      • Git (0)
      • Reflection (1)
      • Troubleshooting (3)
      • Performance Tuning (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    docker
    jwt토큰 #로그인 #회원가입 #쿠키 #보안설정
    djava.io.tmpdir
    어세스토큰
    커스텀익셉션
    globalexception
    rfc6750
    전역예외처리
    재요청
    @value #null #어노테이션 #springboot #springioc #컨테이너
    쿠버네티스
    GKE
    kubernetes
    methodargumentnotvalidexception
    임시디렉토리
    필터2번
    unable to create tempdir. java.io.tmpdir is set to
    onceperrequestfilter
    에러코드관리
    SpringBoot
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
Yun-seul
[Spring Boot 3.x] 3. JWT 토큰 인증(filter) & API 테스트
상단으로

티스토리툴바