
해당 포스팅은 정수원님의
스프링 시큐리티 - Spring Boot 기반으로 개발하는 Spring Security 강의를 참고하여 작성했습니다.
강의 예제는 원리 이해하는 부분은 springboot starter 3.0.1 (security 6.0.1), 구현하는 부분은 Springboot 2.7.3을 사용해 진행
용어 정리
인증 : 어떤 개체(사용자 또는 장치)의 신원을 확인하는 과정
인가 : 어떤 개체가 어떤 리소스에 접근할 수 있는지 또는 어떤 동작을 수행할 수 있는지를 검증하는 것, 즉 접근 권한을 얻는 일
기본 API 및 Filter
자동 구성 항목
Spring Security 의존성 추가 시 자동 구성
- 모든 요청은 인증이 되어야 접근
- 인증 방식 Form 로그인 방식 / httpBasic 로그인 방식 제공
- 기본 로그인 페이지 제공 (기본 계정 : id : user / password 랜덤 문자열)
- CSRF 공격 방지
- 세션 고정 보호
- 보안 헤더 통합
추가로 계정 추가, 권한 추가, DB연동 등 작업 필요
@Configuration
@EnableWebSecurity // WebSecurity Configuration, Web보안 활성화
public class SecurityConfig{
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 인가 정책
http
.authorizeHttpRequests((authz) -> authz // 규칙 지정
.anyRequest().authenticated() // 모든 요청이 인증을 요청하게 설정
)
// 인증 정책
.formLogin(withDefaults());
return http.build();
}
}
설정하고 서버를 실행하면 user계정의 password가 실행 마다 변경되면서 console창에 노출이 되는데
application.properties파일에 이를 명시할 수 있다.
spring.security.user.name=user
spring.security.user.password=1111
Login Form 인증 과정
-
UsernamePasswordAuthenticationFilter 에서 요청 ID, Password를 통해 Token을 생성한다
-
생성한 token을 AuthenticationManager에서 확인한다.
-
실패 시 3.1) SecurityContextHolder에서 인증 정보가 지워진다.
3.2) RememberMeServices.loginFail이 호출된다.
3.3) SecurityConfig에서 설정한 AuthenticationFailureHandler가 호출 된다. -
성공 시
4.1) SessionAuthenticationStrategy에 새로운 로그인을 알린다.
4.2)SecurityContextHolder에 인증정보(username, password, authorities(사용자의 권한 정보)) 저장
4.3) ApplicationEventPublisher 생성
4.4) SecurityConfig에서 설정한 AuthenticationSuccessHandler가 호출 된다.
http
.formLogin() // form 로그인 인증 기능 적용
// .loginPage("/loginPage")
.defaultSuccessUrl("/")
.failureUrl("/login")
.usernameParameter("username")
.passwordParameter("password")
.loginProcessingUrl("/login_proc")
.successHandler(new AuthenticationSuccessHandler() {
// 요청, 응답, 인증 객체
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println(authentication.getName());
response.sendRedirect("/");
}
})
.failureHandler(new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
response.sendRedirect("/login");
}
})
.permitAll();
Logout과정
CompositeLogoutHandler의 구성을 살펴보면 logout handler로 CookieClearing, CsrfLogoutHandler, SecurityContextLogoutHandler, LogoutSuccessEventPublishingLogoutHandler이 구성되어 있다
logout시 동작을 대략적으로 정리해보면
- HTTP 세션 무효화
- 구성된 모든 RememberMe 인증 정리
- SecurityContextHolder.clearContext()
- 리다이렉션
http
.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("login")
.addLogoutHandler(new LogoutHandler() {
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, authentication authentication) {
HttpSession session = request.getSession();
session.invalidate();
}
})
.logoutSuccessHandler(new LogoutSuccessHandler() {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.sendRedirect("/login");
}
})
.deleteCookies("remember-me")
RememberMe 인증 (영구 로그인 인증)
토근 기반 인증을 사용해 세션이 만료되고 웹 브라우저가 종료된 후에도 App이 사용자를 기억하는 기능
Spring Security는 2가지 방법 제공
-
- 해싱을 사용하여 쿠키 기반 토큰의 보안 유지 기능 (보통 14일)
-
- DB / 기타 영구 저장 매커니즘을 사용해 생성된 토큰 저장
인증 과정
-
세션이 만료 되었거나, Security Context에 인증 정보가 없는 경우(인증 객체가 Null인 경우) RememberMeAuthenticationFilter가 탐지
-
Token 일치 여부 -> User계정 확인 -> 새로운 Authentication 생성
http
.rememberMe()
.rememberMeParameter("remember") // 파라미터 변경 / 기본 파라미터 명은 remember-me
.tokenValiditySeconds(3600) // remeberme 시간 설정 가능 / default(14)
.alwaysRemember(false); // 기능이 활성화 되지 않아도 항상 리하는 것 여부 / default false
userDetailsService(userDetailsService); // 사용자 계정을 조회하는 과정 (필수로 설정 해야한다)
- rememberMeParameter 이름을 화면상의 이름과 일치 시켜 준다.
- 사용 시 remember-me 쿠키값이 추가된다.
정리
- Remember Me기능 사용 시, 스프링 시큐리티는 인증 객체 관련 정보를 Remember Me 쿠키에 저장한 후 사용자에게 전달
- Remember Me 쿠키가 유효하면 JSESSIONID가 없어도 Remember Me 쿠키를 통해 로그인 처리
익명사용자 필터
AnonymousAuthenticationFilter가 처리 하는데, 익명 사용자와 인증 사용자를 구분해서 처리하기 위한 용도
Authentication을 검사하고 사용자의 Authentication정보가 아니라면 AnonymousAuthenticationToken을 생성 후 SecurityContext에 넣는다.
- 화면에서 인증 여부를 구현할 때 isAnonymous()와 isAuthenticated()로 구분해서 사용
- 인증 객체를 세션에 저장하지 않는다.
- 사용자가 인증을 받지 않았으면 인증되지 않은 사용자의 토큰을 만들어 인증된 사용자와 구분하기 위한 용도
잠시 정리를 해보면 Spring Security는 기본적으로
SecurityContextHolder 내부의 SecurityContext에 인증정보가 담긴 Authentication 객체 값이 존재
해야 하는 구조로 익명 사용자라 할지라도 Authentication 객체가 null이 아니다
동시 세션 제어
동일한 계정으로 접속했을 때 만들어지는 세션을 제어하는 방법
-
이전 사용자 세션 만료
동일 계정으로 누군가 로그인 했을 때, 이전에 사용하던 사용자의 세션을 만료
-
현재 사용자 인증 실패
이미 동일 계정으로 접속하고 있는 사람이 있을 경우, 현재 인증 요청한 사용자의 인증 과정에서 인증 예외 처리
// 동시 세션 제어
http
.sessionManagement()
.maximumSessions(1) // 최대 허용 가능 세션 수 (1) , -1 -> 무제한 로그인 세션 허용
.maxSessionsPreventsLogin(false) // true : 현재 사용자 인증 실패, false : 기존 사용자 인증 실패
// .invalidSessionUrl("/invalid") // 세션이 유효하지 않을 경우 이동 할 페이지
.expiredUrl("/expired"); // 세션이 만료 된 경우 이동 할 페이지
세션 고정 보호
// 세션 고정 보호
http
.sessionManagement() // 세션 관리 설정 모드 사용.
.sessionFixation()
.changeSessionId(); // 기존 사용자의 세션에 Session ID만 변경
// .none(); //세션 고정 보호를 하지 않음.
// .newSession() // 인증 성공 시 새로운 세션 사용
세션 정책
세션 생성, 사용 여부를 설정 가능 (ALWAYS, IF_REQUIRED, NEVER, STATELESS)
// 세션 정책
http
.sessionManagement()
// .sessionCreationPolicy(SessionCreationPolicy.ALWAYS); // Spring Security가 항상 세션 설정
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED); // 필요 시 생성(default)
// .sessionCreationPolicy(SessionCreationPolicy.NEVER); // 생성x, 존재하면 사용
// .sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 생성x, 존재해도 사용x (JWT 사용 시)
SessionManagementFilter, ConcurrentSessionFilter
SessionManagementFilter는
- 동시 세션 제어
- 세션 고정 보호
- 세션 관리
- 세션 생성 정책 설정
4가지 기능을 수행하는데 동시 세션을 관리하는데 있어서 ConcurrentSessionFilter의 도움을 받는다.
사용자 요청이 들어올 때 마다, ConcurrentSessionFilter를 통해 매번 세션이 만료되었는지 확인 -> 만료 되었으면 1. 로그아웃, 2. 세션 만료를 사용자에게 알림
권한 설정과 표현식
URL과 Method에 대해 권한 설정
선언적 방식
URL과 Method 방식 존재
1. URL
HttpSecurity 객체를 사용해 설정
http
.requestMatchers("/users/**").hasRole("USER");
Http 객체 설정 (Toggle)
Method | Describe |
---|---|
hasRole(String) | 사용자가 주어진 역할이 있다면 접근 허용 |
hasAuthority(String) | 사용자가 주어진 권한이 있다면 접근 허용 ex) hasAuthority(‘read’) |
hasAnyRole(String…,) | 사용자가 주어진 권한이 있다면 접근 허용. (하나라도 있으면 , 로 구분) |
hasAnyAuthority(String…) | 사용자가 주어진 권한 중 어떤 것이라도 있따면 접근 허용 |
hasIpAddress(String) | 주어진 IP로부터 요청이 온 경우 접근 허용 |
permitAll() | 모든 요청 허용 |
denyAll() | 모든 요청 접근 차단 |
isAuthenticated() | 로그인 인증을 받은 사용자는 권한에 관계 없이 허용, 익명 사용자는 로그인 페이지로 이동 |
isFullyAuthenticated() | 자동 로그인하지 않고 로그인 인증을 한 사용자는 권한에 관계 없이 허용 |
isAnonymous() | 권한이 없는 익명의 사용자만 접근을 허용함 (로그인되어 권한이 있으면 접근 불가) |
isRememberMe() | 자동 로그인 대상 사용자의 경우 접근을 허용 |
Method
@Pre and @Post Annotations 어노테이션을 이용해 권한 설정
@Pre and @Post Annotations doc
2. 동적 방식 (DB연동)
뒤에서 설명
주의 사항
- 경로 표현을 생략하는 경우 모든 요청은 인증을 받아야 접근 가능
- 구체적인 경로는 앞에 작성, 뒤로 갈 수록 큰 범위의 경로 설정
- 하위 경로 표현 시 **로 표현
예외 처리 및 요청 캐시 필터
에러 처리 기능은 **ExceptionTranslationFilter()**가 처리
- AuthenticationException -> 인증 예외 / AccessDeniedException -> 인가 예외
- RequestCache -> 사용자의 요청 정보 세션에 저장
- SavedRequest -> 사용자의 request 파라미터, 헤더값 등 저장
SavedRequest savedRequest = requestCache.getRequest(request, response);
AuthenticationException
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
...
http
.exceptionHandling()
.authenticationEntryPoint(new AuthenticationEntryPoint() {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.sendRedirect("/login");
}
})
.accessDeniedHandler(new AccessDeniedHandler() {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.sendRedirect("denied");
}
});
}
CSRF처리
Spring Security는 기본적으로 CSRF 사용 설정이 되어있으며, X-CSRF-TOKEN값을 헤더에 추가한다
view template, Spring form 태그 사용 시 csrf토큰이 자동으로 설정
기본적인 폼태그 사용할 땐 _csrf 값으로 추가
주요 아키텍처
Servlet Container
- SecurityFilterAutoConfiguration에서 springSecurityFilterChain을 가져올 수 있게 DelegatingFilterProxy가 생성
Spring Container
- WebSecurityConfiguration 클래스에서 WebSecurity 클래스가 springSecurityFilterChain이름으로 생성된 Bean들을 FilterChainProxy로 생성시켜 준다.
모든 요청은 DelegatingFilterProxy에서 시작
DelegatingFilterProxy
Filter는 Servlet 2.3에서 부터 제공되는 기술로
서블렛을 통해 어떤 요청이 들어올 때 서블릿 자원에 들어오기 전에 처리
즉, 요청에 대한 최종 접근 전, 후로 처리할 수 있게 하는 것이다.
이 필터는 서블릿 스펙의 기술이며 Servlet 컨테이너에서 생성되고, 실행이 되기 때문에
Spring에서 만든 Bean을 Injection 해서 사용할 수 없다.
Spring이 사용하는 기능을 사용하기 위해
DelegatingFilterProxy는 ApplicationContext에서
springSecurityFilterChain이름을 가진 스프링 빈(FilterChainProxy)을 찾아서 그 빈에게 요청을 위임한다
FilterChainProxy
- springSecurityFilterChain의 이름으로 생성되는 필터 빈
- DelegatingFilterProxy로 부터 요청을 위임받고 실제 보안 처리
- SpringSecurity 초기화 시 생성되는 필터들을 관리하고 제어
- 사용자 요청을 필터 순서대로 호출하여 전달
- 모든 Filter를 통과 시 보안 처리 완료
Filter 초기화 다중 보안 설정
여러개의 SecurityFilterChain 클래스를 만들어 url에 따라 다른 보안 정책이 적용될 수 있게 설정하면 WebSecurity에서 FilterChainProxy가 만들어 질 때 url에 따라 다른 정책이 적용될 수 있게 filter chain이 생성된다.
@Bean
@Order(1)
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(authorize -> authorize
.anyRequest().hasRole("ADMIN")
)
.httpBasic(withDefaults());
return http.build();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
...
}
multiple-httpsecurity- instances - Spring Security Doc
인증(Authentication)
어떤 개체(사용자 또는 장치)의 신원을 확인하는 과정
인증 흐름과 같이 진행이 되고 생성되는 Authentication Token의 구조를 살펴보면
- principal : 사용자 ID 또는 User객체
- credentials : 사용자 비밀번호, 대부분의 경우에 인증된 후 값이 지워져서 유출되지 않는다.
- authorities : 인증된 사용자의 권한 목록
- details : 인증 부가 정보
- Authenticated : 인증 여부
로 이루어져 있다.
UsernamePasswordAuthenticationFilter -> Authentication 객체
-> AuthenticationManager의 구현체 ProviderManager가 인증 처리
SecurityContext
필요 시 언제든지 Authentication 객체를 꺼내 쓸 수 있게 저장되는 저장소
- ThreadLocal에 저장되어 아무 곳에서나 참조 가능
- 인증 완료 시, Httpsession에 저장 (App내 전역적인 참조 가능)
SecurityContextHolder
SecurityContext를 감싸고 있는 wrapper 클래스
- SecurityContext객체 저장 방식
- MODE_THREADLOCAL : 스레드 당 SecurityContext 객체 할당 (default)
- MODE_INHERITABLETHREADLOCAL : 메인 스레드와 자식 스레드에 관하여 동일한 SecurityContext를 유지
- MODE_GLOBAL : 응용 프로그램에서 단 하나의 SecurityContext저장 (static 변수로)
- ThreadLocal에 SecurityContextHolder를 담고 있다.
- HttpSession에 SPRING_SECURITY_CONTEXT이름으로 저장
SecurityContextHolder에서 인증정보 가져오는 방법
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
SecurityPersistenceFilter
SecurityContext 객체의 생성, 저장, 조회
- 여러 필터들이 Authentication 객체를 쓸 수 있도록 하기 위해서 SecurityContextPersistenceFilter는 2번째에 위치
- SecurityContextPersistenceFilter에서 인증 전인지, 인증 후인지를 판단 SecurityContext에 Authentication 객체 존재 유무 결정
익명 사용자
- 새로운 SecurityContext 객체를 생성하여 SecurityContextHolder에 저장
- AnonymouseAuthenticationFilter에서 AnonymousAuthenticationToken 객체를 SecurityContext에 저장
인증 시
- 새로운 SecurityContext객체를 생성하여 SecurityContextHolder에 저장
- UsernamePasswordAuthenticationFilter에서 인증 성공 후 SecurityContext 에 UsernamePasswordAuthentication 객체를 SecurityContext에 저장
- 인증이 최종 완료되면 Session 에 SecurityContext를 저장
인증 후
- Session에서 SecurityContext 꺼내 SecurityContextHolder에서 저장
- SecurityContext안에 Authentication 객체가 존재하면 계속 인증을 유지
최종 응답 시 공통
- SecurityContextHolder안의 SecurityContext객체에서 보관하던 인증정보 반드시 초기화
- 초기화 시 SecurityContextHolder.clearContext() 사용
SecurityPersistenceFilter Flow
-
FilterChainProxy에 2번째 위치하며 모든 요청에서 수행
-
내부적으로 HttpSecurityContextRepository가 SecurityContext객체 생성, 조회하는 역할을 하는 로직 수행
a. 인증 전- 새로운 컨텍스트 생성 (SecurityContext는 null)
- 인증 필터(AuthFilter)가 인증 처리
- 인증객체(Authentication)생성 후 SecurityContext 객체안에 저장
- Session에 SecurityContext저장
b. 인증 후
- Session에서 SecurityContext가 있는지 확인
- SecurityContext를 꺼내 SecurityContextHolder에 저장
인증 흐름 (Authentication Flow)
- UsernamePasswordAuthenticationFilter는 요청을 가로채서 Authentication(인증정보) 생성
- AuthenticationManager는 적절한 Provider를 찾아 인증 처리 위임
- AuthenticationProvider는 실제 ID/PW 인증 처리를 하는데 ID의 경우 UserDetailsService를 사용해 조회 -> 조회 한 UserDetails 정보로 PW 검증
- AuthenticationProvider는 인증 성공 후 유저정보(UserDetails) + 권한 정보(authorities) 담아 토큰 생성
- UsernamePasswordAuthenticationFilter가 인증 객체를 전달 받아 SecurityContext에 인증 정보 저장
AuthenticationManager
AuthenticationProvider 목록 중에서 인증 처리 요건에 맞는 AuthenticationProvider를 찾아 인증 처리 위임
- AuthenticationManager 는 인터페이스로 실제 구현체는 ProviderManager
- ProviderManager에 인증 처리 요건에 맞는 것이 없을 경우 부모 ProviderManager에서 탐색 후 처리
AuthenticationProvider
-
AuthenticationProvider 는 인터페이스로 2개의 메서드 존재
- authenticate(authentication) : 실제적인 인증처리를 위한 검증 메서드
- supports(authentication): 인증처리가 가능한 Provider인지 검사하는 메서드
-
아이디 검증
- 존재 -> UserDetails 반환 / 존재 x -> UserNotFoundException
-
패스워드 검증
- 반환된 UserDetails에 저장된 password와 로그인시 입력한 패스워드(authentication.password)가 일치하는지 비교
- 일치하지 않을 경우 BadCredentialException 발생
- 일반적으로 패스워드를 저장할 때 Password Encoder를 이용해 암호화 하여 저장하기 때문에 해당 클래스(PasswordEncoder)를 이용해 두 암호를 비교
-
추가 검증
- 추가적으로 사용자가 정의한 검증 조건 검증
- 검증이 모두 성공하면 최종적으로 인증객체를 생성 후 AuthenticationManager에 전달
인가(Authorization)
어떤 개체가 어떤 리소스에 접근할 수 있는지 접근 권한 할당
시큐리티가 지원하는 권한 계층
- 웹 계층 : URL요청에 따른 메뉴 혹은 화면단위의 레벨 보안
- 서비스 계층 : 메소드 같은 기능 단위의 레벨 보안
- 도메인 계층 : 객체 단위의 레벨 보안
AuthorizationFilter
마지막에 위치한 필터로 승인, 거부를 최종적으로 결정
- 인증객체 없이 보호자원에 접근을 시도하면 AuthenticationException 발생
- 인증 후 자원에 접근 가능한 권한이 존재하지 않을 경우 AccessDeniedException 을 발생
- HTTP 자원의 보안을 처리하는 필터 (URL방식으로 접근 할 경우 동작)
- 권한 처리를 AccessDecisionManager에게 맡김
AccessDecisionManager
인증,요청,권한 정보를 이용해서 사용자의 자원접근을 허용/거부 여부를 최종 결정하는 주체
- 여러 개의 Voter들을 가질 수 있고, Voter들로부터 접근허용, 거부, 보류에 해당하는 각각의 값을 리턴받아 판단, 결정
접근결정의 세 가지 유형 (구현체)
- AffirmativeBased : OR 연산자와 같은 논리
- ConsensusBased : 다수결
- UnanimousBased : AND 연산자와 같은 논리
권한 부여 과정에서 판단하는 값 (3가지)
- Authenticaion - 인증정보(user)
- FilterInvocator - 요청 정보(requestMatchers(“/user”))
- ConfigAttributes - 권한 정보(hasRole(“USER”))
http
.authorizeHttpRequests((authz) -> authz // 규칙 지정
.requestMatchers("/user").hasRole("USER")
.requestMatchers("/admin/pay").hasRole("ADMIN")
.requestMatchers("/admin/**").hasAnyRole("ADMIN", "SYS")
.anyRequest().authenticated() // 모든 요청이 인증을 요청하게 설정
);
정리
초기화
-
Security가 초기화 될 때 Security Config 구성대로 생성 된 Filters를 WebSecurity가 FilterChainProxy 생성 (springSecurityFilterChain 이름을 가진다)
-
DelegatingFilterProxy가 springSecurityFilterChain이름을 가진 FilterChainProxy를 찾아 요청을 위임한다.
인증
-
SecurityPersistenceFilter가 SecurityContext가 있는지 확인 후, 없으면 Authentication 객체 생성 후 SecurityContext에 저장
-
Logout 요청이 없는경우 특별한 동작 x
-
UsernamePasswordAuthenticationFilter에서 UserDetailsService로 ID, PW 검사 후 인증 정보를 더해 SecurityContext에 저장
-
인증 성공 시 뒤에있는 Session ManagementFilter 과정도 동시에 처리, 동시 세션에 대한 처리한다
-
사용자가 정의한 SuccessHandler 작업 수행, 특정한 경로로 리다이렉트
-
다시 요청이 들어와서 SecurityContextPersistenceFilter가 ContextHolder에서 loadContext 후 진행
ConcurrentSessionFilter ~ SessionManagementFilter는 해당사항이 있을 경우만 진행
인가
-
ExceptionTranslationFilter로 감싸 FilterSecurityInterceptor 인가 필터가 인증 객체부터 검사
-
AccessDecisionManager가 실행
인증 구현
정적자원 관리
WebIgnore 설정 시 js, css, image같은 정적 자원들을 보안 필터에서 예외시킨다.
// SecurityConfig
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
PasswordEncoder
비밀번호를 안전하게 암호화
기본 타입 : bcrypt
UserDetails
Spring Security에서 사용자의 정보를 담는 인터페이스
// Account, AccountDto 생성
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Account implements UserDetails, Serializable {
...
}
// UserService상속받아 구현체 생성
@Slf4j
@Service("userService")
public class UserServiceImpl implements UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private RoleRepository roleRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@Transactional
@Override
public void createUser(Account account) {
Role role = roleRepository.findByRoleName("ROLE_USER");
Set<Role> roles = new HashSet<>();
roles.add(role);
account.setUserRoles(roles);
userRepository.save(account);
}
...
}
UserDetails, Serializable을 상속해 확장 가능
메소드 | 리턴 타입 | 설명 | 기본값 |
---|---|---|---|
getAuthorities() | Collection<? extends GrantedAuthority> | 계정의 권한 목록을 리턴 | |
getPassword() | String | 계정의 비밀번호를 리턴 | |
getUsername() | String | 계정의 고유한 값을 리턴( ex : DB PK값, 중복이 없는 이메일 값 ) | |
isAccountNonExpired() | boolean | 계정의 만료 여부 리턴 | true ( 만료 안됨 ) |
isAccountNonLocked() | boolean | 계정의 잠김 여부 리턴 | true ( 잠기지 않음 ) |
isCredentialsNonExpired() | boolean | 비밀번호 만료 여부 리턴 | true ( 만료 안됨 ) |
isEnabled() | boolean | 계정의 활성화 여부 리턴 | true ( 활성화 됨 ) |
- getUsername() 사용 시 SSO 같은 서버를 만들게 되면 정책에 따라서 중복이 될 수도 있기에 주의
- 사용한 Account 엔티티는 반드시 직렬화(Serializable)
CustomUserDetailsService(DB 연동 구현)
사용자 이름, 암호 및 사용자 이름과 암호로 인증하기 위한 기타 속성을 검색하는 데 사용
- UserDetailsService을 상속받아 loadUserByUsername을 구현
-
- ID 존재 여부 확인, 2) User 권한 확인 후 security.core.userdetails.User를 상속받은 AccountContext({유저 정보}, 권한)으로 리턴
- 권한 정보는 GrantedAutority타입으로 담아야 함
- AccountContext를 확장해서 사용 시 정의한 클래스로 객체 생성해 반환
// AccountContext
// UserDetails 타입으로 계정에 관한 정보를 넣어주어야 스프링 시큐리티가 요구하는 스펙(인증 메서드를 사용하기 위한 인자 조건)을 맞출 수 있다
@Data
public class AccountContext extends User {
private Account account;
public AccountContext(Account account, List<GrantedAuthority> roles) {
super(account.getUsername(), account.getPassword(), roles);
this.account = account;
}
}
// UserDetailsServiceImpl
@Slf4j
@Service("userDetailsService")
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Autowired
private HttpServletRequest request;
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Account account = userRepository.findByUsername(username);
if (account == null) {
if (userRepository.countByUsername(username) == 0) {
throw new UsernameNotFoundException("No user found with username: " + username);
}
}
Set<String> userRoles = account.getUserRoles()
.stream()
.map(userRole -> userRole.getRoleName())
.collect(Collectors.toSet());
List<GrantedAuthority> collect = userRoles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
return new AccountContext(account, collect);
}
}
Form인증 구현
CustomAuthenticationProvider
CustomUserDetailsService에서 만든 AccountContext를 검증
구현
- AuthenticationProvider 클래스를 상속받아 인증 절차를 수행하는 authenticate, supports 함수 구현
- Security Config 파일에서 authenticationProvider bean 등록
- Security Config 파일에서 2에서 생성한 빈 객체를 configure 설정으로 전달
특징
- supports 함수는 Authentication 객체를 이 AuthenticationProvider가 인증 가능한 클래스인지 확인
- supports를 통과한 뒤 authenticate 메서드가 호출
- 만약 JWT token을 사용하고, DB에 저장하고 있다면 이런 정보를 추가로 Token에 담아 전달 가능
// FormAuthenticationProvider
@Slf4j
public class FormAuthenticationProvider implements AuthenticationProvider {
@Autowired
private UserDetailsService userDetailsService;
private PasswordEncoder passwordEncoder;
public FormAuthenticationProvider(PasswordEncoder passwordEncoder) {
this.passwordEncoder = passwordEncoder;
}
@Override
@Transactional
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String loginId = authentication.getName();
String password = (String) authentication.getCredentials();
AccountContext accountContext = (AccountContext) userDetailsService.loadUserByUsername(loginId);
if (!passwordEncoder.matches(password, accountContext.getPassword())) {
throw new BadCredentialsException("Invalid password");
}
String secretKey = ((FormWebAuthenticationDetails) authentication.getDetails()).getSecretKey();
if (secretKey == null || !secretKey.equals("secret")) {
throw new IllegalArgumentException("Invalid Secret");
}
return new UsernamePasswordAuthenticationToken(accountContext.getAccount(), null, accountContext.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
// SecurityConfig.java
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(authenticationProvider());
}
@Bean
public AuthenticationProvider authenticationProvider() {
return new FormAuthenticationProvider(passwordEncoder());
}
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
추가로 AuthenticationManager를 외부에서 사용하기 위한 설정은 AuthenticationManagerBean 을 이용하여 Sprint Securtiy 밖으로 AuthenticationManager 빼 내야 한다.
UserDetails 정보 확장
예제에선 secret_key를 추가로 받아 인증과정에 추가
- Details를 담는 AuthenticationDetailsSource 작성
- WebAuthenticationDetails
@Component
public class FormAuthenticationDetailsSource implements AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> {
@Override
public WebAuthenticationDetails buildDetails(HttpServletRequest context) {
return new FormWebAuthenticationDetails(context);
}
}
//사용자가 전달하는 추가적인 파라미터들을 저장
public class FormWebAuthenticationDetails extends WebAuthenticationDetails {
private String secretKey;
public FormWebAuthenticationDetails(HttpServletRequest request) {
super(request);
secretKey = request.getParameter("secret_key");
}
public String getSecretKey() {
return secretKey;
}
}
// config 추가
http.authenticationDetailsSource(authenticationDetailsSource)
Login, Logout 처리 및 핸들러 구현
- login 설정
http
.formLogin()
.loginPage("/login") // 사용자 정의 로그인 페이지, default: /login
.loginProcessingUrl("/login") // 로그인 Form Action Url, default: /login
.defaultSeccessUrl("/")
.permitAll()
loginProcessingUrl은 Spring Security가 Form인증을 처리할 경로로 Spring MVC 및 컨트롤러에 요청을 전달하지 않는다.
-
logout
- view source에서 logout
- session 해제 Controller 구현 - SecurityContextHolder에서 context를 직접 가져와 해제해준다.
@GetMapping(value = "/logout") public String logout(HttpServletRequest request, HttpServletResponse response) throws Exception { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null) { new SecurityContextLogoutHandler().logout(request, response, authentication); } return "redirect:/login"; }
-
인증 성공 handler 구현
SimpleUrlAuthenticationSuccessHandler 을 상속받아 구현 - 인증 성공 후 페이지 이동 기능 구현
@Component
@Slf4j
public class FormAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private RequestCache requestCache = new HttpSessionRequestCache();
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Override
public void onAuthenticationSuccess(final HttpServletRequest request, final HttpServletResponse response, final Authentication authentication) throws IOException {
setDefaultTargetUrl("/");
SavedRequest savedRequest = requestCache.getRequest(request, response);
if (savedRequest != null) {
String targetUrl = savedRequest.getRedirectUrl();
redirectStrategy.sendRedirect(request, response, targetUrl);
} else {
redirectStrategy.sendRedirect(request, response, getDefaultTargetUrl());
}
}
}
- 인증 실패 handler 구현
SimpleUrlAuthenticationFailureHandler 상속 받아 구현 - exception이 난 경우 에러 처리 구현
@Component
public class FormAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Override
public void onAuthenticationFailure(final HttpServletRequest request, final HttpServletResponse response, final AuthenticationException exception) throws IOException, ServletException {
String errorMessage = "Invalid Username or Password";
if (exception instanceof BadCredentialsException) {
errorMessage = "Invalid Username or Password";
} else if (exception instanceof DisabledException) {
errorMessage = "Locked";
} else if (exception instanceof CredentialsExpiredException) {
errorMessage = "Expired password";
}
setDefaultFailureUrl("/login?error=true&exception=" + errorMessage);
super.onAuthenticationFailure(request, response, exception);
}
}
- 인증 거부 처리
인증은 성공 했지만 권한이 없는 경우
AccessDeniedHandler 을 상속받아 구현한다.
- 인가 예외는 AbstractSecurityInterceptor에서 발생
- exception 처리는 ExceptionTranslationFilter의 AccessDeniedHandler에서 처리
@Component
public class FormAccessDeniedHandler implements AccessDeniedHandler {
private String errorPage;
private ObjectMapper mapper = new ObjectMapper();
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
if (WebUtil.isAjax(request)) {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(this.mapper.writeValueAsString(ResponseEntity.status(HttpStatus.FORBIDDEN)));
} else {
String deniedUrl = errorPage + "?exception=" + accessDeniedException.getMessage();
redirectStrategy.sendRedirect(request, response, deniedUrl);
}
}
public void setErrorPage(String errorPage) {
if ((errorPage != null) && !errorPage.startsWith("/")) {
throw new IllegalArgumentException("errorPage must begin with '/'");
}
this.errorPage = errorPage;
}
}
Ajax 인증
AjaxAuthenticationFilter -> AjaxAuthenticationToken -> AuthenticationManager -> AjaxAuthenticationProvider 순으로 인증처리 흐름
1. Ajax 요청 처리 할 Filter 추가
AbstractAuthenticationProcessingFilter을 상속받아 AjaxAuthenticationFilter 구현
- AbstractAuthenticationProcessingFilter는 Form 로그인에서 UsernamePassowordAuthenticationFilter가 상속받던 부모 객체로, 맨 처음 인증 처리를 해주는 Filter 추상 클래스이다.
- 필터 적용 조건 1) url 요청 정보 매칭, 2) 헤더에 X-Requested-With으로 Ajax요청 확인
- ObjectMapper을 이용해 request.getReader()로 요청 정보를 읽어서 AccountDto로 매핑
- AjaxAuthenticationToken에 담아 AuthenticationManager의 authenticate 인증 메서드로 전달하여 인증 처리
public class AjaxLoginProcessingFilter extends AbstractAuthenticationProcessingFilter {
private ObjectMapper objectMapper = new ObjectMapper();
public AjaxLoginProcessingFilter() {
super(new AntPathRequestMatcher("/ajaxLogin", "POST"));
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException, IOException {
if (!HttpMethod.POST.name().equals(request.getMethod()) || !WebUtil.isAjax(request)) {
throw new IllegalArgumentException("Authentication method not supported");
}
AccountDto accountDto = objectMapper.readValue(request.getReader(), AccountDto.class);
if (StringUtils.isEmpty(accountDto.getUsername()) || StringUtils.isEmpty(accountDto.getPassword())) {
throw new AuthenticationServiceException("Username or Password not provided");
}
AjaxAuthenticationToken token = new AjaxAuthenticationToken(accountDto.getUsername(), accountDto.getPassword());
return this.getAuthenticationManager().authenticate(token);
}
}
2. AjaxAuthenticationToken객체 생성
인증 정보를 담는 Token 객체 생성, Token 객체를 공통으로 구현하는 AbstractAuthenticationToken 추상 클래스를 상속해 구현
- oken의 구성정보를 직접 손댈 필요는 없기 때문에, UsernamePasswordAuthenticationToken.class의 내용을 그대로 복사
public class AjaxAuthenticationToken extends AbstractAuthenticationToken {
private final Object principal;
private Object credentials;
public AjaxAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
setAuthenticated(false);
}
public AjaxAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true);
}
@Override
public Object getCredentials() {
return this.credentials;
}
@Override
public Object getPrincipal() {
return this.principal;
}
}
추가로 설정한 Filter를 사용하기 위해선 AuthenticationManager에 등록(set) 시켜야 한다.
// AuthenticationManager bean override
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public AjaxLoginProcessingFilter ajaxLoginProcessingFilter() throws Exception {
AjaxLoginProcessingFilter ajaxLoginProcessingFilter = new AjaxLoginProcessingFilter();
ajaxLoginProcessingFilter.setAuthenticationManager(authenticationManagerBean()); // 등록
return ajaxLoginProcessingFilter;
}
3. 인증 처리 할 AjaxAuthenticationProvider 구현
Provider는 Form 방법과 동일하게 작성 (반환 시 AjaxAuthenticationToken)
4. 인증 성공, 실패 처리 핸들러 구현
성공, 실패 handler 처리는 Form과 동일하게 생성 내용은 같아 생략
설정한 handler함수들은 위에서 사용한 ajaxLoginProcessingFilter를 사용해서 AuthenticationManager에 등록시킨다.
(ProcessingFilter에서 set 메서드들을 이용하여 handler들을 추가)
5. 인가 설정
인가 처리는 FilterSecurityInterceptor가 수행
- 인증을 받지 않는 익명 사용자의 접근처리
- 인증을 받은 사용자의 권한 정보 처리
에 대한 처리를 한다.
5.1) 인증을 받지 않는 익명 사용자의 접근처리
AuthenticationEntryPoint를 상속받은 구현체 구현 / 익명 사용자가 인증이 필요한 자원에 접근한 경우, commence 메서드가 호출됨
public class AjaxLoginAuthenticationEntryPoint implements AuthenticationEntryPoint {
//익명 사용자가 인증이 필요한 자원에 접근한 경우, commence 메서드가 호출됨
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "UnAuthorized");
}
}
5.2) 인증을 받은 사용자의 권한 정보 처리
AccessDeniedHandler를 상속받고, handler 메서드를 Override 해준다.
구현체를 모두 작성하였으면 설정 파일에 authenticationEntryPoint, accessDeniedHandler 메서드를 추가
각 객체들을 그대로 사용할 것이기 때문에 따로 Bean으로 만들지 않고 new 키워드를 통해 바로 생성
6. Bean 설정 및 Ajax용 SecurityConfig 설정 추가
- Ajax용 Config가 Form Config보다 먼저 로드되게 설정 -> @Order(0)
// config
http
.addFilterBefore(ajaxLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class) //기존 필터 앞에 위치할때 사용하는 method
.authenticationEntryPoint(new AjaxLoginAuthenticationEntryPoint())
.accessDeniedHandler(new AjaxAccessDeniedHandler())
http.csrf().disable() //기본적으로 csrf 토큰값을 들고 요청을 해야되는데, 임시적으로 off 한다.
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public AjaxLoginProcessingFilter ajaxLoginProcessingFilter() throws Exception {
AjaxLoginProcessingFilter ajaxLoginProcessingFilter = new AjaxLoginProcessingFilter();
ajaxLoginProcessingFilter.setAuthenticationManager(authenticationManagerBean());
//인증 성공, 실패 시 handler 처리
ajaxLoginProcessingFilter.setAuthenticationSuccessHandler(ajaxAuthenticationSuccessHandler());
ajaxLoginProcessingFilter.setAuthenticationFailureHandler(ajaxAuthenticationFailureHandler());
return ajaxLoginProcessingFilter;
}
DSLs
강의에선 dsl을 사용해 Configurer 클래스를 생성해 Config 부분을 나눠
filter, handler, 환경설정 메서드의 set을 만든다
공식 문서에서는 AbstractHttpConfigurer를 상속받아 설명하는데
강의에서는 AbstractHttpConfigurer를 상속받은 AbstractAuthenticationFilterConfigurer를 상속받아 Configurer를 구현한다.
사실 이해하기 힘들어서 해당 부분은 코드 읽고 넘어간다.
추후에 config파일을 나눌 일이 있으면 해당 document를 참고해서 작성하자
Spring Security 6.0.1 document
Spring Security 5.3.2 document - 토리맘의 한글라이즈 프로젝트
Ajax Login, CSRF 설정
csrf token값은 header에 meta태그나 form태그의 hidden속성을 주고 ajax요청 시 csrfHeader에 csrfToken을 함께 보내준다.
- csrf Filter는 disable 처리를 하지 않는 한 자동 처리
- 헤더에 토큰을 포함 시킬 경우 X-CSRF-TOKEN, X-XSRF-TOKEN
- form에 담을 경우 파라미터 명 _csrf
추가로 CSRF토큰에 대해 잘 정리해둔 블로그 글이 있어 링크 남긴다.
[스프링 Security] CSRF 토큰 이야기 - 그래서 개발자는 뭘 하면 되죠
인가 프로세스 DB연동
강의 진행을 위해 설정 클래스(config)에서 관련된 코드 모두 제거
URL 방식 처리
URL 방식 기본 동작 원리
FilterSecurityInterceptor가 AccessDecisionManager에게 인증, 요청, 권한정보를 담아서 전달해주어 인증 및 인가처리
-
FilterChainProxy에 있는 FilterSecurityInterceptor 에서 사용자는 요청받은 url정보 처리
-
FilterInvocationSecurityMetadataSource의 RequestMap객체를 확인하여 url에 매핑되어 있는 권한 정보를 확인
key | value |
---|---|
/user | ROLE_ADMIN, ROLE_USER |
/configs | ROLE_ADMIN |
-
권한 정보가 있다면 AccessDecisionManager로 **Authentication, FilterInovation, List
**를 넘겨 인가처리 -
매핑 정보가 없다면 어떤 권한으로 접근해도 허용
FilterInvocationSecurityMetadataSource
Url 방식으로 처리할 땐 FilterInvocationSecurityMetadataSource 구현
- Url 자원에 대한 권한 정보 추출
- AccessDecisionManager에게 전달 해 인가처리 수행
- DB로 부터 자원 및 권한정보 매핑해 Map으로 관리
- 사용자의 요청마다 요청정보에 매핑된 권한 정보 확인
UrlFilterInvocationMetadataSource 파일 생성
-> getAttributes() 메서드만 구현 / 나머지 DefaultFilterInvocationSecurityMetadataSource 복사해 사용
public class UrlFilterInvocationMetadataSource implements FilterInvocationSecurityMetadataSource {
private LinkedHashMap<RequestMatcher, List<ConfigAttribute>> requestMap = new LinkedHashMap<>();
@Override
public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
HttpServletRequest request = ((FilterInvocation) o).getRequest();
requestMap.put(new AntPathRequestMatcher("/mypage"), Arrays.asList(new SecurityConfig("ROLE_USER"))); // null로 넘어가서 test 용도로 사용
if(!Objects.isNull(requestMap)) {
return requestMap.entrySet().stream().map(entry -> {
RequestMatcher matcher = entry.getKey();
if(matcher.matches(request)){
return entry.getValue(); //권한 정보
} else {
return null;
}
})
.filter(Objects:nonNull)
.flatMap(Collection::stream)
.collect(Collectors.toList());
}
return null;
}
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
Set<ConfigAttribute> allAttributes = new HashSet();
Iterator var2 = this.requestMap.entrySet().iterator();
while(var2.hasNext()) {
Map.Entry<RequestMatcher, Collection<ConfigAttribute>> entry = (Map.Entry)var2.next();
allAttributes.addAll((Collection)entry.getValue());
}
return allAttributes;
}
@Override
public boolean supports(Class<?> clazz) {
return FilterInvocation.class.isAssignableFrom(clazz);
}
}
getAttributes()
FilterInvocationSecurityMetadataSource가 나중에 FilterSecurityInterceptor에 의해 호출되는데 이 때 사용되는
url Mapping 정보인 requestMap 객체를 정의
Config 설정
// security config
http.addFilterBefore(customFilterSecurityInterceptor(), FilterSecurityInterceptor.class)
@Bean
public FilterSecurityInterceptor customFilterSecurityInterceptor() throws Exception {
FilterSecurityInterceptor filterSecurityInterceptor = new FilterSecurityInterceptor();
filterSecurityInterceptor.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource());
filterSecurityInterceptor.setAccessDecisionManager(affirmativeBased()); // 인가 방식 종류 중 affirmativeBased 사용 - 가장 많이 사용하는 방식
filterSecurityInterceptor.setAuthenticationManager(authenticationManagerBean());
return filterSecurityInterceptor;
}
private AccessDecisionManager affirmativeBased() {
return new AffirmativeBased(getAccessDecisionVoters());
}
private List<AccessDecisionVoter<?>> getAccessDecisionVoters() {
return Arrays.asList(new RoleVoter());
}
@Bean
public FilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource() {
return new UrlFilterInvocationMetadataSource();
}
-
custom Interceptor가 먼저 동작하게 config 파일 설정
-
customFilterSecurityInterceptor를 Bean으로 등록시킨다.
추가로 Bean으로 등록시키는 경우는 custom해서 작성한 객체 사용 시 Bean, 기존에 등록된 객체 사용 시 해당 객체를 꺼내와 return
AccessDecisionManager 전략 중 affirmativeBased를 사용하는데 해당 설정은
url에 매칭되는 권한 리스트 중 1개만 존재해도 인가 해주는 전략
requestMap DB 연동
UrlResourcesMapFactoryBean (권한/자원 정보를 ResurceMap 생성)
User정보를 DB로부터 가져온 권한/자원 정보를 ResurceMap만드는 구현체 (Bean으로 등록)
public class UrlResourcesMapFactoryBean implements FactoryBean<LinkedHashMap<RequestMatcher, List<ConfigAttribute>>> {
private SecurityResourceService securityResourceService;
private LinkedHashMap<RequestMatcher, List<ConfigAttribute>> resourceMap;
public void setSecurityResourceService(SecurityResourceService securityResourceService) {
this.securityResourceService = securityResourceService;
}
@Override
public LinkedHashMap<RequestMatcher, List<ConfigAttribute>> getObject() throws Exception {
if (resourceMap == null) {
init();
}
return resourceMap;
}
private void init() {
resourceMap = securityResourceService.getResourceList();
}
@Override
public Class<?> getObjectType() {
return LinkedHashMap.class;
}
@Override
public boolean isSingleton() {
return true;
}
}
- <LinkedHashMap<RequestMatcher, List
> 부분은 role - resource 정보를 불러오기 위해 사용
requestMap.put(new AntPathRequestMatcher("/mypage"), Arrays.asList(new SecurityConfig("ROLE_USER")));
위와 같은 느낌으로
즉, 해당 클래스의 역할은 DB로 부터 가져오는 Service객체를 생성해주고 정보를 가져와 FilterInvocationMetadataSource로 전달해주는 역할
Resource를 가져오는 Service를 생성
@RequiredArgsConstructor
public class SecurityResourceService {
private final ResourcesRepository resourcesRepository;
private final RoleResourceRepository roleResourceRepository;
public LinkedHashMap<RequestMatcher, List<ConfigAttribute>> getResourceList() {
LinkedHashMap<RequestMatcher, List<ConfigAttribute>> result = new LinkedHashMap<>();
List<Resource> resources = resourcesRepository.findAll();
resources.forEach(resource -> {
List<ConfigAttribute> configAttributes = new ArrayList<>();
Long resourceId = resource.getId();
List<RoleResource> roleResources = roleResourceRepository.findAllByResourceId(resourceId);
roleResources.forEach(roleResource -> {
configAttributes.add(new SecurityConfig(roleResource.getRole().getRoleName())); //ConfigAttribute 타입의 구현체인 SecurityConfig를 넣어준다.
result.put(new AntPathRequestMatcher(resource.getResourceName()), configAttributes);
});
});
return result;
}
}
Config 파일 설정
작성한 service와 service객체를 만들어주는 Factory Bean을 Security Config 파일에 등록
이전엔 UrlFilterInvocationSecurityMetadataSource를 그대로 사용했는데 urlResourcesMapFactoryBean을 사용하게 변경
@Bean
public UrlFilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource() throws Exception {
return new UrlFilterInvocationSecurityMetadataSource(urlResourcesMapFactoryBean().getObject(), securityResourceService);
}
private UrlResourcesMapFactoryBean urlResourcesMapFactoryBean() {
UrlResourcesMapFactoryBean urlResourcesMapFactoryBean = new UrlResourcesMapFactoryBean();
urlResourcesMapFactoryBean.setSecurityResourceService(securityResourceService);
return urlResourcesMapFactoryBean;
}
}
DB 접근 권한 실시간 반영
DB에 자원/권한 정보를 업데이트 시, 실시간으로 ResourceMap에 업데이트
UrlFilterInvocationMetadataSource에 reload 메서드 추가
public void reload() {
LinkedHashMap<RequestMatcher, List<ConfigAttribute>> reloadedMap = securityResourceService.getResourceList();
Iterator<Map.Entry<RequestMatcher, List<ConfigAttribute>>> iterator = reloadedMap.entrySet().iterator();
requestMap.clear(); //기존 정보를 지움
while (iterator.hasNext()) {
Map.Entry<RequestMatcher, List<ConfigAttribute>> entry = iterator.next();
requestMap.put(entry.getKey(), entry.getValue());
}
}
Controller 수정
@PostMapping(value="/admin/resource/register")
public String createResource(RoleResourcesPo roleResourcesPo) throws Exception {
ModelMapper modelMapper = new ModelMapper();
RoleResourcesDto roleResourcesDto = modelMapper.map(roleResourcesPo, RoleResourcesDto.class);
resourcesService.createRoleAndResources(roleResourcesDto);
urlFilterInvocationMetadataSource.reload(); // 자원 생성 시 reload 추가
return "redirect:/admin/resources";
}
자원 추가 시 urlFilterInvocationMetadataSource를 reload하는 구문을 추가한다.
허용 필터
인증 및 권한심사, 허용 IP -> ACCESS_ABSTAIN return / 허용 x IP -> 즉시 예외 처리 해 자원 접근 거부를 할 필요가 없는 자원 (/, /home, login …) 들을 미리 설정해서 바로 리소스 접근이 가능하게 하는 필터
인가 처리 과정
FilterSecurityInterceptor -> AbstractSecurityInterceptor -> List<ConfigAttribute> (null이 아닐 경우) -> AccessDecisionManager
FilterSecurityInterceptor를 상속받아 구현한다.
permitAllFilter라는 이름을 가진 구현체를 구현하는데
url list (List
-
FilterSecurityInterceptor를 상속받아 invoke함수 사용
-
PermitAllFilter에서 객체 생성 시 url list를 받을 수 있게 생성자에서 requestMatchers에 대한 list를 생성
-
url list를 검사 할 beforeInvocation 함수 작성
-
이전 config 설정에서 FilterSecurityInterceptor를 사용한 부분을 PermitAllFilter를 사용하게 수정한다.
-
PermitAllFilter에서 사용하게 될 url을 Config 파일에서 String 배열로 만들어 넣어준다.
(추후에 적용시킬 경우 DB로 불러와 적용시켜도 좋을 것 같다.)
public class PermitAllFilter extends FilterSecurityInterceptor {
private static final String FILTER_APPLIED = "__spring_security_filterSecurityInterceptor_filterApplied";
private boolean observeOncePerRequest = true;
private List<RequestMatcher> permitAllRequestMatchers = new ArrayList<>();
public PermitAllFilter(String... permitAllResources) {
for (String resource : permitAllResources) {
permitAllRequestMatchers.add(new AntPathRequestMatcher(resource));
}
}
@Override
protected InterceptorStatusToken beforeInvocation(Object object) {
boolean permitAll = false;
HttpServletRequest request = ((FilterInvocation) object).getRequest();
for (RequestMatcher requestMatcher : permitAllRequestMatchers) {
if (requestMatcher.matches(request)) {
permitAll = true;
break;
}
}
if (permitAll) {
return null;
}
return super.beforeInvocation(object);
}
public void invoke(FilterInvocation fi) throws IOException, ServletException {
if ((fi.getRequest() != null)
&& (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
&& observeOncePerRequest) {
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
} else {
if (fi.getRequest() != null && observeOncePerRequest) {
fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
}
InterceptorStatusToken token = beforeInvocation(fi);
try {
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
} finally {
super.finallyInvocation(token);
}
super.afterInvocation(token, null);
}
}
}
계층 권한 적용
RoleHierarchyService를 만들어 Config에 적용시킨다
- entity, repository, Service 구현 RoleHierarchyService 에서 Spring security에서 지정한 규칙대로 String으로 반환
Hierarchical Roles - Spring security document
- getAccessDecisionVoters에서 기본 RoleVoter를 사용하던 부분을 RoleHierarchyVoter가 설정되게 변경
// entity
@Entity
@Table(name = "ROLE_HIERARCHY")
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
@Builder
public class RoleHierarchy implements Serializable {
@Id
@GeneratedValue
private Long id;
@Column(name = "child_name")
private String childName;
@ManyToOne(cascade = {CascadeType.ALL}, fetch = FetchType.LAZY)
@JoinColumn(name = "parent_name", referencedColumnName = "child_name")
private RoleHierarchy parentName;
@OneToMany(mappedBy = "parentName", cascade = {CascadeType.ALL})
private Set<RoleHierarchy> roleHierarchy = new HashSet<RoleHierarchy>();
}
// repository
public interface ResourcesRepository extends JpaRepository<Resources, Long> {
Resources findByResourceNameAndHttpMethod(String resourceName, String httpMethod);
@Query("select r from Resources r join fetch r.roleSet where r.resourceType = 'url' order by r.orderNum desc")
List<Resources> findAllResources();
@Query("select r from Resources r join fetch r.roleSet where r.resourceType = 'method' order by r.orderNum desc")
List<Resources> findAllMethodResources();
@Query("select r from Resources r join fetch r.roleSet where r.resourceType = 'pointcut' order by r.orderNum desc")
List<Resources> findAllPointcutResources();
}
// Service
@Bean
public PermitAllFilter customFilterSecurityInterceptor() throws Exception {
PermitAllFilter permitAllFilter = new PermitAllFilter(permitAllResources);
permitAllFilter.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource());
permitAllFilter.setAccessDecisionManager(affirmativeBased());
permitAllFilter.setAuthenticationManager(authenticationManagerBean());
return permitAllFilter;
}
private AccessDecisionManager affirmativeBased() {
AffirmativeBased affirmativeBased = new AffirmativeBased(getAccessDecisionVoters());
return affirmativeBased;
}
private List<AccessDecisionVoter<?>> getAccessDecisionVoters() {
List<AccessDecisionVoter<? extends Object>> accessDecisionVoters = new ArrayList<>();
accessDecisionVoters.add(roleVoter());
return accessDecisionVoters;
}
@Bean
public AccessDecisionVoter<? extends Object> roleVoter() {
RoleHierarchyVoter roleHierarchyVoter = new RoleHierarchyVoter(roleHierarchy());
return roleHierarchyVoter;
}
@Bean
public RoleHierarchyImpl roleHierarchy() {
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
return roleHierarchy;
}
추가로 config에서 사용되는 RoleHierarchy를 전달받아야 하는데 강의에서는 ApplicationRunner의 run메소드를 이용해서 RoleHierarchy객체를 전달한다.
@Component
public class SecurityInitializer implements ApplicationRunner {
@Autowired
private RoleHierarchyService roleHierarchyService;
@Autowired
private RoleHierarchyImpl roleHierarchy;
@Override
public void run(ApplicationArguments args) throws Exception {
String allHierarchy = roleHierarchyService.findAllHierarchy();
roleHierarchy.setHierarchy(allHierarchy);
}
}
config에서도 Bean으로 주입받아도 사용 가능 (어느 타이밍에 하던 상관x)
IP 접속 제한
- Voter에 의한 인가 처리는 AccessDecisionManager가 처리
- 추가적인 Voter 구현체로 IpAddressVoter 구현
IPAddressVoter
- 특정 IP만 접근이 가능하도록 심의하는 Voter
- 가장 먼저 심사, 허용 IP -> ACCESS_ABSTAIN return / 허용 x IP -> 즉시 예외 처리 해 자원 접근 거부
- ACCESS_ABSTAIN로 리턴 하면 일단 해당 Voter에서는 인가를 승인하되, 다음 Voter로 승인을 보류
- ACCESS_DENIED로 넘기면 다른 Voter가 판단할 수 있으므로 exception처리
- 테이블 추가
@Entity
@Table(name = "ACCESS_IP")
@Data
@EqualsAndHashCode(of = "id")
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AccessIp implements Serializable {
@Id
@GeneratedValue
@Column(name = "IP_ID", unique = true, nullable = false)
private Long id;
@Column(name = "IP_ADDRESS", nullable = false)
private String ipAddress;
}
- IpAddressVoter 작성
- AccessDecisionVoter를 상속받는 구현체 작성
- authentication 객체에서 IP에 대한 정보를 꺼내와 IP 조회 로직 구현
@RequiredArgsConstructor
public class IpAddressVoter implements AccessDecisionVoter {
private final SecurityResourceService securityResourceService;
@Override
public boolean supports(ConfigAttribute configAttribute) {
return true;
}
@Override
public boolean supports(Class aClass) {
return true;
}
@Override
public int vote(Authentication authentication, Object o, Collection collection) {
//authentication.getDetails에서 사용자 정보를 얻을 수 있으며, WebAuthenticationDetails 객체로 캐스팅 해줘야 사용 가능
WebAuthenticationDetails details = (WebAuthenticationDetails) authentication.getDetails();
String remoteAddress = details.getRemoteAddress();
List<String> accessIpList = securityResourceService.getAccessIpList();
for (String accessIp : accessIpList) {
if (accessIp.equals(remoteAddress)) {
return ACCESS_ABSTAIN;
}
}
throw new AccessDeniedException("Invalid Ip Address");
}
}
- Security Config 추가
인가 결정을 보류하는 로직이 있기 때문에 반드시 roleVoter 앞에 위치
private List<AccessDecisionVoter<?>> getAccessDecisionVoters() {
List<AccessDecisionVoter<? extends Object>> accessDecisionVoters = new ArrayList<>();
accessDecisionVoters.add(new IpAddressVoter(securityResourceService));
accessDecisionVoters.add(roleVoter());
return accessDecisionVoters;
}
Method 방식 인가 처리
URL별 인가처리가 아닌 메소드 단위로 인가처리 적용 가능
- AOP기반으로 동작 (프록시와 어드바이스)
- 설정 방식 2가지 - 어노테이션 기반, 맵 기반(DB와 연동)
동작 방식
- URL 방식은 Filter가 가로채서 요청 처리
- Proxy객체에 등록된 Advise가 동작해 요청 처리
관련 어노테이션
@PreAuthorize, @PostAuthorize
- SpEL(Spring Expression Language) 지원
- @PreAuthorize(“hasRole(‘ROLE_USER’)and(#account.username == principal.username)”) 같이 작성 가능
- PrePostAnnotationSecurityMetadataSource가 처리
@Secured, @RolesAllowed
- SpEL을 지원X
- @Secured(“ROLE_USER”), @RolesAllowed(“ROLE_USER”) 같이 작성 가능
- SecuredAnnotationSecurityMetadataSource, Jsr250MethodSecurityMetadataSource가 처리
@EnableGlobalMethodSecurity
- 메서드 방식의 인가 처리가 가능하게 해준다.
- 설정 클래스에 선언을 해야 함
- prePostEnabled = true, securedEnabled = true 등과 같이 속성값을 반드시 true로 변경 해야 해당 어노테이션 사용 가능
PrePostAnnotationSecurityMetadataSource 등은 앞서 살펴본 MethodSecurityMetadataSource 객체를 상속하고 있다.
적용 방법
-
config 설정
- Url 처리 방식에서 UrlResourceMap을 관리하는 것과 동일하게 MethodResourcesMapFactoryBean을 생성
- url 방식 config와 Method config와 분리해 관리하기 위해 MethodSecurity클래스를 만들어 구현
- GlobalMethodSecurityConfiguration을 상속받아 작성
// MethodResourcesMapFactoryBean
@Slf4j
public class MethodResourcesMapFactoryBean implements FactoryBean<LinkedHashMap<String, List<ConfigAttribute>>> {
private SecurityResourceService securityResourceService;
private String resourceType;
public void setResourceType(String resourceType) {
this.resourceType = resourceType;
}
public void setSecurityResourceService(SecurityResourceService securityResourceService) {
this.securityResourceService = securityResourceService;
}
private LinkedHashMap<String, List<ConfigAttribute>> resourcesMap;
public void init() {
if ("method".equals(resourceType)) {
resourcesMap = securityResourceService.getMethodResourceList();
} else if ("pointcut".equals(resourceType)) {
resourcesMap = securityResourceService.getPointcutResourceList();
}
}
public LinkedHashMap<String, List<ConfigAttribute>> getObject() {
if (resourcesMap == null) {
init();
}
return resourcesMap;
}
@SuppressWarnings("rawtypes")
public Class<LinkedHashMap> getObjectType() {
return LinkedHashMap.class;
}
public boolean isSingleton() {
return true;
}
}
// MethodSecurityConfig
@Configuration
//@EnableGlobalMethodSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {
@Autowired
private SecurityResourceService securityResourceService;
@Override
protected MethodSecurityMetadataSource customMethodSecurityMetadataSource() {
return mapBasedMethodSecurityMetadataSource();
}
@Bean
public MapBasedMethodSecurityMetadataSource mapBasedMethodSecurityMetadataSource() {
return new MapBasedMethodSecurityMetadataSource(methodResourcesMapFactoryBean().getObject());
}
@Bean
public MethodResourcesMapFactoryBean methodResourcesMapFactoryBean() {
MethodResourcesMapFactoryBean methodResourcesMapFactoryBean = new MethodResourcesMapFactoryBean();
methodResourcesMapFactoryBean.setSecurityResourceService(securityResourceService);
methodResourcesMapFactoryBean.setResourceType("method");
return methodResourcesMapFactoryBean;
}
}
- Config파일에서 resourcesMap으로 하용 할 service, repository 객체 구현
DB 저장 시 저장 방법
- Method에 어노테이션 적용
AOP 방식
ProtectPointcutPostProcessor가 DB정보를 읽어 Bean에 해당하는 TargetClass 정보, Method 정보, ConfigAttribute정보를 읽어 MethodSecurityMetadata에 전달
구현하려면 ProtectPointcutPostProcessor을 상속받아 사용해야 하는데 final로 선언되어 있어 확장이 불가능 -> 강의에선 동일한 기능을 하는 클래스 생성해 기능 구현
Method방식과 동일하게 config 설정, service, repository 구현해 사용
Aop방식은 잘 사용할 것 같지 않아서 추후에 실무에서 사용 시 다시 공부해서 적용해야겠다…
Reference
- 인증(Authentication) vs. 인가(Authorization)
- Spring Security Document
- 스프링 시큐리티 - Spring Boot 기반으로 개발하는 Spring Security
- Spring Security UserDetails, UserDetailsService 란? - 삽질중인 개발자
- Spring Security loginPage Vs loginProcessingURL - stackoverflow
- [스프링 Security] CSRF 토큰 이야기 - 그래서 개발자는 뭘 하면 되죠
- [스프링 시큐리티]23. Method 방식 : 동작방식 및 구조 알아보기 - 컴퓨터 탐험가 찰리