Java, IntelliJ/Spring
JWT토큰이란, 장단점, 구현
반응형
JWT(JSON Web Token) 이란
JSON Web Token (JWT) 은 웹표준 (RFC 7519) 으로서
두 개체에서 JSON 객체를 사용하여 가볍고 자가수용적인 (self-contained) 방식으로
정보를 안전성 있게 전달해줍니다.
- 자가 수용적 (self-contained)
JWT 는 필요한 모든 정보를 자체적으로 지니고 있습니다. JWT 시스템에서 발급된 토큰은, 토큰에 대한 기본정보, 전달 할 정보 (로그인시스템에서는 유저 정보를 나타내겠죠?) 그리고 토큰이 검증됐다는것을 증명해주는 signature 를 포함하고있습니다.
언제 사용하는가
- 로그인
- 사용자 로그인 -> 서버가 해당 유저의 토큰을 유저에게 전달 (JWT)
-> 유저가 유청을 할때 토큰을 포함해서 전달
-> 서버는 해당 토큰일 권한이 있는지 유효하고 인증이 되었는지 확인하고 작업을 진행 - 서버는 유저의 세션을 유지할 필요가 없다.
유저가 보낸 토큰만 확인하면 된다.
서버의 자원을 아낄수 있다.
- 사용자 로그인 -> 서버가 해당 유저의 토큰을 유저에게 전달 (JWT)
- 정보교류
- JWT는 두 개체 사이에서 안정성있게 정보를 교환하기에 좋은 방법이다.
그 이유는, 정보가 sign 이 되어있기 때문에 정보를 보낸이가 바뀌진 않았는지,
또 정보가 도중에 조작되지는 않았는지 검증할 수 있다.
- JWT는 두 개체 사이에서 안정성있게 정보를 교환하기에 좋은 방법이다.
JWT 구조
- Header, Payload, Signature의 3부분으로 이루어져 있다.
Json 형태인 각 부분은 Base64로 인코딩 되어 표현된다. 각 부분을 이어주기 위해 .구분자를 반환한다. - Base64는 암호화된 문자열이 아니고, 같은 문자열에 대해 항상 같은 문자열을 반환한다
Header/ Payload/ Signature
- Header
- Header 는 두가지의 정보를 지니고 있습니다.
- typ: 토큰의 타입을 지정합니다. ex)JWT
- alg: 해싱 알고리즘을 지정합니다. 해싱 알고리즘으로는 보통 HMAC SHA256 혹은 RSA 가 사용되며, 이 알고리즘은, 토큰을 검증 할 때 사용되는 signature 부분에서 사용됩니다.
{
"alg": "HS256",
"typ": JWT
}
- Payload
- Payload 부분에는 토큰에 담을 정보가 들어있습니다. 여기에 담는 정보의 한 ‘조각’ 을 클레임(Claim) 이라고 부르고, 이는 Json(Key/Value) 형태의 한 쌍으로 이뤄져있습니다. 토큰에는 여러개의 클레임들을 넣을 수 있습니다.
- 클레임 의 종류는 다음과 같이 크게 세 분류로 나뉘어져있습니다:
1) 등록된 (registered) 클레임
iss: 토큰 발급자 (issuer)
sub: 토큰 제목 (subject)
aud: 토큰 대상자 (audience)
exp: 토큰의 만료시간 (expiraton),
시간은 NumericDate 형식으로 되어있어야 하며
(예: 1480849147370) 언제나 현재 시간보다 이후로 설정되어있어야합니다.
nbf: Not Before 를 의미하며, 토큰의 활성 날짜와 비슷한 개념입니다.
여기에도 NumericDate 형식으로 날짜를 지정하며,
이 날짜가 지나기 전까지는 토큰이 처리되지 않습니다.
iat: 토큰이 발급된 시간 (issued at), 이 값을 사용하여 토큰의 age 가 얼마나 되었는지 판단 할 수 있습니다.
jti: JWT의 고유 식별자로서, 주로 중복적인 처리를 방지하기 위하여 사용됩니다.
일회용 토큰에 사용하면 유용합니다.
2) 공개(public)클레임
공개 클레임들은 충돌이 방지된 (collision-resistant) 이름을 가지고 있어야 합니다.
충돌을 방지하기 위해서는, 클레임 이름을 URI 형식으로 짓습니다.
등록된 (registered) 클레임
공개 (public) 클레임
비공개 (private) 클레임
{
"https://velopert.com/jwt_claims/is_admin": true
}
3) 비공개(private)클레임
등록된 클레임도아니고, 공개된 클레임들도 아닙니다. 양 측간에 (보통 클라이언트 <->서버) 협의하에 사용되는 클레임 이름들입니다. 공개 클레임과는 달리 이름이 중복되어 충돌이 될 수 있으니 사용할때에 유의해야합니다.
{
"username": "velopert"
}
종합하면,
{
"iss": "gorokke.com",
"exp": "1485270000000",
"https://gorokke.com/jwt_claims/is_admin": true,
"userId": "11028373727102",
"username": "gorokke"
}
2개의 등록된 클레임, 1개의 공개 클레임, 2개의 비공개 클레임
- Signature
- 서명(Signature)은 토큰을 인코딩하거나 유효성 검증을 할 때 사용하는 고유한 암호화 코드이다.
서명(Signature)은 위에서 만든 헤더(Header)와 페이로드(Payload)의 값을 각각 BASE64로 인코딩하고, 인코딩한 값을 비밀 키를 이용해 헤더(Header)에서 정의한 알고리즘으로 해싱을 하고, 이 값을 다시 BASE64로 인코딩하여 생성한다.
JWT토큰 장단점
JWT의 장점
- JWT 의 주요한 이점은 사용자 인증에 필요한 모든 정보는 토큰 자체에 포함하기 때문에 별도의 인증 저장소가 필요 없습니다.
- 쿠키를 전달하지 않아도 되므로 쿠키를 사용함으로써 발생하는 취약점이 사라집니다.
- URL 파라미터와 헤더로 사용
- 트래픽 대한 부담이 낮음
- REST 서비스로 제공 가능
- 내장된 만료
- 독립적인 JWT
JWT의 단점
- Self-contained: 토큰 자체에 정보를 담고 있으므로 양날의 검이 될 수 있습니다.
- 토큰 길이: 토큰의 페이로드(Payload)에 3종류의 클레임을 저장하기 때문에, 정보가 많아질수록 토큰의 길이가 늘어나 네트워크에 부하를 줄 수 있습니다.
- Payload 인코딩: 페이로드(Payload) 자체는 암호화 된 것이 아니라, BASE64로 인코딩 된 것입니다. 중간에 Payload를 탈취하여 디코딩하면 데이터를 볼 수 있으므로, JWE로 암호화하거나 Payload에 중요 데이터를 넣지 않아야 합니다.
- Stateless: JWT는 상태를 저장하지 않기 때문에 한번 만들어지면 제어가 불가능합니다. 즉, 토큰을 임의로 삭제하는 것이 불가능하므로 토큰 만료 시간을 꼭 넣어주어야 합니다.
- Tore Token: 토큰은 클라이언트 측에서 관리해야 하기 때문에, 토큰을 저장해야 합니다.
JWT spring 구현
usercontroller
@PostMapping("/user/login")
public String login(@RequestBody SignupRequestDto requestDto) {
User user = userRepository.findByUsername(requestDto.getUsername())
.orElseThrow(() -> new IllegalArgumentException("가입되지 않은 유저입니다."));
if (!passwordEncoder.matches(requestDto.getPassword(), user.getPassword())) {
throw new IllegalArgumentException("잘못된 비밀번호입니다.");
}
return jwtTokenProvider.createToken(user.getUsername(), user.getRole());
}
JwtAuthenticationFilter
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 헤더에서 JWT 받아옴
String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
// 유효한 토큰인지 확인
if (token != null && jwtTokenProvider.validateToken(token)) {
// 토큰이 유효하면 토큰으로부터 유저 정보를 받아옴
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// SecurityContext 에 Authentication 객체를 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
JwtTokenProvider
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 헤더에서 JWT 받아옴
String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
// 유효한 토큰인지 확인
if (token != null && jwtTokenProvider.validateToken(token)) {
// 토큰이 유효하면 토큰으로부터 유저 정보를 받아옴
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// SecurityContext 에 Authentication 객체를 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
MvcConfig
@EnableWebMvc
@Configuration
public class MvcConfig extends WebMvcConfigurerAdapter {
@Override
public void addResourceHandlers(
ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**")
.addResourceLocations("/WEB-INF/view/react/build/static/");
registry.addResourceHandler("/*.js")
.addResourceLocations("/WEB-INF/view/react/build/");
registry.addResourceHandler("/*.json")
.addResourceLocations("/WEB-INF/view/react/build/");
registry.addResourceHandler("/*.ico")
.addResourceLocations("/WEB-INF/view/react/build/");
registry.addResourceHandler("/index.html")
.addResourceLocations("/WEB-INF/view/react/build/index.html");
}
}
UserDetailsImpl
package com.cheerup.cheerup.security;
import com.cheerup.cheerup.model.User;
import com.cheerup.cheerup.model.UserRole;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.Collections;
public class UserDetailsImpl implements UserDetails {
private final User user;
public UserDetailsImpl(User user) {
this.user = user;
}
public User getUser() {
return user;
}
public UserRole getRole() {
return user.getRole();
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override // 인가를 해주는 부분
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.emptyList();
}
}
UserDetailsServiceImpl
@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private UserRepository userRepository;
@Autowired
public UserDetailsServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Can't find " + username));
return new UserDetailsImpl(user);
}
}
WebSecurityConfig
package com.cheerup.cheerup.security;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@AllArgsConstructor
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtTokenProvider jwtTokenProvider;
// 암호화에 필요한 PasswordEncoder Bean 등록
@Bean
public PasswordEncoder passwordEncoder(){
// return new BCryptPasswordEncoder();
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
// authenticationManager를 Bean 등록
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 필터 등록
http
.httpBasic().disable() // REST API만을 고려, 기본 설정 해제
.csrf().disable() // csrf 사용 X
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 토큰 기반 인증이므로 세션도 사용 X
.and()
.authorizeRequests() // 요청에 대한 사용권한 체크
.anyRequest().permitAll() // 나머지 요청은 누구나 접근 가능
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class);
// JwtAuthenticationFilter는
// UsernamePasswordAuthenticationFilter 전에 넣음
}
}
UserService
package com.cheerup.cheerup.service;
import com.cheerup.cheerup.dto.SignupRequestDto;
import com.cheerup.cheerup.model.User;
import com.cheerup.cheerup.model.UserRole;
import com.cheerup.cheerup.repository.UserRepository;
import com.cheerup.cheerup.security.UserDetailsImpl;
import com.cheerup.cheerup.security.kakao.KakaoOAuth2;
import com.cheerup.cheerup.security.kakao.KakaoUserInfo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
import javax.transaction.Transactional;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
@Service
public class UserService {
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
private final KakaoOAuth2 kakaoOAuth2;
private final AuthenticationManager authenticationManager;
private static final String ADMIN_TOKEN = "AAABnv/xRVklrnYxKZ0aHgTBcXukeZygoC";
@Autowired
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, KakaoOAuth2 kakaoOAuth2, AuthenticationManager authenticationManager) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.kakaoOAuth2 = kakaoOAuth2;
this.authenticationManager = authenticationManager;
}
@Transactional
public void registerUser(SignupRequestDto requestDto) {
String username = requestDto.getUsername();
String password = requestDto.getPassword();
String passwordChecker = requestDto.getPasswordChecker();
Optional<User> found = userRepository.findByUsername(username);
if (username.equals("") || password.equals("") || passwordChecker.equals("")) {
throw new IllegalArgumentException("username || password || passwordChecker가 비어있습니다.");
} else if (password.length() < 4) {
throw new IllegalArgumentException("password는 최소 4글자입니다.");
} else if (!password.equals(passwordChecker)) {
throw new IllegalArgumentException("password와 passwordChecker가 다릅니다.");
} else if (found.isPresent()) {
throw new IllegalArgumentException("중복된 사용자 ID가 존재합니다.");
}
password = passwordEncoder.encode(requestDto.getPassword());
UserRole role = UserRole.USER;
User user = new User(username, password, role);
userRepository.save(user);
}
public void kakaoLogin(String authorizedCode) {
// 카카오 OAuth2 를 통해 카카오 사용자 정보 조회
KakaoUserInfo userInfo = kakaoOAuth2.getUserInfo(authorizedCode);
Long kakaoId = userInfo.getId();
String nickname = userInfo.getNickname();
// 우리 DB 에서 회원 Id 와 패스워드
// 회원 Id = 카카오 nickname
String username = nickname;
// 패스워드 = 카카오 Id + ADMIN TOKEN
String password = kakaoId + ADMIN_TOKEN;
// DB 에 중복된 Kakao Id 가 있는지 확인
User kakaoUser = userRepository.findByKakaoId(kakaoId)
.orElse(null);
// 카카오 정보로 회원가입
if (kakaoUser == null) {
// 패스워드 인코딩
String encodedPassword = passwordEncoder.encode(password);
// ROLE = 사용자
UserRole role = UserRole.USER;
kakaoUser = new User(nickname, encodedPassword, role, kakaoId);
userRepository.save(kakaoUser);
}
// 로그인 처리
Authentication kakaoUsernamePassword = new UsernamePasswordAuthenticationToken(username, password);
Authentication authentication = authenticationManager.authenticate(kakaoUsernamePassword);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
public Map<String, String> userSession(HttpServletRequest request, @AuthenticationPrincipal UserDetailsImpl userDetails) {
Map<String, String> session = new HashMap<>();
String username = "null";
String role = "null";
Optional<UserDetailsImpl> userDetailsOptional = Optional.ofNullable(userDetails);
if (userDetailsOptional.isPresent()) {
username = userDetails.getUsername();
role = String.valueOf(userDetails.getRole());
}
request.getSession().setAttribute("username", username);
request.getSession().setAttribute("role", role);
request.getSession().setMaxInactiveInterval(360 * 60);
session.put("username", String.valueOf(request.getSession().getAttribute("username")));
session.put("role", String.valueOf(request.getSession().getAttribute("role")));
return session;
}
}
JWT 를 통한 인증 절차
기본적으로 구현한 인증절차
1. [프론트엔드] ID와 비밀번호를 준다.
2. [백엔드] ID와 비밀번호를 검증하고 AccessToken을 반환한다.
3. [프론트엔드] AccessToken을 받아 다음 api호출부터 헤더에 붙여준다.
4. [백엔드] api호출시 AccessToken이 유효한지 확인하여 처리한다.
기본기능으로는 토큰이 만료가 되면 다시 로그인을 해서 토큰을 발급 받아야 할 것이라 예상되고,
이후에 토큰이 만료시간에 가까웠을 때, refresh 해주는 기능들을 추가하는 방법에 대해서는 더 공부가 필요할 것 같다.
1. [프론트엔드] ID와 비밀번호를 준다.
2. [백엔드] ID와 비밀번호를 검증하고 AccessToken과 RefreshToken, AccessToken의 만료시간을 반환해준다. 이 때 생성한 RefreshToken은 DB에 {ID,RefreshToken}으로 저장한다.
3. [프론트엔드] 반환받은 AccessToken을 매 api 호출마다 헤더에 붙여서 전송한다.
4. [백엔드] api호출시 헤더의 AccessToken을 확인하고 유효한지, 만료기간이 지났는지를 체크 후 api를 동작시킨다.
5. [프론트엔드] AccessToken의 만료 기간이 지나거나, 30초 미만으로 남았다면, 백엔드에 RefreshToken을 붙여 Reissue 요청을 보낸다.
6. [백엔드] Reissue요청이 들어올 경우, RefreshToken이 DB에 있는 것인지 확인한 후, 맞다면 AccessToken과 새로운 AccessToken 만료 시간을 반환한다.
7. [프론트엔드] Reissue결과 반환된 AccessToken과 만료기간을 저장하여 다음 api호출에 사용한다.
https://mangkyu.tistory.com/56
반응형
'Java, IntelliJ > Spring' 카테고리의 다른 글
Spring annotation 모음 (0) | 2021.07.19 |
---|---|
Spring/ SpringBoot 개념 정리 (0) | 2021.07.19 |
[Spring]Thymeleaf Js내부에서 사용하기, 로그인 여부에 따라 보이기 (0) | 2021.07.06 |
Spring_CRUD할 수 있는 API 연습 (0) | 2021.06.27 |
Java Spring_API-GET/POST/PUT/DELETE (0) | 2021.06.27 |
댓글