반응형
JWT란?
- JSON Web Token의 줄임말로 JSON 객체로 정보를 주고 받을 때, 안전하게 전송하기 위한 방식
- HMAC, RSA 등의 암호화 방식을 사용해 서명함
- 로그인 기능 구현 등에 사용
JWT 구조
- JWT는 Header, Payload, Signature로 이루어져 있으며 각각 점으로 구분
- ex) xxxxxxx.yyyyyyy.zzzzzzzzz
Header
- Header은 일반적으로 토큰 유형(JWT)과 사용중인 서명 알고리즘(HMAC, SHA256 등)이 포함됨
- 예시
{
"typ": "JWT",
"alg": "HS256"
}
Payload
- Payload는 등록된 클레임과 개인 클레임 등으로 이루어짐
- 등록된 클레임 : iss(발행자), exp(만료시간), sub(제목), aud(대상) 등이 있음 => 권장되긴 하지만 필수는 아님
- 개인 클레임 : 서로 정보를 공유하기 위해 생성된 사용자 지정 클레임 => 원하는 정보들을 넣으면 됨
- 예시
{
// 등록된 클레임
"iss": "chb2005.tistory.com",
"sub": "123456789",
"exp": "1659002265",
// 개인 클레임
"userName": "changbum",
"isAdmin": false
}
Signature
- Signature은 Header, Payload, Secret Key를 합쳐 암호화한 결과값
- HS256( base64UrlEncode(header) + "." + base64UrlEncode(payload), Secret key)
JWT 인증 진행 방식
- https://jwt.io 이 사이트를 이용해 인증 진행 방식을 정리해 봄
- 오른쪽이 Encoding값, 왼쪽이 Decoding 값
- Header와 Payload 값은 Decoding을 통해 누구나 정보를 알아낼 수 있음
- 하지만 Signatur 값은 우리가 지정한 비밀키(ex) my-secret-key-123123)를 알아야만 구할 수 있음
예시
- 유저 A가 B 사이트에 로그인 하는 상황
- A가 id, password를 B 서버에 전송
- B 서버가 DB에서 확인 후 id, password가 맞다면 B만 아는 비밀키를 사용해 토큰을 만들어 A에게 전송해 줌
- A는 이 토큰을 들고있다가 다른 요청시 이 토큰을 헤더에 담아서 보내줌
- ex) 발급받은 Jwt Token이 'xxxx.yyyy.zzzzz'라면
- A가 B에 요청 전송시 Request Header의 'Authorization'에 'Bearer xxxx.yyyy.zzzzz'를 담아 전송
- 요청과 토큰을 받은 B는 토큰을 통해 사용자를 인증하고 요청에 대한 응답을 진행
- 만약 유저 A가 Payload에 유저 정보를 바꿔 다른 유저인 것 처럼 접근하려 해도 비밀키를 모르기 때문에 정확한 Signature을 만들 수 없음
JWT의 장단점
장점
- 서버는 비밀키만 알고 있으면 되기 때문에 세션 방식과 같이 별도의 인증 저장소가 필요하지 않음 => 서버측 부하 감소
- 여러개의 서버를 사용하는 대형 서비스 같은 경우에 접근 권한 관리가 매우 효율적임 => 확장성이 좋음
- Refresh Token까지 활용한다면 더 높은 보안성을 가질 수 있음
단점
- Payload의 정보(Claim)가 많아질 수록 토큰이 커짐
- 중요한 데이터는 넣을 수 없음
- 토큰 자체를 탈취당하면 대처가 어려움
- 로그아웃 시 JWT 방식은 세션이 없는 stateless 방식이기 때문에 토큰 관리가 어려움
JWT Token 로그인 구현 예제
라이브러리 추가
- Spring Security 관련 라이브러리와 JWT Token 라이브러리 설치 필요
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
// JWT Token
implementation 'io.jsonwebtoken:jjwt:0.9.1'
JwtTokenUtil 생성
- Jwt Token 방식을 사용할 때 필요한 기능들을 정리해놓은 클래스
- 새로운 Jwt Token 발급, Jwt Token의 Claim에서 "loginId" 꺼내기, 만료 시간 체크 기능 수행
public class JwtTokenUtil {
// JWT Token 발급
public static String createToken(String loginId, String key, long expireTimeMs) {
// Claim = Jwt Token에 들어갈 정보
// Claim에 loginId를 넣어 줌으로써 나중에 loginId를 꺼낼 수 있음
Claims claims = Jwts.claims();
claims.put("loginId", loginId);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expireTimeMs))
.signWith(SignatureAlgorithm.HS256, key)
.compact();
}
// Claims에서 loginId 꺼내기
public static String getLoginId(String token, String secretKey) {
return extractClaims(token, secretKey).get("loginId").toString();
}
// 밝급된 Token이 만료 시간이 지났는지 체크
public static boolean isExpired(String token, String secretKey) {
Date expiredDate = extractClaims(token, secretKey).getExpiration();
// Token의 만료 날짜가 지금보다 이전인지 check
return expiredDate.before(new Date());
}
// SecretKey를 사용해 Token Parsing
private static Claims extractClaims(String token, String secretKey) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
}
}
SecurityConfig 생성
- Spring Security에 대한 설정
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final UserService userService;
private static String secretKey = "my-secret-key-123123";
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(new JwtTokenFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class)
.authorizeRequests()
.antMatchers("/jwt-login/info").authenticated()
.antMatchers("/jwt-login/admin/**").hasAuthority(UserRole.ADMIN.name())
.and().build();
}
}
- [Spring Boot] Spring Security를 사용한 로그인 구현 (Form Login)에서 진행했던 Form Login과는 달리 sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) 을 사용
- Token 로그인 방식에서는 session이 필요없기 때문
- Spring Security에서 로그인을 진행해주는 Filter(UsernamePasswordAuthenticationFilter)에 가기 전에 JwtTokenFilter을 거치게 함
- JwtTokenFilter에서는 사용자의 요청에서 Jwt Token을 추출한 후 해당 Token이 유효한지 체크 => 유효하다면 UsernamePasswordAuthenticationFilter를 통과할 수 있게끔 권한 부여
JwtTokenFilter
- 위에서 언급한 바와 같이 사용자의 요청에서 Jwt Token을 추출해 통과하면 권한을 부여하고 실패하면 권한을 부여하지 않고 다음 필터로 진행시킴
// OncePerRequestFilter : 매번 들어갈 때 마다 체크 해주는 필터
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
private final UserService userService;
private final String secretKey;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
// Header의 Authorization 값이 비어있으면 => Jwt Token을 전송하지 않음 => 로그인 하지 않음
if(authorizationHeader == null) {
filterChain.doFilter(request, response);
return;
}
// Header의 Authorization 값이 'Bearer '로 시작하지 않으면 => 잘못된 토큰
if(!authorizationHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
// 전송받은 값에서 'Bearer ' 뒷부분(Jwt Token) 추출
String token = authorizationHeader.split(" ")[1];
// 전송받은 Jwt Token이 만료되었으면 => 다음 필터 진행(인증 X)
if(JwtTokenUtil.isExpired(token, secretKey)) {
filterChain.doFilter(request, response);
return;
}
// Jwt Token에서 loginId 추출
String loginId = JwtTokenUtil.getLoginId(token, secretKey);
// 추출한 loginId로 User 찾아오기
User loginUser = userService.getLoginUserByLoginId(loginId);
// loginUser 정보로 UsernamePasswordAuthenticationToken 발급
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
loginUser.getLoginId(), null, List.of(new SimpleGrantedAuthority(loginUser.getRole().name())));
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 권한 부여
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
}
}
JwtLoginApiController 구현
- Entity, DTO, Repository, Service는 미리 만들어 놓음
- 전에 구현했던 쿠키, 세션, Security Form 로그인과는 달리 화면을 사용하지 않고 API로 로그인 진행
@RestController
@RequiredArgsConstructor
@RequestMapping("/jwt-login")
public class JwtLoginApiController {
private final UserService userService;
@PostMapping("/join")
public String join(@RequestBody JoinRequest joinRequest) {
// loginId 중복 체크
if(userService.checkLoginIdDuplicate(joinRequest.getLoginId())) {
return "로그인 아이디가 중복됩니다.";
}
// 닉네임 중복 체크
if(userService.checkNicknameDuplicate(joinRequest.getNickname())) {
return "닉네임이 중복됩니다.";
}
// password와 passwordCheck가 같은지 체크
if(!joinRequest.getPassword().equals(joinRequest.getPasswordCheck())) {
return"바밀번호가 일치하지 않습니다.";
}
userService.join2(joinRequest);
return "회원가입 성공";
}
@PostMapping("/login")
public String login(@RequestBody LoginRequest loginRequest) {
User user = userService.login(loginRequest);
// 로그인 아이디나 비밀번호가 틀린 경우 global error return
if(user == null) {
return"로그인 아이디 또는 비밀번호가 틀렸습니다.";
}
// 로그인 성공 => Jwt Token 발급
String secretKey = "my-secret-key-123123";
long expireTimeMs = 1000 * 60 * 60; // Token 유효 시간 = 60분
String jwtToken = JwtTokenUtil.createToken(user.getLoginId(), secretKey, expireTimeMs);
return jwtToken;
}
@GetMapping("/info")
public String userInfo(Authentication auth) {
User loginUser = userService.getLoginUserByLoginId(auth.getName());
return String.format("loginId : %s\nnickname : %s\nrole : %s",
loginUser.getLoginId(), loginUser.getNickname(), loginUser.getRole().name());
}
@GetMapping("/admin")
public String adminPage() {
return "관리자 페이지 접근 성공";
}
}
결과
- 로그인 실패
- 로그인 성공 (토큰 발급)
- 인증 실패 1. Header에 "Authorization"이 없는 경우(403 에러)
- 인증 실패 2. Jwt Token이 잘못된 경우(500 에러 => 에러 처리를 통해 403 에러로 만들어 줘야 함)
- 인증 실패 3. Header의 "Authorization"이 "Bearer "로 시작하지 않는 경우 (403 에러)
- 인증 성공
- 인가 실패 (권한이 ADMIN이 아니기 때문에 접근 불가)
- 인가 성공 (권한이 ADMIN인 유저로 로그인 후 접근한 경우)
반응형
'Spring Boot > 문법 정리' 카테고리의 다른 글
[Spring Boot] Exception 처리 - 에러 페이지 적용(화면), 에러 코드 적용(API) (0) | 2023.01.09 |
---|---|
[Spring Boot] Front-End 없이 Jwt 화면 로그인 구현 (1) | 2023.01.07 |
[Spring Boot] Spring Security 인증, 인가 실패 처리 - authenticationEntryPoint, accessDeniedHandler (0) | 2023.01.07 |
[Spring Boot] Spring Security를 사용한 로그인 구현 (Form Login) (1) | 2023.01.06 |
[Spring Boot] Session을 사용한 로그인 구현 (7) | 2023.01.04 |