본문 바로가기
spring & java

[Spring] Spring Security + JWT

by do5do 2023. 2. 6.

Spring Security 기반에 JWT 인증 방식을 적용해보았다. 

 

인증 방식은 이렇다. 

1. 클라이언트가 로그인을 요청한다.

2. ID/PW 검증 후 access token, rerfresh token을 발급한다.

3. refresh token은 DB에 저장하고, access token은 클라이언트에게 응답한다.

4. 클라이언트가 api 요청 시 access token이 만료되었으면 해당 유저의 refresh token으로 검증 후 access token을 재발급해준다.

 

위 방식을 하나씩 구현해 보자.

먼저 Spring Security와 JWT를 사용할 수 있도록 의존성을 추가해준다.

 

의존성 추가

build.gradle

// security
implementation 'org.springframework.boot:spring-boot-starter-security'

// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2', 'io.jsonwebtoken:jjwt-jackson:0.11.2'

 

AuthController

@Slf4j
@RequestMapping("/auth")
@RequiredArgsConstructor
@RestController
public class AuthController {
    private final UserService userService;
    
    @PostMapping("/signin")
    public ResponseEntity<LoginResponseDto> signin(@RequestBody LoginRequestDto loginRequestDto) {
        return ResponseEntity.ok(userService.signin(loginRequestDto));
    }
    
    ...
}

LoginRequestDto에는 유저의 email과 password가 담겨있다. 상세 로직은 서비스 계층에서 처리할 예정이다.

 

UserService

...
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class UserService {
    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final JwtProvider jwtProvider;
    private final RefreshTokenService refreshTokenService;

    @Transactional
    public LoginResponseDto signin(LoginRequestDto loginRequestDto) {
        // user 검증
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginRequestDto.getEmail(), loginRequestDto.getPassword());
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // token 생성
        String accessToken = jwtProvider.generateAccessToken(authentication);
        String refreshToken = jwtProvider.generateRefreshToken(authentication);
        User user = (User) authentication.getPrincipal(); // user 정보

        // refresh token 저장
        refreshTokenService.saveOrUpdate(user, refreshToken);
        
        return LoginResponseDto.builder()
                .accessToken(accessToken)
                .tokenType("Bearer ")
                .userId(user.getId())
                .build();
    }
    
    ...
}

서비스 로직을 하나씩 뜯어보자.

 

1) 인증 객체 생성

먼저 AuthenticationManagerBuilder를 생성자에 주입해주어 Athentication(인증 객체)을 생성할 수 있도록 준비한다. UsernamePasswordAuthenticationToken에는 인증을 원하는 주체(Principal)의 신원을 확인할 수 있는 정보(email, password)를 넣어준다.

이렇게 생성한 authenticationToken으로 authentication을 만들고(UsernamePasswordAuthenticationToken은 Authentication 인터페이스의 구현체), 이 인증 정보를 전역적으로 사용하기 위해 SecurityContext에 저장해준다. (ThreadLocal에 저장하기 때문에 thread 별로 다른 인증 객체를 가질 수 있다.)

 

여기서 로그인 요청한 유저가 이전에 회원가입한 유저가 맞는지 어떻게 검증하는지 의문이 들 수 있다.

바로 AuthenticationMamagerBuilder가 authenticate() 메서드를 실행할 때 미리 정의해 둔 CustomUserDetailsService의 loadUserByUsername() 메서드를 통해 유저에 대한 검증을 하고 인증이 완료되면 Authentication객체를 리턴하는 방식으로 동작한다.

 

이렇게 UserDetailsService를 미리 정의해 두고 사용하려면 Security 설정에서 custom하게 정의한 객체를 검증에 사용할 수 있도록 등록해둬야 한다. (config 등록은 아래에서 보겠다.)

 

CustomUserDetailsService

...
import lombok.RequiredArgsConstructor;
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.Component;

@RequiredArgsConstructor
@Component
public class CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException(username + " 사용자를 찾을 수 없습니다."));
    }
}

여기서 또 의문은 loadUserByUsername에서는 유저의 아이디만 검증하는 것으로 보인다.

비밀번호는 인증을 안하는건가? 라고 생각할 수 있다.

 

AuthenticationManager의 authenticate() 메서드 내부 구현을 따라가보면, 미리 빈으로 등록해 둔 PasswordEncoder로 UserDetails의 password와 matching 작업이 구현되어 있어서 올바르게 비밀번호 검증이 된다.

 

찾은 User 엔티티를 바로 반환할 수 있는 이유는 Spring Security에서 제공하는 UserDetails를 User Entity에 구현해두었기 때문이다.

 

User Entity

...
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import net.minidev.json.annotate.JsonIgnore;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import javax.persistence.*;
import java.util.Collection;
import java.util.Collections;

@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Table(name = "users")
@Entity
public class User extends BaseTimeEntity implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_seq_gen")
    @SequenceGenerator(name = "user_seq_gen", sequenceName = "users_sequence", initialValue = 1, allocationSize = 1)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String email;

    @Column(nullable = false)
    private String password;

    @Enumerated(EnumType.STRING) // enum type을 string으로 설정
    @Column(nullable = false)
    private Role role;

    @JsonIgnore
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.singletonList(new SimpleGrantedAuthority(role.getKey()));
    }

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

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

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

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

인증에 대한 설명이 조금 길었다. 이제 두번째 로직인 토큰 생성 부분을 살펴보자.

 

2) 토큰 발급

인증을 완료 했으면 해당 유저에 대한 token을 발급한다.

token 발급은 JwtProvider가 담당한다.

 

JwtProvider

...
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
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.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.PostConstruct;
import java.security.Key;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@Component
public class JwtProvider {
    @Value("${jwt.secret}") // application.properties에 있는 secret key
    private String secretKey;
    private Key key;
    private final String AUTHORITIES_KEY = "auth";
    private final long accessTokenValidTime = (60 * 1000) * 30; // 30분
    private final long refreshTokenValidTime = (60 * 1000) * 60 * 24 * 7; // 7일
    private final RefreshTokenRepository refreshTokenRepository;

    @PostConstruct
    protected void init() {
        // key를 base64로 인코딩
        String encodedKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
        key = Keys.hmacShaKeyFor(encodedKey.getBytes());
    }

    public String generateToken(Authentication authentication, Long accessTokenValidTime) {
        // 인증된 사용자의 권한 목록 조회
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        Date now = new Date();
        Date expiration = new Date(now.getTime() + accessTokenValidTime); // 만료 시간

        return Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .setIssuedAt(now) // 발행 시간
                .setExpiration(expiration)
                .signWith(key, SignatureAlgorithm.HS512) // (비밀키, 해싱 알고리즘)
                .compact();
    }

    public String generateAccessToken(Authentication authentication) {
        return generateToken(authentication, accessTokenValidTime);
    }

    public String generateRefreshToken(Authentication authentication) {
        return generateToken(authentication, refreshTokenValidTime);
    }

    public Authentication getAuthentication(String token) {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();

        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        User principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }

    public JwtCode validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return JwtCode.ACCESS;
        } catch (ExpiredJwtException e) { // 기한 만료
            return JwtCode.EXPIRED;
        } catch (Exception e) {
            return JwtCode.DENIED;
        }
    }

    @Transactional(readOnly = true)
    public String getRefreshToken(Long userId) {
        RefreshToken refreshToken = refreshTokenRepository.findByUserId(userId)
                .orElseThrow(() -> new IllegalArgumentException("refresh token이 존재하지 않습니다."));
        return refreshToken.getRefreshToken();
    }
}

generateToken() 메소드부터 살펴 보자.

먼저 인증 객체(authentication)에서 권한 목록을 가져온다. 권한 목록이란 ROLE_USER, ROLE_ADMINE과 같은 사용자 권한을 의미한다.

토큰 생성 시 subject로는 unique한 값을 사용하는데 보통 사용자의 email을 넣는다. (getAthentication()에서 principal에 토큰의 subject인 email을 넣었기 때문에 getName()을 하면 email이 리턴된다.)

 

3) 응답

refresh token은 DB(트래픽이 많을 땐 속도 측면에서 redis를 쓴다.)에 저장하고, access token과 userId를 클라이언트에게 전달한다.

(userId를 함께 전달하는 이유는 마지막에 얘기하겠음..)

 

로그인 요청에 대한 것은 끝났다. 이제 클라이언트가 발급받은 access token을 가지고 api를 요청했을 때 검증하는 단계가 남았다.

모든 요청마다 토큰 검증을 먼저 할 수 있도록 security 설정을 해주어야 한다.

 

SecurityConfig

...
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final CustomUserDetailsService customUserDetailsService;
    private final JwtProvider jwtProvider;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(customUserDetailsService); // customUserDetailsService 등록
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable() // 서버에 인증정보를 저장하지 않기 때문에(stateless, rest api) csrf를 추가할 필요가 없다.
                .and()
                .httpBasic().disable() // 기본 인증 로그인 사용하지 않음. (rest api)
                .cors()
                
                // session 설정 -> stateless(사용하지 않음)
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                // request permission
                .and()
                .authorizeRequests()
                .antMatchers("/", "/favicon.ico", "/auth/**").permitAll()
                .antMatchers("/admin/**").hasRole(Role.ADMIN.name())
                .anyRequest().authenticated()

                // exception handling
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(new CustomAuthenticationEntryPoint())
                .accessDeniedHandler(new CustomAccessDeniedHandler())

                // jwt filter -> 인증 정보 필터링 전에(filterBefore) 필터
                .and()
                .addFilterBefore(new JwtFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

기본적인 rest api에 대한 설정과 api 접근 권한 설정 아래 부터는 jwt 관련 설정이다.

 

UsernamePasswordAuthenticationFilter에서 아이디, 패스워드를 이용하여 유저 인증을 검증하는데, 인증 정보 검증 전에 토큰을 가지고 있는지, 해당 서버에서 발급한 토큰이 맞는지를 확인하는 JwtFilter를 거치게 된다.

 

JwtFilter

...
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
    private final JwtProvider jwtProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String accessToken = resolveToken(request, "Authorization");
		
        // access token 검증
        if (StringUtils.hasText(accessToken) && jwtProvider.validateToken(accessToken) == JwtCode.ACCESS) {
            Authentication authentication = jwtProvider.getAuthentication(accessToken);
            SecurityContextHolder.getContext().setAuthentication(authentication); // security context에 인증 정보 저장
        } else if (StringUtils.hasText(accessToken) && jwtProvider.validateToken(accessToken) == JwtCode.EXPIRED) {
            log.info("Access token expired");
            
            String refreshToken = null;
            if (StringUtils.hasText(request.getHeader("Auth"))) { // Auth에는 userId가 담겨 있음
                Long userId = Long.parseLong(request.getHeader("Auth"));
                refreshToken = jwtProvider.getRefreshToken(userId); // userId로 refreshToken 조회 
            }
			
            // refresh token 검증
            if (StringUtils.hasText(refreshToken) && jwtProvider.validateToken(refreshToken) == JwtCode.ACCESS) {
                // access token 재발급
                Authentication authentication = jwtProvider.getAuthentication(refreshToken);
                String newAccessToken = jwtProvider.generateAccessToken(authentication);
                SecurityContextHolder.getContext().setAuthentication(authentication);

                response.setHeader(HttpHeaders.AUTHORIZATION, newAccessToken);
                log.info("Reissue access token");
            }
        }
        filterChain.doFilter(request, response);
    }

    public String resolveToken(HttpServletRequest request, String header) {
        String bearerToken = request.getHeader(header);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

jwt 필터의 작업은 이러하다.

1. request의 헤더를 파싱하여 토큰을 가져오고, 해당 토큰에 대한 유효성 검증을 한다.

2. 정상적인 토큰이고 유효하다면 해당 토큰에서 인증 정보를 파싱하여 그 인증 정보를 security context에 저장한다.

3. 토큰이 정상적이고 기한만 만료된 것이라면 클라이언트에게 토큰을 전달할 때 같이 보냈던 user의 식별자, userId를 request 헤더에서 가져온다.

4. 파싱한 userId로 refresh token을 조회하여 토큰에 대한 유효성 검증을 하고, 정상적인 토큰이고 유효기간 만료 전 이라면 access token을 새로 발급해준다.

 

문제점 및 개선 방안

JwtFilter를 위와 같은 방법으로 구현한 이유는 access token은 클라이언트에게 전달하는 것이기 때문에 보안을 생각하여(탈취 가능성) 기간을 30분으로 짧게 잡았다. access token 만료 시 마다 요청을 돌려보내면 유저는 30분마다 로그인을 다시해야 하는 상황이 된다.

 

그래서 처음 로그인 시 기간이 긴 refresh token을 함께 생성하지만 유저에게 보내지 않고(기간이 긴 refresh token을 탈취 당하면 더 위험하다고 생각했다.) DB에 저장하여 보관한다. 대신에 해당 유저에 대한 refresh token을 조회하기 위해 userId를 함께 넘기는 방식을 선택했다. 결과적으로 유저의 로그인 유지 기간은 refresh token의 기간 만큼 길어지게 됐지만 userId를 노출 시키는 것에 대해서는 좋지 않은 방법이라고 생각한다.

 

+

나중에 알게된 개선 방안인데, access token에 넣는 유저에 대한 정보와 refresh token에 넣는 유저에 대한 정보를 달리하여 생성하면 된다. 즉, refresh token에는 유저에 대한 정보 대신 UUID를 생성하여 담는다. 그러면 클라이언트에게 access, refresh token을 같이 전달해도 기간이 긴 refresh token을 탈취 당했을 시에 대한 걱정이 적어진다. 번거롭게 userId를 넘길 필요도 없고 refresh token을 굳이 db에 저장하지 않아도 된다. 그럼 영속성 컨텍스트 범위가 긴 것에 대한 문제도 사라진다. (위에서는 refresh token을 조회하는 트랜잭션이 필터단에서 일어남)

마음에 걸리던 문제가 한번에 해결되네..ㅎㅎ