Spring

[Spring] JWT Token 인증 구현

lakelight 2022. 8. 18. 09:40
728x90
반응형

JWT Token

인증에 필요한 정보들을 Token에 담아서 암호화시켜 사용하는 인증 방식 입니다. 아래와 같이 Header, Payload, Signature로 구성되어 있습니다.

  • Header에는 보통 토큰의 타입이나, 서명 생성에 사용되는 알고리즘에 대한 내용을 저장합니다. 
  • Payload에는 보통 Claim이라는 사용자, 토큰에 대한 property를 key-value의 형태로 저장합니다.
  • Signature는 Header와 Payload에 대한 값을 디코딩하여 서버가 개인키를 가지고 암호화 한 상태를 보여줍니다.

이미지 출처: https://velopert.com/2389

 

JWT Token을 이용한 인증 시스템 구현

전체적인 흐름
  1. 사용자가 로그인 했을 때 토큰 발급
  2. 사용자는 토큰을 저장해놓고 API 요청 할 때 토큰을 Authorization 헤더에 넣어서 요청합니다.
  3. 서버는 Authorization 헤더에서 토큰을 꺼내서 검증을 진행합니다.
  4. 검증이 완료되면 사용자가 원하는 정보를 응답합니다.

 

구체적인 흐름
  1. HTTP 요청이 들어오면 요청 Header에 Authorization 값에서 토큰을 가져와서 검증을 진행합니다.
  2. 검증이 완료되었다면 AuthenticationProvider를 통해 Authentication객체를 생성해야합니다.
  3. AuthenticationProvider에서 MemberDetailService에 loadUserByUsername()을 호출해서 DB에 userId와 일치하는 사용자를 가져와서 userDetails 객체를 만들어 반환합니다.
  4. userDetails 객체를 통해서 UsernamePasswordAuthenticationToken()을 호출하여 Authentication 인증객체를 받아옵니다.
  5. 인증 객체 Authentication을 인증된 사용자 정보를 저장해놓는 SecurityContextHolder의 Context에 저장합니다.
  6. 마지막으로 chain.doFilter를 통해 계속 진행시키고 만약 인증이 실패했다면  403 Forbidden을 반환하고, 인증에 성공했다면 사용자가 요구하는 응답을 하게됩니다.

이미지 출처: https://cjw-awdsd.tistory.com/45

 

참고

저는 AuthenticationManager를 생략하고 진행했습니다. 이점 참고해주시면 감사하겠습니다.

 

코드

[WebSecurityConfig.class]

package hyjung.shop_management.config;

import hyjung.shop_management.jwt.JwtAuthenticationFilter;
import hyjung.shop_management.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtTokenProvider jwtTokenProvider;
    @Override
    protected void configure(HttpSecurity http) throws Exception{
        http.csrf().disable();
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .formLogin().disable()
                .httpBasic().disable()
                .authorizeRequests().mvcMatchers("/login", "/api/user").permitAll()
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
                //JWTAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 등록을 해줍니다.
    }

    @Bean
    public PasswordEncoder getPasswordEncoder(){
        return new BCryptPasswordEncoder();
    }
}
.authorizeRequests().mvcMatchers("/login", "/api/user").permitAll()
.anyRequest().authenticated()
위의 코드를 사용하여 로그인과, 회원가입을 할 때는 인증을 진행하지 않고, 그 외의 경우에만 인증을 진행하도록 하였습니다.

 

[JwtAuthenticationFilter.class]

package hyjung.shop_management.jwt;

import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilter {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

	// HTTP request에서 Authorization헤더에서 토큰을 가져오는 메서드입니다.
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);

	// 토큰 검증을 진행합니다. 토큰이 있는지와 토큰이 만료되지 않았는지 확인합니다.
        if(token!=null && jwtTokenProvider.validateTokenExpiration(token)){
        
            // 토큰을 통해 Authentication 객체를 얻어오는 메서드입니다.
            Authentication auth = jwtTokenProvider.getAuthentication(token);
            
            // Authentication 객체를 인증된 사용자 정보를 저장하는 곳에 저장
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        chain.doFilter(request, response);
    }
}

 

[application.propertiest]

spring.jwt.secretKey=#ShOp#MaNaGeMeNt#SeCrEt#KeY#

 

[JwtTokenProvider.class]

package hyjung.shop_management.jwt;

import io.jsonwebtoken.*;
import lombok.RequiredArgsConstructor;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import java.util.Date;

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
    // 위에 application.properties에서 설정한 값 가져왔습니다.
    @Value("${spring.jwt.secretKey}")
    private String secretKey;

    // 토큰 만료 시간 설정합니다.
    private Long ExpiredTime = 1000L * 60 * 15;

    // Refresh 토큰 만료 시간 설정합니다.
    private Long RefreshTime = 1000L * 60 * 60 * 24 * 7;

    // 서명 암호화 알고리즘 설정합니다.
    private SignatureAlgorithm algorithm = SignatureAlgorithm.HS256;

    //사용자의 정보를 받아오는 서비스 클래스
    private final UserDetailsService memberDetailService;

    // 토큰 생성합니다.
    public String createToken(String userId){
        Claims claims = Jwts.claims().setSubject(userId);
        Date now = new Date();

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime()+ExpiredTime))
                .signWith(algorithm, secretKey)
                .compact();
    }

    // 리프레시 토큰 생성합니다.
    public String createRefreshToken(){
        Date now = new Date();

        return Jwts.builder()
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime()+RefreshTime))
                .signWith(algorithm, secretKey)
                .compact();
    }

    // Authentication 인증 객체 생성하여 반환합니다.
    public Authentication getAuthentication(String token){
        // 사용자의 정보를 반환합니다.
        UserDetails userDetails = memberDetailService.loadUserByUsername(getUserId(token));

        // 사용자 정보인 userDetails를 통해 Authentication 객체를 반환합니다.
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    // 토큰을 통해서 사용자의 아이디를 받습니다.
    public String getUserId(String token){
        try{
            return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
        } catch (ExpiredJwtException e){
            return e.getClaims().getSubject();
        }
    }

    // 토큰이 만료되었는지 확인합니다.
    public boolean validateTokenExpiration(String token){
        try{
            // 토큰의 Claims를 받아오는 코드를 실행하고 만약 토큰이 만료되었거나, 잘못된 토큰이라면 에러가 반환될 것이기 때문에
            // 에러가 반환되지 않았다면 만료되지 않은 토큰으로 판단합니다.
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            return true;
        } catch (Exception e){
            return false;
        }
    }

    public String resolveToken(HttpServletRequest request){
        // Http Header에 Authorization 값을 가져옵니다.
        // 원래 Bearer을 앞에 붙여주어야합니다.
        // 이 코드에서만 Bearer을 제외시키고 코드를 구성하였습니다.
        return request.getHeader("Authorization");
    }
}

 

[MemeberDetail.class]

package hyjung.shop_management.jwt;

import lombok.Builder;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;

public class MemberDetail implements UserDetails {

    private String userId;
    private String userPw;
    private List<GrantedAuthority> authorities;

    @Builder
    public MemberDetail(String userId, String userPw, List<GrantedAuthority> authorities) {
        this.userId = userId;
        this.userPw = userPw;
        this.authorities = authorities;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return userPw;
    }

    @Override
    public String getUsername() {
        return userId;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

[MemberDetailService.class]

package hyjung.shop_management.jwt;

import hyjung.shop_management.domain.Member;
import hyjung.shop_management.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
@RequiredArgsConstructor
public class MemberDetailService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
        
        // 데이터베이스에서 사용자를 찾아서 엔티티 반환합니다.
        Member member = memberRepository.findByUserId(userId);
        
        // 역할을 저장하기 위해서 리스트를 만들어줍니다.
        List<GrantedAuthority> roles = new ArrayList<>();
        
        // 역할에 member의 역할에 대한 내용을 넣어줍니다.
        roles.add(new SimpleGrantedAuthority(member.getRole().toString()));

        // MemberDetail을 생성하여 반환합니다.
        return MemberDetail.builder()
                .userId(member.getUserId())
                .userPw(member.getUserPw())
                .authorities(roles)
                .build();
    }
}

 

 

결론

그동안 개념만 이해하고 실제로 코드에 적용해보지 못했는데 이번 기회에
실제 코드에 적용해보면서 사용해봐서 실제 코드에서 적용하는 방법을 알게되었습니다.

실제 실무에서 사용할 수 있도록 세부적인 내용을 더 공부해서
JWT를 더 잘 사용할 수 있도록 실력을 키우겠습니다.

포스팅 봐주셔서 감사합니다.

 

전체 Code Git Link

 

GitHub - hooyn/ShopMangement: 트윔 신입 교육 - 소규모 가게 프로젝트

트윔 신입 교육 - 소규모 가게 프로젝트. Contribute to hooyn/ShopMangement development by creating an account on GitHub.

github.com

728x90
반응형