REST API에서 Spring Securit, JWT 인증

나도잘몰라·2024년 5월 13일
0

spring

목록 보기
4/5

흐름

REST API에서 사용자는 로그인을 통해 Access Token을 발행받고 이후 해당 토큰을 서비스 요청할 때 같이 보냄으로 인증을 받아 서비스를 이용할 수 있다.

1. Login Flow

  • 사용자는 로그인 정보를 입력하여 로그인을 요청한다.
  • 서버는 해당 정보를 통해 인증 정보 조회 후 성공하면 토큰을 발급하고 토큰을 기억(저장)한다.

2. Service Request Flow

  • 사용자는 서비스 요청 시 해당 토큰을 같이 보낸다.
  • 서버는 유효한 토큰인지 확인하고 유효하다면 spring security 권한에 따라 요청을 처리한다.

3. Logout Flow

  • 사용자는 로그아웃을 요청하며 토큰을 같이 보낸다.
  • 서버는 유효한 토큰인지 확인하고 유효하다면 해당 토큰의 정보를 검색해 저장된 토큰을 삭제한다.

코드

리포 참고


의존성 추가

dependencies {
	...
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-data-redis'
	implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
	implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
	testImplementation 'org.springframework.security:spring-security-test'
}

1. 로그인 요청

Controller > Service

public String login(RequestUserLoginDto requestDto) {
        // 로그인 검사 : 유저 정보 존재 여부, 비밀번호 일치 여부
        User findUser = userRepository.findById(requestDto.getId()).orElseThrow(() -> new UserNotFoundException(""));
        if (!passwordEncoder.matches(requestDto.getPassword(), findUser.getPassword()))
            throw new PasswordMismatchException("");

        // 토큰 생성
        String token = jwtTokenProvider.createToken(findUser.getId());

        // 레디스에 토큰 저장
        redisTemplate.opsForValue().set("JWT_TOKEN:" + requestDto.getId(), token);

        return token;
    }

로그인 요청 시
1. 해당 정보의 유저가 존재하는지, 비밀번호가 일치하는지 확인한다.
2. 유효하다면 유저의 id 정보를 담은 토큰을 생성한다.
3. 로그인 처리된 유저의 토큰을 기억하기 위해 레디스에 저장한다.


JwtTokenProvider - createToken

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    @Value("${spring.jwt.key}")
    private String SECRET_KEY;

    private final long tokenValidTime = 30 * 60 * 1000L; // 토큰 유효시간 = 30분
    private final CustumUserDetailService custumUserDetailService;

    // 객체 초기화, SecretKey를 Base64로 인코딩
    @PostConstruct
    protected void init() {
        SECRET_KEY = Base64.getEncoder().encodeToString(SECRET_KEY.getBytes());
    }

    // 토큰 생성
    public String createToken(String userId) {
        return Jwts.builder()
                .setClaims(Jwts.claims().setSubject(userId)) // 정보 저장
                .setIssuedAt(new Date()) // 토큰 발행시간
                .setExpiration(new Date(new Date().getTime() + tokenValidTime)) // 토큰 유효시간
                .signWith(SignatureAlgorithm.HS512, SECRET_KEY) // 암호화 알고리즘, secret 값
                .compact();
    }
    
    ...
}

필요한 정보를 설정하여 토큰을 생성한다.

  • jwt key
    • Token은 Header와 Payload의 값을 각각 Base64로 인코딩한 후 인코딩 된 값을 Secret Key를 이용해 헤더에서 정의한 알고리즘으로 암호화하고 다시 Base64로 인코딩하여 생성
    • 무작위로 생성한 Key를 application.yml 파일에 넣고 @Value("${spring.jwt.key}")로 접근
    spring:
        jwt:
            key: 

2. 서비스 요청

헤더에 발급받은 토큰을 담아 요청합니다. 가장 먼저 Spring Filter를 거쳐 로그인 인증, 사용자 권한 인증 등의 과정을 수행합니다.

Spring Security

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

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

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable) // rest api : csrf, httpBasic, formLogin 미사용
                .httpBasic(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .sessionManagement((sessionManagement) ->
                        sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // jwt 인증 : session 미사용
                .authorizeHttpRequests((authz) -> authz // 권한 설정
                        .requestMatchers("/auth/**").permitAll() // 로그인, 로그아웃 관련
                        .requestMatchers("/register/**").permitAll() // 회원가입 관련
                        .requestMatchers("/swagger-ui/**", "/v3/**").permitAll() // api 명세 관련
                        .requestMatchers("/admin/**").hasRole("ADMIN") // admin 관련
                        .anyRequest().authenticated()
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
        return http.build();
    }

}
  • rest api에 필요없는 설정들 제거
  • 접근 가능한 패턴 설정
  • 인증 필터 설정


JwtAuthenticationFilter

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    @Autowired
    private final JwtTokenProvider jwtTokenProvider;
    private final RedisTemplate<String, String> redisTemplate;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request); // 헤더에서 토큰 받기

        if (token != null && jwtTokenProvider.validateToken(token)) { // 토큰이 유효하다면
            String key = "JWT_TOKEN:" + jwtTokenProvider.getUserId(token);
            if (redisTemplate.hasKey(key) && redisTemplate.opsForValue().get(key) != null) { // 로그인 여부 체크
                Authentication authentication = jwtTokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        chain.doFilter(request, response); // 다음 Filter 실행
    }
}

서비스 요청 시
1. 헤더에서 토큰을 추출한다.
2. 토큰이 유효한지 검사한다.
3. 토큰이 로그인 상태인지 검사한다.
4. 유효하고 로그인 상태라면 Spring Security 상에서 인증 처리한다.
인증 처리되면 인정된 유저의 권한에 따라 요청 처리 후 값을 반환합니다.


JwtTokenProvider

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    @Value("${spring.jwt.key}")
    private String SECRET_KEY;

    private final long tokenValidTime = 30 * 60 * 1000L; // 토큰 유효시간 = 30분
    private final CustumUserDetailService custumUserDetailService;

    // 객체 초기화, SecretKey를 Base64로 인코딩
    @PostConstruct
    protected void init() {
        SECRET_KEY = Base64.getEncoder().encodeToString(SECRET_KEY.getBytes());
    }

    // 토큰 생성
    public String createToken(String userId) {
        return Jwts.builder()
                .setClaims(Jwts.claims().setSubject(userId)) // 정보 저장
                .setIssuedAt(new Date()) // 토큰 발행시간
                .setExpiration(new Date(new Date().getTime() + tokenValidTime)) // 토큰 유효시간
                .signWith(SignatureAlgorithm.HS512, SECRET_KEY) // 암호화 알고리즘, secret 값
                .compact();
    }

    // 인증 정보 조회
    public Authentication getAuthentication(String token) {
        CustomUserDetails customUserDetails = (CustomUserDetails) custumUserDetailService.loadUserByUsername(getUserId(token));
        return new UsernamePasswordAuthenticationToken(customUserDetails, "", customUserDetails.getAuthorities());
    }

    // 토큰에서 User Id 추출
    public String getUserId(String token) {
        try {
            return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody().getSubject();
        } catch (ExpiredJwtException e) {
            return e.getClaims().getSubject();
        }
    }

    // 토큰 유효성, 만료일자 확인
    public boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
            return true;
        } catch (SecurityException e) {
            throw new JwtException("잘못된 JWT 시그니처입니다.");
        } catch (MalformedJwtException ex) {
            throw new JwtException("유효하지 않은 토큰입니다.");
        } catch (ExpiredJwtException ex) {
            throw new JwtException("만료된 토큰입니다.");
        } catch (UnsupportedJwtException ex) {
            throw new JwtException("지원되지 않는 토큰입니다.");
        } catch (IllegalArgumentException ex) {
            throw new JwtException("토큰에 저장된 정보가 없습니다.");
        }
    }

    // Request의 Header에서 token 값 가져오기
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("X-AUTH-TOKEN");
    }
}
  • 예외 처리는 마음대로 하시면 됩니다.
  • 저는 헤더에 "X-AUTH-TOKEN"로 담아 보냈기 때문에 resolveToken을 저렇게 설정했습니다.
  • Spring Security 상에서의 인증 처리를 위해 UserDetails, UserDetailsService의 구현체가 필요합니다. 이를 사용한 부분이 인증 정보 조회 getAuthentication입니다. token에 담긴 id와 일치하는 id가 있다면 UserDetails 구현체를 반환하고 이는 SecurityConfig에서 Spring Security 인증 정보 저장 처리됩니다.


CustomUserDetails

public class CustomUserDetails implements UserDetails {

    private User user;

    public CustomUserDetails(User user) {
        this.user = user;
    }

    @Override
    public List<GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority("ROLE_" + user.getUserRole().name()));
        return authorities;
    }

    // get Password 메서드
    @Override
    public String getPassword() {
        return user.getPassword();
    }

    // get Username 메서드 (생성한 User은 id 사용)
    @Override
    public String getUsername() {
        return user.getId();
    }

    // 계정이 만료 되었는지 (true: 만료X)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    // 계정이 잠겼는지 (true: 잠기지 않음)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    // 비밀번호가 만료되었는지 (true: 만료X)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    // 계정이 활성화(사용가능)인지 (true: 활성화)
    @Override
    public boolean isEnabled() {
        return true;
    }
}



CustomUserDetailsService

@Service
@RequiredArgsConstructor
public class CustumUserDetailService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException {
        User user = userRepository.findById(id).orElseThrow(() -> new UserNotFoundException(""));
        return new CustomUserDetails(user);
    }
}

3. 로그아웃

Controller > Service

public void logout() {
        // spring security 상 인증된 user detail의 username과 일치하는 jwt를 삭제
        CustomUserDetails customUserDetails = (CustomUserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        redisTemplate.delete("JWT_TOKEN:" + customUserDetails.getUsername());
    }

로그아웃 요청 시
1. 현재 Spring Security에서 인증된 유저 정보를 받는다.
2. 레디스에서 유저 정보와 일치하는 JWT를 찾아 삭제한다.

0개의 댓글