SpringBoot

서비스의 인증과 권한 부여

똑똑한망치 2024. 3. 17. 17:47
728x90
반응형

※ 참고 자료

https://smarthammer.tistory.com/109

 

[Java Spring] 스프링부트 3.X 스프링 시큐리티 (Spring security)

1. 스프링 시큐리티 (Spring security) (1) 인증 / 인가 차이 인증(Authentication) 은 사용자가 본인의 신원을 입증하는 과정이다. 예를 들면 어떤 사이트에 아이디와 비밀번호를 입력하고 로그인 하는 과

smarthammer.tistory.com

 

 

1. 보안 용어 이해

(1) 인증 (authentication)

사용자가 누구인지 확인하는 단계를 의미한다. 대표적인 예로 로그인이 있다. 로그인은 데이터베이스에 등록된 아이디와 패스워드를 사용자가 입력한 아이디와 패스워드와 비교하여 일치 여부를 확인하는 과정이다. 로그인에 성공하면 애플리케이션 서버는 응답으로 사용자에게 토큰(Token)을 전달한다. 로그인에 실패한 사용자는 토큰을 전달받지 못해 원하는 리소스에 접근할 수 없다.

 

(2) 인가 (authorization)

인증을 통해 검증된 사용자가 애플리케이션 내부의 리소스에 접근할 때 사용자가 해당 리소스에 접근할 권리가 있는지를 확인하는 과정이다. 

일반적으로 사용자가 인증 단계에서 발급받은 토큰은 인가 내용을 포함하고 있으며, 사용자가 리소스에 접근하면서 토큰을 함께 전달하면 애플리케이션 서버는 토큰을 통해 권한 유무를 확인해 인가를 수행한다.

 

(3) 접근 주체

접근 주체(principal)는 애플리케이션의 기능을 사용하는 주체를 의미한다. 접근 주체는 사용자가 될 수도 있고, 디바이스, 시스템 등이 될 수 있다. 애플리케이션은 인증 과정을 통해 접근 주체가 신뢰할 수 있는지 확인하고, 인가 과정을 통해 접근 주체에게 부여된 권한을 확인하는 과정을 거친다.

 

 

 

 

2. 스프링 시큐리티


 

(1) 스프링 시큐리티 동작 구조

스프링 시큐리티는 서블릿 필터(Servlet Filter) 를 기반으로 동작하며, DispatcherServlet 앞에 필터가 배치되어 있다.

 

서블릿 필터의 배치

 

위 그림의 필터체인(FilterChain)은 서블릿 컨테이너에서 관리하는 ApplicationFilterChain을 의미한다.

클라이언트에서 애플리케이션으로 요청을 보내면 서블릿 컨테이너는 URI를 확인해서 필터와 서블릿을 매핑한다.

스프링 시큐리티는 사용하고자 하는 필터체인을 서블릿 컨테이너의 필터 사이에서 동작시키기 위해 아래 그림과 같이 DelegatingFilterProxy를 사용한다.

 

DelegatingFilterProxy 내에 FilterChainProxy 구조

 

DelegatingFilterProxy는 서블릿 컨테이너의 생명주기와 스프링 애플리케이션 컨텍스트 (Application Context) 사이에서 다리 역할을 수행하는 필터 구현체다. 표준 서블릿 필터를 구현하고 있으며, 역할을 위힘할 필터체인 프록시 (FilterChain Proxy)를 내부에 가지고 있다. 필터체인 프록시는 스프링 부트의 자동 설정에 의해 자동 생성된다.

 

필터체인 프록시는 스프링 시큐리티에서 제공하는 필터로서 보안 필터체인(SecurityFilterChain)을 통해 많은 보안 필터(Security Filter)를 사용할 수 있다. 필터체인 프록시에서 사용할 수 있는 보안 필터 체인은 List 형식으로 담을 수 있게 설정되어 있어 URI 패턴에 따라 특정 보안필터 체인을 선택해서 사용하게 된다.

 

보안 필터체인은 WebSecurityConfigurerAdapter 클래스를 상속받아 설정할 수 있다. 여러 보안 필터체인을 만들기 위해서는 WebSecurityConfigurerAdapter 클래스를 상속받는 클래스를 여러 개 생성하면 된다. 이때 WebSecurityConfigurerAdapter 클래스는 @Order 어노테이션을 통해 우선순위가 지정되어 있는데, 2개 이상의 클래스를 생성했을 때 우선순위가 동일하면 예외가 발생하기 때문에 @Order 어노테이션을 지정해 순서를 정의하는 것이 중요하다.

 

별도의 설정이 없다면 스프링 시큐리티에서는 SecurityFilterChain에서 사용하는 필터 중 UsernamePasswordAuthenticationFilter를 통해 인증을 처리한다.

 

UsernamePasswordAuthenticationFilter를 통한 인증 과정

 

 

UsernamePasswordAuthenticationFilter을 통한 인증 과정

  • 클라이언트로부터 요청을 받으면 서블릿 필터에서 SecurityFilterChain으로 작업이 위임되고 그 중 UsernamePasswordAuthenticationFilter 에서 인증을 처리한다.
  • AuthenticationFilter는 요청 객체(HttpServletRequest)에서 username 과 password를 추출하여 토큰을 생성한다.
  • 그러고 나서 AuthenticationManager에게 토큰을 전달한다. AuthenticationManager은 인터페이스이며, 일반적으로 사용되는 구현체는 ProviderManager 이다.
  • ProviderManager 는 인증을 위해 AuthenticationProvider로 토큰을 전달한다.
  • UserDetailSerivce는 전달받은 정보를 통해 데이터베이스에서 일치하는 사용자를 찾아 UserDetails 객체를 생성한다.
  • 생성된 UserDetails 객체는 AuthenticationProvider로 전달되며, 해당 Provider 에서 인증을 수행하고 성공하게 되면 ProviderManager 로 권한을 담은 토큰을 전달한다.
  • ProviderManager는 검증된 토큰을 AuthenticationFilter로 전달한다.
  • AuthenticationFilter는 검증된 토큰을 SecurityContextHolder에 있는 SecurityContext에 저장한다.

UsernamePasswordAuthenticationFilter는 접근 권한을 확인하고 인증이 실패할 경우 로그인 폼이라는 화면을 보내는 역할을 수행한다.

 

 


 

 

3. JWT


JWT(JSON Web Token)는 당사자 간에 정보를 JSON 형태로 안전하게 전송하기 위한 토큰이다.

JWT는 URL로 이용할 수 있는 문자열로만 구성되어 있으며, 디지털 서명이 적용되어 있어 신뢰할 수 있다.

JWT는 주로 서버와의 통신에서 권한 인가를 위해 사용된다. 

URL에서 사용할 수 있는 문자열로만 구성되어 있기 때문에 HTTP 구성요소 어디든 위치할 수 있다.

 

 

(1) JWT의 구조

JWT는 점('.') 으로 구분되어 헤더(Header), 내용(Payload), 서명(Signature)  3부분으로 구성된다.

 

JWT 구조

 

헤더

JWT의 헤더는 검증과 관련된 내용을 담고 있다. 2가지 정보를 포함하고 있는데 , alg와 typ 속성이다.

// 헤더 예제
{
    "alg" : "HS256",
    "typ" : "JWT"
}

 

alg 속성에서는 해싱 알고리즘을 지정한다. 보통 SHA256 또는 RSA를 사용하며, 토큰을 검증할 때 사용되는 서명부분에서 사용된다.

typ 속성에서는 토큰의 타입을 지정한다.

완성된 헤더는 Base64Url 형식으로 인코딩되어 사용된다.

 

 

내용

JWT 내용에서는 토큰에 담는 정보를 포함한다. 이곳에 포함된 속성들은 클레임(Claim)이라 하며, 3가지로 분류된다.

  • 등록된 클레임(Registered Claims) : 필수 X , 토큰에 대한 정보를 담기 위해 이미 이름이 정해져 있는 클레임
    • iss : JWT의 발급자(Issuer) 주체, iss의 값은 문자열이나 URI를 포함하는 대소문자를 구분하는 문자열
    • sub : JWT의 제목(Subject)
    • aud : JWT의 수신인(Audience), 요청을 처리하는 주체가 'aud'값으로 자신을 식별하지 않으면 JWT는 거부된다.
    • exp : JWT의 만료시간(EXpiration), NumericDate 형식으로 지정
    • nbf : 'Not Before' 의미
    • iat : JWT 발급된 시간(Issued at)
    • jti : JWT의 식별자 (JWT ID), 주로 중복 처리 방지위해 사용
  • 공개 클레임 : 키 값 마음대로 정의 가능, 하지만 충돌 발생하지 않도록 설정
  • 비공개 클레임 : 통신 간에 상호 합의되고 등록된 클레임과 공개된 클레임이 아닌 클레임 의미
// JWT 내용 예시
{
    "sub" : "wikibooks payload",
    "exp" : "1602076408",
    "userId" : "wifibooks",
    "username" : "flature"
}

 

이렇게 완성된 내용은 Base64Url 형식으로 인코딩되어 사용한다.

 

 

 

서명

JWT 서명 부분은 인코딩된 헤더, 인코딩된 내용, 비밀키, 헤더의 알고리즘 속성값을 가져와 생성된다.

 

 


 

 

 

4. 스프링 시큐리티와 JWT 사용


(1) UserDetails 와 UserDetailsService 구현

// User Entity 생성

@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;
    
    @Column(nullable = false, unique = true)
    private String uid;
    
    @JsonProperty(access = Access.WRITE_ONLY)
    @Column(nullable = false)
    private String password;
    
    @ElementCollection(fetch = FetchType.EAGER)
    @Builder.Default
    private List<String> roles = new ArrayList<>();
    
    @Override
    public Collections<? extends GrantedAuthority> getAuthorities() {
        return this.roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
    }
    
    @JsonProperty(access = Access.WRITE_ONLY)
    @Override
    public String getUsername() {
        return this.uid;
    }
    
    @JsonProperty(access = Access.WRITE_ONLY)
    @Override
    public boolean isAccoutNonExpired() {
        return true;
    }
    
    @JsonProperty(access = Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
    
    @JsonProperty(access = Access.WRITE_ONLY)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    
    @JsonProperty(access = Access.WRITE_ONLY)
    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

User 엔티티는 UserDetails 인터페이스를 구현하고 있다. UserDetails는 UserDetailsService를 통해 입력된 로그인 정보를 가지고 데이터베이스에서 사용자 정보를 가져오는 역할을 수행한다. 

각 메서드 용도를 정리하자면,

  • getAuthorities() : 계정이 가지고 있는 권한 목록 리턴
  • getPassword() : 계정의 비밀번호 리턴
  • getUsername() : 계정의 이름 리턴, 일반적으로 아이디를 리턴
  • isAccountNonExpired() : 계정이 만료되었는지 리턴
  • isAccountNonLocked() : 계정이 잠겨있는지 리턴
  • isCredentialNonExpired() : 비밀번호가 만료됐는지 리턴
  • isEnabled() : 계정이 활성화 되어있는지 리턴

 

// UserRepository 구현

public interface UserRepository extends JpaRepository<User, Long> {
    User getByUid(String uid);
}
// UserDetailsSerivce 인터페이스

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
// UserDetailsServiceImpl 구현

@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    
    private final Logger LOGGER = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
    
    private final UserRepository userRepository;
    
    @Override
    public UserDetails loadUserByUsername(String username) {
        LOGGER.info("[loadUserByUsername] loadUserByUsername 수행. username : {} ", username);
        return userRepository.getByUid(username);
    }
}

 

 

 

(2) JwtTokenProvider 구현

JWT 토큰을 생성하는 데 필요한 정보는 UserDetails에서 가져올 수 있기 때문에 JWT 토큰을 생성하는 JwtTokenProvider를 생성한다.

 

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
    private final Logger LOGGER = LoggerFactory.getLogger(JwtTokenProvider.class);
    private final UserDetailsService userDetailsService;
    
    @Value("${springboot.jwt.secret}")
    private String secretKey = "secretKey";
    private final long tokenValidMillisecond = 1000L * 60 * 60;
    
    @PostConstruct
    protected void init() {
        LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandaredCharsets.UTF_8));
        
        LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
        
    }
    
    public String createToken(String userUid, List<String> roles) {
        LOGGER.info("[createToken] 토큰 생성 시작");
        Claims claims = Jwts.claims().setSubject(userUid);
        claims.put("roles", roles);
        Date now = new Date();
        
        String token = Jwts.builder()
                    .setClaims(claims)
                    .setIssuedAt(now)
                    .setExpiration(new Date(now.getTime() + tokenValidMillisecond))
                    .signWith(SignatureAlgorithm.HS256, secretKey)
                    .compact();
                    
        LOGGER.info("[createToken] 토큰 생성 완료");
        return token;
    }
    
    public Authentication getAuthentication(String token) {
        LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 시작");
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUsername(token));
        LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 완료, UserDetails Username : {} ", userDetails.getUsername());
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }
    
    
    public String getUsername(String token) {
        LOGGER.info("[getUsername] : 토큰 기반 회원 구별 정보 추출");
        String info = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
        LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출 완료, info : {}", info);
        return info;
    }
    
    public String resolveToken(HttpServletRequest request) {
        LOGGER.info("[resolveToken] HTTP 헤더에서 Token 값 추출");
        return request.getHeader("X-AUTH-TOKEN");
    }
    
    public boolean validateToken(String token) {
        LOGGER.info("[validateToken] 토큰 유효 체크 시작");
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            
            return !claims.getBody().getExpiration.before(new Date());
        } catch (Exception e) {
            LOGGER.info("[validateToken] 토큰 유효 체크 예외 발생");
            return false;
        }
    }
}

 

 

UsernamePasswordAuthenticationToken 의 상속구조

 

 

 

(3) JwtAuthenticationFilter 구현

JwtAuthenticationFilter는 JWT 토큰으로 인증하고 SecurityContextHolder에 추가하는 필터를 설정하는 클래스이다.

 

// JwtAuthenticationFilter 클래스

public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
    private final JwtTokenProvider jwtTokenProvider;
    
    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }
    
    @Override
    protected void doFilterInternal(HttpServletRequest servletRequest,
        HttpServletResponse servletResponse,
        FilterChain filterChain
    ) throws ServletException, IOException {
        String token = jwtTokenProvider.resolveToken(servletRequest)
        LOGGER.info("[doFilterInternal] token 값 추출 완료. token : {}", token);
        
        LOGGER.info("[doFilterInternal] token 값 유효성 체크 시작");
        if (token != null && jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            LOGGER.info("[doFilterInternal] token 값 유효성 체크 완료");
        }
        
        filterChain.doFilter(servletRequest, servletResponse);
    }
}

 

 

 

 

(4) SecurityConfiguration 구현

스프링 시큐리티를 설정하는 대표적인 방법은 WebSecurityConfigureAdapter를 상속받는 Configuration 클래스를 구현하는 것이다.

 

// SecurityConfiguration 클래스

@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    
    private final JwtTokenProvider jwtTokenProvider;
    
    @Autowired
    public SecurityConfiguration(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }
    
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.httpBasic().disable()
            .csrf().disable()
            
            .sessionManagement()
            .sessionCreationPolicy(
                SessionCreationPolicy.STATELESS)
                
            .and()
            
            .authorizeRequest()
            .antMatchers("/sign-api/sign-in", "/sign-api/sign-up",
                "/sign-api/exception").permitAll()
            .antMatchers(HttpMethod.GET, "/product/**").permitAll()
            
            .antMatchers("**exception**").permitAll()
            
            .anyRequest().hasRole("ADMIN")
            
            .and()
            .exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
            .and()
            .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
            
            .and()
            .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                UsernamePasswordAuthenticationFilter.class);
    }
    
    @Override
    public void configure(WebSecurity webSecurity) {
        webSecurity.ignoring().antMatchers("/v2/api-docs", "/swagger-resources/**",
            "/swagger-ui.html", "/webjars/**", "/swagger/**", "/sign-api/exception");
    }
}

 

SecurityConfiguration 클래스의 주요 메서드는 2가지로, WebSecurity 파라미터를 받은 configure() 메서드와 HttpSecurity 파라미터를 받은 configure() 메서드이다.

 

먼저 HttpSecurity를 설정하는 configure() 메서드를 살펴보자.

스프링 시큐리티의 설정은 대부분 HttpSecurity를 통해 진행한다. 대표적인 기능으로는

  • 리소스 접근 권한 설정
  • 인증 실패 시 발생하는 예외 처리
  • 인증 로직 커스터마이징
  • csrf, cors 등의 스프링 시큐리티 설정

등이 있다.


설정의 의미

  • httpBasic().disable() : UI를 사용하는 것을 기본값으로 가진 시큐리티 설정을 비활성화한다.
  • csrt().disable() : REST API에서는 CSRF 보안이 필요 없기 때문에 비활성화 하는 로직
    • CSRF는 Cross-Site Request Forgery의 줄임말로 '사이트 간 요청 위조'를 의미한다. CSRF란 웹 애플리케이션의 취약점 중 하나로서 사용자가 자신의 의지와 무관하게 웹 애플리케이션을 대상으로 공격자가 의도한 행동을 함으로써 특정 페이지의 보안을 취약하게 한다거나 수정, 삭제 등의 작업을 하는 공격 방법이다.
  • sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) : REST API 기반 애플리케이션의 동작 방식을 설정한다. 세션을 사용하지 않기 때문에 STATELESS로 설정한다.
  • authorizeRequest() : 애플리케이션에 들어오는 요청에 대한 사용 권한을 체크한다.
  • exceptionHandling().accessDeniedHandler() : 권한을 확인하는 과정에서 통과하지 못하는 예외가 발생할 경우 예외를 전달한다.
  • exceptionHandling().authenticationEntryPoint() : 인증 과정에서 예외가 발생할 경우 예외를 전달

 

두번째로, WebSecurity를 사용하는 configure() 메서드이다. 

WebSecurity는 HttpSecurity 앞단에 적용되며, 전체적으로 스프링 시큐리티의 영향권 밖에 있다. 즉, 인증과 인가가 모두 적용되기 전에 동작하는 설정이다. 정확하게는 인증, 인가를 무시하는 경로를 설정한 것이다.

 

 

 

 

(5) 커스텀 AccessDeniedHandler, AuthenticationEntryPoint 구현

위의 코드에서는 인증과 인가 과정의 예외 상황에서 CustomAccessDeniedHandler와 CustomAuthenticationEntryPoint로 예외를 전달하고 있다.

 

먼저 AccessDeniedHandler 인터페이스의 구현체 클래스를 생성해보자.

// CustomAccessDeniedHandler 클래스

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    
    private final Logger LOGGER = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);
    
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
        AccessDeniedException exception) throws IOException {
    
        LOGGER.info("[handle] 접근이 막혔을 경우 경로 리다이렉트");
        response.sendRedirect("/sign-api/exception");
    }
}

 

AccessDeniedException은 액세스 권한이 없는 리소스에 접근할 경우 발생하는 예외이다. 이 예외를 처리하기 위해 AccessDeniedHandler 인터페이스가 사용되며, SecurityConfiguration 에도 exceptionHandling() 메서드를 통해 추가했다.

 

 

두번째로 인증이 실패한 상황을 처리하는 AuthenticationEntryPoint 인터페이스를 구현한 CustomAuthenticationEntryPoint 클래스이다.

 

// CustomAuthenticationEntryPoint 클래스

@Component
public class CustomAuthenticaitonEntryPoint implements AuthenticationEntryPoint {

    private final Logger LOGGER = LoggerFactory.getLogger(CustomAuthenticationEntryPoint.class);
    
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
        AuthenticationException exception) throws IOException {
        
        ObjectMapper objectMapper = new ObjectMapper();
        LOGGER.info("[commence] 인증 실패로 response.sendError 발생");
        
        EntryPointErrorResponse entryPointErrorResponse = new EntryPointErrorResponse();
        entryPointErrorResponse.setMsg("인증이 실패하였습니다.");
        
        response.setStatus(401);
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.getWriter().write(objectMapper.writeValueAsString(entryPointErrorResponse));
    }
}

 

반응형