[TIL] 스프링 숙련 - JWT 인증과 권한 관리
JWT 의존성 추가, 비밀키 설정, JwtUtil 구성, 사용자 권한 enum 관리와 토큰 생성 흐름을 정리한 글입니다.
For the English version of this post, see here.
공부한 내용
JWT 다루기
JWT dependency 추가하기
application.properties에 jwt.secret.key 설정
- Util 클래스
- 특정 매개 변수(파라미터)에 대한 작업을 수행하는 메서드들이 존재하는 클래스를 뜻함 → 다른 객체에 의존하지 않고, 하나의 모듈로서 동작하는 클래스
JWT 관련 기능들을 가진 JwtUtil이라는 클래스를 만들어 JWT 관련 기능을 수행시킬 예정
- JWT 관련 기능
JWT 생성
생성된 JWT를 Cookie에 저장
Cookie에 들어있던 JWT 토큰을 Substring
JWT 검증
JWT에서 사용자 정보 가져오기
토큰 생성에 필요한 데이터
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
// 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); }
Base64로 Encode된 Secret Key를 properties에 작성해두고 @Value를 통해 가져옵니다.
- JWT를 생성할 때 가져온 Secret Key로 암호화합니다.
이때 Encode된 Secret Key를 Decode 해서 사용합니다.
Key는 Decode된 Secret Key를 담는 객체입니다.
@PostConstruct는 딱 한 번만 받아오면 되는 값을 사용 할 때마다 요청을 새로 호출하는 실수를 방지하기 위해 사용됩니다.
- JwtUtil 클래스의 생성자 호출 이후에 실행되어 Key 필드에 값을 주입 해줍니다.
암호화 알고리즘은 HS256 알고리즘을 사용합니다.
- Bearer 란 JWT 혹은 OAuth에 대한 토큰을 사용한다는 표시입니다.
- 로깅이란 애플리케이션이 동작하는 동안 프로젝트의 상태나 동작 정보를 시간순으로 기록하는 것을 의미합니다.
- 우리는 Logback 로깅 프레임워크를 사용해서 로깅을 진행하도록 하겠습니다.
사용자의 권한의 종류는 Enum을 사용해서 관리함
JWT를 생성할 때 사용자의 정보로 해당 사용자의 권한을 넣어줄 때 사용함
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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"; } }코드 설명
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112- `UserRoleEnum`은 사용자 권한(Role)을 enum으로 관리하는 클래스입니다. - enum을 사용하면 권한 값을 문자열로 직접 작성하지 않아도 되어 오타를 방지할 수 있습니다. - 현재 권한은 `USER`, `ADMIN` 두 가지로 정의되어 있습니다. ```java USER(Authority.USER), ADMIN(Authority.ADMIN); ``` - `USER`는 일반 사용자 권한입니다. - `ADMIN`은 관리자 권한입니다. - `authority` 필드는 실제 Spring Security에서 사용하는 권한 문자열을 저장합니다. ```java private final String authority; ``` - Spring Security는 일반적으로 `ROLE_` prefix가 포함된 권한 값을 사용합니다. ```plaintext ROLE_USER ROLE_ADMIN ``` - enum 생성자를 통해 권한 문자열을 저장합니다. ```java UserRoleEnum(String authority) { this.authority = authority; } ``` - enum 생성 시 전달된 권한 문자열이 `authority` 필드에 저장됩니다. ```java USER("ROLE_USER") ADMIN("ROLE_ADMIN") ``` - `getAuthority()` 메서드는 저장된 권한 문자열을 반환합니다. ```java public String getAuthority() { return this.authority; } ``` - 예시 ```java UserRoleEnum.USER.getAuthority() ``` ```plaintext ROLE_USER ``` - `Authority` 내부 클래스는 권한 문자열 상수를 관리합니다. ```java public static class Authority { public static final String USER = "ROLE_USER"; public static final String ADMIN = "ROLE_ADMIN"; } ``` - 권한 문자열을 상수로 관리하면 문자열을 직접 작성하지 않아도 됩니다. - 오타를 방지하고 유지보수성을 높일 수 있습니다. ```java "ROLE_USRE" // 오타 발생 가능 ``` - 전체 흐름 - enum 이름 ```java UserRoleEnum.USER ``` → 코드에서 사용하는 권한 타입 - 실제 권한 문자열 ```java UserRoleEnum.USER.getAuthority() ``` → Spring Security가 인식하는 권한 값 ```plaintext ROLE_USER ``` - 정리 - `UserRoleEnum` - 사용자 권한 종류를 enum으로 관리 - `Authority` - 실제 권한 문자열 상수 관리 - `getAuthority()` - Spring Security에서 사용할 권한 문자열 반환 - 목적 - 권한을 안전하고 일관성 있게 관리하기 위함입니다.
JWT 생성
1 2 3 4 5 6 7 8 9 10 11 12 13
// 토큰 생성 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(); }
JWT의 subject에 사용자의 식별값 즉, ID를 넣습니다.
JWT에 사용자의 권한 정보를 넣습니다. key-value 형식으로 key 값을 통해 확인할 수 있습니다.
토큰 만료시간을 넣습니다. ms 기준입니다.
issuedAt에 발급일을 넣습니다.
signWith에 secretKey 값을 담고있는 key와 암호화 알고리즘을 값을 넣어줍니다.
- ket와 암호화 알고리즘을 사용하여 JWT를 암호화합니다.
JWT Cookie에 저장
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
// 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()); } }
받아온 Cookie의 Value인 JWT 토큰 substring
1 2 3 4 5 6 7 8
// 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"); }
StringUtils.hasText를 사용하여 공백, null을 확인하고 startsWith을 사용하여 토큰의 시작값이 Bearer이 맞는지 확인합니다.
맞다면 순수 JWT를 반환하기 위해 substring을 사용하여 Bearer을 잘라냅니다.
JWT 검증
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
// 토큰 검증 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()를 사용하여 JWT를 파싱할 수 있습니다.JWT가 위변조되지 않았는지 secretKey(key)값을 넣어 확인합니다.
JWT에서 사용자 정보 가져오기
1 2 3 4
// 토큰에서 사용자 정보 가져오기 public Claims getUserInfoFromToken(String token) { return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody(); }
JWT의 구조 중 Payload 부분에는 토큰에 담긴 정보가 들어있습니다.
여기에 담긴 정보의 한 ‘조각’ 을 클레임(claim) 이라고 부르고, 이는 key-value 의 한 쌍으로 이뤄져있습니다. 토큰에는 여러개의 클레임 들을 넣을 수 있습니다.
Jwts.parserBuilder()와 secretKey를 사용하여 JWT의 Claims를 가져와 담겨 있는 사용자의 정보를 사용합니다.