Аналог NavigationHandler в jmix

Добрый день.

jmix: 2.3.2
plugin: 2.3.2-241
idea: IntelliJ IDEA 2024.1.4 (Community Edition)

В cuba-platform был интерфейс NavigationHandler.

С его помощью был реализован следующий сценарий:

  • В приложении был реализован бин кастомной авторизации, который по некоему ключу (далее тикет) находил сессию пользователя и выставлял ее в контекст безопасности:
    public void authorize(UUID ticket) {

        if (Objects.isNull(ticket)) {
            throw new RuntimeException("No ticket");
        }

        //если есть текущая сессия
        Connection connection = App.getInstance().getConnection();
        UserSession userSession = connection.getSession();
        if (Objects.isNull(userSession)) {
            throw new RuntimeException("No session");
        }

        //если текущая сессия соответствует сессии пользователя получившего тикет - выходим
        if (ticketService.check(ticket
                , userSession.getId())) {
            return;
        }

        //системные сессии ругаются если их попытаться выйти
        if (!userSession.isSystem()) {
            connection.logout();
        }

        Credentials credentials = new AnonymousUserCredentials(locale);

        //тикет не для анонимного пользователя
        if (!ticketService.isForAnonymous(ticket)) {
            credentials = new ExternalUserCredentials(cgiswsTicketService.getLogin(ticket)
                    , new Locale("ru"));
        }

        connection.login(credentials);

        ticketService.setWebClientUserSessionId(ticket, connection.getSession().getId());
    }
  • В приложении был реализован бин имплементирующий интерфейс NavigationHandler:
@Component(NavigationHandlerBean.NAME)
@Scope(BeanDefinition.SCOPE_PROTOTYPE)
@Order(NavigationHandler.LOWEST_PLATFORM_PRECEDENCE - 50)
public class NavigationHandlerBean implements NavigationHandler {

    public static final String NAME = "app_NavigationHandlerBean";

    protected static final String PARAM_TICKET = "ticket";

    @Inject
    protected GlobalConfig globalConfig;

    @Inject
    private CustomAuthorizationBean authorizationBean;

    @Override
    public boolean doHandle(NavigationState requestedState, AppUI ui) {

        //получаем значение параметра тикета
        String ticketValue = requestedState.getParams().getOrDefault(PARAM_TICKET, null);
        if (Objects.isNull(ticketValue)) {
            return false;
        }

        //получаем тикет
        UUID ticket = null;
        try {
            ticket = UuidProvider.fromString(ticketValue);
        } catch (Exception e) {
            logger.error("Bad ticket");
        }

        authorizationBean.authorize(ticket);

        return false;
    }
}
  • Пользователь стороннего клиента будучи авторизованным (bearer) в приложении через REST получал тикет;
  • Тикет передавался по ссылке к экрану приложения (клиент открывал экран в iframe на своей стороне) в параметрах запроса (пример: http://localhost:8080/app/somescreen?ticket=тикет);
  • Бин имплементирующий интерфейс NavigationHandler при выполнении метода doHandle выполнял метод login для соединения;
  • Далее требуемый экран открывался уже авторизированным в сессии связанного с тикетом пользователя (соответственно со всеми настройками безопасности данного пользователя).

Есть ли в jmix механизмы позволяющие повторить этот сценарий?

Спасибо.

С уважением, Алексей.

Добрый день, Алексей

Можете попробовать воспользоваться com.vaadin.flow.server.RequestHandler, чтобы реализовать Вашу логику.

Его можно добавлять как к отдельной сессии через com.vaadin.flow.server.VaadinSession#addRequestHandler, так и ко всем сессиям, используя com.vaadin.flow.server.ServiceInitEvent#addRequestHandler.

С уважением,
Дмитрий

Спасибо. Попробую.

С уважением, Алексей.

Добрый день.

Через com.vaadin.flow.server.ServiceInitEvent#addRequestHandler добавил handler:

public class TicketAuthenticationRequestHandler implements RequestHandler {

    /**
     * Наименование свойства - тикет.
     */
    public static final String PARAM_TICKET = "ticket";

    protected final WSAuthorizationBean wsAuthorizationBean;

    public TicketAuthenticationRequestHandler(WSAuthorizationBean wsAuthorizationBean) {
        super();
        this.wsAuthorizationBean = wsAuthorizationBean;
    }

    @Override
    public boolean handleRequest(VaadinSession session
            , VaadinRequest request
            , VaadinResponse response) throws IOException {

        if (!request.getPathInfo().startsWith("/frames")) {
            return false;
        }

        String[] ticketStringArray = request.getParameterMap().get(PARAM_TICKET);

        if (Objects.isNull(ticketStringArray)
                || ticketStringArray.length < 1) {
            //TODO: exception
            return false;
        }

        //тикет
        String ticketString = ticketStringArray[0];
        UUID ticket = UuidProvider.fromString(ticketString);

        //нежно "входим"
        Authentication authentication = wsAuthorizationBean.authorize(ticket
                , Locale.ROOT);

        //предполагается аноним
        if (Objects.isNull(authentication)) {
            session.getSession().removeAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);

            return false;
        }

        //принудительно контекст выставляем сессии
        SecurityContextHelper.setAuthentication(authentication);

        session.getSession().setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY
                , SecurityContextHolder.getContext());

        return false;
    }
}

Handler обращается к wsAuthorizationBean.authorize:

    public Authentication authorize(UUID ticket
            , Locale locale) {
        logger.debug("authorize : ticket -> {}"
                , ticket);

        //тикета нет
        if (Objects.isNull(ticket)) {
            logger.warn("authorize : ticket is null");

            throw new TicketBadException();
        }

        // для упрощения тестирования "входим" админом
        String ticketUsername = "admin";

        // возвращаемся к анонимному доступу
        if (Objects.equals(ticket
                , UuidProvider.fromString("99c4944e-53e2-d849-1589-39e58c222f9c"))) {
            SecurityContextHelper.setAuthentication(null);

            return null;
        }

        String currentUserName = currentAuthentication.getUser().getUsername();

        //если текущая сессия для пользователя тикета
        if (Objects.equals(ticketUsername
                , currentUserName)) {
            return SecurityContextHelper.getAuthentication();
        }

        Authentication authentication = authenticationManager.authenticate(new SystemAuthenticationToken(ticketUsername));

        SecurityContextHelper.setAuthentication(authentication);

        return authentication;
    }

Был добавлен экран c аннотациями @AnonymousAllowed и @Route(value=“frames/info”), в котором через CurrentAuthentication получаем имя текущего пользователя.

В целом вроде все работает и отображается правильно.

Есть уточняющие вопросы:

  1. “Правильный” ли способ используется для возврата к анонимному доступу?
  2. Корректно ли будут инициализироваться сессии (ваадин, спринг) пользователей “вошедших по тикету”?

Спасибо.

С уважением, Алексей.

Добрый день, Алексей.

По поводу первого вопроса:
Обнуление контекста, в целом, является одним из способов возврата к анонимному доступа.

По поводу второго вопроса:
Возможно, вам необходимо пересоздать сессию, чтобы предотвратить Session fixation attack.
Вы можете обратиться в стандартному механизму аутентификации, чтобы ознакомиться, как это реализовано в Jmix: io.jmix.securityflowui.authentication.LoginViewSupport#authenticate

С уважением,
Дмитрий

Добрый день, Дмитрий.

Спасибо, буду смотреть.