Chapter#01 : [Spring] IntelliJ를 사용한 Spring Project 생성( Gradle )
Chapter#02 : [Spring] MVC 패턴 및 MyBatis를 사용한 게시판 제작
Chapter#03 : [Spring] Log4j 설정 및 사용하기( log 파일 저장하기 )
Chapter#04 : [Spring] SpringSecurity를 이용한 사용자 인증 프로세스 구축
Chapter#05 : [Spring] JWT 토큰 발급 및 토큰 인증 받기
Chapter#06 : [Spring] Swagger 웹 서비스 RESTful API 문서 자동 생성
※ 해당 포스팅은 [Spring] SpringSecurity를 이용한 사용자 인증 프로세스 구축 포스팅의 내용과 이어 진행됩니다.
JWT( Json Web Token ) 란?
JWT( JSON Web Token)은 웹에서 정보를 안전하게 전달하기 위한 열려있는 표준이다.
JWT는 JSON 형식으로 구성되어 있으며, 클레임(claim)이라는 속성을 사용하여 필요한 정보를 포함하고 서명(signature)을 통해 인증과 데이터 무결성을 보장하며, 주로 인증(Authentication)과 정보 교환을 위해 사용됩니다.
1. JWT 라이브러리 추가
build.gradle을 열고 Dependencies에 JWT( Json Web Token ) 라이브러리 추가하여 준다.
build.gradle
~~ 이 하 생 략 ~~
dependencies {
~~ 이 하 생 략 ~~
// JWT( JsonWebToken )
implementation "io.jsonwebtoken:jjwt:0.9.1"
~~ 이 하 생 략 ~~
}
test {
useJUnitPlatform()
}
plugins {
id "java"
id "war"
}
apply plugin : "war"
group "org.example"
version "0.0.1-SNAPSHOT"
sourceCompatibility = 1.8
repositories {
mavenCentral()
}
dependencies {
testImplementation "org.junit.jupiter:junit-jupiter-api:5.8.1"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.8.1"
// Servelt API
providedCompile "javax.servlet:servlet-api:2.5"
// JSTL
implementation "javax.servlet:jstl:1.2"
// SpringFramework
implementation "org.springframework:spring-aop:5.2.22.RELEASE"
implementation "org.springframework:spring-beans:5.2.22.RELEASE"
implementation "org.springframework:spring-context:5.2.22.RELEASE"
implementation "org.springframework:spring-core:5.2.22.RELEASE"
implementation "org.springframework:spring-expression:5.2.22.RELEASE"
implementation "org.springframework:spring-jcl:5.2.22.RELEASE"
implementation "org.springframework:spring-tx:5.2.22.RELEASE"
implementation "org.springframework:spring-web:5.2.22.RELEASE"
implementation "org.springframework:spring-webmvc:5.2.22.RELEASE"
implementation "org.springframework:spring-jdbc:5.2.22.RELEASE"
// Spring Security
implementation "org.springframework.security:spring-security-acl:5.5.0"
implementation "org.springframework.security:spring-security-config:5.5.0"
implementation "org.springframework.security:spring-security-core:5.5.0"
implementation "org.springframework.security:spring-security-crypto:5.5.0"
implementation "org.springframework.security:spring-security-taglibs:5.5.0"
implementation "org.springframework.security:spring-security-web:5.5.0"
// JWT( JsonWebToken )
implementation "io.jsonwebtoken:jjwt:0.9.1"
// log4j
implementation "org.apache.logging.log4j:log4j-api:2.14.1"
implementation "org.apache.logging.log4j:log4j-core:2.14.1"
implementation "org.apache.logging.log4j:log4j-slf4j-impl:2.14.1"
// slf4j
implementation "org.slf4j:slf4j-api:1.7.25"
// MariaDB Connect
implementation "org.mariadb.jdbc:mariadb-java-client:2.5.4"
// MyBatis
implementation "org.mybatis:mybatis:3.5.8"
implementation "org.mybatis:mybatis-spring:2.0.6"
// Jackson
implementation "com.fasterxml.jackson.core:jackson-core:2.11.4"
implementation "com.fasterxml.jackson.core:jackson-annotations:2.11.4"
implementation "com.fasterxml.jackson.core:jackson-databind:2.11.4"
}
test {
useJUnitPlatform()
}
2. JWT 토큰 생성 및 인증 프로세스 구현
1) Spring Security 설정에 JWT 생성 및 인증 추가
스프링 시큐리티 설정에 JWT 토큰을 생성 및 인증에 필요한 설정값을 추가하여 준다.
context-security.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:security="http://www.springframework.org/schema/security"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/security
http://www.springframework.org/schema/security/spring-security.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<!-- auto-config="false" : 로그인 폼을 직접 만들어서 사용 -->
<security:http auto-config="false">
<!-- CSRF 보호 비활성화 -->
<security:csrf disabled="true"></security:csrf>
<!-- 로그인 페이지와 로그인 처리 URL 설정 -->
<!-- security:form-login login-page="/member/logIn.do" login-processing-url="/member/authenticationProcess.do" username-parameter="memberId" password-parameter="memberPw" default-target-url="/member/main.do" always-use-default-target="true"></security:form-login -->
<!-- 로그인 페이지는 접근권한 permitAll() 설정 -->
<!-- security:intercept-url pattern="/member/logIn.do" access="permitAll()"></security:intercept-url -->
<!-- 회원가입 페이지는 접근권한 permitAll() 설정 -->
<!-- security:intercept-url pattern="/member/singUp.do" access="permitAll()"></security:intercept-url -->
<!-- 회원가입시 회원 신청 ID 중복 확인 프로세스 접근권한 permitAll() 설정 -->
<!-- security:intercept-url pattern="/member/duplicateId.do" access="permitAll()"></security:intercept-url -->
<!-- 회원가입 로직처리 프로세스 접근권한 permitAll() 설정 -->
<!-- security:intercept-url pattern="/member/memberRegistry.do" access="permitAll()"></security:intercept-url -->
<!-- 인증된 사용자에 한해 isAuthenticated() 설정 -->
<!-- security:intercept-url pattern="/**" access="hasRole('ROLE_USER') or hasRole('ROLE_ADMIN')"></security:intercept-url -->
<!-- 로그아웃 설정 -->
<!-- security:logout logout-url="/member/logOut.do" logout-success-url="/member/logIn.do?logout" invalidate-session="true" delete-cookies="JSESSIONID"></security:logout -->
<!-- 동시 세션 제어 설정 -->
<!-- security:session-management>
<security:concurrency-control max-sessions="1" error-if-maximum-exceeded="true" expired-url="/member/login.do?expired=true"></security:concurrency-control>
</security:session-management -->
<!-- API 로그인 설정 -->
<security:form-login login-processing-url="/access/authenticationProcess.do" username-parameter="memberId" password-parameter="memberPw" authentication-success-handler-ref="authenticationSuccessHandler" authentication-failure-handler-ref="authenticationFailureHandler"></security:form-login>
<!-- API 로그아웃 설정 -->
<security:logout logout-url="/access/logOut.do" invalidate-session="true" delete-cookies="JSESSIONID" success-handler-ref="jwtLogoutSuccessHandler"></security:logout>
<!-- JwtTokenFilter를 추가하여 토큰 유효성 검사 수행 -->
<security:custom-filter before="FILTER_SECURITY_INTERCEPTOR" ref="jwtTokenProvider"></security:custom-filter>
</security:http>
<!-- Spring Security의 사용자 인증을 수행 -->
<security:authentication-manager id="authenticationManager">
<security:authentication-provider user-service-ref="securityUserDetailsService">
<security:password-encoder ref="passwordEncoder"></security:password-encoder>
</security:authentication-provider>
</security:authentication-manager>
<bean id="securityUserDetailsService" class="org.example.security.SecurityUserDetailsService"></bean>
<bean id="passwordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"></bean>
<!-- jwtConfig bean -->
<bean id="jwtConfig" class="org.example.security.JwtConfig"></bean>
<!-- JWT 토큰 생성 및 유효성 검사 bean -->
<bean id="jwtTokenProvider" class="org.example.security.JwtTokenProvider">
<constructor-arg ref="jwtConfig"></constructor-arg>
</bean>
<!-- 사용자 로그아웃 및 JWT 토큰 파기 -->
<bean id="jwtLogoutSuccessHandler" class="org.example.security.JwtLogoutSuccessHandler">
<property name="jwtTokenProvider" ref="jwtTokenProvider"></property>
</bean>
<!-- 인증 성공 bean -->
<bean id="authenticationSuccessHandler" class="org.example.security.SecurityAuthenticationSuccessHandler">
<constructor-arg ref="jwtTokenProvider"></constructor-arg>
</bean>
<!-- 인증 실패 bean -->
<bean id="authenticationFailureHandler" class="org.example.security.SecurityAuthenticationFailureHandler"></bean>
</beans>
2) JWT 관련 설정 Properties 파일 생성
src/main/resources/config 경로에 JWT관련 설정 정보를 포함하는 jwt.properties 파일을 생성한다.
jwt.properties 파일에 아래와 같은 키=값을 추가하여 준다.
jwt.properties
jwt.secret = NMA8JPctFuna59f5
jwt.expiration = 3600000
jwt.header = Authorization
jwt.prefix = Bearer
jwt.secret | JWT 서명을 생성하고 검증하기 위한 비밀키 |
jwt.expiration | JWT 만료시간을 지정한다.( 3600000 밀리초 = 1시간 ) |
jwt.header | JWT를 HTTP 헤더에 실어 보낼 때 사용할 헤더 이름을 지정 |
jwt.prefix | JWT 토큰 앞에 붙는 접두어를 선언한다. 주로 "Bearer"를 사용한다. |
3) JWT 생성 및 인증 프로세스 구현
① JWT 설정 정보를 관리하는 클래스 생성
org.example.security 패키지에 JwtConfig.java 파일을 생성한다.
JwtConfig 클래스는 다른 클래스에서 JwtConfig 클래스의 빈을 주입받아서 JWT 설정값을 사용할 수 있게 된다.
스프링 시큐리티 필터나 서비스에서 JWT 생성, 검증, 설정 값을 사용할 때 유용하게 활용할 수 있게된다.
JwtConfig 클래스에 @PropertySources 어노테이션을 사용하여 jwt.properties 파일을 로드하여 설정값을 가져오고
멤버 변수를 추가하고 Getter와 Setter 메서드를 정의하여 설정값을 가져오거나 변경하게 된다.
JwtConfig.java
package org.example.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.PropertySource;
import org.springframework.stereotype.Component;
@Component
@PropertySource("classpath:config/jwt.properties")
public class JwtConfig {
@Value("${jwt.secret}")
private String secret; // 비밀키
@Value("${jwt.expiration}")
private long expiration; // 토큰 만료 시간
@Value("${jwt.header}")
private String header; // 헤더 이름
@Value("${jwt.prefix}")
private String prefix; // 접두사
public String getSecret() {
return secret;
}
public void setSecret(String secret) {
this.secret = secret;
}
public long getExpiration() {
return expiration;
}
public void setExpiration(long expiration) {
this.expiration = expiration;
}
public String getHeader() {
return header;
}
public void setHeader(String header) {
this.header = header;
}
public String getPrefix() {
return prefix;
}
public void setPrefix(String prefix) {
this.prefix = prefix;
}
}
② JWT 토큰을 생성하고 검증하는 클래스 구현
org.example.security 패키지 경로에 JwtTokenProvider.java 클래스 파일을 생성한다.
JwtTokenProvider 클래스는 OncePerRequestFilter를 상속하여 사용자의 요청을 가로채서 JWT관련 작업을 수행한다.
JwtTokenProvider.java
package org.example.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.example.member.service.MemberDAO;
import org.example.member.service.MemberVO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.annotation.Resource;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;
@Component
public class JwtTokenProvider extends OncePerRequestFilter {
@Resource(name="memberDaoMyBatis")
private MemberDAO memberDAO;
private final JwtConfig jwtConfig;
@Autowired
public JwtTokenProvider(JwtConfig jwtConfig) {
this.jwtConfig = jwtConfig;
}
// 블랙리스트에 추가되어야 하는 토큰들을 저장하는 공간
private Set<String> tokenBlacklist = new HashSet<>();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = extractToken(request);
// 토큰이 유효한 경우, 컨트롤러 호출
if(StringUtils.hasText(token) == true && validateToken(token) == true) {
Authentication authentication = getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
// 다음 필터 또는 컨트롤러로 요청 전달
filterChain.doFilter(request, response);
} else {
// 토큰이 유효하지 않은 경우, 인증 실패 처리
handleAuthenticationFailure(request, response);
}
}
// JWT 토큰 생성
public String generateToken(Authentication authentication) {
// 사용자 UUID 추출
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
String uuid = userDetails.getUsername();
List<String> roles = Arrays.asList("ROLE_USER", "ROLE_ADMIN");
try {
// 사용자 정보 조회
MemberVO memberVO = memberDAO.selectMemberInfo(uuid);
// JWT 토큰 생성
String token = Jwts.builder()
.setSubject(userDetails.getUsername()) // 사용자 UUID
.claim("roles", roles) // 사용자 권한 역할
.claim("memberId", memberVO.getMemberId()) // 사용자 ID
.claim("memberName", memberVO.getMemberName()) // 사용자 이름
.setIssuedAt(new Date()) // JWT 토큰 발행 시간
.setExpiration(new Date(System.currentTimeMillis() + jwtConfig.getExpiration())) // JWT 토큰 만료 시간
.signWith(SignatureAlgorithm.HS512, jwtConfig.getSecret()) // JWT 토큰 서명
.compact();
return token;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// JWT 토큰 유효성 검증
public boolean validateToken(String token) {
try {
// 토큰의 서명을 확인하여 변조 여부를 검사
Claims claims = Jwts.parser().setSigningKey(jwtConfig.getSecret()).parseClaimsJws(token).getBody();
// 토큰의 만료 시간을 확인하여 만료 여부를 검사
Date expirationDate = claims.getExpiration();
Date now = new Date();
if(expirationDate.before(now)) {
System.out.println("토큰이 만료됨");
return false; // 토큰이 만료됨
}
// 필요한 경우 추가적인 유효성 검사를 수행
return true; // 토큰 유효성 검증 성공
} catch (Exception ex) {
System.out.println("토큰 유효성 검증 실패");
return false; // 토큰 유효성 검증 실패
}
}
// 토큰 추출 로직 구현
public String extractToken(HttpServletRequest request) {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (StringUtils.hasText(header) && header.startsWith("Bearer ")) {
return header.substring("Bearer ".length());
} else {
return null;
}
}
/*
public String extractTokenFromRequest(HttpServletRequest request) {
// String header = request.getHeader(HttpHeaders.AUTHORIZATION);
String authorizationHeader = request.getHeader("Authorization");
if(authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
return authorizationHeader.substring(7);
} else {
return null;
}
}
*/
// 토큰으로부터 인증 객체 생성 로직 구현
private Authentication getAuthentication(String token) {
Claims claims = Jwts.parser().setSigningKey(jwtConfig.getSecret()).parseClaimsJws(token).getBody();
// 토큰에서 필요한 정보 추출
String username = claims.getSubject();
List<String> roles = claims.get("roles", List.class);
// 인증 객체 생성
List<GrantedAuthority> authorities = roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return new UsernamePasswordAuthenticationToken(username, null, authorities);
}
// 인증 실패 처리를 수행합니다. (예: 오류 응답 반환)
private void handleAuthenticationFailure(HttpServletRequest request, HttpServletResponse response) throws IOException {
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html;charset=utf-8");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write("유효하지 않은 토큰 또는 만료된 토큰");
}
// 토큰 파기
protected void invalidateToken(String token) {
// 토큰이 블랙리스트에 없으면 추가
if(tokenBlacklist.contains(token) == false) {
tokenBlacklist.add(token);
}
}
// 토큰이 블랙리스트에 있는지 확인
public boolean isTokenBlacklisted(String token) {
return tokenBlacklist.contains(token);
}
// JWT 토큰에서 사용자 정보를 추출
public Claims extractClaims(String token) {
// 토큰이 블랙리스트에 있는지 확인 후, 블랙리스트에 없으면 클레임을 추출
if(isTokenBlacklisted(token) == false) {
return Jwts.parser()
.setSigningKey(jwtConfig.getSecret())
.parseClaimsJws(token)
.getBody();
} else {
// 토큰이 블랙리스트에 있다면 유효하지 않은 토큰으로 처리
return null;
}
}
}
③ 사용자 인증 성공시 JWT 토큰을 생성 프로세스
org.example.security 패키지 경로에 SecurityAuthenticationSuccessHandler.java 클래스 파일을 생성한다.
SecurityAuthenticationSuccessHandler 클래스는 스프링 시큐리티에서 인증성공시 호출되어 JWT 토큰을 생성한다.
SecurityAuthenticationSuccessHandler.java
package org.example.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
public class SecurityAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final JwtTokenProvider jwtTokenProvider;
public SecurityAuthenticationSuccessHandler(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
String token = jwtTokenProvider.generateToken(authentication);
token = String.format("Bearer %s",token);
// 인증 성공 시 처리할 로직을 구현합니다.
Map<String, Object> responseBody = new HashMap<>();
responseBody.put("status", "success");
responseBody.put("message", "인증 성공");
responseBody.put("token", token);
// JWT 토큰을 Response Header에 설정
response.addHeader("Authorization", token);
// JSON 형태로 응답을 반환합니다.
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(new ObjectMapper().writeValueAsString(responseBody));
response.getWriter().flush();
}
}
④ 사용자 인증에 실패한 경우의 프로세스
org.example.security 패키지 경로에 SecurityAuthenticationFailureHandler.java 클래스 파일을 생성한다.
SecurityAuthenticationFailureHandler 클래스는 사용자의 인증이 실패한 경우 필요한 응답을 클라이언트에게 반환한다.
SecurityAuthenticationFailureHandler.java
package org.example.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
public class SecurityAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
// 인증 실패 시 처리할 로직을 구현합니다.
Map<String, Object> responseBody = new HashMap<>();
responseBody.put("status", "failure");
responseBody.put("message", "인증 실패");
// JSON 형태로 응답을 반환합니다.
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(new ObjectMapper().writeValueAsString(responseBody));
response.getWriter().flush();
}
}
⑤ 사용자 로그아웃시 JWT 블랙리스트 등록
JwtLogoutSuccessHandler.java
package org.example.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
public class JwtLogoutSuccessHandler implements LogoutSuccessHandler {
private JwtTokenProvider jwtTokenProvider;
public void setJwtTokenProvider(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
Map<String, Object> responseBody = new HashMap<>();
// 로그아웃 성공 시 JWT 토큰 파기 로직 수행
String jwtToken = jwtTokenProvider.extractTokenFromRequest(request);
jwtTokenProvider.invalidateToken(jwtToken);
if(jwtTokenProvider.isTokenBlacklisted(jwtToken) == true) {
// JWT 토큰 사용이 만료 or 파기됨
responseBody.put("status", "success");
responseBody.put("message", "사용자 로그아웃");
} else {
responseBody.put("status", "failure");
responseBody.put("message", "로그아웃 실패");
}
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().write(new ObjectMapper().writeValueAsString(responseBody));
response.getWriter().flush();
}
}
4. 클라이언트의 로그인 요청을 처리하는 컨틀롤러 생성
org.example 패키지에 api 패키지를 하나 추가한다.
org.example.api 패키지가 생성되면 다시 controller 패키지를 추가하여 준다.
org.example.api.controller 패키지에 AccessController.java 클래스 파일을 추가하여 준다.
AccessController 클래스 파일이 생성되면 클라이언트의 로그인 요청에 JWT 토큰을 반환하고
클라이언트의 JWT 토큰의 사용가능 유무를 체크하는 비즈니스 로직을 수행하는 코드를 아래와 같이 작성한다.
AccessController.java
package org.example.api.controller;
import io.jsonwebtoken.Claims;
import org.example.member.service.MemberService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.example.security.JwtTokenProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/access")
public class AccessController {
@Resource(name="memberService")
private MemberService memberService;
@Autowired
@Qualifier("authenticationManager")
private AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
public AccessController(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@RequestMapping(value = "/authenticationProcess.do", method=RequestMethod.POST)
public void authenticate(HttpServletRequest request, RedirectAttributes redirectAttributes) {
String memberId = request.getParameter("memberId");
String memberPw = request.getParameter("memberPw");
try {
// 사용자 인증을 위한 UsernamePasswordAuthenticationToken 객체 생성
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(memberId, memberPw);
// AuthenticationManager를 사용하여 인증 수행
Authentication authentication = authenticationManager.authenticate(token);
// 인증 성공 후 SecurityContext에 인증 객체 설정
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception e) {
redirectAttributes.addAttribute("error", true);
}
}
@RequestMapping(value = "/disconnectionProcess.do", method=RequestMethod.POST)
public void disconnect(HttpServletRequest request, HttpServletResponse response) throws Exception {
Map<String, Object> responseBody = new HashMap<>();
// 로그아웃 성공 시 JWT 토큰 파기 로직 수행
String jwtToken = jwtTokenProvider.extractToken(request);
jwtTokenProvider.invalidateToken(jwtToken);
if(jwtTokenProvider.isTokenBlacklisted(jwtToken) == true) {
// JWT 토큰 사용이 만료 or 파기됨
responseBody.put("status", "success");
responseBody.put("message", "사용자 로그아웃");
} else {
responseBody.put("status", "failure");
responseBody.put("message", "로그아웃 실패");
}
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().write(new ObjectMapper().writeValueAsString(responseBody));
response.getWriter().flush();
}
@RequestMapping(value="/jwtTokenValidation.do", method = RequestMethod.POST)
public void jwtTokenValidation(HttpServletRequest request, HttpServletResponse response) throws Exception {
// 스프링 시큐리티로 사용자 ID 가져오기
// Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// 사용자ID 가져오기
// String username = (String)authentication.getPrincipal();
Map<String, Object> responseBody = new HashMap<>();
String jwtToken = jwtTokenProvider.extractToken(request);
// 토큰이 유효한 경우
if(jwtTokenProvider.isTokenBlacklisted(jwtToken) == false) {
// JWT 토큰 Claim 설정값 가져오기
Claims claims = jwtTokenProvider.extractClaims(jwtToken);
String memberUuid = claims.getSubject();
String memberId = (String)claims.get("memberId");
String memberName = (String)claims.get("memberName");
responseBody.put("status", "success");
responseBody.put("message", "토큰이 유효");
responseBody.put("memberUuid", memberUuid);
responseBody.put("memberId", memberId);
responseBody.put("memberName", memberName);
}
// 토큰이 만료된 경우
else {
responseBody.put("status", "failure");
responseBody.put("message", "토큰만료");
}
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().write(new ObjectMapper().writeValueAsString(responseBody));
response.getWriter().flush();
}
}
Apache Tomcat을 실행하고 브라우저를 오픈하면 아래와 같이
`유효하지 않은 토큰 또는 만료된 토큰` 이라는 메시지가 출력된다.
1) API - 사용자 로그인 처리를 진행하고 JWT 토큰을 반환 하는 요청 수행
[ POST ] http://localhost:8181/access/authenticationProcess.do
POST맨을 통해서 사용자 로그인 처리를 수행하는 API 요청을 수행하면
"Bearer Json_Web_Token"을 반환하게 된다.
여기서 Bearer을 제외한 Json_Web_Token부분을 복사한다.
2) API - 발급받은 JWT 토큰 사용가능 유무 확인
[ POST ] http://localhost:8181/access/jwtTokenValidation.do
POST맨에서 Authorization에서 Bearer Token을 선택하고 위에서 복사한 JWT 토큰 값을 복사하여 붙여넣기 하여 준다.
이후 [Send] 버튼을 클릭하고 위 이미지와 같이 "success"값을 반환하면 JWT 토큰 인증 작업 또한 마무리 되었다.
레디스 이용한 JWT 토큰 관리
build.gradle
~~ 이 하 생 략 ~~
dependencies {
~~ 이 하 생 략 ~~
// Spring Data Redis
implementation "org.springframework.data:spring-data-redis:2.7.18"
// Jedis (Redis Java Client)
implementation "redis.clients:jedis:3.10.0"
~~ 이 하 생 략 ~~
}
test {
useJUnitPlatform()
}
plugins {
id "java"
id "war"
}
apply plugin : "war"
group "org.example"
version "0.0.1-SNAPSHOT"
sourceCompatibility = 1.8
repositories {
mavenCentral()
}
dependencies {
testImplementation "org.junit.jupiter:junit-jupiter-api:5.8.1"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.8.1"
// Servelt API
providedCompile "javax.servlet:servlet-api:2.5"
// JSTL
implementation "javax.servlet:jstl:1.2"
// Spring Framework
implementation "org.springframework:spring-aop:5.2.22.RELEASE"
implementation "org.springframework:spring-beans:5.2.22.RELEASE"
implementation "org.springframework:spring-context:5.2.22.RELEASE"
implementation "org.springframework:spring-core:5.2.22.RELEASE"
implementation "org.springframework:spring-expression:5.2.22.RELEASE"
implementation "org.springframework:spring-jcl:5.2.22.RELEASE"
implementation "org.springframework:spring-tx:5.2.22.RELEASE"
implementation "org.springframework:spring-web:5.2.22.RELEASE"
implementation "org.springframework:spring-webmvc:5.2.22.RELEASE"
implementation "org.springframework:spring-jdbc:5.2.22.RELEASE"
// Spring Data Redis
implementation "org.springframework.data:spring-data-redis:2.7.18"
// Jedis (Redis Java Client)
implementation "redis.clients:jedis:3.10.0"
// Spring Security
implementation "org.springframework.security:spring-security-acl:5.5.0"
implementation "org.springframework.security:spring-security-config:5.5.0"
implementation "org.springframework.security:spring-security-core:5.5.0"
implementation "org.springframework.security:spring-security-crypto:5.5.0"
implementation "org.springframework.security:spring-security-taglibs:5.5.0"
implementation "org.springframework.security:spring-security-web:5.5.0"
// JWT( JsonWebToken )
implementation "io.jsonwebtoken:jjwt:0.9.1"
// log4j
implementation "org.apache.logging.log4j:log4j-api:2.14.1"
implementation "org.apache.logging.log4j:log4j-core:2.14.1"
implementation "org.apache.logging.log4j:log4j-slf4j-impl:2.14.1"
// MariaDB Connect
implementation "org.mariadb.jdbc:mariadb-java-client:2.5.4"
// MyBatis
implementation "org.mybatis:mybatis:3.5.8"
implementation "org.mybatis:mybatis-spring:2.0.6"
// Jackson
implementation "com.fasterxml.jackson.core:jackson-core:2.11.4"
implementation "com.fasterxml.jackson.core:jackson-annotations:2.11.4"
implementation "com.fasterxml.jackson.core:jackson-databind:2.11.4"
}
test {
useJUnitPlatform()
}
redis.properties
# redis.properties
redis.host=192.168.0.10
redis.port=6379
redis.password=1q2w3e
RedisConfig.java
package org.example.redis.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
@PropertySource("classpath:config/redis.properties")
public class RedisConfig {
@Value("${redis.host}")
private String redisHost;
@Value("${redis.port}")
private int redisPort;
@Value("${redis.password}")
private String redisPassword;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
JedisConnectionFactory jedisConnectionFactory = new JedisConnectionFactory();
// 레디스 서버 호스트 및 포트 설정
jedisConnectionFactory.setHostName(redisHost);
jedisConnectionFactory.setPort(redisPort);
jedisConnectionFactory.setPassword(redisPassword);
return jedisConnectionFactory;
}
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
template.setKeySerializer(new StringRedisSerializer());
// 다른 설정을 추가할 수 있음
return template;
}
}
RedisService.java
package org.example.redis.service;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import org.example.security.JwtTokenProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class RedisService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private final String keyPrefix = "jwt:";
public void setData(String key, Object value) {
redisTemplate.opsForValue().set(key, value);
}
public Object getData(String key) {
return redisTemplate.opsForValue().get(key);
}
// Redis에 JWT 토큰 저장
public void saveTokenToRedis(String uuid, String token, long expiration, TimeUnit timeUnit) {
// System.out.println("uuid : " + uuid);
// System.out.println("token : " + token);
// System.out.println("expiration : " + expiration);
// System.out.println("timeUnit : " + timeUnit);
// Redis에 저장시 사용할 key 지정
String key = keyPrefix + uuid;
// JWT 토큰 저장
redisTemplate.opsForValue().set(
key // Redis 저장 Key 설정
, token // JWT 토큰
);
// 토큰 만료시간 설정
redisTemplate.expire(
key // Redis 저장 Key 설정
, expiration // 토큰 만료 시간
, timeUnit // 시간 단위 설정( 초 )
);
}
// Redis에서 JWT 토큰 삭제
public void deleteJwtToken(String uuid) {
// Redis에 저장시 사용할 key 지정
String key = keyPrefix + uuid;
redisTemplate.delete(key);
}
// Redis에서 JWT 토큰값 가져오기
public String getJwtToken(String uuid) {
System.out.println("getJwtToken().uuid : " + uuid);
// Redis에 저장시 사용할 key 지정
String key = keyPrefix + uuid;
return (String) redisTemplate.opsForValue().get(key);
}
}
JwtTokenProvider.java
package org.example.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.example.member.service.MemberDAO;
import org.example.member.service.MemberVO;
import org.example.redis.service.RedisService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.annotation.Resource;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@Component
public class JwtTokenProvider extends OncePerRequestFilter {
@Resource(name="memberDaoMyBatis")
private MemberDAO memberDAO;
@Autowired
private RedisService redisService;
private final JwtConfig jwtConfig;
@Autowired
public void setRedisService(RedisService redisService) {
this.redisService = redisService;
}
@Autowired
public JwtTokenProvider(JwtConfig jwtConfig) {
this.jwtConfig = jwtConfig;
}
// 블랙리스트에 추가되어야 하는 토큰들을 저장하는 공간
private Set<String> tokenBlacklist = new HashSet<>();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = extractToken(request);
// 토큰이 유효한 경우, 컨트롤러 호출
if(StringUtils.hasText(token) == true && validateToken(token) == true) {
Authentication authentication = getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
// 다음 필터 또는 컨트롤러로 요청 전달
filterChain.doFilter(request, response);
} else {
// 토큰이 유효하지 않은 경우, 인증 실패 처리
handleAuthenticationFailure(request, response);
}
}
// JWT 토큰 생성
public String generateToken(Authentication authentication) {
// 사용자 UUID 추출
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
String uuid = userDetails.getUsername();
List<String> roles = Arrays.asList("ROLE_USER", "ROLE_ADMIN");
try {
// 사용자 정보 조회
MemberVO memberVO = memberDAO.selectMemberInfo(uuid);
// JWT 토큰 생성
String token = Jwts.builder()
.setSubject(userDetails.getUsername()) // 사용자 UUID
.claim("roles", roles) // 사용자 권한 역할
.claim("memberId", memberVO.getMemberId()) // 사용자 ID
.claim("memberName", memberVO.getMemberName()) // 사용자 이름
.setIssuedAt(new Date()) // JWT 토큰 발행 시간
.setExpiration(new Date(System.currentTimeMillis() + jwtConfig.getExpiration())) // JWT 토큰 만료 시간( 1시간 설정 )
.signWith(SignatureAlgorithm.HS512, jwtConfig.getSecret()) // JWT 토큰 서명
.compact();
// Redis에 생성한 JWT 토큰을 저장
redisService.saveTokenToRedis(
userDetails.getUsername() // 사용자 UUID
, token // JWT 토큰
, jwtConfig.getExpiration() // 토큰 만료시간
, TimeUnit.MILLISECONDS // 밀리초(milliseconds) 지정
);
return token;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// JWT 토큰 유효성 검증
public boolean validateToken(String token) {
try {
// 토큰의 서명을 확인하여 변조 여부를 검사
Claims claims = Jwts.parser().setSigningKey(jwtConfig.getSecret()).parseClaimsJws(token).getBody();
// 토큰의 만료 시간을 확인하여 만료 여부를 검사
Date expirationDate = claims.getExpiration();
Date now = new Date();
if(expirationDate.before(now)) {
System.out.println("토큰이 만료됨");
return false; // 토큰이 만료됨
}
// 필요한 경우 추가적인 유효성 검사를 수행
return true; // 토큰 유효성 검증 성공
} catch (Exception ex) {
System.out.println("토큰 유효성 검증 실패");
return false; // 토큰 유효성 검증 실패
}
}
// 토큰 추출 로직 구현
public String extractToken(HttpServletRequest request) {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (StringUtils.hasText(header) && header.startsWith("Bearer ")) {
return header.substring("Bearer ".length());
} else {
return null;
}
}
// 토큰으로부터 인증 객체 생성 로직 구현
private Authentication getAuthentication(String token) {
Claims claims = Jwts.parser().setSigningKey(jwtConfig.getSecret()).parseClaimsJws(token).getBody();
// 토큰에서 필요한 정보 추출
String username = claims.getSubject();
List<String> roles = claims.get("roles", List.class);
// 인증 객체 생성
List<GrantedAuthority> authorities = roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return new UsernamePasswordAuthenticationToken(username, null, authorities);
}
// 인증 실패 처리를 수행합니다. (예: 오류 응답 반환)
private void handleAuthenticationFailure(HttpServletRequest request, HttpServletResponse response) throws IOException {
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html;charset=utf-8");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write("유효하지 않은 토큰 또는 만료된 토큰");
}
public String extractTokenFromRequest(HttpServletRequest request) {
// String header = request.getHeader(HttpHeaders.AUTHORIZATION);
String authorizationHeader = request.getHeader("Authorization");
if(authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
return authorizationHeader.substring(7);
} else {
return null;
}
}
// 토큰 파기
public void invalidateToken(String token) {
// Redis에 JWT 토큰이 존재하면 파기
if(checkTokenInCache(token) == true) {
Claims claims = extractClaims(token);
String uuid = claims.getSubject();
redisService.deleteJwtToken(uuid);
}
}
// 토큰이 Redis에 등록되어 있는지 확인
public boolean checkTokenInCache(String token) {
Claims claims = extractClaims(token);
String uuid = claims.getSubject();
String jwtToken = redisService.getJwtToken(uuid);
// 토큰이 존재하지 않음
if(jwtToken == null || jwtToken.isEmpty()) {
return false;
}
// 토큰이 존재
else {
return true;
}
}
// JWT 토큰에서 사용자 정보를 추출
public Claims extractClaims(String token) {
return Jwts.parser()
.setSigningKey(jwtConfig.getSecret())
.parseClaimsJws(token)
.getBody();
}
}
'Spring Web > Spring Framework' 카테고리의 다른 글
[IntelliJ] Console창 한글 깨짐 해결방법 (0) | 2023.07.17 |
---|---|
[Spring] Swagger 웹 서비스 RESTful API 문서 자동생성 (0) | 2023.06.13 |
[Spring] SpringSecurity를 이용한 사용자 인증 프로세스 구축 (0) | 2023.06.13 |
[Spring] Log4j 설정 및 사용하기(log 파일 저장하기) (0) | 2023.06.13 |
[Spring] MVC 패턴 및 MyBatis 사용하는 게시판 제작 (0) | 2023.06.13 |