본문 바로가기
개발/Spring

Spring Security와 JWT를 통한 인증 및 인가

by solchan98 2022. 9. 28.
2024.05.18 글이 새로 업데이트 되었습니다.

Spring Security와 JWT를 통한 인증 및 인가 구현하기

저장소

 

Playground/spring-security-jwt at main · solchan98/Playground

🛝 개발 공부 놀이터 🛝. Contribute to solchan98/Playground development by creating an account on GitHub.

github.com

 

이 글을 읽기 전, 아래 내용을 생각해볼 필요가 있다.

1. Spring Security의 기본 프로세스는 알고 있는가?
2. Spring Security없이 JWT를 통한 인증 및 인가 구현을 할 수 있는데, 왜 Spring Security를 사용하는가?

 

1번에 대해서는 이 글에서 아주 간단하게 다룰 것이다.

우선, 본인은 두 방식으로 구현해보면서 Spring Security의 장점을 다음과 같이 느꼈다.

  1. 필요한 구현부만 구현한 뒤, 단순 Config 설정만으로 적용이 끝난다.
  2. Spring Security의 구현부를 직접 보면 알 수 있지만, 이미 Spring Security가 많은 부분을 핸들링 해주고 있다.

따라서 Spring Security가 직접 핸들링 해주는 부분이 필요 없다거나, 간단한 인증 및 인가 프로세스를 진행하는 것이 목적이라면, 굳이 Spring Security를 사용할 필요는 없다고 생각한다.

Spring Security의 인증 및 인가 프로세스

Spring Security의 상세 프로세스를 여기서 다루지는 않을 것 이다.
Spring Security의 컨셉을 간단하게 글로 말하며 지나갈 것 이다.

 

Spring Security는 Filter Chaing 방식으로 모든 프로세스가 진행된다.
AFilter -> BFilter -> CFilter -> ... 순으로 호출된다.
즉, 호출 프로세스를 보면 아래와 같다.
AFilter -> BFilter - > CFilter -> ... -> CFilter -> BFilter -> AFilter

 

위와 같이 진행되며 필터의 순서는 일반적으로 아래와 같이 진행된다.

Request 인증 데이터 추출 -> 인증 -> 인가

물론, Filter 순서는 직접 변경 가능하나 일반적으로 인증 -> 인가 순으로 진행된다.
위에서 언급된 순서별로 하나씩 보자.

1.  Request 인증 데이터 추출

로그인 요청을 할 때, Content-Typeform 혹은 json 등 타입이 다 다를 수 있다.
따라서 요청 타입에 맞게, 요청 데이터 형식에 맞게 인증 정보를 파싱하여 Authenticaion 객체로 만드는 과정을 진행한다.

 

Authentication : Sprign Security에서 인증 및 인가를 진행할 때, 대상이 되는 객체이며 유저의 인증 및 인가 정보(권한 리스트 등)와 인증 성공 여부(authenticated)를 반환하는 인터페이스이다.

즉, 인터페이스이기 때문에 유저의 인증 및 인가 정보에 따라 구현체를 직접 만들어 사용할 수 있고 Spring Security에서 제공하는 구현체도 존재한다. ex) UsernamePasswordAuthenticationToken

2.  인증

말 그대로 아이디와 패스워드 혹은 JWT 같은 인증 정보로 요청자를 인증하는 부분이다.
여기서는 2가지 방식으로 구분이 된다.

  1. 로그인 성공 후, 접근 정보(ex, JWT) 응답
  2. 접근 정보(ex, JWT)로 인증을 진행한 뒤, 인증된 Authentication을 만들어 다음 필터로 진행

로그인 정보(id, password) 또는 JWT 모두 인증 정보지만, 목적이 서로 다르다.
로그인은 접근 정보를 얻기위한 목적을 가지고, JWT는 특정 API에 접근하기 위한 목적을 가진다.

즉, 로그인의 경우 인증이 성공되면 다음 필터를 타는 것이 아닌, 결과를 응답하는 것이 목적이다.


실제 Spring Security의 Form-Data 형식의 요청 로그인 필터는 AuthenticationFilter가 아닌, AbstractAuthenticationProcessingFilter 추상 클래스를 상속한 UsernamePasswordAuthenticationFilter를 만들어 사용하고 있다.

상세 구현을 다루면 복잡해지기 때문에 간단하게 정리해보면 다음과 같다.

인증 성공 시, AuthenticationFilter와 
AbstractAuthenticationProcessingFilter의 차이는 다음 필터 진행 유무이다.

3.  인가

접근 정보에 대한 인증이 성공하면 인가 과정이 남게된다.
어떤 정보를 어떻게 어디서 얻어서 인가를 진행해야 할까?

 

Spring Security에는 Security Context라는 개념이 존재한다.
Security Context는 Thread Local을 활용하여 인증된 정보를 가지고 있는다.

어떻게? 인증 과정을 통해 생성된 인증된 Authentication 객체를 Security Context에 저장한 후, 다음 필터가 동작하기 때문이다.

따라서 인가 과정에서 Security Context Holder에 있는 Authentication 객체를 통해 인가가 진행된다.

 

위에서 Authentication은 인터페이스이며, 인증 및 인가 정보를 반환하는 역할을 한다고 말했었다.
즉, Security Context에 있는 Authentication 객체를 통해 권한을 확인할 수 있고, 이는 Spring Security에 이미 구현되어 있다.

따라서 우리는 설정 정보만 작성해주면 기본적인 인가 처리가 완료된다.

 

여기까지 확인이 되었다면 Spring Security 사용하여 얻는 이점을 어느정도 확인할 수 있을거라 생각한다.

코드 설명

https://github.com/solchan98/Playground/tree/main/spring-security-jwt

위 저장소에는 Access Token 재발급 및 Refresh Token 관리 기능이 함께 구현되어 있다.
따라서 위 부분은 제외하고 진행한다.

 

코드 설명은 Spring Security Config를 중점으로 다루며, 모든 객체를 다루지 않는다.
그 이유는 Spring Security의 인터페이스 중, 필요한 구현체만 구현하여 적용하기 때문이다.

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final ObjectMapper objectMapper;

    private final UserDetailsService userDetailsService;

    private final AccessTokenProvider accessTokenProvider;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.csrf(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable);

        setLoginFilter(httpSecurity);
        setAccessTokenFilter(httpSecurity);
        setPermissions(httpSecurity);

        return httpSecurity.exceptionHandling(eh -> eh.accessDeniedHandler(new AccessTokenAccessDeniedHandler()))
                .build();
    }

    private void setLoginFilter(HttpSecurity httpSecurity) {
        SimpleAuthenticationProcessingFilter emailPasswordAuthenticationFilter = new SimpleAuthenticationProcessingFilter(
                RequestMatchers.LOGIN,
                new EmailPasswordAuthenticationConverter(objectMapper)
        );
        emailPasswordAuthenticationFilter.setAuthenticationSuccessHandler(
                new EmailPasswordAuthenticationSuccessHandler(objectMapper, accessTokenProvider, refreshTokenProvider));
        emailPasswordAuthenticationFilter.setAuthenticationFailureHandler(new AuthenticationFailureHandlerImpl());

        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsService);
        daoAuthenticationProvider.setPasswordEncoder(new SimplePasswordEncoder());

        emailPasswordAuthenticationFilter.setAuthenticationManager(new ProviderManager(daoAuthenticationProvider));

        httpSecurity.addFilterBefore(emailPasswordAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }


    private void setAccessTokenFilter(HttpSecurity httpSecurity) {
        AuthenticationFilter accessAuthenticationFilter = new AuthenticationFilter(new ProviderManager(accessTokenProvider),
                new BearerAuthenticationConverter());

        accessAuthenticationFilter.setRequestMatcher(new AndRequestMatcher(
                new NegatedRequestMatcher(RequestMatchers.PERMIT_ALL)
        ));
        accessAuthenticationFilter.setFailureHandler(new AuthenticationFailureHandlerImpl());
        accessAuthenticationFilter.setSuccessHandler((request, response, authentication) -> {
        });

        httpSecurity.addFilterAfter(accessAuthenticationFilter, SimpleAuthenticationProcessingFilter.class);
    }

    private void setPermissions(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.authorizeHttpRequests(ahr -> ahr.requestMatchers(RequestMatchers.ADMIN)
                .hasAnyAuthority(Role.ADMIN.name())
                .requestMatchers(RequestMatchers.SELLER)
                .hasAnyAuthority(Role.ADMIN.name(), Role.SELLER.name())
                .requestMatchers(RequestMatchers.BUYER)
                .hasAnyAuthority(Role.ADMIN.name(), Role.BUYER.name())
                .requestMatchers(RequestMatchers.AUTHENTICATED)
                .authenticated()
                .requestMatchers(RequestMatchers.PERMIT_ALL)
                .permitAll()
        );
    }
}

 

securityFilterChain 메서드 부분을 보면 크게 3가지로 나누어 설정하는 것을 볼 수 있다.

  1. setLoginFilter : 로그인 인증 설정
  2. setAccessTokenFilter : API 접근 인증 설정
  3. setPermissions : 인가 설정

위 3가지 외 공통 설정 부분을 다루어 보자.

  • csrf(AbstractHttpConfigurer::disable)
  • formLogin(AbstractHttpConfigurer::disable)
    • 이번 예제는 form 로그인이 아닌, json 형식의 요청 정로르 받아 로그인을 처리하기 때문에 disable 한다.
    • disable하지 않으면 기본적으로 위에서 계속 언급했던 UsernamePasswordAuthentcationFilter가 적용된다.

로그인 인증 설정 (setLoginFilter)

private void setLoginFilter(HttpSecurity httpSecurity) {
        // 로그인 필터 생성
        SimpleAuthenticationProcessingFilter emailPasswordAuthenticationFilter = new SimpleAuthenticationProcessingFilter(
                RequestMatchers.LOGIN,
                new EmailPasswordAuthenticationConverter(objectMapper)
        );
        // 로그인 성공 핸들러 설정
        emailPasswordAuthenticationFilter.setAuthenticationSuccessHandler(
                new EmailPasswordAuthenticationSuccessHandler(objectMapper, accessTokenProvider, refreshTokenProvider));
         // 로그인 실패 핸들러 설정
        emailPasswordAuthenticationFilter.setAuthenticationFailureHandler(new AuthenticationFailureHandlerImpl());
        // 로그인 처리를 진행하는 Authentication Provder 생성
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        // 로그인 처리를 위한 UserDetails 유저 정보를 가져오는 UserDetailsService 설정
        daoAuthenticationProvider.setUserDetailsService(userDetailsService);
        // 패스워드 비교를 진행하기 위한 PasswordEncoder 설정
        daoAuthenticationProvider.setPasswordEncoder(new SimplePasswordEncoder());

        // 인증 Provider를 호출하는 AuthenticationManager(ProviderManager) 설정
        emailPasswordAuthenticationFilter.setAuthenticationManager(new ProviderManager(daoAuthenticationProvider));

        // 필터 체이닝 위치 설정
        httpSecurity.addFilterBefore(emailPasswordAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }

 

우선, SimpleAuthenticationProcessingFilter 필터를 만들고 있다.
기본적으로 Spring Security는 AbstractAuthenticationProcessingFilter 추상 클래스를 상속한 UsernamePasswordAuthenticationFilter를 제공하고 있다.

 

왜? UsernamePasswordAuthenticationFilter를 사용하지 않는가?
UsernamePasswordAuthenticationFilter은 요청 스펙이 픽스되어 있다.


요청 스펙을 커스텀 하기 위해 AbstractAuthenticationProcessingFilter 추상 클래스를 상속하는 SimpleAuthenticationProcessingFilter를 작성 하였다.

따라서 생성자에 RequestMatchers.LOGIN, EmailPasswordAuthenticationConverter를 넘겨주는 것이다.

 

참고로 Spring Security는 인증 필터로 AuthenticationFilter 클래스도 제공한다.


AbstractAuthenticationProcessingFilter와 다른점은 인증 성공 시, 다음 필터 진행 유무이다.

위 설정 부분을 보면 알 수 있듯이 필요한 구현체만 구현한 뒤 필터 생성시에 넣어주면 된다.


우리가 인증 및 인가 프로세스를 수정하거나 직접 구현할 필요가 없다.

API 접근 인증 설정(setAccessTokenFilter)

private void setAccessTokenFilter(HttpSecurity httpSecurity) {
        AuthenticationFilter accessAuthenticationFilter = new AuthenticationFilter(new ProviderManager(accessTokenProvider),
                new BearerAuthenticationConverter());

        // 필터가 매치되는 RequestMatcher 설정 
        accessAuthenticationFilter.setRequestMatcher(new AndRequestMatcher(
                new NegatedRequestMatcher(RequestMatchers.PERMIT_ALL)
        ));
        accessAuthenticationFilter.setFailureHandler(new AuthenticationFailureHandlerImpl());
        accessAuthenticationFilter.setSuccessHandler((request, response, authentication) -> {
        });

        httpSecurity.addFilterAfter(accessAuthenticationFilter, SimpleAuthenticationProcessingFilter.class);
    }

 

로그인 인증 설정과 다르게 Spring Security가 제공하는 AuthenticationFilter 그대로 사용한다.

 

위에서 계속 언급했던 것 처럼 API 접근 인증은 로그인 인증과 다르다.
인증이 성공하면 다음으로 인가가 진행되어야 한다.
따라서 인증 성공 시, 다음 필터를 진행하는 AuthenticationFilter를 그대로 사용한다.

 

로그인 인증과 동일하게 구현체를 설정해주면서 설정이 진행된다.

 

여기서 successHandler를 보면 아무런 동작을 수행하지 않는 람다식으로 적용된 것을 볼 수 있다.
AuthenticationFilter의 구현부를 보면 기본적으로 SavedRequestAwareAuthenticationSuccessHandler로 핸들러가 설정되고 있다.

하지만 우리가 필요한건 Security Cotext에 인증 Authentication 객체를 넣는것이 필요하고 그 외 SavedRequestAwareAuthenticationSuccessHandler가 수행하는 기능이 필요하지 않다.


Security ContextAuthentication 객체를 넣어주는건 AuthenticationFilter가 수행하고 있다.
따라서 부가적인 성공 핸들링 처리가 필요 없기에 핸들러를 변경한 것 이다.

 

참고, SavedRequestAwareAuthenticationSuccessHandler의 경우 Redirect 관련 기능을 수행하고 있는데 이 기능이 필요하다면 구현체를 확인하여 적용하면 된다.

인가 설정(setPermissions)

private void setPermissions(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.authorizeHttpRequests(ahr -> ahr.requestMatchers(RequestMatchers.ADMIN)
                .hasAnyAuthority(Role.ADMIN.name())
                .requestMatchers(RequestMatchers.SELLER)
                .hasAnyAuthority(Role.ADMIN.name(), Role.SELLER.name())
                .requestMatchers(RequestMatchers.BUYER)
                .hasAnyAuthority(Role.ADMIN.name(), Role.BUYER.name())
                .requestMatchers(RequestMatchers.AUTHENTICATED)
                .authenticated()
                .requestMatchers(RequestMatchers.PERMIT_ALL)
                .permitAll()
        );
    }

 

이 부분은 특별한 설명이 필요하지 않아 보인다.
코드 그대로 특정 RequestMathcher에 해당되면 특정 Authority를 가져야 한다는 것을 알 수 있다.

 

테스트

이제 설정이 완료되었다면 .http를 통해 테스트해 볼 수 있다.
어플리케이션 실행 후, 프로젝트 root 경로에 있는 `.http` 테스트해볼 수 있다.

### Admin 권한 유저 로그인
POST http://localhost:8080/login
Content-Type: application/json

{
  "email": "admin@sol.com",
  "password": "a1234567"
}

### Seller 권한 유저 로그인
POST http://localhost:8080/login
Content-Type: application/json

{
  "email": "seller@sol.com",
  "password": "a1234567"
}


### Admin 권한 API 호출
GET http://localhost:8080/admin
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhY2Nlc3MgdG9rZW4iLCJ1c2VySW5mbyI6IntcImlkXCI6MixcImVtYWlsXCI6XCJzZWxsZXJAc29sLmNvbVwiLFwibmFtZVwiOlwic2VsbGVyXCIsXCJhdXRob3JpdGllc1wiOlt7XCJhdXRob3JpdHlcIjpcIlNFTExFUlwifV19IiwiaWF0IjoxNzE2MDE0MDQ0LCJleHAiOjE3MTYwMTc2NDR9.dogkehnKCAZe56Mwrd2VYXknEYwQu4lbgLQGQO_biw4

### Seller 권한 API 호출
GET http://localhost:8080/seller
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhY2Nlc3MgdG9rZW4iLCJ1c2VySW5mbyI6IntcImlkXCI6MixcImVtYWlsXCI6XCJzZWxsZXJAc29sLmNvbVwiLFwibmFtZVwiOlwic2VsbGVyXCIsXCJhdXRob3JpdGllc1wiOlt7XCJhdXRob3JpdHlcIjpcIlNFTExFUlwifV19IiwiaWF0IjoxNzE2MDE0MDQ0LCJleHAiOjE3MTYwMTc2NDR9.dogkehnKCAZe56Mwrd2VYXknEYwQu4lbgLQGQO_biw4

### buyer 권한 API 호출
GET http://localhost:8080/buyer
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJyZWZyZXNoIHRva2VuIiwiZW1haWwiOiJzZWxsZXJAc29sLmNvbSIsImlhdCI6MTcxNTc1ODM4MCwiZXhwIjoxNzE1NzYxOTgwfQ.K3qpdu7rfSZ0-LvRkv1MagjZOOCRbP--wvDuDjFzigw

### Permit All 호출(권한 상관없는 API)
GET http://localhost:8080/authenticated
Content-Type: application/json
Authorization:  Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhY2Nlc3MgdG9rZW4iLCJ1c2VySW5mbyI6IntcImlkXCI6MixcImVtYWlsXCI6XCJzZWxsZXJAc29sLmNvbVwiLFwibmFtZVwiOlwic2VsbGVyXCIsXCJhdXRob3JpdGllc1wiOlt7XCJhdXRob3JpdHlcIjpcIlNFTExFUlwifV19IiwiaWF0IjoxNzE2MDE0MDk1LCJleHAiOjE3MTYwMTc2OTV9.Jtwds3yKzD_wykcHqXeuuU6cgv37Qr9UNBcjz1I7Yek

### Open API 호출
GET http://localhost:8080/permit-all
Content-Type: application/json