Spring Security + JWT 이해하며 구현하기

2024. 3. 5. 10:20
728x90

https://hehesim.tistory.com/36

 

스프링 시큐리티 인증 절차

1) Authentication (인증) : 'A'라고 주장하는 주체가 'A'가 맞는지 확인하는 것. - 코드에서 Authentication : 인증 과정에 사용되는 핵심 객체. 2) Authorization (권한) : 특정 자원에 대한 권한이 있는지 확인하

hehesim.tistory.com

이것은 내가 이전에 공부하며 만들었던 Spring Security 인증 절차이다. 

이때는 딱 Spring Security만을 사용하여 인증과정을 구현하고 공부했다. 

이번 프로젝트에서는 여기에 더하여 JWT토큰을 활용한 인증을 구현했다. 

이유는 Stateless를 지키기 위하여였다. 상태를 저장하지 않기 때문에 세션관리가 필요없고, 인증 시 위변조 확인까지 가능하다. 

 

1. JwtTokenFilter 추가

그래서 JwtTokenFilter를 활용하여 매 요청마다 JwtToken을 확인하는 과정을 만들었다. 

실행 시 SecurityFilterChain을 살펴보면 이런 여러가지 필터들을 거쳐간다. 

filters : DisableEncoderUrlFilter - WebAsyncManagerIntegrationFilter - SecurityContextHolderFilter - HeaderWriterFilter - CorsFilter - LogoutFilter - RequestCacheAwareFilter - SecurityContextHolderAwareRequestFilter - AnonymousAuthenticationFilter - SessionManagementFilter - ExceptionTranslationFilter - JwtTokenFilter - AuthorizationFilter

 

여기서 JwtTokenFilter을 내가 추가한 것이다.

- addFilterBefore(new JwtTokenFilter(jwtTokenUtils, manager), Authorization.class)

@Configuration
@RequiredArgsConstructor
public class WebSecurityConfig {
    private final JwtTokenUtils jwtTokenUtils;
    private final UserDetailsManager manager;

    @Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity httpSecurity
    ) throws Exception {
        httpSecurity
                //csrf 보안 해제
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(session ->
                        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                // AuthorizationFilter이전에 TokenFilter를 추가해줌.
                .addFilterBefore(new JwtTokenFilter(jwtTokenUtils, manager), AuthorizationFilter.class)
                //url에 따른 요청 인가
                .authorizeHttpRequests(
                        auth -> //로그인, 회원가입은 익명사용자만 요청 가능
                                auth
                                        .requestMatchers("/error", "/payment/**",
                                                "/static/**", "/toss/**", "shopping-malls/order/{orderId}/payment").permitAll()
                                        // 관리자 페이지는 관리자만 가능
                                        .requestMatchers("/admin/**")
                                        .hasAnyRole("ADMIN")
                                        // 로그인과 회원가입은 익명 사용자만 가능
                                        .requestMatchers("/users/login", "/users/register")
                                        .anonymous()
                                        // 추가정보 입력은 inactive만 가능
                                        .requestMatchers("/users/{accountId}/additional-info")
                                        .hasAnyRole("INACTIVE")
                                        // 중고거래, 비즈니스 계정 신청/확인, 쇼핑몰 서비스 이용 active, business만 가능
                                        .requestMatchers("/users/**", "/used-goods/**")
                                        .hasAnyRole("ACTIVE", "BUSINESS")
                                        // 쇼핑몰 운영 서비스는 business회원만 가능
                                        .requestMatchers("/shopping-malls/**", "/payment/**")
                                        .hasAnyRole("ACTIVE", "BUSINESS", "ADMIN")
                                        .anyRequest().authenticated()
                );
        return httpSecurity.build();
    }
}

 

 

2. JwtTokenFilter 내부

JwtTokenFilter를 자세하게 살펴보자.

- OncePerRequestFilter를 상속받아 각 요청마다 이 필터를 거쳐가게끔 한다.

- doFilterInternal()를 오버라이딩하여 여기서 JWT Token을 확인하고, 인증정보를 생성하고, 등록하는 과정을 실시한다.

  • 1) Http요청 헤더에서 Authorization 헤더를 회수
  • 2) 헤더가 존재하는지, Bearer 로 시작하는지 확인 => JwtToken
  • 3) 토큰이 존재하면 유효한 토큰인지 확인
  • 4) 유효하다면 토큰에서 사용자 정보를 가져온다 => UserDetails
  • 5) 토큰과 토큰에서 가져온 사용자 정보를 활용하여 AuthenticationToken을 생성한다.

       (Principal, Credentials, Collection<GrantedAuthority> = userDetails, token, userDetails.getAuthority())

  • 6) 생성한 인증정보를 SecurityContext에 등록. (SecurityContextHolder로 SecurityContext를 만들고, 여기에 setAuthentication())

- 이후 꼭 filterChain연결해주어야 한다. (filterChain.doFilter(request, response);

// JwtTokenFilter class

//들어온 요청에 대해 토큰 유효성 확인 &이에 따른 인증정보를 등록해주는 필터
@Slf4j
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
    private final JwtTokenUtils jwtTokenUtils;
    private final UserDetailsManager manager;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        // 1. authorization 헤더를 회수
        String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

        // 2. 헤더가 존재하는지, Bearer로 시작하는지
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            // 3. 존재하면 유효한 토큰인지 확인
            String token = authHeader.split(" ")[1];
            //토큰 확인
            if (jwtTokenUtils.validate(token)) {
                // contextHolder에서 context를 만들고,
                SecurityContext context = SecurityContextHolder.createEmptyContext();
                // 토큰에서 사용자 정보 가져오기
                String username = jwtTokenUtils.parseClaims(token).getSubject();
                log.info("username:: "+username);

                UserDetails userDetails = manager.loadUserByUsername(username);
                log.info(userDetails.toString());
                for (GrantedAuthority authority : userDetails.getAuthorities()) {
                    log.info("authority: {}", authority.getAuthority());
                }

                //인증 정보 생성
                AbstractAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                        userDetails, token, userDetails.getAuthorities()
                );
                //인증 정보 등록
                context.setAuthentication(authenticationToken);
                SecurityContextHolder.setContext(context);
                log.info("===set security context with jwt");
                log.info("Auth :: "+SecurityContextHolder.getContext().getAuthentication().toString());
            } else {
                log.warn("jwt validation failed!");
            }
        }
        filterChain.doFilter(request, response);
    }
}

 

3. JwtTokenUtils

** JwtTokenUtils : JwtToken 관련 서비스를 실행함.

- 생성시 : yaml 파일의 jwtSecret을 암호화하여 signingKey로 저장, 이 signingKey를 담은 jwtParser 저장.

- generateToken() : UserDetails를 받아서 JWT로 변환하는 과정. + 여기서 auth라는 Claim에다가 그 유저의 authority를 담는다. 즉, token을 통해서 그 유저의 권한을 알 수 있도록 하는 것. 

- parseClaim() : JWT 토큰을 통해 payload를 반환 (Claims를 반환)

(토큰의 구조: header - payload (claim, sub, iat, exp...) - signature)

- isNotExpired(): JWT가 만료된 토큰인지 확인

- validate() : 해당 JWT 토큰이 정상적인 JWT인지 확인하는 과정. (isNotExpired & parseClaim이 정상적으로 되어야 한다.)

// JwtTokenUtils

@Slf4j
@Component
public class JwtTokenUtils { //JwtToken관련 서비스
    private final Key signingKey;
    private final JwtParser jwtParser;

    public JwtTokenUtils(
            @Value("${jwt.secret}")
            String jwtSecret
    ) {
        this.signingKey = Keys.hmacShaKeyFor(jwtSecret.getBytes());
        this.jwtParser = Jwts.parserBuilder().setSigningKey(signingKey).build();
    }

    // UserDetails를 받아서 JWT로 변환
    // header - payload : claim, sub, iat, exp - signature
    public String generateToken(UserDetails userDetails) {
        Instant now = Instant.now(); //시간
        Claims jwtClaims = Jwts
                .claims()
                .setSubject(userDetails.getUsername())
                .setIssuedAt(Date.from(now))
                .setExpiration(Date.from(now.plusSeconds(86400L))); //todo: 임시적인 시간 (줄여야한다)
        jwtClaims.put("auth", userDetails.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList()));

        // jwt 발급
        return Jwts.builder()
                .setClaims(jwtClaims)
                .signWith(this.signingKey)
                .compact(); //압축
    }

    // payload를 반환
    public Claims parseClaims(String token) {
        return jwtParser.parseClaimsJws(token).getBody();
    }

    // JWT가 만료된 토큰인지 확인해야 함.
    public boolean isNotExpired(String token) {
        if (Instant.now().isAfter(this.parseClaims(token).getExpiration().toInstant())) {
            return false;
        }
        return true;
    }
    
    // 정상적인 jwt인지 판단
    public boolean validate(String token) {
        try {
            if (this.isNotExpired(token)) {
                this.parseClaims(token);
            }
            return true;
        } catch (Exception e) {
            log.warn("invalid jwt");
        }
        return false;
    }

}

 

4. PasswordEncoder 

: 비밀번호 암호화 과정이 필요하므로 PasswordEncoder도 Configuration을 만들고, Bean으로 등록해주어야 한다. 암호화 방식은 BCrypt로 설정했다.

//비밀번호 인코더
@Configuration
public class PasswordEncoderConfig {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

 

5. CustomUserDetailsManager

그리고, 이러한 Jwt를 로그인시 발급받고,,,, 등등의 유저 인증 관련 처리들을 위한 UserDetailsManager의 구현체를 만들어주었다. 

그 내부를 간단하게 정리해보자면 UserDetailsService를 UserDetailsManager가 상속받고, 얘를 구현한 구현체로 CustomUserDetailsManager를 만들어준 것. 그래서 여기 내부의 메서드들을 모두 오버라이딩한 클래스가 되었고, 여기에 필요에 따른 다른 인증 관련 메서드들도 구현해주었다.

- userLogin() : 로그인 시의 과정을 구현했다. 계정 id가 있는지 확인하고, 비밀번호가 일치하는지 확인. 둘다 일치한다면 토큰을 발급하고, 일단은 토큰 정보를 return하도록 만들었다. 

- createUser() : 회원가입 시의 과정을 구현했다. 이때 UserDetails형태로 받아와서 실제 UserRepository의 엔티티인 UserEntity형태로 변환 및 암호화하는 과정을 통해 저장하였다. 

- checkIdIsEqual() : 어떤 메서드를 실행할 때 로그인한 사용자가 해당 메서드의 권한이 있는지를 확인하기 위해 미리 메서드를 만들었다. 현재 인증정보의 아이디와 같은지 확인하는 과정이다.

- checkIdIsNotEqual(): 반대로 해당 아이디와 인증정보의 아이디가 같지 않은지 확인하는 과정이다. 

- loadUserFromAuth(): 인증정보에서 UserEntity를 추출하는 메서드.

// CustomUserDetailsManager class

@Slf4j
@Service
@RequiredArgsConstructor
public class CustomUserDetailsManager implements UserDetailsManager {
    private final JwtTokenUtils jwtTokenUtils;
    private final PasswordEncoder passwordEncoder;
    private final UserRepository userRepository;
    private final AuthenticationFacade facade;

    @Override
    public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
        //repo에서 userId로 찾아오기
        Optional<UserEntity> optionalUser = userRepository.findByAccountId(userId);
        if (optionalUser.isEmpty()) {
            log.info("loadUserByUsername : not found");
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        }

        UserEntity userEntity = optionalUser.get();
        //리턴을 UserDetails 형태로 한다
        UserDetails userDetails = CustomUserDetails.builder()
                .userId(userEntity.getAccountId())
                .password(userEntity.getPassword())
                .authorities(userEntity.getAuthority())
                .build();
        return userDetails;
    }

    public JwtResponseDto userLogin(JwtRequestDto dto) {
        // 넘어온 dto의 계정id가 없는 계정이라면 예외 발생
        UserDetails userDetails = this.loadUserByUsername(dto.getUserId());
        // 비밀번호가 옳지 않아도 예외 발생
        if (!passwordEncoder.matches(dto.getPassword(), userDetails.getPassword())) {
            log.warn("password mismatch");
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
        }

        //일치하면 토큰 발급
        String generatedToken = jwtTokenUtils.generateToken(userDetails);
        // 토큰과 subject dto에 넣어 반환
        JwtResponseDto jwtResponseDto = JwtResponseDto.builder()
                .token(generatedToken)
                .subject(jwtTokenUtils.parseClaims(generatedToken).getSubject())
                .build();
        log.info("토큰 발급 완료 : {}", jwtResponseDto.getSubject());
        return jwtResponseDto;
    }


    @Override
    public void createUser(UserDetails user) {
        // userId가 겹치면 안됨
        if (this.userExists(user.getUsername())) {
            log.info("user already exists");
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
        }
        try {//안겹치면 가입진행
            CustomUserDetails userDetails = (CustomUserDetails) user;

            UserEntity newUser = UserEntity.builder()
                    .accountId(userDetails.getUserId())
                    .password(passwordEncoder.encode(userDetails.getPassword()))
                    .authority(userDetails.getStringAuthorities())
                    .build();
            userRepository.save(newUser);
        } catch (Exception e) {
            log.error("Failed to create user: {}", e.getMessage());
            throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }

    @Override
    public boolean userExists(String userId) {
        return userRepository.existsByAccountId(userId);
    }


    // 로그인한 아이디와 해당 아이디가 같은지 확인하는 메서드
    public void checkIdIsEqual(String accountId) {
        String loginId = facade.getAuth().getName(); //현재 인증정보의 id name
        // 다르면 예외처리
        if (!accountId.equals(loginId)) {
            throw new ResponseStatusException(HttpStatus.FORBIDDEN);
        }
        // 같으면 계속 이어서 감
    }

    // 로그인한 아이디와 해당 아이디가 같은지 확인하는 메서드
    public void checkIdIsNotEqual(String accountId) {
        String loginId = facade.getAuth().getName(); //현재 인증정보의 id name
        // 다르면 예외처리
        if (accountId.equals(loginId)) {
            throw new ResponseStatusException(HttpStatus.FORBIDDEN);
        }
        // 같으면 계속 이어서 감
    }

    // 인증정보에서 UserEntity를 추출하는 메서드
    public UserEntity loadUserFromAuth() {
        //repo에서 userId로 찾아오기
        Optional<UserEntity> optionalUser = userRepository.findByAccountId(facade.getAuth().getName());
        if (optionalUser.isEmpty()) {
            log.info("loadUserFromAuth : not found");
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        }
        return optionalUser.get();
    }

    @Override
    public void updateUser(UserDetails user) {
        throw new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED);
    }

    @Override
    public void deleteUser(String username) {
        throw new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED);
    }

    @Override
    public void changePassword(String oldPassword, String newPassword) {
        throw new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED);
    }
}

 

6. CustomUserDetails

: UserDetailsManager와 마찬가지로  UserDetails도 인터페이스이므로 구현체를 만들어주어야 한다. 

이 때 이미 스프링에서 만들어둔 User라는 구현체가 있지만, 나는 확장성과 유연성을 위하여 CustomUserDetails를 만들어주었다. UserDetails의 모든 메서드들을 오버라이딩하였고, String형태로 Authority를 반환하는 메서드를 추가로 만들어주었다. 

 

 

 

 

전체 과정을 이렇게 정리해보았다.

JwtTokenFilter이후에 Authorization.class로 가서 그 사용자의 권한에 따라 접근 가능한지/불가능한지를 확인하면 된다. 

728x90

BELATED ARTICLES

more