Spring 입문 3-1 (JWT)

SJ.CHO·2024년 10월 8일

Bean 수동등록

  • 일반적으로는 @Component를 사용하여 Bean을 자동으로 등록
    • 규모가 커질수록 개발자의 관리가 힘들어짐. 또한 생산성 증대
  • 기술지원 Bean : 기술적 문제 혹은 공통 관심사 처리의 경우 객체들을 수동 등록함
    • 비즈니스 로직보다 수가적고 문제 발생시 위치파악이 명확함.
@Configuration
public class PasswordConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
  • Bean으로 등록하고자하는 객체를 반환하는 메서드를 선언하고 @Bean을 설정
  • Bean을 등록하는 메서드가 속한 해당 클래스에 @Configuration을 설정

같은타입이 2개라면?

  • 같은타입의 Bean 객체가 하나이상 있을시 주입이 불가능.
    (어느 구현체가 들어가야할지 판별 불가능)
  1. 등록된 Bean 이름 명시
@SpringBootTest
public class BeanTest {

    @Autowired
    Food pizza;
    
    @Autowired
    Food chicken;
    
}
  • @Autowired가 기본적으로는 Bean Type(Food)으로 DI를 지원하며 연결이 되지않을 경우 Bean Name(pizza, chicken)으로 찾게됌. (하향식 선택)
  1. @primary
@Component
@Primary
public class Chicken implements Food {
    @Override
    public void eat() {
        System.out.println("치킨을 먹습니다.");
    }
}
  • @primary 가 추가되면 동일 타입 Bean 이라도 우선시 됌.
  1. @Qualifier
@Component
@Qualifier("pizza")
public class Pizza implements Food {
    @Override
    public void eat() {
        System.out.println("피자를 먹습니다.");
    }
}
@SpringBootTest
public class BeanTest {

    @Autowired
    @Qualifier("pizza")
    Food food;
}
  • Class 및 주입받는 필드에 @Qualifier 추가시 해당 Bean 객체가 추가된다.

  • Qualifier와 Primary가 동시에 적용되어있다면 Qualifier의 우선순위가 더 높다

  • 하지만 Qualifier는 반드시 명시적으로 주입해줘야하기에 번거로움

  • 범용적 객체는 Primary를 지엽적 객체는 Qualifier를 사용.
    (좁은 범위의 우선순위가 더 높다)

인증과 인가

  • 인증 (Authentication)
    • 해당 유저가 시제 해당유저인지 인증
  • 인가 (Authorization)
    • 해당 유저가 특정 리소스에 접근 이 가능한지를 확인하는 개념

웹 어플리케이션의 특수성

  • HTTP 프로토콜은 비연결성(Connectionless) 무상태(Stateless)로 이루어짐

    • 비연결성(Connectionless)
      • 서버와 클라이언트가 실시간적으로 연결되어있지 않음.
      • 하나의 요청의 대한 하나의 응답을 전송하고 연결을 끊음.
      • 연결 유지를 위한 비용이 기하급수적임.
    • 무상태(Stateless)
      • 서버가 클라이언트 상태를 저장하지않음.
      • 서버는 클라이언트가 어떤 정보들을 보냈는지 전혀모름
  • 그렇다면 현재 유저들의 인증 상태를 어떻게 유지하는가?

1. 쿠키-세션 방식

  • 특정 유저가 로그인되었다는 상태를 저장하여 서버와 클라이언트가 같은 값을 가지고 있다면 로그인 상태에 대한 인증 완료
    (대게 타임 유효기간을 사용하여 발급함)

2. JWT 기반 인증

  • JWT(JSON Web Token)란 인증에 필요한 정보들을 암호화시킨 토큰을 의미
  • JWT 토큰을 HTTP 헤더에 실어서 서버가 클라이언트를 식별
  • 따로 정보를 저장하는게 아닌 검증만 하면 되기에 불필요한 정보양이 늘어나지 않음.

쿠키

  • 클라이언트(브라우저)에서 저장되는 목적으로 생성하는 정보파일
  • 구성요소
    • Name (이름): 쿠키를 구별하는 데 사용되는 키 (중복될 수 없음)
    • Value (값): 쿠키의 값
    • Domain (도메인): 쿠키가 저장된 도메인
    • Path (경로): 쿠키가 사용되는 경로
    • Expires (만료기한): 쿠키의 만료기한 (만료기한 지나면 삭제됩니다.)

세션

  • 서버에서 일정시간동안 클라이언트 상태를 유지.
  • 중복되지않는 '세션ID'를 부여한 후 클라이언트별 서버저장.

JWT

  • Json 포맷을 이용하여 사용자 속성을 저장하는 Cliam 기반의 Web Token

사용이유

  • 서버가 1대인 경우

    • 서버가 모든 클라이언트 로그인 정보를 소유
  • 서버가 2대 이상인경우

    • 서버가 여러대일 경우 클라이언트의 로그인정보가 모두 분배되있음.
    • 다른 서버로 갈경우 해당 서버는 로그인정보를 모름
    • Sticky Session : 클라이언트 마다 요청 서버를 고정
    • 세션저장소를 생성하여 사용
  • JWT 방법

    • 로그인정보를 서버가 가지는게 아닌 클라이언트 정보를 JWT로 암호화하여 저장. (Secret Key 를 모든서버가 동일하게 소유)
    • 장점 :
      • 동시접속자가 많을때 서버 부하 하락
      • C : S 가 다른 도메인을 사용할 때
    • 단점 :
      • 구현의 복잡도가 증가.
      • JWT의 크기가 커질수록 네트워크 비용 증가.
      • 생성된 JWT를 일부만 만료시킬 수 없음.
      • Secret Key 유출시 JWT 조작가능.
    • 사용 흐름 :
    1. 클라이언트가 로그인 성공시 서버에서 로그인정보를 JWT로 암호화.
    2. 서버에서 쿠키를 생성해서 JWT를 담아 클라이언트 응답전달.
    3. 브라우저 쿠키 저장소에 자동으로 JWT 저장.
    4. 서버에서 API 요청시 마다 쿠키에 포함된 JWT를 찾아서 인증.
      (쿠키에 담긴 정보가 N개이상일수 있기 떄문에 그중 쿠키의 이름과 동일한지 확인하여 확인)
    5. 클라이언트의 JWT 위조여부검증
    6. 유효기간 검증 후 사용자 정보를 가져와 확인.
  • JWT 구조

    • 누구나 복호화가 가능하지만 Key가 없다면 수정은 불가능 Read Only 데이터

JWT 다루기

  • Util Class :
    • 매개 변수(파라미터)에 대한 작업을 수행하는 메서드들이 존재하는 클래스
    • 다른 객체에 의존하지 않고 하나의 모듈로서 동작하는 클래스
      1. JWT 생성
      2. 생성된 JWT를 Cookie에 저장
      3. Cookie에 들어있던 JWT 토큰을 Substring
      4. JWT 검증
      5. JWT에서 사용자 정보 가져오기

1. 토큰 필요 데이터

// Header KEY 값
public static final String AUTHORIZATION_HEADER = "Authorization";
// 사용자 권한 값의 KEY
public static final String AUTHORIZATION_KEY = "auth";
// Token 식별자
public static final String BEARER_PREFIX = "Bearer ";
// 토큰 만료시간
private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분

@Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey
private String 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);
    key = Keys.hmacShaKeyFor(bytes);
}
  • @Value("${jwt.secret.key}") : Base64 Encode 한 SecretKey properties에 저장되어져있다.
  • SignatureAlgorithm.HS256 를 통해 HS256 암호화 알고리즘 사용.
  • Bearer 란 JWT 혹은 OAuth에 대한 토큰을 사용한다는 표시
@PostConstruct
public void init() {
    byte[] bytes = Base64.getDecoder().decode(secretKey);
    key = Keys.hmacShaKeyFor(bytes);
}
  • Key 부분에 Decode된 Secret Key를 담음. @PostConstruct 를 통해 요청을 새로호출하는 상황을 방지.
public enum UserRoleEnum {
    USER(Authority.USER),  // 사용자 권한
    ADMIN(Authority.ADMIN);  // 관리자 권한

    private final String authority;

    UserRoleEnum(String authority) {
        this.authority = authority;
    }

    public String getAuthority() {
        return this.authority;
    }

    public static class Authority {
        public static final String USER = "ROLE_USER";
        public static final String ADMIN = "ROLE_ADMIN";
    }
}
  • Enum Class 를 통해서 유저의 인가권한 정보 선언.

2. JWT 생성

// 토큰 생성
public String createToken(String username, UserRoleEnum role) {
    Date date = new Date();

    return BEARER_PREFIX +
            Jwts.builder()
                    .setSubject(username) // 사용자 식별자값(ID)
                    .claim(AUTHORIZATION_KEY, role) // 사용자 권한
                    .setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
                    .setIssuedAt(date) // 발급일
                    .signWith(key, signatureAlgorithm) // 암호화 알고리즘
                    .compact();
}
  • String 생성자를 통해서 조건에 맞는 사용자 정보 및 Key값을 생성

3. Cookie에 저장

// 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());
    }
}
  • BEARER_PREFIX 공백값이 있기에 공백을 제거, HttpResponse 객체에 Cookie 추가

4. JWT SubString

// JWT 토큰 substring
public String substringToken(String tokenValue) {
    if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
        return tokenValue.substring(7);
    }
    logger.error("Not Found Token");
    throw new NullPointerException("Not Found Token");
}
  • Token 의 형태가 작성한 규칙에 맞는지 확인, 맞다면 BEARER 제거.

5. JWT 검증

// 토큰 검증
public boolean validateToken(String token) {
    try {
        Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(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;
}
  • Jwts.parserBuilder() 를 통해 해당 Token이 유효한지 확인.

6. JWT 내부의 사용자 정보 가져오기

// 토큰에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token) {
    return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
  • payload 부분에 토큰에 담긴 정보를 확인.
  • claim 내부에 K : V 형태로 여러개의 정보가 저장되어져있음.
  • Jwts.parserBuilder() 와 secretKey를 사용 claim을 가져와 사용자 정보 확인.
profile
70살까지 개발하고싶은 개발자

0개의 댓글