Spring Security를 공부하면서 검색 할 때마다 JWT인증이라는 글도 관련해서 자주 보여서 이번 글에서는 JWT에대해서 공부를 해보려고 합니다.. =)
JWT...?
JWT(JSON Web Token)는 웹 애플리케이션에서 사용자 인증 및 권한 부여를 위한 토큰 기반 인증 시스템으로 서버에 세션 정보를 저장하지 않고, 클라이언트가 자체적으로 인증 정보를 저장해서 서버의 부하를 줄이고, 확장성을 높일 수 있는 장점이 있다.
- Stateless : 서버에서 상태를 유지할 필요가 없어 확장성이 높은 시스템 구현이 가능하다.
- 자체 정보 저장 : JWT에 사용자 정보나 권한 정보를 포함시킬 수 있어서 추가적인 데이터베이스 조회가 필요없다.
- 간편한 관리 : 토큰을 클라이언트에서 관리해 서버 측에서는 세션을 저장할 필요가 없다.
Spring Security와 JWT를 결합하여 Stateless 인증 구현
Spring Security는 기본적으로 Stateful 인증을 사용하여 세션을 통해 인증 정보를 관리합니다. 하지만 JWT를 사용하면 서버에서 상태 정보를 저장할 필요 없이 클라이언트가 JWT를 저장하고 이를 통해 인증을 처리할 수 있습니다.
- 사용자가 로그인 시 자격 증명을 서버로 전송.
- 서버는 자격 증명이 유효하면 JWT를 생성해 클라이언트에 전달.
- 클라이언트는 JWT를 로컬 저장소에 저장하고, 이후 요청 시 JWT를 Authorization 헤더에 포함시켜 서버에 전송.
- 서버는 JWT를 확인해 사용자의 인증 정보를 검증.
Token???
- Access Token : 사용자가 서버에 요청할 때 사용하는 토큰.(일정 시간이 지나면 자동으로 만료)
- Refresh Token : Access Token이 만료된 후 새로운 Access Token을 발급받기 위한 토큰.(더 긴 유효시간)
Access Token은 인증을 위한 단기 토큰으로 사용하고, Refresh Token은 Access Token을 갱신하는 용도로 사용합니다.
기존 프로젝트에서 실습을 위한 의존성 주입
pom.xml
(버전은 Maven Repository에 jjwt 입력후 받으시면 됩니다.)
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
의존성 주입 후 maven update 해주세요.
JWT 생성 및 검증 유틸리티 클래스 작성
package com.demo.config;
import java.util.Date;
import org.springframework.stereotype.Component;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import jakarta.annotation.PostConstruct;
@Component
public class JwtUtil {
@PostConstruct
public void init() {
System.out.println("JwtUtil initialized successfully!");
}
private static final String SECRET_KEY = "secret_key";
private static final long EXPIRATION_TIME = 86400000; // 1일
// JWT 생성
public static String generateToken(String username) {
return Jwts.builder()
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();
}
// JWT 유효성 검증
public static boolean validateToken(String token, String username) {
return username.equals(extractUsername(token)) && !isTokenExpired(token);
}
static String extractUsername(String token) {
return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody().getSubject();
}
private static boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
private static Date extractExpiration(String token) {
return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody().getExpiration();
}
}
- 상수 변수
- SECRET_KEY : JWT를 서명할 때 사용되는 비밀 키.(토큰 위조 방지)
- EXPIRATION_TIME : JWT 만료 시간 정의.(여기서는 1일 = 만료 시간 이후 유효하지 않음)
- generateToken(String username) : username을 입력받아 JWT를 생성하는 메서드
- setSubject(username) : JWT의 subject 필드에 사용자명을 주입.(일반적으로 사용자의 고유 식별자를 저장)
- setIssuedAt(new Date()) : JWT 발급 일시를 현재 시간으로 설정.
- setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) : JWT 만료 시간 설정. (현재 시간 + EXPIRATION_TIME)
- signWith(SignatureAlgorithm.HS256, SECRET_KEY) : HS256 서명 알고리즘을 사용, SECRET_KEY로 서명해 JWT의 무결성을 보장.
- .compact(); : JWT를 문자열 형식으로 생성.
- validateToken(String token, String username) : JWT의 유효 검사 메서드
- extractUsername(token) : JWT에서 사용자명을 추출해 입력된 username과 비교.
- isTokenExpired(token) : JWT가 만료되지 않았는지 확인.
- 두 조건이 모두 만족하면 true를 반환하고, 그렇지 않으면 false를 반환하여 유효하지 않은 토큰을 거부합니다.
- extractUsername(String token) : JWT에서 사용자명 추출 메서드
- Jwts.parser().setSigningKey(SECRET_KEY) : SECRET_KEY를 사용하여 JWT를 파싱.
- parseClaimsJws(token) : JWT를 파싱하여 Claims 객체를 반환.
- .getBody().getSubject(); : Claims 객체에서 subject 값을 추출하여 사용자명을 반환.
- isTokenExpired(String token) : JWT 만료 여부 확인 메서드
- extractExpiration(token) : JWT에서 만료 시간 추출.
- .before(new Date()); : 만료 시간이 현재 시간 이전이면 ture, 아니면 false 반환.
- extractExpiration(String token) : JWT에서 만료 시간 추출 메서드
- .getExpiration(); : claims 객체에서 만료 시간을 반환.
JWT 필터 구현
(각 요청에 대한 JWT를 검사하고, 인증을 처리)
package com.demo.config;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@WebFilter("/*") // 모든 요청에 대해 적용
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// JWT 토큰 추출 및 유효성 검사
String token = extractJwtToken(request);
if(token != null && isValidToken(token)) {
// 유효 JWT 토큰이면 인증 처리 진행
authenticateWithJwtToken(token);
}
// 필터 체인 계속 처리
filterChain.doFilter(request, response);
}
// JWT 토큰을 요청에서 추출하는 메서드
private String extractJwtToken(HttpServletRequest request) {
String header = request.getHeader("authorization"); // Authorization 헤더에서 추출
if(header != null && header.startsWith("Bearer")) {
return header.substring(7); // Bearer 이후 토큰 부분만 추출
}
return null;
}
// JWT 토큰 유효 확인
private boolean isValidToken(String token) {
// token에서 username을 추출하고, 토큰의 유효성 검사
String username = jwtUtil.extractUsername(token);
return JwtUtil.validateToken(token, username); // 예시로 true 반환
}
// JWT 토큰 기반 인등 설정
private void authenticateWithJwtToken(String token) {
// 토큰에서 username 추출
String username = jwtUtil.extractUsername(token);
// 인증 객체 생성 (사용자 이름과 역할 정보)
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
username, null, null); // 권한은 필요에 따라 설정
// SecurityContext에 인증 설정
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
- doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) :
- extractJwtToken(request); : 요청 헤더에서 jwt 토큰 추출.
- 추출한 토큰이 null이 아니고 유효한 토큰이라면, authenticateWithJwtToken(token);메서드를 통해 인증을 처리.
- filterChain.doFilter(request, response); : 필터 체인에서 다음 필터가 실행.
- extractJwtToken(HttpServletRequest request) : HTTP 요청 헤더에서 JWT 토큰을 추출하는 역할.
- request.getHeader("authorization"); : 요청 헤더에서 authorization헤더를 가져온다.
- header.startsWith("Bearer") : 이 헤더 값이 Bearer로 시작하는지 확인한 후,
header.substring(7); : Bearer 이후의 실제 JWT 토큰 부분을 추출하여 반환.
- isValidToken(String token) : JWT 토큰의 유효성을 검사하는 로직
- jwtUtil.extractUsername(token); : 토큰에서 사용자명을 추출
- JwtUtil.validateToken(token, username); : 토큰이 유효하면 true, 그렇지 않으면 false를 반환합니다.
- authenticateWithJwtToken(String token) : Spring Security의 SecurityContext에 인증 정보를 설정
- jwtUtil.extractUsername(token); : jwt 토큰에서 사용자명 추출.
- UsernamePasswordAuthenticationToken : 객체를 생성하고,
new UsernamePasswordAuthenticationToken(username, null, null); : 사용자 이름, 패스워드(여기서는 null), 사용자 권한(여기서는 null)을 인자로 받습니다. - SecurityContextHolder.getContext().setAuthentication(authentication); : 인증 정보를 저장.(저장된 인증 정보는 이후 SecurityContext를 통해 접근 가능.)
Security Configuration 클래스 설정
(Spring Security 설정을 통해 JWT 필터를 등록하고, 로그인 후 JWT를 발급하는 로직을 구현합니다.)
package com.demo.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import com.demo.service.MemberDetailService;
import jakarta.servlet.Filter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private MemberDetailService memberDetailService;
@Autowired
private JwtUtil jwtUtil;
@Autowired
private JwtAuthenticationSuccessHandler jwtAuthenticationSuccessHandler;
// 비밀번호 암호화
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// SecurityFilterChain 설정
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.requestMatchers("/login", "/register").permitAll() // 로그인, 회원가입은 누구나 접근 가능
.requestMatchers("/admin/**").hasRole("ADMIN") // 관리자 페이지는 관리자만 접근 가능
.anyRequest().authenticated() // 나머지 페이지는 인증된 사용자만 접근 가능
.and()
.formLogin()
.loginPage("/login") // 커스텀 로그인 페이지 URL
.loginProcessingUrl("/login") // 로그인 처리 URL
.successHandler(jwtAuthenticationSuccessHandler) // 로그인 성공 시 JWT 발급
.defaultSuccessUrl("/main", true) // 로그인 성공 후 메인페이지로 리다이렉트
.failureUrl("/login?error=true") // 로그인 실패 시 에러 URL
.and()
.logout()
.logoutUrl("/logout") // 로그아웃 URL
.logoutSuccessUrl("/login?logout=true") // 로그아웃 후 리다이렉트 URL
.and()
.csrf().disable(); // CSRF 비활성화 (개발 환경에서는 활성화 권장)
// JWT 필터를 UsernamePasswordAuthenticationFilter 전에 추가
http.addFilterBefore(new JwtAuthenticationFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(memberDetailService).passwordEncoder(passwordEncoder());
}
}
- 수정 부분
- 로그인 성공 시 JWT 발급 : formLogin()의 .successHandler(jwtAuthenticationSuccessHandler) 설정으로,
로그인을 성공하면 jwtAuthenticationSuccessHandler 가 실행. - JWT 인증 필터 추가 : http.addFilterBefore(new JwtAuthenticationFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); JwtAuthenticationFilter 는 요청에 포함된 JWT를 검증하고 인증 객체를 SecurityContext에 설정하고, UsernamePasswordAuthenticationFilter 이전에 추가되어 요청의 JWT를 먼저 처리합니다.
- 로그인 성공 시 JWT 발급 : formLogin()의 .successHandler(jwtAuthenticationSuccessHandler) 설정으로,
JwtAuthenticationSuccessHandler 클래스 구현
(로그인 성공 후 JWT를 발급하고, 응답으로 반환)
package com.demo.config;
import java.io.IOException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Component
public class JwtAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final JwtUtil jwtUtil;
// 생성자에서 JwtUtil을 주입 받음
public JwtAuthenticationSuccessHandler(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
// 로그인 성공 후 인증된 사용자의 username을 가져옴
String username = ((User) authentication.getPrincipal()).getUsername();
// JWT 토큰 생성
String token = JwtUtil.generateToken(username);
// JWT를 Authorization 헤더에 추가하여 응답
response.setHeader("Authorization", "Bearer " + token);
// JWT를 응답에 포함시켜 클라이언트에게 반환
response.setContentType("application/json");
response.getWriter().write("{\"token\":\"" + token + "\"}");
}
}
- AuthenticationSuccessHandler : 인증이 성공했을 때 실행되는 로직을 정의하는 인터페이스
- JWT 생성 (JwtUtil.generateToken(username)) : 인증된 사용자의 username을 사용해 JWT를 생성.(이 메서드는 JwtUtil 클래스의 static 메서드를 호출)
- 클라이언트 응답: 생성한 JWT를 JSON 형식으로 클라이언트에 반환합니다. 이 토큰은 이후 요청에서 인증을 위한 헤더에 포함됩니다.
++ 수정 ++
JWT 관련 기능 테스트를 위한 의존성 주입 추가
테스트 코드 작성
- 로그인 성공 시 JWT 발급 확인
package com.demo;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.Test;
import com.demo.config.JwtUtil;
public class JwtTest {
@Test
public void testGenerateToken() {
// 토큰 생성
String token = JwtUtil.generateToken("TestDragon");
// 생성된 토큰이 null이 아닌지 확인
assertNotNull(token, "Jwt 토큰 생성 실패");
// 발급 토큰 출력
System.out.println("발급 토큰 : " + token);
}
}
JwtUtil 클래스 수정
package com.demo.config;
import java.security.Key;
import java.util.Date;
import org.springframework.stereotype.Component;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
@Component
public class JwtUtil {
private static final String SECRET_KEY = "SecretKey1234567890SecretKey1234567890";
private static final long EXPIRATION_TIME = 86400000; // 1일
private static Key key;
static {
// 객체 초기화
key = Keys.hmacShaKeyFor(SECRET_KEY.getBytes());
System.out.println("JwtUtil init() 성공");
}
// JWT 생성
public static String generateToken(String username) {
return Jwts.builder()
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
// JWT 유효성 검증
public static boolean validateToken(String token, String username) {
return username.equals(extractUsername(token)) && !isTokenExpired(token);
}
// JWT 유효성 검증
static String extractUsername(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
// 토큰 만료 여부 확인
private static boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
// 만료 시간 추출
private static Date extractExpiration(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody()
.getExpiration();
}
}
run as >> JUit Test
Handler 수정
package com.demo.config;
import java.io.IOException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Component
public class JwtAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final JwtUtil jwtUtil;
// 생성자에서 JwtUtil을 주입 받음
public JwtAuthenticationSuccessHandler(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
// 로그인 성공 후 인증된 사용자의 username을 가져옴
String username = ((User) authentication.getPrincipal()).getUsername();
// JWT 토큰 생성
String token = JwtUtil.generateToken(username);
// JWT 토큰을 JSON 형태로 응답에 반환
response.setContentType("application/json");
response.getWriter().write("{\"token\":\"" + token + "\"}");
response.getWriter().flush();
}
}
응답으로 JWT 토큰을 JSON 형태로 반환하고, 클라이언트에서 localStorage에 저장하는 방법을 사용.
html, js 생성 및 수정
- login.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>로그인 페이지</title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script th:src="@{/js/login.js}"></script>
</head>
<body>
<div class="login">
<h1>로그인</h1>
<form action="/login" method="post">
<div>
<input type="text" id="username" name="username" placeholder="아이디" required/>
</div>
<div>
<input type="password" id="password" name="password" placeholder="비밀번호" required/>
</div>
<button type="submit">로그인</button>
</form>
<a th:href="@{/register}">회원가입</a>
</div>
</body>
</html>
- login.js
document.getElementById("loginForm").addEventListener("submit", function(event) {
event.preventDefault();
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
fetch("/login", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: `username=${username}&password=${password}`
})
.then(response => response.json())
.then(data => {
if (data.token) {
// JWT 토큰을 localStorage에 저장
const token = data.token;
localStorage.setItem("jwtToken", token);
// 메인 페이지로 리디렉션
window.location.href = "/main";
} else {
console.log("로그인 실패");
}
})
.catch(error => {
console.error("로그인 실패:", error);
});
});
- main.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>메인 페이지</title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script th:src="@{/js/main.js}"></script>
</head>
<body>
<div class="main">
<h1>메인 화면</h1>
<a th:href="@{/logout}" class="logout">로그아웃</a>
<div th:if="${isAdmin}">
<a th:href="@{/admin/adminMain}" class="page">관리자페이지</a>
</div>
<div th:unless="${isAdmin}">
<a th:href="@{/mypage}" class="page">마이페이지</a>
</div>
</div>
</body>
</html>
- url 경로 매핑 작성
@GetMapping("/mypage")
public String mypageView(Model model, Authentication authentication, HttpServletRequest request) {
Object principal = authentication.getPrincipal();
if (principal instanceof CustomUserDetails) {
CustomUserDetails userDetails = (CustomUserDetails) principal;
model.addAttribute("memberSeq", userDetails.getMemberSeq());
model.addAttribute("id", userDetails.getUsername());
model.addAttribute("name", userDetails.getName());
model.addAttribute("roles", userDetails.getRoles());
}
return "mypage";
}
- mypage.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>마이페이지</title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script th:src="@{/js/mypage.js}"></script>
</head>
<body>
<div class="mypage">
<h1>마이페이지</h1>
<div class="card-container">
<div class="card">
<h2>아이디 : <span th:text="${id}"></span></h2>
<p>이름 : <span th:text="${name}"></span></p>
<p>회원 번호 : <span th:text="${memberSeq}"></span></p>
<p>회원 정보 : <span th:text="${roles}"></span></p>
<p>발급 토큰 : <span th:text="${jwtToken}"></span></p>
</div>
</div>
<a th:href="@{/main}">메인으로</a>
</div>
</body>
</html>
- mypage.js
document.addEventListener('DOMContentLoaded', function() {
// localStorage에서 JWT 토큰 가져오기
const token = localStorage.getItem("jwtToken");
// 토큰이 있으면 HTML 요소에 표시
if (token) {
// 마이페이지에서 발급된 JWT 토큰을 표시하는 부분
document.getElementById("jwtToken").textContent = token;
} else {
console.log("JWT 토큰 없음");
}
});
++ 결과 ++
'개인 공부' 카테고리의 다른 글
Spring Security를 활용한 사용자 권한 기반 접근 제어 (0) | 2024.12.24 |
---|---|
Spring Security를 활용한 로그인!?!? (0) | 2024.12.21 |
MVC 패턴이 뭔데... (1) | 2024.12.18 |