Spring/개인공부_실습

[Spring REST API]로그인 구현하기 - JWT + Security

빙응이 2024. 6. 3. 23:24

📝JWT 기본 동작 알아보기 

JWT의 기본 동작을 알아보자 

간단히 알아보자면 시큐리티처럼 Access Token(시큐리티에서 세션)으로 검증 로직이 이루어진다.

  1. 접속을 하면 Access Token을 검사하여 없으면 로그인 요청을 한다. 
  2. 유저가 로그인을 하면 Access Token을 발행한다. 
  3. Access Token을 발행받고 접속 시 서버에 Access Token을 Request에 담아 전달한다.
  4. 서버는 Access Token의 유효기간을 확인하고 서비스를 제공한다. 

다음에 다시 접속 했을 때도 똑같이 동작한다.

  • Access Token의 유효기간이 지나지 않았다면 인증 절차를 패스할 수 있다.
  • 그렇기에 인증 전에 Access Token을 검사하는 기능이 필요하다.

 

실제로 인증을 진행하는 것은 Scurity의 AuthenticationFilter이지만 이 작업 전에 JWT Token 검증을 진행하는 

JwtAuthenticationFilter를 만들어 이를 확인해주면 된다. 

 

📝 JWT 토큰 발행하기 - JwtTokenProvider

Access Token을 발행해주는 Provider를 만들어보자 

JWT 개요 (tistory.com)

 

JWT 개요

📝JWT JWT(JSON Web Token) JWT란 JSON 객체로 당사자 간에 정보를 안전하게 전송하기 위한 간결하고 독립적인 방법을 정의하는 개방형 표준이다. JWT는 비밀키 혹은 RSA 등을 사용하여 공개/개인 키 알고

quddnd.tistory.com

이 포스팅에서 나온 것처럼  JWT의 토큰은 해당 과정으로 만들어진다.

  1. Payload와 Sercret Key를 서명 알고리즘에 입력해 서명을 만든다.
  2. 여기서는 HS256 서명알고리즘을 사용하여 이 정보를 Header로 만든다. 
  3. 이 정보들을 조립하고 base64로 인코딩한다. 

 

이를 위해 Secret Key를 생성해 주어야 한다. 이것은 application.properties에 정의해두자

spring.jwt.secret={Key}

application.yml은 해당 방식을 사용한다.

spring:
  jwt:
    secretKey: {Key}

 

토큰 만료 시간 정의
  • 토큰은 보안을 이유로 유효기간을 정해야한다. 
private long tokenRemiteTime = 1000L * 60 * 30; // 30분
  • 해당 단위는 Millisecond 단위로 지정되기에 다음과 같이 생성해준다.
토큰 생성 로직
    public String createToken(String username) {

        return Jwts.builder()
            .claim("username", username)
            .issuedAt(new Date(System.currentTimeMillis()))
            .expiration(new Date(System.currentTimeMillis() + tokenRemiteTime))
            .signWith(secretKey)
            .compact();
    }
  • 'Jwts,builder()' : jwt를 생성하기 위한 빌더 객체를 생성
  • '.claim("username", username)' : 클레임을 추가하며, 클레임이란 토큰에 담기는 정보를 의미한다.
  • '.issuedAt(new Date(System.currentTimeMillis()))' : 토큰의 발급 시간을 설정한다. 현재 시간을 기준
  • '.expiration(new Date(System.currentTimeMillis() + tokenRemiteTime))' : 토큰의 유효 시간을 설정
  • ' .signWith(secretKey)' : 토큰에 서명을 추가, secretKey는 서명에 사용될 키를 의미한다.
  • '.compact()' : 토큰 반환 
토큰에서 필요 정보 받아오기
  public String getUsername(String token) {

    return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class);
  }

  //토큰이 만료되었는지 확인한다.
  public Boolean isExpired(String token) {

    return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
  }
  • 해당 메소드들은 토큰을 디코딩하여 정보를 얻어오는 것이다. 
public Authentication getAuthentication(String token) {
    // JWT 토큰에서 사용자 이름을 추출
    String username = getUsername(token);

    // 사용자 이름을 사용하여 사용자 세부 정보를 로드
    UserDetails userDetails = userDetailsService.loadUserByUsername(username);

    // 사용자 세부 정보와 권한을 포함하여 인증 토큰을 생성
    return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
  • 해당 메소드는 사용자가 로그인할 때 사용하는 것이 아닌 이미 로그인한 사용자가 JWT 토큰을 가지고 있을 때 인증 토큰을 통해 인증정보를 생성하는 것이다. 
전체 코드
@Component
@Slf4j
public class JwtTokenProvider {
    private final SecretKey secretKey;
    private final UserDetailsService userDetailsService;
    private final long tokenRemiteTime = 1000L * 60 * 30; // 30분

    public JwtTokenProvider(@Value("${spring.jwt.secret}")String secret, UserDetailsService userDetailsService) {
        secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
        this.userDetailsService = userDetailsService;
    }
    public String getUsername(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class);
    }

    //토큰이 만료되었는지 확인한다.
    public Boolean isExpired(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
    }
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(getUsername(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }
    public String createToken(String username) {

        return Jwts.builder()
            .claim("username", username)
            .issuedAt(new Date(System.currentTimeMillis()))
            .expiration(new Date(System.currentTimeMillis() + tokenRemiteTime))
            .signWith(secretKey)
            .compact();
    }
}

 

 

📝로그인 필터 만들기 - LoginFilter

일단 우리는 FormLogin을 하지 않기 때문에 LoginFilter로직을 만들어줘야 한다.

 

로그인 메소드 - attemptAuthentication

 

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
        try {
            // 요청 본문에서 JSON 데이터를 읽어옴
            UserLoginRequest userLoginRequest = objectMapper.readValue(request.getReader(), UserLoginRequest.class);

            // 유효한 JSON 데이터가 아닌 경우 null 반환
            if (!isValidJsonAuthenticationRequest(userLoginRequest)) {
                return null;
            }

            // UsernamePasswordAuthenticationToken 생성
            UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                userLoginRequest.getUserName(),
                userLoginRequest.getPassword()
            );

            // AuthenticationManager에게 인증 요청 전달
            return authenticationManager.authenticate(authToken);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
       private boolean isValidJsonAuthenticationRequest(UserLoginRequest userLoginRequest) {
        return userLoginRequest != null && StringUtils.hasText(userLoginRequest.getUserName()) && StringUtils.hasText(userLoginRequest.getPassword());
    }
  • UsernamePasswordAuthenticationFilter에서 제공하는 로그인 시 자동으로 호출되는 메소드이다.
  • 나는 JSON으로 데이터를 보낼 것이기 때문에 JSON 로직을 추가하였다. 
    1. /Login 으로 요청이 오면 요청 데이터를 읽어옴
    2. JSON 형식인지 검사
    3. 형식이 맞다면 인증을 위한 UsernamePasswordAuthenticationToken 생성
    4. AuthenticationManager에게 인증요청
로그인 성공, 실패 로직
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {

        CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();

        String userName = customUserDetails.getUsername();


        String token = jwtTokenProvider.createToken(userName);

        response.addHeader("Authorization", "Bearer " + token);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed){
        response.setStatus(401);
    }
  • 해당 메소드는 각각 인증 성공, 실패 로직이다.
  • 인증 성공 로직은
    • 인증 성공 객체를 미리 정의해둔 CustomUserDetails로 받아 토큰을 생성해 응답으로 보내준다.
  • 인증 실패 로직은 실패시 오류 처리를 해주면 된다.
커스텀 UserDetails, UserDetailsService
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {

    private final User user;


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

    @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;
    }
}
@Service
@RequiredArgsConstructor
public class UserDetailsService implements org.springframework.security.core.userdetails.UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {

        // DB에서 사용자 조회
        User user = userRepository.findByUserName(userName)
            .orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + userName));

        return new CustomUserDetails(user);
    }
}

 

📝 JWT 토큰 검사하기 

이제 로그인, JWT 토큰 발급을 완료했으니 JWT 토큰이 있나 검사하고 유효 기간을 검사하는 것을 만들어보자

위에서 말했듯이 JWT 토큰 검사는 로그인 앞에서 진행해야 한다.

/**
 * jwt를 검증하기 위한 커스텀 필터
 * 해당 필터를 통해 요청 헤더 키에 jwt가 존재하는 경우 jwt를 검증하고
 * 강제로 세션을 생성한다.
 */
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        //request에서 Authorization 헤더를 찾음
        String authorization= request.getHeader("Authorization");

        //Authorization 헤더 검증
        if (authorization == null || !authorization.startsWith("Bearer ")) {

            System.out.println("token null");
            //필터 다음으로 넘겨줌(JWT 검증 -> 로그인)
            filterChain.doFilter(request, response);

            //조건이 해당되면 메소드 종료
            return;
        }

        //Bearer 부분 제거 후 순수 토큰만 획득
        String token = authorization.split(" ")[1];

        //토큰 소멸 시간 검증
        if (jwtTokenProvider.isExpired(token)) {

            System.out.println("token expired");
            filterChain.doFilter(request, response);

            //조건이 해당되면 메소드 종료
            return;
        }


        //스프링 시큐리티 인증 토큰 생성
        Authentication authToken = jwtTokenProvider.getAuthentication(token);
        //세션에 사용자 등록
        SecurityContextHolder.getContext().setAuthentication(authToken);

        filterChain.doFilter(request, response);
    }
}
  1. 들어온 요청에서 Authorization 헤더를 찾아 검증한다.(토큰 존재 유무)
    1.  없다면 로그인으로 필터를 넘긴다.
  2. 토큰이 있다면 토큰 소멸 시간을 검증한다.
    1. 소멸 시간이 지났다면 필터를 다음으로 넘겨준다.
  3. 소멸 시간이 안지났다면 세션에 사용자를 등록하여 검증을 끝낸다.

📝SecurityConfig

이제 다 만들었으니 필터의 순서들을 적용시켜야 한다.

우리는 REST API를 활용하기 때문에 httpBasic, csrf, formlogin, 세션을 모두 꺼준다. 

그리고 우리가 만든 JwtFilter와 LoginFilter를 등록해주면 된다. 

물론 안에 들어가는 인자 넣는 것을 까먹지 말자!

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

  //AuthenticationManager가 인자로 받을 AuthenticationConfiguraion 객체 생성자 주입
  private final AuthenticationConfiguration authenticationConfiguration;
  private final JwtTokenProvider jwtTokenProvider;
  private final BCryptPasswordEncoder bCryptPasswordEncoder;

  @Bean
  public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {

    return configuration.getAuthenticationManager();
  }

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

    //csrf disable
    http
        .csrf((auth) -> auth.disable());

    //From 로그인 방식 disable
    http
        .formLogin((auth) -> auth.disable());

    //http basic 인증 방식 disable
    http
        .httpBasic((auth) -> auth.disable());
    //JWTFilter 등록
    //로그인 필터보다 앞에 두어 자동로그인 구현
    http
        .addFilterBefore(new JwtFilter(jwtTokenProvider), LoginFilter.class);
    //필터 추가 LoginFilter()는 인자를 받음 (AuthenticationManager() 메소드에 authenticationConfiguration 객체를 넣어야 함) 따라서 등록 필요
    http
        .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration),jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

    //경로별 인가 작업
    http
        .authorizeHttpRequests((auth) -> auth
            .requestMatchers("/login", "/users/join").permitAll()
            .anyRequest().authenticated());

    //세션 설정
    //JWT 방식은 항상 상태없음 방식으로 동작하기에 설정해줘야 한다.
    http
        .sessionManagement((session) -> session
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS));

    return http.build();
  }
}

 

📝 테스트 

일단 로그인을 하고 들어갈 페이지를 만들어주자

@RestController

public class mainController {
    @GetMapping("/main")
    public ResponseEntity<String> main() {
        return ResponseEntity.status(200).body("성공");
    }
}

 

회원가입 

회원가입이 성공하고 원하는 형식으로 Json을 반환한 것을 알 수 있다.

 

로그인

로그인을 진행하면 JSON으로 아이디와 비밀번호를 보내 인증하고 성공하면 Authorization에서 Bearer {Token}을 받는다.

성공한 것을 알 수 있다. 

이제 이 헤더를 요청할 때 담아서 보내면 된다.

 

요청해보기

성공한 것을 알 수 있다.

 

📝마무리

REST API 상에서 JWT를 구현하는 것을 알아보았다.

그러나 보안을 위해 Refresh Token을 이용해서 관리하는 것이 좋다.

다음 포스팅에서 해당 기능을 구현할 것이다.