[전공자들 10] JWT 이해하기(로그인, 토큰발급, 요청보내기)
목차
1. JWT 기본구조
2. 로그인요청 후 토큰발급까지의 흐름
3. 발급된 토큰으로 요청을 전송하고 처리하는 흐름
1. JWT 기본구조
xxxxxxxxx.xxxxxxxxxxx.xxxxxxxxxx
JWT는 '.'으로 나뉘어진 세 개의 부분으로 구성된다
왼쪽부터 각각 헤더,페이로드, 서명이라고 함
- 헤더 : 토큰의 종류("JWT"), 암호화 방식(어떤 알고리즘을 사용해 서명을 암호화했는지) 등을 담음
- 페이로드 : 사용자 정보(계정, 역할 등) 및 토큰 설정 정보(유효기간 등)
- 서명 : 헤더와 페이로드를 조합한 내용을 담음
JWT요약정리
헤더와 페이로드는 단순 인코딩만 되어있기 때문에 탈취하여 내용을 열어보고 조작하기가 쉬움
그래서 서버만 가지고 있는 시크릿 키를 사용해 헤더와 페이로드의 내용을 암호화하여 서명에 담음
토큰을 가지고 서버에 요청을 하면 헤더와 페이로드의 부분과 서명을 대조해보고 토큰이 변경되지 않았음을 검증힌다
2. 로그인요청 후 토큰발급까지의 흐름
본 프로젝트에서는 id대신 email을 사용했다.
로그인 화면을 통해 사용자 email과 password를 받아서 서버로 로그인 요청을 보냄
서버는 받은 email과 password로 데이터베이스에 해당 유저가 있는지 검증하고,
확인이 완료되면 토큰을 발급한다.
이 과정은 controller와 service를 통해 진행되는데 주요 메소드는 다음과 같음
- MemberController
@PostMapping("/login")
public ResponseEntity<String> login(@RequestBody Map<String,String> body){
try{
//post로 requestBody에 담아온 이메일과 비밀번호를 꺼냄
String email = body.get("email");
String password = body.get("password");
String token = memberService.login(email,password); //서비스를 호출해서 토큰을 받아옴
return new ResponseEntity<String>(token, HttpStatus.OK); //토큰을 프론트로 전달
}catch (Exception e){
return new ResponseEntity<String>(e.getMessage(), HttpStatus.BAD_REQUEST);
}
}
- MemberService
@Override
public String login(String email, String password) throws Exception {
//1 사용자 인증을 위한 객체 생성
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(email, password);
//2 인증된 사용자 객체 생성
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
//3 토큰 발급
return jwtProvider.createToken(email, authentication);
}
서비스에서 토큰을 발급받는 메소드는 직접 정의한 메소드이고 UsernamePasswordAuthenticationToken 객체와 Authentication객체를 통해 사용자 인증을 진행한다.
//1 사용자 인증을 위한 객체 생성
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(email, password);
UsernamePasswordAuthenticationToKen 는사용자 인증을 위한 객체로 사용자의 아이디와 비밀번호를 담은 객체를 생성하여 사용자를 인증한다.
//2 인증된 사용자 객체 생성
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
//authenticationManagerBuilder.getObject() : 사용자 인증을 수행하는 authenticationManager 객체를 생성
//authenticate(): 인증을 위한 UsernamePasswordAuthenticationToKen객체를 넘겨받아 인증을 수행하고, 검증된 사용자 객체(Authentication)를 리턴
사용자 인증을 수행하는 객체를 생성하여 authenticate()가 호출되면
spring security는 userDetailsService를 상속받아 오버라이딩된 loadUserByUsername() 메소드를 찾는다.
loadUserByUsername() 메소드를 통해 데이터베이스에 사용자가 있는지 확인하고, 통과하면 인증된 사용자 객체인 Authentication 객체를 리턴한다.
이 loadUserByUsername() 메소드는 직접 정의를 해줘야 함..
- UserDetailsService
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<MemberDto> oMember = memberRepository.findByEmail(username);//repository를 통해 데이터베이스에서 사용자를 찾음
if(oMember.isPresent()){
return createUserDetails(oMember.get()); //1
}else{
throw new UsernameNotFoundException("회원정보가 일치하지 않습니다.");
}
}
//1 UserDetails 생성
private UserDetails createUserDetails(MemberDto member){
return User.builder()
.username(member.getEmail())
.password(member.getPassword())
.roles(member.getRoles().toArray(new String[0]))
.build();
}
}
요약하면,
프로젝트에서 회원가입된 유저정보를 담고 있는 Member컬렉션(테이블)에서 유저를 검색한 후 있으면 return하는 메소드이다.
리턴을 할 때, MemberDto를 그대로 리턴하지 않고 UserDetails라는 객체를 만들어서 리턴했다.
스프링 시큐리티에서는 사용자 정보를 일관되게 처리하기 위해서 사용자 정보(이름,비밀번호,권한 등)를 담은 UserDetails 구현체를 통해 인증을 하는데 나는 UserDetails를 구현하지 않았기 때문에 MemberDto를 스프링시큐리티의 User객체로 만들어 주는 과정을 추가했다.
그렇다면 memberDto에서 UserDetails를 상속받으면 중간 과정 없이 바로 MemberDto를 리턴할 수 있지 않을까?
일단 맞긴 하다. UserDetails를 상속받게되면 createUserDetails메소드는 필요가 없다.
하지만 일반적으로 MemberDto같은 객체는 데이터베이스와 상호작용하는 것이 그 목적이고
UserDetails는 사용자 인증 및 권한 등 스프링 시큐리티의 여러 기능과 상호작용하기 위해 설계되었다.
각각의 목적이 다르기 때문에 역할에 맞게 책임을 분리하는 것이 맞다고 판단했다.
이렇게 사용자 인증을 완료하면, 다시 서비스로 돌아가 드디어 토큰을 발급받을 수 있다.
//3 토큰 발급
return jwtProvider.createToken(email, authentication);
- JwtProvider
@Component
public class JwtProvider {
@Value("${SECRET_KEY}") //보안을 위해 환경변수로 설정 후 application.properties에 작성함
private String SECRET_KEY;
@Value("${EXP_TIME}")
private int EXP_TIME; //토큰의 유효기간을 설정할 변수
//토큰생성(로그인시 호출)
public String createToken(String subject, Authentication authentication){
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; //서명알고리즘선택
byte[] secretKeyBytes = DatatypeConverter.parseBase64Binary(SECRET_KEY); //시크릿키를 사용해서 문자열->바이트 배열로 파싱
Key signingKey = new SecretKeySpec(secretKeyBytes, signatureAlgorithm.getJcaName()); //파싱된 시크릿 키를 기반으로 서명에 사용할 key객체 생성
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities(); //인증된 사용자 정보를 매개변수로 받아 유저의 역할을 추출
String roles = authorities.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
return Jwts.builder() //토큰 생성
.setSubject(subject) //토큰의 주제. 어떤 정보에 대해 발급되었는지(email,id등)
.claim("auth", roles) //역할
.signWith(signingKey, signatureAlgorithm) //서명키와 서명알고리즘
.setExpiration(new Date(System.currentTimeMillis()+EXP_TIME)) //유효기간 설정(현재시각+보통 30분의 유효기간)
.compact();
}
}
토큰을 생성하는 코드이다. 처음에 언급했던 토큰의 구성요소인 헤더 , 페이로드, 서명에 대해
서명을 어떤 방식으로 암호화할건지 알고리즘을 선택하고, 시크릿키를 사용해서 문자열을 byte배열로 파싱한다.
여기서 시크릿 키는 문자열 형태로 되어있으며 서버만 가지고 있어야 한다.
실제 배포하고 운영하는 사이트일 경우 깃허브 같은 곳에 프로젝트를 올리면 시크릿 키가 노출이 되기 때문에
보안을 유지해야 함. 나는 시스템의 환경변수로 등록하였음.
사용자의 역할같은 경우도 현재 프로젝트에서는 사용하지 않아서 안쓰려다가 추후에 관리자역할 정도는 필요할 수도 있을 것 같아서 일단 코드를 작성했다.
사용자의 역할은 보통 "ROLE_USER,ROLE_ADMIN"과 같은 형태로 작성되며,
나는 회원가입을 할 때 MembeDto에 roles라는 문자열 리스트 변수를 만들어 "USER"라는 역할을 부여했다.
"ROLE"은 스프링시큐리티가 자동으로 붙여주기 때문에 역할을 부여할 때 "ROLE_USER"라고 하면 에러가 났고 "USER" 라고만 작성해야 했다..
아무튼 loadUserByUsername()를 통해 데이터베이스의 유저정보를 가지고 올 때 가지고 온 역할은 인증이 완료된 Authentication객체에 들어있고, 그걸 토큰을 생성할 때 가지고 와서 다시 꺼내어 토큰에 넣었다.
이렇게 하면 로그인하고 토큰 생성하는 기능은 끝~!@!@~!~!!
3. 발급된 토큰으로 요청을 전송하고 처리하는 흐름
생성한 토큰을 프론트로 내려보내주면 프론트(react)에서 잘 가지고 있다가 (localStroage같은 곳에서)
헤더에 토큰을 포함시켜 요청을 전송한다.
프론트에서 보낸 요청이 컨트롤러에 도착하는 과정에서 필터(Filter)라는 중간 처리 단계를 거치게 되는데,
(보통 도착 전과 후에서 요청과 응답을 조작)
스프링시큐리티는 여러 보안 관련 필터들을 기본적으로 포함하고 있고, 이러한 필터들은 보안 기능을 구현하고 사용자의 인증 및 권한 부여를 처리한다.
그 필터목록들 사이에 우리가 만든 토큰을 검증할 사용자 정의 필터를 만들어서 중간에 넣어 줄 것임.
그러면 요청이 컨트롤러에 도달하기 전에 사용자 정의 필터에서 토큰을 검증하고, 변조되지 않았으며 유효한 토큰일 경우에만 요청한 응답을 내려줄 수 있다.
일단 사용자 정의 필터를 만들기 전에, 프로젝트에 사용할 스프링시큐리티의 보안 설정을 변경하고 필터목록에 사용자 정의 필터를 끼워넣는 Configuration을 설정할 것임!
- SecurityConfig
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtProvider jwtProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
http.httpBasic(HttpBasicConfigurer::disable)
.csrf(CsrfConfigurer::disable)
.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize->
authorize
.requestMatchers("/**").permitAll()
)
.addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
HttpSecurity : Spring Security를 사용하여 HTTP 요청에 대한 보안 구성을 제공하는 클래스. 이 객체를 통해 SecurityFilterChain을 설정할 수 있다
스프링부트 3.0 이상부터 설정 코드 작성법이 이렇게 변경되었음..!!
자세한 내용은 스프링 3.0 스프링 시큐리티 or 스프링시큐리티 버전 6 검색 ㄱㄱ
설정내용
.httpBasic(HttpBasicConfigurer::disable)
HTTP Basic는기본 인증 방식 중 하나로, 사용자의 이름과 비밀번호를 인코딩하여 요청 헤더에 넣는 방식임.
지금은 jwt를 사용해서 인증을 할 것이므로 비활성화했다.
.csrf(CsrfConfigurer::disable)
CSRF(Cross-Site Request Forgery) 공격을 막기 위해 CSRF 토큰을 사용해 매 요청마다 토큰을 생성하고 전송해야 하는데 RESTful API는 stateless하고 서버와 클라이언트 간에 상태를 저장하지 않는 특성을 갖고 있어서 중요하지 않다고 함..
그래서 비활성화.
.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
세션 관리 방식을 설정하는건데 SessionCreationPolicy.STATELESS는 세션을 사용하지 않는 상태를 의미
.authorizeHttpRequests(authorize->
authorize
.requestMatchers("/**").permitAll()
)
HTTP 요청에 대한 접근 권한을 설정. 하지만 개발단계이기 때문에 일단 모든 역할에 대해 모든 요청을 허락해 놓았다
.addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
그리고 이게 가장 중요한 토큰을 검증하기 위해 사용자 필터를 끼워넣는 설정.
UsernamePasswordAuthenticationFilter는 로그인을 처리하는 필터로 이 필터 이전에 위치하게끔 설정함.
(로그인 처리 필터는 인증된 사용자가 없을 시 자동으로 필터 진입)
이렇게 설정을 완료해주고, 사용자 필터를 작성하러 간다
- JwtAuthenticationFilter(사용자정의필터)
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request); //요청에서 토큰을 분리
if(token!=null&&jwtProvider.validateToken(token)){
Authentication authentication = jwtProvider.getAuthentication(token); //1
SecurityContextHolder.getContext().setAuthentication(authentication); //2
}
filterChain.doFilter(request, response); //다음 필터로 넘어감
}
//요청에서 토큰을 분리
private String resolveToken(HttpServletRequest request){
String token = request.getHeader("Authorization"); //요청의 헤더에서 Authorization을 찾음
if(token!=null&&!token.trim().isEmpty()&&token.startsWith("Bearer")){ //Bearer 뒤의 토큰이 있으면 토큰을 반환
return token.substring(7);
}
return null;
}
}
먼저 OncePerRequestFilter 는 요청당 한 번만 실행되도록 보장해주는 스프링 프레임워크의 필터로, 한 번의 요청에서 여러 번의 필터가 중복으로 실행되는 것을 방지할 수 있기 때문에 JWT를 검증하기 위해 사용됨
OncePerRequestFilter 를 상속받으면 doFilterInternal()을 오버라이딩 해야한다.
요청에서 토큰을 분리하고,
만약 토큰이 null이 아니라면 유효한 토큰인지 인증하고, 인증이 완료되었다면 Authentication를 리턴한다.
//1
Authentication authentication = jwtProvider.getAuthentication(token);
이 때 토큰을 검증하고, Authentication을 리턴하는 로직은 이전에 토큰을 생성할 때 만들었던 jwtProvider에 작성해줬다.
- jwtProvider 전체코드
@Component
public class JwtProvider {
@Value("${SECRET_KEY}")
private String SECRET_KEY;
@Value("${EXP_TIME}")
private int EXP_TIME;
//토큰생성(로그인시 호출)
public String createToken(String subject, Authentication authentication){
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; //서명알고리즘선택
byte[] secretKeyBytes = DatatypeConverter.parseBase64Binary(SECRET_KEY); //시크릿키를 사용해서 문자열->바이트 배열로 파싱
Key signingKey = new SecretKeySpec(secretKeyBytes, signatureAlgorithm.getJcaName()); //파싱된 시크릿 키를 기반으로 서명에 사용할 key객체 생성
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
String roles = authorities.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
return Jwts.builder() //토큰 생성
.setSubject(subject) //토큰의 주제. 어떤 정보에 대해 발급되었는지(email,id등)
.claim("auth", roles)
.signWith(signingKey, signatureAlgorithm) //서명키와 서명알고리즘
.setExpiration(new Date(System.currentTimeMillis()+EXP_TIME)) //유효기간 설정
.compact();
}
////////////////////////////////////////////////////////////////////////////////////////////////////
//토큰을 받아 클레임 가져오기
public Claims getClaim(String token) {
try {
return Jwts.parserBuilder().setSigningKey(SECRET_KEY).build().parseClaimsJws(token).getBody();
}catch(ExpiredJwtException e) {
return e.getClaims();
}
}
//토큰이 유효한지 확인(필터에서사용)
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(SECRET_KEY).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
}
return false;
}
//유효한 토큰을 받아 인증된 객체를 생성
public Authentication getAuthentication(String token){
Claims claims = getClaim(token); //토큰의 페이로드 부분(클레임)
if(claims.get("auth")==null){
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get("auth").toString().split(",")) //클레임에서 사용자 역할 추출
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new User(claims.getSubject(),"",authorities); //로그인서비스에서의 로직과 비슷함
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
}
로그인서비스에서는 아이디와 비밀번호를 받아서 데이터베이스에 유저가 있으면 UserDetails를 리턴했지만, 여기서는
이미 발급받은 토큰으로 유효성 검증이 완료되면 페이로드 부분의 사용자정보(subject)와 역할(authorities)을 가지고 바로 UserDetails를 생성하고 Authentication객체를 리턴한다.
(UsernamePasswordAuthenticationToken는 사실 Authentication 인터페이스를 상속받은 클래스였음)
//2
SecurityContextHolder.getContext().setAuthentication(authentication);
SecurityContextHolder :는 스프링 시큐리티에서 현재 실행 중인 스레드의 보안 정보를 저장하는 역할을 한다.
getContext() : 현재 사용자의 인증 및 권한 정보가 포함된 컨텍스트를 반환
setAuthentication() : 해당 보안 컨텍스트의 인증 정보를 설정.
=> 인증된 Authentication 객체를 현재 스레드의 보안 컨텍스트에 설정하고 저장.
이제 컨트롤러에서 사용자의 정보가 필요할 때
SecurityContextHolder.getContext().getAuthentication();
를 통해서 현재 로그인된 사용자의 정보를 받아올 수 있따..
좀더 편하게 쓰기 위해서 로그인된 유저의 이메일(id)를 받아오는 메소드를 static으로 작성했음
public class JwtUtil {
//현재 로그인된 멤버 아이디 리턴하기
public static String getCurrentMemberEmail(){
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if(authentication == null || authentication.getName()==null){
throw new RuntimeException("SecurityContextHolder에 등록된 인증 정보가 없습니다.");
}
return authentication.getName();
}
}
이렇게하면 jwt 끝 '0' ~~!@!@!
이제 프론트에서 로그인된 상태를 어떻게 전역적으로 관리할 지를 고민해봐야겠다,,,,
