Аналог 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

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

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

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

Добрый день.

В итоге получилось следующее:

public class WebsocketTicketAuthenticationRequestHandler extends SynchronizedRequestHandler implements RequestHandler {

    protected final Logger logger = LoggerFactory.getLogger(getClass());

    public static final String PARAM_TICKET = "ticket";
    public static final String PARAM_LOCALE = "locale";

    protected final ApplicationContext applicationContext;
    protected final WSAuthenticationBean wsAuthenticationBean;

    public WebsocketTicketAuthenticationRequestHandler(ApplicationContext applicationContext) {
        super();
        this.applicationContext = applicationContext;
        this.wsAuthenticationBean = this.applicationContext.getBean(WSAuthenticationBean.class);
    }

    @Override
    public boolean synchronizedHandleRequest(VaadinSession session, VaadinRequest request, VaadinResponse response) throws IOException {
        logger.debug("synchronizedHandleRequest : session -> {}, request -> {}, response -> {}"
                , session
                , request
                , response);

        try {
            if (!request.getPathInfo().startsWith(RouteUtil.getRoutePath(request.getService().getContext()
                    , FramesMainView.class))
                    || request.getPathInfo().startsWith(RouteUtil.getRoutePath(request.getService().getContext()
                    , FramesConnectionFailedView.class))
                    || request.getPathInfo().startsWith(RouteUtil.getRoutePath(request.getService().getContext()
                    , FramesConnectionClosedView.class))) {
                return false;
            }

            Map<String, String[]> requestParameterMap = request.getParameterMap();

            String[] ticketStringArray = requestParameterMap.get(PARAM_TICKET);

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

            Locale locale = null;

            try {
                locale = LocaleResolver.resolve(requestParameterMap.get(PARAM_LOCALE)[0]);
            } catch (Exception e) {
                //ничего не делаем
            }

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

            //нежно "входим"
            Authentication authentication = wsAuthenticationBean.authenticate(ticket
                    , locale);

            //предполагается аноним
            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;
        } catch (Exception e) {
            logger.error(e.getMessage()
                    , e);

            redirectOnException(response);

            return true;
        }
    }

    protected void redirectOnException(VaadinResponse response) {
        logger.debug("forwardOnException : response -> {}"
                , response);

        response.setNoCacheHeaders();
        response.setStatus(HttpStatus.TEMPORARY_REDIRECT.value());
        response.setHeader(HttpHeaders.LOCATION
                , RouteUtil.getRoutePath(response.getService().getContext()
                        , FramesConnectionFailedView.class));
    }
@Component(WSAuthenticationBean.NAME)
public class WSAuthenticationBean {

    public static final String NAME = "ws_WSAuthenticationBean";

    protected final Logger logger = LoggerFactory.getLogger(getClass());
    protected final ApplicationContext applicationContext;

    @Autowired
    protected AuthenticationManager authenticationManager;
    @Autowired
    protected CurrentAuthentication currentAuthentication;
    @Autowired
    protected SystemAuthenticator systemAuthenticator;
    @Autowired
    protected UserRepository userRepository;
    @Autowired
    protected CoreProperties coreProperties;
    @Autowired
    protected UiProperties uiProperties;
    @Autowired
    protected SessionHolder sessionHolder;
    @Autowired
    protected SessionAuthenticationStrategy authenticationStrategy;
    @Autowired
    protected SecurityContextRepository securityContextRepository;
    @Autowired
    protected ApplicationEventPublisher applicationEventPublisher;
    @Autowired
    protected WSTicketService ticketService;
    @Autowired
    protected UserService userService;


    public WSAuthenticationBean(ApplicationContext applicationContext) {
        super();
        logger.info("{} : power on"
                , getClass().getSimpleName());
        this.applicationContext = applicationContext;
    }

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

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

            throw new WSTicketBadException();
        }

        //тикет невалиден или не подключен
        if (!ticketService.check(ticket)
                || !ticketService.isWebSocketConnected(ticket)) {
            logger.warn("authorize : ticket not valid/connected");

            throw new WSTicketNotConnectedException();
        }

        //мы в той же сессии
        if (ticketService.checkWebClientSessionId(ticket
                , getCurrentVaadinSessionId())) {
            logger.debug("authorize : ticket check success");

            return SecurityContextHelper.getAuthentication();
        }

        String ticketUsername = ticketService.getUsername(ticket);

        String ticketSessionId = ticketService.getWebClientSessionId(ticket);

        List<VaadinSession> vaadinSessions = sessionHolder.getActiveSessionsForUsernames(List.of(ticketUsername)).getOrDefault(ticketUsername
                , new ArrayList<>());
        vaadinSessions.stream()
                .filter(vaadinSession -> Objects.equals(vaadinSession.getSession().getId()
                        , ticketSessionId)
                        && !Objects.equals(vaadinSession.getSession().getId()
                        , getCurrentVaadinSessionId()))
                .forEach(vaadinSession -> {
                    try {
                        systemAuthenticator.runWithUser(ticketUsername
                                , () -> vaadinSession.access(() -> onSessionAccess(vaadinSession)));
                    } catch (Exception e) {
                        String message = String.format("authorize : can not invalidate active session for username [%s] and ticket [%s] with error [%s] -> [%s]"
                                , ticketUsername
                                , ticket
                                , e.getClass()
                                , e.getMessage());
                        logger.error(message);
                    }
                });

        Authentication authentication = null;

        if (Objects.equals(ticketUsername
                , currentAuthentication.getUser().getUsername())) {
            authentication = SecurityContextHelper.getAuthentication();
        }

        if (!ticketService.isForAnonymous(ticket)
                && Objects.isNull(authentication)) {
            VaadinServletRequest request = VaadinServletRequest.getCurrent();

            SystemAuthenticationToken systemAuthenticationToken = new SystemAuthenticationToken(ticketUsername);

            TimeZone timeZone = TimeZone.getDefault();

            try {
                timeZone = TimeZone.getTimeZone(((HasTimeZone) userRepository.loadUserByUsername(ticketUsername)).getTimeZoneId());
            } catch (Exception e) {
                //ничего не делаем
            }

            ClientDetails clientDetails = ClientDetails.builder()
                    .locale(Objects.nonNull(locale)
                            ? locale
                            : getDefaultLocale())
                    .scope(SecurityScope.UI)
                    .sessionId(request.getSession().getId())
                    .timeZone(timeZone)
                    .build();

            systemAuthenticationToken.setDetails(clientDetails);

            authentication = authenticationManager.authenticate(systemAuthenticationToken);

            VaadinSession vaadinSession = VaadinSession.getCurrent();

            try {
                vaadinSession.lock();

                preventSessionFixation(authentication);

                onSuccessfulAuthentication(authentication
                        , systemAuthenticationToken);
            } catch (Exception e) {
                String message = String.format("authenticate : error on prevent session fixation issue -> %s"
                        , e.getMessage());
                logger.error(message
                        , e);
            } finally {
                try {
                    vaadinSession.unlock();
                } catch (Exception e) {
                    logger.warn("authenticate : error with vaadin session unlock -> {}"
                            , e.getMessage());
                }
            }
        }

        SecurityContextHelper.setAuthentication(authentication);

        ticketService.setWebClientSessionId(ticket
                , getCurrentVaadinSessionId());

        return authentication;
    }

    protected String getCurrentVaadinSessionId() {
        try {
            return VaadinRequest.getCurrent().getWrappedSession().getId();
        } catch (Exception e) {
            logger.warn("getCurrentVaadinSessionId : no session id -> {}"
                    , e.getMessage());
            return null;
        }
    }

    protected void onSessionAccess(VaadinSession session) {
        logger.debug("onSessionAccess : session -> {}"
                , session);

        //перенаправляем все открытые интерфейсы на экран отображающий закрытие сессии
        session.getUIs().forEach(ui -> ui.access(() -> onUIAccess(ui)));

        session.getSession().removeAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
    }

    protected void onUIAccess(UI ui) {
        logger.debug("onUIAccess : ui -> {}"
                , ui);

        if (ui.isClosing()) {
            return;
        }

        ui.navigate(FramesConnectionClosedView.class);
    }

    protected Locale getDefaultLocale() {
        List<Locale> locales = coreProperties.getAvailableLocales();
        return locales.get(0);
    }

    protected void preventSessionFixation(Authentication authentication) {
        if (authentication.isAuthenticated()
                && Objects.nonNull(VaadinRequest.getCurrent())
                && uiProperties.isUseSessionFixationProtection()) {
            VaadinService.reinitializeSession(VaadinRequest.getCurrent());
        }
    }

    protected void onSuccessfulAuthentication(Authentication authentication,
                                              SystemAuthenticationToken systemAuthenticationToken) {
        VaadinServletRequest request = VaadinServletRequest.getCurrent();

        VaadinServletResponse response = VaadinServletResponse.getCurrent();
        request.setAttribute(DEFAULT_PARAMETER
                , false);

        if (authenticationStrategy != null) {
            authenticationStrategy.onAuthentication(authentication
                    , request
                    , response);
        }

        SecurityContextHelper.setAuthentication(authentication);
        securityContextRepository.saveContext(SecurityContextHolder.getContext(), request, response);

        applicationEventPublisher.publishEvent(
                new InteractiveAuthenticationSuccessEvent(authentication
                        , this.getClass()));
    }
}

, где:

  • FramesMainView - дополнительный StandardMainView без каких либо компонентов;
  • FramesConnectionFailedView, FramesConnectionClosedView - экраны с layout=FramesMainView.class

Спасибо.