본문 바로가기
Spring Boot

Spring Boot 3 + Spring Security 6 + JWT 인증

by 오이가지아빠 2025. 2. 6.
반응형

#1. 프로젝트 생성

Intellij 의 New Spring Boot Project 생성, 혹은 start.spring.io 를 통해 신규 프로젝트를 생성합니다.

 

Dependencies 설정은 아래와 같이 심플하게 시작합니다. 

 

생성된 프로젝트에 추가로 JWT 관련 Dependencies를 추가 해줍니다.

implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'

 

최종 의존성은 아래와 같습니다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    runtimeOnly 'com.h2database:h2'

    implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

 

필요한 의존성를 전부 로드하고, 이제 나머지 필요한 코드들을 작성합니다.

 

#2. User , UserDetailsService 클래스

사용자의 정보를 담을 User 클래스와, 정보를 가져올 UserDetailsService를 구현합니다.

UserDetailsService 인터페이스를 구현하여 loadUserByUsername 메소드를 오버라이드 합니다.

 

예시에서는 JPA가 아닌 Mybatis를 사용하여 DB repository를 구현합니다.

 

@Getter
public class User {
    private Long userId;
    private String username;
    private String password;
}

-

@Mapper
public interface UserDetailsMapper {
    @Select("SELECT userId, username, password FROM EMP WHERE username = #{username}")
    User getUserByUsername(String username);
}

-

@Slf4j
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserDetailsMapper userDetailsMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("loadUserByUsername");
        User user = userDetailsMapper.getUserByUsername(username);

        return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), true, true, true, true, new ArrayList<>());
    }
}

 

#3. JwtUtil

JwtUtil 클래스는 토큰의 생성, request 헤더에서 토큰의 추출, 정합성 검사 등의 역할을 수행 합니다.

 

Jwt.parserBuilder() 메소드는 최신버전에서 Deprecated 되었으므로, 아래의 소스를 참고하여 작성합니다.

@Slf4j
@Component
public class JwtUtil {

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

    @Value("${jwt.access-token-expiry}")
    private long accessTokenExpiry;

    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String BEARER_PREFIX = "Bearer ";

    @PostConstruct
    protected void init() {
        this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
    }

    public String createAccessToken(String username) {
        log.info("createAccessToken");
        return Jwts.builder()
            .signWith(secretKey)
            .subject(username)
            .issuedAt(Date.from(Instant.now()))
            .expiration(Date.from(Instant.now().plusSeconds(accessTokenExpiry)))
            .compact();
    }

    public Claims extractToken(String token) {
        log.info("extractToken");
        return Jwts.parser()
            .verifyWith(secretKey)
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }

    public String extractUsername(String token) {
        log.info("extractUsername");
        return extractToken(token).getSubject();
    }

    public String extractBearerToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(BEARER_PREFIX.length());
        }
        return null;
    }

    public Boolean validateToken(final String token) {
        try {
            Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token);
            return true;
        } catch (SecurityException e) {
            log.warn("Invalid JWT signature: {}", e.getMessage());
        } catch (MalformedJwtException e) {
            log.warn("Invalid JWT token: {}", e.getMessage());
        } catch (ExpiredJwtException e) {
            log.warn("JWT token is expired: {}", e.getMessage());
        } catch (UnsupportedJwtException e) {
            log.warn("JWT token is unsupported: {}", e.getMessage());
        } catch (IllegalArgumentException e) {
            log.warn("JWT claims string is empty: {}", e.getMessage());
        }

        return false;
    }

}

 

#4. JwtRequestFilter

JwtRequestFilterOncePerRequestFilter를 상속받아 작성합니다.

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

    private final JwtUtil jwtUtil;
    private final CustomUserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {
        log.info("doFilterInternal");

        final String authorizationHeader = request.getHeader("Authorization");

        String jwtToken = null;
        String username = null;

        if(authorizationHeader != null) {
            jwtToken = jwtUtil.extractBearerToken(request);
            if(jwtUtil.validateToken(jwtToken)) {
                username = jwtUtil.extractUsername(jwtToken);
            }
        }

        if(username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }

        filterChain.doFilter(request, response);
    }
}

 

#5. SecurityConfig

SecurityConfig에서는 인증매커니즘, URL패턴 정의, jwt 토큰을 사용하여 인증을 처리하기 위한 필터 처리 등이 포함됩니다.

예제에서는 /auth 경로만 접근을 허용하여 토큰을 발급 받도록 합니다.

@Slf4j
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtRequestFilter jwtRequestFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable)
            .formLogin(AbstractHttpConfigurer::disable)
            .httpBasic(AbstractHttpConfigurer::disable)
            .logout(AbstractHttpConfigurer::disable)
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
		
        // jwtRequestFilter -> UsernamePasswordAuthenticationFilter 순서
        http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);

        http.authorizeHttpRequests(authorizeRequests -> authorizeRequests
            .requestMatchers("/auth").permitAll()
            .anyRequest().authenticated());

        return http.build();
    }
}

 

#6. 컨트롤러 구현

토큰 발급에 사용될 /auth 와 테스트를 위한 /hello 메소드를 구현합니다.

 

먼저 인증 Request / Response 구조체를 생성하고

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

@Getter
@AllArgsConstructor
public class AuthResponse {
    private String token;
}

 

AuthController를 작성합니다.

@Slf4j
@RestController
@RequiredArgsConstructor
public class AuthController {

    private final JwtUtil jwtUtil;
    private final UserDetailsService userDetailsService;

    @PostMapping("/auth")
    public AuthResponse createToken(@RequestBody AuthRequest authRequest) {
        log.info("createToken");

        userDetailsService.loadUserByUsername(authRequest.getUsername());
        String accessToken = jwtUtil.createAccessToken(authRequest.getUsername());

        return new AuthResponse(accessToken);
    }

    @GetMapping("/hello")
    public String hello() {
        return "hello";
    }
}

 

#7. 테스트

테스트는 intellij의 HTTP Client 플러그인을 사용해서 간단히 수행합니다.

에디터에 아래와 같은 내용으로 .http 파일을 생성하여 실행하기만 하면 됩니다.

# AuthController

### GET without token
GET http://localhost:8080/hello
Content-Type: application/json


### POST create a token
POST http://localhost:8080/auth
Content-Type: application/json

{
  "username": "user1",
  "password": "1234"
}

> {% client.global.set("auth_token", response.body.token); %}
### GET with token
GET http://localhost:8080/hello
Content-Type: application/json
Authorization: Bearer {{auth_token}}

 

먼저, 토큰정보 없이 /hello 를 수행하면

다음으로 /auth를 실행하여 토큰을 발급받습니다.

 

이제 발급된 토큰정보와 함께 다시한번 /hello 를 실행합니다.

 

정상적으로 결과를 받아오는 것을 확인할 수 있습니다.

전체 테스트 결과

 

이것으로 기본적인 토큰 처리 절차가 모두 완료되었습니다.

 

fin.

반응형

댓글