프로그래밍/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

로그아웃

로그아웃

토큰 체크

토큰 체크