프로그래밍/Java

spring boot 이용한 JWT 로그인 로그아웃 토큰 유효성 검사

소행성왕자 2025. 1. 21. 15:52

Spring Boot3.1 과  JWT를 이용하여 로그인 / 로그아웃 / 토큰 생성 / 토큰 검증 기능을 간다하게 구현해 봅니다.

프로젝트 구성

src/main/java/com/example/jwtapp/
    ├── controller/AuthController.java
    ├── filter/JwtFilter.java
    ├── model/AuthRequest.java
    ├── model/AuthResponse.java
    ├── model/LogoutRequest.java
    ├── security/JwtUtil.java
    ├── security/SecurityConfig.java
    ├── service/TokenBlacklistService.java
    └── JwtAppApplication.java

 

Gradle 의존성 추가

build.gradle

plugins {
	id 'java'
	id 'war'
	id 'org.springframework.boot' version '3.1.0'
	id 'io.spring.dependency-management' version '1.1.6'
	id 'application'  // 추가
	id "com.github.node-gradle.node" version "7.1.0"
}

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

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

node {
    download = true
    version = "22.12.0"
    npmVersion = "10.9.0"
    distBaseUrl = "https://nodejs.org/dist"
    npmInstallCommand = "install"
    workDir = file("${project.projectDir}/.gradle/nodejs")
    npmWorkDir = file("${project.projectDir}/.gradle/npm")
    nodeProjectDir = file('./frontend')
}

/**
	Node.js 프로젝트 디렉토리(./frontend)에서 npm install 명령을 실행합니다.
	package.json에 정의된 종속성을 설치합니다.
*/
task setUp(type: NpmTask) {
    description = "Install Node.js Packages"
    args = ['install']
}

/**
	npm run build 명령을 실행하여 Vue.js 프로젝트를 빌드합니다.
	dependsOn: setUp: Vue 빌드 전에 npm install이 실행되도록 설정합니다.
*/
task buildFrontEnd(type: NpmTask, dependsOn: setUp) {
    description = "build Vue"
    args = ['run', 'build']
}




/**
	Spring Boot의 리소스 처리 단계(processResources)가 Vue 빌드 결과를 포함하도록 설정.
	Gradle이 Spring Boot 프로젝트를 빌드할 때 Vue.js도 자동으로 빌드되도록 합니다.
*/
processResources.dependsOn 'buildFrontEnd'


configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-devtools'
	implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.0'
	implementation 'com.fasterxml.jackson.core:jackson-databind'
	implementation 'org.springframework.boot:spring-boot-starter-websocket'
	implementation 'jakarta.websocket:jakarta.websocket-api'
	implementation 'org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16'
	implementation 'com.google.code.gson:gson:2.10.1' 
	implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0'
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
	implementation 'org.springframework.boot:spring-boot-starter-tomcat'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
	runtimeOnly 'mysql:mysql-connector-java:8.0.25'
	implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0'
	implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
	implementation 'jakarta.servlet:jakarta.servlet-api:6.0.0'

}

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

JWT 유틸리티 클래스

JwtUtil.java

package com.coforward.egfwts.jwt.security;

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Date;

@Component
public class JwtUtil {

    // HS256에 적합한 비밀 키 생성
    private final SecretKey SECRET_KEY = Keys.secretKeyFor(SignatureAlgorithm.HS256);

    private final long EXPIRATION_TIME = 1000 * 60 * 60; // 1시간

    // JWT 토큰 생성
    public String generateToken(String username) {
        return Jwts.builder()
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
                .signWith(SECRET_KEY) // 비밀 키를 직접 사용
                .compact();
    }

    // JWT 토큰에서 사용자 이름 추출
    public String extractUsername(String token) {
        return Jwts.parserBuilder() // 새로운 파서 빌더 사용
                .setSigningKey(SECRET_KEY)
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    
    // JWT 토큰 유효성 검증
    /*
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder() // 새로운 파서 빌더 사용
                    .setSigningKey(SECRET_KEY)
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (JwtException e) {
            return false;
        }
    }
    */

    // JWT 토큰에서 클레임 추출
    private Claims extractClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(SECRET_KEY)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    // JWT 토큰 유효성 검사
    public boolean isTokenExpired(String token) {
        return extractClaims(token).getExpiration().before(new Date());
    }

    // JWT 토큰 유효성 검사 및 사용자 추출
    public boolean validateToken(String token, String username) {
        return (username.equals(extractUsername(token)) && !isTokenExpired(token));
    }
}

로그인 요청 및 응답 모델

AuthRequest.java

package com.coforward.egfwts.jwt.model;

import lombok.Getter;
import lombok.Setter;

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

    // Getter와 Setter
}

AuthResponse.java

package com.coforward.egfwts.jwt.model;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class AuthResponse {
    private String token;

    public AuthResponse(String token) {
        this.token = token;
    }

    // Getter
}

로그아웃 요청 모델

LogoutRequest.java

package com.coforward.egfwts.jwt.model;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class LogoutRequest {
    private String token;

    // Getter와 Setter
}

블랙리스트 서비스

TokenBlacklistService.java

package com.coforward.egfwts.jwt.service;

import org.springframework.stereotype.Service;

import java.util.HashSet;
import java.util.Set;

@Service
public class TokenBlacklistService {
    private final Set<String> blacklist = new HashSet<>();

    // 토큰 블랙리스트에 추가
    public void addTokenToBlacklist(String token) {
        blacklist.add(token);
    }

    // 토큰이 블랙리스트에 있는지 확인
    public boolean isTokenBlacklisted(String token) {
        return blacklist.contains(token);
    }
}

컨트롤러: 로그인 및 로그아웃 토큰 유효서 체크

AuthController.java

package com.coforward.egfwts.jwt.controller;

import com.coforward.egfwts.jwt.model.AuthRequest;
import com.coforward.egfwts.jwt.model.AuthResponse;
import com.coforward.egfwts.jwt.model.LogoutRequest;
import com.coforward.egfwts.jwt.security.JwtUtil;
import com.coforward.egfwts.jwt.service.TokenBlacklistService;
import com.coforward.egfwts.relay.RelaySocket;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;


@RestController
@RequestMapping("/auth")
public class AuthController {


    private static final Logger logger = LoggerFactory.getLogger(AuthController.class);

    private final JwtUtil jwtUtil;
    private final TokenBlacklistService blacklistService;

    public AuthController(JwtUtil jwtUtil, TokenBlacklistService blacklistService) {
        this.jwtUtil = jwtUtil;
        this.blacklistService = blacklistService;
    }

    @PostMapping("/login")
    public ResponseEntity<AuthResponse> login(@RequestBody AuthRequest authRequest) {
        
        // 간단한 인증 로직 (실제에서는 DB와 연동)
        logger.debug(">>>>1: " + authRequest.getUsername());

        if ("user".equals(authRequest.getUsername()) && "password".equals(authRequest.getPassword())) {
            String token = jwtUtil.generateToken(authRequest.getUsername());
         
            logger.debug(">>>>2: " + token);
            return ResponseEntity.ok(new AuthResponse(token));
        }
        return ResponseEntity.status(401).body(null);
    }

    @PostMapping("/logout")
    public ResponseEntity<Void> logout(@RequestBody LogoutRequest logoutRequest) {
        blacklistService.addTokenToBlacklist(logoutRequest.getToken());
        return ResponseEntity.ok().build();
    }

    @GetMapping("/check")
    public String checkAuthentication(@RequestHeader(value = "Authorization", required = false) String authorization) {
        if (authorization != null && authorization.startsWith("Bearer ")) {
            String token = authorization.substring(7);
            String username = jwtUtil.extractUsername(token);

            if (jwtUtil.validateToken(token, username)) {
                return "okok";  // 인증 성공
            }
        }
        return "notokok";  // 인증 실패
    }
    
}

JWT 필터

JwtFilter.java

package com.coforward.egfwts.jwt.filter;

import com.coforward.egfwts.jwt.security.JwtUtil;
import com.coforward.egfwts.jwt.service.TokenBlacklistService;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.lang.NonNull;

public class JwtFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final TokenBlacklistService blacklistService;

    public JwtFilter(JwtUtil jwtUtil, TokenBlacklistService blacklistService) {
        this.jwtUtil = jwtUtil;
        this.blacklistService = blacklistService;
    }

    @Override
    protected void doFilterInternal(
        @NonNull HttpServletRequest request, 
        @NonNull HttpServletResponse response, 
        @NonNull FilterChain chain)
            throws ServletException, IOException {
        String authHeader = request.getHeader("Authorization");

        // Authorization 헤더가 "Bearer <token>" 형식인지 확인
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);
            String username = jwtUtil.extractUsername(token);

            if (jwtUtil.validateToken(token, username) && !blacklistService.isTokenBlacklisted(token)) {
                //String username = jwtUtil.extractUsername(token);
                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(username, null, null);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            } else {
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.getWriter().write("Invalid or expired token");
                return; // 유효하지 않거나 만료된 토큰일 경우 인증을 중단하고 응답
            }
        }

        chain.doFilter(request, response);
    }
}

Spring Security 설정

SecurityConfig.java

package com.coforward.egfwts.jwt.security;

import com.coforward.egfwts.jwt.filter.JwtFilter;
import com.coforward.egfwts.jwt.service.TokenBlacklistService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;


@Configuration
public class SecurityConfig {

    private final JwtUtil jwtUtil;
    private final TokenBlacklistService blacklistService;

    public SecurityConfig(JwtUtil jwtUtil, TokenBlacklistService blacklistService) {
        this.jwtUtil = jwtUtil;
        this.blacklistService = blacklistService;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/auth/login", "/auth/logout").permitAll()
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            );

        // 필터 순서 지정: JwtFilter를 UsernamePasswordAuthenticationFilter 앞에 추가
        http.addFilterBefore(new JwtFilter(jwtUtil, blacklistService), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

실행 및 테스트

  1. 로그인 요청 (POST)
    • URL: http://localhost:8080/auth/login
    • Body:
      { "username": "user", "password": "password" }
    • 응답:
      { "token": "generated_jwt_token" } 
  2. 로그아웃 요청 (POST)
    • URL: http://localhost:8080/auth/logout
    • Body:
      { "token": "generated_jwt_token" }
  3. 토큰 체크 (GET)
    • URL: http://localhost:8080/auth/check
    • header:
    •   key : Authorization
    •   value : Bearer token

 

로그인 요청

로그인 요청

토큰 체크

토큰 check

로그아웃

로그아웃

토큰 체크

토큰 체크