
스프링부트 시큐리티 코드는 아직 잘 모르겠다. 미완성이기도 하고 각 코드가 정확히 무슨일을 하는건지 설명하라고 하면 절대 못 할것같은 느낌.
어차피 프로젝트를 진행하면서 권한에대해서 직접 설정도 해야하고 하니 그때나 돼서야 어느정도 감을 잡지 않을까 싶다.
그래도 오늘 JWT에 대해 어느정도 알게된거같아 그 내용에 대해서 정리해보려 한다.
- Cookie? Session? Token?
나는 개발 공부를 하며 느끼는건데 항상 무언가 새로운 기술을 배울때 그걸 왜 쓰는지를 아는게 가장 중요한거같다.
그래서 토큰 정리를 하는겸 인증방식 3종류에 대해 먼저 정리를 하는게 순서일거같다.
인증방식에는 Cookie, Session, Token 이렇게 3종류가 있고, 각각 무엇인지 그리고 어떨때 쓰는건지 알아보자
쿠키는 클라이언트의 브라우저에 설치되는 작은 기록 정보파일이라고한다. 이거를 각각 사용자 브라우저에 저장하여 그것으로 고유 정보 식별을 한다.
1. 브라우저가 서버에 요청을 보낸다.
2. 서버는 클라이언트의 요청에 대한 응답을 작성할 때, 클라이언트 측에 저장하고 싶은 정보를 응답 헤더의 Set-Cookie에 담는다. (아마 사이트들에서 가끔 뜨는 쿠키를 허용하시겠습니까? 가 이 단계이지 싶다.)
3. 서버는 쿠키에 담긴 정보를 바탕으로 해당 요청의 클라이언트가 누군지 식별하 거나 정보를 바탕으로 추천 광고를 띄우거나 한다.
단점 : 가장 큰 단점은 보안에 취약하다는 점이다. 요청 시 쿠키의 값을 그대로 보내기 때문에 유출 및 조작 당할 위험이 존재한다. 쿠키에는 용량 제한이 있어 많은 정보를 담을 수 없다
이렇기 때문에 요즘은 아예 쓰지 않는 방식인거같다.
다음은 세션이다. 세션은 서버측에서 클라이언트의 정보를 관리하며 서버의 메모리, 로컬파일 또는 데이터베이스에 저장한다고 한다. 우리가 1차프로젝트때 세션방식을 썼었는데 세션을 어디다 저장하고 한 기억이 없어서 우리는 어디다 저장한건가 궁금해서 질문을 드렸더니 톰캣이 알아서 다 해줬다고 한다. 갓 톰캣
(1) 유저가 웹사이트에서 로그인하면 세션이 서버 메모리(혹은 데이터베이스) 상 에 저장된다. 이때, 세션을 식별하기 위한 Session Id를 기준으로 정보를 저장한다.
(2) 서버에서 브라우저에 쿠키에다가 Session Id를 저장한다.
(3) 쿠키에 정보가 담겨있기 때문에 브라우저는 해당 사이트에 대한 모든 Request에 Session Id를 쿠키에 담아 전송한다. (4) 서버는 클라이언트가 보낸 Session Id 와 서버 메모리로 관리하고 있는 Session Id를 비교하여 인증을 수행한다.
쿠키에 담긴 정보는 Session Id밖에 없어서 쿠키방식에 비해 훨씬 안전하긴 하지만 해커가 ID를 탈취하여 클라이언트인척 위장할 수 있다는 한계가 있다고 한다. 그리고 단점은 딱 봐도 보이겠지만 모든 인증을 서버에서 저장하고 관리하므로 요청이 많아지면 서버에 부하가 심해진다.
그러고 이러한 단점을 극복하기 위해서 나온게 토큰기반 인증 시스템 이라고 한다.
(1) 사용자가 아이디와 비밀번호로 로그인을 한다.
(2) 서버 측에서 사용자(클라이언트)에게 유일한 토큰을 발급한다.
(3) 클라이언트는 서버 측에서 전달받은 토큰을 쿠키나 스토리지에 저장해 두고, 서버에 요청을 할 때마다 해당 토큰을 HTTP 요청 헤더에 포함시켜 전달한다.
(4) 서버는 전달받은 토큰을 검증하고 요청에 응답한다.
(5) 토큰에는 요청한 사람의 정보가 담겨있으므로 서버는 DB를 조회하지 않고 누가 요청하는지 알 수 있다.
단점 : 1. 쿠키/세션과 다르게 토큰 자체의 데이터 길이가 길어, 인증 요청이 많아질수록 네트워크 부하가 심해질 수 있다.
2.Payload 자체는 암호화되지 않기 때문에 유저의 중요한 정보는 담을 수 없다.
3.토큰을 탈취당하면 대처하기 어렵다. (따라서 사용 기간 제한을 설정하는 식으 로 극복한다)
이 단점들을 이해하려면 토큰이 어떻게 생겨있는지 부터 알아야한다.
토큰 헤더, 페이로드, 서명 세 부분으로 되어있고 헤더와 페이로드는 base64로 인코딩 될 뿐 암호화는 되지 않는다.
이렇게 생겼고 헤더에는 알고리즘과 토큰 유형이 들어간다.
왜인진 모르겠지만 다들 HS256을 쓰는듯 하다. 정보처리기사 공부할때 암호화 알고리즘을 몇 개 외웠던거같은 기억이,,
그 다음은 페이로드 아래 실습코드를 보면 알겠지만 웹사이트를 이동하며 필요한 정보들을 여기에 key-value로 이루어진 Claim에 담는다. 그러고 꺼내서 쓰는듯 하다.
클레임에 세가지 종류가 있고 스프링 부트에서 각각에 따라 다른 메소드로 등록하는거같던데 잘 모르겠다. 이정도만 이해하면 될듯,,
그리고 아래 시그니쳐가 개인 서명인데, 여기에 암호화 알고리즘이 들어가고 서버에서 정해놓은 코드를 알지 못하면 복호화할 수 없다고 한다.
jwt.secret=JsdkfkawnklnlkfkJAJFKfdskdsklglzdflkdsf4243JFqfasklzzkdfs424QKlzdfl214NFJKFNKLfdsdfsklaewnfnkl
오늘 실습에서는 application.properties에 이렇게 설정했다. 어디서 생성한건 아니고 막 쳤다. 생성하는 사이트도 있고 어제 간단히 해봤을때 깃 명령어로도 했었는데 오늘은 귀찮아서 그냥 쳤다.
여기 정리하다 갑자기 생각났는데 사람들이 저거를 영상보고 따라하는 사람들이 많아서( 심지어 실무에서도) 저 시크릿 코드를 영상에서 secret으로 해두었는데 그 시크릿코드마저 따라서 secret으로 해놔서 털린 사이트가 있다고 한다. 생각난김에 관련 영상을 다시한번 보고왔다. 확실히 이 분 영상을 보면 깔끔하게 정리해주셔서 너무 좋은듯. 뭔가 예시도 MZ하다.
https://www.youtube.com/watch?v=XXseiON9CV0
영상을 다시보고 알았는데 내일 배울 refresh토큰이 access토큰 유효기간이 지나면 refresh토큰을 새로 발급한다 ? 그런 느낌으로 이해했었는데 (이제보니 뭔가 말이안되기도 하고) Access토큰과 Refresh토큰을 두 개를 동시에 발급해주고 Refresh토큰은 유효기간을 길게 설정해서 이 유효기간이 안지났다면 Access토큰 유효기간이 지나면 다시 재발급을 받는 식인것 같다. 내일 강의들을때 혼란이 조금 덜해질거같다. 영상 다시보길 잘했네
# 실습코드 ( 아직 미완성이고 에러많음 )
JWTUtil
package org.ict.test_security.security.jwt.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.ict.test_security.member.model.dto.MemberDto;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import org.ict.test_security.member.model.service.MemberService;
@Component // 스프링 컨테이너에 의해 관리되는 컴포넌트로 선언함
public class JWTUtil {
//JWT생성과 검증에 사용될 비밀키와 만료 시간을 필드로 선언함
private final SecretKey secretKey;
private final MemberService memberService;
//생성자를 통해 application.properties 에서 정의한 jwt 비밀키와 만료 시간, MemberService 를 주입함
public JWTUtil(@Value("${jwt.secret}") String secret, MemberService memberService){
// 비밀키 초기화함, 이 비밀키는 서명에 사용됨
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), SignatureAlgorithm.HS256.getJcaName());
this.memberService = memberService; //로그인한 사용자 정보를 조회하기 위해 인스턴스 초기화함
}
//JWT 생성 : 로그인한 사용자의 아이디를 저장함
//userId : 로그인 요청 사용자 아이디 받음, category : 토큰의 종류 (access, refresh), expiredMs: 만료시한 밀리초
public String generateToken(String userId, String category, Long expiredMs){
//MemberService 사용해서 db에서 로그인한 사용자 정보를 조회해 옴
MemberDto memberDto = memberService.selectMember(userId);
//사용자 정보가 없는 경우, UsernameNotFoundException(스프링 제공함) 을 발생시킴
if(memberDto == null){
throw new UsernameNotFoundException("Userid : " + userId + "not found");
}
//사용자의 관리자 여부 확인
String adminYN = memberDto.getAdminYN();
//JWT생성 : 사용자아이디를 주제(subject)에 넣고 관리자 여부는 클레임으로 추가함 (임의대로 정함)
return Jwts.builder()
.setSubject(userId)
.claim("admin",adminYN) //"admin"클레임에 관리자여부 등록함
.claim("category", category) //토큰의 종류
//.claim("username", memberDto.getUserName()) //더 추가하고싶은 내용은 claim으로 추가하면됨 . 쓸대없는 값 넣지말것, 비밀번호 넣지말 것
.setExpiration(new Date(System.currentTimeMillis() + expiredMs)) //토큰의 만료시간 // 현재시간 + 전달받은 expiredMs
.signWith(secretKey, SignatureAlgorithm.HS256) //비밀키와 HS256알고리즘으로 JWT를 서명함
.compact(); //jwt문자열을 만듦
}
//JWT에서 사용자 아이디 추출하는 메서드
public String getUserIdFromToken(String token){
Claims claims = Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody();
return claims.getSubject();
}
//JWT 의 만료 여부 확인용 메서드
public boolean isTokenExpired(String token){
Claims claims = Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody();
return claims.getExpiration().before(new Date()); //true : 만료, false : 만료안됨
}
//JWT에서 관리자 여부 추출하는 메서드
public String getAdminFromToken(String token){
Claims claims = Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody();
return claims.get("admin", String.class);
}
//JWT에서 등록된 토큰종류 추출하는 메서드
public String getCategoryFromToken(String token){
Claims claims = Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody();
return claims.get("category", String.class);
}
}
JWTFilter
package org.ict.test_security.security.jwt.filter;
import io.jsonwebtoken.ExpiredJwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.tomcat.util.net.openssl.ciphers.Authentication;
import org.ict.test_security.member.model.dto.CustomUserDetails;
import org.ict.test_security.member.model.dto.MemberDto;
import org.ict.test_security.security.jwt.util.JWTUtil;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.io.PrintWriter;
@Slf4j //Lombok의 Slf4j 어노테이션 사용해서 로깅 기능을 자동 추가함
//Spring Security의 OncePerRequestFilter를 상속받음
//모든 요청에 대해 한번씩 실행되는 필터가 됨
public class JWTFilter extends OncePerRequestFilter {
//JWT 관련 유틸리티 메서드를 제공하는 JWTUtil 의 인스턴스를 멤버로 선언함
private final JWTUtil jwtUtil;
//생성자를 통해 멤버변수 의존성 주입함
public JWTFilter(JWTUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
//필터의 주요 로직을 구현하는 메서드임
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//요청(request)에서 'Authorization' 헤더를 추출함
String authorization = request.getHeader("Authorization");
String requestURI = request.getRequestURI(); //누구한테로 요청이 가는 서비스인지 /boards or /members ?
if("/logout".equals(requestURI)) {
//찾아갈 대상으로 그대로 넘김
filterChain.doFilter(request, response);
return;
}//토큰 확인이 필요없는 요청(로그인하지 않고 이용하는 서비스 url)은 그대로 다음 단계로 넘김
//'Authorization' 이 헤더에 없거나 Bearer토큰이 아니면 요청을 계속 진행함
if (authorization == null || !authorization.startsWith("Bearer")){
filterChain.doFilter(request, response);
return;
}
// Bearer 토큰에서 JWT를 추출함 (토큰 정보가 request헤더에 있는 경우)
String token = authorization.split(" ")[0];
//토큰 만료 여부 확인, 만료시 다음 필터로 넘기지 않음
try{
jwtUtil.isTokenExpired(token);
}
catch(ExpiredJwtException e){
//response body
PrintWriter writer = response.getWriter();
writer.println("access token expired");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
//token 에서 category (access, refresh) 추출
String category = jwtUtil.getCategoryFromToken(token);
//토큰 category 가 access 가 아니라면 만료된 토큰으로 판단함
if(!category.equals("access")){
//response body
PrintWriter writer = response.getWriter();
writer.println("invalid token expired");
//response status code
//응답 코드를 front와 맞추는 부분 401 에러 외 다른 상태코드로 맞추면
//리프레시 토큰 발급 체크를 좀 더 빠르게 할 수 있음
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
//위의 조건들에 해당되지 않으면, 정상적인 만료되지 않은 access 토큰으로 요청이 왔다면
//사용자 아이디(또는 이메일), 관리자 여부 추출함
String userId = jwtUtil.getUserIdFromToken(token);
String adminYN = jwtUtil.getAdminFromToken(token);
//인증에 사용할 임시 User객체를 생성하고, 추출한 정보 저장함
MemberDto user = new MemberDto();
user.setUserId(userId);
user.setAdminYN(adminYN);
user.setUserPwd("temp"); //실제 인증에서는 사용되지 않는 임시 비밀번호를 설정함
//아래 Authentication 객체에 password가 들어가야함
//인증에 사용할 user 를 기반으로 한 CustomUserDetail 객체가 필요함
CustomUserDetails userDetails = new CustomUserDetails(user);
//Spring Security 의 Authentication 객체를 생성하고, SecurityContext 에 등록 설정함
//이것으로 해당 요청에 대한 사용자 인증이 완료됨
Authentication authToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
//필터 체인을 계속 진행함
filterChain.doFilter(request, response);
}
}
'국비지원교육 > Spring boot' 카테고리의 다른 글
스프링부트 테스트코드 작성 (0) | 2024.05.16 |
---|---|
리액트/ 스프링부트 연동하기 (0) | 2024.05.16 |
스프링부트 - Security (0) | 2024.05.08 |
정리 시작 (0) | 2024.05.08 |