2024.05.18 글이 새로 업데이트 되었습니다.
Spring Security와 JWT를 통한 인증 및 인가 구현하기
이 글을 읽기 전, 아래 내용을 생각해볼 필요가 있다.
1. Spring Security의 기본 프로세스는 알고 있는가?
2. Spring Security없이 JWT를 통한 인증 및 인가 구현을 할 수 있는데, 왜 Spring Security를 사용하는가?
1번에 대해서는 이 글에서 아주 간단하게 다룰 것이다.
우선, 본인은 두 방식으로 구현해보면서 Spring Security의 장점을 다음과 같이 느꼈다.
- 필요한 구현부만 구현한 뒤, 단순 Config 설정만으로 적용이 끝난다.
- 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-Type
이 form
혹은 json
등 타입이 다 다를 수 있다.
따라서 요청 타입에 맞게, 요청 데이터 형식에 맞게 인증 정보를 파싱하여 Authenticaion
객체로 만드는 과정을 진행한다.
Authentication
: Sprign Security에서 인증 및 인가를 진행할 때, 대상이 되는 객체이며 유저의 인증 및 인가 정보(권한 리스트 등)와 인증 성공 여부(authenticated
)를 반환하는 인터페이스이다.
즉, 인터페이스이기 때문에 유저의 인증 및 인가 정보에 따라 구현체를 직접 만들어 사용할 수 있고 Spring Security에서 제공하는 구현체도 존재한다. ex) UsernamePasswordAuthenticationToken
2. 인증
말 그대로 아이디와 패스워드 혹은 JWT 같은 인증 정보로 요청자를 인증하는 부분이다.
여기서는 2가지 방식으로 구분이 된다.
- 로그인 성공 후, 접근 정보(ex, JWT) 응답
- 접근 정보(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가지로 나누어 설정하는 것을 볼 수 있다.
setLoginFilter
: 로그인 인증 설정setAccessTokenFilter
: API 접근 인증 설정setPermissions
: 인가 설정
위 3가지 외 공통 설정 부분을 다루어 보자.
csrf(AbstractHttpConfigurer::disable)
- csrf 설정을 disable 한다.
- cors와 csrf가 뭔데?
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 Context
에 Authentication
객체를 넣어주는건 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
'개발 > Spring' 카테고리의 다른 글
Paging CountQuery With Querydsl (1) | 2023.10.25 |
---|---|
Redis를 통한 JWT Refresh Token 관리 (4) | 2022.02.11 |
Refresh Token 발급과 Access Token 재발급 (5) | 2022.02.11 |
발급받은 JWT로 요청하기 (0) | 2022.02.11 |
[Spring] SecurityContext에 사용자 정보 넣어 테스트하기 (0) | 2022.02.10 |