Spring 숙련 - 3주 (2)

ayboori·2023년 6월 21일
0

Spring

목록 보기
6/24

JWT (Json Web Token)

  • JSON 포맷을 이용하여 사용자에 대한 속성을 저장하는 Claim 기반의 Web Token
  • 일반적으로 쿠키 저장소를 사용하여 JWT를 저장

JWT를 사용하는 이유

  • 서버가 1대인 경우
    - 서버의 Session1이 모든 Client의 로그인 정보를 소유하고 있어 어떤 클라이언트가 오건 쉽게 구분 가능
  • 서버가 2대 이상인 경우

    - Session (웹 서버에 저장됨) 마다 각자 다른 Client의 로그인 정보를 가지고 있을 수 있다.
    - Client의 정보를 가지지 않은 Server에 API 요청을 하면 문제가 발생

해결 방법
1. Sticky Session: Client 마다 요청 Server 고정
2. 세션 저장소 생성하여 모든 세션을 저장
Session storage가 모든 Client의 정보를 저장하고 있어 모든 서버에서 모든 클라이언트 처리 가능
3. JWT 사용

- 로그인 정보를 Server 에 저장하지 않고, Client 에 로그인 정보를 JWT 로 암호화하여 저장 → JWT 통해 인증/인가
- 이때 모든 서버에서 동일한 Secret Key 소유

>  Secret Key : 로그인 정보를 암호화, 받아온 JWT 위조 검증 (복호화)

JWT 장/단점

  1. 장점
  • 동시 접속자가 많을 때 서버 측 부하 낮춤

    세션 저장소 사용 시 세션 저장소에 접근하는 부담이 있다.
    Secret Key는 외부 서버에 저장되는 것이 아니라 부담이 적다.

  • Client, Sever 가 다른 도메인을 사용할 때
    예) 카카오 OAuth2 로그인 시 JWT Token 사용 (후에 나옴)
  1. 단점
  • 구현의 복잡도 증가
  • JWT 에 담는 내용이 커질 수록 네트워크 비용 증가 (클라이언트 → 서버)

    JWT의 길이가 길어짐 > HTTP 프로토콜이 무거워짐 > 네트워크 비용 증가

  • 기 생성된 JWT 를 일부만 만료시킬 방법이 없음

    JWT는 클라이언트의 쿠키에 저장됨
    서버 입장에서 쿠키를 임의로 만료시킬 수 없음
    대신 JWT나 쿠키의 만료 기한을 부여할 수 있음

  • Secret key 유출 시 JWT 조작 가능

    비밀번호 등의 민감한 데이터를 담지 말아야 한다

JWT 사용 흐름

Client가 로그인 성공 시

  1. 서버에서 "로그인 정보" → JWT 로 암호화 (Secret Key 사용)
  2. 서버에서 직접 쿠키를 생성해 JWT를 담아 Client 응답에 전달
    • JWT 전달방법은 개발자가 정함
    • 응답 Header에 아래 형태로 JWT 전달
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value
cookie.setPath("/");

// Response 객체에 Cookie 추가
res.addCookie(cookie);
  1. 브라우저 쿠키 저장소에 자동으로 JWT 저장됨
    Cookie의 Authorization을 Name으로 하여 저장됨

Client 에서 JWT 통해 인증방법

  1. 서버에서 API 요청 시마다 Request 객체의 쿠키에 포함된 JWT를 찾아서 사용
    컨트롤러 사용하지 못하고 받을 때 필요한 로직 (아래)
// HttpServletRequest 에서 Cookie Value : JWT 가져오기
public String getTokenFromRequest(HttpServletRequest req) {
    Cookie[] cookies = req.getCookies(); // 쿠키 여러개
    if(cookies != null) {
        for (Cookie cookie : cookies) {
            if (cookie.getName().equals(AUTHORIZATION_HEADER)) {
            // 인증 헤더와 동일한 이름의 쿠키를 찾아
                try {
                    return URLDecoder.decode(cookie.getValue(), "UTF-8"); 
                    // Encode 되어 넘어간 Value 다시 Decode
                } catch (UnsupportedEncodingException e) {
                    return null;
                }
            }
        }
    }
    return null;
}

쿠키에 담긴 정보가 여러 개일 수 있기 때문에 그 중 이름이 JWT가 담긴 쿠키의 이름과 동일한지 확인하여 JWT를 가져온다.

  1. Server

    1. Client 가 전달한 JWT 위조 여부 검증 (Secret Key 사용)
    2. JWT 유효기간이 지나지 않았는지 검증
    3. 검증 성공시, JWT 에서 사용자 정보를 가져와 확인

      ex) GET /api/products : JWT를 보낸 사용자의 관심상품 목록 조회

    JWT 구조

  • JWT 는 누구나 평문으로 복호화 가능합니다.

  • 하지만 Secret Key 가 없으면 JWT 수정 불가능

    → JWT 는 Read only 데이터

  • 아래의 모든 데이터는 JSON 형식으로 저장되어 있다

    1. Header

{
  "alg": "HS256", # 알고리즘 
  "typ": "JWT" # 타입
}

2. Payload = 실제 유저의 데이터

발급일자 등의 다양한 데이터를 넣을 수 있다.

{
  "sub": "1234567890",
  "username": "카즈하",
  "admin": true
}

3. Signature

Secret Key를 넣어서 조작함, 암호화 관련한 정보 양식

HMACSHA256(
 base64UrlEncode(header) + "." +
 base64UrlEncode(payload),
 secret)

JWT 다루기

  • Util 클래스 (보통 사용되는 이름)
    - 특정 매개변수 / parameter에 대한 어떠한 작업을 수행하는 메서드들이 존재하는 Class
    - 다른 메소드에 의존하지 않고, 하나의 모듈로서 동작하는 Class

JwtUtil 만들기

  1. JWT 생성
  2. 생성된 JWT를 Cookie에 저장
  3. Cookie에 들어있던 JWT 토큰을 Substring
  4. JWT 검증
  5. JWT에서 사용자 정보 가져오기
@Component
public class JwtUtil {
    // Header KEY 값 - Response 객체 Header에 바로 넣는 방법 / Token에 담아서 넣는 방법이 있음
    // Cookie를 만들 때는 Cookie의 Name 값이 된다.
    public static final String AUTHORIZATION_HEADER = "Authorization";
    // 사용자 권한 값의 KEY (Admin, ... )
    public static final String AUTHORIZATION_KEY = "auth";
    // Token 식별자 / 이후에 오는 것은 Token이라고 알려주는 일종의 규칙
    public static final String BEARER_PREFIX = "Bearer ";
    // 토큰 만료시간
    private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분

    @Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey (application.properties에서 가져오는 법)
    private String secretKey; // 여기에 위의 secretKey 담아준다
    private Key key;
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    // 로그 설정
    public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그");

    @PostConstruct // 한 번만 받아오면 되는 값을 설정할 때마다 새로 요청하는 실수를 방지하기 위해 사용됨
    // 생성자 호출 뒤에 이 코드가 호출됨
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey); // secretKey Decoding
        key = Keys.hmacShaKeyFor(bytes);
    }

    // 1. JWT 토큰 생성
    // 아래의 모든 값을 넣을 필요는 없다
    public String createToken(String username, UserRoleEnum role) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(username) // 사용자 식별자값(ID) // PK 등의 다른 값을 넣어도 됨
                        .claim(AUTHORIZATION_KEY, role) // 사용자 권한
                        .setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
                        .setIssuedAt(date) // 발급일
                        .signWith(key, signatureAlgorithm) // 암호화 알고리즘 (KEY, 알고리즘을 넣어서 암호화 시킴)
                        .compact();
    }


    // 2. 생성된 JWT를 Cookie에 저장
    public void addJwtToCookie(String token, HttpServletResponse res) {
        try {
            token = URLEncoder.encode(token, "utf-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행

            Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value(인코딩한 토큰)
            cookie.setPath("/");

            // Response 객체에 Cookie 추가
            res.addCookie(cookie);
        } catch (UnsupportedEncodingException e) {
            logger.error(e.getMessage());
        }
    }

// 3. Cookie에 들어있던 JWT 토큰을 Substring
public String substringToken(String tokenValue) {
    if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) { // 공백이나 null이 아니고, bearer로 시작하는지
        return tokenValue.substring(7); // "bearer " = 7자 / 순수한 token 값만 return
    }
    logger.error("Not Found Token");
    throw new NullPointerException("Not Found Token");
}

// 4. JWT 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); // token의 위변조 / 만료 검사
            return true;
        } catch (SecurityException | MalformedJwtException | SignatureException e) {
            logger.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
        } catch (ExpiredJwtException e) {
            logger.error("Expired JWT token, 만료된 JWT token 입니다.");
        } catch (UnsupportedJwtException e) {
            logger.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
        } catch (IllegalArgumentException e) {
            logger.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
        }
        return false;
    }

// 5. JWT에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token) { // JWT는 Claim 기반 Web Token
    return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
}

Bearer : 참고 링크

Logging : Application이 동작을 하는 동안에 프로젝트의 상태 / 동작 정보를 시간 순으로 기록하는 것.

  • LogBack Logging framework를 사용하여 추적해보자!
    public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그");

Encode / Decode 확인 : https://jwt.io/


사용자 관리하기

관리자 회원 가입 인가

  • 관리자 가입 토큰을 입력받는다
    토큰을 인가하는 방식은 보통 관리자 페이지를 생성, 승인 후 부여하는 방식으로 구현한다.

패스워드 암호화 이해

  • 해커가 DB에 저장된 패스워드 정보를 갈취하더라도 이해할 수 없게끔 단방향 알고리즘으로 설계해야 한다.
  • 양방향 암호 알고리즘
    • 암호화: 평문 → (암호화 알고리즘) → 암호문
    • 복호화: 암호문 → (암호화 알고리즘) → 평문
  • 단방향 암호 알고리즘
    • 암호화: 평문 → (암호화 알고리즘) → 암호문
    • 복호화: 불가 (암호문 → (암호화 알고리즘) → 평문)

이때 사용자는 암호화된 패스워드를 알 필요는 없으며, 아래 함수에서 암호화된 비밀번호와 비교해서 일치여부를 판단한다.

// 사용예시
// 비밀번호 확인

if(!passwordEncoder.matches("사용자가 입력한 비밀번호", "저장된 비밀번호")) {
		   throw new IllegalAccessError("비밀번호가 일치하지 않습니다.");
 }

Filter

  • Web 애플리케이션에서 관리되는 영역으로 Client로 부터 오는 요청과 응답에 대해 최초/최종 단계의 위치

  • 이를 통해 요청과 응답의 정보를 변경하거나 부가적인 기능을 추가할 수 있습니다.

  • 주로 범용적으로 처리해야 하는 작업들, 예를들어 로깅 및 보안 처리에 활용합니다.

    • 또한 인증, 인가와 관련된 로직들을 처리할 수도 있습니다.
    • Filter를 사용하면 인증, 인가와 관련된 로직을 비즈니스 로직과 분리하여 관리할 수 있다는 장점이 있습니다.

    Filter Chain

    Filter은 한 개만 존재하는 게 아니라 여러 개가 Chain 형식으로 묶여서 처리될 수 있다.

    필터 구현 부분 강의 자료 참고하기


'Spring Security' 프레임워크

  • Spring 서버에 필요한 인증 및 인가를 위해 많은 기능을 제공해 줌

CSRF (사이트 간 요청 위조, Cross-site request forgery)

  • CSRF 설정이 되어있는 경우 html 에서 CSRF 토큰 값을 넘겨주어야 요청 수신 가능
  • 쿠키 기반의 취약점을 이용한 공격이기 때문에 REST 방식의 API 에서는 disable 가능
  • POST 요청마다 처리해 주는 대신 CSRF protection 을 disable 하겠습니다.
    • http.csrf((csrf) -> csrf.disable());

Spring Security - Filter Chain

  • Spring에서 모든 호출은 DispatcherServlet을 통과하게 되고 이후에 각 요청을 담당하는 Controller 로 분배됩니다.
  • 이 때, 각 요청에 대해서 공통적으로 처리해야할 필요가 있을 때 DispatcherServlet 이전에 단계가 필요하며 이것이 Filter 입니다.
  • Spring Security도 인증 및 인가를 처리하기 위해 Filter를 사용하는데
    • Spring Security는 FilterChainProxy를 통해서 상세로직을 구현하고 있습니다.

UsernamePasswordAuthenticationFilter

  • Spring Security의 필터인 AbstractAuthenticationProcessingFilter를 상속한 Filter입니다.
  • 기본적으로 Form Login 기반을 사용할 때 username 과 password 확인하여 인증합니다.
  • 인증 과정
    1. 사용자가 username과 password를 제출하면 UsernamePasswordAuthenticationFilter는 인증된 사용자의 정보가 담기는 인증 객체인 Authentication의 종류 중 하나인 UsernamePasswordAuthenticationToken을 만들어 AuthenticationManager에게 넘겨 인증을 시도합니다.
    2. 실패하면 SecurityContextHolder를 비웁니다.
    3. 성공하면 SecurityContextHolder에 Authentication를 세팅합니다.

상세 처리 과정 보면서 구현하자

profile
프로 개발자가 되기 위해 뚜벅뚜벅.. 뚜벅초

0개의 댓글