Spring Security - JWT 방식

박영준·2023년 7월 8일
1

Spring

목록 보기
38/58

이노캠에서 주어진 과제가 요구하는 것은 JWT 토큰 방식을 사용하면서, Spring Security 로 이를 인증/인가 하는 것이었다. (참고: 서버 인증 - 토큰 기반 인증 방식 (JWT))

Spring Security - Session / Cookie 방식 에서 정리해둔 Spring Security 의 동작 과정과 흡사하다.

이에 대한 구현 과정을 정리해보고자 한다.

1. 프로젝트 세팅

build.gradle > dependencies

implementation 'org.springframework.boot:spring-boot-starter-security'
  • Spring Security 를 사용하기 위해서는 다음 코드를 추가해주어야 한다.

User 엔티티

@Entity
@Getter
@NoArgsConstructor
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    @Enumerated(value = EnumType.STRING)
    private UserRoleEnum role;

    public User(String username, String password, UserRoleEnum role) {
        this.username = username;
        this.password = password;
        this.role = role;
    }
}

JwtUtil 클래스

@Slf4j(topic = "JwtUtil")
@Component
public class JwtUtil {

    /* 1. JWT 데이터 준비하기 */
    public static final String AUTHORIZATION_HEADER = "Authorization";      // Header KEY 값
    public static final String AUTHORIZATION_KEY = "auth";      // 사용자 권한 값의 KEY
    public static final String BEARER_PREFIX = "Bearer ";       // Token 식별자
    private static final long TOKEN_TIME = 60 * 60 * 1000L;        // 토큰 만료시간 : 60분

    @Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey (application.properties 에 추가해둔 값)
    private String secretKey;       // 그 값을 가져와서 secretKey 변수에 넣는다
    private static Key key;        // Secret key 를 담을 변수
    private static final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;     // 사용할 알고리즘 선택

    @PostConstruct      // 한 번만 받으면 값을 사용할 때마다, 매번 요청을 새로 호출하는 것을 방지
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }

    /* 2. JWT 토큰 생성 */
        // 인증된 토큰을 기반으로 JWT 토큰을 발급
    public static String createToken(String username, UserRoleEnum role) {
        Date date = new Date();

        // 암호화
        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(username)               // 사용자 식별자값(ID). 여기에선 username 을 넣음
                        .claim(AUTHORIZATION_KEY, role)     // 사용자 권한 (key, value)
                        .setExpiration(new Date(date.getTime() + TOKEN_TIME))   // 만료 시간 : 현재시간 date.getTime() + 위에서 지정한 토큰 만료시간(60분)
                        .setIssuedAt(date)                  // 발급일
                        .signWith(key, signatureAlgorithm)  // 암호화 알고리즘 (Secret key, 사용할 알고리즘 종류)
                        .compact();
    }

    // 3. header 에서 JWT 가져오기
    public String getJwtFromHeader(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }

    /* 4. JWT 토큰 검증 */
        // 토큰의 만료, 위/변조 를 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
        } catch (ExpiredJwtException e) {
            log.error("Expired JWT token, 만료된 JWT token 입니다.");
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
        } catch (IllegalArgumentException e) {
            log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
        }
        return false;
    }

    /* 5. JWT 토큰에서 사용자 정보 가져오기 */
    public Claims getUserInfoFromToken(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
    }
}

2. 로그인

@Getter
@NoArgsConstructor
public class LoginRequestDto {
    private String username;
    private String password;
}

username 과 password 로 로그인 요청을 보낸다.

3. 인증 (Authentication)

로그인 요청은 가장 먼저 Application Filters 로 들어오고, 그 필터들 中 AuthenticationFilter 로 들어온다.
그리고 AuthenticationFilter 필터들 中 최종적으로 UsernamePasswordAuthenticationFilter 에 도착하게 된다.

이를 위해 AuthenticationFilter 가 UsernamePasswordAuthenticationFilter 를 상속받도록 했다.
(JWT 토큰을 이용하므로, 클래스 명을 JwtAuthenticationFilter 로 했다.)

JwtAuthenticationFilter 클래스

@Slf4j(topic = "로그인 및 JWT 생성")
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final JwtUtil jwtUtil;

    public JwtAuthenticationFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
        setFilterProcessesUrl("/api/user/login");       // 1. 
    }

    // 로그인 시, username 과 password 를 바탕으로 UsernamePasswordAuthenticationToken 을 발급
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        try {
            LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);     // 2.

            return getAuthenticationManager().authenticate(         // 3.
                    new UsernamePasswordAuthenticationToken(requestDto.getUsername(), requestDto.getPassword(),null)    // 4.
            );
        } catch (IOException e) {
            log.error(e.getMessage());
            throw new RuntimeException(e.getMessage());
        }
    }
    
    // 로그인 성공 시
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) {
        String username = ((UserDetailsImpl) authResult.getPrincipal()).getUsername();          	// username
        UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole();      // role

        String token = jwtUtil.createToken(username, role);     // 5.
        response.addHeader(JwtUtil.AUTHORIZATION_HEADER, token);        // 6.
    }

    // 로그인 실패 시
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
        response.setStatus(401);
    }
}    

1.

  • 로그인 요청 URI 를 정의

2.

  • JSON 형식의 데이터를 변환한다
  • (JSON 형식으로 넘어올 username 과 password, 변환할 타입)

3.

  • 생성된 UsernamePasswordAuthenticationToken(Authentication 객체) 을 가지고, AuthenticationManager (실질적으로는 구현체인 ProviderManager)에게 인증을 진행하도록 위임

4.

  • 로그인 요청으로 입력된 정보(username, password)를 바탕으로 UsernamePasswordAuthenticationToken 토큰을 생성

5.

  • 로그인 성공 시, username 과 role 을 담은 토큰을 생성

6.

  • 헤더에 해당 토큰을 추가

이를 통해, 로그인 시 해다 사용자에게 JWT 토큰이 생성된다.
즉, 인증 이 완료 된 것이다.

4. UserDetailsService

@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {		// 1.
    private final UserRepository userRepository;

    // DB 에 저장된 사용자 정보와 일치하는지 여부를 판단
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username).orElseThrow(
                () -> new UsernameNotFoundException("Not Found " + username));

        return new UserDetailsImpl(user)
    }
}

1.

  • UserDetailsService 를 상속받음으로써, 토큰에 저장된 유저 정보를 활용

2.

  • 해당 인터페이스는 UserDetails 객체를 반환해준다.
  • 조회된 회원 정보(user) 를 UserDetails 로 변환

5. UserDetails

@Getter
@RequiredArgsConstructor
public class UserDetailsImpl implements UserDetails {
    private final User user;        // 1. 

    public User getUser() {         // 2.
        return user;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    // 사용자의 권한을 GrantedAuthority 로 추상화 및 반환
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {		// 3.
        UserRoleEnum role = user.getRole();
        String authority = role.getAuthority();

        SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(simpleGrantedAuthority);

        return authorities;
    }

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

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

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

    @Override
    public boolean isEnabled() {
        return true;
    }
}
  • 인증된 회원의 상세정보를 담은 UserDetails 객체

  • UsernamePasswordAuthenticationToken (Authentication 객체를 구현한)을 생성하기 위해 사용된다

1.

  • 인증이 완료된 사용자 추가

2.

  • 인증완료된 User 를 가져오는 Getter

3.

  • authorities 를 통해, 간편히 권한 제어가 가능하다
    • GrantedAuthority : 현재 사용자가 가지고 있는 권한

6. 인가 (Authorization)

인증이 완료된 사용자(즉, 회원)은 게시글 또는 댓글을 작성/수정/삭제 하기 위해서는 '인가'가 떨어져야만 가능하다.

JwtAuthorizationFilter 클래스

@Slf4j(topic = "JWT 검증 및 인가")
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {
    private final JwtUtil jwtUtil;
    private final UserDetailsServiceImpl userDetailsService;

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

        String tokenValue = jwtUtil.getJwtFromHeader(req);       // header 에서 JWT 가져오기

        if (StringUtils.hasText(tokenValue)) {
            if (!jwtUtil.validateToken(tokenValue)) {           // JWT 토큰 검증
                log.error("Token Error");
                return;
            }

            Claims info = jwtUtil.getUserInfoFromToken(tokenValue);     // JWT 토큰에서 사용자 정보 가져오기

            try {
                setAuthentication(info.getSubject());       // 인증 처리
            } catch (Exception e) {
                log.error(e.getMessage());
                return;
            }
        }

        filterChain.doFilter(req, res);
    }

    // 인증 처리
    public void setAuthentication(String username) {
        SecurityContext context = SecurityContextHolder.createEmptyContext();       // 1.
        Authentication authentication = createAuthentication(username);     		// 2.
        context.setAuthentication(authentication);

        SecurityContextHolder.setContext(context);			// 4.
    }

    // 인증 객체 생성
    private Authentication createAuthentication(String username) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());        // 3.
    }
}

1.

  • SecurityContextHolder
    • authentication 을 담고 있는 Holder

2.

  • 인증 객체 생성

3.

  • 인증된 사용자의 정보와 권한을 담은 UsernamePasswordAuthenticationToken 을 생성

4.

  • SecurityContextHolder 에 인증된 객체를 저장하면, 해당 인증 객체를 전역적으로 사용할 수 있게 된다.

7. Spring Security 설정 및 인증/인가 필터 등록 클래스

WebSecurityConfig 클래스

@Configuration
@EnableWebSecurity          // Spring Security 지원을 가능하게 함
@RequiredArgsConstructor
public class WebSecurityConfig {
    private final JwtUtil jwtUtil;
    private final UserDetailsServiceImpl userDetailsService;
    private final AuthenticationConfiguration authenticationConfiguration;

    // 1.
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

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

    // 3.
    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
        JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil);
        filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
        return filter;
    }

    @Bean
    public JwtAuthorizationFilter jwtAuthorizationFilter() {
        return new JwtAuthorizationFilter(jwtUtil, userDetailsService);
    }

    // 4. SecurityFilterChain 빈
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // 5.
        http.csrf((csrf) -> csrf.disable());

        // 6.
        http.sessionManagement((sessionManagement) ->
                sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        );

        http.authorizeHttpRequests((authorizeHttpRequests) ->
                authorizeHttpRequests
                        .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()       // resources 접근 허용 설정
                        .requestMatchers("/api/auth/**").permitAll()        // '/api/auth/'로 시작하는 요청 모두 접근 허가
                        .anyRequest().authenticated()       // 그 외 모든 요청 인증처리
        );

        // 7.
        http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
        http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

1.

  • 비밀번호 암호화
    • Spring Security 가 제공하는 적응형 단방향 함수인 bCrypt 를 사용하여 비밀번호를 암호화(Encoder)
    • 적응형 단방향이 자동 적용

2.

  • AuthenticationManager
    • 인증 처리를 해준다
    • 실제론 AuthenticationManager 인터페이스가 ProviderManager 에게 인증을 위임하는 것

3.

  • UsernamePasswordAuthenticationToken 을 발급, 로그인 성공/실패 시

4.

  • 인증을 처리하는 여러 개의 Security Filter를 담는 Filter Chain 이다.
  • Filter Chain Froxy 를 통해, 서블릿 필터와 연결된다
  • 어떤 Security Filter를 통해 인증을 수행할지 결정하는 역할

5.

  • CSRF 설정 (CSRF 사용하지 않겠다)
    • CSRF (사이트 간 요청 위조, Cross-site request forgery)
      • 공격자가 인증된 브라우저에 저장된 쿠키의 세션 정보를 활용하여, 웹 서버에 사용자가 의도하지 않은 요청을 전달하는 것
      • CSRF 설정이 되어있는 경우, html 에서 CSRF 토큰 값을 넘겨주어야 요청을 수신 가능
      • 쿠키 기반의 취약점을 이용한 공격이기 때문에, REST 방식의 API 에서는 disable 가능
      • POST 요청마다 처리해 주는 대신, CSRF protection 을 disable 한다(= 사용 하지 않겠다는 의미)

6.

  • 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
    • Spring Security 는 기본적으로 세션/쿠키 방식으로 사용하기 때문

7.

  • 필터 관리

  • JWT 인증/인가를 사용하기 위한 설정

  • http.addFilterBefore( , )

    • 앞의 필터를 수행한 후, 뒤의 필터를 수행할 것이라는 의미

    • 순서

      1. 앞의 필터를 통해 인증 객체를 만듦
      2. context 에 해당 인증 객체를 추가
      3. 인증 완료
      4. 뒤의 필터를 수행
      5. 인증 됐으므로, 다음 Filter 로 이동
      6. Controller 까지도 이동

SecurityFilterChain


참고: [SpringBoot] SpringBoot로 SpringSecurity 기반의 JWT 토큰 구현하기

profile
개발자로 거듭나기!

1개의 댓글

comment-user-thumbnail
2024년 7월 4일

작동하나요? 매요청마다 컨텍스트 홀더를 어떻게 세팅하는거지..... sessioncontextpersistencefilter 은 빠져있어서요

답글 달기