Большинство разработчиков имеет только примерное представление о том что происходит внутри Spring Security, что опасно и может привести к появлению уязвимостей.
В этой статье шаг за шагом пройдемся по пути http запроса, что поможет с пониманием настраивать и решать проблемы Spring Security.
@RestController
public class Controller {
@GetMapping
public String get() {
return String.valueOf(System.currentTimeMillis());
}
}
testCompile('io.rest-assured:rest-assured:3.0.2')
apply plugin: 'groovy'
ControllerIT.groovy
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(properties = "security.user.password=pass")
class ControllerIT {
@LocalServerPort
private int serverPort;
@Before
void initRestAssured() {
RestAssured.port = serverPort;
RestAssured.filters(new ResponseLoggingFilter());
RestAssured.filters(new RequestLoggingFilter());
}
@Test
void 'api call without authentication must fail'() {
when()
.get("/")
.then()
.statusCode(HttpStatus.SC_UNAUTHORIZED);
}
}
Request method: GET
Request URI: http://localhost:51213/
HTTP/1.1 401
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Strict-Transport-Security: max-age=31536000 ; includeSubDomains
WWW-Authenticate: Basic realm="Spring"
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Sun, 22 Oct 2017 11:53:00 GMT
{
"timestamp": 1508673180745,
"status": 401,
"error": "Unauthorized",
"message": "Full authentication is required to access this resource",
"path": "/"
}
SpringBootWebSecurityConfiguration
поставляемая spring boot-ом. Внутри этого класса лежит ApplicationNoWebSecurityConfigurerAdapter
который устанавливает дефолты.SecurityProperties
@TestPropertySource(properties = [
"security.user.password=pass",
"security.enable-csrf=true",
"security.sessions=if_required"
])
@Test
void 'api call with authentication must succeed'() {
given()
.auth().preemptive().basic("user", "pass")
.when()
.get("/")
.then()
.statusCode(HttpStatus.SC_OK);
}
(new Exception().getStackTrace().length == 91)
и найдем первое упоминание спрингаfilterChain
springSecurityFilterChain
именно он делает всю работу SS в веб части.DelegatingFilterProxyRegistrationBean
не очень интересен, посмотрим кому он делегирует свою работуFilterChainProxy
. Внутри него происходит несколько интересных вещей.FilterChainProxy#doFilterInternal
. Что здесь происходит? Получаем фильтры, создаем VirtualFilterChain
и запускаем по ним запрос и ответ.List<Filter> filters = getFilters(fwRequest);
...
VirtualFilterChain vfc = new VirtualFilterChain(fwRequest, chain, filters);
vfc.doFilter(fwRequest, fwResponse);
SecurityFilterChain
который совпадет с запросом.private List<Filter> getFilters(HttpServletRequest request) {
for (SecurityFilterChain chain : filterChains) {
if (chain.matches(request)) {
return chain.getFilters();
}
}
return null;
}
OrRequestMatcher
, который попытается сматчить текущий url с хотя бы с одним паттерном из списка.Любой url который совпадет с этим паттернами, по умолчанию не будет защищен SS.Добавим метод:
"/css/**", "/js/**", "/images/**", "/webjars/**", "/**/favicon.ico", "/error"
@GetMapping("css/hello")
public String cssHello() {
return "Hello I'm secret data";
}
@Test
void 'get css/hello must succeed'() {
when()
.get("css/hello")
.then()
.statusCode(HttpStatus.SC_OK);
}
0 = {WebAsyncManagerIntegrationFilter}
1 = {SecurityContextPersistenceFilter}
2 = {HeaderWriterFilter}
3 = {CsrfFilter}
4 = {LogoutFilter}
5 = {BasicAuthenticationFilter}
6 = {RequestCacheAwareFilter}
7 = {SecurityContextHolderAwareRequestFilter}
8 = {AnonymousAuthenticationFilter}
9 = {SessionManagementFilter}
10 = {ExceptionTranslationFilter}
11 = {FilterSecurityInterceptor}
http
.authorizeRequests().anyRequest().authenticated()
.and()
.formLogin()
.and()
.httpBasic();
UsernamePasswordAuthenticationFilter
DefaultLoginPageGeneratingFilter
@PostMapping("post")
public String testPost() {
return "Hello it is post request";
}
@Test
void 'POST without CSRF token must return 403'() {
given()
.auth().preemptive().basic("user", "pass")
.when()
.post("/post")
.then()
.statusCode(HttpStatus.SC_FORBIDDEN);
}
Ant [pattern='/logout', POST] - по умолчанию
handler = {CompositeLogoutHandler}
logoutHandlers = {ArrayList} size = 2
0 = {CsrfLogoutHandler}
1 = {SecurityContextLogoutHandler}
AuthenticationManager
if (headers.get("Authorization").startsWith("Basic")) {
try {
UsernamePasswordAuthenticationToken token = extract(header);
Authentication authResult = authenticationManager.authenticate(token);
} catch (AuthenticationException failed) {
SecurityContextHolder.clearContext();
this.authenticationEntryPoint.commence(request, response, failed);
return;
}
} else {
chain.doFilter(request, response);
}
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
}
public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
boolean supports(Class<?> authentication);
}
Authentication
в ProviderManager
, он перебирает существующие AuthenticationProvider
-ры и проверяет суппортит ли
public boolean supports(Class<?> authentication) {
return (UsernamePasswordAuthenticationToken.class
.isAssignableFrom(authentication));
}
AuthenticationProvider.authenticate
мы уже можем скастить переданный Authentication в нужную реализацию без каст эксепшена. AuthenticationProvider
должен бросить эксепшен, ProviderManager
поймает его и попробует следующий AuthenticationProvider из списка, если ни один AuthenticationProvider не вернет успешную аутентификацию, то ProviderManager пробросит последний пойманный эксепшен.BasicAuthenticationFilter
сохраняет полученный Authentication в SecurityContextHolder SecurityContextHolder.clearContext();
контекст и вызовится AuthenticationEntryPoint.
public interface AuthenticationEntryPoint {
void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException;
}
response.addHeader("WWW-Authenticate", "Basic realm=\"" + realmName + "\"");
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
@GetMapping("customHeader")
public String customHeader(@RequestHeader("x-custom-header") String customHeader) {
return customHeader;
}
@Test
void 'passed x-custom-header must be returned'() {
def sessionCookie = given()
.header("x-custom-header", "hello")
.when()
.get("customHeader")
.then()
.statusCode(HttpStatus.SC_UNAUTHORIZED)
.extract().cookie("JSESSIONID")
given()
.auth().basic("user", "pass")
.cookie("JSESSIONID", sessionCookie)
.when()
.get("customHeader")
.then()
.statusCode(HttpStatus.SC_OK)
.body(equalTo("hello"));
}
chain.doFilter(this.requestFactory.create((HttpServletRequest) req, (HttpServletResponse) res), res);
SecurityContextRepository
с дефолтной реализацией HttpSessionSecurityContextRepository сохраняет SecurityContext в сессию.sessionAuthenticationStrategy.onAuthentication
sessionAuthenticationStrategy = {CompositeSessionAuthenticationStrategy}
delegateStrategies
0 = {ChangeSessionIdAuthenticationStrategy}
1 = {CsrfAuthenticationStrategy}
@Test
void 'JSESSIONID must be changed after login'() {
def sessionCookie = when()
.get("/")
.then()
.statusCode(HttpStatus.SC_UNAUTHORIZED)
.extract().cookie("JSESSIONID")
def newCookie = given()
.auth().basic("user", "pass")
.cookie("JSESSIONID", sessionCookie)
.when()
.get("/")
.then()
.statusCode(HttpStatus.SC_OK)
.extract().cookie("JSESSIONID")
Assert.assertNotEquals(sessionCookie, newCookie)
}
sendStartAuthentication
, внутри которого происходит следующиее:SecurityContextHolder.getContext().setAuthentication(null);
— отчищает SecurityContextHolderrequestCache.saveRequest(request, response);
— сохраняет в requestCache текущий запрос, чтобы RequestCacheAwareFilter было что восстанавливать.authenticationEntryPoint.commence(request, response, reason);
— вызывает authenticationEntryPoint — который записывает в ответ сигнал о том что необходимо произвести аутентификацию (заголовки \ редирект)
if (authenticationTrustResolver.isAnonymous(authentication) ||
authenticationTrustResolver.isRememberMe(authentication)) {
...
} else {
...
}
for (AccessDecisionVoter voter : getDecisionVoters()) {
int result = voter.vote(authentication, object, configAttributes);
switch (result) {
case AccessDecisionVoter.ACCESS_GRANTED:
return;
case AccessDecisionVoter.ACCESS_DENIED:
deny++;
break;
default:
break;
}
}
if (deny > 0) {
throw new AccessDeniedException();
}
checkAllowIfAllAbstainDecisions();
springSecurityFilterChain
— набор фильтров spring security.WebAsyncManagerIntegrationFilter
— Интегрирует SecurityContext с WebAsyncManagerSecurityContextPersistenceFilter
— Ищет SecurityContext в сессии и заполняет SecurityContextHolder если находитHeaderWriterFilter
— Добавляет «security» заголовки в ответCsrfFilter
— Проверяет на наличие сsrf токенаLogoutFilter
— Выполняет logoutBasicAuthenticationFilter
— Производит basic аутентификациюRequestCacheAwareFilter
— Восстанавливает сохраненный до аутентификации запрос, если такой естьSecurityContextHolderAwareRequestFilter
— Оборачивает существущий запрос в SecurityContextHolderAwareRequestWrapperAnonymousAuthenticationFilter
— Заполняет SecurityContext ананонимной аутентификациейSessionManagementFilter
— Выполняет действия связанные с сессиейExceptionTranslationFilter
— Обрабатывает AuthenticationException\AccessDeniedException которые происходят ниже по стеку.FilterSecurityInterceptor
— Проверяет имеет ли текущей пользователь доступ к текущему url.FilterComparator
— здесь можно посмотреть список фильтров и их возможный порядок.AuthenticationManager
— интерфейс, ответственнен за аутентификациюProviderManager
— реализация AuthenticationManager, которая использует внутри использует AuthenticationProvider
AuthenticationProvider
— интерфейс, отвественнен за аутентификаци конкретной реализации Authentication
.SecurityContextHolder
— хранит в себе аутентификацию обычно в ThreadLocal переменной.AuthenticationEntryPoint
— модифицирует ответ, чтобы дать понять клиенту что необходима аутентификация (заголовки, редирект на страницу логина, т.п.)AccessDecisionManager
решает имеет ли Authentication
доступ к какому-то ресурсу.AffirmativeBased
— стратегия используемая AccessDecisionManager по умолчанию.FilterChainIT.groovy
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class FilterChainIT {
@Autowired
FilterChainProxy filterChainProxy;
@Autowired
List<Filter> filters;
@Test
void 'test main filter chain'() {
assertEquals(5, filters.size());
assertEquals(OrderedCharacterEncodingFilter, filters[0].getClass())
assertEquals(OrderedHiddenHttpMethodFilter, filters[1].getClass())
assertEquals(OrderedHttpPutFormContentFilter, filters[2].getClass())
assertEquals(OrderedRequestContextFilter, filters[3].getClass())
assertEquals("springSecurityFilterChain", filters[4].filterName)
}
@Test
void 'test security filter chain order'() {
assertEquals(2, filterChainProxy.getFilterChains().size());
def chain = filterChainProxy.getFilterChains().get(1);
assertEquals(chain.filters.size(), 11)
assertEquals(WebAsyncManagerIntegrationFilter, chain.filters[0].getClass())
assertEquals(SecurityContextPersistenceFilter, chain.filters[1].getClass())
}
@Test
void 'test ignored patterns'() {
def chain = filterChainProxy.getFilterChains().get(0);
assertEquals("/css/**", chain.requestMatcher.requestMatchers[0].pattern);
assertEquals("/js/**", chain.requestMatcher.requestMatchers[1].pattern);
assertEquals("/images/**", chain.requestMatcher.requestMatchers[2].pattern);
}
}
interface Auth {
...
}
public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType().equals(Auth.class);
}
@Override
public Auth resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return toAuth(principal)
}
}
@GetMapping
public String get(Auth auth) {
return "hello " + auth.getId();
}
AbstractAuthenticationToken
, а аутенфикационный фильтр разумно отнаследовать от AbstractAuthenticationProcessingFilter
HttpBasicConfigurer, OpenIDLoginConfigurer
они делают тоже самое.
class MyConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Override
public void configure(HttpSecurity http) throws Exception {
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
MyAuthenticationProvider myAuthenticationProvider = http.getSharedObject(MyAuthenticationProvider.class);
MyAuthenticationFilter filter = new MyAuthenticationFilter(authenticationManager);
http
.authenticationProvider(myAuthenticationProvider)
.addFilterBefore(postProcess(filter), AbstractPreAuthenticatedProcessingFilter.class);
}
}
public class SecurityConfig extends WebSecurityConfigurerAdapter {
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests().anyRequest().authenticated()
.and()
.apply(new MyConfigurer())
}
}
"url/methodOne", "url/methodTwo"
,
authorizeRequests().antMatchers(HttpMethod.GET, "url/**").permitAll().
authorizeRequests().antMatchers(HttpMethod.GET, "url/methodOne", "url/methodTwo").permitAll().
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().
anyRequest()
.authenticated()
.antMatchers("permit_all_url")
.permitAll();
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("ignored_url");
}
К сожалению, не доступен сервер mySQL