[Spring]스프링 시큐리티로 인증, 인가 구현_2 (Jwt Auth+Security)

cielo ru·2024년 5월 23일
0

Spring

목록 보기
3/9

➰ 서론

이전에 스프링 시큐리티로 로그인, 회원가입을 구현하였는데 이번에는 jwt 를 추가해 인증, 인가를 구현해보려 한다.

➿ JWT(토큰 방식)을 선택한 이유

➰ 세션 방식

장점

  1. 보안성: 세션은 서버 측에 저장되어 클라이언트에서 조작할 수 없다.
  2. 안전한 로그아웃: 서버에서 세션을 관리하여 로그아웃 시 즉시 세션을 무효화할 수 있다.

단점

  1. 확장성 문제: 유저가 늘어나면 서버의 메모리 부담이 증가합니다.
  2. Stateful: 세션은 서버에 상태를 저장하고 동기화를 해야 하기 때문에 무상태 원칙에 어긋난다.

➰ 토큰 방식

장점

  1. Stateless: 서버가 세션 상태를 유지할 필요가 없어 확장성에 유리하다.
  2. 자체 포함: JWT는 필요한 정보를 자체적으로 포함하여 별도의 DB 조회 없이 인증/인가가 가능하다.
  3. 부하 감소: 서버에 인증 정보를 저장할 필요가 없어 부하가 덜 걸린다.

단점

  1. 네트워크 대역폭 차지: JWT는 세션 ID보다 크기가 크기 때문에 네트워크 대역폭을 더 많이 차지한다.
  2. 안전한 로그아웃 불가 : JWT는 무상태이기 때문에 서버에서 이미 발급된 특정 토큰을 무효화하는 것이 어렵다.
  3. payload 보안성 문제 : payload는 암호화가 되어있지 않으므로 유저의 critical한 정보를 담을 수 없다.

세션 방식과 JWT 방식은 각각의 장단점이 있지만 우선 별도의 메모리 DB를 두기에는 학생이기 때문에 비용적으로 부담이 컸다. 또한 stateless한 특성으로 세션을 관리하지 않아도 되고, 그로 인해 부하가 발생하지 않아 대량의 트래픽이 발생해도 대처할 수 있다는 장점이 크게 다가왔고 JWT(토큰 방식)을 선택했다.


➰ JWT 동작 과정

Jwt 의 동작 과정은 다음과 같다.

실제로 다음과 같이 구현할 것이고, JWT의 보안성 문제와 안전한 로그아웃이 되지 않는 단점을 극복한 방법도 블로그를 통해 작성할 예정이다.


➰ 의존성 추가

JWT 토큰 방식을 적용하기 위해 다음 라이브러리들를 build.gradle에 추가해준다.

	// Jwt
	implementation 'io.jsonwebtoken:jjwt:0.9.1'
	implementation 'javax.xml.bind:jaxb-api:2.3.0'
	implementation 'org.webjars.npm:jsonwebtoken:8.5.1'
	implementation 'com.nimbusds:nimbus-jose-jwt:9.31'

➰ application.yml

jwt 사용을 위한 secretkey를 application.yml에 암호화하여 작성한다.

** 키가 노출되면 jwt 가 조작될 수 있기 때문에 암호화를 하여 git에 올리거나 application.yml을 .gitignore에 추가해줘야 한다.

jwt:
  secret: ENC(C2bA91/W5AnYOlwQ7s2QTEbw26I0epS+)

➰ Config 설정

JWT와 인증을 위해 설정 파일을 수정한다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final JwtAuthenticationEntryPoint authenticationEntryPoint;

    private static final String[] WHITE_LIST = {
            "/test/**",
            "/swagger-resources/**",
            "/swagger-ui/**",
            "/v3/api-docs/**",
            "/api/v1/auth/**",
            "/error"
    };

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws
            Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .authorizeHttpRequests(request -> request
                        .requestMatchers(WHITE_LIST).permitAll()
                        .anyRequest().authenticated() //어떠한 요청이라도 인증 필요
                )
                .exceptionHandling(exception -> exception
                        .authenticationEntryPoint(authenticationEntryPoint))
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
        ;

        return http.build();

    }
  • 인증을 위해 AuthenticationManager를 빈 등록 해준다.
    필자는 AuthenticationManager 가 빈 등록이 제대로 되지 않았는데 이전 버전의 코드로 작성하고 있었고 import 가 제대로 되지 않았다. (authenticationManager까지 코드가 바뀐 줄은 몰랐다. 이제와서 보니 너무 당연한 것들인데 고생했다..)
    혹시 위 코드로도 안된다면 import를 아래 import를 바꿔주면 코드가 제대로 돌아갈 것이다..
import org.springframework.security.authentication.AuthenticationManager;

이전 코드

/** @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
**/
  • 또한 커스텀한 에러를 반환하기 위해 authenticationEntryPoint 를 적용해주었다.

  • .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)에서 Jwt 인증을 위한 필터와 기본적인 사용자 인증을 처리하는 필터를 적용했다.

➰ JwtAuthenticationEntryPoint

유효한 자격증명을 제공하지 않고 접근하려 할때 401 Unauthorized 에러를 리턴한다.

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {

        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
    }

➰ JwtAuthenticationFilter

JWT 토큰으로 인증하고 인증정보를 SecurityContextHolder에 추가하는 역할을 담당한다.

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    // 토큰의 인증정보를 SecurityContext에 저장하는 역할 수행
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

            String token = jwtTokenProvider.resolveToken(request);

            // 토큰 검증
            if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {

                Authentication authentication = jwtTokenProvider.getAuthentication(token);

                SecurityContextHolder.getContext().setAuthentication(authentication);

            }
        filterChain.doFilter(request, response);
    }

}

➰ JwtTokenProvider

JWT를 생성하고 검증하는 컴포넌트이고, JWT 생성과 유효성 검사 등의 로직을 포함하고 있다. 토큰과 관련된 모든 것은 여기서 이루어진다.

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    // 토큰의 암호화/복호화를 위한 secret key
    @Value("${jwt.secret}")
    private String secretKey;

    public static final String BEARER = "Bearer";

    // Refresh Token 유효 기간 14일 (ms 단위)
    private final Long REFRESH_TOKEN_VALID_TIME = 14 * 1440 * 60 * 1000L;

    // Access Token 유효 기간 30분
    private final Long ACCESS_TOKEN_VALID_TIME = 30 * 60 * 1000L;

    private final MyUserDetailsService userDetailsService;

    private final RedisServiceImpl redisServiceImpl;

    // 의존성 주입이 완료된 후에 실행되는 메소드, secretKey를 Base64로 인코딩
    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    // JWT 토큰 생성
    public JWTAuthResponse generateToken(String username, Authentication authentication, Long userId, String name) {
        String id = authentication.getName();

        Claims claims = Jwts.claims().setSubject(username); //사용자 아이디
        claims.put("userId", userId); //사용자 UID
        claims.put("name", name); //사용자 이름

        Date currentDate = new Date();
        Date accessTokenExpireDate = new Date(currentDate.getTime() + ACCESS_TOKEN_VALID_TIME);
        Date refreshTokenExpireDate = new Date(currentDate.getTime() + REFRESH_TOKEN_VALID_TIME);

        String accessToken = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(currentDate)
                .setExpiration(accessTokenExpireDate)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        String refreshToken = Jwts.builder()
                .setClaims(claims)
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setExpiration(refreshTokenExpireDate)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        redisServiceImpl.setValues(username, refreshToken, Duration.ofMillis(REFRESH_TOKEN_VALID_TIME));

        return JWTAuthResponse.builder()
                    .accessToken(accessToken)
                    .refreshToken(refreshToken)
                    .tokenType(BEARER)
                    .accessTokenExpireDate(ACCESS_TOKEN_VALID_TIME)
                    .build();
    }

    // Token 복호화 및 예외 발생(토큰 만료, 시그니처 오류)시 Claims 객체 미생성
    public Claims parseClaims(String token) {
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getBody();
    }

    // 리프레시 토큰 만료 시간을 가져오는 메서드
    public Long getRefreshTokenExpirationMillis() {
        return REFRESH_TOKEN_VALID_TIME;
    }

    // Access Token의 만료 시간을 가져오는 메서드
    public Long getAccessTokenExpiration(String accessToken) {
        Claims claims = Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(accessToken)
                .getBody();
        Date expiration = claims.getExpiration();

        if (expiration != null) {
            return expiration.getTime();
        } else {
            // 만료 시간이 null이면 기본값인 0 반환
            return 0L;
        }
    }

    public String getUsername(String token) {
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getBody().getSubject();
    }

    // JWT 토큰을 복호화하여 토큰에 들어있는 사용자 인증 정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUsername(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", new ArrayList<>());
    }

    // Request의 Header로부터 토큰 값 조회
    public String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return bearerToken;
    }

    // Request Header에 Refresh Token 정보를 추출하는 메서드
    public String resolveRefreshToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Refresh");
        if (StringUtils.hasText(bearerToken)) {
            return bearerToken;
        }
        return null;
    }

    // 토큰의 유효성 검증
    public boolean validateToken(String jwtToken) {
        try {
            Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return true;
        } catch (SecurityException e) {
            throw new JwtException("잘못된 JWT 서명입니다.");
        } catch (MalformedJwtException e) {
            throw new JwtException("잘못된 JWT 토큰입니다.");
        } catch (ExpiredJwtException e) {
            throw new JwtException("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            throw new JwtException("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            throw new JwtException("JWT 토큰의 구조가 유효하지 않습니다.");
        }
    }
}
  • AccessToken과 RefreshToken을 생성하며 각각 유효시간은 30분, 14일이다. AccessToken은 너무 짧기도 길지도 않게 설정하기 위해 30분으로 설정했다.

  • RefreshToken은 Redis에 저장한다.

➰ JWTAuthResponse

로그인(인증) 성공 시 응답으로 전달할 JWT 관련 정보를 담는 DTO이다.

@Getter
@NoArgsConstructor
public class JWTAuthResponse {
    
    private String tokenType;
    private String accessToken;
    private String refreshToken;
    private Long accessTokenExpireDate;

    @Builder
    public JWTAuthResponse(String tokenType, String accessToken, String refreshToken, Long accessTokenExpireDate) {
        this.tokenType = tokenType;
        this.accessToken = accessToken;
        this.refreshToken = refreshToken;
        this.accessTokenExpireDate = accessTokenExpireDate;
    }
}

➰ UserController

회원가입, 로그인 controller 를 작성한다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/user-service")
public class UserController {

    private final UserService userService;
  
    @PostMapping("/login")
    public ResponseEntity<JWTAuthResponse> login(@RequestBody RequestLogin requestLogin){
        JWTAuthResponse token = userService.login(requestLogin);
        return ResponseEntity.ok(token);
    }

    @PostMapping("/register")
    public ResponseEntity<String> register(@RequestBody RequestUser requestUser){
        String response = userService.register(requestUser);
        return new ResponseEntity<>(response, HttpStatus.CREATED);
    }

** 현재 개발이 모두 완료된 상태이기 때문에 이전에 작성했던 코드를 가져왔습니다. url 등이 security 설정과 맞지 않을 수 있습니다.

➰ UserService, UserServiceImpl

회원가입, 로그인 UserService 인터페이스와 UserServiceImpl를 구현한다.

public interface UserService{

    JWTAuthResponse login(RequestLogin requestLogin);

    String register(RequestUser requestUser);
    
}
@Slf4j
@Transactional
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService{

    private final BCryptPasswordEncoder pwdEncoder;
    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider jwtTokenProvider;
    private final MyUserDetailsService myUserDetailsService;
    private final UserRepository userRepository;


    @Override
    public JWTAuthResponse login(RequestLogin requestLogin) {
        Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(
                requestLogin.getEmail(), requestLogin.getPwd()));

        SecurityContextHolder.getContext().setAuthentication(authentication);
        Long userId = myUserDetailsService.findUserIdByEmail(requestLogin.getEmail());
        JWTAuthResponse token = jwtTokenProvider.generateToken(requestLogin.getEmail(), authentication, userId);
        return token;
    }

    @Override
    public String register(RequestUser requestUser) {

        // add check for email exists in database
        if(userRepository.existsByEmail(requestUser.getEmail())){
            throw new BlogAPIException(HttpStatus.BAD_REQUEST, "Email is already exists!.");
        }

        ModelMapper mapper = new ModelMapper();
        mapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);
        UserEntity userEntity = mapper.map(requestUser, UserEntity.class);
        userEntity.setEncryptedPwd(pwdEncoder.encode(requestUser.getPwd()));
        userEntity.setApproved(false);
        userRepository.save(userEntity);

        return "User registered successfully!.";
    }
}
  • 로그인 시 빈등록을 했던 AuthenticationManager를 통해 아이디(여기선 이메일), 비밀번호를 인증하고 인증 완료 시 인증 정보를 SecurityContextHolder에 저장한다.

  • 사용자 이메일과 인증 정보, userId를 통해 토큰을 생성한다.

➰ UserRepository

사용자 정보를 저장, 조회, 수정, 삭제를 위한 UserRepository를 구현한다.

public interface UserRepository extends JpaRepository<UserEntity, Long> {

    Optional<UserEntity> findByEmail(String email);

    Boolean existsByEmail(String email);
    
}

➰ 결과

회원가입 후 로그인이 잘되는 것을 확인할 수 있다.

시큐리티에 JWT 를 적용하는 방법은 생각보다 간단하다.

Security 설정 코드에서 addFilterBefore에 UsernamePasswordAuthenticationFilter보다 먼저
JwtAuthenticationFilter를 적용시키면 된다.

또한 JwtAuthenticationEntryPoint를 통해 커스텀한 에러를 반환할 수 있게 할 수 있다. 이러한 방법으로 Filter를 커스텀 하여 인증을 수행하면 된다.


다음 포스팅에서는 로그인 후 모든 요청에서 토큰을 인증하는 Jwt Intercepter를 구현하는 방법에 대해 알아보겠습니다.

➰ 참고

https://spring.io/guides/topicals/spring-security-architecture

https://0strich.tistory.com/39

https://eungeun506.tistory.com/137

profile
Cloud Engineer & BackEnd Developer

0개의 댓글