Давайте рассмотрим настройку SSO в Spring Security с использованием OpenAm.
OpenAm
Для начала нам потребуется сам OpenAm развёрнутый на том домене, который мы хотим покрыть с помощью SSO. Не мудрствуя лукаво воспользуемся готовым докер образом от разработчиков опенсорсной версии OpenAm - https://hub.docker.com/r/openidentityplatform/openam/.
Запустим образ согласно инструкции.
$ docker run -h openam-01.domain.com -p 8080:8080 --name openam-01 openidentityplatform/openam
Поскольку мы будем использовать авторизацию на основе куки iPlanetDirectoryPro, нам необходимо, чтобы OpenAm и наше приложение находились в рамках одного домена.
Пропишем доменные имена OpenAm'а и нашего будущего приложения в /etc/hosts
$ echo '127.0.0.1 openam-01.domain.com' | sudo tee --append /etc/hosts
Зайдём в админку OpenAm'а и произведём дефолную конфигурацию
- Открываем http://openam-01.domain.com:8080/openam
- Видим срашицу, предлагающую нам выбрать конфигурацию. Выбираем дефолтную
- Задаём пароли для админских аккаунтов и ждём. Если в логе в процессе конфигурации выпало NPE, откройте админку в приватном окне и повторите процедуру ещё раз.
- После окончания конфигурации нажимаем кнопочку "Перейти к логину" и логинимся как пользователь AmAdmin с заданным ранее паролем.
Видим перед собой следующий интерфейс:
В целях упрощения статьи не будем настраивать OpenAm на работу с lDap и другими ресурсами, а создадим пользователя вручную.
Жмякаем на Top Level Realm, в открывшемся интерфейсе в меню слева выбираем Subjects и попадаем на страницу Subjects/User, создаём нового пользователя.
Разлогинимся и закончим на этом настройку OpenAm.
Spring Secutiry
Давайте кратко рассмотрим как работает Spring Security
- Запрос, проходя по цепочке фильтров, отлавливается фильтром, настроенный на аутентификацию запросов на определённый URL с определённым типом запроса
- Данный фильтр создаёт объект типа Authentication и направляет его в AuthenticationManager, дефолтной реализацией которого является ProviderManager. Если необходимо, вы всегда можете написать свою реализацию.
- AuthenticationManager возвращает авторизованный объект типа Authentication с вложенными в него дополнительными сведениями о пользователе. ProviderManager делает это с помощью заданного набора AuthenticationProvider'ов, каждый из которых имеет метод
supports
,определяющий применим ли данный провайдер к данной реализации Authentication
- Authentication возвращает то же самое, что и AuthenticationManager, т.е. проводит авторизацию пользователя, устанавливает его роли и другую информацию. Как правило для этого используется интерфейс UserDetailsService или его расширение UserDetailsManager, которые возвращают объект типа UserDetails, содержащий информацию о пользователе.
- В случае неудачной авторизации пробрасывается исключение типа AuthenticationException, в случае удачной возвращается авторизованный объект типа Authentication, который сохраняется в SecurityContext.
Перейдём с созданию проекта
Создадим gradle проект, указав зависимостями Spring Security, Spring Web для тестирования и lombok для упрощения жизни.
build.gradle
plugins {
id 'org.springframework.boot' version '2.1.0.RELEASE'
}
apply plugin: 'java'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'application'
jar {
mainClassName = 'ru.tuneit.OpenAmAuthApp'
}
repositories {
jcenter()
mavenCentral()
}
dependencies {
compile 'org.slf4j:slf4j-api:1.7.21'
compile 'org.projectlombok:lombok:1.18.4'
compile 'org.springframework.boot:spring-boot-starter-security:2.1.0.RELEASE'
compile 'org.springframework.boot:spring-boot-starter-web:2.1.0.RELEASE'
}
Создадим главный класс приложения, в который поместим конфигурацию SpringSecurity
@EnableWebSecurity
@SpringBootApplication
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class OpenAmAuthApp extends WebSecurityConfigurerAdapter {
public static void main(String[] args) {
SpringApplication.run(OpenAmAuthApp.class, args);
}
@Autowired
private AbstractAuthenticationProcessingFilter authenticationProcessingFilter;
@Autowired
private LogoutSuccessHandler logoutSuccessHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.addFilterAt(authenticationProcessingFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeRequests()
.antMatchers("/login", "/logout", "/test/home").permitAll()
.antMatchers("/test/hello").hasAnyAuthority("ROLE_USER")
.and().formLogin().loginPage("/login")
.and().logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
}
@Bean(name = BeanIds.AUTHENTICATION_MANAGER)
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
В данном конфиге мы задаём ограничение доступа к ресурсам, URL для логина и логаута, а так же устанавливаем наш AuthenticationFilter и LogoutSuccessHandler. О них чуть позже.
Бин AuthenticationManager необходим для того, чтобы сринг сам подставил его в наш самописный AuthenticationFilter.
Создадим контроллер для тестирования.
@RestController
@RequestMapping(value = "/test")
public class TestController {
@GetMapping("/hello")
public String hello(Authentication authentication) {
UserDetails currentUser
= (UserDetails) authentication.getDetails();
return "Hello " + currentUser.getUsername() + "!";
}
@GetMapping("/home")
public String home() {
return "Welcome to home page!";
}
}
Создадим собственный AuthenticationToken, который будет содержать только имя пользователя, поскольку с паролем мы никак не взаимодействуем.
public class OpenAmAuthenticationToken extends AbstractAuthenticationToken {
private String username;
public OpenAmAuthenticationToken(String username, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.username = username;
}
public OpenAmAuthenticationToken(String username) {
super(null);
this.username = username;
}
@Override
public Object getCredentials() {
return "";
}
@Override
public Object getPrincipal() {
return username;
}
}
Создадим AuthenticationProvider, который будет затем использован в ProviderManager'е
@Component
public class OpenAmAuthenticationProvider implements AuthenticationProvider {
private UserDetailsManager userDetailsManager;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
if (!userDetailsManager.userExists((String) authentication.getPrincipal())) {
userDetailsManager.createUser(new User((String) authentication.getPrincipal(),
(String) authentication.getCredentials(),
Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"))));
}
UserDetails userDetails = userDetailsManager.loadUserByUsername((String) authentication.getPrincipal());
OpenAmAuthenticationToken auth = new OpenAmAuthenticationToken((String) authentication.getPrincipal(), userDetails.getAuthorities());
auth.setAuthenticated(true);
auth.setDetails(userDetails);
return auth;
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(OpenAmAuthenticationToken.class);
}
@Autowired
public void setUserDetailsService(@Qualifier("openAmUserDetailsManager") UserDetailsManager userDetailsManager) {
this.userDetailsManager = userDetailsManager;
}
}
В данном случае класс User - дефолтная реализация интерфейса UserDetails в спринге.
Как видно, в данном провайдере использовуется самописный UserDetailsManager, приведём его код.
@Component
@Slf4j
public class OpenAmUserDetailsManager implements UserDetailsManager {
private List<UserDetails> userDetails = new ArrayList<>();
@Override
public void createUser(UserDetails user) {
log.info("Creating user with username {}", user.getUsername());
userDetails.add(user);
}
@Override
public void updateUser(UserDetails user) {
}
@Override
public void deleteUser(String username) {
}
@Override
public void changePassword(String oldPassword, String newPassword) {
}
@Override
public boolean userExists(String username) {
return userDetails.stream().anyMatch(u -> u.getUsername().equals(username));
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userDetails.stream().filter(u -> u.getUsername().equals(username)).findFirst().orElse(null);
}
}
В данном менеджере реализованы лишь три метода, название которых говорит само за себя.
Теперь перейдём к основному - фильтру аутентификации, который и будет осуществялть взаимодействие с OpenAm.
Вазимодействие с OpenAm происходит по следующему алгоримту:
- Проверяется наличие куки iPlanetDirectoryPro в запросе, если она есть, переходим к пункту 3. Так же сохранятеся URL, который изначально был запрошен пользователем.
- Если куки нет, происходит редирект пользователя на страницу логина OpenAm с параметром goto, указывающим, куда следует отправить пользователя после авторизации.
- Отправляем запрос в OpenAm для получения информации о пользователе, приложив данную куку. Если сессия в OpenAm истекла, мы получим ответ 401 и переходим к шагу 2.
- Получив ответ от OpenAm, извлекаем имя пользователя и проводим его авторизацию используя написанные ранее классы.
Рассмотрим код фильтра
@Component
public class OpenAmAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private final String OPENAM_LOGIN_URL = "http://openam-01.domain.com:8080/openam/XUI/#login/";
private final String OPENAM_ATTRIBUTES_URL = "http://openam-01.domain.com:8080/openam/identity/json/attributes";
private final String HOME_PAGE_URL = "http://openam-01.domain.com:8888/test/home";
OpenAmAuthenticationFilter() {
super("/login");
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
Optional<Cookie> iPlanetDirectoryPro = Arrays.stream(request.getCookies())
.filter(c -> c.getName().equals("iPlanetDirectoryPro")).findFirst();
SavedRequest savedRequest = new HttpSessionRequestCache().getRequest(request, response);
String redirectUrl = savedRequest == null ? HOME_PAGE_URL : savedRequest.getRedirectUrl();
if (!iPlanetDirectoryPro.isPresent()) {
response.sendRedirect(OPENAM_LOGIN_URL + "&goto=" + redirectUrl);
return null;
} else {
HttpHeaders headers = new HttpHeaders();
headers.add("cookie", iPlanetDirectoryPro.get().getName() + "=" + iPlanetDirectoryPro.get().getValue());
HttpEntity entity = new HttpEntity(headers);
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<OpenAmAttributeResponse> attributesResponse;
try {
attributesResponse = restTemplate.exchange(OPENAM_ATTRIBUTES_URL, HttpMethod.GET, entity, OpenAmAttributeResponse.class);
} catch (HttpClientErrorException e) {
if (e.getStatusCode().equals(HttpStatus.UNAUTHORIZED)) {
response.sendRedirect(OPENAM_LOGIN_URL + "&goto=" + redirectUrl);
return null;
} else {
//Redirect to home page
response.sendRedirect(HOME_PAGE_URL);
return null;
}
}
if (attributesResponse != null && attributesResponse.hasBody()) {
Optional<String> username = Arrays.stream(attributesResponse.getBody().attributes)
.filter(a -> a.name.equals("uid"))
.findFirst()
.map(OpenAmAttribute::getValues)
.map(v -> v[0]);
if (username.isPresent()) {
OpenAmAuthenticationToken authRequest = new OpenAmAuthenticationToken(username.get());
return this.getAuthenticationManager().authenticate(authRequest);
}
}
throw new UsernameNotFoundException("Can't get username");
}
}
@Override
@Autowired
public void setAuthenticationManager(AuthenticationManager authenticationManager) {
super.setAuthenticationManager(authenticationManager);
}
@Data
public static class OpenAmAttributeResponse {
private OpenAmAttribute[] attributes;
}
@Data
private static class OpenAmAttribute {
private String name;
private String[] values;
}
}
В конструкторе задаём URL, на который будет срабатывать наш фильтр. Далее, в методе attemptAuthentication происходит извлечение куки, получение сохранённого запроса и формирование URL для обратного редиректа пользователя. Затем, если кука не была найдена, происходит редирект пользователя на страницу логина OpenAm. Делаем запрос на получение данных пользователя и извлекаем атрибут uid, который содержит имя пользователя. Если имя пользователя найдено, производим его аутентификацию.
Для того, чтобы разлогинить пользователя в конфиге Security мы указали
.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
Эта строка говорит о том, что при запросе на /logout необходимо инвалидировать сессию пользователя и в случае успеха использовать LougoutSuccessHandler, код которого представлен ниже.
@Component
public class OpenAmLogoutSuccessHandler implements LogoutSuccessHandler {
private final String OPENAM_LOGOUT_URL = "http://openam-01.domain.com:8080/openam/XUI/#logout/";
private final String HOME_PAGE_URL = "http://openam-01.domain.com:8888/test/home";
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException {
response.sendRedirect(OPENAM_LOGOUT_URL + "&goto=" + HOME_PAGE_URL);
}
}
Данный хэндлер произведёт редирект пользователя на страницу логаута OpenAm для инвалидации сессии в нём. При редиректе так же указывается параметр goto, определяющий куда направить пользователя после совершения операции.
На это краткий обзор интеграции Spring Security и OpenAm можно закончить.