Архитектура такова: есть интерфейс React + серверное приложение Spring Boot. Файлы внешнего интерфейса помещаются в папку ресурсов Java, а затем обслуживаются Spring Tomcat. В то же время серверная часть предоставляет ряд конечных точек для использования внешним интерфейсом, включая вход в систему, выход из системы, настройку и все виды конечных точек данных. Фронтенд вызывает конечную точку входа с именем пользователя + паролем в теле, чтобы установить файл cookie сеанса, который затем позволит ему вызывать другие конечные точки без предоставления учетных данных каждый раз.
Я нашел это руководство: https:// Spring.io/guides/gs/authenticating-ldap, где они используют комбинацию авторизацииHttpRequests + formLogin. Это хорошее начало, но необходимо выполнить множество настроек, поскольку мой проект является SPA.
Мне удалось заставить работать аутентификацию LDAP, но, к сожалению, я не могу принудительно любая из аутентифицированных точек работает. Все они возвращают статус 403 Forbidden — я погрузился в внутренности Spring гораздо глубже, чем когда-либо хотел, чтобы обнаружить, что сеанс не переносится между запросами. Симптомы следующие:
- файл cookie сеанса, JSESSIONID, не установлен, а ApplicationSessionCookieConfig#createSessionCookie никогда не вызывается,
аутентификация — это AnonumousAuthenticationToken везде — например, AuthorizationFilter#getAuthentication возвращает вышеупомянутый токен, и, таким образом, доступ отказано, - стратегией держателя контекста безопасности является InheritableThreadLocalSecurityContextHolderStrategy – я не знаю, хорошо это или нет
- вызывает request.getSession(false), который возвращает значение null — сеанс не существует
Код: Выделить всё
HttpSessionSecurityContextRepository#loadDeferredContext
- в конце, поскольку нет аутентификации< /code> в SecurityContextHolder, срабатывает AnonymousAuthenticationFilter.
Код: Выделить всё
package com.visiona.suka.gui.adapter.in.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.Filter;
import java.util.List;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.ldap.core.support.LdapContextSource;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.ObjectPostProcessor;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.ldap.userdetails.DefaultLdapAuthoritiesPopulator;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.Http403ForbiddenEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.session.ChangeSessionIdAuthenticationStrategy;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.header.HeaderWriterFilter;
import static com.visiona.suka.gui.adapter.in.web.CspFilter.NONCE_PLACEHOLDER;
import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfiguration {
static final String LOGIN_ENDPOINT_PATH = "/v1/login";
static final String LOGOUT_ENDPOINT_PATH = "/v1/logout";
private static final org.slf4j.Logger LOG = LoggerFactory.getLogger(SecurityConfiguration.class);
private static final String DEFAULT_CSP_PATTERN =
"default-src 'self'; style-src 'self' 'nonce-" + NONCE_PLACEHOLDER + "'";
private static final String CONFIGURATION_PATH = "/v1/configuration";
private final ObjectMapper objectMapper = new ObjectMapper();
@Value("${adapter.in.web.cspPattern:" + DEFAULT_CSP_PATTERN + "}")
public String cspPattern;
@Value("${adapter.in.web.csrfProtection.enabled:true}")
private boolean isCsrfProtectionEnabled;
@Value("${server.ssl.enabled:false}")
private boolean isHttpsEnabled;
private static AuthenticationManager createAndSetAuthenticationManager(HttpSecurity http) throws Exception {
AuthenticationManagerBuilder authenticationManagerBuilder =
http.getSharedObject(AuthenticationManagerBuilder.class);
AuthenticationManager authenticationManager = authenticationManagerBuilder.build();
http.setSharedObject(AuthenticationManager.class, authenticationManager);
http.authenticationManager(authenticationManager);
return authenticationManager;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
Http403ForbiddenEntryPoint entryPoint = new Http403ForbiddenEntryPoint();
AuthenticationManager authenticationManager = createAndSetAuthenticationManager(http);
configureCsrf(http);
http.headers((headers) ->
headers.xssProtection(HeadersConfigurer.XXssConfig::disable) // Most modern browsers don't support it
.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny) // Block iframes
.httpStrictTransportSecurity(httpStrictTrans ->
httpStrictTrans.includeSubDomains(true)
.maxAgeInSeconds(31_536_000)
)
).httpBasic(basic -> basic
.authenticationEntryPoint(entryPoint)
.withObjectPostProcessor(new ObjectPostProcessor() {
@Override
public O postProcess(O authenticationFilter) {
authenticationFilter.setAuthenticationConverter(new JsonBasicAuthenticationConverter());
return authenticationFilter;
}
})
// .securityContextRepository()
// ).sessionManagement(session -> session
// .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
// .addSessionAuthenticationStrategy(new CsrfAuthenticationStrategy())
// .addSessionAuthenticationStrategy(new ChangeSessionIdAuthenticationStrategy())
// ).securityContext((securityContext) -> securityContext
// .requireExplicitSave(true)
//this is default anyway:
// .securityContextRepository(new DelegatingSecurityContextRepository(
// new RequestAttributeSecurityContextRepository(),
// new HttpSessionSecurityContextRepository()
// ))
).authorizeHttpRequests(requests -> requests
//note: ordering of matchers matters - more specific must come first
.requestMatchers(antMatcher(HttpMethod.POST, LOGIN_ENDPOINT_PATH)).permitAll()
.requestMatchers(antMatcher(HttpMethod.POST, LOGOUT_ENDPOINT_PATH)).permitAll()
.requestMatchers(antMatcher(HttpMethod.GET, CONFIGURATION_PATH)).permitAll()
.requestMatchers("/v1/**/*").authenticated()
.requestMatchers(antMatcher(HttpMethod.GET, "/**")).permitAll()
.anyRequest().authenticated()
).exceptionHandling(exceptionHandling ->
exceptionHandling.authenticationEntryPoint(entryPoint)
// );
).formLogin(customizer -> customizer
.loginProcessingUrl("/v1/login")
.successHandler((request, response, authentication) -> {
response.getOutputStream().println(objectMapper.writeValueAsString(authentication.getAuthorities()));
})
.failureHandler((request, response, authentication) -> {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
})
.permitAll()
).logout(customizer -> customizer
.logoutUrl(LOGOUT_ENDPOINT_PATH)
.logoutSuccessHandler((request, response, authentication) ->
response.setStatus(HttpStatus.NO_CONTENT.value())
)
).rememberMe(Customizer.withDefaults());
//legit replacement of filters doesn't work, they are overriden at a later stage
// http.addFilterBefore(new JsonUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter
// .class);
// http.addFilter(new JsonUsernamePasswordAuthenticationFilter());
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
DefaultSecurityFilterChain securityFilterChain = http.build();
JsonUsernamePasswordAuthenticationFilter authenticationFilter =
new JsonUsernamePasswordAuthenticationFilter(authenticationManager);
replaceAuthenticationFilter(securityFilterChain, authenticationFilter);
return securityFilterChain;
}
/**
* UsernamePasswordAuthenticationFilter doesn't manage to read credentials from the request.
*/
private static void replaceAuthenticationFilter(DefaultSecurityFilterChain builtChain, JsonUsernamePasswordAuthenticationFilter replacement) {
List filters = builtChain.getFilters();
for (int i = 0; i < filters.size(); i++) {
Filter filter = filters.get(i);
if (filter instanceof UsernamePasswordAuthenticationFilter) {
filters.set(i, replacement);
return;
}
}
}
/**
* This is called before securityFilterChain, so the configuration of security chain already has LDAP manager
* configuration.
*/
@Autowired
public void configure(AuthenticationManagerBuilder auth) throws Exception {
String url = "(censored)";
String base = "(censored)";
String user = "(censored)";
String password = "(censored)";
LdapContextSource contextSource = new LdapContextSource();
contextSource.setUrl(url);
contextSource.setBase(base);
contextSource.setUserDn(user);
contextSource.setPassword(password);
contextSource.afterPropertiesSet();
DefaultLdapAuthoritiesPopulator populator = new DefaultLdapAuthoritiesPopulator(
contextSource,
"");
populator.setGroupSearchFilter("(member={0})");
populator.setGroupRoleAttribute("cn");
populator.setSearchSubtree(true);
populator.setRolePrefix("");
auth
.ldapAuthentication()
.userSearchFilter("distinguishedName={0}")
.ldapAuthoritiesPopulator(populator)
.contextSource(contextSource);
auth
.ldapAuthentication()
.userSearchFilter("cn={0}")
.ldapAuthoritiesPopulator(populator)
.contextSource(contextSource);
auth
.ldapAuthentication()
.userSearchFilter("sAMAccountName={0}")
.ldapAuthoritiesPopulator(populator)
.contextSource(contextSource);
}
private Customizer getCsrfConfigurerCustomizer() {
CookieCsrfTokenRepository cookieCsrfTokenRepository = new CookieCsrfTokenRepository();
cookieCsrfTokenRepository.setCookieCustomizer(responseCookieBuilder -> responseCookieBuilder
.secure(isHttpsEnabled)
.sameSite("Lax")
.httpOnly(false)
);
return csrf -> csrf.csrfTokenRepository(cookieCsrfTokenRepository)
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler());
}
private void configureCsrf(HttpSecurity http) throws Exception {
if (!isCsrfProtectionEnabled) {
http.csrf(AbstractHttpConfigurer::disable);
LOG.warn("Protection against CSRF (Cross-Site Request Forgery also known as XSRF) is disabled");
return;
}
http.csrf(getCsrfConfigurerCustomizer())
.addFilterAfter(new EagerCsrfCookieFilter(), BasicAuthenticationFilter.class);
}
}
Код: Выделить всё
package com.visiona.suka.gui.adapter.in.web;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.HashMap;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import static com.visiona.suka.gui.adapter.in.web.SecurityConfiguration.LOGIN_ENDPOINT_PATH;
public class JsonUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final ObjectMapper objectMapper = new ObjectMapper();
public JsonUsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher(LOGIN_ENDPOINT_PATH,
"POST"));
this.setAuthenticationSuccessHandler((request, response, authentication) -> {
response.getOutputStream().println(objectMapper.writeValueAsString(authentication.getAuthorities()));
});
this.setAuthenticationFailureHandler((request, response, authentication) -> {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
});
}
@Override
protected String obtainUsername(HttpServletRequest request) {
try {
return obtainFieldFromBody(request, this.getUsernameParameter());
} catch (IOException e) {
return null;
}
}
@Override
protected String obtainPassword(HttpServletRequest request) {
try {
return obtainFieldFromBody(request, this.getPasswordParameter());
} catch (IOException e) {
return null;
}
}
private String obtainFieldFromBody(HttpServletRequest request, String fieldName) throws IOException {
TypeReference typeRef
= new TypeReference() {};
HashMap parsedBody = objectMapper.readValue(request.getReader(), typeRef);
return parsedBody.get(fieldName);
}
}
Код: Выделить всё
package com.visiona.suka.gui.adapter.in.web;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.HashMap;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.web.authentication.www.BasicAuthenticationConverter;
public class JsonBasicAuthenticationConverter extends BasicAuthenticationConverter {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public UsernamePasswordAuthenticationToken convert(HttpServletRequest request) {
String username = obtainUsername(request);
String password = obtainPassword(request);
UsernamePasswordAuthenticationToken result = UsernamePasswordAuthenticationToken
.unauthenticated(username, password);
result.setDetails(this.getAuthenticationDetailsSource().buildDetails(request));
return result;
}
protected String obtainUsername(HttpServletRequest request) {
try {
return obtainFieldFromBody(request, "username");
} catch (IOException e) {
return null;
}
}
protected String obtainPassword(HttpServletRequest request) {
try {
return obtainFieldFromBody(request, "password");
} catch (IOException e) {
return null;
}
}
private String obtainFieldFromBody(HttpServletRequest request, String fieldName) throws IOException {
TypeReference typeRef
= new TypeReference() {};
HashMap parsedBody = objectMapper.readValue(request.getReader(), typeRef);
return parsedBody.get(fieldName);
}
}
Подробнее здесь: https://stackoverflow.com/questions/793 ... e-page-app