- 스프링부트 3.2.2 , 스프링 시큐리티 6.2 , JWT 0.12.3 , JPA , Thymeleaf
스프링 시큐리티를 통해 로그인 인증을 구현했는데, 내친김에 JWT까지 같이 사용하기로하여
시큐리티에서 로그인 인증을 완료한뒤, Jwt 토큰을 통해 권한 관리를 하였다.
Jwt 디펜던시 추가
Jwt를 사용하기위한 디펜던시를 build.gradle에 추가해준다.
주의해야할점은 0.11.5 버전과 0.12.3 버전의 JWT Util 메소드 구현방법이 다르므로 나는 0.12.3버전에 맞추어 구현하였다.
스프링 시큐리티 form-login 비활성화
기존에 스프링 시큐리티를 통해 formLogin으로 로그인 처리를 하였다.
시큐리티에서 제공하는 formLogin을 통해 로그인 처리를 하면 시큐리티의 필터중, UsernamePasswordAuthenticationFilter이 UserDetailService를 통해 DB에서 가져온 유저 정보를 바탕으로 로그인 인증처리를 수행한다.
그러나 Jwt를 사용할 것이기때문에 formLogin을 통해 로그인 인증을 하지않고 직접 LoginFilter을 커스텀하여 구현할것이므로 formLogin을 주석처리하였다.
LoginFilter 커스텀 구현
package com.project.findjob.jwt;
import com.project.findjob.model.User;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.io.IOException;
import java.util.Collection;
import java.util.Iterator;
// formlogin을 비활성화 했기때문에 시큐리티에서 로그인을 진행해주는 UsernamePasswordAuthenticationFilter을 구현
@Slf4j
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final JWTUtil jwtUtil;
public LoginFilter(AuthenticationManager authenticationManager,JWTUtil jwtUtil){
this.authenticationManager=authenticationManager;
this.jwtUtil= jwtUtil;
}
// UserDetailService가 DB에서 가져온 유저정보를 비교해서 로그인을 수행해주는 객체
private final AuthenticationManager authenticationManager;
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response){
// 클라이언트로부터 id,pw를 받아온다.
String username = obtainUsername(request);
String password = obtainPassword(request);
log.info("@#login Filter ==>"+username);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username,password,null);
return authenticationManager.authenticate(authToken);
}
// 로그인 성공시 유저권한정보를 가져와 해당정보로 jwt 토큰을 생성해준다.
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,Authentication authentication) throws IOException {
log.info("sucess~!!!!");
// Authentication을 통해 유저정보,권한등을 가져올수있다.
User user = (User) authentication.getPrincipal();
String username = user.getUsername();
// UserDetail의 getAuthorities를 통해 유저의 권한정보를 뽑아내서 Jwt에 넣기위해 String으로 변환
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth = iterator.next();
String role = auth.getAuthority();
// jwt토큰 생성
String token = jwtUtil.createJwt(username, role, 60*60*10L);
// http 응답 헤더에 jwt토큰을 넣는다.
response.addHeader("Authorization", "Bearer " + token);
// response.sendRedirect("/main");
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
log.info("fail....TT");
// 로그인 실패시 401코드 반환
response.setStatus(401);
}
}
UsernamePasswordAuthenticationFilter를 상속받아 LoginFilter를 직접 구현한다.
해당 필터에서 직접 DB에서 가져온 유저 정보를 바탕으로 AuthenticationManager가 로그인 인증처리를 한뒤,
인증에 성공하면 successfulAuthentication() 메소드를, 실패하면 un~ 메소드를 실행한다.
그리고 해당 필터를 시큐리티 SecurityFilterChain에 등록시켜준다.
이렇게하면, 기존에 formLogin이 UsernamePasswordAuthenticationFilter 필터 차례에서 수행하던 자리에 대신
LoginFilter가 끼어들어 위의 설정대로 로그인 인증처리를 한다.
Jwt 비밀키 만들기
application.properties에 Jwt의 서명에 사용할 비밀키를 직접 하드코딩해줬다.
해당 키를 바탕으로 서명 정보를 암호화하므로 해당 비밀키는 보안에 유의해야한다.
JwtUtil 클래스 생성
package com.project.findjob.jwt;
import io.jsonwebtoken.Jwts;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Date;
@Component
public class JWTUtil {
private SecretKey secretKey;
// 하드코딩된 비밀키를 가지고 개인키를 만듬
public JWTUtil(@Value("${spring.jwt.secret}")String secret){
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}
// token을 secretKey를 가지고 검증한뒤, Payload()를 통해 username을 String타입으로 가져온다.
public String getUsername(String token){
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username",String.class);
}
public String getRole(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
}
public Boolean isExpired(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
}
// Jwt 토큰을 빌터패턴으로 생성하는 메소드
public String createJwt(String username,String role,Long expiredMs){
return Jwts.builder()
.claim("username",username)
.claim("role",role)
.issuedAt(new Date(System.currentTimeMillis())) // 토큰 발행시간은 현재시간
.expiration(new Date(System.currentTimeMillis()+ expiredMs)) // 만료시간은 현재시간 + 만료기간
.signWith(secretKey) // 비밀키로 서명한다.
.compact();
}
}
Jwt토큰을 만들기위해 비밀키를 멤버로 header,payload,signature를 암호화 설정하기위한 메소드와
Jwt 토큰의 만료시간,유저정보와 권한등을 담기위한 메소드들을 구현해준다.
그리고 createJwt() 메소드를 통해 Jwt토큰을 생성하는 메소드를 빌더패턴으로 만든다.
해당 JwtUtil 클래스의 메소드들을 사용해서 Jwt 토큰을 만들것이다.
Jwt Filter 생성
package com.project.findjob.jwt;
import com.project.findjob.model.User;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
// Jwt 토큰을 기반으로 다양한 요청에 대한 응답처리를 하기위한 필터
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {
private final JWTUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// request에서 Authorization 헤더를 찾는다. (JwtUtil에서 jwt토큰을 Authorization 헤더에 담았었음)
String authorization= request.getHeader("Authorization");
// 1. jwt 토큰이 있는지없는지 Authorization 헤더 검증
if(authorization==null || !authorization.startsWith("Bearer ")){
log.info("token is null!!!");
filterChain.doFilter(request,response); // 필터체인으로 다음 필터로 request,response 넘김
// 토큰이 없으므로 메소드 종료(필수임)
return;
}
// 2. jwt토큰이 있으면, 토큰을 분리해서 소멸시간을 검증한다.
String token = authorization.split(" ")[1]; // Bearer 부분 제거
if(jwtUtil.isExpired(token)){ // 소멸시간이 만료되면
filterChain.doFilter(request,response);
return;
}
// 토큰에서 username과 role 가져옴
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
// user Entity를 생성해서 값을 세팅
User user = new User();
user.setUserid(username);
user.setPassword("temppassword");
//스프링 시큐리티 인증 토큰 생성
Authentication authToken = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
//세션에 사용자 등록
SecurityContextHolder.getContext().setAuthentication(authToken);
log.info("@#세션등록 완료!!");
}
}
로그인 처리를 하고나서, 클라이언트에게 응답할때 Jwt토큰을 만들어서 헤더에 담아서 보내야한다.
따라서 JwtFilter라는 필터를 만들어 로그인 인증이 완료되었다면, Jwt 토큰을 생성하고,
소멸시간이 만료되거나 토큰이 없다면 해당 메소드가 실행되지않고 종료될수있도록 처리를 해준다.
모든 인증이 완료되었다면 이전에 구현한 createToken 메소드를 통해 jwt토큰을 생성한뒤, 헤더에 토큰을 담아 응답해준다.
PostMan을 통한 로그인 테스트
/login URL로 Post요청을 보내면, 이전의 시큐리티 필터체인에 등록해둔 Login 필터가 해당 요청을 받아
로그인 인증처리를 한뒤, 헤더에 Authorization 이라는 이름으로 Jwt 토큰을 담아서 응답해준다.
위의 빨간색 네모박스에서 Bearer eyJh@#!$#!$~~~ 가 Jwt 토큰이다.
이제, 기존의 시큐리티 세션이 아니라 JWT토큰을 통해서 서버는 STATLESS상태를 유지할수있고,
클라이언트의 정보를 기억하지않고 권한이 필요한 api에 접근할때에는 클라이언트는 헤더에 Jwt토큰을 담아 요청을 보내
서버에서 jwt토큰을 기반으로 응답해줄수 있게되었다!
참고한 사이트 : https://substantial-park-a17.notion.site/JWT-7a5cd1cf278a407fae9f35166da5ab03
※해당 게시글은 위 게시글을 참고하여 개인적으로 공부한 내용으로 틀린 내용이 있을수있습니다.
'Category > Project' 카테고리의 다른 글
[개인프로젝트] 6일차 - JPA 다대다 테이블 매핑 순환참조 문제 (0) | 2024.02.22 |
---|---|
[개인프로젝트] 5일차 - 카카오 주소 api를 사용해서 네이버 map api 표현하기 (0) | 2024.02.22 |
[개인프로젝트] 2일차 - 시큐리티 권한 세팅하기, 영속성 컨텍스트 (0) | 2024.02.19 |
[팀프로젝트] 15일차 - Sse를 이용해 실시간 알림기능 구현2 (0) | 2024.02.02 |
[팀프로젝트] 14일차 - SSE를 사용한 실시간 알림 기능 구현 (1) | 2024.02.01 |