Spring Security и OpenAm

Давайте рассмотрим настройку 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

  1. Запрос, проходя по цепочке фильтров, отлавливается фильтром, настроенный на аутентификацию запросов на определённый URL с определённым типом запроса
  2. Данный фильтр создаёт объект типа Authentication и направляет его в AuthenticationManager, дефолтной реализацией которого является ProviderManager. Если необходимо, вы всегда можете написать свою реализацию.
  3. AuthenticationManager возвращает авторизованный объект типа Authentication с вложенными в него дополнительными сведениями о пользователе. ProviderManager делает это с помощью заданного набора AuthenticationProvider'ов, каждый из которых имеет метод supports ,определяющий применим ли данный провайдер к данной реализации Authentication
  4. Authentication возвращает то же самое, что и AuthenticationManager, т.е. проводит авторизацию пользователя, устанавливает его роли и другую информацию. Как правило для этого используется интерфейс UserDetailsService или его расширение UserDetailsManager, которые возвращают объект типа UserDetails, содержащий информацию о пользователе.
  5. В случае неудачной авторизации пробрасывается исключение типа 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 происходит по следующему алгоримту:

  1. Проверяется наличие куки iPlanetDirectoryPro в запросе, если она есть, переходим к пункту 3. Так же сохранятеся URL, который изначально был запрошен пользователем.
  2. Если куки нет, происходит редирект пользователя на страницу логина OpenAm с параметром goto, указывающим, куда следует отправить пользователя после авторизации.
  3. Отправляем запрос в OpenAm для получения информации о пользователе, приложив данную куку. Если сессия в OpenAm истекла, мы получим ответ 401 и переходим к шагу 2.
  4. Получив ответ от 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 можно закончить.