diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..86f55109 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,47 @@ +# Version control +.git +.gitignore + +# Docker files themselves +Dockerfile +compose*.yml +.dockerignore +docker/ + +# Build output (rebuilt inside Docker) +**/target/ +**/*.war +**/*.jar +!install/*.jar + +# IDE and editor +.idea/ +*.iml +.vscode/ +*.swp +*.swo + +# AI Factory +.ai-factory/ +.claude/ +AGENTS.md + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Environment files (never bake into image) +.env +.env.* +!.env.example + +# Test artifacts +**/surefire-reports/ +**/failsafe-reports/ + +# Maven wrapper (already COPY'd explicitly if needed) +# Keep mvnw / .mvn in the image for development stage diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..4618d174 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Superfly — Docker environment variables +# Copy to .env and fill in values. Never commit .env to git. +# ───────────────────────────────────────────────────────────────────────────── + +# Project name (used as Docker Compose project prefix) +COMPOSE_PROJECT_NAME=superfly + +# ─── Application ───────────────────────────────────────────────────────────── +APP_PORT=8080 +# For production: pre-built image tag +# APP_IMAGE=registry.example.com/superfly +# APP_VERSION=2.0.0 + +# ─── Database ──────────────────────────────────────────────────────────────── +DB_NAME=sso +DB_USER=sso +DB_PASSWORD=change_me_in_production +DB_ROOT_PASSWORD=change_root_password_in_production +DB_TIMEZONE=UTC + +# Development: expose MySQL port to host +MYSQL_HOST_PORT=3306 diff --git a/.gitignore b/.gitignore index abd0d140..ed51c003 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,20 @@ target .classpath .project .settings + +# Docker secrets — never commit +.env + +# AI Factory / Claude Code — local tooling, not for repo +.ai-factory.json +.ai-factory/ +.claude/ +.cursor/ +.mcp.json +skills-lock.json +PLAN.md +FIX_PLAN.md + +# IDE run configs & generated plan docs — local, not for repo +.run/ +docs/plans/ diff --git a/.run/Start.run.xml b/.run/Start.run.xml deleted file mode 100644 index 49068a73..00000000 --- a/.run/Start.run.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..ca3da0de --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,131 @@ +# AGENTS.md + +> Этот файл — структурная карта проекта для AI-агентов и новых разработчиков. +> Обновлять при значительных изменениях структуры проекта. + +## Обзор проекта + +Superfly — централизованный SSO-сервер для управления пользователями, ролями и правами +доступа в нескольких системах одновременно. Поддерживает Spring Security, Wicket-UI и +EE8/EE10 (javax/jakarta servlet) клиентские библиотеки. + +## Технологический стек + +- **Язык:** Java 21 +- **Фреймворк:** Spring Framework 6.2.18 + Spring Security 6.4.11 +- **Веб-UI:** Apache Wicket 10.6.0 +- **База данных:** MySQL (stored procedures, jdbc-proc) +- **Logging:** SLF4J 2.0 + Logback +- **Сборка:** Maven (multi-module, 20 модулей) +- **CI:** GitHub Actions + +## Структура проекта + +``` +superfly/ +├── pom.xml # корневой POM, версии всех зависимостей +├── AGENTS.md # этот файл +├── README.md # описание проекта +├── mvnw / mvnw.cmd # Maven wrapper +├── .github/workflows/ # GitHub Actions CI +│ └── maven.yml +│ +├── superfly-remote-api/ # REST/RPC API интерфейсы для клиентов +├── superfly-spi/ # SPI-интерфейсы (HOTP, соль и др.) +├── superfly-spi-support/ # Реализации SPI +├── superfly-service/ # Бизнес-логика: пользователи, роли, права +│ └── src/main/java/com/payneteasy/superfly/ +│ ├── dao/ # DAO через jdbc-proc (stored procedures) +│ ├── service/ # Spring @Service — бизнес-операции +│ ├── model/ # Доменные модели и UI-модели (UI* prefix) +│ ├── password/ # Хэширование паролей +│ ├── hotp/ # HOTP/2FA +│ ├── email/ # Email-уведомления +│ └── spring/ # Spring конфигурация, условия +│ +├── superfly-web/ # Веб-приложение (Wicket + Spring MVC, Jetty) +│ └── src/main/java/com/payneteasy/superfly/web/wicket/ +│ ├── page/ # Wicket страницы (users, roles, actions...) +│ ├── component/ # Переиспользуемые Wicket компоненты +│ └── security/ # Security-интеграция для веб-слоя +│ +├── superfly-client-core/ # Клиент без servlet зависимости +├── superfly-client-ee8/ # Клиент для javax.servlet (EE8) +├── superfly-client-ee10/ # Клиент для jakarta.servlet (EE10) +├── superfly-client/ # Клиент (алиас/агрегат) +├── superfly-client-web-security/ # Web security клиент +├── superfly-client-opt/ # Клиент с опциональными фичами +│ +├── superfly-spring-security-core/ # Spring Security integration (core, без servlet) +├── superfly-spring-security-ee8/ # Spring Security для EE8 (javax.servlet) +├── superfly-spring-security/ # Spring Security для EE10 (jakarta.servlet) +│ +├── superfly-wicket/ # Wicket компоненты (EE10) +├── superfly-wicket-ee8/ # Wicket компоненты (EE8) +│ +├── superfly-common/ # Shared utilities +├── superfly-crypto/ # Шифрование, хэширование +├── superfly-httpclient-hc5/ # HTTP client (Apache HC5) + SSL/mTLS хелперы +│ +├── superfly-sql/ # SQL-скрипты и миграции +│ ├── src/ # Stored procedures +│ └── mi/ # Миграции: R/.sql +│ +├── superfly-integration-test/ # Интеграционные тесты (против реальной MySQL) +└── superfly-demo/ # Демо-приложение +``` + +## Ключевые точки входа + +| Файл | Назначение | +|------|-----------| +| `pom.xml` | Корневой POM, версии зависимостей (`spring.version`, `wicket.version` и др.) | +| `superfly-web/src/main/webapp/WEB-INF/jetty-web.xml` | Конфигурация Jetty | +| `superfly-web/src/main/resources/jetty-env.conf` | Переменные окружения Jetty | +| `superfly-service/src/main/java/.../spring/` | Spring Application Context конфигурации | +| `superfly-sql/mi/` | SQL-миграции базы данных | +| `.github/workflows/maven.yml` | CI pipeline | + +## Документация + +| Документ | Путь | Описание | +|----------|------|---------| +| README | README.md | Landing page проекта | +| Установка и запуск | docs/getting-started.md | Требования, сборка, первый запуск | +| Конфигурация | docs/configuration.md | БД, Jetty, Spring конфиги | +| Доменная модель | docs/domain-model.md | Сущности, связи, перечисления | +| API Reference | docs/api.md | RPC и REST endpoints | +| Руководство по интеграции | docs/integration-guide.md | Подключение клиентских приложений | +| Миграция EE8 / EE10 | docs/migration-client-ee8-ee10.md | Переход на раздельные модули | +| Выпуск релиза | docs/releasing.md | Публикация в Maven Central | + +## AI Context файлы + +| Файл | Назначение | +|------|-----------| +| AGENTS.md | Карта проекта для AI-агентов | +| .ai-factory/DESCRIPTION.md | Детальное описание стека и модулей | +| .ai-factory/ARCHITECTURE.md | Clean Architecture: слои, правила зависимостей, примеры кода | +| .ai-factory/rules/base.md | Кодовые конвенции проекта | +| .ai-factory/config.yaml | Настройки AI Factory | + +## Docker + +| Файл | Назначение | +|------|-----------| +| `Dockerfile` | Multi-stage: builder → production (Jetty 12) + development (mvn jetty:run) | +| `compose.yml` | База: app + MySQL 8.0 | +| `compose.override.yml` | Dev-оверрайды: source mount, Maven hot-reload | +| `compose.production.yml` | Hardened production: read_only, cap_drop, resource limits | +| `docker/jetty/ROOT.xml` | Jetty context descriptor с JNDI datasource (env vars) | +| `docker/jetty/entrypoint.sh` | Передаёт DB-параметры как Java system properties | +| `.env.example` | Шаблон переменных окружения | + +## Правила для агентов + +- Команды git выполнять по одной — не объединять в цепочку: + - Неправильно: `git checkout master && git pull` + - Правильно: сначала `git checkout master`, затем `git pull origin master` +- Не мешать javax.servlet и jakarta.servlet в одном модуле — см. EE8/EE10 разделение +- Интеграционные тесты требуют реальный MySQL — не заменять на H2/mock +- Слой данных — только stored procedures через jdbc-proc, без JPA/Hibernate diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..8c2781c8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,94 @@ +# syntax=docker/dockerfile:1 +# ───────────────────────────────────────────────────────────────────────────── +# Stage 1: Build WAR and collect runtime JARs +# ───────────────────────────────────────────────────────────────────────────── +FROM maven:3.9-eclipse-temurin-21-alpine AS builder +WORKDIR /build + +# Prefetch dependencies separately so this layer is cached +COPY pom.xml ./ +COPY */pom.xml ./ +# Hack: copy individual module poms while preserving directory structure +RUN --mount=type=cache,target=/root/.m2 \ + find . -name "pom.xml" -exec dirname {} \; | xargs -I{} mkdir -p {} 2>/dev/null; true + +COPY . . + +# Build WAR (skip tests; tests require a live MySQL) +RUN --mount=type=cache,target=/root/.m2 \ + ./mvnw -B -DskipTests -pl superfly-web -am package + +# Download JARs that are test-scoped but required at Jetty runtime +RUN --mount=type=cache,target=/root/.m2 \ + ./mvnw -B dependency:copy \ + -Dartifact=com.mysql:mysql-connector-j:8.2.0:jar \ + -DoutputDirectory=/build/jetty-lib && \ + ./mvnw -B dependency:copy \ + -Dartifact=org.apache.commons:commons-dbcp2:2.13.0:jar \ + -DoutputDirectory=/build/jetty-lib && \ + ./mvnw -B dependency:copy \ + -Dartifact=org.apache.commons:commons-pool2:2.12.0:jar \ + -DoutputDirectory=/build/jetty-lib + +# ───────────────────────────────────────────────────────────────────────────── +# Stage 2: Production image — Jetty 12 + WAR + JNDI datasource from env vars +# ───────────────────────────────────────────────────────────────────────────── +FROM eclipse-temurin:21-jre-alpine AS production + +ARG JETTY_VERSION=12.0.32 +ENV JETTY_HOME=/opt/jetty +ENV JETTY_BASE=/var/lib/jetty +ENV JETTY_PORT=8080 + +# Install Jetty standalone +RUN apk add --no-cache curl gettext && \ + curl -fsSL "https://repo1.maven.org/maven2/org/eclipse/jetty/jetty-home/${JETTY_VERSION}/jetty-home-${JETTY_VERSION}.tar.gz" \ + | tar xz -C /opt && \ + mv /opt/jetty-home-${JETTY_VERSION} ${JETTY_HOME} + +# Set up Jetty base with required modules +RUN mkdir -p ${JETTY_BASE}/webapps ${JETTY_BASE}/lib/ext && \ + cd ${JETTY_BASE} && \ + java -jar ${JETTY_HOME}/start.jar \ + --add-modules=server,http,deploy,webapp,jndi,plus,annotations,logging-jetty + +# Copy WAR and extra JARs +COPY --from=builder /build/superfly-web/target/superfly.war ${JETTY_BASE}/webapps/ROOT.war +COPY --from=builder /build/jetty-lib/ ${JETTY_BASE}/lib/ext/ + +# Copy context descriptor and entrypoint +COPY docker/jetty/ROOT.xml ${JETTY_BASE}/webapps/ROOT.xml +COPY docker/jetty/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Non-root user +RUN addgroup -S jetty && adduser -S jetty -G jetty && \ + chown -R jetty:jetty ${JETTY_BASE} +USER jetty + +EXPOSE ${JETTY_PORT} + +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD wget -qO- http://localhost:${JETTY_PORT}/ >/dev/null 2>&1 || exit 1 + +ENTRYPOINT ["/entrypoint.sh"] +CMD ["java", "-jar", "/opt/jetty/start.jar"] + +# ───────────────────────────────────────────────────────────────────────────── +# Stage 3: Development image — Maven + hot-reload via jetty:run +# ───────────────────────────────────────────────────────────────────────────── +FROM maven:3.9-eclipse-temurin-21-alpine AS development +WORKDIR /app + +# Pre-fetch dependencies +COPY pom.xml ./ +COPY */pom.xml ./ +RUN --mount=type=cache,target=/root/.m2 \ + ./mvnw -B dependency:go-offline -pl superfly-web -am || true + +COPY . . + +EXPOSE 8080 + +# jetty:run uses jetty-env.conf for JNDI — override via -Djetty.jndi.* if needed +CMD ["./mvnw", "-pl", "superfly-web", "jetty:run"] diff --git a/README-RELEASE.md b/README-RELEASE.md deleted file mode 100644 index 58962059..00000000 --- a/README-RELEASE.md +++ /dev/null @@ -1,67 +0,0 @@ -# Инструкция по релизу проекта Superfly - -## Настройка окружения - -1. Убедитесь, что у вас настроен GPG ключ: - ```bash - gpg --list-keys - ``` - Если ключа нет, создайте его: - ```bash - gpg --gen-key - ``` - -2. Отправьте ваш публичный ключ на сервер ключей: - ```bash - gpg --keyserver hkp://keyserver.ubuntu.com --send-keys YOUR_KEY_ID - ``` - -3. Настройте Maven с вашими учетными данными. В файле `~/.m2/settings.xml` добавьте: - ```xml - - - - central - ваш_логин_sonatype - ваш_токен_sonatype - - - - ``` - -## Выполнение релиза - -### Автоматический способ (рекомендуется) - -Используйте скрипт `release.sh`: - -```bash -chmod +x release.sh -./release.sh ваш_пароль_gpg -``` - -### Ручной способ - -1. Подготовка релиза: - ```bash - mvn --batch-mode release:prepare - ``` - -2. Выполнение релиза с подписью GPG: - ```bash - mvn release:perform -P gpg-sign -Dgpg.passphrase=ваш_пароль_gpg - ``` - -## Проверка результатов - -После успешного выполнения релиза ваши артефакты будут автоматически опубликованы в Maven Central (если настроен `autoPublish=true`). Если автоматическая публикация отключена, перейдите на портал Central Portal для завершения процесса публикации. - -## Решение проблем - -Если возникают ошибки: - -1. Убедитесь, что ваши GPG ключи правильно настроены и отправлены на сервер. -2. Проверьте корректность настройки `settings.xml`. -3. Убедитесь, что все зависимости проекта доступны. - -Для более подробной информации о процессе публикации посетите [официальную документацию Sonatype](https://central.sonatype.org/publish/publish-portal-maven/). diff --git a/README.md b/README.md index d81bd76d..051fe7e9 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,81 @@ -[![build Status](https://github.com/payneteasy/superfly/actions/workflows/maven.yml/badge.svg)](https://github.com/payneteasy/superfly/actions/workflows/maven.yml) - -Each web-application usually has some security: users, roles, permissions. When your organization posesses several systems, a situation may arise when the same user has to present in several systems at once. It is a burden to maintain users/roles/permissions in different systems at the same time. Superfly intends to alleviate such a burden. - - * It allows you to register users on a central server and give them permissions there. - * It integrates seamlessly with spring-securiry. - * It exposes its APIs so you're not tied to spring-security integration. - * [Redirect-based mode](../wiki/SingleSignOn.md) is supported which enables user to enter password once and then be authenticated to all the systems. - * No-redirect mode is supported, in which user does not even know that some central server authenticates them. - * Jira integration is provided (so you can control Jira's users permissions in the same way, from the central server). - -## Project news ## - -## August 19, 2018: Superfly supports Java 9 and 10 ## - -Starting with release 1.7-11, the project successfully builds and runs on Java 9+ (Java 9 and 10 were checked). -Please note that Tomcat 7 will not work in Java 9+, so you'll have to upgrade Tomcat as well is you are planning to run Superfly on these recent Java versions. -Minimal Java version is raised to 8. -Spring version is upgraded to 5. - -## February 8, 2013: version 1.4-1 released ## - -[Single Sign-on](../wiki/SingleSignOn.md) (SSO) based on redirects is implemented. Full spring-security integration is included. - -## November 13, 2012: version 1.3-8 released ## - -All HttpClient timeouts are made configurable; default timeouts are configured which eliminates a freeze possibility. - -## September 10, 2012: version 1.3-7 released ## - -All dynamically-generated URLs are made compatible with nginx mod\_security module. - -[More news...](../wiki/ProjectNews.md) - -## Starting points ## - - * [Get started!](../wiki/GettingStarted.md) - * [SecurityModel](../wiki/SecurityModel.md) - * [Domain entities and how they interrelate](../wiki/DomainModel.md) - * [Installing on Tomcat](../wiki/IntallOnTomcat.md) - * [FAQ](../wiki/FAQ.md) - -# Gratitudes # - -Thanks to [JProfiler](http://www.ej-technologies.com/products/jprofiler/overview.html) for their wonderful [Java profiler](http://www.ej-technologies.com/products/jprofiler/overview.html). +[![Build Status](https://github.com/payneteasy/superfly/actions/workflows/maven.yml/badge.svg)](https://github.com/payneteasy/superfly/actions/workflows/maven.yml) + +# Superfly + +> Централизованный SSO-сервер для управления пользователями, ролями и правами доступа. + +Superfly позволяет зарегистрировать пользователей один раз и управлять их правами доступа +из единой точки для всех подключённых систем. Поддерживает Spring Security, Apache Wicket +и оба поколения Servlet API (Java EE 8 / Jakarta EE 10). + +## Быстрый старт + +```bash +# Клонировать и собрать +git clone https://github.com/payneteasy/superfly.git +cd superfly +./mvnw -DskipTests package + +# Настроить БД (MySQL) +# Отредактировать superfly-web/src/main/webapp/WEB-INF/jetty-web.xml +# Запустить сервер (Jetty) +./mvnw -pl superfly-web jetty:run +``` + +После запуска веб-интерфейс доступен по адресу `http://localhost:8080`. + +## Ключевые возможности + +- **Единый вход (SSO)** — пользователь вводит пароль один раз для всех систем +- **Centralized RBAC** — пользователи, роли и права в одном месте +- **Spring Security** — готовая интеграция для Java EE 8 и Jakarta EE 10 +- **Wicket UI** — веб-интерфейс администратора +- **HOTP / OTP** — двухфакторная аутентификация +- **No-redirect режим** — прозрачная аутентификация без редиректов +- **REST API** — не привязан к Spring Security + +## Подключение к клиентскому приложению + +```xml + + + com.payneteasy.superfly + superfly-spring-security + 2.0-3-SNAPSHOT + + + + + com.payneteasy.superfly + superfly-spring-security-ee8 + 2.0-3-SNAPSHOT + + + + + com.payneteasy.superfly + superfly-client-opt + 2.0-3-SNAPSHOT + +``` + +--- + +## Документация + +| Руководство | Описание | +|-------------|----------| +| [Установка и запуск](docs/getting-started.md) | Требования, сборка, первый запуск | +| [Конфигурация](docs/configuration.md) | База данных, Jetty, параметры запуска | +| [Доменная модель](docs/domain-model.md) | Сущности, связи, перечисления | +| [API Reference](docs/api.md) | RPC и REST endpoints, форматы запросов | +| [Руководство по интеграции](docs/integration-guide.md) | Подключение к клиентскому приложению | +| [SSO HTTP Client](docs/sso-http-client.md) | Клиентский `SSOHttpServiceApiClient`, per-endpoint timeouts | +| [SSL / mTLS](docs/ssl-mtls.md) | TLS-соединение, hostname verification, кастомный CA | +| [Apache HC5 Transport](docs/httpclient-hc5.md) | Connection pooling, AutoCloseable lifecycle, mTLS | +| [Миграция EE8 / EE10](docs/migration-client-ee8-ee10.md) | Переход на раздельные модули | +| [Выпуск релиза](docs/releasing.md) | Публикация в Maven Central | + +## Лицензия + +[Apache License 2.0](LICENSE) diff --git a/compose.override.yml b/compose.override.yml new file mode 100644 index 00000000..b754ad21 --- /dev/null +++ b/compose.override.yml @@ -0,0 +1,37 @@ +# Development overrides — auto-loaded by docker compose +# Mounts source code for hot-reload via Maven Jetty plugin + +services: + app: + build: + target: development + volumes: + # Mount full source for mvn jetty:run hot-reload + - .:/app + # Cache Maven local repo between rebuilds + - maven_cache:/root/.m2 + ports: + - "${APP_PORT:-8080}:8080" + environment: + # jetty-env.conf reads these via + MAVEN_OPTS: >- + -Ddb.host=mysql + -Ddb.port=3306 + -Ddb.name=${DB_NAME:-sso} + -Ddb.user=${DB_USER:-sso} + -Ddb.password=${DB_PASSWORD} + -Ddb.timezone=${DB_TIMEZONE:-UTC} + command: + - ./mvnw + - -pl + - superfly-web + - -am + - jetty:run + + mysql: + # Expose MySQL port to host for GUI clients (DBeaver, IntelliJ, etc.) + ports: + - "127.0.0.1:${MYSQL_HOST_PORT:-3306}:3306" + +volumes: + maven_cache: diff --git a/compose.production.yml b/compose.production.yml new file mode 100644 index 00000000..3ecaf77c --- /dev/null +++ b/compose.production.yml @@ -0,0 +1,60 @@ +# Production hardening overlay +# Usage: docker compose -f compose.yml -f compose.production.yml up -d + +x-security: &hardened + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + +x-logging: &prod-logging + driver: json-file + options: + max-size: "20m" + max-file: "5" + +services: + app: + <<: *hardened + image: ${APP_IMAGE:-superfly-app}:${APP_VERSION} + # No build: in production — use pre-built image + build: !reset null + tmpfs: + - /tmp:noexec,nosuid,size=100m + deploy: + resources: + limits: + cpus: "2.0" + memory: 1G + pids: 256 + reservations: + memory: 512M + restart: unless-stopped + logging: *prod-logging + # No ports: exposed to host — use external load balancer or proxy + + mysql: + <<: *hardened + cap_add: + # MySQL needs DAC_OVERRIDE for socket files + - DAC_OVERRIDE + tmpfs: + - /tmp:noexec,nosuid,size=50m + # MySQL needs a writable data dir — override read_only for specific paths + read_only: false + deploy: + resources: + limits: + cpus: "2.0" + memory: 2G + pids: 512 + reservations: + memory: 512M + restart: unless-stopped + logging: *prod-logging + # No ports exposed — app connects via internal Docker network only + +networks: + backend: + internal: true diff --git a/compose.yml b/compose.yml new file mode 100644 index 00000000..0a301586 --- /dev/null +++ b/compose.yml @@ -0,0 +1,57 @@ +name: ${COMPOSE_PROJECT_NAME:-superfly} + +x-logging: &default-logging + driver: json-file + options: + max-size: "20m" + max-file: "5" + +services: + app: + build: + context: . + target: production + image: superfly-app:${APP_VERSION:-latest} + ports: + - "${APP_PORT:-8080}:8080" + environment: + DB_HOST: mysql + DB_PORT: "3306" + DB_NAME: ${DB_NAME:-sso} + DB_USER: ${DB_USER:-sso} + DB_PASSWORD: ${DB_PASSWORD} + DB_TIMEZONE: ${DB_TIMEZONE:-UTC} + JETTY_PORT: "8080" + depends_on: + mysql: + condition: service_healthy + restart: unless-stopped + logging: *default-logging + networks: + - backend + + mysql: + image: mysql:8.0-debian + environment: + MYSQL_DATABASE: ${DB_NAME:-sso} + MYSQL_USER: ${DB_USER:-sso} + MYSQL_PASSWORD: ${DB_PASSWORD} + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "${DB_USER:-sso}", "-p${DB_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + logging: *default-logging + networks: + - backend + +volumes: + mysql_data: + +networks: + backend: diff --git a/docker/jetty/ROOT.xml b/docker/jetty/ROOT.xml new file mode 100644 index 00000000..8321af1e --- /dev/null +++ b/docker/jetty/ROOT.xml @@ -0,0 +1,25 @@ + + + + + + java:comp/env/jdbc/superfly + + + + jdbc:mysql://:/?characterEncoding=utf8&useInformationSchema=true&noAccessToProcedureBodies=false&useLocalSessionState=true&autoReconnect=false&serverTimezone= + com.mysql.cj.jdbc.Driver + + + true + true + {call create_collections()} + + + + diff --git a/docker/jetty/entrypoint.sh b/docker/jetty/entrypoint.sh new file mode 100644 index 00000000..db051035 --- /dev/null +++ b/docker/jetty/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu + +# Pass DB connection params as Java system properties +# so ROOT.xml tags pick them up +exec "$@" \ + -Ddb.host="${DB_HOST:-mysql}" \ + -Ddb.port="${DB_PORT:-3306}" \ + -Ddb.name="${DB_NAME:-sso}" \ + -Ddb.user="${DB_USER:-sso}" \ + -Ddb.password="${DB_PASSWORD:-}" \ + -Ddb.timezone="${DB_TIMEZONE:-UTC}" \ + -Djetty.http.port="${JETTY_PORT:-8080}" diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 00000000..45fd6e2b --- /dev/null +++ b/docs/api.md @@ -0,0 +1,491 @@ +[← Доменная модель](domain-model.md) · [Back to README](../README.md) · [Руководство по интеграции →](integration-guide.md) + +# API Reference + +Superfly предоставляет два типа API: + +| Тип | Путь | Формат | Назначение | +|-----|------|--------|-----------| +| [RPC API](#rpc-api-ssoservice) | `/sso.service/{method}` | JSON / XML | Аутентификация, управление пользователями | +| [REST API](#rest-api) | `/check-password`, `/check-otp` | JSON | Проверка учётных данных | + +--- + +## Аутентификация + +### RPC API (`/sso.service/**`) + +Требует роль `ROLE_SUBSYSTEM`. Поддерживается: +- **Bearer Token**: `Authorization: Bearer {subsystem_token}` +- **Basic Auth**: `Authorization: Basic {base64(user:password)}` + +Токен подсистемы (`subsystemToken`) задаётся при регистрации подсистемы в UI. + +### REST API (`/check-password`, `/check-otp`) + +Обязателен заголовок: +``` +Authorization: Bearer {subsystem_token} +``` + +--- + +## RPC API: `/sso.service` + +Каждый метод — это `POST /{methodName}` с JSON-телом. + +**Content-Type**: `application/json` (по умолчанию) или `application/xml` + +**Обработка ошибок**: все исключения возвращаются как `ExceptionWrapper` с кодом ошибки: +```json +{ "className": "com.payneteasy.superfly.api.exceptions.UserNotFoundException", + "message": "User 'john' not found" } +``` + +--- + +### Аутентификация + +#### `POST /sso.service/authenticate` + +Проверяет логин/пароль. Возвращает сессию с ролями и действиями. + +**Request:** +```json +{ + "username": "john", + "password": "secret", + "authRequestInfo": { + "subsystemIdentifier": "MY_APP" + } +} +``` + +**Response:** `SSOUser | null` +```json +{ + "name": "john", + "sessionId": 42, + "otpType": "none", + "isOtpOptional": false, + "actionsMap": { + "OPERATOR": ["READ_ORDERS", "UPDATE_STATUS"] + }, + "preferences": {} +} +``` + +--- + +#### `POST /sso.service/pseudoAuthenticate` + +Аутентификация без проверки пароля (для доверенных систем). Параметры аналогичны `authenticate`. + +--- + +#### `POST /sso.service/exchangeSubsystemToken` + +Обменивает SSO-токен (redirect-based flow) на сессию пользователя. + +**Request:** +```json +{ "subsystemToken": "abc123..." } +``` + +**Response:** `SSOUser | null` + +--- + +#### `POST /sso.service/checkOtp` + +Проверяет OTP-код для двухфакторной аутентификации. + +**Request:** +```json +{ + "username": "john", + "otpEncrypted": "...", + "sessionId": 42 +} +``` + +**Response:** `boolean` — `true` если код верный + +**Исключения:** `SsoDecryptException` + +--- + +#### `POST /sso.service/hasOtpMasterKey` + +Проверяет, настроен ли Google Authenticator для пользователя. + +**Request:** +```json +{ "username": "john" } +``` + +**Response:** `boolean` + +--- + +#### `POST /sso.service/touchSessions` + +Обновляет время активности сессий (предотвращает таймаут). + +**Request:** +```json +{ "sessionIds": [42, 43, 44] } +``` + +**Response:** `void` + +--- + +### Управление пользователями + +#### `POST /sso.service/registerUser` + +Регистрирует нового пользователя. Пароль должен соответствовать политике безопасности. + +**Request:** +```json +{ + "username": "john", + "password": "SecurePass1!", + "email": "john@example.com", + "firstName": "John", + "lastName": "Doe", + "organization": "Acme Corp", + "subsystemHint": "MY_APP", + "otpType": "none", + "isOtpOptional": false, + "isPasswordTemp": false, + "roleGrants": [ + { "roleName": "OPERATOR", "subsystemName": "MY_APP" } + ] +} +``` + +**Response:** `void` + +**Исключения:** `UserExistsException`, `PolicyValidationException`, `BadPublicKeyException`, `MessageSendException` + +--- + +#### `POST /sso.service/getUserDescription` + +Возвращает профиль пользователя. + +**Request:** +```json +{ "username": "john" } +``` + +**Response:** `UserDescription | null` +```json +{ + "username": "john", + "email": "john@example.com", + "firstName": "John", + "lastName": "Doe", + "organization": "Acme Corp", + "otpType": "none", + "isOtpOptional": false, + "publicKey": null +} +``` + +--- + +#### `POST /sso.service/updateUserDescription` + +Обновляет профиль пользователя. + +**Request:** `UserDescription` (те же поля, что и в `getUserDescription`) + +**Исключения:** `UserNotFoundException`, `BadPublicKeyException` + +--- + +#### `POST /sso.service/changeTempPassword` + +Меняет временный пароль (при первом входе). + +**Request:** +```json +{ + "username": "john", + "newPassword": "NewSecurePass1!" +} +``` + +**Исключения:** `PolicyValidationException` + +--- + +#### `POST /sso.service/resetPassword` + +Сбрасывает пароль пользователя администратором. + +**Request:** +```json +{ + "username": "john", + "newPassword": "TempPass1!", + "isPasswordTemp": true +} +``` + +**Исключения:** `UserNotFoundException`, `PolicyValidationException` + +--- + +#### `POST /sso.service/getUserStatuses` + +Возвращает статусы нескольких пользователей (заблокирован, приостановлен и т.д.). + +**Request:** +```json +{ "usernames": ["john", "jane"] } +``` + +**Response:** `List` + +--- + +#### `POST /sso.service/getUsersWithActions` + +Возвращает пользователей с их действиями для указанной подсистемы. + +**Request:** +```json +{ "subsystemIdentifier": "MY_APP" } +``` + +**Response:** `List` +```json +[ + { + "username": "john", + "email": "john@example.com", + "actions": ["READ_ORDERS", "UPDATE_STATUS"] + } +] +``` + +--- + +#### `POST /sso.service/completeUser` + +Завершает процесс регистрации пользователя (активирует аккаунт). + +--- + +#### `POST /sso.service/changeUserRole` + +Меняет роль пользователя в подсистеме. + +**Request:** +```json +{ + "username": "john", + "subsystemName": "MY_APP", + "newRoleName": "ADMIN" +} +``` + +--- + +### OTP / Google Authenticator + +#### `POST /sso.service/updateUserOtpType` + +Меняет тип OTP для пользователя. + +**Request:** +```json +{ + "username": "john", + "otpType": "google_auth" +} +``` + +--- + +#### `POST /sso.service/updateUserIsOtpOptionalValue` + +Устанавливает, является ли OTP обязательным. + +**Request:** +```json +{ + "username": "john", + "isOtpOptional": true +} +``` + +--- + +#### `POST /sso.service/resetGoogleAuthMasterKey` + +Сбрасывает и перегенерирует мастер-ключ Google Authenticator. + +**Request:** +```json +{ + "username": "john", + "masterKeyEncrypted": "..." +} +``` + +**Response:** `String` — новый мастер-ключ (зашифрованный) + +**Исключения:** `UserNotFoundException`, `SsoDecryptException` + +--- + +#### `POST /sso.service/getUrlToGoogleAuthQrCode` + +Возвращает `otpauth://` URI для QR-кода. + +**Request:** +```json +{ + "username": "john", + "masterKeyEncrypted": "..." +} +``` + +**Response:** `String` — `otpauth://totp/Superfly:john?secret=...` + +--- + +### Синхронизация данных + +#### `POST /sso.service/sendSystemData` + +Регистрирует список действий подсистемы (синхронизирует Actions в Superfly). +Вызывается при старте приложения. + +**Request:** +```json +{ + "subsystemIdentifier": "MY_APP", + "actionDescriptions": [ + { "name": "READ_ORDERS", "description": "Просмотр заказов" }, + { "name": "UPDATE_STATUS", "description": "Изменение статуса" } + ] +} +``` + +**Response:** `void` + +--- + +#### `POST /sso.service/getEvents` + +Long-polling для получения событий (изменения прав, блокировки). + +**Request:** +```json +{ + "lastEventTime": "2025-01-01T00:00:00+0300", + "waitTimeMs": 30000 +} +``` + +**Response:** `List` +```json +[ + { + "eventId": 1, + "eventTime": "2025-05-20T12:00:00+0300", + "eventTypeCode": "USER_LOCKED", + "eventData": "{\"username\":\"john\"}" + } +] +``` + +--- + +## REST API + +### `POST /check-password/{subsystemName}/{username}` + +Проверяет зашифрованный пароль пользователя. + +**Request:** +``` +POST /check-password/MY_APP/john +Authorization: Bearer {subsystem_token} +Content-Type: application/json + +{ + "username": "john", + "passwordEncrypted": "..." +} +``` + +**Response (200 OK):** +```json +{ + "username": "john", + "sessionToken": "session-abc123", + "otpRequired": false +} +``` + +**HTTP-коды:** `200 OK` · `400 Bad Request` · `401 Unauthorized` · `500 Internal Server Error` + +--- + +### `POST /check-otp/{subsystemName}/{username}` + +Проверяет OTP-код для завершения двухфакторной аутентификации. + +**Request:** +``` +POST /check-otp/MY_APP/john +Authorization: Bearer {subsystem_token} +Content-Type: application/json + +{ + "username": "john", + "otpEncrypted": "...", + "sessionToken": "session-abc123" +} +``` + +**Response (200 OK):** +```json +{ + "username": "john", + "sessionToken": "session-abc123", + "result": "OK" +} +``` + +--- + +## Иерархия исключений + +``` +SsoException + ├── SsoAuthException — ошибки аутентификации + ├── SsoClientException — ошибки клиента (400) + ├── SsoServerException — ошибки сервера (500) + ├── SsoConnectionException — проблемы соединения + ├── SsoDecryptException — ошибки шифрования + ├── UserNotFoundException — пользователь не найден + ├── UserExistsException — пользователь уже существует + ├── PolicyValidationException — нарушение политики паролей + ├── BadPublicKeyException — некорректный публичный ключ + └── MessageSendException — ошибка отправки email +``` + +--- + +## See Also + +- [Доменная модель](domain-model.md) — структура сущностей +- [Руководство по интеграции](integration-guide.md) — как подключить клиентское приложение diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 00000000..19a95bf5 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,95 @@ +[← Установка и запуск](getting-started.md) · [Back to README](../README.md) · [Доменная модель →](domain-model.md) + +# Конфигурация + +## База данных + +Конфигурация DataSource задаётся в Jetty XML-файлах. + +### `superfly-web/src/main/webapp/WEB-INF/jetty-web.xml` + +Основной конфиг для деплоя на Jetty. Используется в production. + +```xml + + + java:comp/env/jdbc/superfly + + + jdbc:mysql://HOST:PORT/sso + ?characterEncoding=utf8 + &useInformationSchema=true + &noAccessToProcedureBodies=false + &useLocalSessionState=true + &autoReconnect=false + &serverTimezone=Europe/Moscow + com.mysql.cj.jdbc.Driver + sso + YOUR_PASSWORD + true + {call create_collections()} + + + + +``` + +### `superfly-web/src/main/resources/jetty-env.conf` + +Конфиг для разработки через `mvn jetty:run`. Структура аналогична `jetty-web.xml`, но использует `org.apache.commons.dbcp.BasicDataSource` (dbcp1). + +| Параметр | Описание | +|----------|---------| +| `url` | JDBC URL. Порт по умолчанию: `3306`. Схема: `sso` | +| `username` | Пользователь MySQL | +| `password` | Пароль MySQL | +| `serverTimezone` | Часовой пояс MySQL-сервера (например, `UTC`, `Europe/Moscow`) | +| `testOnBorrow` | Проверять соединение при взятии из пула | +| `validationQuery` | SQL-вызов для проверки соединения | + +## Параметры запуска Jetty + +Порт и другие параметры Jetty задаются при запуске через Maven: + +```bash +# Изменить порт (по умолчанию 8080) +./mvnw -pl superfly-web jetty:run -Djetty.http.port=9090 +``` + +Или в `superfly-web/pom.xml` в конфигурации `jetty-maven-plugin`. + +## Spring Application Context + +Конфигурационные XML-файлы Spring находятся в `superfly-web/src/main/webapp/WEB-INF/`: + +| Файл | Назначение | +|------|-----------| +| `applicationContext.xml` | Основной контекст приложения | +| `security.xml` | Spring Security конфигурация | +| `mvc-dispatcher-servlet.xml` | Spring MVC диспетчер | + +## Политика паролей + +Superfly поддерживает несколько политик паролей. Активная политика задаётся через Spring-конфигурацию с условием `@OnPolicyCondition`: + +| Значение | Описание | +|----------|---------| +| `NONE` | Без политики (пароли не хэшируются) | +| `PCIDSS` | Политика PCI DSS: минимум 8 символов, спецсимволы | + +## SMTP (Email-уведомления) + +SMTP-серверы настраиваются через веб-интерфейс администратора (`/smtpServer/list`), а не через файлы конфигурации. + +## HOTP / Двухфакторная аутентификация + +HOTP-провайдер настраивается через `superfly-spi`. Реализация подключается через Spring DI. По умолчанию используется `NullHOTPProvider` (2FA отключена). + +## Переменные окружения + +На данный момент все параметры задаются через XML-конфиги Jetty и Spring, а не через переменные окружения. Для контейнеризации рекомендуется использовать внешние конфиги с volume-монтированием `jetty-web.xml`. + +## See Also + +- [Установка и запуск](getting-started.md) — начальная настройка БД +- [Руководство по интеграции](integration-guide.md) — настройка клиентских приложений diff --git a/docs/domain-model.md b/docs/domain-model.md new file mode 100644 index 00000000..a12fad75 --- /dev/null +++ b/docs/domain-model.md @@ -0,0 +1,205 @@ +[← Конфигурация](configuration.md) · [Back to README](../README.md) · [API Reference →](api.md) + +# Доменная модель + +## Ключевые сущности + +``` +Subsystem (подсистема) + ├── Role (роль) ──────────────── Group (группа действий) + │ └── Action (действие) └── Action + └── User (пользователь) + └── AuthSession (сессия) + └── AuthRole → AuthAction +``` + +Все права пользователя всегда **контекстно-зависимы от подсистемы** — одному пользователю +можно дать разные роли в разных подсистемах. + +--- + +## Subsystem — подсистема + +Внешнее приложение, зарегистрированное в Superfly. Каждая подсистема имеет собственный +набор ролей и действий. + +| Поле | Тип | Описание | +|------|-----|---------| +| `id` | long | Первичный ключ | +| `name` | String | Уникальный идентификатор (используется в API-вызовах) | +| `title` | String | Человекочитаемое название | +| `callbackUrl` | String | URL для callback-уведомлений при изменении прав | +| `sendCallbacks` | boolean | Включить ли отправку callbacks | +| `subsystemToken` | String | Токен для redirect-based SSO | +| `subsystemUrl` | String | Базовый URL приложения | +| `landingUrl` | String | URL страницы после успешного SSO | +| `loginFormCssUrl` | String | CSS для кастомизации формы входа | +| `privateKey` / `publicKey` | String | Ключевая пара для шифрования токенов | +| `encryptionAlgorithm` | String | Алгоритм шифрования токенов | +| `allowListUsers` | boolean | Разрешить получение списка пользователей подсистемы | +| `smtpServer` | UISmtpServerForFilter | SMTP-сервер для email-уведомлений | + +--- + +## User — пользователь + +| Поле | Тип | Описание | +|------|-----|---------| +| `id` | Long | Первичный ключ | +| `username` | String | Логин (уникальный) | +| `password` | String | Хэш пароля | +| `email` | String | Email | +| `name` / `surname` | String | Имя и фамилия | +| `organization` | String | Организация | +| `secretQuestion` / `secretAnswer` | String | Секретный вопрос для восстановления | +| `salt` | String | Соль для хэширования пароля | +| `publicKey` | String | Публичный ключ пользователя | +| `otpType` | OTPType | Тип двухфакторной аутентификации | +| `isOtpOptional` | boolean | Можно ли пропустить OTP | + +### Статус пользователя (в UIUserForList) + +| Поле | Описание | +|------|---------| +| `accountLocked` | Аккаунт заблокирован (превышен лимит неудачных попыток) | +| `accountSuspended` | Аккаунт приостановлен администратором | +| `loginsFailed` | Число неудачных попыток входа подряд | +| `lastLoginDate` | Дата последнего успешного входа | +| `nextOtpCounter` | Счётчик HOTP | + +--- + +## Role — роль + +Роль принадлежит конкретной подсистеме. Пользователю назначается роль в контексте подсистемы. + +| Поле | Тип | Описание | +|------|-----|---------| +| `roleId` | long | Первичный ключ | +| `roleName` | String | Имя роли | +| `principalName` | String | Spring Security principal (обычно совпадает с `roleName`) | +| `subsystemId` | long | Подсистема, к которой относится роль | + +--- + +## Action — действие + +Атомарное разрешение внутри подсистемы. Пример: `READ_ORDERS`, `ADMIN`, `VIEW_REPORTS`. + +| Поле | Тип | Описание | +|------|-----|---------| +| `actionId` | long | Первичный ключ | +| `actionName` | String | Имя действия (идентификатор) | +| `actionDescription` | String | Человекочитаемое описание | +| `subsystemId` | long | Подсистема | +| `logAction` | boolean | Логировать ли использование | + +--- + +## Group — группа действий + +Удобный способ объединить набор действий для массового назначения. + +| Поле | Тип | Описание | +|------|-----|---------| +| `id` | long | Первичный ключ | +| `name` | String | Имя группы | +| `subsystemId` | long | Подсистема | + +Группа содержит набор действий. Роли можно назначать действия как по одному, так и через группу. + +--- + +## AuthSession — сессия аутентификации + +Создаётся при успешном входе. Содержит все роли и действия, активные в данный момент. + +| Поле | Тип | Описание | +|------|-----|---------| +| `sessionId` | Long | Уникальный ID сессии | +| `username` | String | Пользователь | +| `otpTypeId` | Long | Тип OTP | +| `isOtpOptional` | boolean | OTP опционален | +| `roles` | List\ | Роли с вложенными действиями | + +```java +// Структура AuthSession +AuthSession { + sessionId: 42L, + username: "john", + roles: [ + AuthRole { + roleName: "OPERATOR", + actions: [ + AuthAction { actionName: "READ_ORDERS" }, + AuthAction { actionName: "UPDATE_STATUS" } + ] + } + ] +} +``` + +--- + +## Перечисления + +### OTPType — тип двухфакторной аутентификации + +| Значение | Код | Описание | +|----------|-----|---------| +| `NONE` | `"none"` | OTP отключён | +| `GOOGLE_AUTH` | `"google_auth"` | Google Authenticator (TOTP) | + +### Policy — политика паролей + +| Значение | Описание | +|----------|---------| +| `NONE` | Без политики | +| `PCIDSS` | Минимум 8 символов, спецсимволы, история паролей | + +### UserLoginStatus + +| Значение | Код | Описание | +|----------|-----|---------| +| `SUCCESS` | `"Y"` | Успешный вход | +| `FAILED` | `"N"` | Неудачная попытка | +| `TEMP_PASSWORD` | `"T"` | Вход с временным паролем | + +### LockoutType — причина блокировки + +`SESSION` · `ROLES` · `PASSWORD` · `HOTP` + +--- + +## RoutineResult — результат операции + +Большинство write-операций возвращают `RoutineResult`: + +| Статус | Значение | +|--------|---------| +| `"OK"` | Успех | +| `"duplicate"` | Запись уже существует | +| `"fail"` | Ошибка (см. `errorMessage`) | + +--- + +## UI-модели: соглашения о суффиксах + +| Суффикс | Назначение | Пример | +|---------|-----------|--------| +| `ForList` | Пагинированные списки в таблицах | `UIUserForList` | +| `ForView` | Детальный просмотр одной записи | `UIRoleForView` | +| `ForFilter` | Выпадающие списки и фильтры | `UISubsystemForFilter` | +| `ForCheckbox` | Чекбоксы при массовом назначении прав | `UIActionForCheckbox` | +| `ForCreate` | Форма создания | `UIUserForCreate` | +| `WithRolesAndActions` | Полная иерархия прав | `UIUserWithRolesAndActions` | +| `Details` | Расширенное редактирование | `UIUserDetails` | + +Эти модели — DTO между DAO-слоем и Wicket-UI. Никогда не используются в REST API. + +--- + +## See Also + +- [API Reference](api.md) — типы запросов/ответов в REST API +- [Руководство по интеграции](integration-guide.md) — как назначать роли через API diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 00000000..d79f0a51 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,166 @@ +[Back to README](../README.md) · [Конфигурация →](configuration.md) + +# Установка и запуск + +## Требования + +| Компонент | Версия | +|-----------|--------| +| Java | 21+ | +| Maven | 3.9+ (или `./mvnw`) | +| MariaDB | 10.3+ (рекомендуется) | +| Docker | любая (для быстрого старта) | +| Git | любая | + +> **MariaDB vs MySQL:** SQL-схема использует синтаксис MariaDB (в частности `GRANT ... IDENTIFIED BY` +> и таблицу `groups`). Для MySQL 8+ потребуется адаптация миграций. + +--- + +## Быстрый старт (локальная разработка) + +### 1. Запустить MariaDB в Docker + +```bash +docker run -d --name superfly-db \ + -p 3344:3306 \ + -e MYSQL_ROOT_PASSWORD=1234 \ + mariadb:10.3 \ + --character-set-server=utf8mb4 \ + --collation-server=utf8mb4_general_ci + +# Ждём готовности (10–20 сек) +until mysql -h 127.0.0.1 -P 3344 -u root -p1234 -e "SELECT 1" 2>/dev/null; do + echo "Waiting for MariaDB..."; sleep 2 +done +``` + +Переменные окружения миграционных скриптов: + +| Переменная | По умолчанию | Описание | +|------------|-------------|---------| +| `SSO_DB_HOST` | `localhost` | Хост БД | +| `SSO_DB_PORT` | `3344` | Порт | +| `SSO_DB_ROOT` | `root` | Root-пользователь | +| `SSO_DB_ROOT_PASSWORD` | `1234` | Root-пароль | +| `SSO_DB_USERNAME` | `sso` | Пользователь приложения | +| `SSO_DB_PASSWORD` | `123sso123` | Пароль приложения | +| `SSO_DB_DATABASE` | `sso` | Имя базы | + +### 2. Накатить миграции + +```bash +cd superfly-sql/mi +bash all_mi.sh +``` + +Скрипт последовательно применяет все версии R1.0.0 → R1.7.x. Логи пишутся в `/target/*.log`. + +Для нестандартного порта: + +```bash +SSO_DB_PORT=3343 bash all_mi.sh +``` + +### 3. Сбросить пароль admin + +Миграции создают пользователя `admin` с солью (для pcidss-политики). В dev-режиме используется +политика `none` (см. шаг 4), поэтому нужно сбросить хеш на SHA-256 без соли: + +```bash +# SHA-256("123admin123") = c9cddf4205cb7b6e6e918dc967bb6505d1447a2a499ef165a8c03784170307ee +mysql -h 127.0.0.1 -P 3344 -u sso -p123sso123 sso -e " +UPDATE users +SET user_password = 'c9cddf4205cb7b6e6e918dc967bb6505d1447a2a499ef165a8c03784170307ee', + salt = NULL +WHERE user_name = 'admin'; +" +``` + +Пользователь admin должен иметь: + +```sql +SELECT user_name, user_password, salt FROM users WHERE user_name = 'admin'; +-- user_password = c9cddf42... salt = NULL +SELECT COUNT(*) FROM user_roles ur JOIN users u ON u.user_id = ur.user_user_id WHERE u.user_name = 'admin'; +-- >= 1 +``` + +### 4. Добавить dev-политику в override-web.xml + +Файл `superfly-web/src/test/resources/jetty/override-web.xml` должен содержать: + +```xml + + superfly-policy + none + +``` + +**Почему это нужно:** `web.xml` устанавливает `superfly-policy=pcidss` → `RandomStoredSaltSource`. +При логине она читает соль из БД и вычисляет хеш с ней. Если `salt=NULL` — аутентификация падает. +Политика `none` активирует `NullSaltSource` → SHA-256 без соли. Файл `override-web.xml` используется +только `Start.java` и не попадает в production WAR. + +### 5. Собрать проект + +```bash +./mvnw -DskipTests -pl superfly-web -am package +``` + +### 6. Запустить + +**Через IDEA:** Run-конфиг `Start` из `.run/Start.run.xml` +- Main class: `com.payneteasy.superfly.Start` +- Модуль: `superfly-web` + +**Через Maven:** + +```bash +./mvnw -pl superfly-web -DskipTests jetty:run +``` + +Приложение ждёт нажатия клавиши для остановки. + +### 7. Проверить + +Откройте `http://localhost:8085/superfly/` + +| Поле | Значение | +|------|---------| +| Логин | `admin` | +| Пароль | `123admin123` | + +--- + +## Настройка подключения к БД + +Файл `superfly-web/src/main/webapp/WEB-INF/jetty-web.xml` — подключение по умолчанию: + +```xml +jdbc:mysql://localhost:3344/sso?characterEncoding=utf8 + &useInformationSchema=true&noAccessToProcedureBodies=false + &useLocalSessionState=true +sso +123sso123 +``` + +--- + +## Устранение неисправностей + +| Симптом | Причина | Решение | +|---------|---------|---------| +| `Connection refused` на 3344 | MariaDB не запущена | Шаг 1 | +| `Table 'sso.users' doesn't exist` | Миграции не накатаны | Шаг 2 | +| Логин неверен (правильный пароль) | salt != NULL или policy=pcidss | Шаги 3–4 | +| `NullPointerException` при старте | Нет JNDI datasource | Проверить `jetty-web.xml` | +| Wicket error после логина | Нет `user_role_actions` | Проверить шаг 3: роли накатаны? | + +--- + +## Следующие шаги + +- [Конфигурация](configuration.md) — настройка портов, БД, параметров приложения +- [Руководство по интеграции](integration-guide.md) — подключение клиентских приложений +- [Миграция EE8/EE10](migration-client-ee8-ee10.md) — javax/jakarta split diff --git a/docs/httpclient-hc5.md b/docs/httpclient-hc5.md new file mode 100644 index 00000000..1f075484 --- /dev/null +++ b/docs/httpclient-hc5.md @@ -0,0 +1,184 @@ +# Apache HttpClient 5 Transport — `superfly-httpclient-hc5` + +> **SSO-3** — замена JDK `HttpURLConnection` (`HttpClientImpl`) на Apache HC5 +> Добавляет connection pooling, explicit lifecycle и per-request timeout override. + +## Зачем + +| Проблема `HttpClientImpl` | Решение в `ApacheHC5HttpClient` | +|---------------------------|--------------------------------| +| Нет connection pooling — каждый вызов = новый TCP+TLS handshake | PoolingHttpClientConnectionManager (по умолчанию 20/20 conn) | +| Нет `AutoCloseable` — resource leak при timeout | `implements AutoCloseable`, `close()` корректно шатдаунит пул | +| Нет eviction idle соединений | `evictIdleConnections(30s)` | + +## Зависимость + +```xml + + com.payneteasy.superfly + superfly-httpclient-hc5 + ${superfly.version} + +``` + +Транзитивно тащит: `org.apache.httpcomponents.client5:httpclient5:5.4.4`. SSL-хелперы (`JdkSslSocketFactoryBuilder` и др.) входят в сам модуль `superfly-httpclient-hc5` (пакет `com.payneteasy.httpclient.contrib.ssl`). + +## Builder API + +```java +ApacheHC5HttpClient client = ApacheHC5HttpClient.builder() + // SSL/mTLS — опционально; null = JVM default SSL context + .sslContext(JdkSslSocketFactoryBuilder.buildSslContext(keyStoreUrl, ksPwd, tsUrl, tsPwd)) + // HostnameVerifier — опционально; null = стандартная hostname verification + .hostnameVerifier(JdkSslSocketFactoryBuilder.buildCnHostnameVerifier("superfly-server")) + // Connection pool (дефолты ниже) + .maxConnTotal(20) // суммарный лимит + .maxConnPerRoute(20) // лимит на маршрут (host:port) + .idleEvictionSec(30) // eviction idle connections + .build(); +``` + +### Дефолтные значения + +| Параметр | Значение | Обоснование | +|----------|---------|-------------| +| `maxConnTotal` | 20 | Покрывает peak load типичного consumer (paynet ~20-30 RPS) | +| `maxConnPerRoute` | 20 | = total для single-host setup (consumer видит один SSO-host) | +| `idleEvictionSec` | 30 | Освобождает соединения за пределами обычного traffic burst | + +> Если consumer обслуживает несколько SSO-серверов (горячее переключение) — увеличьте +> `maxConnTotal` пропорционально количеству хостов, сохраняя `maxConnPerRoute=20`. +> Для high-RPS приложений (> 100 RPS к SSO) — поднимите оба значения, измерив реальный peak. + +## Per-request timeout + +Timeout'ы передаются через `HttpRequestParameters.timeouts` и применяются через `HttpClientContext` к каждому запросу независимо: + +```java +HttpRequestParameters params = HttpRequestParameters.builder() + .timeouts(new HttpTimeouts(connectMs, readMs)) + .build(); + +HttpResponse response = client.send(request, params); +``` + +Поля `sslSocketFactory` и `hostnameVerifier` из `HttpRequestParameters` игнорируются — HC5 использует pooled connections с одним SSL-контекстом, заданным при создании. + +## Exception mapping + +| HC5 исключение | Mapped to | +|----------------|-----------| +| `ConnectTimeoutException` | `HttpConnectException` | +| `SocketTimeoutException` | `HttpReadException` | +| `IOException` (connection refused / unreachable) | `HttpConnectException` | +| `IOException` (read/write I/O) | `HttpReadException` | + +## Lifecycle — AutoCloseable + +`ApacheHC5HttpClient` реализует `AutoCloseable`. `close()` корректно завершает все соединения пула: + +```java +// try-with-resources +try (ApacheHC5HttpClient client = ApacheHC5HttpClient.builder().build()) { + HttpResponse response = client.send(request, params); +} + +// Spring @Bean: destroyMethod автоматически вызовет close() при остановке контекста +@Bean(destroyMethod = "close") +public IHttpClient ssoHttpClient() throws IOException, GeneralSecurityException { + return ApacheHC5HttpClient.builder() + .sslContext(buildSslContext()) + .hostnameVerifier(JdkSslSocketFactoryBuilder.buildCnHostnameVerifier("superfly-server")) + .build(); +} +``` + +После `close()` любой вызов `send()` бросает `IllegalStateException`. + +## Spring @Bean — полный пример + +```java +@Configuration +public class SpringUISuperflyClientConfiguration { + + @Bean(destroyMethod = "close") + public IHttpClient ssoHttpClient() throws IOException, GeneralSecurityException { + ApacheHC5HttpClient.Builder builder = ApacheHC5HttpClient.builder(); + if (ssoConfig.getKeyStoreResourceUrl() != null) { + builder.sslContext(buildSslContext()); + if (ssoConfig.getExpectedServerCn() != null) { + builder.hostnameVerifier( + JdkSslSocketFactoryBuilder.buildCnHostnameVerifier(ssoConfig.getExpectedServerCn())); + } + } + return builder.build(); + } + + private SSLContext buildSslContext() throws IOException, GeneralSecurityException { + URL keyStore = resolveUrl(ssoConfig.getKeyStoreResourceUrl()); + URL trustStore = resolveUrl(ssoConfig.getTrustStoreResourceUrl()); + return JdkSslSocketFactoryBuilder.buildSslContext( + keyStore, ssoConfig.getKeyStorePassword(), + trustStore, ssoConfig.getTrustStorePassword()); + } +} +``` + +## mTLS интеграция + +``` +keystore.jks (client cert + key) + ↓ +JdkSslSocketFactoryBuilder.buildSslContext(keyStoreUrl, ksPwd, trustStoreUrl, tsPwd) + ↓ SSLContext +ApacheHC5HttpClient.builder().sslContext(ctx) + ↓ SSLConnectionSocketFactory (HC5) +PoolingHttpClientConnectionManager + ↓ +CloseableHttpClient (мТLS соединение к superfly-server) +``` + +Если CN сертификата сервера не совпадает с hostname соединения (например CN=`superfly-server`, host=`localhost`) — используйте CN hostname verifier: + +```java +builder.hostnameVerifier( + JdkSslSocketFactoryBuilder.buildCnHostnameVerifier("superfly-server")); +``` + +Безопасно только в связке с кастомным trustStore: цепочка сертификата уже проверена TLS. + +## Logging + +| Уровень | Что логируется | +|---------|---------------| +| `INFO` | Создание/закрытие клиента, ssl-режим | +| `DEBUG` | Каждый запрос: method/url/timeouts, статус, elapsed, pool stats | +| `WARN` | Timeout (connect/read), ошибки close() | + +```xml + + + +``` + +## Тесты + +``` +superfly-httpclient-hc5/src/test/java/.../ApacheHC5HttpClientTest.java +``` + +6 тестов с embedded JDK `HttpServer` (без WireMock): + +1. `testSuccessfulPost` — POST 200 + body +2. `testConnectTimeoutThrows` → `HttpConnectException` +3. `testResponseTimeoutThrows` → `HttpReadException` +4. `testPerRequestTimeoutOverride` — per-request override работает +5. `testAutoCloseableShutdown` — после `close()` → `IllegalStateException` +6. `testHeadersForwardedCorrectly` — headers доходят до сервера + +## См. также + +- [SSL/mTLS Integration Guide](ssl-mtls.md) +- [SSO HTTP Client](sso-http-client.md) +- `JdkSslSocketFactoryBuilder` — `superfly-httpclient-hc5` (пакет `com.payneteasy.httpclient.contrib.ssl`) +- `SSOClientConfig.parametersFor(endpoint)` — per-endpoint timeout config diff --git a/docs/integration-guide.md b/docs/integration-guide.md new file mode 100644 index 00000000..a239335c --- /dev/null +++ b/docs/integration-guide.md @@ -0,0 +1,166 @@ +[← API Reference](api.md) · [Back to README](../README.md) · [Миграция EE8 / EE10 →](migration-client-ee8-ee10.md) + +# Руководство по интеграции + +Описывает, как подключить клиентское Java-приложение к Superfly как SSO-провайдеру. + +## Выбор артефакта + +| Стек приложения | Артефакт | +|-----------------|---------| +| Spring 6 + Jakarta EE 10 (`jakarta.servlet.*`) | `superfly-spring-security` | +| Spring 5 + Java EE 8 (`javax.servlet.*`) | `superfly-spring-security-ee8` | +| Общие типы без Servlet API | `superfly-spring-security-core` | +| Wicket 10 (Jakarta) | `superfly-wicket` | +| Wicket 8 (javax) | `superfly-wicket-ee8` | +| Без Spring Security | `superfly-client-ee10` или `superfly-client-ee8` | + +## Быстрый старт (Jakarta EE 10 + Spring Security) + +### 1. Добавить зависимость + +```xml + + com.payneteasy.superfly + superfly-spring-security + ${superfly.version} + +``` + +### 2. Настроить Spring Security + +```xml + + + + + + + + + +``` + +### 3. Добавить фильтры сессий + +```xml + +``` + +## Режимы аутентификации + +### No-redirect режим + +Пользователь вводит логин/пароль в форму самого приложения. Приложение обращается +к Superfly по HTTP для проверки учётных данных. + +``` +Пользователь → Форма логина в приложении → Superfly (HTTP) → ответ → сессия +``` + +### Redirect-based SSO + +Приложение перенаправляет пользователя на Superfly-сервер для аутентификации, +после чего Superfly перенаправляет обратно с токеном. + +``` +Пользователь → Приложение → Redirect → Superfly UI → аутентификация → Redirect назад +``` + +## HTTP API + +Если Spring Security не используется, можно работать напрямую через HTTP API. + +### Базовый URL + +``` +http://superfly-server:8080/ +``` + +### Проверка учётных данных + +```http +POST /api/v1/auth/login +Content-Type: application/json + +{ + "username": "user", + "password": "secret", + "subsystem": "MY_SUBSYSTEM" +} +``` + +Ответ: JSON с токеном сессии или ошибкой. + +## Подсистемы (Subsystems) + +Каждое приложение регистрируется в Superfly как **подсистема** с уникальным идентификатором. +Права пользователей назначаются на уровне подсистемы. + +1. Войти в Superfly UI → Subsystems → Create +2. Задать идентификатор (`SUBSYSTEM_IDENTIFIER`) +3. Указать тот же идентификатор в конфигурации клиента (`subsystemIdentifier`) + +## Роли и действия + +Superfly использует модель: **пользователь → роль → набор действий**. + +- **Action** (действие) — конкретное разрешение (например, `READ_ORDERS`) +- **Role** (роль) — набор действий (например, `OPERATOR`) +- Пользователю назначается роль в рамках подсистемы + +Действия можно описать через XML-дескриптор и зарегистрировать автоматически: + +```java +// Spring компонент для автоматической регистрации действий +@Component +public class MyActionsSource implements ActionsSource { + public List getActions() { + return List.of("READ_ORDERS", "WRITE_ORDERS", "ADMIN"); + } +} +``` + +## Интеграция с Wicket + +```xml + + com.payneteasy.superfly + superfly-wicket + ${superfly.version} + +``` + +```java +// WicketApplication.java +@Override +protected void init() { + super.init(); + setRootRequestMapper(new PageInterceptingRequestMapper( + getRootRequestMapper(), + new PageInterceptingRequestMapperLogic(...) + )); +} +``` + +## HOTP (двухфакторная аутентификация) + +Для включения HOTP подключите `SuperflyOTPAuthenticationProvider`: + +```xml + + + +``` + +## Миграция между EE8 и EE10 + +Подробности в [руководстве по миграции](migration-client-ee8-ee10.md). + +## See Also + +- [Конфигурация](configuration.md) — настройка сервера Superfly +- [Миграция EE8 / EE10](migration-client-ee8-ee10.md) — переход на раздельные модули diff --git a/docs/migration-client-ee8-ee10.md b/docs/migration-client-ee8-ee10.md new file mode 100644 index 00000000..7240f47c --- /dev/null +++ b/docs/migration-client-ee8-ee10.md @@ -0,0 +1,322 @@ +[← Руководство по интеграции](integration-guide.md) · [Back to README](../README.md) · [Выпуск релиза →](releasing.md) + + +# Миграция на модули Superfly Client EE8 / EE10 + +## Обзор + +С версии 2.0 клиентская часть Superfly разделена на модули по стеку: + +- **superfly-client-core** — общая логика и абстракции сессий без зависимостей на Servlet API (подходит и для EE8, и для EE10). +- **superfly-client-ee10** — фильтры, слушатели и `JakartaHttpSessionWrapper` для приложений на **Jakarta EE 10** (`jakarta.servlet.*`). +- **superfly-client-ee8** — те же фильтры и слушатели для приложений на **Java EE 8** (`javax.servlet.*`), плюс `JavaxHttpSessionWrapper`. + +Артефакт **superfly-client** оставлен для обратной совместимости: это POM-фасад, который подключает **superfly-client-ee10**. Менять группу/артефакт в существующих проектах не обязательно. + +## Выбор артефакта + +| Стек приложения | Подключаемый артефакт | +|-----------------|------------------------| +| Jakarta EE 10, Spring 6, сервлеты `jakarta.servlet.*` | `superfly-client-ee10` или по‑прежнему `superfly-client` | +| Java EE 8, сервлеты `javax.servlet.*` | `superfly-client-ee8` | + +## Минимальные изменения + +### Проекты на Jakarta EE 10 (как раньше) + +Зависимость можно не менять: + +```xml + + com.payneteasy.superfly + superfly-client + ${superfly.version} + +``` + +Либо явно перейти на EE10-адаптер: + +```xml + + com.payneteasy.superfly + superfly-client-ee10 + ${superfly.version} + +``` + +Имена классов и пакетов те же (фильтры в `com.payneteasy.superfly.client.session.*`, `SuperflyLogoutFilter` и т.д.; абстракции сессий `SessionMappingLocator` и `HttpSessionWrapper` — в `com.payneteasy.superfly.common.session`), конфигурация фильтров в `web.xml` или Spring не меняется. + +### Проекты на Java EE 8 (javax) + +Подключить только EE8-адаптер, без `superfly-client` и без `superfly-client-ee10`: + +```xml + + com.payneteasy.superfly + superfly-client-ee8 + ${superfly.version} + +``` + +Имена классов и пакетов совпадают с EE10-вариантом; в коде используются только `javax.servlet.*`. Конфигурация фильтров (например, `SuperflyLogoutFilter`, `AbstractSessionTouchingFilter`) остаётся той же. + +### Опциональные возможности (superfly-client-opt) + +Если используется **superfly-client-opt** (например, `XmlActionDescriptionCollector`, `ScanningActionDescriptionCollector`, Spring `HttpClientFactoryBean`): + +- Коллекторы действий перенесены в **superfly-client-core**; при зависимости от **superfly-client-opt** они подтягиваются через **superfly-client-core**. +- Для проектов только на EE8 подключайте `superfly-client-ee8` и при необходимости `superfly-client-opt` (он тянет `superfly-client-ee10`; при конфликте javax/jakarta используйте только `superfly-client-ee8` и `superfly-client-core` и подключайте коллекторы из core). + +Рекомендация: для EE8-приложений подключать `superfly-client-ee8` и при необходимости `superfly-client-core`; `superfly-client-opt` оставить для проектов на Jakarta, если нужны фабрики и конфигурации под Spring. + +## Сессии и обёртки + +- **SessionMappingLocator**, **HttpSessionWrapper** и реализация маппинга сессий (`HashMapBackedSessionMapping`) живут в **superfly-common** (без javax/jakarta). +- Реализации под конкретный API: + - **JakartaHttpSessionWrapper** — в **superfly-client-ee10** (`jakarta.servlet.http.HttpSession`). + - **JavaxHttpSessionWrapper** — в **superfly-client-ee8** (`javax.servlet.http.HttpSession`). + +При использовании фильтра привязки сессии (например, в Spring Security) в EE8-проекте регистрируйте в маппинге **JavaxHttpSessionWrapper**, в EE10 — **JakartaHttpSessionWrapper**. + +## Wicket-интеграция + +### Jakarta EE 10 / Wicket 10 + +Для приложений на Wicket 10 (`jakarta.servlet.*`) используйте существующий модуль: + +```xml + + com.payneteasy.superfly + superfly-wicket + ${superfly.version} + +``` + +Публичный контракт, ориентированный на внешние приложения: + +- `com.payneteasy.superfly.wicket.PageInterceptingRequestMapper` +- `com.payneteasy.superfly.wicket.PageInterceptingRequestMapperLogic` +- `com.payneteasy.superfly.wicket.InterceptionDecisions` + +`SessionStoreUrlWebRequestCodingStrategy` удалён как устаревший и неиспользуемый. + +### Java EE 8 / Wicket 8 + +Для приложений на Wicket 8 (`javax.servlet.*`) доступен отдельный экспортный модуль: + +```xml + + com.payneteasy.superfly + superfly-wicket-ee8 + ${superfly.version} + +``` + +API и пакеты те же (`com.payneteasy.superfly.wicket.*`), классы совместимы с Wicket 8. +Типовой пример использования можно посмотреть в Paynet (`PaynetUIApplication`, настройка `PageInterceptingRequestMapper`). + +> **Важно (с 2.0-3):** зависимость на `org.apache.wicket:wicket` (8.x) в `superfly-wicket-ee8` +> объявлена со `scope=provided` и **не приносится транзитивно**. Wicket 8 (вместе с его +> встроенным jQuery) подключает само приложение (см. [«Контракт provided-scope»](#контракт-provided-scope-для-ee8-потребителей)). + +## Spring Security-интеграция + +### Общий слой (core) + +Общий набор типов (не зависящих от Servlet API), пригодных как для EE8, так и для EE10, вынесен в отдельный модуль: + +- пакет `com.payneteasy.superfly.security.authentication.*` — токены (`SSOUserAuthenticationToken`, `UsernamePasswordCheckedToken`, `OTPCheckedToken`, `OtpUsernamePasswordCheckedToken`, `SSOAuthenticationRequest` и др.); +- пакет `com.payneteasy.superfly.security.*`: + - `StringTransformer`, `UppercaseTransformer`; + - `RoleSource`, `SSOActionRoleSource`, `SSORoleRoleSource`, `CompoundRoleSource`; + - `CompoundAuthenticationProvider`, `SuperflyUsernamePasswordAuthenticationProvider`, `SuperflyOTPAuthenticationProvider`, `SuperflySelectRoleAuthenticationProvider`, `SuperflyMultiMockAuthenticationProvider`, `SuperflyMockAuthenticationProvider`, `SuperflySSOAuthenticationProvider`; + - валидаторы и post-processor-ы: `CompoundAuthenticationValidator`, `AuthenticationPostProcessor`, `SSOUserShortCircuitingPostProcessor`, `IdAuthenticationPostProcessor`, `CompoundLatestAuthUnwrappingPostProcessor`; +- карта действий: `com.payneteasy.superfly.security.mapbuilder.*` (`ActionsSource`, `CollectingActionsSource`, `ResourceActionsSource`, `SeparateActionsMapBuilder`). + +Эти классы находятся в артефакте: + +```xml + + com.payneteasy.superfly + superfly-spring-security-core + ${superfly.version} + +``` + +и могут использоваться как в EE8-, так и в EE10-приложениях (версии Spring/Spring Security задаёт само приложение и целевой стек — EE8 или EE10). + +### EE10 / Jakarta (web-слой) + +Для приложений на Jakarta EE 10 используются web-компоненты из модуля `superfly-spring-security`, завязанные на `jakarta.servlet.*` и зависящие от core: + +- фильтры и entry point-ы: + - `SuperflyUsernamePasswordAuthenticationProcessingFilter` + - `SuperflyOTPAuthenticationProcessingFilter` + - `SuperflySelectRoleAuthenticationProcessingFilter` + - `SuperflySSOAuthenticationProcessingFilter` + - `MultiStepLoginUrlAuthenticationEntryPoint` + - `TwoStepAuthenticationProcessingFilter` + - `TwoStepAuthenticationProcessingFilterEntryPoint` + - `InsufficientAuthenticationHandlingFilter` +- CSRF и сессии: + - `CsrfValidator`, `CsrfValidatorImpl` + - `SSOUserSessionBindFilter` + - `UnauthorizedFailureHandler`. + +Они ожидают стек **Spring 6 / Spring Security 6 + Jakarta Servlet API**. + +Рекомендуемая зависимость: + +```xml + + com.payneteasy.superfly + superfly-spring-security + ${superfly.version} + +``` + +### EE8 / javax + +Для Java EE 8 приложений теперь доступен отдельный экспортный модуль web-адаптера под `javax.servlet.*`, поверх общего core: + +1. Подключить core-типы: + + ```xml + + com.payneteasy.superfly + superfly-spring-security-core + ${superfly.version} + + ``` + +2. Подключить EE8 web-адаптер: + + ```xml + + com.payneteasy.superfly + superfly-spring-security-ee8 + ${superfly.version} + + ``` + + Внутри используются: + + - `javax.servlet.*` (Servlet API 4.0.x); + - Spring 5.x / Spring Security 5.8.x (javax-совместимые артефакты). + + > **Важно (с 2.0-3):** framework-зависимости в `superfly-spring-security-ee8` объявлены + > со `scope=provided` и **не приносятся транзитивно** в ваш проект. Spring 5.x / Spring + > Security 5.8.x вы подключаете сами (см. [«Контракт provided-scope»](#контракт-provided-scope-для-ee8-потребителей)). + +3. Использовать те же классы web-слоя, что и в Jakarta-варианте, но из модуля EE8: + +- фильтры и entry point-ы: + - `SuperflyUsernamePasswordAuthenticationProcessingFilter` + - `SuperflyOTPAuthenticationProcessingFilter` + - `SuperflySelectRoleAuthenticationProcessingFilter` + - `SuperflySSOAuthenticationProcessingFilter` + - `MultiStepLoginUrlAuthenticationEntryPoint` + - `TwoStepAuthenticationProcessingFilter` + - `TwoStepAuthenticationProcessingFilterEntryPoint` + - `InsufficientAuthenticationHandlingFilter` +- CSRF и сессии: + - `CsrfValidator`, `CsrfValidatorImpl` + - `SSOUserSessionBindFilter` + - `UnauthorizedFailureHandler`. + +При этом важно: + +- **не тащить в classpath `jakarta.servlet.*`** — все web-компоненты в EE8-приложении должны работать только на `javax.servlet.*`; +- переиспользовать общий core (`authentication.*`, провайдеры, валидаторы, mapbuilder’ы) независимо от стека. + +Типовой пример такой интеграции уже реализован в Paynet (`SpringUIWebSecurityConfiguration`, `CustomUsernamePasswordAuthenticationProcessingFilter` и др.), который использует Superfly как удалённый SSO-сервер; теперь вместо кастомных фильтров можно переходить на стандартные классы из `superfly-spring-security-ee8`. + +## Контракт provided-scope для EE8-потребителей + +Начиная с **2.0-3**, экспортные EE8-модули перестали навязывать свой framework-стек +потребителю. Это меняет публикуемый контракт `superfly-spring-security-ee8` и +`superfly-wicket-ee8`. + +### Что изменилось + +Раньше эти модули объявляли Spring 5.x / Spring Security 5.8.x / Wicket 8 со `scope=compile`, +из-за чего они **транзитивно навязывались** любому потребителю. Эти javax-линии (Spring 5.3.x, +Spring Security 5.8.x, Wicket 8 + встроенный старый jQuery) находятся на EOL, известные CVE по ним +не закрыты и закрыты, скорее всего, не будут. Теперь все framework-зависимости в обоих модулях +объявлены со `scope=provided`: + +- они остаются на собственном compile/test-classpath модуля (компиляция адаптеров против javax-5.x работает); +- но **не попадают** на compile/runtime-classpath потребителя — то есть устаревший javax-стек + больше не «прилетает» к вам вместе с Superfly. + +Тот же приём уже применялся к `javax.servlet-api`: его всегда приносило само приложение/контейнер. + +> **Боевой EE10-сервер не затронут.** `superfly-web` работает на Spring 6 / Wicket 10; эти CVE +> там не используются. Изменение касается только публикуемых EE8-библиотек для внешних +> javax-потребителей. + +### Что теперь обязан добавить потребитель + +Поскольку Spring 5.x / Spring Security 5.8.x / Wicket 8 больше не приходят транзитивно, +**EE8-приложение должно объявить их явно** (версии — javax-совместимые линии). Минимальный набор: + +```xml + + + org.springframework.security + spring-security-web + 5.8.16 + + + org.springframework.security + spring-security-config + 5.8.16 + + + org.springframework.security + spring-security-core + 5.8.16 + + + org.springframework + spring-web + 5.3.39 + + + + + + org.apache.wicket + wicket + 8.18.0 + pom + +``` + +Если у приложения уже есть собственный javax-совместимый Spring/Wicket-стек (как у Paynet), +ничего добавлять не нужно — его и подхватит provided-зависимость. + +### Гарантия от регрессии + +В оба EE8-модуля встроен `maven-enforcer` (`bannedDependencies`), который **роняет сборку**, если +какая-либо из этих зависимостей снова окажется в scope `compile`/`runtime` (т.е. начнёт течь +транзитивно). Остаточные находки OWASP по самим provided-либам подавлены **узкими version-pinned +suppressions** (`src/main/dependency-check/suppressions.xml`) с обоснованием «provided, не +отгружается транзитивно, не рантайм EE10-сервера». Пины строго привязаны к 5.x/Wicket-8, поэтому +открытые находки по in-line EE10-стеку (Spring 6 / Spring Security 6.4.x) не маскируются. + +## Итог + +- **Клиент:** для EE10 используйте `superfly-client-ee10` или фасад `superfly-client`; для EE8 — `superfly-client-ee8`. +- **Wicket:** для Wicket 10 (Jakarta) — `superfly-wicket`; для Wicket 8 (EE8) — `superfly-wicket-ee8`. +- **Spring Security:** + - общий core берётся из `superfly-spring-security-core`; + - для EE10 можно использовать готовые web-компоненты на `jakarta.servlet.*` из `superfly-spring-security`; + - для EE8 — использовать общий core и готовый web-адаптер `superfly-spring-security-ee8` на `javax.servlet.*` и Spring Security 5.8.x. +- **provided-scope (с 2.0-3):** framework-стек EE8-модулей (Spring 5.x / Spring Security 5.8.x / Wicket 8) больше не приходит транзитивно — потребитель подключает его сам; см. [«Контракт provided-scope»](#контракт-provided-scope-для-ee8-потребителей). + +## See Also + +- [Руководство по интеграции](integration-guide.md) — подключение к клиентскому приложению +- [Конфигурация](configuration.md) — настройка сервера Superfly diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 00000000..7411b477 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,81 @@ +[← Миграция EE8 / EE10](migration-client-ee8-ee10.md) · [Back to README](../README.md) + +# Выпуск релиза + +Инструкция по публикации новой версии Superfly в Maven Central. + +## Требования + +- GPG-ключ для подписи артефактов +- Учётная запись на [Sonatype Central Portal](https://central.sonatype.com/) +- Maven-профиль `gpg-sign` в `~/.m2/settings.xml` + +## Настройка окружения + +### 1. Создать / проверить GPG-ключ + +```bash +gpg --list-keys +# Если ключа нет: +gpg --gen-key +``` + +### 2. Отправить ключ на keyserver + +```bash +gpg --keyserver hkp://keyserver.ubuntu.com --send-keys YOUR_KEY_ID +``` + +### 3. Настроить `~/.m2/settings.xml` + +```xml + + + + central + ваш_логин_sonatype + ваш_токен_sonatype + + + +``` + +## Выполнение релиза + +### Автоматически (рекомендуется) + +```bash +chmod +x release.sh +./release.sh ваш_пароль_gpg +``` + +Скрипт `release.sh` выполняет подготовку и деплой в один шаг. + +### Вручную + +```bash +# 1. Подготовить релиз (обновляет версию, создаёт тег) +./mvnw --batch-mode release:prepare + +# 2. Выполнить релиз (подписывает и публикует артефакты) +./mvnw release:perform -P gpg-sign -Dgpg.passphrase=ваш_пароль_gpg +``` + +## Проверка результатов + +После успешного деплоя артефакты публикуются в Maven Central автоматически +(если `autoPublish=true`). Иначе — перейдите на [Central Portal](https://central.sonatype.com/) +и завершите публикацию вручную. + +## Устранение неполадок + +| Проблема | Решение | +|----------|---------| +| GPG-ключ не найден | Убедитесь, что ключ создан и экспортирован на keyserver | +| Ошибка аутентификации Sonatype | Проверьте `settings.xml`, используйте токен (не пароль) | +| Зависимости недоступны | Проверьте VPN и доступ к репозиторию `maven.pne.io` | + +## See Also + +- [Установка и запуск](getting-started.md) — сборка из исходников +- [Конфигурация](configuration.md) — настройка окружения diff --git a/docs/ssl-mtls.md b/docs/ssl-mtls.md new file mode 100644 index 00000000..a12c1adc --- /dev/null +++ b/docs/ssl-mtls.md @@ -0,0 +1,296 @@ +[← Руководство по интеграции](integration-guide.md) · [Back to README](../README.md) + +# SSL / mTLS — настройка клиентского соединения + +Описывает как Superfly-клиент устанавливает HTTPS-соединение к серверу, какие проверки выполняет JDK и как правильно их настроить. + +--- + +## Как работает TLS-соединение: две независимые проверки + +При каждом соединении JDK выполняет **два разных** шага верификации. Многие думают что это одно и то же — это не так. + +### Шаг 1 — Certificate chain validation (TrustManager) + +**Вопрос:** "Этому сертификату вообще можно доверять?" + +JDK берёт сертификат сервера, смотрит кем он подписан (Issuer), находит этого CA в truststore и математически проверяет подпись. Если CA есть в truststore — цепочка доверия установлена. + +``` +Сервер прислал: cert { CN=superfly-server, Issuer=myCA, ... } +Truststore: содержит cert { CN=myCA } + +JDK: подпись myCA на сертификате сервера валидна? → ✅ доверяем +``` + +Кастомный `AuthSSLX509TrustManager` из модуля `superfly-httpclient-hc5` оборачивает стандартный JDK TrustManager и дополнительно логирует информацию о сертификате — это полезно для отладки. Сама верификация цепочки выполняется делегатом (стандартный JDK). + +### Шаг 2 — Hostname verification (HostnameVerifier) + +**Вопрос:** "Этот сертификат выдан именно для того хоста, к которому мы подключаемся?" + +JDK сравнивает hostname из URL с полями сертификата: + +``` +URL: https://superfly.internal:8446/... +Cert CN: superfly-server +Cert SAN: DNS:superfly.internal, DNS:superfly-server + +Проверка: "superfly.internal" есть в SAN? → ✅ ok +``` + +Если сертификат содержит только `CN=superfly-server` без SAN, а мы подключаемся к `localhost`: + +``` +URL: https://localhost:8446/... +Cert CN: superfly-server +Cert SAN: (нет) + +Проверка: "localhost" == "superfly-server"? → ❌ +→ SSLHandshakeException: No name matching localhost found +``` + +**Важно:** шаг 2 происходит **после** шага 1. Даже если цепочка доверия установлена — JDK всё равно проверит hostname. + +### Зачем нужна проверка hostname? + +Защита от ситуации когда CA выдал легитимный сертификат другому сервису, и этот сервис (или атакующий, его скомпрометировавший) пытается выдать себя за нужный хост: + +``` +evil.internal получил cert { CN=evil.internal } от того же CA +Пытается выдать себя за superfly.internal + +Без hostname check: TrustManager говорит ок (cert от доверенного CA) → уязвимость +С hostname check: CN=evil.internal ≠ superfly.internal → отказ ✅ +``` + +--- + +## Компоненты SSL в superfly-httpclient-hc5 + +После миграции на Apache HttpClient 5 SSL-хелперы живут в модуле `superfly-httpclient-hc5` +(пакет `com.payneteasy.httpclient.contrib.ssl`); отдельный модуль `superfly-httpclient-ssl` +и commons-coupled класс `AuthSSLProtocolSocketFactory` (для Apache Commons HttpClient 3.x) удалены. + +| Класс | Назначение | +|-------|-----------| +| `JdkSslSocketFactoryBuilder` | Строит `SSLContext` (для HC5), `SSLSocketFactory` и `HostnameVerifier` из JKS-файлов | +| `AuthSSLX509TrustManager` | Обёртка над стандартным TrustManager с логированием сертификата | +| `AuthSSLX509KeyManager` | Обёртка над стандартным KeyManager с логированием | + +### JdkSslSocketFactoryBuilder + +Основной entry point для HC5 — `buildSslContext(...)`: собирает `SSLContext` из keystore + truststore, +который передаётся в `ApacheHC5HttpClient.Builder.sslContext(...)`. + +```java +// SSLContext для HC5 (keyStore/trustStore — java.net.URL; любой может быть null) +SSLContext ssl = JdkSslSocketFactoryBuilder.buildSslContext( + keyStoreUrl, keyStorePassword, + trustStoreUrl, trustStorePassword +); + +// HostnameVerifier по CN вместо hostname (для dev / self-signed) +HostnameVerifier verifier = JdkSslSocketFactoryBuilder.buildCnHostnameVerifier("superfly-server"); + +// Legacy: SSLSocketFactory (если нужен напрямую, вне HC5) +SSLSocketFactory factory = JdkSslSocketFactoryBuilder.buildSocketFactory( + keyStoreUrl, keyStorePassword, trustStoreUrl, trustStorePassword); +``` + +--- + +## Конфигурация SSL в ApacheHC5HttpClient + +SSL/mTLS настраивается **один раз при создании** клиента через builder — HC5 использует +пул соединений с единым SSL-контекстом: + +| Метод builder | Тип | Назначение | +|---------------|-----|-----------| +| `.sslContext(SSLContext)` | `SSLContext` | Какому CA доверяем + клиентский сертификат для mTLS (шаги 1). `null` → JVM default | +| `.hostnameVerifier(HostnameVerifier)` | `HostnameVerifier` | Проверка hostname (шаг 2). `null` → стандартная проверка | + +```java +try (ApacheHC5HttpClient client = ApacheHC5HttpClient.builder() + .sslContext(ssl) + .hostnameVerifier(verifier) + .build()) { + // client.send(request, params) +} +``` + +> Поля `sslSocketFactory` / `hostnameVerifier` в per-request `HttpRequestParameters` +> игнорируются HC5 — SSL фиксируется на уровне пула при создании клиента. + +--- + +## Сценарии использования + +### Сценарий 1: HTTPS с публичным сертификатом (прод) + +Сертификат выдан доверенным публичным CA (Let's Encrypt, DigiCert и т.д.), CN/SAN соответствует реальному hostname. + +```java +// sslContext/hostnameVerifier не нужны — JDK использует системный truststore +ApacheHC5HttpClient client = ApacheHC5HttpClient.builder().build(); +``` + +### Сценарий 2: HTTPS с кастомным CA, CN совпадает с hostname (прод с внутренним PKI) + +``` +Cert: CN=superfly.internal, SAN: DNS:superfly.internal +URL: https://superfly.internal:8446/... +``` + +```java +SSLContext ssl = JdkSslSocketFactoryBuilder.buildSslContext( + keyStoreUrl, keyStorePassword, + trustStoreUrl, trustStorePassword // truststore с кастомным CA +); + +ApacheHC5HttpClient client = ApacheHC5HttpClient.builder() + .sslContext(ssl) // кастомный CA + // hostnameVerifier не нужен — CN/SAN совпадает с hostname + .build(); +``` + +### Сценарий 3: mTLS, CN не совпадает с hostname (dev / localhost) + +``` +Cert: CN=superfly-server (без SAN) +URL: https://localhost:8446/... +``` + +Стандартный hostname verifier откажет ("localhost" ≠ "superfly-server"). Решения: + +**Вариант A — CN-based verifier (рекомендуется для dev):** + +```java +SSLContext ssl = JdkSslSocketFactoryBuilder.buildSslContext( + keyStoreUrl, keyStorePassword, trustStoreUrl, trustStorePassword); +HostnameVerifier verifier = JdkSslSocketFactoryBuilder.buildCnHostnameVerifier("superfly-server"); + +ApacheHC5HttpClient client = ApacheHC5HttpClient.builder() + .sslContext(ssl) + .hostnameVerifier(verifier) + .build(); +``` + +Верификатор проверяет CN сертификата вместо hostname. Безопасен при условии что приватный ключ CA под контролем (CA не скомпрометирован). + +**Вариант B — перевыпустить сертификат с SAN (рекомендуется для прода):** + +```bash +# Добавить в конфиг при генерации: +subjectAltName=DNS:superfly-server,DNS:localhost,IP:127.0.0.1 +``` + +После этого стандартный verifier сработает корректно. + +--- + +## Конфигурация в клиентском приложении (Spring) + +Клиентское приложение собирает `ApacheHC5HttpClient` как Spring-бин (`IHttpClient`) и применяет +SSL, когда заданы пути к keystore/truststore. Типичный паттерн @Bean-фабрики: + +```java +@Bean +public IHttpClient superflyHttpClient(SsoClientProperties cfg) throws Exception { + ApacheHC5HttpClient.Builder builder = ApacheHC5HttpClient.builder(); + if (cfg.getKeyStoreUrl() != null) { + builder.sslContext(JdkSslSocketFactoryBuilder.buildSslContext( + cfg.getKeyStoreUrl(), cfg.getKeyStorePassword(), + cfg.getTrustStoreUrl(), cfg.getTrustStorePassword())); + if (cfg.getExpectedServerCn() != null) { // CN сервера; null → стандартный hostname verifier + builder.hostnameVerifier( + JdkSslSocketFactoryBuilder.buildCnHostnameVerifier(cfg.getExpectedServerCn())); + } + } + return builder.build(); // AutoCloseable — Spring закроет пул на shutdown +} +``` + +Рекомендуемые параметры конфигурации приложения: + +| Параметр | Описание | +|----------|---------| +| `keyStoreUrl` / `keyStorePassword` | Keystore с клиентским сертификатом и приватным ключом (mTLS) | +| `trustStoreUrl` / `trustStorePassword` | Truststore с сертификатом CA сервера | +| `expectedServerCn` | CN сертификата сервера; `null` = стандартный hostname verifier (прод с корректным SAN) | + +Для прода с корректным SAN в сертификате — оставить `expectedServerCn` пустым. + +--- + +## Keystore / truststore: как создать для разработки + +```bash +# 1. Создать CA (корневой сертификат) +keytool -genkeypair -alias myCA -keyalg RSA -keysize 2048 \ + -dname "CN=myCA,C=RU" -validity 7300 \ + -keystore ca.jks -storepass changeit + +# 2. Создать сертификат сервера +keytool -genkeypair -alias superfly-server -keyalg RSA -keysize 2048 \ + -dname "CN=superfly-server,C=RU" \ + -ext "SAN=DNS:superfly-server,DNS:localhost,IP:127.0.0.1" \ + -validity 7300 -keystore server.jks -storepass changeit + +# 3. Подписать сертификат сервера через CA +keytool -certreq -alias superfly-server -keystore server.jks -storepass changeit | \ +keytool -gencert -alias myCA -keystore ca.jks -storepass changeit \ + -ext "SAN=DNS:superfly-server,DNS:localhost,IP:127.0.0.1" \ + -validity 7300 | \ +keytool -importcert -alias superfly-server -keystore server.jks -storepass changeit + +# 4. Создать truststore для клиентов (только публичный cert CA) +keytool -exportcert -alias myCA -keystore ca.jks -storepass changeit | \ +keytool -importcert -alias myCA -keystore cacert.jks -storepass changeit -noprompt +``` + +Сертификат созданный на шаге 3 имеет SAN с `localhost` → стандартный hostname verifier будет работать без `buildCnHostnameVerifier`. + +--- + +## Диагностика + +### "No name matching X found" + +``` +SSLHandshakeException: No name matching localhost found +``` + +Причина: cert CN ≠ hostname, SAN не задан или не включает нужный хост. + +Решения: +1. Добавить `expectedServerCn` в конфиг (dev-workaround) +2. Перевыпустить сертификат с нужным SAN + +### "PKIX path building failed" + +``` +SSLHandshakeException: PKIX path building failed +``` + +Причина: CA сервера нет в truststore клиента. + +Решение: добавить CA в `cacert.jks`: +```bash +keytool -importcert -alias myCA -file ca.crt -keystore cacert.jks -storepass changeit +``` + +### "Certificate expired" + +``` +CertificateExpiredException: NotAfter +``` + +Решение: перевыпустить сертификат сервера. + +### Включить подробный SSL лог + +Для диагностики TLS-хендшейка на уровне JVM: +``` +-Djavax.net.debug=ssl:handshake +``` diff --git a/docs/sso-http-client.md b/docs/sso-http-client.md new file mode 100644 index 00000000..0713a830 --- /dev/null +++ b/docs/sso-http-client.md @@ -0,0 +1,232 @@ +[← API Reference](api.md) · [Back to README](../README.md) · [Руководство по интеграции →](integration-guide.md) + +# SSO HTTP Client + +`SSOHttpServiceApiClient` — клиентская реализация `SSOService` в `superfly-remote-api`, +работающая поверх HTTP. Используется paynet и другими подсистемами для вызова RPC-эндпоинтов +SSO-сервера. + +## Принципы + +- **Минимум зависимостей.** superfly не тянет Micrometer, retry-библиотеки или health-framework. + Эти cross-cutting concerns решаются на стороне consumer через decorator pattern. +- **Чистый интерфейс `SSOService`.** Domain port не знает о транспорте, метриках, retry. +- **Композиция вместо наследования.** Транспорт инжектируется через конструктор. +- **HTTPS-only by default.** PCI DSS 4.2.1 compliance. + +## Архитектура + +``` +SSOService (interface) + ▲ + │ implements +SSOHttpServiceApiClient ──uses──> IHttpClient + │ + ┌─────────┴──────────┐ + ▼ ▼ + HttpClientImpl ApacheHC5HttpClient + (legacy, JDK) (production) +``` + +Транспорт инжектируется через конструктор — клиент не зависит от конкретной реализации HTTP. +Это позволяет подменять `IHttpClient` без правки `SSOHttpServiceApiClient`: для тестов +(EasyMock/Mockito), для production (Apache HC5 с connection pooling), для отладки. + +## Quickstart (5 минут) + +```java +// 1. Build transport (production: Apache HC5 + mTLS) +IHttpClient transport = ApacheHC5HttpClient.builder() + .sslContext(JdkSslSocketFactoryBuilder.buildSslContext(keyStoreUrl, ksPwd, trustStoreUrl, tsPwd)) + .hostnameVerifier(JdkSslSocketFactoryBuilder.buildCnHostnameVerifier("superfly-server")) + .build(); + +// 2. Build config +SSOClientConfig config = SSOClientConfig.builder() + .baseUrl("https://superfly.example.com/sso.service") + .subsystemName("paynet-ui") + .subsystemToken(System.getenv("SSO_TOKEN")) + .defaultParameters(HttpRequestParameters.builder() + .timeouts(new HttpTimeouts(5_000, 20_000)) + .build()) + .endpointParameter(Endpoint.AUTHENTICATE, + HttpRequestParameters.builder() + .timeouts(new HttpTimeouts(3_000, 8_000)).build()) + .endpointParameter(Endpoint.GET_EVENTS, + HttpRequestParameters.builder() + .timeouts(new HttpTimeouts(3_000, 90_000)).build()) + .build(); + +// 3. Build client +SSOService sso = new SSOHttpServiceApiClient(transport, config, new ApiSerializationManager()); + +// 4. Use +SSOUser user = sso.authenticate(new AuthenticateRequest("john", "secret")); + +// 5. On shutdown — закрывает transport pool +((AutoCloseable) sso).close(); +``` + +`SSOHttpServiceApiClient` thread-safe и предназначен для долгого жизненного цикла — +создавайте один экземпляр на приложение и переиспользуйте. + +## Per-endpoint таймауты + +В production разные эндпоинты имеют разные характеристики: + +| Эндпоинт | Назначение | Рекомендуемые connect / socket | +|---|---|---| +| `AUTHENTICATE`, `CHECK_OTP`, `PSEUDO_AUTHENTICATE` | Критический путь логина | **3s / 8s** (fast fail при сбое SSO) | +| `GET_EVENTS` | Long-polling | **3s / 90s** (server держит ≤ 75s + 15s буфер) | +| default (остальные) | Фоновые операции | **5s / 20s** | + +`SSOClientConfig.endpointParameter(...)` позволяет задать override per endpoint +(см. Quickstart выше). + +> **Важно про GET_EVENTS:** socket timeout должен быть **больше** server-side `waitTimeMs` +> минимум на 10-15 секунд, иначе при сетевой задержке клиент закроет соединение раньше +> чем сервер ответит. Согласуйте с `GetEventsRequest.waitTimeMs`. + +## Безопасность + +### HTTPS-only + +`SSOClientConfig` по умолчанию **запрещает** HTTP-схему — соответствие PCI DSS 4.2.1. +Попытка передать `http://...` в `baseUrl` бросает `IllegalArgumentException`. + +Для локальной разработки можно разрешить HTTP через JVM property: + +``` +-Dsuperfly.client.allowInsecureScheme=true +``` + +При этом в лог выводится `WARN`. **Использовать в production запрещено.** Рекомендуется +в production setup явно проверять отсутствие этого флага в стартап-скрипте контейнера. + +### mTLS + +mTLS настраивается через `ApacheHC5HttpClient.Builder.sslContext(...)` — SSL-контекст +создаётся **один раз** на клиент, не per-request: + +```java +SSLContext ssl = JdkSslSocketFactoryBuilder.buildSslContext( + keyStoreUrl, keyStorePassword, + trustStoreUrl, trustStorePassword); + +ApacheHC5HttpClient transport = ApacheHC5HttpClient.builder() + .sslContext(ssl) + .hostnameVerifier(JdkSslSocketFactoryBuilder.buildCnHostnameVerifier("superfly-cn")) + .build(); +``` + +См. [SSL/mTLS Integration](ssl-mtls.md) и [HC5 transport](httpclient-hc5.md). + +### Logging policy + +- `DEBUG` логи **не содержат тело** запроса/ответа — только длину (`body.length=N`). +- `DEBUG` логи **не содержат пароли** и `subsystemToken` (покрыто тестом + `SSOHttpServiceApiClientSecurityTest`). +- Если будете добавлять новые логи — соблюдайте эту policy, не логируйте payload. + +### Token lifecycle + +`subsystemToken` хранится immutable в `SSOClientConfig` и компилируется в заголовки один раз. +**Ротация токена = рестарт приложения** — это явное архитектурное решение для простоты. +Heap-dump risk минимизируется на уровне продакшен-окружения (restricted `/tmp` perms, +`-XX:+HeapDumpOnOutOfMemoryError` в защищённую директорию). + +## Обработка ошибок + +Сервер возвращает ошибки в формате `ExceptionWrapper`: + +```json +{ + "exceptionClass": "com.payneteasy.superfly.api.exceptions.UserExistsException", + "message": "User foo already exists", + "detailMessage": "..." +} +``` + +`SSOHttpServiceApiClient` парсит wrapper для **любого** не-200 ответа (включая 4xx/5xx) +и бросает типизированное доменное исключение (`UserExistsException`, +`PolicyValidationException`, …). Если wrapper не распознан — fallback к status-based +исключению (`SsoBadRequestException`, `SsoUnauthorizedException`, `SsoServerException`). + +Все исключения наследуются от `SsoException extends RuntimeException` — checked обработка +не требуется. + +## Lifecycle + +Клиент реализует `AutoCloseable`. `close()` делегирует в transport (`IHttpClient`), если +тот тоже `AutoCloseable` — это критично для production setup с Apache HC5, где транспорт +держит connection pool. + +В Spring-приложении — оберните в `@Bean(destroyMethod = "close")`: + +```java +@Bean(destroyMethod = "close") +public SSOService ssoService(IHttpClient transport, ApiSerializationManager serializer) { + return new SSOHttpServiceApiClient(transport, ssoConfig(), serializer); +} +``` + +## Метрики на стороне consumer + +`SSOService` — это интерфейс. superfly **сам не предоставляет** метрики, чтобы не тянуть +зависимости. Consumer добавляет инструментирование composition-pattern'ом за ~30 строк: + +```java +// в paynet / другом consumer'е +public class MetricsSSOService implements SSOService { + + private final SSOService delegate; + private final MeterRegistry registry; + + public MetricsSSOService(SSOService delegate, MeterRegistry registry) { + this.delegate = delegate; + this.registry = registry; + } + + @Override + public SSOUser authenticate(AuthenticateRequest req) { + return measure("authenticate", () -> delegate.authenticate(req)); + } + + // ... остальные методы аналогично — try/finally с Timer.Sample + + private T measure(String op, java.util.function.Supplier body) { + Timer.Sample sample = Timer.start(registry); + boolean ok = false; + try { + T result = body.get(); + ok = true; + return result; + } finally { + sample.stop(Timer.builder("sso.call.duration") + .tag("operation", op).tag("status", ok ? "success" : "error") + .register(registry)); + } + } +} + +// composition: +SSOService raw = new SSOHttpServiceApiClient(transport, config, serializer); +SSOService instrumented = new MetricsSSOService(raw, meterRegistry); +``` + +> **Совет:** различайте инфраструктурные ошибки от user-rejected (`UserNotFoundException`, +> `PolicyValidationException`) — иначе error-rate dashboard будет показывать failure при +> штатных невалидных логинах. Сделать через два разных catch-block (или instanceof check) +> с разными значениями тега `status`: `error` vs `rejected`. + +Аналогично делается **retry** для идемпотентных операций (`getEvents`, `getUsersWithActions`): +`RetryingSSOService implements SSOService` оборачивает другой `SSOService`. superfly не +включает retry потому что **idempotency специфична для каждого consumer'а** (paynet может +retry'ить `getEvents` 3 раза, jira-integration — один раз). + +## Ссылки + +- [API Reference](api.md) — серверная сторона: эндпоинты и формат ответов +- [SSL/mTLS Integration](ssl-mtls.md) — настройка взаимной аутентификации +- [Apache HC5 transport](httpclient-hc5.md) — connection pool детали +- [Integration Guide](integration-guide.md) — Spring Security обвязка diff --git a/pom.xml b/pom.xml index fb6aada6..8efeb49a 100644 --- a/pom.xml +++ b/pom.xml @@ -11,16 +11,21 @@ superfly-spi superfly-service superfly-web - superfly-httpclient-ssl + superfly-httpclient-hc5 superfly-common - superfly-client superfly-client-web-security superfly-client-opt - superfly-spring-security + superfly-spring-security-ee10 superfly-spi-support superfly-crypto superfly-wicket superfly-integration-test + superfly-client-core + superfly-client-ee8 + superfly-client-ee10 + superfly-spring-security-core + superfly-spring-security-ee8 + superfly-wicket-ee8 https://github.com/rpuch/superfly @@ -132,11 +137,24 @@ ${project.version} - + com.payneteasy.superfly - superfly-httpclient-ssl + superfly-httpclient-hc5 ${project.version} + + + org.apache.httpcomponents.client5 + httpclient5 + ${httpclient5.version} + + + commons-logging + commons-logging + + + com.payneteasy.superfly @@ -144,21 +162,52 @@ ${project.version} - + com.payneteasy.superfly - superfly-client + superfly-client-opt ${project.version} - + com.payneteasy.superfly - superfly-client-opt + superfly-client-core + ${project.version} + + + + com.payneteasy.superfly + superfly-client-ee8 + ${project.version} + + + + com.payneteasy.superfly + superfly-client-ee10 + ${project.version} + + + + com.payneteasy.superfly + superfly-spring-security-core ${project.version} + + + com.payneteasy.superfly + superfly-spring-security-ee8 + ${project.version} + + + + javax.servlet + javax.servlet-api + 4.0.1 + provided + com.payneteasy.superfly - superfly-spring-security + superfly-spring-security-ee10 ${project.version} @@ -190,7 +239,7 @@ com.fasterxml.jackson.dataformat jackson-dataformat-xml - 2.19.2 + ${jackson.version} @@ -321,7 +370,7 @@ ch.qos.logback logback-classic - 1.5.17 + 1.5.25 @@ -469,24 +518,6 @@ aspectjweaver ${aspectj.version} - - - org.quartz-scheduler - quartz - 2.3.2 - - - - commons-httpclient - commons-httpclient - 3.1 - - - commons-logging - commons-logging - - - commons-io @@ -529,24 +560,6 @@ - - - com.googlecode.xremoting - xremoting-core - 1.0-10 - - - - com.thoughtworks.xstream - xstream - 1.4.21 - - - - com.caucho - hessian - 4.0.66 - com.github.spullara.mustache.java @@ -591,6 +604,13 @@ wicket-tester ${wicket.version} + + + commons-fileupload + commons-fileupload + 1.6.0 + @@ -706,22 +726,6 @@ 1.84 - - - ca.juliusdavies - not-yet-commons-ssl - - 0.3.17-20180724 - - - log4j - log4j - - - @@ -769,6 +773,20 @@ passay 1.6.1 + + + com.github.ben-manes.caffeine + caffeine + 3.1.8 + + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + org.projectlombok @@ -858,6 +876,7 @@ org.apache.maven.plugins maven-javadoc-plugin + 3.11.2 ${java.version} none @@ -960,21 +979,6 @@ - - deploy - - - - org.sonatype.central - central-publishing-maven-plugin - - true - - - - - - gpg-sign @@ -1005,6 +1009,21 @@ + + deploy + + + + org.sonatype.central + central-publishing-maven-plugin + + true + + + + + + owasp-dependency-check @@ -1054,13 +1073,16 @@ 1.1-25 + 5.4.4 2.0.17 6.2.18 - 6.4.11 + 6.4.13 + 5.3.39 + 5.8.16 1.6.5 - 10.6.0 - 12.0.32 + 10.9.1 + 12.0.35 ${java.version} ${java.version} @@ -1068,6 +1090,8 @@ true utf-8 + 2.19.2 + 21 true true diff --git a/src/main/dependency-check/suppressions.xml b/src/main/dependency-check/suppressions.xml index 4c876109..8f3f6d98 100644 --- a/src/main/dependency-check/suppressions.xml +++ b/src/main/dependency-check/suppressions.xml @@ -1,358 +1,126 @@ - - + + - 851212e5584b21d22ec32a88f0fc5a60321adee3 - cpe:/a:pgp:pgp:1.6.6 - - - - f70cca6343136ff015ae059b3f2dd728b57397de - cpe:/a:pgp:pgp:1.6.6 - - - - 8ae1efd2f5083030fd84623816bcd3a6c0192999 - CVE-2001-0265 - - - - 8575fb242603f2f2906bc1dd2679502db30a21fe + superfly-crypto is our own helper module, falsely matched against cpe:/a:pgp:pgp. + ]]> + ^com\.payneteasy\.superfly:superfly-crypto:.*$ + cpe:/a:pgp:pgp CVE-2001-0265 - - - - 15eab6dce094fa9ea71fa6f6d69056b9fe6e493c - CVE-2005-0366 - - - - - - 1aa1579ae5ecd41920c4f355b0a9ef40b68315dd - cpe:/a:sun:javamail:1.4 - - - - - - 964cd74171f427720480efdec40a7c7f6e58426a - CVE-2014-3577 - - - - 964cd74171f427720480efdec40a7c7f6e58426a - CVE-2012-6153 - - - - - - 964cd74171f427720480efdec40a7c7f6e58426a - CVE-2015-5262 - - - - - - 31b230d2853993661055176acb0d92f8eb18192c - cpe:/a:pgp:pgp:1.7.1 - - - - - - b92c863d75bbdbfa88f08301cb243f406a58bec4 - cpe:/a:openpgp:openpgp:1.58 - - - - - - 04ff14d809195b711fd6bcc87e6777f886730ca1 - CVE-2016-1000031 - - - + - a3706c9bbd48d1933cb38bafcea07c2b6148e878 - CVE-2018-1258 - - - - 33c4ecc4d51632b4c52e59cc129779a0e5bfefac - CVE-2018-1258 - - - - a9384989345a88c3ad71b2d6fcfb43cfc966e87f - CVE-2018-1258 - - - - 7a93b88235e4a8ac4f46116e867cecbc3f524b48 - CVE-2018-1258 + ^org\.bouncycastle:bcpg-jdk18on:.*$ + cpe:/a:openpgp:openpgp + + - 3a3f6d8e593bce5624bfe0aa4a898bc1826d621a - CVE-2018-1258 + ^org\.springframework:spring-web:5\..*$ + CVE-2016-1000027 + + =5.8.24 / >=6.4.15). + Remove once the patched spring-security release is available and bumped. ]]> - 0d718f457cba424663223fbd27e7704ceb9cfee4 - CVE-2018-1258 + ^org\.springframework\.security:spring-security-.*:(5\.8\.|6\.4\.).*$ + CVE-2026-22732 + - - - 476fb3b3bb3716cad797cd054ce45f89445794e9 - CVE-2013-7285 - CVE-2016-3674 - CVE-2017-7957 - CVE-2020-26217 - CVE-2020-26258 - CVE-2020-26259 - CVE-2021-21341 - CVE-2021-21342 - CVE-2021-21343 - CVE-2021-21344 - CVE-2021-21345 - CVE-2021-21346 - CVE-2021-21347 - CVE-2021-21348 - CVE-2021-21349 - CVE-2021-21350 - CVE-2021-21351 - CVE-2021-29505 - CVE-2021-39139 - CVE-2021-39140 - CVE-2021-39141 - CVE-2021-39144 - CVE-2021-39145 - CVE-2021-39146 - CVE-2021-39147 - CVE-2021-39148 - CVE-2021-39149 - CVE-2021-39150 - CVE-2021-39151 - CVE-2021-39152 - CVE-2021-39153 - CVE-2021-39154 - CVE-2022-41966 - + skipProvidedScope is intentionally NOT enabled, so the aggregate scan still sees these + provided deps on the libraries themselves. The suppressions below clear those residual + findings with the justification "provided, not shipped transitively, not the EE10 runtime". + Each is VERSION-PINNED so it cannot mask the in-line EE10 stack: + - spring-core is pinned 5.3.x (spring-core 6.2.18 is clean — must stay visible). + - spring-security is pinned 5.8.x: CVE-2026-22746 / -22748 are SHARED with the open EE10 + 6.4.13 finding; a cve-only or 6.4-matching suppress would hide a real EE10 issue. + ============================================================================ --> - - - - 4b0d63c7e2f55e0621bb4796a5860e36ba73bd74 - CVE-2017-5929 - CVE-2021-42550 + + + + ^org\.springframework:spring-.*:5\.3\..*$ + CVE-2024-38820 + CVE-2026-22735 + CVE-2026-22737 + CVE-2026-22740 + CVE-2026-22741 + CVE-2026-22745 - + - 433836da7e015f2eb3fc386817de88b78248f6ef - CVE-2015-9251 - CVE-2019-11358 - CVE-2020-11022 - CVE-2020-11023 - - - - 5a9dcfbef655a2668e78baebeaa8dc6f41d8dabb - CVE-2015-9251 - CVE-2019-11358 - CVE-2020-11022 - CVE-2020-11023 - - - - 26e6b63b81813d8ad942c90d369df2673602b812 - CVE-2015-9251 - CVE-2019-11358 - CVE-2020-11022 - CVE-2020-11023 + ^org\.springframework\.security:spring-security-.*:5\.8\..*$ + CVE-2026-22746 + CVE-2026-22748 + + - 69bb69e25ca7d5ef0935317584e6153f3fd9a88c + ^.*wicket-core-8\..*\.jar.*jquery.*\.js$ CVE-2015-9251 CVE-2019-11358 CVE-2020-11022 CVE-2020-11023 - - - - - - 2c0ea6a839df080c4bb7925f7e46840ae284e5bc - CVE-2012-5881 - CVE-2012-5882 - CVE-2012-5883 - - - - 81fdbfa751ce9876375810febb66dd7ea5430405 - CVE-2012-5881 - CVE-2012-5882 - CVE-2012-5883 - - - - bf1d45bf1645918f24cbfd35726a65632ead2f66 - CVE-2012-5881 - CVE-2012-5882 - CVE-2012-5883 - - - - 4891d997f6630d463539a5f9eb88d56b21e7eddb - CVE-2012-5881 - CVE-2012-5882 - CVE-2012-5883 - - - - 1c3f7df5fd8cf5937bb26f0ca76d09dc3e87eb5c - CVE-2012-5881 - CVE-2012-5882 - CVE-2012-5883 - - - - f58c82c7e8388d7945996d97d327e51720b8db24 - CVE-2012-5881 - CVE-2012-5882 - CVE-2012-5883 - - - - 63aa83b8b83f9569802c3b24819cc477f4b5fdf3 - CVE-2012-5881 - CVE-2012-5882 - CVE-2012-5883 - - - - f63225ad08c2769101b7db1604bb84eef07d6747 - CVE-2012-5881 - CVE-2012-5882 - CVE-2012-5883 - - - - 117431b4de69f8927468fe55d068c495ebe77e27 - CVE-2012-5881 - CVE-2012-5882 - CVE-2012-5883 - - - - 14ab8b78242e9c57899f4be78f393d7ccfaa0cc4 - CVE-2012-5881 - CVE-2012-5882 - CVE-2012-5883 - - - - 03fbda977f5ef8619db207e2f6f140a5ed01f4f9 - CVE-2012-5881 - CVE-2012-5882 - CVE-2012-5883 - - - - - - 062f72ec06f31a6c31a3f3355fce0384b21126d7 - cpe:/a:openpgp:openpgp:1.70 + ^jquery issue:.*$ diff --git a/superfly-client/pom.xml b/superfly-client-core/pom.xml similarity index 53% rename from superfly-client/pom.xml rename to superfly-client-core/pom.xml index c22f4b6e..fda23387 100644 --- a/superfly-client/pom.xml +++ b/superfly-client-core/pom.xml @@ -5,47 +5,47 @@ com.payneteasy.superfly 2.0-3-SNAPSHOT - superfly-client - Superfly Client - Contains classes used to use Superfly facilities by clients + superfly-client-core + Superfly Client Core + Core client logic and session abstraction for Superfly; no Servlet/JAX-RS dependencies (EE8/EE10 agnostic) + - - com.fasterxml.jackson.dataformat - jackson-dataformat-xml - 2.18.3 - - - com.payneteasy.superfly superfly-remote-api - com.payneteasy.superfly superfly-common - org.slf4j slf4j-api - - jakarta.servlet - jakarta.servlet-api + org.projectlombok + lombok + provided - - jakarta.annotation - jakarta.annotation-api + + org.springframework + spring-beans + true - - org.springframework.security spring-security-core test + + org.reflections + reflections + + + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/ActionDescriptionBean.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/ActionDescriptionBean.java similarity index 99% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/ActionDescriptionBean.java rename to superfly-client-core/src/main/java/com/payneteasy/superfly/client/ActionDescriptionBean.java index 2e119557..c143098f 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/ActionDescriptionBean.java +++ b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/ActionDescriptionBean.java @@ -4,7 +4,6 @@ import java.io.Serial; - public class ActionDescriptionBean extends ActionDescription { @Serial private static final long serialVersionUID = -513189437457390542L; diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/ActionDescriptionCollector.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/ActionDescriptionCollector.java similarity index 89% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/ActionDescriptionCollector.java rename to superfly-client-core/src/main/java/com/payneteasy/superfly/client/ActionDescriptionCollector.java index 212d09bf..022e9f00 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/ActionDescriptionCollector.java +++ b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/ActionDescriptionCollector.java @@ -1,14 +1,13 @@ package com.payneteasy.superfly.client; -import java.util.List; - - import com.payneteasy.superfly.api.ActionDescription; import com.payneteasy.superfly.client.exception.CollectionException; +import java.util.List; + /** * Collects and returns action descriptions. - * + * * @author Roman Puchkovskiy */ public interface ActionDescriptionCollector { @@ -16,7 +15,7 @@ public interface ActionDescriptionCollector { * Returns action descriptions. * * @return action descriptions - * @throws CollectionException + * @throws CollectionException on collection error */ List collect() throws CollectionException; } diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/ActionDescriptionRoot.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/ActionDescriptionRoot.java similarity index 100% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/ActionDescriptionRoot.java rename to superfly-client-core/src/main/java/com/payneteasy/superfly/client/ActionDescriptionRoot.java diff --git a/superfly-client-core/src/main/java/com/payneteasy/superfly/client/ActionsXmlParser.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/ActionsXmlParser.java new file mode 100644 index 00000000..06645ad9 --- /dev/null +++ b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/ActionsXmlParser.java @@ -0,0 +1,103 @@ +package com.payneteasy.superfly.client; + +import com.payneteasy.superfly.api.ActionDescription; + +import javax.xml.XMLConstants; +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * Parses actions XML format using StAX (no external XML library required). + * Expected format: {@code ...} + */ +public final class ActionsXmlParser { + + private static final String ELEMENT_ACTIONS = "actions"; + private static final String ELEMENT_ACTION = "action"; + private static final String ATTR_NAME = "name"; + private static final String ATTR_DESCRIPTION = "description"; + + private final XMLInputFactory inputFactory; + + public ActionsXmlParser() { + this.inputFactory = createSecureInputFactory(); + } + + private static XMLInputFactory createSecureInputFactory() { + XMLInputFactory factory = XMLInputFactory.newInstance(); + try { + factory.setProperty(XMLConstants.FEATURE_SECURE_PROCESSING, true); + } catch (Exception ignored) { + // property may not be supported by all implementations + } + return factory; + } + + /** + * Parses XML from the given file and returns a list of action descriptions. + * + * @param file XML file with root element {@code } + * @return list of actions (never null) + * @throws IOException if reading or parsing fails + */ + public List parse(File file) throws IOException { + try (InputStream in = new FileInputStream(file)) { + return parse(in); + } + } + + /** + * Parses XML from the given input stream. Caller is responsible for closing the stream. + * + * @param inputStream XML input with root element {@code } + * @return list of actions (never null) + * @throws IOException if parsing fails + */ + public List parse(InputStream inputStream) throws IOException { + try { + XMLStreamReader reader = inputFactory.createXMLStreamReader(inputStream); + try { + return parse(reader); + } finally { + reader.close(); + } + } catch (XMLStreamException e) { + throw new IOException("Failed to parse actions XML", e); + } + } + + private List parse(XMLStreamReader reader) throws XMLStreamException { + List actions = new ArrayList<>(); + + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT) { + String localName = reader.getLocalName(); + if (ELEMENT_ACTION.equals(localName)) { + String name = getAttribute(reader, ATTR_NAME); + String description = getAttribute(reader, ATTR_DESCRIPTION); + actions.add(new ActionDescriptionBean(name != null ? name : "", description)); + } + } + } + + return actions; + } + + private static String getAttribute(XMLStreamReader reader, String localName) { + for (int i = 0; i < reader.getAttributeCount(); i++) { + if (localName.equals(reader.getAttributeLocalName(i))) { + return reader.getAttributeValue(i); + } + } + return null; + } +} diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/CommaDelimitedListActionDescriptionCollector.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/CommaDelimitedListActionDescriptionCollector.java similarity index 87% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/CommaDelimitedListActionDescriptionCollector.java rename to superfly-client-core/src/main/java/com/payneteasy/superfly/client/CommaDelimitedListActionDescriptionCollector.java index 44cf9160..2ea66396 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/CommaDelimitedListActionDescriptionCollector.java +++ b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/CommaDelimitedListActionDescriptionCollector.java @@ -1,20 +1,19 @@ package com.payneteasy.superfly.client; -import java.util.ArrayList; -import java.util.List; - import com.payneteasy.superfly.api.ActionDescription; import com.payneteasy.superfly.client.exception.CollectionException; import com.payneteasy.superfly.common.utils.StringUtils; +import java.util.ArrayList; +import java.util.List; + /** * ActionDescriptionCollector implementation which just takes a comma-delimited * list of action names and breaks it to fragments. - * + * * @author Roman Puchkovskiy */ -public class CommaDelimitedListActionDescriptionCollector implements - ActionDescriptionCollector { +public class CommaDelimitedListActionDescriptionCollector implements ActionDescriptionCollector { private String commaDelimitedList; @@ -22,13 +21,13 @@ public void setCommaDelimitedList(String commaDelimitedList) { this.commaDelimitedList = commaDelimitedList; } + @Override public List collect() throws CollectionException { String[] actionNames = StringUtils.commaDelimitedListToStringArray(commaDelimitedList); - List result = new ArrayList(actionNames.length); + List result = new ArrayList<>(actionNames.length); for (String actionName : actionNames) { result.add(new ActionDescription(actionName)); } return result; } - } diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/LowercaseTransformer.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/LowercaseTransformer.java similarity index 94% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/LowercaseTransformer.java rename to superfly-client-core/src/main/java/com/payneteasy/superfly/client/LowercaseTransformer.java index b330721b..932061f3 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/LowercaseTransformer.java +++ b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/LowercaseTransformer.java @@ -2,13 +2,13 @@ /** * Transformer which converts string to lowercase. - * + * * @author Roman Puchkovskiy */ public class LowercaseTransformer implements StringTransformer { + @Override public String transform(String s) { return s.toLowerCase(); } - } diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/MergingActionDescriptionCollector.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/MergingActionDescriptionCollector.java similarity index 84% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/MergingActionDescriptionCollector.java rename to superfly-client-core/src/main/java/com/payneteasy/superfly/client/MergingActionDescriptionCollector.java index 48278ee0..2c36b0b4 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/MergingActionDescriptionCollector.java +++ b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/MergingActionDescriptionCollector.java @@ -1,22 +1,21 @@ package com.payneteasy.superfly.client; +import com.payneteasy.superfly.api.ActionDescription; +import com.payneteasy.superfly.client.exception.CollectionException; + import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import com.payneteasy.superfly.api.ActionDescription; -import com.payneteasy.superfly.client.exception.CollectionException; - /** * ActionDescriptionCollector implementation which takes outputs from several * collectors and merges them. First collector in the list has the highest * priority. - * + * * @author Roman Puchkovskiy */ -public class MergingActionDescriptionCollector implements - ActionDescriptionCollector { +public class MergingActionDescriptionCollector implements ActionDescriptionCollector { private ActionDescriptionCollector[] collectors; @@ -24,8 +23,9 @@ public void setCollectors(ActionDescriptionCollector[] collectors) { this.collectors = collectors; } + @Override public List collect() throws CollectionException { - Map descriptionsMap = new HashMap(); + Map descriptionsMap = new HashMap<>(); for (ActionDescriptionCollector collector : collectors) { List descriptions = collector.collect(); for (ActionDescription description : descriptions) { @@ -34,8 +34,6 @@ public List collect() throws CollectionException { } } } - - return new ArrayList(descriptionsMap.values()); + return new ArrayList<>(descriptionsMap.values()); } - } diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/MethodInvoker.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/MethodInvoker.java similarity index 74% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/MethodInvoker.java rename to superfly-client-core/src/main/java/com/payneteasy/superfly/client/MethodInvoker.java index 0e496e50..f0c571fd 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/MethodInvoker.java +++ b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/MethodInvoker.java @@ -4,5 +4,5 @@ * @author rpuch */ public interface MethodInvoker { - R invoke(Object ... arguments); + R invoke(Object... arguments); } diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/PrefixRemovingTransformer.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/PrefixRemovingTransformer.java similarity index 97% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/PrefixRemovingTransformer.java rename to superfly-client-core/src/main/java/com/payneteasy/superfly/client/PrefixRemovingTransformer.java index ed80d64b..03c9f76c 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/PrefixRemovingTransformer.java +++ b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/PrefixRemovingTransformer.java @@ -2,7 +2,7 @@ /** * Transformer which removes a matching prefix if finds one. - * + * * @author Roman Puchkovskiy */ public class PrefixRemovingTransformer implements StringTransformer { @@ -13,6 +13,7 @@ public void setPrefixes(String[] prefixes) { this.prefixes = prefixes; } + @Override public String transform(String s) { for (String prefix : prefixes) { if (s.startsWith(prefix)) { @@ -21,5 +22,4 @@ public String transform(String s) { } return s; } - } diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/ReflectionInvoker.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/ReflectionInvoker.java similarity index 61% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/ReflectionInvoker.java rename to superfly-client-core/src/main/java/com/payneteasy/superfly/client/ReflectionInvoker.java index e7f8c370..0438926a 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/ReflectionInvoker.java +++ b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/ReflectionInvoker.java @@ -16,22 +16,20 @@ public ReflectionInvoker(Object target) { this.target = target; } - public MethodInvoker method(String methodName, Class ... parameterTypes) { + public MethodInvoker method(String methodName, Class... parameterTypes) { final Method method; try { method = target.getClass().getMethod(methodName, parameterTypes); } catch (NoSuchMethodException e) { throw new IllegalStateException("No such method: " + methodName, e); } - return new MethodInvoker() { - @Override - public T invoke(Object... arguments) { - try { - @SuppressWarnings("unchecked") final T invoke = (T) method.invoke(target, arguments); - return invoke; - } catch (IllegalAccessException | InvocationTargetException e) { - throw new IllegalStateException("Cannot invoke", e); - } + return arguments -> { + try { + @SuppressWarnings("unchecked") + T invoke = (T) method.invoke(target, arguments); + return invoke; + } catch (IllegalAccessException | InvocationTargetException e) { + throw new IllegalStateException("Cannot invoke", e); } }; } diff --git a/superfly-client-opt/src/main/java/com/payneteasy/superfly/client/ScanningActionDescriptionCollector.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/ScanningActionDescriptionCollector.java similarity index 67% rename from superfly-client-opt/src/main/java/com/payneteasy/superfly/client/ScanningActionDescriptionCollector.java rename to superfly-client-core/src/main/java/com/payneteasy/superfly/client/ScanningActionDescriptionCollector.java index de54e566..22e6884b 100644 --- a/superfly-client-opt/src/main/java/com/payneteasy/superfly/client/ScanningActionDescriptionCollector.java +++ b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/ScanningActionDescriptionCollector.java @@ -2,17 +2,12 @@ import com.payneteasy.superfly.api.ActionDescription; import com.payneteasy.superfly.client.exception.CollectionException; -import lombok.Setter; import org.reflections.Reflections; import org.reflections.scanners.MethodAnnotationsScanner; import org.reflections.util.ConfigurationBuilder; import org.reflections.util.FilterBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; -import org.springframework.util.Assert; -import org.springframework.util.StringUtils; import java.lang.annotation.Annotation; import java.lang.reflect.Method; @@ -20,6 +15,7 @@ import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Objects; import java.util.Set; /** @@ -30,8 +26,7 @@ * * @author Roman Puchkovskiy */ -@Setter -public class ScanningActionDescriptionCollector implements ActionDescriptionCollector { +public class ScanningActionDescriptionCollector implements ActionDescriptionCollector { private static final Logger logger = LoggerFactory.getLogger(ScanningActionDescriptionCollector.class); @@ -42,6 +37,23 @@ public class ScanningActionDescriptionCollector implements ActionDescriptionCol private ValuesExtractor valuesExtractor = new ValueAttributeValuesExtractor(); private Set notCollectedActions = Collections.singleton("action_temp_password"); + public void setBasePackages(String[] basePackages) { + this.basePackages = basePackages != null ? basePackages : new String[0]; + } + + public void setAnnotationClass(Class annotationClass) { + this.annotationClass = annotationClass; + } + + public void setValuesExtractor(ValuesExtractor valuesExtractor) { + this.valuesExtractor = valuesExtractor; + } + + public void setNotCollectedActions(Set notCollectedActions) { + this.notCollectedActions = notCollectedActions != null ? notCollectedActions : Collections.emptySet(); + } + + @Override public List collect() throws CollectionException { Reflections reflections = new Reflections(new ConfigurationBuilder() .forPackages(basePackages) @@ -50,24 +62,23 @@ public List collect() throws CollectionException { ); Set extractedActionNames = new HashSet<>(); - final Set> annotatedClasses = reflections.getTypesAnnotatedWith(annotationClass); + Set> annotatedClasses = reflections.getTypesAnnotatedWith(annotationClass); for (Class clazz : annotatedClasses) { - final Annotation annotation = clazz.getAnnotation(annotationClass); - Assert.notNull(annotation, "annotation cannot be null"); + Annotation annotation = clazz.getAnnotation(annotationClass); + Objects.requireNonNull(annotation, "annotation cannot be null"); Collections.addAll(extractedActionNames, valuesExtractor.extract(annotation)); } - final Set annotatedMethods = reflections.getMethodsAnnotatedWith(annotationClass); + Set annotatedMethods = reflections.getMethodsAnnotatedWith(annotationClass); for (Method method : annotatedMethods) { - final Annotation annotation = method.getAnnotation(annotationClass); - Assert.notNull(annotation, "annotation cannot be null"); + Annotation annotation = method.getAnnotation(annotationClass); + Objects.requireNonNull(annotation, "annotation cannot be null"); Collections.addAll(extractedActionNames, valuesExtractor.extract(annotation)); } if (logger.isDebugEnabled()) { - logger.debug("Collected the following actions: " + extractedActionNames); + logger.debug("Collected the following actions: {}", extractedActionNames); } - return buildDescriptions(extractedActionNames); } @@ -76,8 +87,8 @@ private String[] appendDots(String[] basePackages) { for (int i = 0; i < basePackages.length; i++) { String packageName = basePackages[i]; String dottedName = packageName; - if (StringUtils.hasText(packageName) && !packageName.endsWith(".")) { - dottedName += "."; + if (packageName != null && !packageName.isBlank() && !packageName.endsWith(".")) { + dottedName = packageName + "."; } result[i] = dottedName; } diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/SessionToucher.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/SessionToucher.java similarity index 100% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/SessionToucher.java rename to superfly-client-core/src/main/java/com/payneteasy/superfly/client/SessionToucher.java diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/SessionToucherImpl.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/SessionToucherImpl.java similarity index 68% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/SessionToucherImpl.java rename to superfly-client-core/src/main/java/com/payneteasy/superfly/client/SessionToucherImpl.java index 0436591b..eb42960f 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/SessionToucherImpl.java +++ b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/SessionToucherImpl.java @@ -2,24 +2,31 @@ import com.payneteasy.superfly.api.SSOService; import com.payneteasy.superfly.api.request.TouchSessionsRequest; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; -import java.util.*; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Queue; +import java.util.Set; import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.Timer; +import java.util.TimerTask; /** * Default session toucher implementation. Accumulates IDs of sessions * and sends them in bulks every minute (by default). + * Call {@link #startup()} after setting SSOService (e.g. from adapter or Spring @PostConstruct). + * Call {@link #shutdown()} on context destroy (e.g. from adapter or Spring @PreDestroy). * * @author Roman Puchkovskiy */ -public class SessionToucherImpl implements SessionToucher { +public class SessionToucherImpl implements SessionToucher, InitializingBean, DisposableBean { private boolean enabled = true; - private SSOService ssoService; - private final int flushPeriodInSeconds; + private SSOService ssoService; + private final int flushPeriodInSeconds; - private final Queue queue = new ConcurrentLinkedQueue(); + private final Queue queue = new ConcurrentLinkedQueue<>(); private Timer timer; public SessionToucherImpl() { @@ -38,7 +45,9 @@ public void setEnabled(boolean enabled) { this.enabled = enabled; } - @PostConstruct + /** + * Starts the toucher timer. Call after dependency injection (e.g. from Jakarta @PostConstruct or Spring). + */ public void startup() { if (ssoService == null) { throw new IllegalStateException("ssoService not set"); @@ -57,7 +66,19 @@ private long getFlushPeriodInMillis() { return flushPeriodInSeconds * 1000L; } - @PreDestroy + @Override + public void afterPropertiesSet() { + startup(); + } + + @Override + public void destroy() { + shutdown(); + } + + /** + * Stops the toucher timer. Call on context destroy (e.g. from Jakarta @PreDestroy or Spring). + */ public void shutdown() { if (timer != null) { timer.cancel(); diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/StringTransformer.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/StringTransformer.java similarity index 85% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/StringTransformer.java rename to superfly-client-core/src/main/java/com/payneteasy/superfly/client/StringTransformer.java index 2cb8455a..656f7b80 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/StringTransformer.java +++ b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/StringTransformer.java @@ -2,14 +2,14 @@ /** * Transforms a string. - * + * * @author Roman Puchkovskiy */ public interface StringTransformer { /** * Does transformation. * - * @param s string to transform + * @param s string to transform * @return transformed string */ String transform(String s); diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/SuperflyDataSender.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/SuperflyDataSender.java similarity index 80% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/SuperflyDataSender.java rename to superfly-client-core/src/main/java/com/payneteasy/superfly/client/SuperflyDataSender.java index 10fa948b..b45cb120 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/SuperflyDataSender.java +++ b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/SuperflyDataSender.java @@ -1,5 +1,13 @@ package com.payneteasy.superfly.client; +import com.payneteasy.superfly.api.ActionDescription; +import com.payneteasy.superfly.api.SSOService; +import com.payneteasy.superfly.api.request.SendSystemDataRequest; +import com.payneteasy.superfly.client.exception.CollectionException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; + import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; @@ -8,22 +16,13 @@ import java.util.Timer; import java.util.TimerTask; -import com.payneteasy.superfly.api.request.SendSystemDataRequest; -import jakarta.annotation.PostConstruct; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.payneteasy.superfly.api.ActionDescription; -import com.payneteasy.superfly.api.SSOService; -import com.payneteasy.superfly.client.exception.CollectionException; - /** - * Class that, upon instantiation and initialization in a Spring Application - * Context, automatically sends subsystem data to SSOService. + * Class that, upon instantiation and initialization, can send subsystem data to SSOService. + * Call {@link #afterPropertiesSet()} after setting dependencies (e.g. from Jakarta @PostConstruct or Spring). * * @author Roman Puchkovskiy */ -public class SuperflyDataSender { +public class SuperflyDataSender implements InitializingBean { private static final Logger logger = LoggerFactory.getLogger(SuperflyDataSender.class); @@ -38,8 +37,7 @@ public void setSsoService(SSOService ssoService) { this.ssoService = ssoService; } - public void setActionDescriptionCollector( - ActionDescriptionCollector actionDescriptionCollector) { + public void setActionDescriptionCollector(ActionDescriptionCollector actionDescriptionCollector) { this.actionDescriptionCollector = actionDescriptionCollector; } @@ -63,7 +61,9 @@ public void setAutoSend(boolean autoSend) { this.autoSend = autoSend; } - @PostConstruct + /** + * Initializes and optionally schedules send. Call after dependency injection (e.g. from Jakarta @PostConstruct or Spring). + */ public void afterPropertiesSet() throws Exception { if (autoSend) { Timer timer = new Timer(); @@ -82,7 +82,7 @@ protected ActionDescription[] obtainActionDescriptions() throws CollectionExcept for (ActionDescription action : actions) { action.setName(applyTransformers(action.getName())); } - return actions.toArray(new ActionDescription[actions.size()]); + return actions.toArray(new ActionDescription[0]); } private List leaveUniqueActionsOnly(List actions) { @@ -111,23 +111,20 @@ public void send() { logger.debug("Sending the following actions: {}", getActionsStringForLog(actionDescriptions)); } ssoService.sendSystemData(SendSystemDataRequest.builder() - .actionDescriptions(List.of(actionDescriptions)) - .subsystemIdentifier(getSubsystemIdentifier()) - .build()); + .actionDescriptions(List.of(actionDescriptions)) + .subsystemIdentifier(getSubsystemIdentifier()) + .build()); } catch (CollectionException e) { logger.error("Cannot send subsystem data", e); } } private String getActionsStringForLog(ActionDescription[] actionDescriptions) { - List names = new ArrayList(actionDescriptions.length); + List names = new ArrayList<>(actionDescriptions.length); for (ActionDescription description : actionDescriptions) { names.add(description.getName()); } - Collections.sort(names); - return names.toString(); } - } diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/TransformingActionDescriptionCollector.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/TransformingActionDescriptionCollector.java similarity index 91% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/TransformingActionDescriptionCollector.java rename to superfly-client-core/src/main/java/com/payneteasy/superfly/client/TransformingActionDescriptionCollector.java index ba2471e4..d70df231 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/TransformingActionDescriptionCollector.java +++ b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/TransformingActionDescriptionCollector.java @@ -1,18 +1,17 @@ package com.payneteasy.superfly.client; -import java.util.List; - import com.payneteasy.superfly.api.ActionDescription; import com.payneteasy.superfly.client.exception.CollectionException; +import java.util.List; + /** * This implementation decorates another {@link ActionDescriptionCollector} * implementation and transforms action names using transformers given to it. - * + * * @author Roman Puchkovskiy */ -public class TransformingActionDescriptionCollector implements - ActionDescriptionCollector { +public class TransformingActionDescriptionCollector implements ActionDescriptionCollector { private ActionDescriptionCollector collector; private StringTransformer[] transformers; @@ -25,6 +24,7 @@ public void setTransformers(StringTransformer[] transformers) { this.transformers = transformers; } + @Override public List collect() throws CollectionException { List list = collector.collect(); for (ActionDescription actionDescription : list) { @@ -39,5 +39,4 @@ protected String applyTransformers(String name) { } return name; } - } diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/ValueAttributeValuesExtractor.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/ValueAttributeValuesExtractor.java similarity index 99% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/ValueAttributeValuesExtractor.java rename to superfly-client-core/src/main/java/com/payneteasy/superfly/client/ValueAttributeValuesExtractor.java index 83ac5c40..a7c3edd9 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/ValueAttributeValuesExtractor.java +++ b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/ValueAttributeValuesExtractor.java @@ -15,10 +15,8 @@ public String[] extract(Annotation annotation) { if (annotation == null) { throw new IllegalArgumentException("annotation is null"); } - return new ReflectionInvoker(annotation) .method("value") .invoke(); } - } diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/ValuesExtractor.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/ValuesExtractor.java similarity index 83% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/ValuesExtractor.java rename to superfly-client-core/src/main/java/com/payneteasy/superfly/client/ValuesExtractor.java index 90ad146c..49450b2f 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/ValuesExtractor.java +++ b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/ValuesExtractor.java @@ -4,14 +4,14 @@ /** * Extracts an array of strings from annotation instance. - * + * * @author Roman Puchkovskiy */ public interface ValuesExtractor { /** * Performs extraction. * - * @param annotation annotation from which to extract + * @param annotation annotation from which to extract * @return values */ String[] extract(Annotation annotation); diff --git a/superfly-client-core/src/main/java/com/payneteasy/superfly/client/XmlActionDescriptionCollector.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/XmlActionDescriptionCollector.java new file mode 100644 index 00000000..9d08dac5 --- /dev/null +++ b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/XmlActionDescriptionCollector.java @@ -0,0 +1,61 @@ +package com.payneteasy.superfly.client; + +import com.payneteasy.superfly.api.ActionDescription; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.util.List; + +/** + * ActionDescriptionCollector implementation which returns a list of actions + * defined in an XML file. + * + * @author Roman Puchkovskiy + */ +public class XmlActionDescriptionCollector implements ActionDescriptionCollector { + + private static final Logger logger = LoggerFactory.getLogger(XmlActionDescriptionCollector.class); + + private final ActionsXmlParser parser = new ActionsXmlParser(); + private File file; + + public void setFile(File file) { + this.file = file; + } + + /** + * Sets resource path; the collector will use getFile() from a Resource (adapter layer). + * This setter is for compatibility when used from Spring — inject a File or use setFile. + */ + public void setResource(Object resource) { + if (resource instanceof File) { + this.file = (File) resource; + } else if (resource != null && resource.getClass().getName().startsWith("org.springframework")) { + try { + this.file = (File) resource.getClass().getMethod("getFile").invoke(resource); + } catch (Exception e) { + throw new IllegalArgumentException("Cannot get File from resource: " + resource, e); + } + } else { + throw new IllegalArgumentException("Unsupported resource type: " + (resource == null ? "null" : resource.getClass().getName())); + } + } + + @Override + public List collect() { + if (file == null || !file.exists()) { + throw new IllegalStateException("XML file not set or does not exist"); + } + try { + List actions = parser.parse(file); + if (logger.isDebugEnabled()) { + logger.debug("Collected the following actions: {}", actions); + } + return actions; + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/exception/CollectionException.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/exception/CollectionException.java similarity index 99% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/exception/CollectionException.java rename to superfly-client-core/src/main/java/com/payneteasy/superfly/client/exception/CollectionException.java index 21c0d4ed..1d9ce044 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/exception/CollectionException.java +++ b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/exception/CollectionException.java @@ -2,7 +2,7 @@ /** * Thrown when a problem occurs during collection. - * + * * @author Roman Puchkovskiy */ public class CollectionException extends Exception { @@ -21,5 +21,4 @@ public CollectionException(Throwable cause) { public CollectionException(String message, Throwable cause) { super(message, cause); } - } diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/session/LogoutService.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/session/LogoutService.java similarity index 93% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/session/LogoutService.java rename to superfly-client-core/src/main/java/com/payneteasy/superfly/client/session/LogoutService.java index 19bc3a7f..015de166 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/session/LogoutService.java +++ b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/session/LogoutService.java @@ -51,8 +51,6 @@ private void invalidateSessionQuietly(HttpSessionWrapper session, String key) { session.invalidate(); logger.info("Session for key '{}' successfully terminated.", key); } catch (IllegalStateException e) { - // This situation is normal if the session has already been terminated. - // Just log at warn level to avoid cluttering the logs. logger.warn("Attempt to terminate an already invalid session for key '{}'.", key, e); } } else { diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/utils/CommonUtils.java b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/utils/CommonUtils.java similarity index 88% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/utils/CommonUtils.java rename to superfly-client-core/src/main/java/com/payneteasy/superfly/client/utils/CommonUtils.java index ec1d89b4..b8f0e0e8 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/utils/CommonUtils.java +++ b/superfly-client-core/src/main/java/com/payneteasy/superfly/client/utils/CommonUtils.java @@ -41,17 +41,12 @@ public static Properties loadPropertiesThrowing(String propsLocation) { } public static InputStream getInputStreamFromClasspath(String resourceLocation) { - InputStream propsIS; String location = resourceLocation.substring(CLASSPATH_PREFIX.length()); - propsIS = CommonUtils.class.getClassLoader().getResourceAsStream(location); - return propsIS; + return CommonUtils.class.getClassLoader().getResourceAsStream(location); } - public static InputStream getInputStreamFromFile(String filename) - throws FileNotFoundException { - InputStream propsIS; - propsIS = new FileInputStream(filename); - return propsIS; + public static InputStream getInputStreamFromFile(String filename) throws FileNotFoundException { + return new FileInputStream(filename); } public static URL getResourceUrl(String location) throws MalformedURLException { diff --git a/superfly-client-core/src/test/java/com/payneteasy/superfly/client/ActionsXmlParserTest.java b/superfly-client-core/src/test/java/com/payneteasy/superfly/client/ActionsXmlParserTest.java new file mode 100644 index 00000000..7de73d5c --- /dev/null +++ b/superfly-client-core/src/test/java/com/payneteasy/superfly/client/ActionsXmlParserTest.java @@ -0,0 +1,41 @@ +package com.payneteasy.superfly.client; + +import com.payneteasy.superfly.api.ActionDescription; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class ActionsXmlParserTest { + + private final ActionsXmlParser parser = new ActionsXmlParser(); + + @Test + public void parseReadsActionElementsWithAttributes() throws Exception { + String xml = "" + + "" + + "" + + ""; + List actions = parser.parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + + assertNotNull(actions); + assertEquals(2, actions.size()); + assertEquals("a1", actions.get(0).getName()); + assertEquals("d1", actions.get(0).getDescription()); + assertEquals("a2", actions.get(1).getName()); + assertEquals("d2", actions.get(1).getDescription()); + } + + @Test + public void parseReturnsEmptyListWhenNoActions() throws Exception { + String xml = ""; + List actions = parser.parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + + assertNotNull(actions); + assertEquals(0, actions.size()); + } +} diff --git a/superfly-client/src/test/java/com/payneteasy/superfly/client/ReflectionInvokerTest.java b/superfly-client-core/src/test/java/com/payneteasy/superfly/client/ReflectionInvokerTest.java similarity index 99% rename from superfly-client/src/test/java/com/payneteasy/superfly/client/ReflectionInvokerTest.java rename to superfly-client-core/src/test/java/com/payneteasy/superfly/client/ReflectionInvokerTest.java index 87976f9f..ad612821 100644 --- a/superfly-client/src/test/java/com/payneteasy/superfly/client/ReflectionInvokerTest.java +++ b/superfly-client-core/src/test/java/com/payneteasy/superfly/client/ReflectionInvokerTest.java @@ -32,4 +32,4 @@ public String greet(String name) { private static class Child extends Target { } -} \ No newline at end of file +} diff --git a/superfly-client-core/src/test/java/com/payneteasy/superfly/client/SessionApiLocationTest.java b/superfly-client-core/src/test/java/com/payneteasy/superfly/client/SessionApiLocationTest.java new file mode 100644 index 00000000..26f08b58 --- /dev/null +++ b/superfly-client-core/src/test/java/com/payneteasy/superfly/client/SessionApiLocationTest.java @@ -0,0 +1,66 @@ +package com.payneteasy.superfly.client; + +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Verifies that HttpSessionWrapper, SessionMappingLocator, and HashMapBackedSessionMapping + * are defined in superfly-common (com.payneteasy.superfly.common.session), not in superfly-client-core. + * Guards against documentation drift and accidental class duplication. + */ +public class SessionApiLocationTest { + + private static final Logger log = LoggerFactory.getLogger(SessionApiLocationTest.class); + + private static final String EXPECTED_PACKAGE = "com.payneteasy.superfly.common.session"; + + private static final String[] SESSION_CLASS_NAMES = { + EXPECTED_PACKAGE + ".HttpSessionWrapper", + EXPECTED_PACKAGE + ".SessionMappingLocator", + EXPECTED_PACKAGE + ".HashMapBackedSessionMapping" + }; + + @Test + public void sessionClassesMustBeLoadable() throws ClassNotFoundException { + for (String className : SESSION_CLASS_NAMES) { + log.debug("Checking that {} is loadable from superfly-common transitive dep", className); + Class clazz = Class.forName(className); + log.debug("OK: {} loaded successfully", className); + assertNotNull("Class must be loadable: " + className, clazz); + } + } + + @Test + public void sessionClassesMustBeInCommonPackage() throws ClassNotFoundException { + for (String className : SESSION_CLASS_NAMES) { + log.debug("Checking package of {}", className); + Class clazz = Class.forName(className); + String actualPackage = clazz.getPackage().getName(); + log.debug("Package of {}: {}", className, actualPackage); + assertEquals( + className + " must be in " + EXPECTED_PACKAGE + " (not in superfly-client-core)", + EXPECTED_PACKAGE, + actualPackage + ); + } + } + + @Test + public void sessionClassesMustOriginateFromSuperflyCommon() throws ClassNotFoundException { + for (String className : SESSION_CLASS_NAMES) { + log.debug("Checking code source of {}", className); + Class clazz = Class.forName(className); + String codeSourcePath = clazz.getProtectionDomain().getCodeSource().getLocation().getPath(); + log.debug("Code source of {}: {}", className, codeSourcePath); + assertTrue( + className + " must originate from superfly-common, but code source was: " + codeSourcePath, + codeSourcePath.contains("superfly-common") + ); + } + } +} diff --git a/superfly-client/src/test/java/com/payneteasy/superfly/client/SessionToucherImplTest.java b/superfly-client-core/src/test/java/com/payneteasy/superfly/client/SessionToucherImplTest.java similarity index 100% rename from superfly-client/src/test/java/com/payneteasy/superfly/client/SessionToucherImplTest.java rename to superfly-client-core/src/test/java/com/payneteasy/superfly/client/SessionToucherImplTest.java diff --git a/superfly-client/src/test/java/com/payneteasy/superfly/client/ValueAttributeValuesExtractorTest.java b/superfly-client-core/src/test/java/com/payneteasy/superfly/client/ValueAttributeValuesExtractorTest.java similarity index 99% rename from superfly-client/src/test/java/com/payneteasy/superfly/client/ValueAttributeValuesExtractorTest.java rename to superfly-client-core/src/test/java/com/payneteasy/superfly/client/ValueAttributeValuesExtractorTest.java index 1821abb3..12ac8c7d 100644 --- a/superfly-client/src/test/java/com/payneteasy/superfly/client/ValueAttributeValuesExtractorTest.java +++ b/superfly-client-core/src/test/java/com/payneteasy/superfly/client/ValueAttributeValuesExtractorTest.java @@ -18,4 +18,4 @@ public void test() { @Secured("abc") private static class Annotated { } -} \ No newline at end of file +} diff --git a/superfly-client-core/src/test/java/com/payneteasy/superfly/client/XmlActionDescriptionCollectorTest.java b/superfly-client-core/src/test/java/com/payneteasy/superfly/client/XmlActionDescriptionCollectorTest.java new file mode 100644 index 00000000..5a3318ea --- /dev/null +++ b/superfly-client-core/src/test/java/com/payneteasy/superfly/client/XmlActionDescriptionCollectorTest.java @@ -0,0 +1,34 @@ +package com.payneteasy.superfly.client; + +import org.junit.Test; + +import java.io.File; + +import static org.junit.Assert.fail; + +public class XmlActionDescriptionCollectorTest { + + @Test(expected = IllegalArgumentException.class) + public void setResourceThrowsForUnsupportedType() { + XmlActionDescriptionCollector collector = new XmlActionDescriptionCollector(); + collector.setResource("not-a-file-or-spring-resource"); + } + + @Test + public void collectThrowsWhenFileNotSet() { + XmlActionDescriptionCollector collector = new XmlActionDescriptionCollector(); + try { + collector.collect(); + fail("Expected IllegalStateException when file is not set"); + } catch (IllegalStateException e) { + // expected + } + } + + @Test + public void setResourceAcceptsFile() { + XmlActionDescriptionCollector collector = new XmlActionDescriptionCollector(); + collector.setResource(new File("actions.xml")); + // setResource itself doesn't validate existence — collect() does + } +} diff --git a/superfly-client-ee10/pom.xml b/superfly-client-ee10/pom.xml new file mode 100644 index 00000000..e4274592 --- /dev/null +++ b/superfly-client-ee10/pom.xml @@ -0,0 +1,37 @@ + + 4.0.0 + + superfly-parent + com.payneteasy.superfly + 2.0-3-SNAPSHOT + + superfly-client-ee10 + Superfly Client Jakarta EE 10 + Servlet filters, listeners and session adapter for Superfly client on Jakarta EE 10 (jakarta.servlet) + + + + com.payneteasy.superfly + superfly-client-core + + + jakarta.servlet + jakarta.servlet-api + provided + + + org.slf4j + slf4j-api + + + org.projectlombok + lombok + provided + + + org.springframework.security + spring-security-core + test + + + diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/session/AbstractNotificationSinkFilter.java b/superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/AbstractNotificationSinkFilter.java similarity index 97% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/session/AbstractNotificationSinkFilter.java rename to superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/AbstractNotificationSinkFilter.java index b399a76b..e3a275df 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/session/AbstractNotificationSinkFilter.java +++ b/superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/AbstractNotificationSinkFilter.java @@ -1,9 +1,7 @@ package com.payneteasy.superfly.client.session; -import java.io.IOException; -import java.util.HashSet; -import java.util.Properties; -import java.util.Set; +import com.payneteasy.superfly.client.utils.CommonUtils; +import com.payneteasy.superfly.common.utils.StringUtils; import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; @@ -13,12 +11,14 @@ import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; -import com.payneteasy.superfly.client.utils.CommonUtils; -import com.payneteasy.superfly.common.utils.StringUtils; +import java.io.IOException; +import java.util.HashSet; +import java.util.Properties; +import java.util.Set; /** * Base for filters that accept notifications from a Superfly server. - * + * * @author Roman Puchkovskiy */ public abstract class AbstractNotificationSinkFilter implements Filter { @@ -27,9 +27,11 @@ public abstract class AbstractNotificationSinkFilter implements Filter { protected abstract boolean acceptsNotificationType(String notificationType); + @Override public void init(FilterConfig filterConfig) throws ServletException { } + @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; @@ -54,6 +56,7 @@ protected String getSuperflyNotificationParameterName() { return "superflyNotification"; } + @Override public void destroy() { } @@ -64,8 +67,7 @@ protected Set initAllowedIps(FilterConfig filterConfig) { String commaDelimited = properties.getProperty("notification.allowed.ips").trim(); if (commaDelimited.length() > 0) { String[] fragments = StringUtils.commaDelimitedListToStringArray(commaDelimited); - - ips = new HashSet(); + ips = new HashSet<>(); for (String ip : fragments) { ips.add(ip); } diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/session/AbstractSessionStoreAwareNotificationSinkFilter.java b/superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/AbstractSessionStoreAwareNotificationSinkFilter.java similarity index 94% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/session/AbstractSessionStoreAwareNotificationSinkFilter.java rename to superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/AbstractSessionStoreAwareNotificationSinkFilter.java index 74667239..5319db4c 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/session/AbstractSessionStoreAwareNotificationSinkFilter.java +++ b/superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/AbstractSessionStoreAwareNotificationSinkFilter.java @@ -1,21 +1,17 @@ package com.payneteasy.superfly.client.session; import com.payneteasy.superfly.common.session.HttpSessionWrapper; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import com.payneteasy.superfly.common.session.SessionMapping; import com.payneteasy.superfly.common.session.SessionMappingLocator; - +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Contains some code useful when dealing with sessions. * * @author Roman Puchkovskiy */ -public abstract class AbstractSessionStoreAwareNotificationSinkFilter extends - AbstractNotificationSinkFilter { +public abstract class AbstractSessionStoreAwareNotificationSinkFilter extends AbstractNotificationSinkFilter { private static final Logger logger = LoggerFactory.getLogger(AbstractSessionStoreAwareNotificationSinkFilter.class); @@ -32,5 +28,4 @@ protected void invalidateSessionQuietly(HttpSessionWrapper session) { } } } - } diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/session/AbstractSessionTouchingFilter.java b/superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/AbstractSessionTouchingFilter.java similarity index 94% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/session/AbstractSessionTouchingFilter.java rename to superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/AbstractSessionTouchingFilter.java index cc4f5dd5..26512c73 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/session/AbstractSessionTouchingFilter.java +++ b/superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/AbstractSessionTouchingFilter.java @@ -6,6 +6,7 @@ import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; + import java.io.IOException; /** @@ -25,7 +26,7 @@ public final void init(FilterConfig filterConfig) throws ServletException { @Override public final void doFilter(ServletRequest request, ServletResponse response, - FilterChain chain) throws IOException, ServletException { + FilterChain chain) throws IOException, ServletException { try { if (request instanceof HttpServletRequest) { Long id = getSuperflySessionId((HttpServletRequest) request); diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/session/DefaultProtectedLogoutNotificationSinkFilter.java b/superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/DefaultProtectedLogoutNotificationSinkFilter.java similarity index 90% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/session/DefaultProtectedLogoutNotificationSinkFilter.java rename to superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/DefaultProtectedLogoutNotificationSinkFilter.java index 3e459d36..d76a0d85 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/session/DefaultProtectedLogoutNotificationSinkFilter.java +++ b/superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/DefaultProtectedLogoutNotificationSinkFilter.java @@ -1,22 +1,20 @@ package com.payneteasy.superfly.client.session; -import java.util.Set; - import jakarta.servlet.FilterConfig; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; +import java.util.Set; + /** * {@link LogoutNotificationSinkFilter} which is protected by a white list of * allowed caller IPs. * * @author Roman Puchkovskiy - * - * deprecated see SuperflyLogoutFilter + * @deprecated Use {@link SuperflyLogoutFilter} */ @Deprecated -public class DefaultProtectedLogoutNotificationSinkFilter extends - LogoutNotificationSinkFilter { +public class DefaultProtectedLogoutNotificationSinkFilter extends LogoutNotificationSinkFilter { private Set allowedIps = null; @Override diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/session/LogoutNotificationSinkFilter.java b/superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/LogoutNotificationSinkFilter.java similarity index 89% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/session/LogoutNotificationSinkFilter.java rename to superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/LogoutNotificationSinkFilter.java index 64d29939..bfcf2bc8 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/session/LogoutNotificationSinkFilter.java +++ b/superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/LogoutNotificationSinkFilter.java @@ -1,18 +1,15 @@ package com.payneteasy.superfly.client.session; - -import com.payneteasy.superfly.common.session.HttpSessionWrapper; -import jakarta.servlet.http.HttpServletRequest; - import com.payneteasy.superfly.api.Notifications; +import com.payneteasy.superfly.common.session.HttpSessionWrapper; import com.payneteasy.superfly.common.utils.StringUtils; +import jakarta.servlet.http.HttpServletRequest; /** * Filter that accepts notifications from the Superfly server. * * @author Roman Puchkovskiy - * - * deprecated see SuperflyLogoutFilter + * @deprecated Use {@link SuperflyLogoutFilter} */ @Deprecated public class LogoutNotificationSinkFilter extends AbstractSessionStoreAwareNotificationSinkFilter { @@ -26,8 +23,6 @@ protected boolean acceptsNotificationType(String notificationType) { protected boolean doFilterRequest(HttpServletRequest request) { String logoutSessionIds = request.getParameter(getLogoutSessionIdsParameterName()); if (logoutSessionIds != null) { - // this is actually a logout request, so process it and - // interrupt filter chain String[] sessionIds = StringUtils.commaDelimitedListToStringArray(logoutSessionIds); for (String key : sessionIds) { HttpSessionWrapper session = getSessionMapping().removeSessionByKey(key); diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/session/SessionMappingMaintainingListener.java b/superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/SessionMappingMaintainingListener.java similarity index 96% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/session/SessionMappingMaintainingListener.java rename to superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/SessionMappingMaintainingListener.java index 4a19bf18..fe1147c4 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/session/SessionMappingMaintainingListener.java +++ b/superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/SessionMappingMaintainingListener.java @@ -1,11 +1,10 @@ package com.payneteasy.superfly.client.session; -import jakarta.servlet.http.HttpSessionEvent; -import jakarta.servlet.http.HttpSessionListener; - import com.payneteasy.superfly.common.session.HttpSessionWrapper; import com.payneteasy.superfly.common.session.SessionMapping; import com.payneteasy.superfly.common.session.SessionMappingLocator; +import jakarta.servlet.http.HttpSessionEvent; +import jakarta.servlet.http.HttpSessionListener; /** * Session listener that removes destroyed sessions from the session mapping. @@ -14,9 +13,11 @@ */ public class SessionMappingMaintainingListener implements HttpSessionListener { + @Override public void sessionCreated(HttpSessionEvent se) { } + @Override public void sessionDestroyed(HttpSessionEvent se) { getSessionMapping().removeSessionById(se.getSession().getId()); } @@ -24,5 +25,4 @@ public void sessionDestroyed(HttpSessionEvent se) { protected SessionMapping getSessionMapping() { return SessionMappingLocator.getSessionMapping(); } - } diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/session/SuperflyLogoutFilter.java b/superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/SuperflyLogoutFilter.java similarity index 84% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/session/SuperflyLogoutFilter.java rename to superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/SuperflyLogoutFilter.java index f271fb94..5422cd97 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/session/SuperflyLogoutFilter.java +++ b/superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/SuperflyLogoutFilter.java @@ -2,10 +2,11 @@ import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; -import java.io.IOException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.IOException; + /** * Unified filter for handling logout notifications from Superfly. *

@@ -37,12 +38,10 @@ public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; - // Filter only triggers on POST requests with a specific parameter if ("POST".equals(request.getMethod())) { String logoutSessionIds = request.getParameter(LogoutService.LOGOUT_SESSION_IDS_PARAM); if (logoutSessionIds != null) { logger.debug("Intercepted logout request for sessions: {}", logoutSessionIds); - // Delegate all logic to the service and break the chain if logout was successful if (logoutService.handleLogout(logoutSessionIds)) { logger.debug("Logout successfully completed, breaking the filter chain"); return; @@ -50,17 +49,14 @@ public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain } } - // If this is not our request or logout was not performed, pass the request further down the chain. chain.doFilter(req, resp); } @Override public void init(FilterConfig filterConfig) { - // no initialization required } @Override public void destroy() { - // no resources to release } } diff --git a/superfly-client/src/main/java/com/payneteasy/superfly/client/session/SuperflyNotificationSinkFilter.java b/superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/SuperflyNotificationSinkFilter.java similarity index 85% rename from superfly-client/src/main/java/com/payneteasy/superfly/client/session/SuperflyNotificationSinkFilter.java rename to superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/SuperflyNotificationSinkFilter.java index 3b268b8e..21976f5f 100644 --- a/superfly-client/src/main/java/com/payneteasy/superfly/client/session/SuperflyNotificationSinkFilter.java +++ b/superfly-client-ee10/src/main/java/com/payneteasy/superfly/client/session/SuperflyNotificationSinkFilter.java @@ -1,8 +1,9 @@ package com.payneteasy.superfly.client.session; -import java.io.IOException; - import com.payneteasy.superfly.common.session.HttpSessionWrapper; +import com.payneteasy.superfly.common.session.SessionMapping; +import com.payneteasy.superfly.common.session.SessionMappingLocator; +import com.payneteasy.superfly.common.utils.StringUtils; import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.FilterConfig; @@ -10,38 +11,33 @@ import jakarta.servlet.ServletRequest; import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.payneteasy.superfly.common.session.SessionMapping; -import com.payneteasy.superfly.common.session.SessionMappingLocator; -import com.payneteasy.superfly.common.utils.StringUtils; +import java.io.IOException; /** * Filter that accepts notifications from the Superfly server. * * @author Roman Puchkovskiy - * - * deprecated see SuperflyLogoutFilter + * @deprecated Use {@link SuperflyLogoutFilter} */ @Deprecated public class SuperflyNotificationSinkFilter implements Filter { - private static Logger logger = LoggerFactory.getLogger(SuperflyNotificationSinkFilter.class); + private static final Logger logger = LoggerFactory.getLogger(SuperflyNotificationSinkFilter.class); + @Override public void init(FilterConfig filterConfig) throws ServletException { } + @Override public void doFilter(ServletRequest req, ServletResponse resp, - FilterChain chain) throws IOException, ServletException { + FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; if ("POST".equals(request.getMethod())) { String logoutSessionIds = request.getParameter(getLogoutSessionIdsParameterName()); if (logoutSessionIds != null) { - // this is actually a logout request, so process it and - // interrupt filter chain String[] sessionIds = StringUtils.commaDelimitedListToStringArray(logoutSessionIds); for (String key : sessionIds) { HttpSessionWrapper session = getSessionMapping().removeSessionByKey(key); @@ -67,7 +63,7 @@ protected String getLogoutSessionIdsParameterName() { return "superflyLogoutSessionIds"; } + @Override public void destroy() { } - } diff --git a/superfly-common/src/main/java/com/payneteasy/superfly/common/session/JakartaHttpSessionWrapper.java b/superfly-client-ee10/src/main/java/com/payneteasy/superfly/common/session/JakartaHttpSessionWrapper.java similarity index 100% rename from superfly-common/src/main/java/com/payneteasy/superfly/common/session/JakartaHttpSessionWrapper.java rename to superfly-client-ee10/src/main/java/com/payneteasy/superfly/common/session/JakartaHttpSessionWrapper.java diff --git a/superfly-client/src/test/java/com/payneteasy/superfly/client/session/AbstractSessionTouchingFilterTest.java b/superfly-client-ee10/src/test/java/com/payneteasy/superfly/client/session/AbstractSessionTouchingFilterTest.java similarity index 100% rename from superfly-client/src/test/java/com/payneteasy/superfly/client/session/AbstractSessionTouchingFilterTest.java rename to superfly-client-ee10/src/test/java/com/payneteasy/superfly/client/session/AbstractSessionTouchingFilterTest.java diff --git a/superfly-client/src/test/java/com/payneteasy/superfly/client/session/LogoutServiceTest.java b/superfly-client-ee10/src/test/java/com/payneteasy/superfly/client/session/LogoutServiceTest.java similarity index 100% rename from superfly-client/src/test/java/com/payneteasy/superfly/client/session/LogoutServiceTest.java rename to superfly-client-ee10/src/test/java/com/payneteasy/superfly/client/session/LogoutServiceTest.java diff --git a/superfly-client/src/test/java/com/payneteasy/superfly/client/session/SuperflyLogoutFilterTest.java b/superfly-client-ee10/src/test/java/com/payneteasy/superfly/client/session/SuperflyLogoutFilterTest.java similarity index 100% rename from superfly-client/src/test/java/com/payneteasy/superfly/client/session/SuperflyLogoutFilterTest.java rename to superfly-client-ee10/src/test/java/com/payneteasy/superfly/client/session/SuperflyLogoutFilterTest.java diff --git a/superfly-client-ee10/src/test/java/com/payneteasy/superfly/common/session/JakartaHttpSessionWrapperTest.java b/superfly-client-ee10/src/test/java/com/payneteasy/superfly/common/session/JakartaHttpSessionWrapperTest.java new file mode 100644 index 00000000..83ecfa24 --- /dev/null +++ b/superfly-client-ee10/src/test/java/com/payneteasy/superfly/common/session/JakartaHttpSessionWrapperTest.java @@ -0,0 +1,90 @@ +package com.payneteasy.superfly.common.session; + +import jakarta.servlet.http.HttpSession; +import org.easymock.EasyMock; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.replay; +import static org.easymock.EasyMock.verify; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; + +public class JakartaHttpSessionWrapperTest { + + private static final Logger logger = LoggerFactory.getLogger(JakartaHttpSessionWrapperTest.class); + + private HttpSession mockSession; + private JakartaHttpSessionWrapper wrapper; + + @Before + public void setUp() { + mockSession = EasyMock.createMock(HttpSession.class); + wrapper = new JakartaHttpSessionWrapper(mockSession); + } + + @After + public void tearDown() { + verify(mockSession); + } + + @Test + public void testGetId() { + logger.debug("Test: {}", "getId"); + expect(mockSession.getId()).andReturn("session-id-456"); + replay(mockSession); + + assertEquals("session-id-456", wrapper.getId()); + } + + @Test + public void testGetAttribute() { + logger.debug("Test: {}", "getAttribute"); + expect(mockSession.getAttribute("key")).andReturn("value"); + replay(mockSession); + + assertEquals("value", wrapper.getAttribute("key")); + } + + @Test + public void testSetAttribute() { + logger.debug("Test: {}", "setAttribute"); + mockSession.setAttribute("key", "value"); + EasyMock.expectLastCall(); + replay(mockSession); + + wrapper.setAttribute("key", "value"); + } + + @Test + public void testRemoveAttribute() { + logger.debug("Test: {}", "removeAttribute"); + mockSession.removeAttribute("key"); + EasyMock.expectLastCall(); + replay(mockSession); + + wrapper.removeAttribute("key"); + } + + @Test + public void testInvalidate() { + logger.debug("Test: {}", "invalidate"); + mockSession.invalidate(); + EasyMock.expectLastCall(); + replay(mockSession); + + wrapper.invalidate(); + } + + @Test + public void testGetOriginalSession() { + logger.debug("Test: {}", "getOriginalSession"); + replay(mockSession); + + assertSame(mockSession, wrapper.getOriginalSession()); + } +} diff --git a/superfly-client-ee8/pom.xml b/superfly-client-ee8/pom.xml new file mode 100644 index 00000000..d0b15fed --- /dev/null +++ b/superfly-client-ee8/pom.xml @@ -0,0 +1,32 @@ + + 4.0.0 + + superfly-parent + com.payneteasy.superfly + 2.0-3-SNAPSHOT + + superfly-client-ee8 + Superfly Client Java EE 8 + Servlet filters, listeners and session adapter for Superfly client on Java EE 8 (javax.servlet) + + + + com.payneteasy.superfly + superfly-client-core + + + javax.servlet + javax.servlet-api + provided + + + org.slf4j + slf4j-api + + + org.projectlombok + lombok + provided + + + diff --git a/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/AbstractNotificationSinkFilter.java b/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/AbstractNotificationSinkFilter.java new file mode 100644 index 00000000..651f8461 --- /dev/null +++ b/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/AbstractNotificationSinkFilter.java @@ -0,0 +1,83 @@ +package com.payneteasy.superfly.client.session; + +import com.payneteasy.superfly.client.utils.CommonUtils; +import com.payneteasy.superfly.common.utils.StringUtils; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Properties; +import java.util.Set; + +/** + * Base for filters that accept notifications from a Superfly server (Java EE 8). + */ +public abstract class AbstractNotificationSinkFilter implements Filter { + + protected abstract boolean doFilterRequest(HttpServletRequest request); + + protected abstract boolean acceptsNotificationType(String notificationType); + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + } + + @Override + public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest request = (HttpServletRequest) req; + if ("POST".equals(request.getMethod())) { + String notificationType = request.getParameter(getSuperflyNotificationParameterName()); + if (notificationType != null && acceptsNotificationType(notificationType)) { + if (isAllowed(request)) { + if (doFilterRequest(request)) { + return; + } + } + } + } + chain.doFilter(req, resp); + } + + protected boolean isAllowed(HttpServletRequest request) { + return true; + } + + protected String getSuperflyNotificationParameterName() { + return "superflyNotification"; + } + + @Override + public void destroy() { + } + + protected Set initAllowedIps(FilterConfig filterConfig) { + String resource = getPropertiesResource(filterConfig); + Set ips = null; + Properties properties = CommonUtils.loadPropertiesThrowing(resource); + String commaDelimited = properties.getProperty("notification.allowed.ips").trim(); + if (commaDelimited.length() > 0) { + String[] fragments = StringUtils.commaDelimitedListToStringArray(commaDelimited); + ips = new HashSet<>(); + for (String ip : fragments) { + ips.add(ip); + } + } + return ips; + } + + protected String getPropertiesResource(FilterConfig filterConfig) { + return filterConfig.getInitParameter("propertiesResource"); + } + + protected boolean isAllowedByIp(HttpServletRequest request, Set allowedIps) { + return allowedIps == null || allowedIps.contains(request.getRemoteAddr()); + } +} diff --git a/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/AbstractSessionStoreAwareNotificationSinkFilter.java b/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/AbstractSessionStoreAwareNotificationSinkFilter.java new file mode 100644 index 00000000..3483b9be --- /dev/null +++ b/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/AbstractSessionStoreAwareNotificationSinkFilter.java @@ -0,0 +1,29 @@ +package com.payneteasy.superfly.client.session; + +import com.payneteasy.superfly.common.session.HttpSessionWrapper; +import com.payneteasy.superfly.common.session.SessionMapping; +import com.payneteasy.superfly.common.session.SessionMappingLocator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Session store aware notification sink filter (Java EE 8). + */ +public abstract class AbstractSessionStoreAwareNotificationSinkFilter extends AbstractNotificationSinkFilter { + + private static final Logger logger = LoggerFactory.getLogger(AbstractSessionStoreAwareNotificationSinkFilter.class); + + protected SessionMapping getSessionMapping() { + return SessionMappingLocator.getSessionMapping(); + } + + protected void invalidateSessionQuietly(HttpSessionWrapper session) { + if (session != null) { + try { + session.invalidate(); + } catch (IllegalStateException e) { + logger.warn("Ignored exception while trying to invalidate a session", e); + } + } + } +} diff --git a/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/AbstractSessionTouchingFilter.java b/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/AbstractSessionTouchingFilter.java new file mode 100644 index 00000000..d3ae3ddf --- /dev/null +++ b/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/AbstractSessionTouchingFilter.java @@ -0,0 +1,49 @@ +package com.payneteasy.superfly.client.session; + +import com.payneteasy.superfly.client.SessionToucher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.*; +import javax.servlet.http.HttpServletRequest; + +import java.io.IOException; + +/** + * Base for a filter that can be used for touching session (Java EE 8). + */ +public abstract class AbstractSessionTouchingFilter implements Filter { + private static final Logger logger = LoggerFactory.getLogger(AbstractSessionTouchingFilter.class); + + private SessionToucher sessionToucher; + + @Override + public final void init(FilterConfig filterConfig) throws ServletException { + sessionToucher = createSessionToucher(filterConfig); + } + + @Override + public final void doFilter(ServletRequest request, ServletResponse response, + FilterChain chain) throws IOException, ServletException { + try { + if (request instanceof HttpServletRequest) { + Long id = getSuperflySessionId((HttpServletRequest) request); + if (id != null) { + sessionToucher.addSessionId(id); + } + } + } catch (Exception e) { + logger.error("Error while adding session ID to toucher", e); + } finally { + chain.doFilter(request, response); + } + } + + protected abstract SessionToucher createSessionToucher(FilterConfig filterConfig); + + protected abstract Long getSuperflySessionId(HttpServletRequest request); + + @Override + public void destroy() { + } +} diff --git a/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/DefaultProtectedLogoutNotificationSinkFilter.java b/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/DefaultProtectedLogoutNotificationSinkFilter.java new file mode 100644 index 00000000..cb4ff5ef --- /dev/null +++ b/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/DefaultProtectedLogoutNotificationSinkFilter.java @@ -0,0 +1,28 @@ +package com.payneteasy.superfly.client.session; + +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; + +import java.util.Set; + +/** + * Protected logout notification sink filter (Java EE 8). + * + * @deprecated Use {@link SuperflyLogoutFilter} + */ +@Deprecated +public class DefaultProtectedLogoutNotificationSinkFilter extends LogoutNotificationSinkFilter { + private Set allowedIps = null; + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + super.init(filterConfig); + allowedIps = initAllowedIps(filterConfig); + } + + @Override + protected boolean isAllowed(HttpServletRequest request) { + return isAllowedByIp(request, allowedIps); + } +} diff --git a/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/LogoutNotificationSinkFilter.java b/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/LogoutNotificationSinkFilter.java new file mode 100644 index 00000000..fd00cbcd --- /dev/null +++ b/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/LogoutNotificationSinkFilter.java @@ -0,0 +1,38 @@ +package com.payneteasy.superfly.client.session; + +import com.payneteasy.superfly.api.Notifications; +import com.payneteasy.superfly.common.session.HttpSessionWrapper; +import com.payneteasy.superfly.common.utils.StringUtils; +import javax.servlet.http.HttpServletRequest; + +/** + * Filter that accepts notifications from the Superfly server (Java EE 8). + * + * @deprecated Use {@link SuperflyLogoutFilter} + */ +@Deprecated +public class LogoutNotificationSinkFilter extends AbstractSessionStoreAwareNotificationSinkFilter { + + @Override + protected boolean acceptsNotificationType(String notificationType) { + return Notifications.LOGOUT.equals(notificationType); + } + + @Override + protected boolean doFilterRequest(HttpServletRequest request) { + String logoutSessionIds = request.getParameter(getLogoutSessionIdsParameterName()); + if (logoutSessionIds != null) { + String[] sessionIds = StringUtils.commaDelimitedListToStringArray(logoutSessionIds); + for (String key : sessionIds) { + HttpSessionWrapper session = getSessionMapping().removeSessionByKey(key); + invalidateSessionQuietly(session); + } + return true; + } + return false; + } + + protected String getLogoutSessionIdsParameterName() { + return "superflyLogoutSessionIds"; + } +} diff --git a/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/SessionMappingMaintainingListener.java b/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/SessionMappingMaintainingListener.java new file mode 100644 index 00000000..ab2362a1 --- /dev/null +++ b/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/SessionMappingMaintainingListener.java @@ -0,0 +1,26 @@ +package com.payneteasy.superfly.client.session; + +import com.payneteasy.superfly.common.session.HttpSessionWrapper; +import com.payneteasy.superfly.common.session.SessionMapping; +import com.payneteasy.superfly.common.session.SessionMappingLocator; +import javax.servlet.http.HttpSessionEvent; +import javax.servlet.http.HttpSessionListener; + +/** + * Session listener that removes destroyed sessions from the session mapping (Java EE 8). + */ +public class SessionMappingMaintainingListener implements HttpSessionListener { + + @Override + public void sessionCreated(HttpSessionEvent se) { + } + + @Override + public void sessionDestroyed(HttpSessionEvent se) { + getSessionMapping().removeSessionById(se.getSession().getId()); + } + + protected SessionMapping getSessionMapping() { + return SessionMappingLocator.getSessionMapping(); + } +} diff --git a/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/SuperflyLogoutFilter.java b/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/SuperflyLogoutFilter.java new file mode 100644 index 00000000..548dbdff --- /dev/null +++ b/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/SuperflyLogoutFilter.java @@ -0,0 +1,52 @@ +package com.payneteasy.superfly.client.session; + +import javax.servlet.*; +import javax.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +/** + * Unified filter for handling logout notifications from Superfly (Java EE 8 / javax). + */ +public class SuperflyLogoutFilter implements Filter { + + private static final Logger logger = LoggerFactory.getLogger(SuperflyLogoutFilter.class); + private final LogoutService logoutService; + + public SuperflyLogoutFilter() { + this(new LogoutService()); + } + + public SuperflyLogoutFilter(LogoutService logoutService) { + this.logoutService = logoutService; + } + + @Override + public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest request = (HttpServletRequest) req; + + if ("POST".equals(request.getMethod())) { + String logoutSessionIds = request.getParameter(LogoutService.LOGOUT_SESSION_IDS_PARAM); + if (logoutSessionIds != null) { + logger.debug("Intercepted logout request for sessions: {}", logoutSessionIds); + if (logoutService.handleLogout(logoutSessionIds)) { + logger.debug("Logout successfully completed, breaking the filter chain"); + return; + } + } + } + + chain.doFilter(req, resp); + } + + @Override + public void init(FilterConfig filterConfig) { + } + + @Override + public void destroy() { + } +} diff --git a/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/SuperflyNotificationSinkFilter.java b/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/SuperflyNotificationSinkFilter.java new file mode 100644 index 00000000..4df352fa --- /dev/null +++ b/superfly-client-ee8/src/main/java/com/payneteasy/superfly/client/session/SuperflyNotificationSinkFilter.java @@ -0,0 +1,68 @@ +package com.payneteasy.superfly.client.session; + +import com.payneteasy.superfly.common.session.HttpSessionWrapper; +import com.payneteasy.superfly.common.session.SessionMapping; +import com.payneteasy.superfly.common.session.SessionMappingLocator; +import com.payneteasy.superfly.common.utils.StringUtils; +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +/** + * Filter that accepts notifications from the Superfly server (Java EE 8). + * + * @deprecated Use {@link SuperflyLogoutFilter} + */ +@Deprecated +public class SuperflyNotificationSinkFilter implements Filter { + + private static final Logger logger = LoggerFactory.getLogger(SuperflyNotificationSinkFilter.class); + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + } + + @Override + public void doFilter(ServletRequest req, ServletResponse resp, + FilterChain chain) throws IOException, ServletException { + HttpServletRequest request = (HttpServletRequest) req; + if ("POST".equals(request.getMethod())) { + String logoutSessionIds = request.getParameter(getLogoutSessionIdsParameterName()); + if (logoutSessionIds != null) { + String[] sessionIds = StringUtils.commaDelimitedListToStringArray(logoutSessionIds); + for (String key : sessionIds) { + HttpSessionWrapper session = getSessionMapping().removeSessionByKey(key); + if (session != null) { + try { + session.invalidate(); + } catch (IllegalStateException e) { + logger.warn("Ignored exception while trying to invalidate a session", e); + } + } + } + return; + } + } + chain.doFilter(req, resp); + } + + protected SessionMapping getSessionMapping() { + return SessionMappingLocator.getSessionMapping(); + } + + protected String getLogoutSessionIdsParameterName() { + return "superflyLogoutSessionIds"; + } + + @Override + public void destroy() { + } +} diff --git a/superfly-client-ee8/src/main/java/com/payneteasy/superfly/common/session/JavaxHttpSessionWrapper.java b/superfly-client-ee8/src/main/java/com/payneteasy/superfly/common/session/JavaxHttpSessionWrapper.java new file mode 100644 index 00000000..cb117d21 --- /dev/null +++ b/superfly-client-ee8/src/main/java/com/payneteasy/superfly/common/session/JavaxHttpSessionWrapper.java @@ -0,0 +1,51 @@ +package com.payneteasy.superfly.common.session; + +import javax.servlet.http.HttpSession; + +/** + * Implementation of HttpSessionWrapper for Java EE 8 (javax) Servlet API. + * + * @author Developer + */ +public class JavaxHttpSessionWrapper implements HttpSessionWrapper { + + private final HttpSession session; + + public JavaxHttpSessionWrapper(HttpSession session) { + this.session = session; + } + + @Override + public String getId() { + return session.getId(); + } + + @Override + public Object getAttribute(String name) { + return session.getAttribute(name); + } + + @Override + public void setAttribute(String name, Object value) { + session.setAttribute(name, value); + } + + @Override + public void removeAttribute(String name) { + session.removeAttribute(name); + } + + @Override + public void invalidate() { + session.invalidate(); + } + + /** + * Returns the original HttpSession. + * + * @return original HttpSession + */ + public HttpSession getOriginalSession() { + return session; + } +} diff --git a/superfly-client-ee8/src/test/java/com/payneteasy/superfly/client/JakartaServletIsolationTest.java b/superfly-client-ee8/src/test/java/com/payneteasy/superfly/client/JakartaServletIsolationTest.java new file mode 100644 index 00000000..cec1dddb --- /dev/null +++ b/superfly-client-ee8/src/test/java/com/payneteasy/superfly/client/JakartaServletIsolationTest.java @@ -0,0 +1,37 @@ +package com.payneteasy.superfly.client; + +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; + +/** + * Verifies that jakarta.servlet-api is not present in the EE8 compile/test classpath. + * Guards against transitive leakage from dependencies like superfly-client-opt. + */ +public class JakartaServletIsolationTest { + + private static final Logger log = LoggerFactory.getLogger(JakartaServletIsolationTest.class); + + @Test + public void jakartaServletMustNotBeOnClasspath() { + log.debug("Checking that jakarta.servlet.http.HttpServletRequest is NOT on EE8 classpath"); + try { + Class.forName("jakarta.servlet.http.HttpServletRequest"); + fail("jakarta.servlet-api must not be on the classpath of an EE8 module; " + + "a dependency is leaking jakarta into the EE8 chain"); + } catch (ClassNotFoundException expected) { + log.debug("OK: jakarta.servlet.http.HttpServletRequest is absent from classpath"); + } + } + + @Test + public void javaxServletMustBeOnClasspath() throws ClassNotFoundException { + log.debug("Checking that javax.servlet.http.HttpServletRequest IS on EE8 classpath"); + Class clazz = Class.forName("javax.servlet.http.HttpServletRequest"); + log.debug("OK: javax.servlet.http.HttpServletRequest loaded — {}", clazz.getName()); + assertNotNull(clazz); + } +} diff --git a/superfly-client-ee8/src/test/java/com/payneteasy/superfly/common/session/JavaxHttpSessionWrapperTest.java b/superfly-client-ee8/src/test/java/com/payneteasy/superfly/common/session/JavaxHttpSessionWrapperTest.java new file mode 100644 index 00000000..e00d2462 --- /dev/null +++ b/superfly-client-ee8/src/test/java/com/payneteasy/superfly/common/session/JavaxHttpSessionWrapperTest.java @@ -0,0 +1,91 @@ +package com.payneteasy.superfly.common.session; + +import org.easymock.EasyMock; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.http.HttpSession; + +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.replay; +import static org.easymock.EasyMock.verify; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; + +public class JavaxHttpSessionWrapperTest { + + private static final Logger logger = LoggerFactory.getLogger(JavaxHttpSessionWrapperTest.class); + + private HttpSession mockSession; + private JavaxHttpSessionWrapper wrapper; + + @Before + public void setUp() { + mockSession = EasyMock.createMock(HttpSession.class); + wrapper = new JavaxHttpSessionWrapper(mockSession); + } + + @After + public void tearDown() { + verify(mockSession); + } + + @Test + public void testGetId() { + logger.debug("Test: {}", "getId"); + expect(mockSession.getId()).andReturn("session-id-123"); + replay(mockSession); + + assertEquals("session-id-123", wrapper.getId()); + } + + @Test + public void testGetAttribute() { + logger.debug("Test: {}", "getAttribute"); + expect(mockSession.getAttribute("key")).andReturn("value"); + replay(mockSession); + + assertEquals("value", wrapper.getAttribute("key")); + } + + @Test + public void testSetAttribute() { + logger.debug("Test: {}", "setAttribute"); + mockSession.setAttribute("key", "value"); + EasyMock.expectLastCall(); + replay(mockSession); + + wrapper.setAttribute("key", "value"); + } + + @Test + public void testRemoveAttribute() { + logger.debug("Test: {}", "removeAttribute"); + mockSession.removeAttribute("key"); + EasyMock.expectLastCall(); + replay(mockSession); + + wrapper.removeAttribute("key"); + } + + @Test + public void testInvalidate() { + logger.debug("Test: {}", "invalidate"); + mockSession.invalidate(); + EasyMock.expectLastCall(); + replay(mockSession); + + wrapper.invalidate(); + } + + @Test + public void testGetOriginalSession() { + logger.debug("Test: {}", "getOriginalSession"); + replay(mockSession); + + assertSame(mockSession, wrapper.getOriginalSession()); + } +} diff --git a/superfly-client-opt/pom.xml b/superfly-client-opt/pom.xml index 1f934b7d..acb34a16 100644 --- a/superfly-client-opt/pom.xml +++ b/superfly-client-opt/pom.xml @@ -7,7 +7,7 @@ 4.0.0 superfly-client-opt Superfly Client optional support - Optional Superfly-related client features (like SSL support) + Optional Superfly-related client features (SSL support, HTTP client factory beans). Servlet-API independent; compatible with both EE8 (javax.servlet) and EE10 (jakarta.servlet) projects. @@ -15,9 +15,14 @@ jackson-dataformat-xml - - commons-httpclient - commons-httpclient + + com.payneteasy.http-client + http-client-api + + + + com.payneteasy.superfly + superfly-httpclient-hc5 @@ -49,26 +54,10 @@ superfly-remote-api - - com.payneteasy.superfly - superfly-client - - - + com.payneteasy.superfly - superfly-httpclient-ssl + superfly-client-core - - - jakarta.servlet - jakarta.servlet-api - - - - ca.juliusdavies - not-yet-commons-ssl - - org.reflections @@ -101,4 +90,33 @@ provided + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + enforce-no-servlet-api + + enforce + + + + + + javax.servlet:javax.servlet-api + jakarta.servlet:jakarta.servlet-api + + superfly-client-opt is servlet-API independent and must not depend on javax.servlet-api or jakarta.servlet-api + + + true + + + + + + diff --git a/superfly-client-opt/src/main/java/com/payneteasy/superfly/client/XmlActionDescriptionCollector.java b/superfly-client-opt/src/main/java/com/payneteasy/superfly/client/XmlActionDescriptionCollector.java deleted file mode 100644 index f08bdcff..00000000 --- a/superfly-client-opt/src/main/java/com/payneteasy/superfly/client/XmlActionDescriptionCollector.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.payneteasy.superfly.client; - -import com.fasterxml.jackson.dataformat.xml.XmlMapper; -import com.payneteasy.superfly.api.ActionDescription; -import lombok.Setter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.BeanUtils; -import org.springframework.core.io.Resource; - -import javax.xml.XMLConstants; -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -/** - * ActionDescriptionCollector implementation which returns a list of actions - * defined in an XML file. - * - * @author Roman Puchkovskiy - */ -@Setter -public class XmlActionDescriptionCollector implements ActionDescriptionCollector { - - private static final Logger logger = LoggerFactory.getLogger(XmlActionDescriptionCollector.class); - - private Resource resource; - - public List collect() { - List list; - try { - // Создаем XmlMapper вместо JAXBContext - File xmlFile = resource.getFile(); - XmlMapper xmlMapper = new XmlMapper(); - - // fix for vulnerability CVE-2022-40152 - xmlMapper.getFactory().getXMLInputFactory().setProperty(XMLConstants.FEATURE_SECURE_PROCESSING, true); - - ActionDescriptionRoot root = xmlMapper.readValue(xmlFile, ActionDescriptionRoot.class); - list = new ArrayList<>(root.getActions()); - } catch (IOException e) { - throw new RuntimeException(e); - } - - List actions = new ArrayList<>(list.size()); - for (ActionDescription bean : list) { - ActionDescription action = new ActionDescription(); - BeanUtils.copyProperties(bean, action); - actions.add(action); - } - - if (logger.isDebugEnabled()) { - logger.debug("Collected the following actions: " + actions); - } - - return actions; - } - -} diff --git a/superfly-client-opt/src/main/java/com/payneteasy/superfly/factorybean/HttpClientFactoryBean.java b/superfly-client-opt/src/main/java/com/payneteasy/superfly/factorybean/HttpClientFactoryBean.java deleted file mode 100644 index c3ffc88c..00000000 --- a/superfly-client-opt/src/main/java/com/payneteasy/superfly/factorybean/HttpClientFactoryBean.java +++ /dev/null @@ -1,153 +0,0 @@ -package com.payneteasy.superfly.factorybean; - -import com.payneteasy.httpclient.contrib.ssl.AuthSSLProtocolSocketFactory; -import lombok.AccessLevel; -import lombok.Data; -import lombok.experimental.Accessors; -import lombok.experimental.FieldDefaults; -import org.apache.commons.httpclient.*; -import org.apache.commons.httpclient.auth.AuthScope; -import org.apache.commons.httpclient.cookie.CookiePolicy; -import org.apache.commons.httpclient.params.HostParams; -import org.apache.commons.httpclient.params.HttpClientParams; -import org.apache.commons.httpclient.protocol.Protocol; -import org.apache.commons.httpclient.protocol.ProtocolSocketFactory; -import org.apache.commons.ssl.HttpSecureProtocol; -import org.springframework.beans.factory.FactoryBean; - -import java.io.IOException; -import java.net.URL; -import java.security.GeneralSecurityException; -import java.util.ArrayList; -import java.util.List; - -/** - * Factory bean for HttpClient. - * - * @author Roman Puchkovskiy - */ -@Accessors(fluent = true, chain = true) -@Data -@FieldDefaults(level = AccessLevel.PRIVATE) -public class HttpClientFactoryBean implements FactoryBean { - - boolean authenticationPreemptive = false; - String username; - String password; - String authHost; - StoresAndSSLConfig hostConfig = null; - long connectionManagerTimeout = 10000; // 10 seconds by default - int soTimeout = 10000; // 10 seconds by default - int connectionTimeout = -1; // negative values mean "leave existing value" - HttpConnectionManager httpConnectionManager = null; - String[] sslEnabledProtocols = null; - - private HttpClient httpClient = null; - - public synchronized HttpClient getObject() throws Exception { - if (httpClient == null) { - createAndConfigureHttpClient(); - } - return httpClient; - } - - protected void createAndConfigureHttpClient() throws IOException, GeneralSecurityException { - httpClient = createHttpClient(); - configureHttpClient(); - } - - protected void configureHttpClient() throws IOException, GeneralSecurityException { - httpClient.getParams().setAuthenticationPreemptive(authenticationPreemptive); - initCredentials(); - initSocketFactory(); - initProtocolIfNeeded(); - if (httpConnectionManager != null) { - httpClient.setHttpConnectionManager(httpConnectionManager); - } - - List

headers = getDefaultHeaders(); - - httpClient.getHostConfiguration().getParams().setParameter(HostParams.DEFAULT_HEADERS, headers); - httpClient.getParams().setParameter(HttpClientParams.USER_AGENT, - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/535.19 (KHTML, like Gecko) Ubuntu/11.04 Chromium/18.0.1025.151 Chrome/18.0.1025.151 Safari/535.19"); - httpClient.getParams().setParameter(HttpClientParams.HTTP_CONTENT_CHARSET, "UTF-8"); - httpClient.getParams().setCookiePolicy(CookiePolicy.BROWSER_COMPATIBILITY); - - httpClient.getParams().setConnectionManagerTimeout(connectionManagerTimeout); - httpClient.getParams().setSoTimeout(soTimeout); - if (connectionTimeout >= 0) { - httpClient.getHttpConnectionManager().getParams().setConnectionTimeout(connectionTimeout); - } - } - - protected void initProtocolIfNeeded() throws GeneralSecurityException, IOException { - if (sslEnabledProtocols != null) { - // have to register our own protocol - HttpSecureProtocol psf = new HttpSecureProtocol(); - psf.setEnabledProtocols(sslEnabledProtocols); - - Protocol sslV3OnlyProtocol = new Protocol("https", (ProtocolSocketFactory) psf, 443); - Protocol.registerProtocol("https", sslV3OnlyProtocol); - } - } - - protected List
getDefaultHeaders() { - List
headers = new ArrayList
(); - - headers.add(new Header("Accept", "*/*")); - headers.add(new Header("Connection", "close")); - headers.add(new Header("Accept-Language", "en-US,en;q=0.8")); - headers.add(new Header("Accept-Charset", "ISO-8859-1,utf-8;q=0.7,*;q=0.3")); - return headers; - } - - protected HttpClient createHttpClient() { - return new HttpClient(); - } - - protected void initCredentials() { - if (username != null && password != null) { - AuthScope authscope = new AuthScope( - authHost == null ? AuthScope.ANY_HOST : authHost, - AuthScope.ANY_PORT); - Credentials credentials = new UsernamePasswordCredentials(username, password); - httpClient.getState().setCredentials(authscope, credentials); - } - } - - protected void initSocketFactory() throws IOException { - if (hostConfig != null) { - URL trustKeyStoreUrl = null; - if (hostConfig.getTrustKeyStoreResource() != null) { - trustKeyStoreUrl = hostConfig.getTrustKeyStoreResource().getURL(); - } - URL keyStoreUrl = null; - if (hostConfig.getKeyStoreResource() != null) { - keyStoreUrl = hostConfig.getKeyStoreResource().getURL(); - } - AuthSSLProtocolSocketFactory factory = new AuthSSLProtocolSocketFactory( - keyStoreUrl, hostConfig.getKeyStorePassword(), - trustKeyStoreUrl, hostConfig.getTrustKeyStorePassword()); - if (sslEnabledProtocols != null) { - factory.setEnabledProtocols(sslEnabledProtocols); - } - Protocol protocol = createProtocol(hostConfig, factory); - httpClient.getHostConfiguration().setHost(hostConfig.getHost(), - hostConfig.getSecurePort(), protocol); - } - } - - private Protocol createProtocol(StoresAndSSLConfig config, - ProtocolSocketFactory factory) { - return new Protocol(config.getSecureSchema(), factory, config.getSecurePort()); - } - - public Class getObjectType() { - return HttpClient.class; - } - - public boolean isSingleton() { - return true; - } - -} diff --git a/superfly-client-opt/src/main/java/com/payneteasy/superfly/factorybean/HttpClientSpringConfiguration.java b/superfly-client-opt/src/main/java/com/payneteasy/superfly/factorybean/HttpClientSpringConfiguration.java index 1e9fe43b..a52077e8 100644 --- a/superfly-client-opt/src/main/java/com/payneteasy/superfly/factorybean/HttpClientSpringConfiguration.java +++ b/superfly-client-opt/src/main/java/com/payneteasy/superfly/factorybean/HttpClientSpringConfiguration.java @@ -1,21 +1,23 @@ package com.payneteasy.superfly.factorybean; -import lombok.AllArgsConstructor; -import org.apache.commons.httpclient.MultiThreadedHttpConnectionManager; +import com.payneteasy.http.client.api.IHttpClient; +import com.payneteasy.superfly.api.transport.ApacheHC5HttpClient; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** + * Provides the {@link IHttpClient} transport used to deliver Superfly notification callbacks. + * + *

Backed by {@link ApacheHC5HttpClient} (Apache HttpClient 5) with connection pooling and an + * {@link AutoCloseable} lifecycle — Spring closes the bean on context shutdown. Per-request + * timeouts are supplied by the caller (see the notification send strategies), so the client itself + * only carries pool/SSL defaults. + */ @Configuration -@AllArgsConstructor public class HttpClientSpringConfiguration { @Bean - public HttpClientFactoryBean httpClientFactoryBean() { - return new HttpClientFactoryBean() - .httpConnectionManager(new MultiThreadedHttpConnectionManager()) - .connectionManagerTimeout(10_000) - .soTimeout(30_000) - .connectionTimeout(30_000) - .sslEnabledProtocols(new String[]{"SSLv3"}); + public IHttpClient notificationHttpClient() { + return ApacheHC5HttpClient.builder().build(); } } diff --git a/superfly-client-opt/src/main/java/com/payneteasy/superfly/factorybean/StoresAndSSLConfig.java b/superfly-client-opt/src/main/java/com/payneteasy/superfly/factorybean/StoresAndSSLConfig.java deleted file mode 100644 index f224ce9e..00000000 --- a/superfly-client-opt/src/main/java/com/payneteasy/superfly/factorybean/StoresAndSSLConfig.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.payneteasy.superfly.factorybean; - -import org.springframework.core.io.Resource; - -/** - * Config for AuthSSLProtocolSocketFactory. - * - * @author Roman Puchkovskiy - */ -public class StoresAndSSLConfig { - private Resource trustKeyStoreResource = null; - private String trustKeyStorePassword; - private Resource keyStoreResource = null; - private String keyStorePassword; - private String secureSchema = "https"; - private String host; - private int securePort = 443; - - public Resource getTrustKeyStoreResource() { - return trustKeyStoreResource; - } - - public void setTrustKeyStoreResource(Resource trustKeyStoreResource) { - this.trustKeyStoreResource = trustKeyStoreResource; - } - - public String getTrustKeyStorePassword() { - return trustKeyStorePassword; - } - - public void setTrustKeyStorePassword(String trustKeyStorePassword) { - this.trustKeyStorePassword = trustKeyStorePassword; - } - - public Resource getKeyStoreResource() { - return keyStoreResource; - } - - public void setKeyStoreResource(Resource keyStoreResource) { - this.keyStoreResource = keyStoreResource; - } - - public String getKeyStorePassword() { - return keyStorePassword; - } - - public void setKeyStorePassword(String keyStorePassword) { - this.keyStorePassword = keyStorePassword; - } - - public String getSecureSchema() { - return secureSchema; - } - - public void setSecureSchema(String secureSchema) { - this.secureSchema = secureSchema; - } - - public String getHost() { - return host; - } - - public void setHost(String host) { - this.host = host; - } - - public int getSecurePort() { - return securePort; - } - - public void setSecurePort(int securePort) { - this.securePort = securePort; - } -} diff --git a/superfly-client-opt/src/test/java/com/payneteasy/superfly/client/XmlActionDescriptionCollectorTest.java b/superfly-client-opt/src/test/java/com/payneteasy/superfly/client/XmlActionDescriptionCollectorTest.java index 075c1af6..c74e8917 100644 --- a/superfly-client-opt/src/test/java/com/payneteasy/superfly/client/XmlActionDescriptionCollectorTest.java +++ b/superfly-client-opt/src/test/java/com/payneteasy/superfly/client/XmlActionDescriptionCollectorTest.java @@ -1,13 +1,10 @@ package com.payneteasy.superfly.client; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.dataformat.xml.XmlMapper; import com.payneteasy.superfly.api.ActionDescription; import com.payneteasy.superfly.client.exception.CollectionException; import org.junit.Test; import org.springframework.core.io.ClassPathResource; -import javax.xml.XMLConstants; import java.util.List; import static org.junit.Assert.assertEquals; @@ -16,19 +13,6 @@ public class XmlActionDescriptionCollectorTest { @Test public void testCollect() throws CollectionException { - ActionDescriptionRoot newRoot = new ActionDescriptionRoot(List.of( - new ActionDescriptionBean("action3", "description3"), - new ActionDescriptionBean("action4", "description4") - )); - XmlMapper xmlMapper = new XmlMapper(); - try { - String newXml = xmlMapper.writeValueAsString(newRoot); - System.out.println(newXml); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - - XmlActionDescriptionCollector collector = new XmlActionDescriptionCollector(); collector.setResource(new ClassPathResource("test-actions.xml")); List descriptions = collector.collect(); diff --git a/superfly-client-opt/src/test/java/com/payneteasy/superfly/client/opt/SslTestMain.java b/superfly-client-opt/src/test/java/com/payneteasy/superfly/client/opt/SslTestMain.java deleted file mode 100644 index d7910666..00000000 --- a/superfly-client-opt/src/test/java/com/payneteasy/superfly/client/opt/SslTestMain.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.payneteasy.superfly.client.opt; - -import org.apache.commons.httpclient.HttpClient; -import org.apache.commons.httpclient.HttpMethod; -import org.apache.commons.httpclient.methods.PostMethod; -import org.springframework.context.ApplicationContext; -import org.springframework.context.support.ClassPathXmlApplicationContext; - -public class SslTestMain { - public static void main(String[] args) throws Exception { - ApplicationContext applicationContext = new ClassPathXmlApplicationContext("test-ssl.xml"); - HttpClient httpClient = (HttpClient) applicationContext.getBean("httpClient"); - HttpMethod method = new PostMethod("/superfly/remoting/sso.service"); - httpClient.executeMethod(method); - System.out.println(method.getStatusCode()); - System.out.println(method.getResponseHeader("Location")); - System.out.println(method.getResponseBodyAsString()); - } -} diff --git a/superfly-client-opt/src/test/java/org/apache/commons/ssl/HttpSecureProtocolTest.java b/superfly-client-opt/src/test/java/org/apache/commons/ssl/HttpSecureProtocolTest.java deleted file mode 100644 index ba479aa8..00000000 --- a/superfly-client-opt/src/test/java/org/apache/commons/ssl/HttpSecureProtocolTest.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.apache.commons.ssl; - -import org.junit.Test; - -import java.io.IOException; -import java.security.GeneralSecurityException; - -public class HttpSecureProtocolTest { - @Test - public void testInstantiation() throws GeneralSecurityException, IOException { - // this test works on JDK8 but failed on JDK9 - new HttpSecureProtocol(); - } -} diff --git a/superfly-client-opt/src/test/resources/test-ssl.xml b/superfly-client-opt/src/test/resources/test-ssl.xml deleted file mode 100644 index b0032d1d..00000000 --- a/superfly-client-opt/src/test/resources/test-ssl.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/superfly-client-web-security/pom.xml b/superfly-client-web-security/pom.xml index 76b14489..4310fabb 100644 --- a/superfly-client-web-security/pom.xml +++ b/superfly-client-web-security/pom.xml @@ -17,12 +17,11 @@ superfly-remote-api - + - - com.caucho - hessian - true + + com.payneteasy.http-client + http-client-impl diff --git a/superfly-client-web-security/src/main/java/com/payneteasy/superfly/security/filters/ExternalFormSecurityFilter.java b/superfly-client-web-security/src/main/java/com/payneteasy/superfly/security/filters/ExternalFormSecurityFilter.java index 32b8ed64..7494ce07 100644 --- a/superfly-client-web-security/src/main/java/com/payneteasy/superfly/security/filters/ExternalFormSecurityFilter.java +++ b/superfly-client-web-security/src/main/java/com/payneteasy/superfly/security/filters/ExternalFormSecurityFilter.java @@ -1,12 +1,17 @@ package com.payneteasy.superfly.security.filters; -import com.caucho.hessian.client.HessianProxyFactory; +import com.payneteasy.http.client.api.HttpRequestParameters; +import com.payneteasy.http.client.api.HttpTimeouts; +import com.payneteasy.http.client.impl.HttpClientImpl; import com.payneteasy.superfly.api.ActionDescription; import com.payneteasy.superfly.api.request.ExchangeSubsystemTokenRequest; import com.payneteasy.superfly.api.request.SendSystemDataRequest; import com.payneteasy.superfly.api.SSOAction; import com.payneteasy.superfly.api.SSOService; import com.payneteasy.superfly.api.SSOUser; +import com.payneteasy.superfly.api.client.SSOClientConfig; +import com.payneteasy.superfly.api.client.SSOHttpServiceApiClient; +import com.payneteasy.superfly.api.serialization.ApiSerializationManager; import com.payneteasy.superfly.security.filters.internal.SecurityFilterFlow; import com.payneteasy.superfly.security.spring.SecuredBeanPostProcessor; import com.payneteasy.superfly.security.spring.internal.SecurityContext; @@ -18,49 +23,91 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import java.net.MalformedURLException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.HashSet; import java.util.List; +import java.util.Objects; import java.util.Set; public class ExternalFormSecurityFilter implements Filter { private static final Logger LOG = LoggerFactory.getLogger(ExternalFormSecurityFilter.class); + private static final int DEFAULT_TIMEOUT_MS = 30_000; + + /** Path-сегмент JSON remote-api на стороне SSO-сервера (RemoteApiController @RequestMapping). */ + private static final String REMOTE_API_PATH = "/remoting/sso.service"; + private final ExcludedPaths paths; private final String loginFormUrl; private final String logoutUrl; private final SSOService ssoService; private final String packageName; private final String systemName; + /** true, если транспортный JSON-клиент создан этим фильтром и должен закрываться в {@link #destroy()}. */ + private final boolean ownsSsoService; + + /** + * DI-конструктор: {@link SSOService} инжектится снаружи (тесты / Spring-конфигурация). + * Жизненным циклом клиента управляет вызывающая сторона — {@link #destroy()} его не закрывает. + */ + public ExternalFormSecurityFilter(ExcludedPaths aPaths + , String aSystemName + , String aSsoWebBaseUrl + , String aPackageName + , SSOService aSsoService + ) { + this(aPaths, aSystemName, aSsoWebBaseUrl, aPackageName, aSsoService, false); + } + /** + * URL-конструктор (обратная совместимость для интеграторов): собирает JSON-клиент + * {@link SSOHttpServiceApiClient} поверх {@link HttpClientImpl}, заменяя прежний Hessian-транспорт. + * + * @param aSsoServiceBaseUrl базовый URL SSO-сервера (HTTPS; добавляется {@value #REMOTE_API_PATH}). + * Допускается http только при {@code -Dsuperfly.client.allowInsecureScheme=true}. + */ public ExternalFormSecurityFilter(ExcludedPaths aPaths , String aSystemName , String aAccessToken , String aSsoWebBaseUrl , String aPackageName , String aSsoServiceBaseUrl + ) { + this(aPaths, aSystemName, aSsoWebBaseUrl, aPackageName, + buildJsonClient(aSystemName, aAccessToken, aSsoServiceBaseUrl), true); + } + + private ExternalFormSecurityFilter(ExcludedPaths aPaths + , String aSystemName + , String aSsoWebBaseUrl + , String aPackageName + , SSOService aSsoService + , boolean aOwnsSsoService ) { paths = aPaths; loginFormUrl = aSsoWebBaseUrl + "/sso/login?subsystemIdentifier=" + aSystemName + "&targetUrl="; logoutUrl = aSsoWebBaseUrl + "/sso/logout?subsystemIdentifier=" + aSystemName + "&targetUrl="; packageName = aPackageName; systemName = aSystemName; + ssoService = Objects.requireNonNull(aSsoService, "ssoService must not be null"); + ownsSsoService = aOwnsSsoService; + LOG.debug("ExternalFormSecurityFilter initialized: system={} ownsClient={}", aSystemName, aOwnsSsoService); + } - HessianProxyFactory factory = new HessianProxyFactory(); - factory.setUser(aSystemName); - factory.setPassword(aAccessToken); - factory.setConnectTimeout(30_000); - factory.setReadTimeout(30_000); - String serviceUrl = aSsoServiceBaseUrl + "/remoting/basic.hessian.service"; - try { - ssoService = (SSOService) factory.create(SSOService.class, serviceUrl); - } catch (MalformedURLException e) { - throw new IllegalStateException("Could not parse url: " + serviceUrl, e); - } - + private static SSOService buildJsonClient(String aSystemName, String aAccessToken, String aSsoServiceBaseUrl) { + String baseUrl = aSsoServiceBaseUrl + REMOTE_API_PATH; + LOG.info("Building JSON SSO client: baseUrl={} subsystem={}", baseUrl, aSystemName); + SSOClientConfig config = SSOClientConfig.builder() + .baseUrl(baseUrl) + .subsystemName(aSystemName) + .subsystemToken(aAccessToken) + .defaultParameters(HttpRequestParameters.builder() + .timeouts(new HttpTimeouts(DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)) + .build()) + .build(); + return new SSOHttpServiceApiClient(new HttpClientImpl(), config, new ApiSerializationManager()); } @Override @@ -113,7 +160,7 @@ public void doFilter(ServletRequest aRequest, ServletResponse aResponse, FilterC response.sendRedirect(request.getParameter("targetUrl")); } catch (Exception e) { LOG.error("Could not validate token", e); - showBadTokenPage(response, e.getMessage()); + showBadTokenPage(response, "Token validation failed"); } return; } @@ -139,7 +186,7 @@ private void validateExternalToken(HttpServletRequest aRequest) { if(subsystemToken == null) { throw new IllegalStateException("No 'subsystemToken' in paraters"); } - LOG.info("Checking token {}", subsystemToken); + LOG.debug("Checking subsystem token"); SSOUser ssoUser = ssoService.exchangeSubsystemToken(new ExchangeSubsystemTokenRequest(subsystemToken)); if(ssoUser == null) { throw new IllegalStateException("Token is not valid"); @@ -167,5 +214,13 @@ private void redirectToLoginPage(String aUrl, HttpServletResponse aResponse) thr @Override public void destroy() { + if (ownsSsoService && ssoService instanceof AutoCloseable closeable) { + try { + LOG.debug("Closing owned JSON SSO client"); + closeable.close(); + } catch (Exception e) { + LOG.warn("Error while closing SSO client", e); + } + } } } diff --git a/superfly-common/pom.xml b/superfly-common/pom.xml index 2665291f..69a265de 100644 --- a/superfly-common/pom.xml +++ b/superfly-common/pom.xml @@ -14,11 +14,6 @@ com.payneteasy.superfly superfly-remote-api - - - jakarta.servlet - jakarta.servlet-api - org.slf4j diff --git a/superfly-demo/pom.xml b/superfly-demo/pom.xml index 1f4ba1ae..08a99c62 100644 --- a/superfly-demo/pom.xml +++ b/superfly-demo/pom.xml @@ -13,7 +13,7 @@ com.payneteasy.superfly - superfly-spring-security + superfly-spring-security-ee10 ${superfly.version} diff --git a/superfly-httpclient-hc5/pom.xml b/superfly-httpclient-hc5/pom.xml new file mode 100644 index 00000000..31f14468 --- /dev/null +++ b/superfly-httpclient-hc5/pom.xml @@ -0,0 +1,34 @@ + + + superfly-parent + com.payneteasy.superfly + 2.0-3-SNAPSHOT + + 4.0.0 + superfly-httpclient-hc5 + Superfly HttpClient HC5 Transport + Apache HttpClient 5 transport implementation (ApacheHC5HttpClient) with connection pooling, + per-request timeouts, SSL/mTLS support, and AutoCloseable lifecycle. + Consumers who need this transport must declare both this artifact and httpclient5 as dependencies. + + + + com.payneteasy.http-client + http-client-api + + + + org.apache.httpcomponents.client5 + httpclient5 + + + + org.slf4j + slf4j-api + + + + + diff --git a/superfly-httpclient-ssl/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLX509KeyManager.java b/superfly-httpclient-hc5/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLX509KeyManager.java similarity index 100% rename from superfly-httpclient-ssl/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLX509KeyManager.java rename to superfly-httpclient-hc5/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLX509KeyManager.java diff --git a/superfly-httpclient-ssl/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLX509TrustManager.java b/superfly-httpclient-hc5/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLX509TrustManager.java similarity index 93% rename from superfly-httpclient-ssl/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLX509TrustManager.java rename to superfly-httpclient-hc5/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLX509TrustManager.java index 482c1327..c5b1ce26 100644 --- a/superfly-httpclient-ssl/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLX509TrustManager.java +++ b/superfly-httpclient-hc5/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLX509TrustManager.java @@ -78,11 +78,11 @@ public void checkClientTrusted(X509Certificate[] certificates,String authType) t for (int c = 0; c < certificates.length; c++) { X509Certificate cert = certificates[c]; LOG.info(" Client certificate " + (c + 1) + ":"); - LOG.info(" Subject DN: " + cert.getSubjectDN()); + LOG.info(" Subject DN: " + cert.getSubjectX500Principal()); LOG.info(" Signature Algorithm: " + cert.getSigAlgName()); LOG.info(" Valid from: " + cert.getNotBefore() ); LOG.info(" Valid until: " + cert.getNotAfter()); - LOG.info(" Issuer: " + cert.getIssuerDN()); + LOG.info(" Issuer: " + cert.getIssuerX500Principal()); } } defaultTrustManager.checkClientTrusted(certificates,authType); @@ -96,11 +96,11 @@ public void checkServerTrusted(X509Certificate[] certificates,String authType) t for (int c = 0; c < certificates.length; c++) { X509Certificate cert = certificates[c]; LOG.info(" Server certificate " + (c + 1) + ":"); - LOG.info(" Subject DN: " + cert.getSubjectDN()); + LOG.info(" Subject DN: " + cert.getSubjectX500Principal()); LOG.info(" Signature Algorithm: " + cert.getSigAlgName()); LOG.info(" Valid from: " + cert.getNotBefore() ); LOG.info(" Valid until: " + cert.getNotAfter()); - LOG.info(" Issuer: " + cert.getIssuerDN()); + LOG.info(" Issuer: " + cert.getIssuerX500Principal()); } } defaultTrustManager.checkServerTrusted(certificates,authType); diff --git a/superfly-httpclient-hc5/src/main/java/com/payneteasy/httpclient/contrib/ssl/JdkSslSocketFactoryBuilder.java b/superfly-httpclient-hc5/src/main/java/com/payneteasy/httpclient/contrib/ssl/JdkSslSocketFactoryBuilder.java new file mode 100644 index 00000000..c247171e --- /dev/null +++ b/superfly-httpclient-hc5/src/main/java/com/payneteasy/httpclient/contrib/ssl/JdkSslSocketFactoryBuilder.java @@ -0,0 +1,176 @@ +package com.payneteasy.httpclient.contrib.ssl; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509KeyManager; +import javax.net.ssl.X509TrustManager; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * Builds JDK-native {@link SSLSocketFactory} and {@link X509TrustManager} from keyStore/trustStore URLs. + * Unlike the former commons-httpclient {@code AuthSSLProtocolSocketFactory}, this builder has no + * servlet-container or legacy-HTTP dependency and feeds the ApacheHC5HttpClient SSL context directly. + */ +public class JdkSslSocketFactoryBuilder { + + private static final Logger LOG = LoggerFactory.getLogger(JdkSslSocketFactoryBuilder.class); + + private JdkSslSocketFactoryBuilder() {} + + /** + * Builds an {@link SSLSocketFactory} from a keyStore and a trustStore. + * Either URL may be {@code null}: {@code null} keyStore skips client auth setup; + * {@code null} trustStore uses the JVM default trust anchors. + */ + public static SSLSocketFactory buildSocketFactory( + URL keyStoreUrl, String keyStorePassword, + URL trustStoreUrl, String trustStorePassword + ) throws GeneralSecurityException, IOException { + LOG.debug("Building SSLSocketFactory: keyStore={}, trustStore={}", keyStoreUrl, trustStoreUrl); + SSLContext ctx = buildSslContext(keyStoreUrl, keyStorePassword, trustStoreUrl, trustStorePassword); + return ctx.getSocketFactory(); + } + + /** + * Builds the first {@link X509TrustManager} from the given trustStore. + * Returns {@code null} when {@code trustStoreUrl} is {@code null}. + */ + public static X509TrustManager buildTrustManager( + URL trustStoreUrl, String trustStorePassword + ) throws GeneralSecurityException, IOException { + if (trustStoreUrl == null) { + LOG.debug("trustStoreUrl is null — returning null trust manager"); + return null; + } + LOG.debug("Building X509TrustManager from trustStore={}", trustStoreUrl); + KeyStore ks = loadKeyStore(trustStoreUrl, trustStorePassword); + TrustManager[] managers = buildTrustManagers(ks); + for (TrustManager m : managers) { + if (m instanceof X509TrustManager) { + return (X509TrustManager) m; + } + } + return null; + } + + /** + * Returns a {@link HostnameVerifier} that validates the server certificate's CN against + * the provided set of expected values instead of the connection hostname. + *

+ * Use when the server cert CN doesn't match the hostname (e.g. CN=superfly-server, host=localhost). + * Safe only when paired with a custom trustStore — the cert chain is already verified by TLS. + */ + public static HostnameVerifier buildCnHostnameVerifier(String... expectedCns) { + Set allowed = new HashSet<>(Arrays.asList(expectedCns)); + return (hostname, session) -> { + try { + java.security.cert.Certificate[] certs = session.getPeerCertificates(); + if (certs.length > 0 && certs[0] instanceof X509Certificate) { + X509Certificate cert = (X509Certificate) certs[0]; + String dn = cert.getSubjectX500Principal().getName(); + String cn = extractCn(dn); + boolean ok = cn != null && allowed.contains(cn); + LOG.debug("CN hostname check: host={}, cert-cn={}, allowed={}, result={}", hostname, cn, allowed, ok); + return ok; + } + } catch (Exception e) { + LOG.warn("CN hostname verification failed: {}", e.getMessage()); + } + return false; + }; + } + + private static String extractCn(String dn) { + for (String part : dn.split(",")) { + String trimmed = part.trim(); + if (trimmed.startsWith("CN=")) { + return trimmed.substring(3); + } + } + return null; + } + + /** + * Builds an {@link SSLContext} from a keyStore and a trustStore. + * Either URL may be {@code null}: {@code null} keyStore skips client auth setup; + * {@code null} trustStore uses the JVM default trust anchors. + *

+ * Use this method when the caller needs an {@link SSLContext} directly (e.g. Apache HttpClient 5) + * rather than a {@link SSLSocketFactory}. + */ + public static SSLContext buildSslContext( + URL keyStoreUrl, String keyStorePassword, + URL trustStoreUrl, String trustStorePassword + ) throws GeneralSecurityException, IOException { + KeyManager[] keyManagers = null; + if (keyStoreUrl != null) { + KeyStore ks = loadKeyStore(keyStoreUrl, keyStorePassword); + keyManagers = buildKeyManagers(ks, keyStorePassword); + } + + TrustManager[] trustManagers = null; + if (trustStoreUrl != null) { + KeyStore ts = loadKeyStore(trustStoreUrl, trustStorePassword); + trustManagers = buildTrustManagers(ts); + } + + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(keyManagers, trustManagers, null); + LOG.debug("SSLContext initialised successfully"); + return ctx; + } + + private static KeyStore loadKeyStore(URL url, String password) throws GeneralSecurityException, IOException { + LOG.debug("Loading KeyStore from {}", url); + KeyStore ks = KeyStore.getInstance("JKS"); + try (InputStream is = url.openStream()) { + ks.load(is, password != null ? password.toCharArray() : null); + } + return ks; + } + + private static KeyManager[] buildKeyManagers(KeyStore ks, String password) + throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException { + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, password != null ? password.toCharArray() : null); + KeyManager[] managers = kmf.getKeyManagers(); + for (int i = 0; i < managers.length; i++) { + if (managers[i] instanceof X509KeyManager) { + managers[i] = new AuthSSLX509KeyManager((X509KeyManager) managers[i]); + } + } + return managers; + } + + private static TrustManager[] buildTrustManagers(KeyStore ks) + throws KeyStoreException, NoSuchAlgorithmException { + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(ks); + TrustManager[] managers = tmf.getTrustManagers(); + for (int i = 0; i < managers.length; i++) { + if (managers[i] instanceof X509TrustManager) { + managers[i] = new AuthSSLX509TrustManager((X509TrustManager) managers[i]); + } + } + return managers; + } +} diff --git a/superfly-httpclient-hc5/src/main/java/com/payneteasy/superfly/api/transport/ApacheHC5HttpClient.java b/superfly-httpclient-hc5/src/main/java/com/payneteasy/superfly/api/transport/ApacheHC5HttpClient.java new file mode 100644 index 00000000..783273fa --- /dev/null +++ b/superfly-httpclient-hc5/src/main/java/com/payneteasy/superfly/api/transport/ApacheHC5HttpClient.java @@ -0,0 +1,330 @@ +package com.payneteasy.superfly.api.transport; + +import com.payneteasy.http.client.api.HttpHeader; +import com.payneteasy.http.client.api.HttpHeaders; +import com.payneteasy.http.client.api.HttpMethod; +import com.payneteasy.http.client.api.HttpRequest; +import com.payneteasy.http.client.api.HttpRequestParameters; +import com.payneteasy.http.client.api.HttpResponse; +import com.payneteasy.http.client.api.HttpTimeouts; +import com.payneteasy.http.client.api.IHttpClient; +import com.payneteasy.http.client.api.exceptions.HttpConnectException; +import com.payneteasy.http.client.api.exceptions.HttpReadException; +import com.payneteasy.http.client.api.exceptions.HttpWriteException; +import org.apache.hc.client5.http.ConnectTimeoutException; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.io.entity.ByteArrayEntity; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.message.BasicClassicHttpRequest; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLContext; +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * {@link IHttpClient} и {@link AutoCloseable} реализация поверх Apache HttpClient 5. + * + *

Преимущества перед {@code HttpClientImpl} (JDK HttpURLConnection): + *

    + *
  • Connection pooling — устраняет накладные расходы на TCP-handshake при каждом вызове
  • + *
  • Explicit lifecycle — {@link #close()} корректно шатдаунит пул; безопасен в try-with-resources
  • + *
  • Per-request timeout — {@link HttpTimeouts} из {@link HttpRequestParameters} отображается + * в HC5 {@link RequestConfig} при каждом вызове
  • + *
+ * + *

SSL/mTLS настраивается один раз при создании через {@link Builder#sslContext(SSLContext)}. + * Per-request поля {@code sslSocketFactory} и {@code hostnameVerifier} из {@link HttpRequestParameters} + * игнорируются — HC5 использует pooled connections с одним SSL-контекстом. + * + *

Использование: + *

{@code
+ * try (ApacheHC5HttpClient client = ApacheHC5HttpClient.builder()
+ *         .sslContext(JdkSslSocketFactoryBuilder.buildSslContext(...))
+ *         .hostnameVerifier(JdkSslSocketFactoryBuilder.buildCnHostnameVerifier("superfly-server"))
+ *         .build()) {
+ *     HttpResponse response = client.send(request, params);
+ * }
+ * }
+ */ +public class ApacheHC5HttpClient implements IHttpClient, AutoCloseable { + + private static final Logger LOG = LoggerFactory.getLogger(ApacheHC5HttpClient.class); + + // ── connection pool defaults ────────────────────────────────────────────── + + /** + * Максимальное суммарное число соединений в пуле. + * + *

Дефолт {@code 20} рассчитан на single-host setup (consumer видит один SSO-host): + * {@code maxConnTotal == maxConnPerRoute}. Покрывает peak-нагрузку типичного consumer + * (paynet ≈ 20–30 RPS). Для multi-host или high-RPS — увеличить осознанно через builder. + */ + public static final int DEFAULT_MAX_CONN_TOTAL = 20; + + /** + * Максимальное число соединений на один маршрут (host:port). + * + *

Симметрично {@link #DEFAULT_MAX_CONN_TOTAL} для single-host setup: при одном + * SSO-сервере «total» и «perRoute» — один и тот же лимит. Для multi-host увеличить + * {@code maxConnTotal} пропорционально количеству хостов, сохраняя {@code perRoute=20}. + */ + public static final int DEFAULT_MAX_CONN_PER_ROUTE = 20; + + /** Время простоя (сек) после которого соединение вытесняется из пула. */ + public static final int DEFAULT_IDLE_EVICTION_SEC = 30; + + // ── state ───────────────────────────────────────────────────────────────── + + private final CloseableHttpClient httpClient; + private final PoolingHttpClientConnectionManager connManager; + private volatile boolean closed = false; + + // ── constructor ─────────────────────────────────────────────────────────── + + private ApacheHC5HttpClient(Builder builder) { + SSLConnectionSocketFactory sslSF = buildSslSocketFactory(builder.sslContext, builder.hostnameVerifier); + + this.connManager = PoolingHttpClientConnectionManagerBuilder.create() + .setSSLSocketFactory(sslSF) + .setMaxConnTotal(builder.maxConnTotal) + .setMaxConnPerRoute(builder.maxConnPerRoute) + .build(); + + this.httpClient = HttpClients.custom() + .setConnectionManager(connManager) + .evictIdleConnections(TimeValue.of(builder.idleEvictionSec, TimeUnit.SECONDS)) + .build(); + + LOG.info("ApacheHC5HttpClient created: maxConnTotal={} maxConnPerRoute={} idleEvictionSec={} ssl={}", + builder.maxConnTotal, builder.maxConnPerRoute, builder.idleEvictionSec, + builder.sslContext != null ? "configured" : "default"); + } + + // ── IHttpClient ─────────────────────────────────────────────────────────── + + @Override + public HttpResponse send(HttpRequest request, HttpRequestParameters params) + throws HttpConnectException, HttpReadException, HttpWriteException { + + if (closed) { + throw new IllegalStateException("ApacheHC5HttpClient is already closed"); + } + + String url = request.getUrl(); + long startMs = System.currentTimeMillis(); + + LOG.debug("send: method={} url={} timeouts={}", request.getMethod(), url, formatTimeouts(params)); + + ClassicHttpRequest hcRequest = buildHcRequest(request); + HttpClientContext context = buildContext(params); + + try { + HttpResponse response = httpClient.execute(hcRequest, context, httpResponse -> { + int status = httpResponse.getCode(); + String reason = httpResponse.getReasonPhrase(); + List headers = mapResponseHeaders(httpResponse.getHeaders()); + byte[] body = readBody(httpResponse.getEntity()); + + long elapsed = System.currentTimeMillis() - startMs; + logPoolStats(); + LOG.debug("send: status={} reason={} bodyLen={} elapsed={}ms url={}", + status, reason, body.length, elapsed, url); + + return new HttpResponse(status, reason, headers, body); + }); + + return response; + + } catch (ConnectTimeoutException e) { + LOG.warn("send: connect timeout after {}ms url={}", System.currentTimeMillis() - startMs, url); + throw new HttpConnectException("Connect timeout to " + url, e); + + } catch (SocketTimeoutException e) { + LOG.warn("send: response timeout after {}ms url={}", System.currentTimeMillis() - startMs, url); + throw new HttpReadException("Response timeout from " + url, e); + + } catch (IOException e) { + String msg = e.getMessage() != null ? e.getMessage().toLowerCase() : ""; + if (msg.contains("connect") || msg.contains("refused") || msg.contains("unreachable")) { + LOG.warn("send: connect error after {}ms url={} error={}", System.currentTimeMillis() - startMs, url, e.getMessage()); + throw new HttpConnectException("Connection failed to " + url, e); + } + LOG.warn("send: read/write error after {}ms url={} error={}", System.currentTimeMillis() - startMs, url, e.getMessage()); + throw new HttpReadException("I/O error communicating with " + url, e); + } + } + + // ── AutoCloseable ───────────────────────────────────────────────────────── + + @Override + public void close() { + if (closed) { + LOG.debug("close: already closed, ignoring"); + return; + } + closed = true; + LOG.info("ApacheHC5HttpClient closing: shutting down connection pool and HTTP client"); + try { + httpClient.close(); + } catch (IOException e) { + LOG.warn("close: error closing httpClient: {}", e.getMessage()); + } + connManager.close(); + LOG.info("ApacheHC5HttpClient closed"); + } + + // ── Builder ─────────────────────────────────────────────────────────────── + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private SSLContext sslContext = null; + private HostnameVerifier hostnameVerifier = null; + private int maxConnTotal = DEFAULT_MAX_CONN_TOTAL; + private int maxConnPerRoute = DEFAULT_MAX_CONN_PER_ROUTE; + private int idleEvictionSec = DEFAULT_IDLE_EVICTION_SEC; + + private Builder() {} + + /** SSLContext для mTLS/TLS. {@code null} → JVM default SSL context. */ + public Builder sslContext(SSLContext sslContext) { + this.sslContext = sslContext; + return this; + } + + /** HostnameVerifier для проверки CN сертификата сервера. {@code null} → стандартная проверка. */ + public Builder hostnameVerifier(HostnameVerifier hostnameVerifier) { + this.hostnameVerifier = hostnameVerifier; + return this; + } + + /** Максимальное суммарное число соединений в пуле. */ + public Builder maxConnTotal(int maxConnTotal) { + this.maxConnTotal = maxConnTotal; + return this; + } + + /** Максимальное число соединений на один маршрут. */ + public Builder maxConnPerRoute(int maxConnPerRoute) { + this.maxConnPerRoute = maxConnPerRoute; + return this; + } + + /** Время простоя (сек) после которого соединение вытесняется. */ + public Builder idleEvictionSec(int idleEvictionSec) { + this.idleEvictionSec = idleEvictionSec; + return this; + } + + public ApacheHC5HttpClient build() { + return new ApacheHC5HttpClient(this); + } + } + + // ── internals ───────────────────────────────────────────────────────────── + + private static SSLConnectionSocketFactory buildSslSocketFactory( + SSLContext sslContext, HostnameVerifier hostnameVerifier) { + + SSLConnectionSocketFactoryBuilder sslBuilder = SSLConnectionSocketFactoryBuilder.create(); + if (sslContext != null) { + sslBuilder.setSslContext(sslContext); + LOG.debug("buildSslSocketFactory: using custom SSLContext"); + } + if (hostnameVerifier != null) { + sslBuilder.setHostnameVerifier(hostnameVerifier); + LOG.debug("buildSslSocketFactory: using custom HostnameVerifier={}", hostnameVerifier.getClass().getSimpleName()); + } + return sslBuilder.build(); + } + + private static ClassicHttpRequest buildHcRequest(HttpRequest request) { + String methodName = request.getMethod() != null ? request.getMethod().name() : HttpMethod.GET.name(); + BasicClassicHttpRequest hcRequest = new BasicClassicHttpRequest(methodName, request.getUrl()); + + HttpHeaders headers = request.getHeaders(); + if (headers != null) { + for (HttpHeader header : headers.asList()) { + hcRequest.addHeader(header.getName(), header.getValue()); + } + } + + byte[] body = request.getBody(); + if (body != null && body.length > 0) { + hcRequest.setEntity(new ByteArrayEntity(body, null)); + } + + return hcRequest; + } + + private static HttpClientContext buildContext(HttpRequestParameters params) { + HttpClientContext context = HttpClientContext.create(); + if (params != null && params.getTimeouts() != null) { + HttpTimeouts timeouts = params.getTimeouts(); + RequestConfig requestConfig = RequestConfig.custom() + .setConnectTimeout(Timeout.ofMilliseconds(timeouts.getConnectTimeoutMs())) + .setResponseTimeout(Timeout.ofMilliseconds(timeouts.getReadTimeoutMs())) + .build(); + context.setRequestConfig(requestConfig); + LOG.debug("buildContext: connectTimeout={}ms responseTimeout={}ms", + timeouts.getConnectTimeoutMs(), timeouts.getReadTimeoutMs()); + } + return context; + } + + private static byte[] readBody(HttpEntity entity) throws IOException { + if (entity == null) { + return new byte[0]; + } + byte[] bytes = EntityUtils.toByteArray(entity); + return bytes != null ? bytes : new byte[0]; + } + + private static List mapResponseHeaders(Header[] headers) { + if (headers == null) { + return List.of(); + } + List result = new ArrayList<>(headers.length); + for (Header h : headers) { + result.add(new HttpHeader(h.getName(), h.getValue())); + } + return result; + } + + private void logPoolStats() { + if (LOG.isDebugEnabled()) { + var stats = connManager.getTotalStats(); + LOG.debug("pool: available={} leased={} pending={} max={}", + stats.getAvailable(), stats.getLeased(), stats.getPending(), stats.getMax()); + } + } + + private static String formatTimeouts(HttpRequestParameters params) { + if (params == null || params.getTimeouts() == null) { + return "none"; + } + HttpTimeouts t = params.getTimeouts(); + return "connect=" + t.getConnectTimeoutMs() + "ms read=" + t.getReadTimeoutMs() + "ms"; + } +} diff --git a/superfly-httpclient-hc5/src/test/java/com/payneteasy/httpclient/contrib/ssl/JdkSslSocketFactoryBuilderTest.java b/superfly-httpclient-hc5/src/test/java/com/payneteasy/httpclient/contrib/ssl/JdkSslSocketFactoryBuilderTest.java new file mode 100644 index 00000000..efe72c9f --- /dev/null +++ b/superfly-httpclient-hc5/src/test/java/com/payneteasy/httpclient/contrib/ssl/JdkSslSocketFactoryBuilderTest.java @@ -0,0 +1,199 @@ +package com.payneteasy.httpclient.contrib.ssl; + +import org.easymock.EasyMock; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509TrustManager; +import javax.security.auth.x500.X500Principal; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.URL; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; + +import static org.easymock.EasyMock.*; +import static org.junit.Assert.*; + +public class JdkSslSocketFactoryBuilderTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + // ── buildSocketFactory ──────────────────────────────────────────────────── + + @Test + public void testBuildSocketFactory_withBothStores_returnsNonNull() throws Exception { + URL ksUrl = emptyJks("ks"); + URL tsUrl = emptyJks("ts"); + + SSLSocketFactory factory = JdkSslSocketFactoryBuilder.buildSocketFactory( + ksUrl, "testpass", tsUrl, "testpass"); + + assertNotNull("SSLSocketFactory must not be null", factory); + } + + @Test + public void testBuildSocketFactory_nullKeyStore_doesNotThrow() throws Exception { + URL tsUrl = emptyJks("ts"); + + SSLSocketFactory factory = JdkSslSocketFactoryBuilder.buildSocketFactory( + null, null, tsUrl, "testpass"); + + assertNotNull(factory); + } + + @Test + public void testBuildSocketFactory_nullTrustStore_doesNotThrow() throws Exception { + URL ksUrl = emptyJks("ks"); + + SSLSocketFactory factory = JdkSslSocketFactoryBuilder.buildSocketFactory( + ksUrl, "testpass", null, null); + + assertNotNull(factory); + } + + @Test + public void testBuildSocketFactory_bothNull_doesNotThrow() throws Exception { + SSLSocketFactory factory = JdkSslSocketFactoryBuilder.buildSocketFactory( + null, null, null, null); + + assertNotNull(factory); + } + + @Test(expected = IOException.class) + public void testBuildSocketFactory_nonExistentKeyStoreUrl_throwsIOException() throws Exception { + URL bad = new File(tmp.getRoot(), "does-not-exist.jks").toURI().toURL(); + + JdkSslSocketFactoryBuilder.buildSocketFactory(bad, "pass", null, null); + } + + // ── buildSslContext ─────────────────────────────────────────────────────── + + @Test + public void testBuildSslContext_withBothStores_returnsNonNull() throws Exception { + URL ksUrl = emptyJks("ks"); + URL tsUrl = emptyJks("ts"); + + SSLContext ctx = JdkSslSocketFactoryBuilder.buildSslContext(ksUrl, "testpass", tsUrl, "testpass"); + + assertNotNull("SSLContext must not be null", ctx); + } + + @Test + public void testBuildSslContext_nullKeyStore_doesNotThrow() throws Exception { + URL tsUrl = emptyJks("ts"); + + SSLContext ctx = JdkSslSocketFactoryBuilder.buildSslContext(null, null, tsUrl, "testpass"); + + assertNotNull(ctx); + } + + @Test + public void testBuildSslContext_bothNull_doesNotThrow() throws Exception { + SSLContext ctx = JdkSslSocketFactoryBuilder.buildSslContext(null, null, null, null); + + assertNotNull(ctx); + } + + @Test + public void testBuildSslContext_socketFactoryConsistent() throws Exception { + // buildSocketFactory and buildSslContext must produce equivalent contexts + URL ksUrl = emptyJks("ks2"); + URL tsUrl = emptyJks("ts2"); + + SSLContext ctx = JdkSslSocketFactoryBuilder.buildSslContext(ksUrl, "testpass", tsUrl, "testpass"); + SSLSocketFactory factoryFromCtx = ctx.getSocketFactory(); + + assertNotNull("SocketFactory from SSLContext must not be null", factoryFromCtx); + } + + // ── buildTrustManager ──────────────────────────────────────────────────── + + @Test + public void testBuildTrustManager_withTrustStore_returnsNonNull() throws Exception { + URL tsUrl = emptyJks("ts"); + + X509TrustManager tm = JdkSslSocketFactoryBuilder.buildTrustManager(tsUrl, "testpass"); + + assertNotNull("TrustManager must not be null", tm); + } + + @Test + public void testBuildTrustManager_nullUrl_returnsNull() throws Exception { + X509TrustManager tm = JdkSslSocketFactoryBuilder.buildTrustManager(null, null); + + assertNull(tm); + } + + // ── buildCnHostnameVerifier ─────────────────────────────────────────────── + + @Test + public void testBuildCnHostnameVerifier_matchingCn_returnsTrue() throws Exception { + HostnameVerifier verifier = JdkSslSocketFactoryBuilder. + buildCnHostnameVerifier("superfly-server"); + + boolean result = verifier.verify("localhost", sessionWithCn("superfly-server")); + + assertTrue("Verifier must accept cert with matching CN", result); + } + + @Test + public void testBuildCnHostnameVerifier_nonMatchingCn_returnsFalse() throws Exception { + HostnameVerifier verifier = JdkSslSocketFactoryBuilder.buildCnHostnameVerifier("superfly-server"); + + boolean result = verifier.verify("localhost", sessionWithCn("other-server")); + + assertFalse("Verifier must reject cert with non-matching CN", result); + } + + @Test + public void testBuildCnHostnameVerifier_emptyCnList_returnsFalse() throws Exception { + HostnameVerifier verifier = JdkSslSocketFactoryBuilder.buildCnHostnameVerifier(); + + boolean result = verifier.verify("localhost", sessionWithCn("superfly-server")); + + assertFalse("Verifier with empty allowed list must reject any CN", result); + } + + @Test + public void testBuildCnHostnameVerifier_multipleCns_acceptsEitherOne() throws Exception { + HostnameVerifier verifier = JdkSslSocketFactoryBuilder.buildCnHostnameVerifier("superfly-server", "superfly-dev"); + + assertTrue(verifier.verify("localhost", sessionWithCn("superfly-server"))); + assertTrue(verifier.verify("localhost", sessionWithCn("superfly-dev"))); + assertFalse(verifier.verify("localhost", sessionWithCn("other"))); + } + + private SSLSession sessionWithCn(String cn) throws Exception { + X509Certificate cert = EasyMock.createMock(X509Certificate.class); + expect(cert.getSubjectX500Principal()).andReturn(new X500Principal("CN=" + cn)).anyTimes(); + replay(cert); + + SSLSession session = EasyMock.createMock(SSLSession.class); + expect(session.getPeerCertificates()).andReturn(new Certificate[]{ cert }).anyTimes(); + replay(session); + + return session; + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + private URL emptyJks(String prefix) throws GeneralSecurityException, IOException { + KeyStore ks = KeyStore.getInstance("JKS"); + ks.load(null, null); + File f = tmp.newFile(prefix + ".jks"); + try (FileOutputStream fos = new FileOutputStream(f)) { + ks.store(fos, "testpass".toCharArray()); + } + return f.toURI().toURL(); + } +} diff --git a/superfly-httpclient-hc5/src/test/java/com/payneteasy/superfly/api/transport/ApacheHC5HttpClientTest.java b/superfly-httpclient-hc5/src/test/java/com/payneteasy/superfly/api/transport/ApacheHC5HttpClientTest.java new file mode 100644 index 00000000..29346e0f --- /dev/null +++ b/superfly-httpclient-hc5/src/test/java/com/payneteasy/superfly/api/transport/ApacheHC5HttpClientTest.java @@ -0,0 +1,185 @@ +package com.payneteasy.superfly.api.transport; + +import com.payneteasy.http.client.api.HttpHeader; +import com.payneteasy.http.client.api.HttpHeaders; +import com.payneteasy.http.client.api.HttpMethod; +import com.payneteasy.http.client.api.HttpRequest; +import com.payneteasy.http.client.api.HttpRequestParameters; +import com.payneteasy.http.client.api.HttpResponse; +import com.payneteasy.http.client.api.HttpTimeouts; +import com.payneteasy.http.client.api.exceptions.HttpConnectException; +import com.payneteasy.http.client.api.exceptions.HttpReadException; +import com.sun.net.httpserver.HttpServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.*; + +/** + * Unit-тесты {@link ApacheHC5HttpClient} с JDK {@link HttpServer} в качестве embedded HTTP-сервера. + */ +public class ApacheHC5HttpClientTest { + + private HttpServer server; + private int serverPort; + private ApacheHC5HttpClient client; + + @Before + public void setUp() throws Exception { + server = HttpServer.create(new InetSocketAddress(0), 10); + server.setExecutor(Executors.newCachedThreadPool()); + server.start(); + serverPort = server.getAddress().getPort(); + client = ApacheHC5HttpClient.builder().build(); + } + + @After + public void tearDown() { + if (client != null) client.close(); + if (server != null) server.stop(0); + } + + /** + * Contract test: дефолты pool size — single-host симметрия (20/20). + * Изменение этих констант должно потребовать осознанного обновления теста и причины в PR + * (см. ROADMAP.md SSO-3-tune: paynet — single SSO-host setup, ≈ 20–30 RPS). + */ + @Test + public void testDefaultPoolSizes() { + assertEquals(20, ApacheHC5HttpClient.DEFAULT_MAX_CONN_TOTAL); + assertEquals(20, ApacheHC5HttpClient.DEFAULT_MAX_CONN_PER_ROUTE); + assertEquals(30, ApacheHC5HttpClient.DEFAULT_IDLE_EVICTION_SEC); + } + + @Test + public void testSuccessfulPost() throws Exception { + server.createContext("/api/login", exchange -> { + byte[] resp = "{\"result\":\"ok\"}".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, resp.length); + exchange.getResponseBody().write(resp); + exchange.close(); + }); + + HttpResponse response = client.send( + HttpRequest.builder() + .url("http://localhost:" + serverPort + "/api/login") + .method(HttpMethod.POST) + .body("{\"user\":\"test\"}".getBytes(StandardCharsets.UTF_8)) + .build(), + HttpRequestParameters.builder().timeouts(new HttpTimeouts(5_000, 5_000)).build()); + + assertEquals(200, response.getStatusCode()); + assertEquals("{\"result\":\"ok\"}", new String(response.getBody(), StandardCharsets.UTF_8)); + } + + @Test(expected = HttpConnectException.class) + public void testConnectTimeoutThrows() throws Exception { + int freePort; + try (ServerSocket ss = new ServerSocket(0)) { freePort = ss.getLocalPort(); } + + ApacheHC5HttpClient freshClient = ApacheHC5HttpClient.builder().build(); + try { + freshClient.send( + HttpRequest.builder().url("http://localhost:" + freePort + "/test").method(HttpMethod.GET).build(), + HttpRequestParameters.builder().timeouts(new HttpTimeouts(200, 200)).build()); + fail("Expected HttpConnectException"); + } finally { + freshClient.close(); + } + } + + @Test(expected = HttpReadException.class) + public void testResponseTimeoutThrows() throws Exception { + server.createContext("/api/slow", exchange -> { + try { Thread.sleep(3_000); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } + byte[] resp = "slow".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, resp.length); + exchange.getResponseBody().write(resp); + exchange.close(); + }); + + client.send( + HttpRequest.builder().url("http://localhost:" + serverPort + "/api/slow").method(HttpMethod.GET).build(), + HttpRequestParameters.builder().timeouts(new HttpTimeouts(5_000, 150)).build()); + fail("Expected HttpReadException"); + } + + @Test + public void testPerRequestTimeoutOverride() throws Exception { + server.createContext("/api/fast", exchange -> { + byte[] resp = "fast".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, resp.length); + exchange.getResponseBody().write(resp); + exchange.close(); + }); + server.createContext("/api/delayed", exchange -> { + try { Thread.sleep(500); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } + byte[] resp = "delayed".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, resp.length); + exchange.getResponseBody().write(resp); + exchange.close(); + }); + + HttpResponse fast = client.send( + HttpRequest.builder().url("http://localhost:" + serverPort + "/api/fast").method(HttpMethod.GET).build(), + HttpRequestParameters.builder().timeouts(new HttpTimeouts(5_000, 5_000)).build()); + assertEquals(200, fast.getStatusCode()); + + try { + client.send( + HttpRequest.builder().url("http://localhost:" + serverPort + "/api/delayed").method(HttpMethod.GET).build(), + HttpRequestParameters.builder().timeouts(new HttpTimeouts(5_000, 100)).build()); + fail("Expected HttpReadException from per-request timeout"); + } catch (HttpReadException e) { + // expected + } + } + + @Test(expected = IllegalStateException.class) + public void testAutoCloseableShutdown() throws Exception { + ApacheHC5HttpClient shortLived = ApacheHC5HttpClient.builder().build(); + shortLived.close(); + shortLived.send( + HttpRequest.builder().url("http://localhost:" + serverPort + "/test").method(HttpMethod.GET).build(), + HttpRequestParameters.builder().timeouts(new HttpTimeouts(5_000, 5_000)).build()); + } + + @Test + public void testHeadersForwardedCorrectly() throws Exception { + AtomicReference capturedToken = new AtomicReference<>(); + AtomicReference capturedCt = new AtomicReference<>(); + + server.createContext("/api/auth", exchange -> { + capturedToken.set(exchange.getRequestHeaders().getFirst("X-Subsystem-Token")); + capturedCt.set(exchange.getRequestHeaders().getFirst("Content-Type")); + byte[] resp = "ok".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, resp.length); + exchange.getResponseBody().write(resp); + exchange.close(); + }); + + HttpResponse response = client.send( + HttpRequest.builder() + .url("http://localhost:" + serverPort + "/api/auth") + .method(HttpMethod.POST) + .headers(new HttpHeaders(List.of( + new HttpHeader("X-Subsystem-Token", "token-abc"), + new HttpHeader("Content-Type", "application/x-java-serialized-object")))) + .body(new byte[]{1, 2, 3}) + .build(), + HttpRequestParameters.builder().timeouts(new HttpTimeouts(5_000, 5_000)).build()); + + assertEquals(200, response.getStatusCode()); + assertEquals("token-abc", capturedToken.get()); + assertNotNull(capturedCt.get()); + } +} diff --git a/superfly-httpclient-ssl/pom.xml b/superfly-httpclient-ssl/pom.xml deleted file mode 100644 index 4c2df62d..00000000 --- a/superfly-httpclient-ssl/pom.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - superfly-parent - com.payneteasy.superfly - 2.0-3-SNAPSHOT - - 4.0.0 - superfly-httpclient-ssl - HttpClient SSL Extensions - Extensions to HttpClient which allow to use client SSL authentication as well as server trust check - - - - commons-httpclient - commons-httpclient - - - - org.slf4j - slf4j-api - - - diff --git a/superfly-httpclient-ssl/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLInitializationError.java b/superfly-httpclient-ssl/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLInitializationError.java deleted file mode 100644 index 64ce5605..00000000 --- a/superfly-httpclient-ssl/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLInitializationError.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * $HeadURL$ - * $Revision$ - * $Date$ - * - * ==================================================================== - * - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * ==================================================================== - * - * This software consists of voluntary contributions made by many - * individuals on behalf of the Apache Software Foundation. For more - * information on the Apache Software Foundation, please see - * . - */ - -package com.payneteasy.httpclient.contrib.ssl; - -/** - *

- * Signals fatal error in initialization of {@link AuthSSLProtocolSocketFactory}. - *

- * - * @author Oleg Kalnichevski - * - *

- * DISCLAIMER: HttpClient developers DO NOT actively support this component. - * The component is provided as a reference material, which may be inappropriate - * for use without additional customization. - *

- */ - -public class AuthSSLInitializationError extends Error { - - /** - * Creates a new AuthSSLInitializationError. - */ - public AuthSSLInitializationError() { - super(); - } - - /** - * Creates a new AuthSSLInitializationError with the specified message. - * - * @param message error message - */ - public AuthSSLInitializationError(String message) { - super(message); - } -} diff --git a/superfly-httpclient-ssl/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLProtocolSocketFactory.java b/superfly-httpclient-ssl/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLProtocolSocketFactory.java deleted file mode 100644 index 5a145ba3..00000000 --- a/superfly-httpclient-ssl/src/main/java/com/payneteasy/httpclient/contrib/ssl/AuthSSLProtocolSocketFactory.java +++ /dev/null @@ -1,471 +0,0 @@ -/* - * $HeadURL$ - * $Revision$ - * $Date$ - * - * ==================================================================== - * - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * ==================================================================== - * - * This software consists of voluntary contributions made by many - * individuals on behalf of the Apache Software Foundation. For more - * information on the Apache Software Foundation, please see - * . - * - */ - -package com.payneteasy.httpclient.contrib.ssl; - -import org.apache.commons.httpclient.ConnectTimeoutException; -import org.apache.commons.httpclient.params.HttpConnectionParams; -import org.apache.commons.httpclient.protocol.SecureProtocolSocketFactory; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import javax.net.SocketFactory; -import javax.net.ssl.KeyManager; -import javax.net.ssl.KeyManagerFactory; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLSocket; -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; -import javax.net.ssl.X509KeyManager; -import javax.net.ssl.X509TrustManager; -import java.io.IOException; -import java.io.InputStream; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.Socket; -import java.net.SocketAddress; -import java.net.URL; -import java.net.UnknownHostException; -import java.security.GeneralSecurityException; -import java.security.KeyStore; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; -import java.security.UnrecoverableKeyException; -import java.security.cert.Certificate; -import java.security.cert.CertificateException; -import java.security.cert.X509Certificate; -import java.util.Enumeration; - -/** - *

- * AuthSSLProtocolSocketFactory can be used to validate the identity of the HTTPS - * server against a list of trusted certificates and to authenticate to the HTTPS - * server using a private key. - *

- * - *

- * AuthSSLProtocolSocketFactory will enable server authentication when supplied with - * a {@link KeyStore truststore} file containg one or several trusted certificates. - * The client secure socket will reject the connection during the SSL session handshake - * if the target HTTPS server attempts to authenticate itself with a non-trusted - * certificate. - *

- * - *

- * Use JDK keytool utility to import a trusted certificate and generate a truststore file: - *

- *     keytool -import -alias "my server cert" -file server.crt -keystore my.truststore
- *    
- *

- * - *

- * AuthSSLProtocolSocketFactory will enable client authentication when supplied with - * a {@link KeyStore keystore} file containg a private key/public certificate pair. - * The client secure socket will use the private key to authenticate itself to the target - * HTTPS server during the SSL session handshake if requested to do so by the server. - * The target HTTPS server will in its turn verify the certificate presented by the client - * in order to establish client's authenticity - *

- * - *

- * Use the following sequence of actions to generate a keystore file - *

- *
    - *
  • - *

    - * Use JDK keytool utility to generate a new key - *

    keytool -genkey -v -alias "my client key" -validity 365 -keystore my.keystore
    - * For simplicity use the same password for the key as that of the keystore - *

    - *
  • - *
  • - *

    - * Issue a certificate signing request (CSR) - *

    keytool -certreq -alias "my client key" -file mycertreq.csr -keystore my.keystore
    - *

    - *
  • - *
  • - *

    - * Send the certificate request to the trusted Certificate Authority for signature. - * One may choose to act as her own CA and sign the certificate request using a PKI - * tool, such as OpenSSL. - *

    - *
  • - *
  • - *

    - * Import the trusted CA root certificate - *

    keytool -import -alias "my trusted ca" -file caroot.crt -keystore my.keystore
    - *

    - *
  • - *
  • - *

    - * Import the PKCS#7 file containg the complete certificate chain - *

    keytool -import -alias "my client key" -file mycert.p7 -keystore my.keystore
    - *

    - *
  • - *
  • - *

    - * Verify the content the resultant keystore file - *

    keytool -list -v -keystore my.keystore
    - *

    - *
  • - *
- *

- * Example of using custom protocol socket factory for a specific host: - *

- *     Protocol authhttps = new Protocol("https",  
- *          new AuthSSLProtocolSocketFactory(
- *              new URL("file:my.keystore"), "mypassword",
- *              new URL("file:my.truststore"), "mypassword"), 443); 
- *
- *     HttpClient client = new HttpClient();
- *     client.getHostConfiguration().setHost("localhost", 443, authhttps);
- *     // use relative url only
- *     GetMethod httpget = new GetMethod("/");
- *     client.executeMethod(httpget);
- *     
- *

- *

- * Example of using custom protocol socket factory per default instead of the standard one: - *

- *     Protocol authhttps = new Protocol("https",  
- *          new AuthSSLProtocolSocketFactory(
- *              new URL("file:my.keystore"), "mypassword",
- *              new URL("file:my.truststore"), "mypassword"), 443); 
- *     Protocol.registerProtocol("https", authhttps);
- *
- *     HttpClient client = new HttpClient();
- *     GetMethod httpget = new GetMethod("https://localhost/");
- *     client.executeMethod(httpget);
- *     
- *

- * @author Oleg Kalnichevski - * - *

- * DISCLAIMER: HttpClient developers DO NOT actively support this component. - * The component is provided as a reference material, which may be inappropriate - * for use without additional customization. - *

- */ - -public class AuthSSLProtocolSocketFactory implements SecureProtocolSocketFactory { - - /** Log object for this class. */ - private static final Logger LOG = LoggerFactory.getLogger(AuthSSLProtocolSocketFactory.class); - - private URL keystoreUrl = null; - private String keystorePassword = null; - private URL truststoreUrl = null; - private String truststorePassword = null; - private SSLContext sslcontext = null; - - private String[] enabledProtocols = null; - - /** - * Constructor for AuthSSLProtocolSocketFactory. Either a keystore or truststore file - * must be given. Otherwise SSL context initialization error will result. - * - * @param keystoreUrl URL of the keystore file. May be null if HTTPS client - * authentication is not to be used. - * @param keystorePassword Password to unlock the keystore. IMPORTANT: this implementation - * assumes that the same password is used to protect the key and the keystore itself. - * @param truststoreUrl URL of the truststore file. May be null if HTTPS server - * authentication is not to be used. - * @param truststorePassword Password to unlock the truststore. - */ - public AuthSSLProtocolSocketFactory( - final URL keystoreUrl, final String keystorePassword, - final URL truststoreUrl, final String truststorePassword) - { - super(); - this.keystoreUrl = keystoreUrl; - this.keystorePassword = keystorePassword; - this.truststoreUrl = truststoreUrl; - this.truststorePassword = truststorePassword; - } - - public void setEnabledProtocols(String[] enabledProtocols) { - if (enabledProtocols == null) { - this.enabledProtocols = null; - } else { - this.enabledProtocols = new String[enabledProtocols.length]; - System.arraycopy(enabledProtocols, 0, this.enabledProtocols, 0, enabledProtocols.length); - } - } - - private static KeyStore createKeyStore(final URL url, final String password) - throws KeyStoreException, NoSuchAlgorithmException, CertificateException, IOException - { - if (url == null) { - throw new IllegalArgumentException("Keystore url may not be null"); - } - LOG.debug("Initializing key store"); - KeyStore keystore = KeyStore.getInstance("jks"); - InputStream is = null; - try { - is = url.openStream(); - keystore.load(is, password != null ? password.toCharArray(): null); - } finally { - if (is != null) is.close(); - } - return keystore; - } - - private static KeyManager[] createKeyManagers(final KeyStore keystore, final String password) - throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException - { - if (keystore == null) { - throw new IllegalArgumentException("Keystore may not be null"); - } - LOG.debug("Initializing key manager"); - KeyManagerFactory kmfactory = KeyManagerFactory.getInstance( - KeyManagerFactory.getDefaultAlgorithm()); - kmfactory.init(keystore, password != null ? password.toCharArray(): null); - KeyManager[] keymanagers = kmfactory.getKeyManagers(); - for (int i = 0; i < keymanagers.length; i++) { - if (keymanagers[i] instanceof X509KeyManager) { - keymanagers[i] = new AuthSSLX509KeyManager((X509KeyManager) keymanagers[i]); - } - } - return keymanagers; - } - - private static TrustManager[] createTrustManagers(final KeyStore keystore) - throws KeyStoreException, NoSuchAlgorithmException - { - if (keystore == null) { - throw new IllegalArgumentException("Keystore may not be null"); - } - LOG.debug("Initializing trust manager"); - TrustManagerFactory tmfactory = TrustManagerFactory.getInstance( - TrustManagerFactory.getDefaultAlgorithm()); - tmfactory.init(keystore); - TrustManager[] trustmanagers = tmfactory.getTrustManagers(); - for (int i = 0; i < trustmanagers.length; i++) { - if (trustmanagers[i] instanceof X509TrustManager) { - trustmanagers[i] = new AuthSSLX509TrustManager( - (X509TrustManager)trustmanagers[i]); - } - } - return trustmanagers; - } - - private SSLContext createSSLContext() { - try { - KeyManager[] keymanagers = null; - TrustManager[] trustmanagers = null; - if (this.keystoreUrl != null) { - KeyStore keystore = createKeyStore(this.keystoreUrl, this.keystorePassword); - if (LOG.isDebugEnabled()) { - Enumeration aliases = keystore.aliases(); - while (aliases.hasMoreElements()) { - String alias = (String)aliases.nextElement(); - Certificate[] certs = keystore.getCertificateChain(alias); - if (certs != null) { - LOG.debug("Certificate chain '" + alias + "':"); - for (int c = 0; c < certs.length; c++) { - if (certs[c] instanceof X509Certificate) { - X509Certificate cert = (X509Certificate)certs[c]; - LOG.debug(" Certificate " + (c + 1) + ":"); - LOG.debug(" Subject DN: " + cert.getSubjectDN()); - LOG.debug(" Signature Algorithm: " + cert.getSigAlgName()); - LOG.debug(" Valid from: " + cert.getNotBefore() ); - LOG.debug(" Valid until: " + cert.getNotAfter()); - LOG.debug(" Issuer: " + cert.getIssuerDN()); - } - } - } - } - } - keymanagers = createKeyManagers(keystore, this.keystorePassword); - } - if (this.truststoreUrl != null) { - KeyStore keystore = createKeyStore(this.truststoreUrl, this.truststorePassword); - if (LOG.isDebugEnabled()) { - Enumeration aliases = keystore.aliases(); - while (aliases.hasMoreElements()) { - String alias = (String)aliases.nextElement(); - LOG.debug("Trusted certificate '" + alias + "':"); - Certificate trustedcert = keystore.getCertificate(alias); - if (trustedcert != null && trustedcert instanceof X509Certificate) { - X509Certificate cert = (X509Certificate)trustedcert; - LOG.debug(" Subject DN: " + cert.getSubjectDN()); - LOG.debug(" Signature Algorithm: " + cert.getSigAlgName()); - LOG.debug(" Valid from: " + cert.getNotBefore() ); - LOG.debug(" Valid until: " + cert.getNotAfter()); - LOG.debug(" Issuer: " + cert.getIssuerDN()); - } - } - } - trustmanagers = createTrustManagers(keystore); - } - SSLContext sslcontext = SSLContext.getInstance("SSL"); - sslcontext.init(keymanagers, trustmanagers, null); - return sslcontext; - } catch (NoSuchAlgorithmException e) { - LOG.error(e.getMessage(), e); - throw new AuthSSLInitializationError("Unsupported algorithm exception: " + e.getMessage()); - } catch (KeyStoreException e) { - LOG.error(e.getMessage(), e); - throw new AuthSSLInitializationError("Keystore exception: " + e.getMessage()); - } catch (GeneralSecurityException e) { - LOG.error(e.getMessage(), e); - throw new AuthSSLInitializationError("Key management exception: " + e.getMessage()); - } catch (IOException e) { - LOG.error(e.getMessage(), e); - throw new AuthSSLInitializationError("I/O error reading keystore/truststore file: " + e.getMessage()); - } - } - - private SSLContext getSSLContext() { - if (this.sslcontext == null) { - this.sslcontext = createSSLContext(); - } - return this.sslcontext; - } - - /** - * Attempts to get a new socket connection to the given host within the given time limit. - *

- * To circumvent the limitations of older JREs that do not support connect timeout a - * controller thread is executed. The controller thread attempts to create a new socket - * within the given limit of time. If socket constructor does not return until the - * timeout expires, the controller terminates and throws an {@link ConnectTimeoutException} - *

- * - * @param host the host name/IP - * @param port the port on the host - * @param localAddress the local host name/IP to bind the socket to - * @param localPort the port on the local machine - * @param params {@link HttpConnectionParams Http connection parameters} - * - * @return Socket a new socket - * - * @throws IOException if an I/O error occurs while creating the socket - * @throws UnknownHostException if the IP address of the host cannot be - * determined - */ - public Socket createSocket( - final String host, - final int port, - final InetAddress localAddress, - final int localPort, - final HttpConnectionParams params - ) throws IOException, UnknownHostException, ConnectTimeoutException { - if (params == null) { - throw new IllegalArgumentException("Parameters may not be null"); - } - int timeout = params.getConnectionTimeout(); - SocketFactory socketfactory = getSSLContext().getSocketFactory(); - if (timeout == 0) { - Socket socket = socketfactory.createSocket(host, port, localAddress, localPort); - doPreConnectSocketStuff(socket); - return socket; - } else { - Socket socket = socketfactory.createSocket(); - doPreConnectSocketStuff(socket); - SocketAddress localaddr = new InetSocketAddress(localAddress, localPort); - SocketAddress remoteaddr = new InetSocketAddress(host, port); - - if (timeout > 0 && socket.getSoTimeout() == 0) { - // force SO timeout if not set so we don't freeze forever - // during a handshake - socket.setSoTimeout(timeout); - } - - socket.bind(localaddr); - socket.connect(remoteaddr, timeout); - return socket; - } - } - - /** - * @see SecureProtocolSocketFactory#createSocket(java.lang.String,int,java.net.InetAddress,int) - */ - public Socket createSocket( - String host, - int port, - InetAddress clientHost, - int clientPort) - throws IOException, UnknownHostException - { - Socket socket = getSSLContext().getSocketFactory().createSocket( - host, - port, - clientHost, - clientPort - ); - doPreConnectSocketStuff(socket); - return socket; - } - - /** - * @see SecureProtocolSocketFactory#createSocket(java.lang.String,int) - */ - public Socket createSocket(String host, int port) - throws IOException, UnknownHostException - { - Socket socket = getSSLContext().getSocketFactory().createSocket( - host, - port - ); - doPreConnectSocketStuff(socket); - return socket; - } - - /** - * @see SecureProtocolSocketFactory#createSocket(java.net.Socket,java.lang.String,int,boolean) - */ - public Socket createSocket( - Socket socket, - String host, - int port, - boolean autoClose) - throws IOException, UnknownHostException - { - Socket createdSocket = getSSLContext().getSocketFactory().createSocket( - socket, - host, - port, - autoClose - ); - doPreConnectSocketStuff(createdSocket); - return createdSocket; - } - - private void doPreConnectSocketStuff(Socket socket) { - if (enabledProtocols != null) { - if (socket instanceof SSLSocket) { - SSLSocket sslSocket = (SSLSocket) socket; - sslSocket.setEnabledProtocols(enabledProtocols); - } - } - } -} diff --git a/superfly-integration-test/src/test/java/com/payneteasy/superfly/dao/UserDaoTest.java b/superfly-integration-test/src/test/java/com/payneteasy/superfly/dao/UserDaoTest.java index faf2ecf7..54dad1d5 100644 --- a/superfly-integration-test/src/test/java/com/payneteasy/superfly/dao/UserDaoTest.java +++ b/superfly-integration-test/src/test/java/com/payneteasy/superfly/dao/UserDaoTest.java @@ -239,6 +239,13 @@ public void testUnlockUser() { assertRoutineResult(result); } + @Test + public void testResetPassword() { + long userId = getAnyUserId(); + RoutineResult result = userDao.resetPassword(userId, "new-password-hash"); + assertRoutineResult(result); + } + @Test public void testCloneUser() { long userId = getAnyUserId(); diff --git a/superfly-remote-api/pom.xml b/superfly-remote-api/pom.xml index 24b6c29e..57678cb4 100644 --- a/superfly-remote-api/pom.xml +++ b/superfly-remote-api/pom.xml @@ -22,16 +22,11 @@ provided - + com.payneteasy.http-client http-client-api - - - com.payneteasy.http-client - http-client-impl - - + com.google.code.gson gson diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOEvent.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOEvent.java new file mode 100644 index 00000000..30e3e323 --- /dev/null +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOEvent.java @@ -0,0 +1,64 @@ +package com.payneteasy.superfly.api; + +import java.io.Serializable; +import java.util.Date; + +/** + * SSO event + */ +public class SSOEvent implements Serializable { + private static final long serialVersionUID = 2939579042187840631L; + private String eventData; + private Long eventId; + private String eventTypeCode; + private Date eventTime; + + public SSOEvent(Long eventId, Date eventTime, String eventTypeCode, String eventData) { + this.eventId = eventId; + this.eventTime = eventTime; + this.eventTypeCode = eventTypeCode; + this.eventData = eventData; + } + + public String getEventData() { + return eventData; + } + + public void setEventData(String eventData) { + this.eventData = eventData; + } + + public Long getEventId() { + return eventId; + } + + public void setEventId(Long eventId) { + this.eventId = eventId; + } + + public String getEventTypeCode() { + return eventTypeCode; + } + + public void setEventTypeCode(String eventTypeCode) { + this.eventTypeCode = eventTypeCode; + } + + public Date getEventTime() { + return eventTime; + } + + public void setEventTime(Date eventTime) { + this.eventTime = eventTime; + } + + @Override + public String toString() { + return "SSOEvent{" + + "eventData='" + eventData + '\'' + + ", eventId=" + eventId + + ", eventTypeCode='" + eventTypeCode + '\'' + + ", eventTime=" + eventTime + + '}'; + } +} diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOService.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOService.java index 68dab199..0364c800 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOService.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOService.java @@ -176,4 +176,11 @@ void registerUser(UserRegisterRequest request) * @param request change user role request containing username, new role, and subsystem hint */ void changeUserRole(ChangeUserRoleRequest request); + + /** + * Get events from lastEventTime. + * + * @param request request containing lastEventTime and waitTimeMs + */ + List getEvents(GetEventsRequest request); } diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOUser.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOUser.java index 7faee5d1..28c2c0bb 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOUser.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/SSOUser.java @@ -128,6 +128,28 @@ public void setActionsMap(Map actionsMap) { this.actionsMap = actionsMap; } + /** + * Checks if the user has the specified action. + * + * @param actionName action name to check + * @return true if the user has the action + */ + public boolean hasAction(String actionName) { + if (actionsMap == null) { + return false; + } + for (SSOAction[] actions : actionsMap.values()) { + if (actions != null) { + for (SSOAction action : actions) { + if (action != null && actionName.equals(action.getName())) { + return true; + } + } + } + } + return false; + } + /** * Returns preferences. * diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/client/Endpoint.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/client/Endpoint.java new file mode 100644 index 00000000..3f6f2c8b --- /dev/null +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/client/Endpoint.java @@ -0,0 +1,49 @@ +package com.payneteasy.superfly.api.client; + +/** + * Идентификатор REST-эндпоинта SSO API. + * + *

Используется как type-safe ключ для {@link SSOClientConfig#parametersFor(Endpoint)} + * (per-endpoint timeout override) и для построения URL через {@link SSOClientConfig#urlFor(Endpoint)}. + * + *

Замещает строковые literals в {@link SSOHttpServiceApiClient} — теперь добавление + * нового эндпоинта требует расширения этого enum, что делает невозможным опечатки и + * даёт compile-time гарантии при настройке per-endpoint config override. + */ +public enum Endpoint { + + AUTHENTICATE ("/authenticate"), + CHECK_OTP ("/checkOtp"), + HAS_OTP_MASTER_KEY ("/hasOtpMasterKey"), + PSEUDO_AUTHENTICATE ("/pseudoAuthenticate"), + SEND_SYSTEM_DATA ("/sendSystemData"), + GET_USERS_WITH_ACTIONS ("/getUsersWithActions"), + UPDATE_USER_OTP_TYPE ("/updateUserOtpType"), + REGISTER_USER ("/registerUser"), + CHANGE_TEMP_PASSWORD ("/changeTempPassword"), + GET_USER_DESCRIPTION ("/getUserDescription"), + RESET_GOOGLE_AUTH_MASTER_KEY ("/resetGoogleAuthMasterKey"), + GET_URL_TO_GOOGLE_AUTH_QR_CODE ("/getUrlToGoogleAuthQrCode"), + UPDATE_USER_IS_OTP_OPTIONAL_VALUE ("/updateUserIsOtpOptionalValue"), + UPDATE_USER_DESCRIPTION ("/updateUserDescription"), + RESET_PASSWORD ("/resetPassword"), + GET_USER_STATUSES ("/getUserStatuses"), + EXCHANGE_SUBSYSTEM_TOKEN ("/exchangeSubsystemToken"), + TOUCH_SESSIONS ("/touchSessions"), + COMPLETE_USER ("/completeUser"), + CHANGE_USER_ROLE ("/changeUserRole"), + GET_EVENTS ("/getEvents"); + + private final String path; + + Endpoint(String path) { + this.path = path; + } + + /** + * @return path-сегмент эндпоинта, начинающийся с {@code /}. + */ + public String path() { + return path; + } +} diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/client/SSOClientConfig.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/client/SSOClientConfig.java new file mode 100644 index 00000000..c80a5ab0 --- /dev/null +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/client/SSOClientConfig.java @@ -0,0 +1,124 @@ +package com.payneteasy.superfly.api.client; + +import com.payneteasy.http.client.api.HttpRequestParameters; +import com.payneteasy.http.client.api.HttpTimeouts; +import lombok.Builder; +import lombok.Getter; +import lombok.Singular; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; +import java.util.Map; +import java.util.Objects; + +/** + * Иммутабельная конфигурация {@link SSOHttpServiceApiClient}. + * + *

Содержит координаты SSO-сервера (baseUrl, subsystem name/token), дефолтные параметры HTTP-запроса + * и опциональный per-endpoint override таймаутов через {@link #endpointParameters}. + * + *

Per-endpoint timeouts. {@code defaultParameters} применяется ко всем эндпоинтам, + * для которых не задан override. Если в {@code endpointParameters} есть запись для конкретного + * {@link Endpoint} — используется она. Это позволяет разнести профили вызовов: + * + *

{@code
+ * SSOClientConfig config = SSOClientConfig.builder()
+ *     .baseUrl("https://superfly.example.com/sso")
+ *     .subsystemName("paynet-ui")
+ *     .subsystemToken(token)
+ *     .defaultParameters(HttpRequestParameters.builder()
+ *         .timeouts(new HttpTimeouts(5_000, 30_000)).build())
+ *     // critical login path — short timeout
+ *     .endpointParameter(Endpoint.AUTHENTICATE,
+ *         HttpRequestParameters.builder()
+ *             .timeouts(new HttpTimeouts(5_000, 10_000)).build())
+ *     // long-poll — large socket timeout, server waits 75s, 15s buffer
+ *     .endpointParameter(Endpoint.GET_EVENTS,
+ *         HttpRequestParameters.builder()
+ *             .timeouts(new HttpTimeouts(5_000, 90_000)).build())
+ *     .build();
+ * }
+ * + *

Безопасность URL. По умолчанию принимаются только {@code https://} URL — соответствие + * PCI DSS 4.2.1. HTTP-схему можно разрешить через JVM property + * {@code -Dsuperfly.client.allowInsecureScheme=true} (выводится WARN); этот режим — только для + * локальной разработки/тестов, использовать в production запрещено. + */ +@Getter +@Slf4j +public final class SSOClientConfig { + + /** + * Имя JVM property для разрешения HTTP-схемы в {@code baseUrl}. По умолчанию недопустима. + */ + public static final String ALLOW_INSECURE_PROPERTY = "superfly.client.allowInsecureScheme"; + + private final String baseUrl; + private final String subsystemName; + @Nullable + private final String subsystemToken; + private final HttpRequestParameters defaultParameters; + private final Map endpointParameters; + + @Builder + private SSOClientConfig( + String baseUrl, + String subsystemName, + @Nullable String subsystemToken, + HttpRequestParameters defaultParameters, + @Singular("endpointParameter") Map endpointParameters + ) { + this.baseUrl = normalizeBaseUrl(baseUrl); + this.subsystemName = Objects.requireNonNull(subsystemName, "subsystemName must not be null"); + this.subsystemToken = subsystemToken; + this.defaultParameters = Objects.requireNonNull(defaultParameters, "defaultParameters must not be null"); + this.endpointParameters = endpointParameters == null ? Map.of() : Map.copyOf(endpointParameters); + log.debug("SSOClientConfig built: baseUrl={} subsystem={} overrides={}", + this.baseUrl, this.subsystemName, this.endpointParameters.keySet()); + } + + /** + * Возвращает {@link HttpRequestParameters} для заданного эндпоинта. + * Если в {@link #endpointParameters} есть override — используется он, иначе {@link #defaultParameters}. + */ + public HttpRequestParameters parametersFor(Endpoint endpoint) { + HttpRequestParameters override = endpointParameters.get(endpoint); + HttpRequestParameters params = override != null ? override : defaultParameters; + if (log.isDebugEnabled()) { + HttpTimeouts t = params.getTimeouts(); + if (t != null) { + log.debug("Resolved endpoint={} → {}(connect={}ms read={}ms)", + endpoint, override != null ? "override" : "default", + t.getConnectTimeoutMs(), t.getReadTimeoutMs()); + } else { + log.debug("Resolved endpoint={} → {} (no timeouts set)", + endpoint, override != null ? "override" : "default"); + } + } + return params; + } + + /** + * @return полный URL для эндпоинта: {@code baseUrl + endpoint.path()}. + */ + public String urlFor(Endpoint endpoint) { + return baseUrl + endpoint.path(); + } + + private static String normalizeBaseUrl(String url) { + if (url == null || url.isBlank()) { + throw new IllegalArgumentException("baseUrl must not be null or empty"); + } + if (url.startsWith("http://")) { + if (Boolean.getBoolean(ALLOW_INSECURE_PROPERTY)) { + log.warn("Insecure HTTP scheme in baseUrl='{}' — allowed via -D{}=true (PCI DSS 4.2.1 violation)", + url, ALLOW_INSECURE_PROPERTY); + } else { + throw new IllegalArgumentException( + "baseUrl must use HTTPS (PCI DSS 4.2.1). Got: " + url + + ". To allow HTTP set -D" + ALLOW_INSECURE_PROPERTY + "=true"); + } + } + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } +} diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/client/SSOHttpServiceApiClient.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/client/SSOHttpServiceApiClient.java index 4a1b8121..1601178e 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/client/SSOHttpServiceApiClient.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/client/SSOHttpServiceApiClient.java @@ -2,14 +2,59 @@ import com.google.gson.JsonSyntaxException; import com.google.gson.reflect.TypeToken; -import com.payneteasy.http.client.api.*; +import com.payneteasy.http.client.api.HttpHeader; +import com.payneteasy.http.client.api.HttpHeaders; +import com.payneteasy.http.client.api.HttpMethod; +import com.payneteasy.http.client.api.HttpRequest; +import com.payneteasy.http.client.api.HttpRequestParameters; +import com.payneteasy.http.client.api.HttpResponse; +import com.payneteasy.http.client.api.IHttpClient; import com.payneteasy.http.client.api.exceptions.HttpConnectException; import com.payneteasy.http.client.api.exceptions.HttpReadException; import com.payneteasy.http.client.api.exceptions.HttpWriteException; -import com.payneteasy.http.client.impl.HttpClientImpl; -import com.payneteasy.superfly.api.*; -import com.payneteasy.superfly.api.exceptions.*; -import com.payneteasy.superfly.api.request.*; +import com.payneteasy.superfly.api.SSOEvent; +import com.payneteasy.superfly.api.SSOService; +import com.payneteasy.superfly.api.SSOUser; +import com.payneteasy.superfly.api.SSOUserWithActions; +import com.payneteasy.superfly.api.UserDescription; +import com.payneteasy.superfly.api.UserRegisterRequest; +import com.payneteasy.superfly.api.UserStatus; +import com.payneteasy.superfly.api.exceptions.BadPublicKeyException; +import com.payneteasy.superfly.api.exceptions.MessageSendException; +import com.payneteasy.superfly.api.exceptions.PolicyValidationException; +import com.payneteasy.superfly.api.exceptions.SsoBadRequestException; +import com.payneteasy.superfly.api.exceptions.SsoClientException; +import com.payneteasy.superfly.api.exceptions.SsoConflictException; +import com.payneteasy.superfly.api.exceptions.SsoConnectionException; +import com.payneteasy.superfly.api.exceptions.SsoException; +import com.payneteasy.superfly.api.exceptions.SsoForbiddenException; +import com.payneteasy.superfly.api.exceptions.SsoNotFoundException; +import com.payneteasy.superfly.api.exceptions.SsoParseException; +import com.payneteasy.superfly.api.exceptions.SsoServerException; +import com.payneteasy.superfly.api.exceptions.SsoUnauthorizedException; +import com.payneteasy.superfly.api.exceptions.UserExistsException; +import com.payneteasy.superfly.api.UserNotFoundException; +import com.payneteasy.superfly.api.exceptions.SsoDecryptException; +import com.payneteasy.superfly.api.request.AuthenticateRequest; +import com.payneteasy.superfly.api.request.ChangeTempPasswordRequest; +import com.payneteasy.superfly.api.request.ChangeUserRoleRequest; +import com.payneteasy.superfly.api.request.CheckOtpRequest; +import com.payneteasy.superfly.api.request.CompleteUserRequest; +import com.payneteasy.superfly.api.request.ExchangeSubsystemTokenRequest; +import com.payneteasy.superfly.api.request.GetEventsRequest; +import com.payneteasy.superfly.api.request.GetGoogleAuthQrCodeRequest; +import com.payneteasy.superfly.api.request.GetUserDescriptionRequest; +import com.payneteasy.superfly.api.request.GetUserStatusesRequest; +import com.payneteasy.superfly.api.request.GetUsersWithActionsRequest; +import com.payneteasy.superfly.api.request.HasOtpMasterKeyRequest; +import com.payneteasy.superfly.api.request.PasswordResetRequest; +import com.payneteasy.superfly.api.request.PseudoAuthenticateRequest; +import com.payneteasy.superfly.api.request.ResetGoogleAuthMasterKeyRequest; +import com.payneteasy.superfly.api.request.SendSystemDataRequest; +import com.payneteasy.superfly.api.request.TouchSessionsRequest; +import com.payneteasy.superfly.api.request.UpdateUserDescriptionRequest; +import com.payneteasy.superfly.api.request.UpdateUserIsOtpOptionalValueRequest; +import com.payneteasy.superfly.api.request.UpdateUserOtpTypeRequest; import com.payneteasy.superfly.api.serialization.ApiSerializationManager; import com.payneteasy.superfly.api.serialization.ExceptionSerializationHelper; import com.payneteasy.superfly.api.serialization.ExceptionWrapper; @@ -24,8 +69,33 @@ import static com.payneteasy.superfly.api.serialization.ApiSerializer.CONTENT_TYPE_JSON; +/** + * HTTP-реализация {@link SSOService} над transport-абстракцией {@link IHttpClient}. + * + *

Thread-safety

+ * Класс immutable после конструирования — все поля {@code final}, конфигурация и заголовки + * прекомпилированы один раз. Безопасен для использования из нескольких потоков, при условии + * что нижележащий {@link IHttpClient} thread-safe. + * + *

Lifecycle

+ * Реализует {@link AutoCloseable}: {@link #close()} делегирует {@code close()} в нижележащий + * transport, если тот тоже {@link AutoCloseable}. Используйте try-with-resources в local scope + * или явно вызывайте {@code close()} при остановке приложения (через Spring {@code DisposableBean} + * или DI-контейнер). + * + *

Per-endpoint таймауты

+ * Каждый вызов резолвит {@link HttpRequestParameters} через {@link SSOClientConfig#parametersFor(Endpoint)}. + * Это позволяет назначать разные таймауты для критичного {@link Endpoint#AUTHENTICATE} (короткий) + * и для {@link Endpoint#GET_EVENTS} (long-poll, длинный). См. Javadoc {@link SSOClientConfig}. + * + *

Decode-path

+ * Любой не-200 статус сначала проверяется на {@link ExceptionWrapper} в теле ответа — если сервер + * прислал типизированное доменное исключение ({@link UserExistsException}, {@link PolicyValidationException} + * и др.), оно прокидывается как есть. Если wrapper не распознан — fallback к + * status-based исключению ({@link SsoBadRequestException}, {@link SsoUnauthorizedException}, …). + */ @Slf4j -public class SSOHttpServiceApiClient implements SSOService { +public final class SSOHttpServiceApiClient implements SSOService, AutoCloseable { private static final String HEADER_SUBSYSTEM_NAME = "X-Subsystem-Name"; private static final String HEADER_SUBSYSTEM_TOKEN = "X-Subsystem-Token"; @@ -33,285 +103,296 @@ public class SSOHttpServiceApiClient implements SSOService { private static final String HEADER_ACCEPT = "Accept"; private final IHttpClient httpClient; - private final HttpRequestParameters parameters; - private final String baseUrl; - private final String subsystemName; - private final String subsystemToken; + private final SSOClientConfig config; private final ApiSerializationManager serializationManager; + private final HttpHeaders staticHeaders; /** - * @param subsystemToken can be null if use x509 authentication - * @param baseUrl for example https://superfly.payneteasy.com/superfly/remoting/sso.service + * Primary конструктор с явной инъекцией зависимостей. + * + * @param httpClient transport-адаптер (например {@code HttpClientImpl} или Apache HC5). + * @param config конфигурация SSO-клиента (URL, subsystem, per-endpoint params). + * @param serializationManager сериализатор тела запросов/ответов. */ public SSOHttpServiceApiClient( - HttpRequestParameters httpRequestParameters, - String baseUrl, - String subsystemName, - @Nullable String subsystemToken, + IHttpClient httpClient, + SSOClientConfig config, ApiSerializationManager serializationManager ) { - this.httpClient = getHttpClient(); - - this.parameters = Objects.requireNonNull(httpRequestParameters, "httpRequestParameters must not be null"); - this.baseUrl = validateUrl(baseUrl); - this.subsystemName = Objects.requireNonNull(subsystemName, "subsystemName must not be null"); - this.subsystemToken = subsystemToken; - this.serializationManager = serializationManager; - } - - protected IHttpClient getHttpClient() { - return new HttpClientImpl(); - } - - private String validateUrl(String url) { - if (url == null || url.isBlank()) { - throw new IllegalArgumentException("baseUrl must not be null or empty"); - } - return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + this.httpClient = Objects.requireNonNull(httpClient, "httpClient must not be null"); + this.config = Objects.requireNonNull(config, "config must not be null"); + this.serializationManager = Objects.requireNonNull(serializationManager, "serializationManager must not be null"); + this.staticHeaders = buildStaticHeaders(config); + log.debug("SSOHttpServiceApiClient initialized: baseUrl={} subsystem={} transport={}", + config.getBaseUrl(), config.getSubsystemName(), httpClient.getClass().getSimpleName()); } - // Interface method implementations + // ==================================================================== + // SSOService interface methods — все делегируют в invoke(Endpoint, ...) + // ==================================================================== @Override - public SSOUser authenticate(AuthenticateRequest request) throws SsoAuthException { - return post("/authenticate", request, SSOUser.class); + public SSOUser authenticate(AuthenticateRequest request) { + return invoke(Endpoint.AUTHENTICATE, request, SSOUser.class); } @Override - public boolean checkOtp(CheckOtpRequest request) throws SsoAuthException { - return post("/checkOtp", request, Boolean.class); + public boolean checkOtp(CheckOtpRequest request) throws SsoDecryptException { + return invoke(Endpoint.CHECK_OTP, request, Boolean.class); } @Override - public boolean hasOtpMasterKey(HasOtpMasterKeyRequest request) throws SsoAuthException { - return post("/hasOtpMasterKey", request, Boolean.class); + public boolean hasOtpMasterKey(HasOtpMasterKeyRequest request) { + return invoke(Endpoint.HAS_OTP_MASTER_KEY, request, Boolean.class); } @Override - public SSOUser pseudoAuthenticate(PseudoAuthenticateRequest request) throws SsoAuthException { - return post("/hasOtpMasterKey", request, SSOUser.class); + public SSOUser pseudoAuthenticate(PseudoAuthenticateRequest request) { + return invoke(Endpoint.PSEUDO_AUTHENTICATE, request, SSOUser.class); } @Override - public void sendSystemData(SendSystemDataRequest request) throws SsoSystemException { - post("/sendSystemData", request, Void.class); + public void sendSystemData(SendSystemDataRequest request) { + invoke(Endpoint.SEND_SYSTEM_DATA, request, Void.class); } @Override - public List getUsersWithActions(GetUsersWithActionsRequest request) throws SsoDataException { - return post( - "/getUsersWithActions", - request, - new TypeToken<>() { - } - ); + public List getUsersWithActions(GetUsersWithActionsRequest request) { + return invoke(Endpoint.GET_USERS_WITH_ACTIONS, request, new TypeToken<>() {}); } @Override - public void updateUserOtpType(UpdateUserOtpTypeRequest request) throws SsoUserException { - post("/updateUserOtpType", request, Void.class); + public void updateUserOtpType(UpdateUserOtpTypeRequest request) { + invoke(Endpoint.UPDATE_USER_OTP_TYPE, request, Void.class); } @Override - public void registerUser(UserRegisterRequest request) throws UserExistsException, PolicyValidationException, - BadPublicKeyException, MessageSendException { - // Simply pass the request, exceptions will be handled automatically - // through the ExceptionWrapper mechanism - post("/registerUser", request, Void.class); + public void registerUser(UserRegisterRequest request) + throws UserExistsException, PolicyValidationException, BadPublicKeyException, MessageSendException { + invoke(Endpoint.REGISTER_USER, request, Void.class); } @Override public void changeTempPassword(ChangeTempPasswordRequest request) throws PolicyValidationException { - post("/changeTempPassword", request, Void.class); + invoke(Endpoint.CHANGE_TEMP_PASSWORD, request, Void.class); } @Override - public UserDescription getUserDescription(GetUserDescriptionRequest request) throws SsoUserException { - return post("/getUserDescription", request, UserDescription.class); + public UserDescription getUserDescription(GetUserDescriptionRequest request) { + return invoke(Endpoint.GET_USER_DESCRIPTION, request, UserDescription.class); } @Override public String resetGoogleAuthMasterKey(ResetGoogleAuthMasterKeyRequest request) throws UserNotFoundException, SsoDecryptException { - return post("/resetGoogleAuthMasterKey", request, String.class); + return invoke(Endpoint.RESET_GOOGLE_AUTH_MASTER_KEY, request, String.class); } @Override public String getUrlToGoogleAuthQrCode(GetGoogleAuthQrCodeRequest request) { - return post("/getUrlToGoogleAuthQrCode", request, String.class); + return invoke(Endpoint.GET_URL_TO_GOOGLE_AUTH_QR_CODE, request, String.class); } @Override - public void updateUserIsOtpOptionalValue(UpdateUserIsOtpOptionalValueRequest request) throws SsoUserException { - post("/updateUserIsOtpOptionalValue", request, Void.class); + public void updateUserIsOtpOptionalValue(UpdateUserIsOtpOptionalValueRequest request) { + invoke(Endpoint.UPDATE_USER_IS_OTP_OPTIONAL_VALUE, request, Void.class); } @Override public void updateUserDescription(UpdateUserDescriptionRequest request) throws UserNotFoundException, BadPublicKeyException { - post("/updateUserDescription", request, Void.class); + invoke(Endpoint.UPDATE_USER_DESCRIPTION, request, Void.class); } @Override - public void resetPassword(PasswordResetRequest reset) throws UserNotFoundException, PolicyValidationException { - post("/resetPassword", reset, Void.class); + public void resetPassword(PasswordResetRequest reset) + throws UserNotFoundException, PolicyValidationException { + invoke(Endpoint.RESET_PASSWORD, reset, Void.class); } @Override - public List getUserStatuses(GetUserStatusesRequest request) throws SsoDataException { - return post( - "/getUserStatuses", - request, - new TypeToken<>() { - } - ); + public List getUserStatuses(GetUserStatusesRequest request) { + return invoke(Endpoint.GET_USER_STATUSES, request, new TypeToken<>() {}); } @Override - public SSOUser exchangeSubsystemToken(ExchangeSubsystemTokenRequest request) throws SsoAuthException { - return post("/exchangeSubsystemToken", request, SSOUser.class); + public SSOUser exchangeSubsystemToken(ExchangeSubsystemTokenRequest request) { + return invoke(Endpoint.EXCHANGE_SUBSYSTEM_TOKEN, request, SSOUser.class); } @Override - public void touchSessions(TouchSessionsRequest request) throws SsoSystemException { - post("/touchSessions", request, Void.class); + public void touchSessions(TouchSessionsRequest request) { + invoke(Endpoint.TOUCH_SESSIONS, request, Void.class); } @Override - public void completeUser(CompleteUserRequest request) throws SsoUserException { - post("/completeUser", request, Void.class); + public void completeUser(CompleteUserRequest request) { + invoke(Endpoint.COMPLETE_USER, request, Void.class); } @Override - public void changeUserRole(ChangeUserRoleRequest request) throws SsoUserException { - post("/changeUserRole", request, Void.class); + public void changeUserRole(ChangeUserRoleRequest request) { + invoke(Endpoint.CHANGE_USER_ROLE, request, Void.class); } - private T post(String endpoint, Object request, Class responseClass) throws SsoClientException { - return post(endpoint, request, TypeToken.get(responseClass)); + @Override + public List getEvents(GetEventsRequest request) { + return invoke(Endpoint.GET_EVENTS, request, new TypeToken<>() {}); } - private T post(String endpoint, Object request, TypeToken typeToken) throws SsoClientException { - String url = baseUrl + endpoint; - String requestBody = serializationManager.serialize(request); + // ==================================================================== + // Core: invoke → send → decode + // ==================================================================== - if (log.isDebugEnabled()) { - log.debug("Sending request to {}: {}", url, requestBody); - } + private T invoke(Endpoint endpoint, Object body, Class responseClass) { + return invoke(endpoint, body, TypeToken.get(responseClass)); + } + + private T invoke(Endpoint endpoint, Object body, TypeToken typeToken) { + HttpRequestParameters parameters = config.parametersFor(endpoint); + HttpResponse response = send(endpoint, body, parameters); + return decode(endpoint, response, typeToken); + } + + private HttpResponse send(Endpoint endpoint, Object body, HttpRequestParameters parameters) { + String url = config.urlFor(endpoint); + String requestBody = serializationManager.serialize(body); + + log.debug("invoke endpoint={} url={} body.length={}", endpoint, url, requestBody.length()); + + HttpRequest request = HttpRequest.builder() + .url(url) + .method(HttpMethod.POST) + .headers(staticHeaders) + .body(requestBody.getBytes(StandardCharsets.UTF_8)) + .build(); - HttpResponse response; try { - response = httpClient.send( - buildRequest(url, requestBody), - parameters - ); - if (log.isDebugEnabled()) { - log.debug("Get response status: {}, body: {}", response.getStatusCode(), new String(response.getBody(), StandardCharsets.UTF_8)); - } + HttpResponse response = httpClient.send(request, parameters); + log.debug("Response endpoint={} status={} body.length={}", + endpoint, response.getStatusCode(), + response.getBody() == null ? 0 : response.getBody().length); + return response; } catch (HttpConnectException | HttpWriteException | HttpReadException e) { - throw new SsoConnectionException("Connection error: " + e.getMessage(), e); + log.debug("Connection error endpoint={}: {}", endpoint, e.getMessage()); + throw new SsoConnectionException("Connection error to " + endpoint + ": " + e.getMessage(), e); } - - validateResponse(response, url); - return parseResponse(response, typeToken.getType()); - } - - private HttpRequest buildRequest(String url, String body) { - return HttpRequest.builder() - .url(url) - .method(HttpMethod.POST) - .headers(createHeaders()) - .body(body.getBytes(StandardCharsets.UTF_8)) - .build(); } - private HttpHeaders createHeaders() { - List headers = new ArrayList<>(List.of( - new HttpHeader(HEADER_SUBSYSTEM_NAME, subsystemName), - new HttpHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON), - new HttpHeader(HEADER_ACCEPT, CONTENT_TYPE_JSON) - )); + /** + * Унифицированный decode-path: + *
    + *
  1. {@code status == 200} → десериализация тела как {@code T}.
  2. + *
  3. Любой другой статус (включая 4xx/5xx и 2xx-non-200) → попытка распарсить тело как + * {@link ExceptionWrapper}; если успешно — пробросить типизированное серверное исключение + * через {@link ExceptionSerializationHelper#createException}.
  4. + *
  5. Иначе fallback к status-based исключению ({@link SsoBadRequestException}, + * {@link SsoUnauthorizedException}, …, {@link SsoServerException}, generic {@link SsoClientException}).
  6. + *
+ * + *

Bug fix: Предыдущая реализация бросала generic status-based исключение до того, + * как могла попытаться распарсить {@link ExceptionWrapper} для 4xx/5xx — серверные доменные + * исключения ({@link UserExistsException}, {@link PolicyValidationException}) терялись и + * заменялись на generic {@link SsoBadRequestException}. Теперь wrapper парсится первым. + */ + private T decode(Endpoint endpoint, HttpResponse response, TypeToken typeToken) { + int status = response.getStatusCode(); + String body = response.getBody() == null ? "" : new String(response.getBody(), StandardCharsets.UTF_8); + String contentType = resolveContentType(response); + + if (status == 200) { + log.debug("decode endpoint={} status=200 → success path", endpoint); + return deserialize(body, typeToken.getType(), contentType); + } - if (subsystemToken != null) { - headers.add(new HttpHeader(HEADER_SUBSYSTEM_TOKEN, subsystemToken)); + ExceptionWrapper wrapper = tryParseExceptionWrapper(body, contentType); + if (wrapper != null && wrapper.getExceptionClass() != null) { + log.debug("decode endpoint={} status={} → ExceptionWrapper class={} message={}", + endpoint, status, wrapper.getExceptionClass(), wrapper.getMessage()); + Throwable recreated = ExceptionSerializationHelper.createException(wrapper); + if (recreated instanceof RuntimeException re) { + throw re; + } + throw new SsoParseException("Server exception is not a RuntimeException: " + recreated.getMessage(), recreated); } - return new HttpHeaders(headers); + log.debug("decode endpoint={} status={} → no wrapper, fallback to status-based exception", endpoint, status); + throw statusToException(endpoint, status, body); } - private void validateResponse(HttpResponse response, String url) throws SsoClientException { - int statusCode = response.getStatusCode(); - - if (statusCode >= 200 && statusCode < 300) { - return; + @Nullable + private ExceptionWrapper tryParseExceptionWrapper(String body, String contentType) { + if (body == null || body.isBlank()) { + return null; } - - String body = new String(response.getBody(), StandardCharsets.UTF_8); - String message = String.format("HTTP error %d from %s: %s", statusCode, url, body); - - switch (statusCode) { - case 400: - throw new SsoBadRequestException(message); - case 401: - throw new SsoUnauthorizedException(message); - case 403: - throw new SsoForbiddenException(message); - case 404: - throw new SsoNotFoundException(message); - case 409: - throw new SsoConflictException(message); - default: - if (statusCode >= 500) { - throw new SsoServerException(message); - } - throw new SsoClientException(statusCode, message); + try { + Object parsed = serializationManager.deserialize(body, ExceptionWrapper.class, contentType); + return parsed instanceof ExceptionWrapper ew ? ew : null; + } catch (JsonSyntaxException e) { + log.debug("Body is not valid ExceptionWrapper JSON: {}", e.getMessage()); + return null; + } catch (Exception e) { + log.debug("Failed to parse body as ExceptionWrapper: {}", e.getMessage()); + return null; } } - private T parseResponse(HttpResponse response, Type type) throws SsoParseException { - String responseText = new String(response.getBody(), StandardCharsets.UTF_8); - String contentType = response - .getHeaders() - .stream() - .filter(s -> s.getName().equalsIgnoreCase(HEADER_CONTENT_TYPE)) - .findFirst() - .orElseGet(() -> new HttpHeader(HEADER_CONTENT_TYPE, serializationManager.getDefaultContentType())) - .getValue() - ; - - - if (response.getStatusCode() != 200) { - ExceptionWrapper exceptionWrapper; - try { - exceptionWrapper = (ExceptionWrapper) serializationManager.deserialize( - responseText, ExceptionWrapper.class, contentType); - } catch (JsonSyntaxException e) { - throw new SsoParseException("Failed to parse response: " + e.getMessage(), e); - } - - if (exceptionWrapper != null && exceptionWrapper.getExceptionClass() != null) { - // Recreate the exception on the client side - Throwable exception = ExceptionSerializationHelper.createException(exceptionWrapper); - if (exception instanceof RuntimeException) { - throw (RuntimeException) exception; - } else { - throw new SsoParseException("Received exception: " + exception.getMessage(), exception); - } - } - } + private T deserialize(String body, Type type, String contentType) { try { - // Check if the response contains exception information @SuppressWarnings("unchecked") - T result = (T) serializationManager.deserialize(responseText, type, contentType); + T result = (T) serializationManager.deserialize(body, type, contentType); return result; } catch (SsoException e) { - // Propagate SsoException exceptions further throw e; } catch (Exception e) { throw new SsoParseException("Failed to parse response: " + e.getMessage(), e); } } - // handleSpecificExceptions removed, as the ExceptionWrapper mechanism is now used + private static RuntimeException statusToException(Endpoint endpoint, int status, String body) { + String message = String.format("HTTP error %d from %s: %s", status, endpoint, body); + return switch (status) { + case 400 -> new SsoBadRequestException(message); + case 401 -> new SsoUnauthorizedException(message); + case 403 -> new SsoForbiddenException(message); + case 404 -> new SsoNotFoundException(message); + case 409 -> new SsoConflictException(message); + default -> status >= 500 ? new SsoServerException(message) : new SsoClientException(status, message); + }; + } + private String resolveContentType(HttpResponse response) { + return response.getHeaders().stream() + .filter(h -> h.getName().equalsIgnoreCase(HEADER_CONTENT_TYPE)) + .findFirst() + .map(HttpHeader::getValue) + .orElseGet(serializationManager::getDefaultContentType); + } + + private static HttpHeaders buildStaticHeaders(SSOClientConfig config) { + List headers = new ArrayList<>(4); + headers.add(new HttpHeader(HEADER_SUBSYSTEM_NAME, config.getSubsystemName())); + headers.add(new HttpHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON)); + headers.add(new HttpHeader(HEADER_ACCEPT, CONTENT_TYPE_JSON)); + if (config.getSubsystemToken() != null) { + headers.add(new HttpHeader(HEADER_SUBSYSTEM_TOKEN, config.getSubsystemToken())); + } + return new HttpHeaders(headers); + } + + // ==================================================================== + // AutoCloseable + // ==================================================================== + + /** + * Закрывает нижележащий transport, если он реализует {@link AutoCloseable}. + * Без побочных эффектов, если transport не {@link AutoCloseable}. + */ + @Override + public void close() throws Exception { + log.debug("Closing SSO client, transport={}", httpClient.getClass().getSimpleName()); + if (httpClient instanceof AutoCloseable closeable) { + closeable.close(); + } + } } diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/AuthenticateRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/AuthenticateRequest.java index e9eb8149..90ca7137 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/AuthenticateRequest.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/AuthenticateRequest.java @@ -5,12 +5,13 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.io.Serializable; @Data @Builder @AllArgsConstructor @NoArgsConstructor -public class AuthenticateRequest { +public class AuthenticateRequest implements Serializable { private String username; private String password; private AuthenticationRequestInfo authRequestInfo; diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ChangeTempPasswordRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ChangeTempPasswordRequest.java index 5f68b207..ad766313 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ChangeTempPasswordRequest.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ChangeTempPasswordRequest.java @@ -4,12 +4,13 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.io.Serializable; @Data @Builder @AllArgsConstructor @NoArgsConstructor -public class ChangeTempPasswordRequest { +public class ChangeTempPasswordRequest implements Serializable { private String username; private String newPassword; } diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ChangeUserRoleRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ChangeUserRoleRequest.java index 06f20e35..fc514591 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ChangeUserRoleRequest.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ChangeUserRoleRequest.java @@ -4,12 +4,13 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.io.Serializable; @Data @Builder @AllArgsConstructor @NoArgsConstructor -public class ChangeUserRoleRequest { +public class ChangeUserRoleRequest implements Serializable { private String username; private String newRole; private String subsystemHint; diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/CheckOtpRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/CheckOtpRequest.java index 1bfb5b45..d823b763 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/CheckOtpRequest.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/CheckOtpRequest.java @@ -5,11 +5,12 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.io.Serializable; @Data @Builder @NoArgsConstructor -public class CheckOtpRequest { +public class CheckOtpRequest implements Serializable { private String userName; private String code; private OTPType otpType; diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/CompleteUserRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/CompleteUserRequest.java index eb08ce3d..e5080dac 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/CompleteUserRequest.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/CompleteUserRequest.java @@ -4,11 +4,12 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.io.Serializable; @Data @Builder @AllArgsConstructor @NoArgsConstructor -public class CompleteUserRequest { +public class CompleteUserRequest implements Serializable { private String username; } diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ExchangeSubsystemTokenRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ExchangeSubsystemTokenRequest.java index 4934277f..18845641 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ExchangeSubsystemTokenRequest.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ExchangeSubsystemTokenRequest.java @@ -4,11 +4,12 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.io.Serializable; @Data @Builder @AllArgsConstructor @NoArgsConstructor -public class ExchangeSubsystemTokenRequest { +public class ExchangeSubsystemTokenRequest implements Serializable { private String subsystemToken; } diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetEventsRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetEventsRequest.java new file mode 100644 index 00000000..a1c5bbb8 --- /dev/null +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetEventsRequest.java @@ -0,0 +1,19 @@ +package com.payneteasy.superfly.api.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Date; +import java.io.Serializable; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class GetEventsRequest implements Serializable { + private Date lastEventTime; + private long waitTimeMs; + private String subsystemName; +} diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetGoogleAuthQrCodeRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetGoogleAuthQrCodeRequest.java index 68ec16ee..4582a54c 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetGoogleAuthQrCodeRequest.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetGoogleAuthQrCodeRequest.java @@ -4,12 +4,13 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.io.Serializable; @Data @Builder @AllArgsConstructor @NoArgsConstructor -public class GetGoogleAuthQrCodeRequest { +public class GetGoogleAuthQrCodeRequest implements Serializable { private String secretKey; private String issuer; private String accountName; diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUserDescriptionRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUserDescriptionRequest.java index 46b4f3cd..d3cc7a0b 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUserDescriptionRequest.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUserDescriptionRequest.java @@ -4,11 +4,12 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.io.Serializable; @Data @Builder @AllArgsConstructor @NoArgsConstructor -public class GetUserDescriptionRequest { +public class GetUserDescriptionRequest implements Serializable { private String username; } diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUserStatusesRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUserStatusesRequest.java index e660ef51..f3388120 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUserStatusesRequest.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUserStatusesRequest.java @@ -6,11 +6,12 @@ import lombok.NoArgsConstructor; import java.util.List; +import java.io.Serializable; @Data @Builder @AllArgsConstructor @NoArgsConstructor -public class GetUserStatusesRequest { +public class GetUserStatusesRequest implements Serializable { private List userNames; } diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUsersWithActionsRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUsersWithActionsRequest.java index 678c8579..7abf5d9a 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUsersWithActionsRequest.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/GetUsersWithActionsRequest.java @@ -4,11 +4,12 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.io.Serializable; @Data @Builder @AllArgsConstructor @NoArgsConstructor -public class GetUsersWithActionsRequest { +public class GetUsersWithActionsRequest implements Serializable { private String subsystemIdentifier; } diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/HasOtpMasterKeyRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/HasOtpMasterKeyRequest.java index c211b3c9..f795c28a 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/HasOtpMasterKeyRequest.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/HasOtpMasterKeyRequest.java @@ -4,11 +4,12 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.io.Serializable; @Data @Builder @AllArgsConstructor @NoArgsConstructor -public class HasOtpMasterKeyRequest { +public class HasOtpMasterKeyRequest implements Serializable { private String username; } diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/PseudoAuthenticateRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/PseudoAuthenticateRequest.java index fb84640a..2084d96f 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/PseudoAuthenticateRequest.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/PseudoAuthenticateRequest.java @@ -4,12 +4,13 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.io.Serializable; @Data @Builder @AllArgsConstructor @NoArgsConstructor -public class PseudoAuthenticateRequest { +public class PseudoAuthenticateRequest implements Serializable { private String username; private String subsystemIdentifier; } diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/RegisterUserRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/RegisterUserRequest.java index bb5f1b2f..91391d5b 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/RegisterUserRequest.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/RegisterUserRequest.java @@ -5,12 +5,13 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.io.Serializable; @Data @Builder @AllArgsConstructor @NoArgsConstructor -public class RegisterUserRequest { +public class RegisterUserRequest implements Serializable { private String username; private String password; private String email; diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ResetGoogleAuthMasterKeyRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ResetGoogleAuthMasterKeyRequest.java index 77b7f81a..04d5bcc1 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ResetGoogleAuthMasterKeyRequest.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/ResetGoogleAuthMasterKeyRequest.java @@ -4,11 +4,12 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.io.Serializable; @Data @Builder @AllArgsConstructor @NoArgsConstructor -public class ResetGoogleAuthMasterKeyRequest { +public class ResetGoogleAuthMasterKeyRequest implements Serializable { private String username; } diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/SendSystemDataRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/SendSystemDataRequest.java index 69dac9f2..34eca00c 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/SendSystemDataRequest.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/SendSystemDataRequest.java @@ -7,12 +7,13 @@ import lombok.NoArgsConstructor; import java.util.List; +import java.io.Serializable; @Data @Builder @AllArgsConstructor @NoArgsConstructor -public class SendSystemDataRequest { +public class SendSystemDataRequest implements Serializable { private String subsystemIdentifier; private List actionDescriptions; } diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/TouchSessionsRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/TouchSessionsRequest.java index 5a81d6d1..c2572909 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/TouchSessionsRequest.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/TouchSessionsRequest.java @@ -6,11 +6,12 @@ import lombok.NoArgsConstructor; import java.util.List; +import java.io.Serializable; @Data @Builder @AllArgsConstructor @NoArgsConstructor -public class TouchSessionsRequest { +public class TouchSessionsRequest implements Serializable { private List sessionIds; } diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserDescriptionRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserDescriptionRequest.java index 6069aa93..58fa757c 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserDescriptionRequest.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserDescriptionRequest.java @@ -5,11 +5,12 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.io.Serializable; @Data @Builder @AllArgsConstructor @NoArgsConstructor -public class UpdateUserDescriptionRequest { +public class UpdateUserDescriptionRequest implements Serializable { private UserDescription userDescription; } diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserIsOtpOptionalValueRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserIsOtpOptionalValueRequest.java index b6a2c85a..429e4bad 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserIsOtpOptionalValueRequest.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserIsOtpOptionalValueRequest.java @@ -4,12 +4,13 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.io.Serializable; @Data @Builder @AllArgsConstructor @NoArgsConstructor -public class UpdateUserIsOtpOptionalValueRequest { +public class UpdateUserIsOtpOptionalValueRequest implements Serializable { private String username; private boolean isOtpOptional; } diff --git a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserOtpTypeRequest.java b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserOtpTypeRequest.java index 8022acf4..a3bed63b 100644 --- a/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserOtpTypeRequest.java +++ b/superfly-remote-api/src/main/java/com/payneteasy/superfly/api/request/UpdateUserOtpTypeRequest.java @@ -4,12 +4,13 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.io.Serializable; @Data @Builder @AllArgsConstructor @NoArgsConstructor -public class UpdateUserOtpTypeRequest { +public class UpdateUserOtpTypeRequest implements Serializable { private String username; private String otpType; } diff --git a/superfly-remote-api/src/test/java/com/payneteasy/superfly/api/SuperflyDepsAssertionTest.java b/superfly-remote-api/src/test/java/com/payneteasy/superfly/api/SuperflyDepsAssertionTest.java new file mode 100644 index 00000000..1728cf8b --- /dev/null +++ b/superfly-remote-api/src/test/java/com/payneteasy/superfly/api/SuperflyDepsAssertionTest.java @@ -0,0 +1,27 @@ +package com.payneteasy.superfly.api; + +import org.junit.Test; + +import static org.junit.Assert.fail; + +/** + * Защита от случайного возврата observability/cross-cutting зависимостей + * в classpath superfly-remote-api. + * + *

Owner observability — consumer (paynet и пр.). SSO-2 был REVERTED + * именно по этой причине — см. .ai-factory/ROADMAP.md. + */ +public class SuperflyDepsAssertionTest { + + @Test + public void micrometerMustNotBeOnClasspath() { + try { + Class.forName("io.micrometer.core.instrument.MeterRegistry"); + fail("io.micrometer.core.instrument.MeterRegistry must NOT be on " + + "classpath of superfly-remote-api — observability is consumer's " + + "responsibility (see ROADMAP.md SSO-2 REVERTED)"); + } catch (ClassNotFoundException expected) { + // OK — desired behaviour + } + } +} diff --git a/superfly-remote-api/src/test/java/com/payneteasy/superfly/api/client/SSOClientConfigTest.java b/superfly-remote-api/src/test/java/com/payneteasy/superfly/api/client/SSOClientConfigTest.java new file mode 100644 index 00000000..9b366ba0 --- /dev/null +++ b/superfly-remote-api/src/test/java/com/payneteasy/superfly/api/client/SSOClientConfigTest.java @@ -0,0 +1,134 @@ +package com.payneteasy.superfly.api.client; + +import com.payneteasy.http.client.api.HttpRequestParameters; +import com.payneteasy.http.client.api.HttpTimeouts; +import org.junit.After; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +public class SSOClientConfigTest { + + private static final String BASE_URL = "https://sso.example.com/api"; + private static final String SUBSYSTEM_NAME = "test-subsystem"; + + @After + public void tearDown() { + System.clearProperty(SSOClientConfig.ALLOW_INSECURE_PROPERTY); + } + + // ── parametersFor: default vs override ────────────────────────────────── + + @Test + public void parametersFor_returnsDefault_whenNoOverride() { + HttpRequestParameters def = HttpRequestParameters.builder() + .timeouts(new HttpTimeouts(5_000, 30_000)).build(); + SSOClientConfig config = baseBuilder().defaultParameters(def).build(); + + assertSame(def, config.parametersFor(Endpoint.AUTHENTICATE)); + assertSame(def, config.parametersFor(Endpoint.GET_EVENTS)); + } + + @Test + public void parametersFor_returnsOverride_whenSet() { + HttpRequestParameters def = HttpRequestParameters.builder() + .timeouts(new HttpTimeouts(5_000, 30_000)).build(); + HttpRequestParameters longPoll = HttpRequestParameters.builder() + .timeouts(new HttpTimeouts(5_000, 90_000)).build(); + + SSOClientConfig config = baseBuilder() + .defaultParameters(def) + .endpointParameter(Endpoint.GET_EVENTS, longPoll) + .build(); + + assertSame("override for GET_EVENTS", longPoll, config.parametersFor(Endpoint.GET_EVENTS)); + assertSame("default for AUTHENTICATE", def, config.parametersFor(Endpoint.AUTHENTICATE)); + } + + // ── urlFor ─────────────────────────────────────────────────────────────── + + @Test + public void urlFor_concatenatesBaseAndPath() { + SSOClientConfig config = baseBuilder().build(); + assertEquals(BASE_URL + "/authenticate", config.urlFor(Endpoint.AUTHENTICATE)); + assertEquals(BASE_URL + "/getEvents", config.urlFor(Endpoint.GET_EVENTS)); + } + + @Test + public void urlFor_handlesTrailingSlash() { + SSOClientConfig config = baseBuilder().baseUrl("https://sso.example.com/api/").build(); + assertEquals("https://sso.example.com/api", config.getBaseUrl()); + assertEquals("https://sso.example.com/api/authenticate", config.urlFor(Endpoint.AUTHENTICATE)); + } + + // ── HTTPS validation ──────────────────────────────────────────────────── + + @Test + public void httpUrl_rejectedByDefault() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> + baseBuilder().baseUrl("http://insecure.example.com").build()); + assertNotNull(e.getMessage()); + } + + @Test + public void httpUrl_acceptedWhenInsecurePropertySet() { + System.setProperty(SSOClientConfig.ALLOW_INSECURE_PROPERTY, "true"); + SSOClientConfig config = baseBuilder().baseUrl("http://insecure.example.com").build(); + assertEquals("http://insecure.example.com", config.getBaseUrl()); + } + + // ── null/empty validation ──────────────────────────────────────────────── + + @Test + public void nullBaseUrl_throwsIAE() { + assertThrows(IllegalArgumentException.class, () -> + baseBuilder().baseUrl(null).build()); + } + + @Test + public void emptyBaseUrl_throwsIAE() { + assertThrows(IllegalArgumentException.class, () -> + baseBuilder().baseUrl("").build()); + } + + @Test + public void blankBaseUrl_throwsIAE() { + assertThrows(IllegalArgumentException.class, () -> + baseBuilder().baseUrl(" ").build()); + } + + @Test + public void nullSubsystemName_throwsNPE() { + assertThrows(NullPointerException.class, () -> + baseBuilder().subsystemName(null).build()); + } + + @Test + public void nullDefaultParameters_throwsNPE() { + assertThrows(NullPointerException.class, () -> + SSOClientConfig.builder() + .baseUrl(BASE_URL) + .subsystemName(SUBSYSTEM_NAME) + .build()); + } + + @Test + public void nullSubsystemToken_isAllowed() { + SSOClientConfig config = baseBuilder().subsystemToken(null).build(); + assertNull(config.getSubsystemToken()); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private static SSOClientConfig.SSOClientConfigBuilder baseBuilder() { + return SSOClientConfig.builder() + .baseUrl(BASE_URL) + .subsystemName(SUBSYSTEM_NAME) + .subsystemToken("test-token") + .defaultParameters(HttpRequestParameters.builder().build()); + } +} diff --git a/superfly-remote-api/src/test/java/com/payneteasy/superfly/api/client/SSOHttpServiceApiClientSecurityTest.java b/superfly-remote-api/src/test/java/com/payneteasy/superfly/api/client/SSOHttpServiceApiClientSecurityTest.java new file mode 100644 index 00000000..a62c1270 --- /dev/null +++ b/superfly-remote-api/src/test/java/com/payneteasy/superfly/api/client/SSOHttpServiceApiClientSecurityTest.java @@ -0,0 +1,160 @@ +package com.payneteasy.superfly.api.client; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.payneteasy.http.client.api.HttpRequestParameters; +import com.payneteasy.http.client.api.IHttpClient; +import com.payneteasy.http.client.api.exceptions.HttpConnectException; +import com.payneteasy.superfly.api.request.AuthenticateRequest; +import com.payneteasy.superfly.api.serialization.ApiSerializationManager; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.LoggerFactory; + +import static org.easymock.EasyMock.*; +import static org.junit.Assert.*; + +public class SSOHttpServiceApiClientSecurityTest { + + private static final String SUBSYSTEM_NAME = "test-subsystem"; + private static final String SUBSYSTEM_TOKEN = "test-token"; + private static final String HTTPS_URL = "https://test.example.com/superfly"; + + private IHttpClient mockHttpClient; + private ApiSerializationManager serializer; + private ListAppender logCapture; + private Logger clientLogger; + private Logger configLogger; + private Level originalClientLevel; + private Level originalConfigLevel; + + @Before + public void setUp() { + mockHttpClient = createMock(IHttpClient.class); + serializer = new ApiSerializationManager(); + + clientLogger = (Logger) LoggerFactory.getLogger(SSOHttpServiceApiClient.class); + configLogger = (Logger) LoggerFactory.getLogger(SSOClientConfig.class); + originalClientLevel = clientLogger.getLevel(); + originalConfigLevel = configLogger.getLevel(); + clientLogger.setLevel(Level.DEBUG); + configLogger.setLevel(Level.DEBUG); + + logCapture = new ListAppender<>(); + logCapture.start(); + clientLogger.addAppender(logCapture); + configLogger.addAppender(logCapture); + } + + @After + public void tearDown() { + clientLogger.detachAppender(logCapture); + configLogger.detachAppender(logCapture); + clientLogger.setLevel(originalClientLevel); + configLogger.setLevel(originalConfigLevel); + System.clearProperty("superfly.client.allowInsecureScheme"); + } + + // ── HTTPS enforcement ──────────────────────────────────────────────────── + + @Test(expected = IllegalArgumentException.class) + public void testHttpUrlRejected() { + newClient("http://insecure.example.com/sso"); + } + + @Test + public void testHttpsUrlAccepted() { + SSOHttpServiceApiClient client = newClient(HTTPS_URL); + assertNotNull(client); + } + + @Test + public void testHttpUrlAllowedViaSystemProperty() { + System.setProperty("superfly.client.allowInsecureScheme", "true"); + SSOHttpServiceApiClient client = newClient("http://allowed.example.com/sso"); + assertNotNull(client); + } + + @Test + public void testInsecureBypassLogsWarn() { + System.setProperty("superfly.client.allowInsecureScheme", "true"); + newClient("http://allowed.example.com/sso"); + + boolean warnFound = logCapture.list.stream() + .anyMatch(e -> e.getLevel() == Level.WARN && e.getFormattedMessage().contains("Insecure")); + assertTrue("WARN must be logged when http:// bypass is used", warnFound); + } + + // ── Credential leak prevention ─────────────────────────────────────────── + + @Test + public void testDebugLogsDoNotContainPassword() throws Exception { + expect(mockHttpClient.send(anyObject(), anyObject())) + .andThrow(new HttpConnectException("refused", new Exception())); + replay(mockHttpClient); + + SSOHttpServiceApiClient client = newTestableClient(); + + try { + client.authenticate(new AuthenticateRequest("alice", "secret-p@ssword-123")); + } catch (Exception e) { + // expected — no server + } + + for (ILoggingEvent event : logCapture.list) { + String msg = event.getFormattedMessage(); + assertFalse("DEBUG log must not contain password. Got: " + msg, + msg.contains("secret-p@ssword-123")); + } + + verify(mockHttpClient); + } + + @Test + public void testDebugLogsDoNotContainSubsystemToken() throws Exception { + expect(mockHttpClient.send(anyObject(), anyObject())) + .andThrow(new HttpConnectException("refused", new Exception())); + replay(mockHttpClient); + + SSOHttpServiceApiClient client = newTestableClient(); + + try { + client.authenticate(new AuthenticateRequest("alice", "pass")); + } catch (Exception e) { + // expected + } + + for (ILoggingEvent event : logCapture.list) { + String msg = event.getFormattedMessage(); + assertFalse("DEBUG log must not contain subsystem token. Got: " + msg, + msg.contains(SUBSYSTEM_TOKEN)); + } + + verify(mockHttpClient); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private SSOHttpServiceApiClient newClient(String baseUrl) { + SSOClientConfig config = SSOClientConfig.builder() + .baseUrl(baseUrl) + .subsystemName(SUBSYSTEM_NAME) + .subsystemToken(SUBSYSTEM_TOKEN) + .defaultParameters(HttpRequestParameters.builder().build()) + .build(); + return new SSOHttpServiceApiClient(mockHttpClient, config, serializer); + } + + private SSOHttpServiceApiClient newTestableClient() { + SSOClientConfig config = SSOClientConfig.builder() + .baseUrl(HTTPS_URL) + .subsystemName(SUBSYSTEM_NAME) + .subsystemToken(SUBSYSTEM_TOKEN) + .defaultParameters(HttpRequestParameters.builder().build()) + .build(); + return new SSOHttpServiceApiClient(mockHttpClient, config, serializer); + } +} diff --git a/superfly-remote-api/src/test/java/com/payneteasy/superfly/api/client/SSOHttpServiceApiClientTest.java b/superfly-remote-api/src/test/java/com/payneteasy/superfly/api/client/SSOHttpServiceApiClientTest.java index e60a1b23..473357fb 100644 --- a/superfly-remote-api/src/test/java/com/payneteasy/superfly/api/client/SSOHttpServiceApiClientTest.java +++ b/superfly-remote-api/src/test/java/com/payneteasy/superfly/api/client/SSOHttpServiceApiClientTest.java @@ -42,89 +42,91 @@ public void setUp() { httpClient = EasyMock.createMock(IHttpClient.class); serializationManager = new ApiSerializationManager(); - HttpRequestParameters parameters = HttpRequestParameters.builder().build(); - - // Mock http client - client = new TestableHttpServiceApiClient( - parameters, - BASE_URL, - SUBSYSTEM_NAME, - SUBSYSTEM_TOKEN, - serializationManager - ); + SSOClientConfig config = SSOClientConfig.builder() + .baseUrl(BASE_URL) + .subsystemName(SUBSYSTEM_NAME) + .subsystemToken(SUBSYSTEM_TOKEN) + .defaultParameters(HttpRequestParameters.builder().build()) + .build(); + + // Inject mock transport directly via new DI constructor + client = new SSOHttpServiceApiClient(httpClient, config, serializationManager); } @Test public void testConstructorValidation() { + SSOClientConfig validConfig = SSOClientConfig.builder() + .baseUrl(BASE_URL) + .subsystemName(SUBSYSTEM_NAME) + .subsystemToken(SUBSYSTEM_TOKEN) + .defaultParameters(HttpRequestParameters.builder().build()) + .build(); + // Valid arguments - new SSOHttpServiceApiClient( - HttpRequestParameters.builder().build(), - BASE_URL, - SUBSYSTEM_NAME, - SUBSYSTEM_TOKEN, - serializationManager + new SSOHttpServiceApiClient(httpClient, validConfig, serializationManager); + + // null IHttpClient → NPE + assertThrows(NullPointerException.class, () -> + new SSOHttpServiceApiClient(null, validConfig, serializationManager) ); - // Check for null and empty values + // null SSOClientConfig → NPE assertThrows(NullPointerException.class, () -> - new SSOHttpServiceApiClient( - null, - BASE_URL, - SUBSYSTEM_NAME, - SUBSYSTEM_TOKEN, - serializationManager - ) + new SSOHttpServiceApiClient(httpClient, null, serializationManager) ); + // null ApiSerializationManager → NPE assertThrows(NullPointerException.class, () -> - new SSOHttpServiceApiClient( - HttpRequestParameters.builder().build(), - BASE_URL, - null, - SUBSYSTEM_TOKEN, - serializationManager - ) + new SSOHttpServiceApiClient(httpClient, validConfig, null) ); + // SSOClientConfig validation: null baseUrl assertThrows(IllegalArgumentException.class, () -> - new SSOHttpServiceApiClient( - HttpRequestParameters.builder().build(), - null, - SUBSYSTEM_NAME, - SUBSYSTEM_TOKEN, - serializationManager - ) + SSOClientConfig.builder() + .baseUrl(null) + .subsystemName(SUBSYSTEM_NAME) + .defaultParameters(HttpRequestParameters.builder().build()) + .build() ); + // SSOClientConfig validation: empty baseUrl assertThrows(IllegalArgumentException.class, () -> - new SSOHttpServiceApiClient( - HttpRequestParameters.builder().build(), - "", - SUBSYSTEM_NAME, - SUBSYSTEM_TOKEN, - serializationManager - ) + SSOClientConfig.builder() + .baseUrl("") + .subsystemName(SUBSYSTEM_NAME) + .defaultParameters(HttpRequestParameters.builder().build()) + .build() ); - // Check that URL without trailing / is handled correctly - SSOHttpServiceApiClient client1 = new SSOHttpServiceApiClient( - HttpRequestParameters.builder().build(), - "http://example.com/api", - SUBSYSTEM_NAME, - SUBSYSTEM_TOKEN, - serializationManager + // SSOClientConfig validation: null subsystemName + assertThrows(NullPointerException.class, () -> + SSOClientConfig.builder() + .baseUrl(BASE_URL) + .subsystemName(null) + .defaultParameters(HttpRequestParameters.builder().build()) + .build() ); - // Check that URL with trailing / is handled correctly - SSOHttpServiceApiClient client2 = new SSOHttpServiceApiClient( - HttpRequestParameters.builder().build(), - "http://example.com/api/", - SUBSYSTEM_NAME, - SUBSYSTEM_TOKEN, - serializationManager - ); + // URL without trailing / handled correctly + SSOClientConfig cfg1 = SSOClientConfig.builder() + .baseUrl("https://example.com/api") + .subsystemName(SUBSYSTEM_NAME) + .subsystemToken(SUBSYSTEM_TOKEN) + .defaultParameters(HttpRequestParameters.builder().build()) + .build(); + assertEquals("https://example.com/api", cfg1.getBaseUrl()); + + // URL with trailing / is trimmed + SSOClientConfig cfg2 = SSOClientConfig.builder() + .baseUrl("https://example.com/api/") + .subsystemName(SUBSYSTEM_NAME) + .subsystemToken(SUBSYSTEM_TOKEN) + .defaultParameters(HttpRequestParameters.builder().build()) + .build(); + assertEquals("https://example.com/api", cfg2.getBaseUrl()); } + @Test public void testAuthenticate_Success() throws SsoAuthException, HttpWriteException, HttpConnectException, HttpReadException { // Prepare test data @@ -538,6 +540,182 @@ public void testParseInvalidExceptionWrapper() throws HttpWriteException, HttpCo } } + // ── new tests: per-endpoint timeouts, AutoCloseable, ExceptionWrapper for 4xx/5xx ── + + @Test + public void testPerEndpointTimeoutOverride() throws Exception { + HttpRequestParameters defaultParams = HttpRequestParameters.builder() + .timeouts(new com.payneteasy.http.client.api.HttpTimeouts(5_000, 30_000)).build(); + HttpRequestParameters eventsParams = HttpRequestParameters.builder() + .timeouts(new com.payneteasy.http.client.api.HttpTimeouts(5_000, 90_000)).build(); + HttpRequestParameters authParams = HttpRequestParameters.builder() + .timeouts(new com.payneteasy.http.client.api.HttpTimeouts(5_000, 10_000)).build(); + + SSOClientConfig config = SSOClientConfig.builder() + .baseUrl(BASE_URL) + .subsystemName(SUBSYSTEM_NAME) + .subsystemToken(SUBSYSTEM_TOKEN) + .defaultParameters(defaultParams) + .endpointParameter(Endpoint.GET_EVENTS, eventsParams) + .endpointParameter(Endpoint.AUTHENTICATE, authParams) + .build(); + + SSOHttpServiceApiClient perEndpointClient = + new SSOHttpServiceApiClient(httpClient, config, serializationManager); + + Capture paramsCapture = newCapture(); + reset(httpClient); + expect(httpClient.send(anyObject(HttpRequest.class), capture(paramsCapture))) + .andReturn(createSuccessResponse("[]")); + replay(httpClient); + + perEndpointClient.getEvents(com.payneteasy.superfly.api.request.GetEventsRequest.builder().build()); + + assertSame("GET_EVENTS must use eventsParams", eventsParams, paramsCapture.getValue()); + verify(httpClient); + + // Now AUTHENTICATE should use authParams + Capture authCapture = newCapture(); + reset(httpClient); + expect(httpClient.send(anyObject(HttpRequest.class), capture(authCapture))) + .andReturn(createSuccessResponse(serializationManager.serialize(createTestSSOUser()))); + replay(httpClient); + + perEndpointClient.authenticate(new AuthenticateRequest("user", "pass")); + + assertSame("AUTHENTICATE must use authParams", authParams, authCapture.getValue()); + verify(httpClient); + + // sendSystemData has no override → defaultParams + Capture defaultCapture = newCapture(); + reset(httpClient); + expect(httpClient.send(anyObject(HttpRequest.class), capture(defaultCapture))) + .andReturn(createSuccessResponse("null")); + replay(httpClient); + + perEndpointClient.sendSystemData( + com.payneteasy.superfly.api.request.SendSystemDataRequest.builder().build()); + + assertSame("SEND_SYSTEM_DATA must fall back to defaultParams", defaultParams, defaultCapture.getValue()); + verify(httpClient); + } + + @Test + public void testAutoCloseable_DelegatesToTransport() throws Exception { + CloseableHttpClient closeableTransport = createMock(CloseableHttpClient.class); + closeableTransport.close(); + expectLastCall().once(); + replay(closeableTransport); + + SSOClientConfig config = SSOClientConfig.builder() + .baseUrl(BASE_URL).subsystemName(SUBSYSTEM_NAME) + .subsystemToken(SUBSYSTEM_TOKEN) + .defaultParameters(HttpRequestParameters.builder().build()) + .build(); + + try (SSOHttpServiceApiClient c = + new SSOHttpServiceApiClient(closeableTransport, config, serializationManager)) { + // use try-with-resources + assertNotNull(c); + } + + verify(closeableTransport); + } + + @Test + public void testAutoCloseable_NonCloseableTransport() throws Exception { + // httpClient mock is NOT AutoCloseable — close() should be a no-op + SSOClientConfig config = SSOClientConfig.builder() + .baseUrl(BASE_URL).subsystemName(SUBSYSTEM_NAME) + .subsystemToken(SUBSYSTEM_TOKEN) + .defaultParameters(HttpRequestParameters.builder().build()) + .build(); + + try (SSOHttpServiceApiClient c = + new SSOHttpServiceApiClient(httpClient, config, serializationManager)) { + assertNotNull(c); + } + // No verify needed — just ensure close() doesn't throw on non-AutoCloseable transport + } + + @Test + public void testExceptionWrapperFor400() throws Exception { + // Server sends ExceptionWrapper with status 400 (real HTTP error semantics). + // Bug fix: previously the client threw generic SsoBadRequestException; now it + // recovers the typed UserExistsException. + ExceptionWrapper wrapper = new ExceptionWrapper( + "com.payneteasy.superfly.api.exceptions.UserExistsException", + "User foo already exists", + "UserExistsException: User foo already exists" + ); + HttpResponse response = new HttpResponse( + 400, "Bad Request", + List.of(new HttpHeader("Content-Type", ApiSerializer.CONTENT_TYPE_JSON)), + serializationManager.serialize(wrapper).getBytes(StandardCharsets.UTF_8) + ); + + reset(httpClient); + expect(httpClient.send(anyObject(HttpRequest.class), anyObject(HttpRequestParameters.class))) + .andReturn(response); + replay(httpClient); + + try { + client.registerUser(new UserRegisterRequest()); + fail("Expected UserExistsException"); + } catch (UserExistsException e) { + assertEquals("User foo already exists", e.getMessage()); + } + } + + @Test + public void testExceptionWrapperFor500() throws Exception { + ExceptionWrapper wrapper = new ExceptionWrapper( + "com.payneteasy.superfly.api.exceptions.SsoSystemException", + "DB unavailable", + "SsoSystemException: DB unavailable" + ); + HttpResponse response = new HttpResponse( + 500, "Internal Server Error", + List.of(new HttpHeader("Content-Type", ApiSerializer.CONTENT_TYPE_JSON)), + serializationManager.serialize(wrapper).getBytes(StandardCharsets.UTF_8) + ); + + reset(httpClient); + expect(httpClient.send(anyObject(HttpRequest.class), anyObject(HttpRequestParameters.class))) + .andReturn(response); + replay(httpClient); + + try { + client.sendSystemData( + com.payneteasy.superfly.api.request.SendSystemDataRequest.builder().build()); + fail("Expected SsoSystemException (typed from wrapper, not generic SsoServerException)"); + } catch (com.payneteasy.superfly.api.exceptions.SsoSystemException e) { + assertEquals("DB unavailable", e.getMessage()); + } + } + + @Test + public void testFallbackOn400WithoutWrapper() throws Exception { + // Plain-text body that is NOT a valid ExceptionWrapper JSON — fallback to status-based exception + HttpResponse response = createErrorResponse(400, "Bad Request from gateway"); + + reset(httpClient); + expect(httpClient.send(anyObject(HttpRequest.class), anyObject(HttpRequestParameters.class))) + .andReturn(response); + replay(httpClient); + + try { + client.authenticate(new AuthenticateRequest("user", "pass")); + fail("Expected SsoBadRequestException (fallback)"); + } catch (SsoBadRequestException e) { + assertTrue(e.getMessage().contains("400")); + } + } + + /** Mock-friendly interface combining IHttpClient + AutoCloseable for AutoCloseable delegation tests. */ + private interface CloseableHttpClient extends IHttpClient, AutoCloseable { + } + // Helper methods private SSOUser createTestSSOUser() { @@ -595,23 +773,6 @@ private void assertThrows(Class expectedType, ThrowingRunna } } - private class TestableHttpServiceApiClient extends SSOHttpServiceApiClient { - public TestableHttpServiceApiClient( - HttpRequestParameters httpRequestParameters, - String baseUrl, - String subsystemName, - String subsystemToken, - ApiSerializationManager serializationManager - ) { - super(httpRequestParameters, baseUrl, subsystemName, subsystemToken, serializationManager); - } - - @Override - protected IHttpClient getHttpClient() { - return httpClient; - } - } - private interface ThrowingRunnable { void run() throws Exception; } diff --git a/superfly-service/pom.xml b/superfly-service/pom.xml index 3b1e9f41..35a10165 100644 --- a/superfly-service/pom.xml +++ b/superfly-service/pom.xml @@ -88,14 +88,9 @@ aspectjweaver - - commons-httpclient - commons-httpclient - - - - org.quartz-scheduler - quartz + + com.payneteasy.http-client + http-client-api @@ -126,5 +121,10 @@ org.projectlombok lombok + + + com.github.ben-manes.caffeine + caffeine + diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/dao/EventDao.java b/superfly-service/src/main/java/com/payneteasy/superfly/dao/EventDao.java new file mode 100644 index 00000000..e28ae7b6 --- /dev/null +++ b/superfly-service/src/main/java/com/payneteasy/superfly/dao/EventDao.java @@ -0,0 +1,17 @@ +package com.payneteasy.superfly.dao; + +import com.googlecode.jdbcproc.daofactory.annotation.AStoredProcedure; +import com.payneteasy.superfly.model.Event; + +import java.util.Date; +import java.util.List; + +/** + * DAO to work with events. + */ +public interface EventDao { + + @AStoredProcedure(name = "ui_get_events") + List getEvents(Date lastEventTime, int limit, String subsystemName); + +} diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/model/Event.java b/superfly-service/src/main/java/com/payneteasy/superfly/model/Event.java new file mode 100644 index 00000000..39d46938 --- /dev/null +++ b/superfly-service/src/main/java/com/payneteasy/superfly/model/Event.java @@ -0,0 +1,57 @@ +package com.payneteasy.superfly.model; + +import javax.persistence.Column; +import java.util.Date; + +public class Event { + private Long eventId; + private Date eventTime; + private String eventTypeCode; + private String eventData; + + @Column(name = "event_id") + public Long getEventId() { + return eventId; + } + + public void setEventId(Long eventId) { + this.eventId = eventId; + } + + @Column(name = "event_time") + public Date getEventTime() { + return eventTime; + } + + public void setEventTime(Date eventTime) { + this.eventTime = eventTime; + } + + @Column(name = "event_type_code") + public String getEventTypeCode() { + return eventTypeCode; + } + + public void setEventTypeCode(String eventTypeCode) { + this.eventTypeCode = eventTypeCode; + } + + @Column(name = "event_data") + public String getEventData() { + return eventData; + } + + public void setEventData(String eventData) { + this.eventData = eventData; + } + + @Override + public String toString() { + return "Event{" + + "eventId=" + eventId + + ", eventTime=" + eventTime + + ", eventTypeCode='" + eventTypeCode + '\'' + + ", eventData='" + eventData + '\'' + + '}'; + } +} diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/model/ui/subsystem/UISubsystem.java b/superfly-service/src/main/java/com/payneteasy/superfly/model/ui/subsystem/UISubsystem.java index 90abe3ee..e5c0aec8 100644 --- a/superfly-service/src/main/java/com/payneteasy/superfly/model/ui/subsystem/UISubsystem.java +++ b/superfly-service/src/main/java/com/payneteasy/superfly/model/ui/subsystem/UISubsystem.java @@ -19,6 +19,9 @@ public class UISubsystem implements Serializable { private String subsystemUrl; private String landingUrl; private String loginFormCssUrl; + private String privateKey; + private String publicKey; + private String encryptionAlgorithm; @Column(name = "ssys_id") public Long getId() { @@ -119,4 +122,31 @@ public String getLoginFormCssUrl() { public void setLoginFormCssUrl(String loginFormCssUrl) { this.loginFormCssUrl = loginFormCssUrl; } + + @Column(name = "private_key") + public String getPrivateKey() { + return privateKey; + } + + public void setPrivateKey(String privateKey) { + this.privateKey = privateKey; + } + + @Column(name = "public_key") + public String getPublicKey() { + return publicKey; + } + + public void setPublicKey(String publicKey) { + this.publicKey = publicKey; + } + + @Column(name = "encryption_algorithm") + public String getEncryptionAlgorithm() { + return encryptionAlgorithm; + } + + public void setEncryptionAlgorithm(String encryptionAlgorithm) { + this.encryptionAlgorithm = encryptionAlgorithm; + } } diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/notification/DefaultNotifier.java b/superfly-service/src/main/java/com/payneteasy/superfly/notification/DefaultNotifier.java index 6f09e79f..ad22a0ff 100644 --- a/superfly-service/src/main/java/com/payneteasy/superfly/notification/DefaultNotifier.java +++ b/superfly-service/src/main/java/com/payneteasy/superfly/notification/DefaultNotifier.java @@ -1,74 +1,69 @@ package com.payneteasy.superfly.notification; import com.payneteasy.superfly.notification.strategy.NotificationSendStrategy; -import com.payneteasy.superfly.service.job.SendNotificationOnceJob; -import com.payneteasy.superfly.utils.SchedulerUtils; -import lombok.Setter; import lombok.extern.slf4j.Slf4j; -import org.quartz.Scheduler; -import org.quartz.SchedulerException; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.ApplicationContext; +import org.springframework.scheduling.TaskScheduler; import org.springframework.stereotype.Component; -import java.util.HashMap; +import java.time.Duration; +import java.time.Instant; import java.util.List; -import java.util.Map; -import java.util.Objects; /** * Default Notifier implementation which delegates some actions to strategies. * + *

Notifications are dispatched asynchronously with a short delay via Spring's + * {@link TaskScheduler} (previously a one-shot Quartz job). The send is a single + * attempt: on failure the notification is logged and dropped, matching the prior + * {@code SendNotificationOnceJob} semantics. + * * @author Roman Puchkovskiy */ @Slf4j @Component public class DefaultNotifier implements Notifier { - private Scheduler scheduler; - private String sendStrategyBeanName; - @Setter - private int maxRetries = 3; + /** Delay before dispatching, identical to the former Quartz trigger start offset. */ + private static final Duration SEND_DELAY = Duration.ofSeconds(1); - @Autowired - public void setScheduler(Scheduler scheduler) { - this.scheduler = scheduler; - } + private final TaskScheduler taskScheduler; + private final NotificationSendStrategy sendStrategy; @Autowired - public void setSendStrategyBeanName(ApplicationContext context, NotificationSendStrategy sendStrategyBeanName) { - Objects.requireNonNull(context, "context"); - String[] beanNamesForType = context.getBeanNamesForType(sendStrategyBeanName.getClass()); - Objects.checkIndex(0, beanNamesForType.length); - - this.sendStrategyBeanName = beanNamesForType[0]; + public DefaultNotifier(TaskScheduler taskScheduler, NotificationSendStrategy sendStrategy) { + this.taskScheduler = taskScheduler; + this.sendStrategy = sendStrategy; } public void notifyAboutLogout(List notifications) { log.info("Notifying about logout: {}", notifications); - for (LogoutNotification notification : notifications) { - createJob(notification); - } + notifications.forEach(this::scheduleSend); } public void notifyAboutUsersChanged(List notifications) { log.info("Notifying about users changed: {}", notifications); - for (UsersChangedNotification notification : notifications) { - createJob(notification); - } + notifications.forEach(this::scheduleSend); } - private void createJob(AbstractNotification notification) { - Map dataMap = new HashMap<>(); - dataMap.put("beanName", sendStrategyBeanName); - dataMap.put("notification", notification); - - dataMap.put("retriesLeft", maxRetries); // deprecated + private void scheduleSend(AbstractNotification notification) { + log.debug("Scheduling notification send in {}: {}", SEND_DELAY, notification); + taskScheduler.schedule(() -> send(notification), Instant.now().plus(SEND_DELAY)); + } + private void send(AbstractNotification notification) { + log.debug("Sending notification: {}", notification); try { - SchedulerUtils.scheduleJob(scheduler, SendNotificationOnceJob.class, dataMap); - } catch (SchedulerException e) { - log.error(e.getMessage(), e); + if (notification instanceof LogoutNotification logoutNotification) { + sendStrategy.send(logoutNotification); + } else if (notification instanceof UsersChangedNotification usersChangedNotification) { + sendStrategy.send(usersChangedNotification); + } else { + throw new IllegalArgumentException("Unknown notification type: " + notification.getClass()); + } + } catch (Exception e) { + log.error("Error while trying to send a notification, no more retries, dropping: {}", + notification, e); } } } diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/notification/strategy/AbstractHttpNotificationSendStrategy.java b/superfly-service/src/main/java/com/payneteasy/superfly/notification/strategy/AbstractHttpNotificationSendStrategy.java index b2e2b9b4..2cf27da1 100644 --- a/superfly-service/src/main/java/com/payneteasy/superfly/notification/strategy/AbstractHttpNotificationSendStrategy.java +++ b/superfly-service/src/main/java/com/payneteasy/superfly/notification/strategy/AbstractHttpNotificationSendStrategy.java @@ -1,59 +1,100 @@ package com.payneteasy.superfly.notification.strategy; -import java.io.IOException; -import java.util.Arrays; - -import org.apache.commons.httpclient.HttpClient; -import org.apache.commons.httpclient.HttpException; -import org.apache.commons.httpclient.methods.PostMethod; +import com.payneteasy.http.client.api.HttpHeader; +import com.payneteasy.http.client.api.HttpHeaders; +import com.payneteasy.http.client.api.HttpMethod; +import com.payneteasy.http.client.api.HttpRequest; +import com.payneteasy.http.client.api.HttpRequestParameters; +import com.payneteasy.http.client.api.HttpResponse; +import com.payneteasy.http.client.api.HttpTimeouts; +import com.payneteasy.http.client.api.IHttpClient; +import com.payneteasy.http.client.api.exceptions.HttpConnectException; +import com.payneteasy.http.client.api.exceptions.HttpReadException; +import com.payneteasy.http.client.api.exceptions.HttpWriteException; +import com.payneteasy.superfly.notification.NotificationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - -import com.payneteasy.superfly.notification.NotificationException; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; /** * Base for any send strategy using HTTP to deliver notifications. * + *

Transport is the project-wide {@link IHttpClient} abstraction (Apache HttpClient 5 under the + * hood — see {@code ApacheHC5HttpClient}); the legacy commons-httpclient transport has been removed. + * Notifications are delivered as {@code application/x-www-form-urlencoded} POST requests, matching + * the wire format consumer callbacks expect. + * * @author Roman Puchkovskiy */ public abstract class AbstractHttpNotificationSendStrategy implements NotificationSendStrategy { + /** Connect timeout for notification callbacks (ms). Mirrors the former commons-httpclient setup. */ + private static final int CONNECT_TIMEOUT_MS = 10_000; + + /** Read/response timeout for notification callbacks (ms). Mirrors the former commons-httpclient setup. */ + private static final int READ_TIMEOUT_MS = 30_000; + + private static final String CONTENT_TYPE_FORM = "application/x-www-form-urlencoded"; + protected static Logger logger = LoggerFactory.getLogger(AbstractHttpNotificationSendStrategy.class); - protected HttpClient httpClient; + protected IHttpClient httpClient; protected void doCall(String uri, String notificationType, ParameterSetter parameterSetter) throws NotificationException { - PostMethod httpMethod = new PostMethod(uri); - httpMethod.setParameter("superflyNotification", notificationType); - parameterSetter.setParameters(httpMethod); + Map params = new LinkedHashMap<>(); + params.put("superflyNotification", notificationType); + parameterSetter.setParameters(params); + + logger.debug("Sending notification to {} with params {}", uri, params); + + HttpRequest request = HttpRequest.builder() + .url(uri) + .method(HttpMethod.POST) + .headers(new HttpHeaders(List.of(new HttpHeader("Content-Type", CONTENT_TYPE_FORM)))) + .body(encodeForm(params)) + .build(); + HttpRequestParameters parameters = HttpRequestParameters.builder() + .timeouts(new HttpTimeouts(CONNECT_TIMEOUT_MS, READ_TIMEOUT_MS)) + .build(); + try { - httpClient.executeMethod(httpMethod); + HttpResponse response = httpClient.send(request, parameters); if (logger.isInfoEnabled()) { - logger.info("Successfully notified " + uri + " with params " + Arrays.asList(httpMethod.getParameters())); + logger.info("Successfully notified {} with params {} (status {})", + uri, params, response.getStatusCode()); } - } catch (HttpException e) { + } catch (HttpConnectException | HttpReadException | HttpWriteException e) { throw new NotificationException(e); - } catch (IOException e) { - throw new NotificationException(e); - } finally { - try { - httpMethod.releaseConnection(); - } catch (IllegalArgumentException ignored) { - logger.warn(ignored.getMessage(), ignored); + } + } + + private static byte[] encodeForm(Map params) { + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : params.entrySet()) { + if (sb.length() > 0) { + sb.append('&'); } + sb.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)) + .append('=') + .append(URLEncoder.encode(entry.getValue() == null ? "" : entry.getValue(), StandardCharsets.UTF_8)); } + return sb.toString().getBytes(StandardCharsets.UTF_8); } @Autowired - public void setHttpClient(HttpClient httpClient) { + public void setHttpClient(IHttpClient httpClient) { this.httpClient = httpClient; } protected interface ParameterSetter { - void setParameters(PostMethod httpMethod); + void setParameters(Map params); } } diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/notification/strategy/SimpleSendStrategy.java b/superfly-service/src/main/java/com/payneteasy/superfly/notification/strategy/SimpleSendStrategy.java index 0cb19608..350a9481 100644 --- a/superfly-service/src/main/java/com/payneteasy/superfly/notification/strategy/SimpleSendStrategy.java +++ b/superfly-service/src/main/java/com/payneteasy/superfly/notification/strategy/SimpleSendStrategy.java @@ -1,6 +1,5 @@ package com.payneteasy.superfly.notification.strategy; -import org.apache.commons.httpclient.methods.PostMethod; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -19,19 +18,13 @@ public class SimpleSendStrategy extends AbstractHttpNotificationSendStrategy { public void send(final LogoutNotification notification) throws NotificationException { - doCall(notification.getCallbackUri(), Notifications.LOGOUT, new ParameterSetter() { - public void setParameters(PostMethod httpMethod) { - httpMethod.setParameter("superflyLogoutSessionIds", - StringUtils.collectionToCommaDelimitedString(notification.getSessionIds())); - } - }); + doCall(notification.getCallbackUri(), Notifications.LOGOUT, params -> + params.put("superflyLogoutSessionIds", + StringUtils.collectionToCommaDelimitedString(notification.getSessionIds()))); } public void send(final UsersChangedNotification notification) throws NotificationException { - doCall(notification.getCallbackUri(), Notifications.USERS_CHANGED, new ParameterSetter() { - public void setParameters(PostMethod httpMethod) { - } - }); + doCall(notification.getCallbackUri(), Notifications.USERS_CHANGED, params -> { }); } } diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/service/EventService.java b/superfly-service/src/main/java/com/payneteasy/superfly/service/EventService.java new file mode 100644 index 00000000..99b5e708 --- /dev/null +++ b/superfly-service/src/main/java/com/payneteasy/superfly/service/EventService.java @@ -0,0 +1,14 @@ +package com.payneteasy.superfly.service; + +import com.payneteasy.superfly.model.Event; + +import java.util.Date; +import java.util.List; + +/** + * Service to work with events. + * + */ +public interface EventService { + List getEvents(Date lastEventTime, long waitTime, String subsystemName); +} diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/service/InternalSSOService.java b/superfly-service/src/main/java/com/payneteasy/superfly/service/InternalSSOService.java index b535f473..484cc3b0 100644 --- a/superfly-service/src/main/java/com/payneteasy/superfly/service/InternalSSOService.java +++ b/superfly-service/src/main/java/com/payneteasy/superfly/service/InternalSSOService.java @@ -1,11 +1,14 @@ package com.payneteasy.superfly.service; +import java.util.Date; import java.util.List; import com.payneteasy.superfly.api.ActionDescription; import com.payneteasy.superfly.api.exceptions.*; import com.payneteasy.superfly.api.OTPType; import com.payneteasy.superfly.api.RoleGrantSpecification; +import com.payneteasy.superfly.api.SSOEvent; +import com.payneteasy.superfly.api.request.GetEventsRequest; import com.payneteasy.superfly.api.SSOUser; import com.payneteasy.superfly.api.SSOUserWithActions; import com.payneteasy.superfly.model.UserWithStatus; @@ -175,4 +178,6 @@ void registerUser(String username, String password, String email, String subsyst void changeUserRole(String username, String newRole, String subsystemIdentifier); boolean hasOtpMasterKey(String username); + + List getEvents(Date lastEventTime, long waitTimeMs, String subsystemIdentifier); } diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/service/RemoteAuthCryptoService.java b/superfly-service/src/main/java/com/payneteasy/superfly/service/RemoteAuthCryptoService.java new file mode 100644 index 00000000..e3297c22 --- /dev/null +++ b/superfly-service/src/main/java/com/payneteasy/superfly/service/RemoteAuthCryptoService.java @@ -0,0 +1,44 @@ +package com.payneteasy.superfly.service; + +import com.payneteasy.superfly.service.impl.remote.check.KeyPairData; +import com.payneteasy.superfly.service.impl.remote.check.RemoteAuthEncryptionAlgorithm; + +import javax.crypto.BadPaddingException; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; + +/** + * Service for cryptographic operations in remote authentication. + */ +public interface RemoteAuthCryptoService { + + KeyPairData generateKeyPair(RemoteAuthEncryptionAlgorithm algorithm); + + /** + * Decrypts a password using the subsystem's private key. + * + * @param encryptedPassword The encrypted password (Base64 URL-safe no padding). + * @param privateKey The private key of the subsystem. + * @param algorithm The encryption algorithm to use. + * @return The decrypted password. + */ + String decryptPassword(String encryptedPassword, String privateKey, RemoteAuthEncryptionAlgorithm algorithm) + throws NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, + InvalidKeySpecException, BadPaddingException, InvalidKeyException; + + /** + * Decrypts an OTP using the subsystem's private key. + * + * @param encryptedOtp The encrypted OTP (Base64 URL-safe no padding). + * @param privateKey The private key of the subsystem. + * @param algorithm The encryption algorithm to use. + * @return The decrypted OTP. + */ + String decryptOtp(String encryptedOtp, String privateKey, RemoteAuthEncryptionAlgorithm algorithm) + throws NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, + InvalidKeySpecException, BadPaddingException, InvalidKeyException; +} + diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/service/RemoteAuthService.java b/superfly-service/src/main/java/com/payneteasy/superfly/service/RemoteAuthService.java new file mode 100644 index 00000000..2fa5b7db --- /dev/null +++ b/superfly-service/src/main/java/com/payneteasy/superfly/service/RemoteAuthService.java @@ -0,0 +1,77 @@ +package com.payneteasy.superfly.service; + +/** + * Service for remote authentication of external applications. + */ +public interface RemoteAuthService { + + /** + * Checks the password for a user in a subsystem. + * + * @param subsystemName Name of the subsystem. + * @param username Username. + * @param passwordEncrypted Encrypted password (Base64 URL-safe no padding). + * @param bearerToken Bearer token provided in the request. + * @param ipAddress IP address of the client. + * @param userAgent User agent of the client. + * @return Session information including token and OTP requirement status if authentication is successful. + * @throws RemoteAuthException If authentication fails or other errors occur. + */ + RemoteAuthSession checkPassword(String subsystemName, String username, String passwordEncrypted, String bearerToken, String ipAddress, String userAgent) throws RemoteAuthException; + + /** + * Checks the OTP for a user in a subsystem. + * + * @param subsystemName Name of the subsystem. + * @param username Username. + * @param otpEncrypted Encrypted OTP (Base64 URL-safe no padding). + * @param sessionToken Session token obtained from checkPassword. + * @param bearerToken Bearer token provided in the request. + * @return Result of the OTP check (SUCCESS, BAD_USER..., etc.). + * @throws RemoteAuthException If validation fails. + */ + String checkOtp(String subsystemName, String username, String otpEncrypted, String sessionToken, String bearerToken) throws RemoteAuthException; + + /** + * Session data returned after successful password check. + */ + class RemoteAuthSession { + private final String sessionToken; + private final boolean otpRequired; + + public RemoteAuthSession(String sessionToken, boolean otpRequired) { + this.sessionToken = sessionToken; + this.otpRequired = otpRequired; + } + + public String getSessionToken() { + return sessionToken; + } + + public boolean isOtpRequired() { + return otpRequired; + } + } + + /** + * Exception class for remote auth errors. + */ + class RemoteAuthException extends Exception { + private final String errorCode; + + public RemoteAuthException(String message, String errorCode) { + super(message); + this.errorCode = errorCode; + } + + public RemoteAuthException(String message, String errorCode, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + + public String getErrorCode() { + return errorCode; + } + } +} + diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/service/SubsystemService.java b/superfly-service/src/main/java/com/payneteasy/superfly/service/SubsystemService.java index e5e2eddf..7d252c39 100644 --- a/superfly-service/src/main/java/com/payneteasy/superfly/service/SubsystemService.java +++ b/superfly-service/src/main/java/com/payneteasy/superfly/service/SubsystemService.java @@ -7,6 +7,8 @@ import com.payneteasy.superfly.model.ui.subsystem.UISubsystem; import com.payneteasy.superfly.model.ui.subsystem.UISubsystemForFilter; import com.payneteasy.superfly.model.ui.subsystem.UISubsystemForList; +import com.payneteasy.superfly.service.impl.remote.check.KeyPairData; +import com.payneteasy.superfly.service.impl.remote.check.RemoteAuthEncryptionAlgorithm; /** * Service for subsystems. @@ -85,4 +87,10 @@ public interface SubsystemService { * @return main token for subsystem */ String generateMainSubsystemToken(); + + /** + * + * @return key pair data for subsystem + */ + KeyPairData generateKeyPair(RemoteAuthEncryptionAlgorithm algorithm); } diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/EventServiceImpl.java b/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/EventServiceImpl.java new file mode 100644 index 00000000..26d9bc03 --- /dev/null +++ b/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/EventServiceImpl.java @@ -0,0 +1,64 @@ +package com.payneteasy.superfly.service.impl; + +import com.payneteasy.superfly.dao.EventDao; +import com.payneteasy.superfly.model.Event; +import com.payneteasy.superfly.service.EventService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +@Service +@Transactional +public class EventServiceImpl implements EventService, DisposableBean { + + private static final Logger logger = LoggerFactory.getLogger(EventServiceImpl.class); + + + private EventDao eventDao; + private static final int DELAY_TIME_MS = 500; + private static final int EVENTS_LIMIT = 200; + private final AtomicBoolean isShutdown = new AtomicBoolean(false); + + @Override + public void destroy() throws Exception { + isShutdown.set(true); + } + + @Autowired + public void setEventDao(EventDao eventDao) { + this.eventDao = eventDao; + } + + @Override + public List getEvents(Date lastEventTime, long waitTimeMs, String subsystemName) { + List events = eventDao.getEvents(lastEventTime, EVENTS_LIMIT, subsystemName); + List result = (events != null ? new ArrayList<>(events) : new ArrayList<>()); + if (result.isEmpty()) { + long now = System.currentTimeMillis(); + long finishTime = now + waitTimeMs; + while ((now < finishTime) && !isShutdown.get()) { + try { + Thread.sleep(DELAY_TIME_MS); + } catch (InterruptedException e) { + logger.error(e.getMessage(), e); + } + final List newEvents = eventDao.getEvents(lastEventTime, EVENTS_LIMIT, subsystemName); + + if (newEvents != null && !newEvents.isEmpty()) { + result.addAll(newEvents); + break; + } + now = System.currentTimeMillis(); + } + } + return result; + } +} diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/InternalSSOServiceImpl.java b/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/InternalSSOServiceImpl.java index caa2e992..3a195f2f 100644 --- a/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/InternalSSOServiceImpl.java +++ b/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/InternalSSOServiceImpl.java @@ -2,6 +2,7 @@ import com.payneteasy.superfly.api.*; import com.payneteasy.superfly.api.exceptions.*; +import com.payneteasy.superfly.api.request.GetEventsRequest; import com.payneteasy.superfly.crypto.PublicKeyCrypto; import com.payneteasy.superfly.lockout.LockoutStrategy; import com.payneteasy.superfly.model.UserRegisterRequest; @@ -24,6 +25,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.*; +import java.util.stream.Collectors; @Service @Transactional @@ -370,4 +372,23 @@ public void changeUserRole(String username, String newRole, String subsystemIden public boolean hasOtpMasterKey(String username) { return userService.getOtpMasterKeyByUsername(username) != null; } + + private EventService eventService; + + @Autowired + public void setEventService(EventService eventService) { + this.eventService = eventService; + } + + @Override + public List getEvents(Date lastEventTime, long waitTimeMs, String subsystemIdentifier) { + List events = eventService.getEvents(lastEventTime, waitTimeMs, subsystemIdentifier); + if (events != null && !events.isEmpty()) { + logger.info("getEvents call info={}", events); + return events.stream() + .map(event -> new SSOEvent(event.getEventId(), event.getEventTime(), event.getEventTypeCode(), event.getEventData())) + .collect(Collectors.toList()); + } + return List.of(); + } } diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/SubsystemServiceImpl.java b/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/SubsystemServiceImpl.java index b45c3d7b..5c9af980 100644 --- a/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/SubsystemServiceImpl.java +++ b/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/SubsystemServiceImpl.java @@ -5,7 +5,9 @@ import java.util.UUID; import com.payneteasy.superfly.model.SubsystemTokenData; -import com.payneteasy.superfly.service.JavaMailSenderPool; +import com.payneteasy.superfly.service.*; +import com.payneteasy.superfly.service.impl.remote.check.KeyPairData; +import com.payneteasy.superfly.service.impl.remote.check.RemoteAuthEncryptionAlgorithm; import com.payneteasy.superfly.utils.RandomGUID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,9 +20,6 @@ import com.payneteasy.superfly.model.ui.subsystem.UISubsystem; import com.payneteasy.superfly.model.ui.subsystem.UISubsystemForFilter; import com.payneteasy.superfly.model.ui.subsystem.UISubsystemForList; -import com.payneteasy.superfly.service.LoggerSink; -import com.payneteasy.superfly.service.NotificationService; -import com.payneteasy.superfly.service.SubsystemService; @Service @Transactional @@ -32,6 +31,7 @@ public class SubsystemServiceImpl implements SubsystemService { private NotificationService notificationService; private LoggerSink loggerSink; private JavaMailSenderPool javaMailSenderPool; + private RemoteAuthCryptoService remoteAuthCryptoService; @Autowired public void setSubsystemDao(SubsystemDao subsystemDao) { @@ -43,6 +43,11 @@ public void setNotificationService(NotificationService notificationService) { this.notificationService = notificationService; } + @Autowired + public void setRemoteAuthCryptoService(RemoteAuthCryptoService remoteAuthCryptoService) { + this.remoteAuthCryptoService = remoteAuthCryptoService; + } + @Autowired public void setLoggerSink(LoggerSink loggerSink) { this.loggerSink = loggerSink; @@ -111,4 +116,9 @@ private String generateUniqueSubsystemToken() { public String generateMainSubsystemToken() { return UUID.randomUUID().toString(); } + + @Override + public KeyPairData generateKeyPair(RemoteAuthEncryptionAlgorithm algorithm) { + return remoteAuthCryptoService.generateKeyPair(algorithm); + } } diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/remote/SSOServiceImpl.java b/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/remote/SSOServiceImpl.java index 292c6bbb..b89dabe0 100644 --- a/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/remote/SSOServiceImpl.java +++ b/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/remote/SSOServiceImpl.java @@ -13,6 +13,7 @@ import com.payneteasy.superfly.utils.StringUtils; import lombok.RequiredArgsConstructor; import lombok.Setter; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.io.ByteArrayOutputStream; @@ -31,6 +32,7 @@ @RequiredArgsConstructor public class SSOServiceImpl implements SSOService { @Setter + @Autowired(required = false) private SubsystemIdentifierObtainer subsystemIdentifierObtainer = new AuthRequestInfoObtainer(); private final InternalSSOService internalSSOService; private final HOTPService hotpService; @@ -288,7 +290,13 @@ public void changeUserRole(ChangeUserRoleRequest request) { ); } + @Override + public List getEvents(GetEventsRequest request) { + return internalSSOService.getEvents(request.getLastEventTime(), request.getWaitTimeMs(), obtainSubsystemIdentifier(null)); + } + protected String obtainSubsystemIdentifier(String systemIdentifier) { return subsystemIdentifierObtainer.obtainSubsystemIdentifier(systemIdentifier); } + } diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/remote/check/KeyPairData.java b/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/remote/check/KeyPairData.java new file mode 100644 index 00000000..687ba9e8 --- /dev/null +++ b/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/remote/check/KeyPairData.java @@ -0,0 +1,4 @@ +package com.payneteasy.superfly.service.impl.remote.check; + +public record KeyPairData(String publicKey, String privateKey) { +} diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/remote/check/RemoteAuthCryptoServiceImpl.java b/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/remote/check/RemoteAuthCryptoServiceImpl.java new file mode 100644 index 00000000..260e4faf --- /dev/null +++ b/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/remote/check/RemoteAuthCryptoServiceImpl.java @@ -0,0 +1,108 @@ +package com.payneteasy.superfly.service.impl.remote.check; + +import com.payneteasy.superfly.service.RemoteAuthCryptoService; +import org.springframework.stereotype.Service; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; + +@Service +public class RemoteAuthCryptoServiceImpl implements RemoteAuthCryptoService { + + @Override + public KeyPairData generateKeyPair(RemoteAuthEncryptionAlgorithm algorithm) { + return switch (algorithm) { + case RSA -> { + try { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(4096); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + String publicKeyPem = toPem(keyPair.getPublic().getEncoded(), "PUBLIC KEY"); + String privateKeyPem = toPem(keyPair.getPrivate().getEncoded(), "PRIVATE KEY"); + + yield new KeyPairData(publicKeyPem, privateKeyPem); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Cannot generate RSA key pair", e); + } + } + case EC -> throw new UnsupportedOperationException( + "EC algorithm is not supported yet. Please use RSA instead." + ); + }; + } + + private String toPem(byte[] encodedKey, String type) { + String base64 = Base64.getEncoder().encodeToString(encodedKey); + StringBuilder sb = new StringBuilder(); + sb.append("-----BEGIN ").append(type).append("-----\n"); + for (int i = 0; i < base64.length(); i += 64) { + int end = Math.min(i + 64, base64.length()); + sb.append(base64, i, end).append('\n'); + } + sb.append("-----END ").append(type).append("-----"); + return sb.toString(); + } + + @Override + public String decryptPassword(String encryptedPassword, String privateKey, RemoteAuthEncryptionAlgorithm algorithm) + throws NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, + InvalidKeySpecException, BadPaddingException, InvalidKeyException { + return decrypt(encryptedPassword, privateKey, algorithm); + } + + @Override + public String decryptOtp(String encryptedOtp, String privateKey, RemoteAuthEncryptionAlgorithm algorithm) + throws NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, + InvalidKeySpecException, BadPaddingException, InvalidKeyException { + return decrypt(encryptedOtp, privateKey, algorithm); + } + + private String decrypt(String encrypted, String privateKey, RemoteAuthEncryptionAlgorithm algorithm) + throws NoSuchAlgorithmException, InvalidKeySpecException, NoSuchPaddingException, + IllegalBlockSizeException, BadPaddingException, InvalidKeyException { + byte[] encryptedBytes = Base64.getUrlDecoder().decode(encrypted); + PrivateKey key = parsePrivateKey(privateKey, algorithm); + byte[] decrypted = decryptBytes(encryptedBytes, key, algorithm); + return new String(decrypted, StandardCharsets.UTF_8); + } + + private PrivateKey parsePrivateKey(String privateKeyPem, RemoteAuthEncryptionAlgorithm algorithm) + throws NoSuchAlgorithmException, InvalidKeySpecException { + + String normalized = privateKeyPem + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + + byte[] keyBytes = Base64.getDecoder().decode(normalized); + + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance(algorithm.name()); + + return keyFactory.generatePrivate(keySpec); + } + + private byte[] decryptBytes(byte[] encrypted, PrivateKey privateKey, RemoteAuthEncryptionAlgorithm algorithm) + throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, IllegalBlockSizeException, + BadPaddingException { + + return switch (algorithm) { + case RSA -> { + Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); + cipher.init(Cipher.DECRYPT_MODE, privateKey); + yield cipher.doFinal(encrypted); + } + case EC -> throw new UnsupportedOperationException( + "EC algorithm is not supported yet. Please use RSA instead." + ); + }; + } +} diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/remote/check/RemoteAuthCryptoServiceStub.java b/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/remote/check/RemoteAuthCryptoServiceStub.java new file mode 100644 index 00000000..01b56d58 --- /dev/null +++ b/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/remote/check/RemoteAuthCryptoServiceStub.java @@ -0,0 +1,34 @@ +package com.payneteasy.superfly.service.impl.remote.check; + +import com.payneteasy.superfly.service.RemoteAuthCryptoService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Stub implementation of RemoteAuthCryptoService. + */ +public class RemoteAuthCryptoServiceStub implements RemoteAuthCryptoService { + + private static final Logger logger = LoggerFactory.getLogger(RemoteAuthCryptoServiceStub.class); + + public RemoteAuthCryptoServiceStub() { + } + + @Override + public KeyPairData generateKeyPair(RemoteAuthEncryptionAlgorithm algorithm) { + return null; + } + + public String decryptPassword(String encryptedPassword, String privateKey, RemoteAuthEncryptionAlgorithm encryptionAlgorithm) { + logger.warn("decryptPassword is a STUB. Returning encryptedPassword as is."); + // In a real implementation, this would decrypt the password. + // For now, we assume the input is already the plain password or we just return it to test flow. + return encryptedPassword; + } + + public String decryptOtp(String encryptedOtp, String privateKey, RemoteAuthEncryptionAlgorithm encryptionAlgorithm) { + logger.warn("decryptOtp is a STUB. Returning encryptedOtp as is."); + return encryptedOtp; + } +} + diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/remote/check/RemoteAuthEncryptionAlgorithm.java b/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/remote/check/RemoteAuthEncryptionAlgorithm.java new file mode 100644 index 00000000..f58a3f9d --- /dev/null +++ b/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/remote/check/RemoteAuthEncryptionAlgorithm.java @@ -0,0 +1,6 @@ +package com.payneteasy.superfly.service.impl.remote.check; + +public enum RemoteAuthEncryptionAlgorithm { + + RSA, EC +} diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/remote/check/RemoteAuthServiceImpl.java b/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/remote/check/RemoteAuthServiceImpl.java new file mode 100644 index 00000000..b848cdce --- /dev/null +++ b/superfly-service/src/main/java/com/payneteasy/superfly/service/impl/remote/check/RemoteAuthServiceImpl.java @@ -0,0 +1,141 @@ +package com.payneteasy.superfly.service.impl.remote.check; + +import com.payneteasy.superfly.api.OTPType; +import com.payneteasy.superfly.api.SSOUser; +import com.payneteasy.superfly.model.ui.subsystem.UISubsystem; +import com.payneteasy.superfly.service.InternalSSOService; +import com.payneteasy.superfly.service.RemoteAuthCryptoService; +import com.payneteasy.superfly.service.RemoteAuthService; +import com.payneteasy.superfly.service.SubsystemService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.stereotype.Service; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +@Service +public class RemoteAuthServiceImpl implements RemoteAuthService { + + private static final Logger logger = LoggerFactory.getLogger(RemoteAuthServiceImpl.class); + + private final SubsystemService subsystemService; + private final InternalSSOService internalSSOService; + private final RemoteAuthCryptoService remoteAuthCryptoService; + + private final Cache sessionCache = Caffeine.newBuilder() + .expireAfterWrite(5, TimeUnit.MINUTES) + .maximumSize(10_000) + .build(); + + public RemoteAuthServiceImpl(SubsystemService subsystemService, + InternalSSOService internalSSOService, + RemoteAuthCryptoService remoteAuthCryptoService) { + this.subsystemService = subsystemService; + this.internalSSOService = internalSSOService; + this.remoteAuthCryptoService = remoteAuthCryptoService; + } + + @Override + public RemoteAuthSession checkPassword(String subsystemName, String username, String passwordEncrypted, String bearerToken, String ipAddress, String userAgent) throws RemoteAuthException { + // 1. Validate Subsystem and Token + UISubsystem subsystem = validateSubsystem(subsystemName, bearerToken); + + // 2. Decrypt Password + String password; + try { + password = remoteAuthCryptoService.decryptPassword( + passwordEncrypted, + subsystem.getPrivateKey(), + RemoteAuthEncryptionAlgorithm.valueOf(subsystem.getEncryptionAlgorithm()) + ); + } catch (Exception e) { + logger.warn("Failed to decrypt password: {}", e.getMessage()); + throw new RemoteAuthException("Decryption failed", "BAD_REQUEST"); + } + + // 3. Authenticate User + // Using internalSSOService to check credentials and get SSOUser + // Note: This creates a session in the DB as well (AuthSession). + // If we want to avoid creating a full session until OTP, we might need a different method, + // but for now we reuse the existing logic. + SSOUser ssoUser = internalSSOService.authenticate(username, password, subsystemName, ipAddress, userAgent); + + if (ssoUser == null) { + // Could be bad password, user blocked, etc. + // For security, we might return generic bad credentials. + throw new RemoteAuthException("Authentication failed", "BAD_USER_OR_PASSWORD_OR_OTP"); + } + + if (ssoUser.hasAction("action_temp_password")) { + throw new RemoteAuthException("User should change password", "USER_SHOULD_CHANGE_PASSWORD"); + } + + // 4. Generate Session Token and Cache + String sessionToken = UUID.randomUUID().toString(); + sessionCache.put(sessionToken, new RemoteSession(username, ssoUser.getOtpType())); + + boolean otpRequired = ssoUser.getOtpType() != OTPType.NONE && !ssoUser.isOtpOptional(); + + return new RemoteAuthSession(sessionToken, otpRequired); + } + + @Override + public String checkOtp(String subsystemName, String username, String otpEncrypted, String sessionToken, String bearerToken) throws RemoteAuthException { + // 1. Validate Subsystem and Token + UISubsystem subsystem = validateSubsystem(subsystemName, bearerToken); + + // 2. Validate Session Token + RemoteSession session = sessionCache.getIfPresent(sessionToken); + if (session == null) { + throw new RemoteAuthException("Session expired or invalid", "BAD_USER_OR_PASSWORD_OR_OTP"); + } + if (!session.username.equals(username)) { + throw new RemoteAuthException("Username mismatch", "BAD_USER_OR_PASSWORD_OR_OTP"); + } + + // 3. Decrypt OTP + String otp; + try { + otp = remoteAuthCryptoService.decryptOtp( + otpEncrypted, + subsystem.getPrivateKey(), + RemoteAuthEncryptionAlgorithm.valueOf(subsystem.getEncryptionAlgorithm()) + ); + } catch (Exception e) { + logger.warn("Failed to decrypt OTP: {}", e.getMessage()); + throw new RemoteAuthException("Decryption failed", "BAD_REQUEST"); + } + + // 4. Verify OTP + boolean otpValid = internalSSOService.authenticateByOtpType(session.otpType, username, otp); + if (!otpValid) { + return "BAD_USER_OR_PASSWORD_OR_OTP"; + } + return "SUCCESS"; + } + + private UISubsystem validateSubsystem(String subsystemName, String bearerToken) throws RemoteAuthException { + UISubsystem subsystem = subsystemService.getSubsystemByName(subsystemName); + if (subsystem == null) { + throw new RemoteAuthException("Subsystem not found", "UNAUTHORIZED"); + } + if (subsystem.getSubsystemToken() == null || !subsystem.getSubsystemToken().equals(bearerToken)) { + throw new RemoteAuthException("Invalid subsystem token", "UNAUTHORIZED"); + } + return subsystem; + } + + private static class RemoteSession { + final String username; + final OTPType otpType; + + RemoteSession(String username, OTPType otpType) { + this.username = username; + this.otpType = otpType; + } + } +} + diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/service/job/BaseJob.java b/superfly-service/src/main/java/com/payneteasy/superfly/service/job/BaseJob.java deleted file mode 100644 index a8de4db7..00000000 --- a/superfly-service/src/main/java/com/payneteasy/superfly/service/job/BaseJob.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.payneteasy.superfly.service.job; - -import org.quartz.JobExecutionContext; -import org.quartz.JobExecutionException; -import org.quartz.SchedulerContext; -import org.quartz.SchedulerException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.BeansException; -import org.springframework.context.ApplicationContext; -import org.springframework.scheduling.quartz.QuartzJobBean; - -import com.payneteasy.superfly.notification.strategy.NotificationSendStrategy; - -/** - * Base job class which gives access to the ApplicationContext and some its - * beans. - * - * @author Roman Puchkovskiy - */ -public abstract class BaseJob extends QuartzJobBean { - private static final Logger logger = LoggerFactory.getLogger(BaseJob.class); - - private static final String APPLICATION_CONTEXT_KEY = "applicationContext"; - - /** - * Obtains Spring's application context. - * - * @param context job execution context - * @return application context - */ - protected ApplicationContext getApplicationContext(JobExecutionContext context) - throws SchedulerException { - - final SchedulerContext schedulerContext = context.getScheduler().getContext(); - ApplicationContext applicationContext = (ApplicationContext) schedulerContext.get(APPLICATION_CONTEXT_KEY); - - // show keys in context - if(applicationContext==null) { - logger.error(APPLICATION_CONTEXT_KEY+" is empty in "+ schedulerContext+":"); - if(schedulerContext.getKeys()!=null) { - for (String key : schedulerContext.getKeys()) { - Object value = schedulerContext.get(key); - String valueText = value!=null ? value.toString() : ""; - logger.info(" {} = {}", key, valueText); - } - } - } - - return applicationContext; - } - - protected NotificationSendStrategy getNotificationSendStrategy(JobExecutionContext context, String beanName) - throws BeansException, SchedulerException { - - ApplicationContext applicationContext = getApplicationContext(context); - return applicationContext!=null ? (NotificationSendStrategy) applicationContext.getBean(beanName) : null; - - } - - /** - * @see org.springframework.scheduling.quartz.QuartzJobBean#executeInternal(org.quartz.JobExecutionContext) - */ - @Override - protected void executeInternal(JobExecutionContext context) throws JobExecutionException { - if(context==null) { - // todo this quick fix for: - // ERROR ErrorLogger - Job (sender.9F9F6217-536B-630D-4B8F-A2A4D5F74011 threw an exception. - //org.quartz.SchedulerException: Job threw an unhandled exception. [See nested exception: java.lang.NullPointerException] - // at org.quartz.core.JobRunShell.run(JobRunShell.java:213) - // at org.quartz.simpl.SimpleThreadPool$WorkerThread.run(SimpleThreadPool.java:525) - //Caused by: java.lang.NullPointerException - // at com.payneteasy.superfly.service.job.BaseJob.getNotificationSendStrategy(BaseJob.java:40) - // at com.payneteasy.superfly.service.job.SendNotificationOnceJob.doExecute(SendNotificationOnceJob.java:39) - // at com.payneteasy.superfly.service.job.BaseJob.executeInternal(BaseJob.java:50) - // at org.springframework.scheduling.quartz.QuartzJobBean.execute(QuartzJobBean.java:86) - // at org.quartz.core.JobRunShell.run(JobRunShell.java:202) - logger.error("Something went wrong, no JobExecutionContext was passed for "+getClass().getSimpleName()+" job"); - } else { - try { - doExecute(context); - } catch (SchedulerException e) { - logger.error("Scheduler error", e); - throw new JobExecutionException(e); - } - } - } - - protected abstract void doExecute(JobExecutionContext context) throws SchedulerException; -} diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/service/job/SendNotificationOnceJob.java b/superfly-service/src/main/java/com/payneteasy/superfly/service/job/SendNotificationOnceJob.java deleted file mode 100644 index 9cf7e1b6..00000000 --- a/superfly-service/src/main/java/com/payneteasy/superfly/service/job/SendNotificationOnceJob.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.payneteasy.superfly.service.job; - -import com.payneteasy.superfly.notification.AbstractNotification; -import com.payneteasy.superfly.notification.LogoutNotification; -import com.payneteasy.superfly.notification.UsersChangedNotification; -import com.payneteasy.superfly.notification.strategy.NotificationSendStrategy; -import lombok.Setter; -import lombok.extern.slf4j.Slf4j; -import org.quartz.JobExecutionContext; -import org.quartz.SchedulerException; - -@Setter -@Slf4j -public class SendNotificationOnceJob extends BaseJob { - - private String beanName; - private AbstractNotification notification; - private int retriesLeft; - - @SuppressWarnings("unchecked") - @Override - protected void doExecute(JobExecutionContext context) throws SchedulerException { - NotificationSendStrategy sendStrategy = getNotificationSendStrategy(context, beanName); - - if (sendStrategy == null) { - log.info("Notification strategy is null"); - return; - } - - try { - if (notification instanceof LogoutNotification) { - sendStrategy.send((LogoutNotification) notification); - } else if (notification instanceof UsersChangedNotification) { - sendStrategy.send((UsersChangedNotification) notification); - } else { - throw new IllegalArgumentException("Unknown notification type: " + notification.getClass()); - } - } catch (Exception e) { - log.error("Error while trying to send a notification, no more retries, dropping: {}", - notification.toString(), - e - ); - } - } - -} diff --git a/superfly-service/src/main/java/com/payneteasy/superfly/utils/SchedulerUtils.java b/superfly-service/src/main/java/com/payneteasy/superfly/utils/SchedulerUtils.java deleted file mode 100644 index ca9707b2..00000000 --- a/superfly-service/src/main/java/com/payneteasy/superfly/utils/SchedulerUtils.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.payneteasy.superfly.utils; - -import org.quartz.Job; -import org.quartz.JobBuilder; -import org.quartz.JobDataMap; -import org.quartz.JobDetail; -import org.quartz.JobExecutionContext; -import org.quartz.Scheduler; -import org.quartz.SchedulerException; -import org.quartz.Trigger; -import org.quartz.TriggerBuilder; - -import java.util.Date; -import java.util.Map; - -public class SchedulerUtils { - - private static final String GROUP = "sender"; - - public static void scheduleJob(Scheduler scheduler, - Class jobClass, Map dataMap) - throws SchedulerException { - String name = new RandomGUID().toString(); - JobDetail jobDetail = JobBuilder.newJob(jobClass) - .withIdentity(name, GROUP) - .usingJobData(new JobDataMap(dataMap)) - .build(); - Trigger trigger = TriggerBuilder.newTrigger() - .withIdentity(name, GROUP) - .startAt(new Date(new Date().getTime() + 1000)) - .build(); - scheduler.scheduleJob(jobDetail, trigger); - } - - public static void rescheduleJob(JobExecutionContext context, Map dataMap) throws SchedulerException { - String name = new RandomGUID().toString(); - JobDetail jobDetail = JobBuilder.newJob(context.getJobDetail().getJobClass()) - .withIdentity(name, GROUP) - .usingJobData(new JobDataMap(dataMap)) - .build(); - Trigger trigger = TriggerBuilder.newTrigger() - .withIdentity(name, GROUP) - .startAt(new Date(new Date().getTime() + 1000 * 60)) - .build(); - context.getScheduler().scheduleJob(jobDetail, trigger); - } -} diff --git a/superfly-service/src/test/java/com/payneteasy/superfly/password/PlaintextPasswordEncoderTest.java b/superfly-service/src/test/java/com/payneteasy/superfly/password/PlaintextPasswordEncoderTest.java index 208571f2..6c675470 100644 --- a/superfly-service/src/test/java/com/payneteasy/superfly/password/PlaintextPasswordEncoderTest.java +++ b/superfly-service/src/test/java/com/payneteasy/superfly/password/PlaintextPasswordEncoderTest.java @@ -5,6 +5,7 @@ import static org.junit.Assert.assertEquals; public class PlaintextPasswordEncoderTest { + private PlaintextPasswordEncoder encoder = new PlaintextPasswordEncoder(); @Test diff --git a/superfly-spring-security-core/pom.xml b/superfly-spring-security-core/pom.xml new file mode 100644 index 00000000..dbc7e3e8 --- /dev/null +++ b/superfly-spring-security-core/pom.xml @@ -0,0 +1,76 @@ + + 4.0.0 + + + com.payneteasy.superfly + superfly-parent + 2.0-3-SNAPSHOT + + + superfly-spring-security-core + Superfly Spring Security Core + Core authentication model and helpers for Superfly Spring Security integration (no servlet API) + + + + + org.springframework.security + spring-security-core + provided + + + + org.springframework + spring-core + provided + + + + org.springframework + spring-context + true + + + + org.springframework + spring-beans + true + + + + org.springframework + spring-web + true + + + + com.payneteasy.superfly + superfly-remote-api + + + + com.payneteasy.superfly + superfly-common + + + + com.payneteasy.superfly + superfly-client-core + + + + org.slf4j + slf4j-api + + + + org.projectlombok + lombok + provided + + + + + + diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/AbstractDisableableAuthenticationProvider.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/AbstractDisableableAuthenticationProvider.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/AbstractDisableableAuthenticationProvider.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/AbstractDisableableAuthenticationProvider.java diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/AbstractRoleTransformingAuthenticationProvider.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/AbstractRoleTransformingAuthenticationProvider.java new file mode 100644 index 00000000..b09fdba5 --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/AbstractRoleTransformingAuthenticationProvider.java @@ -0,0 +1,32 @@ +package com.payneteasy.superfly.security; + +import org.springframework.security.authentication.AuthenticationProvider; + +/** + * Base for {@link AuthenticationProvider} which knows how to deal with + * roles. + * + * @author Roman Puchkovskiy + */ +public abstract class AbstractRoleTransformingAuthenticationProvider implements + AuthenticationProvider { + + protected StringTransformer[] roleNameTransformers = new StringTransformer[]{}; + protected RoleSource roleSource = createDefaultRoleSource(); + + public void setRoleNameTransformers(StringTransformer[] roleNameTransformers) { + this.roleNameTransformers = roleNameTransformers; + } + + public void setRoleSource(RoleSource roleSource) { + this.roleSource = roleSource; + } + + protected RoleSource createDefaultRoleSource() { + RoleSource[] sources = new RoleSource[2]; + sources[0] = new SSOActionRoleSource(); + sources[1] = new SSORoleRoleSource(); + return new CompoundRoleSource(sources); + } + +} diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/AbstractSuperflyAuthenticationProvider.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/AbstractSuperflyAuthenticationProvider.java new file mode 100644 index 00000000..2c30a5b4 --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/AbstractSuperflyAuthenticationProvider.java @@ -0,0 +1,128 @@ +package com.payneteasy.superfly.security; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; + +import com.payneteasy.superfly.api.SSORole; +import com.payneteasy.superfly.api.SSOUser; +import com.payneteasy.superfly.security.authentication.SSOUserAndSelectedRoleAuthenticationToken; +import com.payneteasy.superfly.security.authentication.SSOUserAuthenticationToken; +import com.payneteasy.superfly.security.authentication.SSOUserTransportAuthenticationToken; +import com.payneteasy.superfly.security.authentication.UsernamePasswordAuthRequestInfoAuthenticationToken; +import com.payneteasy.superfly.security.exception.StepTwoException; + +/** + * Base for authentication providers using a two-step password authentication + * for Superfly. + * + * @author Roman Puchkovskiy + */ +public abstract class AbstractSuperflyAuthenticationProvider implements AuthenticationProvider { + + private boolean enabled = true; + private StringTransformer[] transformers = new StringTransformer[0]; + private RoleSource roleSource = createDefaultRoleSource(); + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + protected boolean isActive() { + return enabled; + } + + @SuppressWarnings("rawtypes") + public boolean supports(Class authentication) { + return UsernamePasswordAuthRequestInfoAuthenticationToken.class.isAssignableFrom(authentication) + || SSOUserTransportAuthenticationToken.class.isAssignableFrom(authentication) + || SSOUserAndSelectedRoleAuthenticationToken.class.isAssignableFrom(authentication); + } + + public void setTransformers(StringTransformer[] transformers) { + this.transformers = transformers; + } + + public void setRoleSource(RoleSource roleSource) { + this.roleSource = roleSource; + } + + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + if (isActive()) { + if (authentication instanceof UsernamePasswordAuthRequestInfoAuthenticationToken) { + // step 1: auth username/password to temp Authentication + UsernamePasswordAuthRequestInfoAuthenticationToken authRequest = (UsernamePasswordAuthRequestInfoAuthenticationToken) authentication; + return authenticateUsernamePassword(authRequest); + } else if (authentication instanceof SSOUserTransportAuthenticationToken) { + // step 1.5: throwing so ExceptionTranslationFilter will catch it and + // give entry point a chance to go to step 2 + final StepTwoException stepTwoException = new StepTwoException("Going to step two"); + stepTwoException.setAuthentication(authentication); + throw stepTwoException; + } else if (authentication instanceof SSOUserAndSelectedRoleAuthenticationToken) { + // step 2: auth user+role to final Authentication + SSOUserAndSelectedRoleAuthenticationToken authRequest = (SSOUserAndSelectedRoleAuthenticationToken) authentication; + return authenticateUserRole(authRequest); + } else { + // this is not our responsibility... + return null; + } + } else { + return null; + } + } + + protected Authentication authenticateUserRole(SSOUserAndSelectedRoleAuthenticationToken authRequest) { + SSOUser user = authRequest.getSsoUser(); + SSORole role = authRequest.getSsoRole(); + + return createFinalAuthentication(authRequest, user, role); + } + + protected Authentication authenticateUsernamePassword(UsernamePasswordAuthRequestInfoAuthenticationToken authRequest) { + String username = authRequest.getName(); + String password = (String) authRequest.getCredentials(); + + SSOUser ssoUser = doAuthenticate(authRequest, username, password); + + if (ssoUser == null) { + throw new BadCredentialsException("Bad password"); + } + + if (ssoUser.getActionsMap().isEmpty()) { + throw new BadCredentialsException("No roles assigned"); + } + + if (ssoUser.getActionsMap().keySet().size() > 1) { + // more than 1 role, we need step two + return createTempAuthenticationToken(ssoUser); + } else { + // exactly one role, no step two is needed, short-circuit + return createFinalAuthentication(authRequest, ssoUser, + ssoUser.getActionsMap().keySet().iterator().next()); + } + } + + protected SSOUserTransportAuthenticationToken createTempAuthenticationToken(SSOUser ssoUser) { + return new SSOUserTransportAuthenticationToken(ssoUser); + } + + protected Authentication createFinalAuthentication(Authentication authRequest, SSOUser user, + SSORole role) { + return new SSOUserAuthenticationToken(user, role, + authRequest.getCredentials(), authRequest.getDetails(), + transformers, roleSource); + } + + protected RoleSource createDefaultRoleSource() { + RoleSource[] sources = new RoleSource[2]; + sources[0] = new SSOActionRoleSource(); + sources[1] = new SSORoleRoleSource(); + return new CompoundRoleSource(sources); + } + + protected abstract SSOUser doAuthenticate(UsernamePasswordAuthRequestInfoAuthenticationToken authRequest, String username, + String password); + +} diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/CompoundAuthenticationProvider.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/CompoundAuthenticationProvider.java new file mode 100644 index 00000000..b7a6b716 --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/CompoundAuthenticationProvider.java @@ -0,0 +1,94 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.security.authentication.CompoundAuthentication; +import com.payneteasy.superfly.security.processor.AuthenticationPostProcessor; +import com.payneteasy.superfly.security.validator.AuthenticationValidator; +import lombok.AccessLevel; +import lombok.Setter; +import lombok.experimental.Accessors; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.stereotype.Component; + +/** + * {@link AuthenticationProvider} which allows to work with compound + * (multistep) authentication process. It delegates actual step authentication + * to a delegate {@link AuthenticationProvider}. + * + * @author Roman Puchkovskiy + */ +@Component +@Setter +@Accessors(chain = true) +public class CompoundAuthenticationProvider extends AbstractDisableableAuthenticationProvider { + + private AuthenticationProvider delegateProvider; + private Class[] supportedSimpleAuthenticationClasses = new Class[]{}; + private Class[] notSupportedSimpleAuthenticationClasses = new Class[]{}; + private AuthenticationValidator authenticationValidator = null; + private AuthenticationPostProcessor authenticationPostProcessor = null; + + public CompoundAuthenticationProvider setDelegateProvider(AuthenticationProvider authenticationProvider) { + this.delegateProvider = authenticationProvider; + return this; + } + + @Override + protected Authentication doAuthenticate(Authentication authentication) + throws AuthenticationException { + if (authenticationValidator != null) { + authenticationValidator.validate(authentication); + } + CompoundAuthentication compoundAuthentication = null; + Authentication request; + if (authentication instanceof CompoundAuthentication) { + compoundAuthentication = (CompoundAuthentication) authentication; + request = compoundAuthentication.getCurrentAuthenticationRequest(); + } else { + request = authentication; + } + + // do not do anything for unsupported classes + for (Class clazz : notSupportedSimpleAuthenticationClasses) { + if (clazz == request.getClass()) { + return null; + } + } + + Authentication result = delegateProvider.authenticate(request); + + // if delegate returned null, returning null too as this means that + // we shouldn't handle this + if (result == null) { + return null; + } + + if (compoundAuthentication == null) { + compoundAuthentication = new CompoundAuthentication(); + } + compoundAuthentication.addReadyAuthentication(result); + + Authentication returnValue; + if (authenticationPostProcessor != null) { + returnValue = authenticationPostProcessor.postProcess(compoundAuthentication); + } else { + returnValue = compoundAuthentication; + } + return returnValue; + } + + @Override + protected boolean doSupports(Class authentication) { + if (CompoundAuthentication.class.isAssignableFrom(authentication)) { + return true; + } + for (Class clazz : supportedSimpleAuthenticationClasses) { + if (clazz.equals(authentication)) { + return true; + } + } + return false; + } + +} diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/CompoundRoleSource.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/CompoundRoleSource.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/CompoundRoleSource.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/CompoundRoleSource.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/DelegatingDecisionManager.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/DelegatingDecisionManager.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/DelegatingDecisionManager.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/DelegatingDecisionManager.java diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/InsufficientAuthenticationAccessDecisionManager.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/InsufficientAuthenticationAccessDecisionManager.java new file mode 100644 index 00000000..1c026234 --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/InsufficientAuthenticationAccessDecisionManager.java @@ -0,0 +1,56 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.security.exception.InsufficientAuthenticationException; +import org.springframework.security.access.AccessDecisionManager; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.ConfigAttribute; +import org.springframework.security.core.Authentication; + +import java.util.Collection; + +/** + * {@link AccessDecisionManager} which first checks whether the current + * {@link Authentication} is sufficient. If not, it throws an + * {@link InsufficientAuthenticationException}, otherwise just delegates to + * other {@link AccessDecisionManager}. + * + * @author Roman Puchkovskiy + */ +public class InsufficientAuthenticationAccessDecisionManager extends + DelegatingDecisionManager { + + private Class[] insufficientAuthenticationClasses; + + public void setInsufficientAuthenticationClasses(Class[] classes) { + insufficientAuthenticationClasses = classes; + } + + public InsufficientAuthenticationAccessDecisionManager() { + } + + public InsufficientAuthenticationAccessDecisionManager( + AccessDecisionManager delegate) { + super(delegate); + } + + @Override + public void decide(Authentication authentication, Object object, + Collection configAttributes) + throws AccessDeniedException, InsufficientAuthenticationException { + boolean insufficient = false; + for (Class clazz : insufficientAuthenticationClasses) { + if (clazz.isAssignableFrom(authentication.getClass())) { + insufficient = true; + break; + } + } + if (insufficient) { + InsufficientAuthenticationException ex = new InsufficientAuthenticationException(authentication.getClass().getName()); + ex.setAuthentication(authentication); + throw ex; + } + // sufficient authentication, just proceed + getDelegate().decide(authentication, object, configAttributes); + } + +} diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/PrependingTransformer.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/PrependingTransformer.java new file mode 100644 index 00000000..a60fc608 --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/PrependingTransformer.java @@ -0,0 +1,25 @@ +package com.payneteasy.superfly.security; + +/** + * Prepends a prefix to a transformed string. + * + * @author Roman Puchkovskiy + */ +public class PrependingTransformer implements StringTransformer { + + private String prefix = ""; + + /** + * Sets a prefix to prepend. + * + * @param prefix + */ + public void setPrefix(String prefix) { + this.prefix = prefix; + } + + public String transform(String s) { + return prefix + s; + } + +} diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/RoleSource.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/RoleSource.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/RoleSource.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/RoleSource.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SSOActionRoleSource.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/SSOActionRoleSource.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SSOActionRoleSource.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/SSOActionRoleSource.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SSORoleRoleSource.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/SSORoleRoleSource.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SSORoleRoleSource.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/SSORoleRoleSource.java diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/StringTransformer.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/StringTransformer.java new file mode 100644 index 00000000..7cec6083 --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/StringTransformer.java @@ -0,0 +1,16 @@ +package com.payneteasy.superfly.security; + +/** + * Transforms a string. + * + * @author Roman Puchkovskiy + */ +public interface StringTransformer { + /** + * Does transformation. + * + * @param s string to transform + * @return transformed string + */ + String transform(String s); +} diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflyAuthenticationProvider.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/SuperflyAuthenticationProvider.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflyAuthenticationProvider.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/SuperflyAuthenticationProvider.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflyMockAuthenticationProvider.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/SuperflyMockAuthenticationProvider.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflyMockAuthenticationProvider.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/SuperflyMockAuthenticationProvider.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflyMultiMockAuthenticationProvider.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/SuperflyMultiMockAuthenticationProvider.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflyMultiMockAuthenticationProvider.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/SuperflyMultiMockAuthenticationProvider.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProvider.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProvider.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProvider.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProvider.java diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProvider.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProvider.java new file mode 100644 index 00000000..36d15fc6 --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProvider.java @@ -0,0 +1,58 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.api.SSOService; +import com.payneteasy.superfly.api.SSOUser; +import com.payneteasy.superfly.api.request.ExchangeSubsystemTokenRequest; +import com.payneteasy.superfly.security.authentication.SSOAuthenticationRequest; +import com.payneteasy.superfly.security.authentication.UsernamePasswordCheckedToken; +import lombok.Setter; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; + +/** + * {@link AuthenticationProvider} which uses Superfly Single Sign-on + * authentication to authenticate user. + * + * @author Roman Puchkovskiy + */ +@Setter +public class SuperflySSOAuthenticationProvider implements AuthenticationProvider { + + private SSOService ssoService; + + public SuperflySSOAuthenticationProvider() { + } + + public Authentication authenticate(Authentication authentication) + throws AuthenticationException { + if (authentication instanceof SSOAuthenticationRequest authRequest) { + if (authRequest.getSubsystemToken() == null) { + throw new BadCredentialsException("Null subsystem token"); + } + SSOUser ssoUser = ssoService.exchangeSubsystemToken( + new ExchangeSubsystemTokenRequest( + authRequest.getSubsystemToken() + ) + ); + if (ssoUser == null) { + throw new BadCredentialsException("Bad credentials"); + } + if (ssoUser.getActionsMap().isEmpty()) { + throw new BadCredentialsException("No roles"); + } + return createAuthentication(ssoUser); + } + return null; + } + + public boolean supports(Class authentication) { + return SSOAuthenticationRequest.class.isAssignableFrom(authentication); + } + + protected Authentication createAuthentication(SSOUser ssoUser) { + return new UsernamePasswordCheckedToken(ssoUser); + } + +} diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProvider.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProvider.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProvider.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProvider.java diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProvider.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProvider.java new file mode 100644 index 00000000..99f781c0 --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProvider.java @@ -0,0 +1,109 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.api.OTPType; +import com.payneteasy.superfly.api.SSOService; +import com.payneteasy.superfly.api.SSOUser; +import com.payneteasy.superfly.api.request.AuthenticateRequest; +import com.payneteasy.superfly.api.request.CheckOtpRequest; +import com.payneteasy.superfly.common.utils.StringUtils; +import com.payneteasy.superfly.security.authentication.OtpUsernamePasswordCheckedToken; +import com.payneteasy.superfly.security.authentication.UsernamePasswordAuthRequestInfoAuthenticationToken; +import com.payneteasy.superfly.security.authentication.UsernamePasswordCheckedToken; +import com.payneteasy.superfly.security.exception.BadOTPValueException; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; + +/** + * {@link AuthenticationProvider} which uses Superfly password authentication + * to authenticate user. + * + * @author Roman Puchkovskiy + */ +@Slf4j +public class SuperflyUsernamePasswordAuthenticationProvider implements AuthenticationProvider { + + @Setter + private SSOService ssoService; + + public SuperflyUsernamePasswordAuthenticationProvider() { + } + + public Authentication authenticate(Authentication authentication) + throws AuthenticationException { + if (authentication instanceof UsernamePasswordAuthRequestInfoAuthenticationToken authRequest) { + if (authRequest.getCredentials() == null) { + throw new BadCredentialsException("Null credentials"); + } + SSOUser ssoUser = ssoService.authenticate(new AuthenticateRequest( + authRequest.getName(), + authRequest.getCredentials().toString(), + authRequest.getAuthRequestInfo() + )); + if (ssoUser == null) { + throw new BadCredentialsException("Bad credentials"); + } + boolean otpIsRequired = !ssoUser.isOtpOptional(); + boolean receivedSecondFactory = StringUtils.hasText(authRequest.getSecondFactory()); + OTPType otpType = ssoUser.getOtpType(); + String ssoUserName = ssoUser.getName(); + + if (log.isDebugEnabled()) { + log.debug("Authenticated user: <{}>, with otp: <{} [optional: {}]>, actions size: <{}>", + ssoUserName, + otpType, + ssoUser.isOtpOptional(), + ssoUser.getActionCount() + ); + log.debug("User <{}> has second factory: <{}>", ssoUserName, authRequest.getSecondFactory()); + } + if (ssoUser.getActionsMap().isEmpty()) { + log.error("User <{}> has no roles", ssoUserName); + throw new BadCredentialsException("No roles"); + } + + if(otpIsRequired && otpType == OTPType.NONE) { + log.error("User <{}> has no OTP type", ssoUserName); + throw new BadCredentialsException("No OTP type for user: " + ssoUserName); + } + + if (otpIsRequired && !receivedSecondFactory) { + log.debug("User <{}> has otp type <{}> and is not optional, but no second factory provided", + ssoUserName, otpType + ); + return createOtpAuthentication(ssoUser); + } + + if (receivedSecondFactory && otpType != OTPType.NONE) { + log.debug("User <{}> has otp type <{}> and is optional, second factory provided", ssoUserName, otpType); + if (ssoService.checkOtp(new CheckOtpRequest(ssoUser, authRequest.getSecondFactory()))) { + return createAuthentication(ssoUser); + } else { + log.debug("User <{}> has otp type <{}> and is optional, but no second factory provided", + ssoUserName, otpType + ); + throw new BadOTPValueException("Otp check failed!"); + } + } + + return createAuthentication(ssoUser); + } + return null; + } + + public boolean supports(Class authentication) { + return UsernamePasswordAuthRequestInfoAuthenticationToken.class.isAssignableFrom(authentication); + } + + protected Authentication createAuthentication(SSOUser ssoUser) { + return new UsernamePasswordCheckedToken(ssoUser); + } + + protected Authentication createOtpAuthentication(SSOUser ssoUser) { + return new OtpUsernamePasswordCheckedToken(ssoUser); + } + +} diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/UppercaseTransformer.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/UppercaseTransformer.java new file mode 100644 index 00000000..44c02d9a --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/UppercaseTransformer.java @@ -0,0 +1,14 @@ +package com.payneteasy.superfly.security; + +/** + * Transforms a string to upper-case. + * + * @author Roman Puchkovskiy + */ +public class UppercaseTransformer implements StringTransformer { + + public String transform(String s) { + return s.toUpperCase(); + } + +} diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/CheckOTPToken.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/CheckOTPToken.java new file mode 100644 index 00000000..f47dc599 --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/CheckOTPToken.java @@ -0,0 +1,31 @@ +package com.payneteasy.superfly.security.authentication; + +import org.springframework.security.core.Authentication; + +import com.payneteasy.superfly.api.SSOUser; + +import java.io.Serial; + +/** + * {@link Authentication} which is a request to check an HOTP. + * + * @author Roman Puchkovskiy + */ +public class CheckOTPToken extends SSOUserTransportAuthenticationToken { + @Serial + private static final long serialVersionUID = 2290145086843797962L; + + private String otp; + + public CheckOTPToken(SSOUser ssoUser, String otp) { + super(ssoUser); + this.otp = otp; + } + + @Override + public Object getCredentials() { + return otp; + } + +} + diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/CompoundAuthentication.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/CompoundAuthentication.java new file mode 100644 index 00000000..51ad44ff --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/CompoundAuthentication.java @@ -0,0 +1,64 @@ +package com.payneteasy.superfly.security.authentication; + +import java.io.Serial; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.springframework.security.core.Authentication; + +/** + * Holds several authentication results and an authentication request. + * + * @author Roman Puchkovskiy + */ +public class CompoundAuthentication extends EmptyAuthenticationToken { + @Serial + private static final long serialVersionUID = 8781927069834292831L; + + private List readyAuthentications = new ArrayList(); + private final Authentication currentAuthenticationRequest; + + public CompoundAuthentication() { + this(null); + } + + public CompoundAuthentication(Authentication request) { + this(new Authentication[]{}, request); + } + + public CompoundAuthentication(Authentication[] readyAuthentications, + Authentication currentAuthenticationRequest) { + super(); + this.readyAuthentications = new ArrayList<>(Arrays.asList(readyAuthentications)); + this.currentAuthenticationRequest = currentAuthenticationRequest; + } + + public void addReadyAuthentication(Authentication auth) { + readyAuthentications.add(auth); + } + + public Authentication getCurrentAuthenticationRequest() { + return currentAuthenticationRequest; + } + + public Authentication[] getReadyAuthentications() { + return readyAuthentications.toArray(new Authentication[readyAuthentications.size()]); + } + + public Authentication getLatestReadyAuthentication() { + if (readyAuthentications.isEmpty()) { + return null; + } + return readyAuthentications.get(readyAuthentications.size() - 1); + } + + public Authentication getFirstReadyAuthentication() { + if (readyAuthentications.isEmpty()) { + return null; + } + return readyAuthentications.get(0); + } + +} + diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/EmptyAuthenticationToken.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/EmptyAuthenticationToken.java new file mode 100644 index 00000000..6a28a229 --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/EmptyAuthenticationToken.java @@ -0,0 +1,49 @@ +package com.payneteasy.superfly.security.authentication; + +import java.io.Serial; +import java.util.Collection; +import java.util.Collections; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; + +/** + * Authentication implementation which actually does not auth to anything but + * just used for something else (like value transport). + * + * @author Roman Puchkovskiy + */ +public class EmptyAuthenticationToken implements Authentication { + @Serial + private static final long serialVersionUID = 5670753128479212097L; + + public Collection getAuthorities() { + return Collections.emptySet(); + } + + public Object getCredentials() { + return null; + } + + public Object getDetails() { + return null; + } + + public Object getPrincipal() { + return null; + } + + public boolean isAuthenticated() { + return false; + } + + public void setAuthenticated(boolean isAuthenticated) + throws IllegalArgumentException { + } + + public String getName() { + return null; + } + +} + diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/FastAuthentication.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/FastAuthentication.java new file mode 100644 index 00000000..846ff127 --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/FastAuthentication.java @@ -0,0 +1,15 @@ +package com.payneteasy.superfly.security.authentication; + +import org.springframework.security.core.Authentication; + +/** + * Allows to implement some operations (like determining whether we have an + * authority) fast. + * + * @author Roman Puchkovskiy + * + */ +public interface FastAuthentication extends Authentication { + boolean hasAuthority(String name); +} + diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/OTPCheckedToken.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/OTPCheckedToken.java new file mode 100644 index 00000000..08cf51c2 --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/OTPCheckedToken.java @@ -0,0 +1,18 @@ +package com.payneteasy.superfly.security.authentication; + +import com.payneteasy.superfly.api.SSOUser; + +/** + * Token which contains info about a successful HOTP authentication. + * + * @author Roman Puchkovskiy + */ +public class OTPCheckedToken extends SSOUserTransportAuthenticationToken { + private static final long serialVersionUID = -7861332629416740520L; + + public OTPCheckedToken(SSOUser ssoUser) { + super(ssoUser); + } + +} + diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/OtpUsernamePasswordCheckedToken.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/OtpUsernamePasswordCheckedToken.java new file mode 100644 index 00000000..575a2582 --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/OtpUsernamePasswordCheckedToken.java @@ -0,0 +1,19 @@ +package com.payneteasy.superfly.security.authentication; + +import com.payneteasy.superfly.api.SSOUser; + +/** + * Results of a successful username+password without required OTP (for next step) authentication for Superfly. + * + * @author Igor Vasilyev + */ +public class OtpUsernamePasswordCheckedToken extends + SSOUserTransportAuthenticationToken { + private static final long serialVersionUID = -7096904519277638358L; + + public OtpUsernamePasswordCheckedToken(SSOUser ssoUser) { + super(ssoUser); + } + +} + diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/SSOAuthenticationRequest.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/SSOAuthenticationRequest.java new file mode 100644 index 00000000..22a22e57 --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/SSOAuthenticationRequest.java @@ -0,0 +1,24 @@ +package com.payneteasy.superfly.security.authentication; + +import org.springframework.security.core.Authentication; + +/** + * {@link Authentication} which is a request to check a Single Sign-on + * authentication. + * + * @author Roman Puchkovskiy + */ +public class SSOAuthenticationRequest extends EmptyAuthenticationToken { + private static final long serialVersionUID = 9204822041434318662L; + + private String subsystemToken; + + public SSOAuthenticationRequest(String subsystemToken) { + this.subsystemToken = subsystemToken; + } + + public String getSubsystemToken() { + return subsystemToken; + } +} + diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/SSOUserAndSelectedRoleAuthenticationToken.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/SSOUserAndSelectedRoleAuthenticationToken.java new file mode 100644 index 00000000..2bcc3379 --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/SSOUserAndSelectedRoleAuthenticationToken.java @@ -0,0 +1,33 @@ +package com.payneteasy.superfly.security.authentication; + +import com.payneteasy.superfly.api.SSORole; +import com.payneteasy.superfly.api.SSOUser; +import com.payneteasy.superfly.security.SuperflyAuthenticationProvider; +import lombok.Getter; + +import java.io.Serial; + +/** + * Authentication implementation which is used as an authentication request to + * SuperflyAuthenticationProvider on the final stage of authentication. + * + * @author Roman Puchkovskiy + * @see SuperflyAuthenticationProvider + */ +@Getter +public class SSOUserAndSelectedRoleAuthenticationToken extends EmptyAuthenticationToken { + @Serial + private static final long serialVersionUID = -3043969728081312772L; + + private final SSOUser ssoUser; + private final SSORole ssoRole; + + public SSOUserAndSelectedRoleAuthenticationToken(SSOUser ssoUser, + SSORole ssoRole) { + super(); + this.ssoUser = ssoUser; + this.ssoRole = ssoRole; + } + +} + diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/SSOUserAuthenticationToken.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/SSOUserAuthenticationToken.java new file mode 100644 index 00000000..0d50768e --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/SSOUserAuthenticationToken.java @@ -0,0 +1,104 @@ +package com.payneteasy.superfly.security.authentication; + +import com.payneteasy.superfly.api.SSORole; +import com.payneteasy.superfly.api.SSOUser; +import com.payneteasy.superfly.security.RoleSource; +import com.payneteasy.superfly.security.StringTransformer; +import com.payneteasy.superfly.security.SuperflyAuthenticationProvider; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.io.Serial; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +/** + * Authentication implementation which represents authentication result + * produced by SuperflyAuthenticationProvider on its last stage. + * + * @author Roman Puchkovskiy + * @see SuperflyAuthenticationProvider + */ +public class SSOUserAuthenticationToken implements FastAuthentication { + @Serial + private static final long serialVersionUID = -8426277290421059196L; + + @Getter + private final SSOUser user; + @Getter + private final SSORole role; + private final Object credentials; + private final Object details; + private final GrantedAuthority[] authorities; + private boolean authenticated; + private final Set authorityNames; + + public SSOUserAuthenticationToken(SSOUser user, SSORole role, + Object credentials, Object details, + StringTransformer[] transformers, RoleSource roleSource) { + this.user = user; + this.role = role; + this.credentials = credentials; + this.details = details; + + String[] roles = roleSource.getRoleNames(user, role); + this.authorities = new GrantedAuthority[roles.length]; + this.authorityNames = new HashSet(roles.length); + for (int i = 0; i < roles.length; i++) { + String name = roles[i]; + for (StringTransformer transformer : transformers) { + name = transformer.transform(name); + } + this.authorities[i] = new SimpleGrantedAuthority(name); + this.authorityNames.add(name); + } + + this.authenticated = true; + } + + @Override + public Collection getAuthorities() { + return Arrays.asList(authorities); + } + + @Override + public Object getCredentials() { + return credentials; + } + + @Override + public Object getDetails() { + return details; + } + + @Override + public Object getPrincipal() { + return user; + } + + @Override + public boolean isAuthenticated() { + return authenticated; + } + + @Override + public void setAuthenticated(boolean isAuthenticated) + throws IllegalArgumentException { + this.authenticated = isAuthenticated; + } + + @Override + public String getName() { + return user.getName(); + } + + @Override + public boolean hasAuthority(String name) { + return authorityNames.contains(name); + } + +} + diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/SSOUserTransportAuthenticationToken.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/SSOUserTransportAuthenticationToken.java new file mode 100644 index 00000000..b263f5ca --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/SSOUserTransportAuthenticationToken.java @@ -0,0 +1,35 @@ +package com.payneteasy.superfly.security.authentication; + +import com.payneteasy.superfly.api.SSOUser; +import lombok.Getter; + +import java.io.Serial; + +/** + * Authentication implementation which is actually used to transport SSOUser + * only. + * This is used to store SSOUser temporarily in a SecurityContext between + * steps of two-step authentication process. + * + * @author Roman Puchkovskiy + */ +@Getter +public class SSOUserTransportAuthenticationToken extends EmptyAuthenticationToken { + @Serial + private static final long serialVersionUID = -5726837452086192434L; + + public static final String SESSION_KEY = "superfly-sso-user-transport-token"; + + private final SSOUser ssoUser; + + public SSOUserTransportAuthenticationToken(SSOUser ssoUser) { + this.ssoUser = ssoUser; + } + + @Override + public String getName() { + return getSsoUser().getName(); + } + +} + diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/UsernamePasswordAuthRequestInfoAuthenticationToken.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/UsernamePasswordAuthRequestInfoAuthenticationToken.java new file mode 100644 index 00000000..1e0f1368 --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/UsernamePasswordAuthRequestInfoAuthenticationToken.java @@ -0,0 +1,38 @@ +package com.payneteasy.superfly.security.authentication; + +import com.payneteasy.superfly.api.AuthenticationRequestInfo; +import lombok.Getter; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; + +import java.io.Serial; + +/** + * Extension of UsernamePasswordAuthenticationToken which also allows to + * transfer AuthenticationRequestInfo instance. + * + * @author Roman Puchkovskiy + */ +@Getter +public class UsernamePasswordAuthRequestInfoAuthenticationToken extends + UsernamePasswordAuthenticationToken { + @Serial + private static final long serialVersionUID = 9204822041434318662L; + + private final AuthenticationRequestInfo authRequestInfo; + private String secondFactory; + + public UsernamePasswordAuthRequestInfoAuthenticationToken(Object principal, + Object credentials, + AuthenticationRequestInfo authRequestInfo + ) { + super(principal, credentials); + this.authRequestInfo = authRequestInfo; + } + + public UsernamePasswordAuthRequestInfoAuthenticationToken withSecondFactory(String secondFactory) { + this.secondFactory = secondFactory; + return this; + } + +} + diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/UsernamePasswordCheckedToken.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/UsernamePasswordCheckedToken.java new file mode 100644 index 00000000..7f80c4dd --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/authentication/UsernamePasswordCheckedToken.java @@ -0,0 +1,19 @@ +package com.payneteasy.superfly.security.authentication; + +import com.payneteasy.superfly.api.SSOUser; + +/** + * Results of a successful username+password authentication for Superfly. + * + * @author Roman Puchkovskiy + */ +public class UsernamePasswordCheckedToken extends + SSOUserTransportAuthenticationToken { + private static final long serialVersionUID = -7096904519277638358L; + + public UsernamePasswordCheckedToken(SSOUser ssoUser) { + super(ssoUser); + } + +} + diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/exception/AuthenticationCarrier.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/exception/AuthenticationCarrier.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/exception/AuthenticationCarrier.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/exception/AuthenticationCarrier.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/exception/AuthenticationCarryingException.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/exception/AuthenticationCarryingException.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/exception/AuthenticationCarryingException.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/exception/AuthenticationCarryingException.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/exception/BadOTPValueException.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/exception/BadOTPValueException.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/exception/BadOTPValueException.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/exception/BadOTPValueException.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/exception/CsrfLoginTokenException.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/exception/CsrfLoginTokenException.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/exception/CsrfLoginTokenException.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/exception/CsrfLoginTokenException.java diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/exception/InsufficientAuthenticationException.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/exception/InsufficientAuthenticationException.java new file mode 100644 index 00000000..678df587 --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/exception/InsufficientAuthenticationException.java @@ -0,0 +1,23 @@ +package com.payneteasy.superfly.security.exception; + +/** + * This is a replacement for + * {@link org.springframework.security.authentication.InsufficientAuthenticationException} + * which cannot carry Authentication instances in recent + * Spring Security versions. + * + * Note: this exception class carries an + * {@link org.springframework.security.core.Authentication} instance, + * but this is safe here. + * + * @author rpuch + */ +public class InsufficientAuthenticationException extends AuthenticationCarryingException { + public InsufficientAuthenticationException(String message, Throwable cause) { + super(message, cause); + } + + public InsufficientAuthenticationException(String message) { + super(message); + } +} diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/exception/PreconditionsException.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/exception/PreconditionsException.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/exception/PreconditionsException.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/exception/PreconditionsException.java diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/exception/StepTwoException.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/exception/StepTwoException.java new file mode 100644 index 00000000..529365de --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/exception/StepTwoException.java @@ -0,0 +1,23 @@ +package com.payneteasy.superfly.security.exception; + +/** + * Thrown to signal that step 2 of two-step authentication process + * must be initiated. + * Note: this exception class carries an + * {@link org.springframework.security.core.Authentication} instance, + * but this is safe here. + * + * @author Roman Puchkovskiy + */ +public class StepTwoException extends AuthenticationCarryingException { + private static final long serialVersionUID = -5091470653099959408L; + + public StepTwoException(String msg) { + super(msg); + } + + public StepTwoException(String msg, Throwable t) { + super(msg, t); + } + +} diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/mapbuilder/ActionsMapBuilder.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/mapbuilder/ActionsMapBuilder.java new file mode 100644 index 00000000..57066fb5 --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/mapbuilder/ActionsMapBuilder.java @@ -0,0 +1,16 @@ +package com.payneteasy.superfly.security.mapbuilder; + +import java.util.Map; + +import com.payneteasy.superfly.api.SSOAction; +import com.payneteasy.superfly.api.SSORole; + +/** + * Interface for building a mapping from an SSORole to SSOAction's. Mainly + * used by {@link com.payneteasy.superfly.security.SuperflyMockAuthenticationProvider}. + * + * @author Roman Puchkovskiy + */ +public interface ActionsMapBuilder { + Map build() throws Exception; +} diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/mapbuilder/ActionsSource.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/mapbuilder/ActionsSource.java new file mode 100644 index 00000000..d2eb8b74 --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/mapbuilder/ActionsSource.java @@ -0,0 +1,12 @@ +package com.payneteasy.superfly.security.mapbuilder; + +import com.payneteasy.superfly.api.SSOAction; + +/** + * Obtains somehow an array of actions and returns them. + * + * @author Roman Puchkovskiy + */ +public interface ActionsSource { + SSOAction[] getActions() throws Exception; +} diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/mapbuilder/CollectingActionsSource.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/mapbuilder/CollectingActionsSource.java new file mode 100644 index 00000000..f563761d --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/mapbuilder/CollectingActionsSource.java @@ -0,0 +1,30 @@ +package com.payneteasy.superfly.security.mapbuilder; + +import java.util.List; + +import lombok.Setter; + +import com.payneteasy.superfly.api.ActionDescription; +import com.payneteasy.superfly.api.SSOAction; +import com.payneteasy.superfly.client.ActionDescriptionCollector; + +/** + * Obtains actions from {@link ActionDescriptionCollector}. + * + * @author Roman Puchkovskiy + */ +@Setter +public class CollectingActionsSource implements ActionsSource { + + private ActionDescriptionCollector actionDescriptionCollector; + + public SSOAction[] getActions() throws Exception { + List descriptions = actionDescriptionCollector.collect(); + SSOAction[] actions = new SSOAction[descriptions.size()]; + for (int i = 0; i < descriptions.size(); i++) { + actions[i] = new SSOAction(descriptions.get(i).getName(), false); + } + return actions; + } + +} diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/mapbuilder/ResourceActionsSource.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/mapbuilder/ResourceActionsSource.java new file mode 100644 index 00000000..dce2f3f1 --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/mapbuilder/ResourceActionsSource.java @@ -0,0 +1,40 @@ +package com.payneteasy.superfly.security.mapbuilder; + +import com.payneteasy.superfly.api.SSOAction; +import lombok.AllArgsConstructor; +import org.springframework.core.io.Resource; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; +import java.util.Scanner; + +/** + * {@link ActionsSource} which takes actions from a file. + * + * @author Roman Puchkovskiy + */ +@AllArgsConstructor +public class ResourceActionsSource implements ActionsSource { + + private final Resource resource; + private final Charset charset; + + public SSOAction[] getActions() throws Exception { + InputStream is = resource.getInputStream(); + Reader reader = new InputStreamReader(is, charset); + List actionsList = new ArrayList<>(); + + try (Scanner scanner = new Scanner(reader)) { + while (scanner.hasNext()) { + String token = scanner.next(); + actionsList.add(new SSOAction(token, false)); + } + } + + return actionsList.toArray(new SSOAction[0]); + } +} diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/mapbuilder/SeparateActionsMapBuilder.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/mapbuilder/SeparateActionsMapBuilder.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/mapbuilder/SeparateActionsMapBuilder.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/mapbuilder/SeparateActionsMapBuilder.java diff --git a/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/processor/AuthenticationPostProcessor.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/processor/AuthenticationPostProcessor.java new file mode 100644 index 00000000..583c96ae --- /dev/null +++ b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/processor/AuthenticationPostProcessor.java @@ -0,0 +1,18 @@ +package com.payneteasy.superfly.security.processor; + +import org.springframework.security.core.Authentication; + +/** + * Post-processes authentication. + * + * @author Roman Puchkovskiy + */ +public interface AuthenticationPostProcessor { + /** + * Post-processes authentication. + * + * @param auth auth to process + * @return processing result + */ + Authentication postProcess(Authentication auth); +} diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/processor/CompoundLatestAuthUnwrappingPostProcessor.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/processor/CompoundLatestAuthUnwrappingPostProcessor.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/processor/CompoundLatestAuthUnwrappingPostProcessor.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/processor/CompoundLatestAuthUnwrappingPostProcessor.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/processor/IdAuthenticationPostProcessor.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/processor/IdAuthenticationPostProcessor.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/processor/IdAuthenticationPostProcessor.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/processor/IdAuthenticationPostProcessor.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/processor/SSOUserShortCircuitingPostProcessor.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/processor/SSOUserShortCircuitingPostProcessor.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/processor/SSOUserShortCircuitingPostProcessor.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/processor/SSOUserShortCircuitingPostProcessor.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/session/ISessionAccessor.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/session/ISessionAccessor.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/session/ISessionAccessor.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/session/ISessionAccessor.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/session/ServletSessionAccessor.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/session/ServletSessionAccessor.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/session/ServletSessionAccessor.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/session/ServletSessionAccessor.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/validator/AuthenticationValidator.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/validator/AuthenticationValidator.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/validator/AuthenticationValidator.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/validator/AuthenticationValidator.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/validator/CompoundAuthenticationValidator.java b/superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/validator/CompoundAuthenticationValidator.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/validator/CompoundAuthenticationValidator.java rename to superfly-spring-security-core/src/main/java/com/payneteasy/superfly/security/validator/CompoundAuthenticationValidator.java diff --git a/superfly-spring-security/pom.xml b/superfly-spring-security-ee10/pom.xml similarity index 85% rename from superfly-spring-security/pom.xml rename to superfly-spring-security-ee10/pom.xml index 9eb4c6d1..ed828693 100644 --- a/superfly-spring-security/pom.xml +++ b/superfly-spring-security-ee10/pom.xml @@ -5,9 +5,9 @@ 2.0-3-SNAPSHOT 4.0.0 - superfly-spring-security - Spring Security Extension for Superfly - Module for Spring Security which enables application to use Superfly authentication/authorization declaratively through Spring Security + superfly-spring-security-ee10 + Spring Security Extension for Superfly (EE10) + Module for Spring Security which enables application to use Superfly authentication/authorization declaratively through Spring Security (Jakarta EE10) @@ -21,9 +21,14 @@ superfly-common - + com.payneteasy.superfly - superfly-client + superfly-client-ee10 + + + + com.payneteasy.superfly + superfly-client-core diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/AbstractDisableableAuthenticationProvider.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/AbstractDisableableAuthenticationProvider.java new file mode 100644 index 00000000..f0c8e585 --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/AbstractDisableableAuthenticationProvider.java @@ -0,0 +1,42 @@ +package com.payneteasy.superfly.security; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; + +/** + * Base for {@link AuthenticationProvider} which may be disabled. + * + * @author Roman Puchkovskiy + */ +public abstract class AbstractDisableableAuthenticationProvider implements AuthenticationProvider { + private boolean enabled = true; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public final Authentication authenticate(Authentication authentication) + throws AuthenticationException { + if (!isEnabled()) { + return null; + } + return doAuthenticate(authentication); + } + + public final boolean supports(Class authentication) { + if (!isEnabled()) { + return false; + } + return doSupports(authentication); + } + + protected abstract Authentication doAuthenticate(Authentication authentication); + + protected abstract boolean doSupports(Class authentication); + +} diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/AbstractRoleTransformingAuthenticationProvider.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/AbstractRoleTransformingAuthenticationProvider.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/AbstractRoleTransformingAuthenticationProvider.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/AbstractRoleTransformingAuthenticationProvider.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/AbstractSingleStepAuthenticationProcessingFilter.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/AbstractSingleStepAuthenticationProcessingFilter.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/AbstractSingleStepAuthenticationProcessingFilter.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/AbstractSingleStepAuthenticationProcessingFilter.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/AbstractSuperflyAuthenticationProvider.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/AbstractSuperflyAuthenticationProvider.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/AbstractSuperflyAuthenticationProvider.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/AbstractSuperflyAuthenticationProvider.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/CompoundAuthenticationProvider.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/CompoundAuthenticationProvider.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/CompoundAuthenticationProvider.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/CompoundAuthenticationProvider.java diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/CompoundRoleSource.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/CompoundRoleSource.java new file mode 100644 index 00000000..06113898 --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/CompoundRoleSource.java @@ -0,0 +1,28 @@ +package com.payneteasy.superfly.security; + +import java.util.ArrayList; +import java.util.List; + +import com.payneteasy.superfly.api.SSORole; +import com.payneteasy.superfly.api.SSOUser; + +public class CompoundRoleSource implements RoleSource { + + private RoleSource[] roleSources; + + public CompoundRoleSource(RoleSource[] roleSources) { + this.roleSources = roleSources; + } + + public String[] getRoleNames(SSOUser ssoUser, SSORole ssoRole) { + List resultList = new ArrayList(); + for (RoleSource roleSource : roleSources) { + String[] actionNames = roleSource.getRoleNames(ssoUser, ssoRole); + for (String actionName : actionNames) { + resultList.add(actionName); + } + } + return resultList.toArray(new String[resultList.size()]); + } + +} diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/DelegatingDecisionManager.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/DelegatingDecisionManager.java new file mode 100644 index 00000000..516a8ff7 --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/DelegatingDecisionManager.java @@ -0,0 +1,44 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.security.exception.InsufficientAuthenticationException; +import org.springframework.security.access.AccessDecisionManager; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.ConfigAttribute; +import org.springframework.security.core.Authentication; + +import java.util.Collection; + +public class DelegatingDecisionManager implements AccessDecisionManager { + + private AccessDecisionManager delegate; + + public DelegatingDecisionManager() { + } + + public DelegatingDecisionManager(AccessDecisionManager delegate) { + this.delegate = delegate; + } + + public void setDelegate(AccessDecisionManager delegate) { + this.delegate = delegate; + } + + protected AccessDecisionManager getDelegate() { + return delegate; + } + + public void decide(Authentication authentication, Object object, + Collection configAttributes) + throws AccessDeniedException, InsufficientAuthenticationException { + delegate.decide(authentication, object, configAttributes); + } + + public boolean supports(ConfigAttribute attribute) { + return delegate.supports(attribute); + } + + public boolean supports(Class clazz) { + return delegate.supports(clazz); + } + +} diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/InsufficientAuthenticationAccessDecisionManager.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/InsufficientAuthenticationAccessDecisionManager.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/InsufficientAuthenticationAccessDecisionManager.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/InsufficientAuthenticationAccessDecisionManager.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/InsufficientAuthenticationHandlingFilter.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/InsufficientAuthenticationHandlingFilter.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/InsufficientAuthenticationHandlingFilter.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/InsufficientAuthenticationHandlingFilter.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/MultiStepLoginUrlAuthenticationEntryPoint.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/MultiStepLoginUrlAuthenticationEntryPoint.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/MultiStepLoginUrlAuthenticationEntryPoint.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/MultiStepLoginUrlAuthenticationEntryPoint.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/PrependingTransformer.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/PrependingTransformer.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/PrependingTransformer.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/PrependingTransformer.java diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/RoleSource.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/RoleSource.java new file mode 100644 index 00000000..3d30183c --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/RoleSource.java @@ -0,0 +1,8 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.api.SSORole; +import com.payneteasy.superfly.api.SSOUser; + +public interface RoleSource { + String[] getRoleNames(SSOUser ssoUser, SSORole ssoRole); +} diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SSOActionRoleSource.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SSOActionRoleSource.java new file mode 100644 index 00000000..d4eb5ee0 --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SSOActionRoleSource.java @@ -0,0 +1,18 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.api.SSOAction; +import com.payneteasy.superfly.api.SSORole; +import com.payneteasy.superfly.api.SSOUser; + +public class SSOActionRoleSource implements RoleSource { + + public String[] getRoleNames(SSOUser ssoUser, SSORole ssoRole) { + SSOAction[] actions = ssoUser.getActionsMap().get(ssoRole); + String[] result = new String[actions.length]; + for (int i = 0; i < actions.length; i++) { + result[i] = actions[i].getName(); + } + return result; + } + +} diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SSORoleRoleSource.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SSORoleRoleSource.java new file mode 100644 index 00000000..4ff2684a --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SSORoleRoleSource.java @@ -0,0 +1,14 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.api.SSORole; +import com.payneteasy.superfly.api.SSOUser; + +public class SSORoleRoleSource implements RoleSource { + + public String[] getRoleNames(SSOUser ssoUser, SSORole ssoRole) { + String[] result = new String[1]; + result[0] = ssoRole.getName(); + return result; + } + +} diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SSOUserSessionBindFilter.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SSOUserSessionBindFilter.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SSOUserSessionBindFilter.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SSOUserSessionBindFilter.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/StringTransformer.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/StringTransformer.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/StringTransformer.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/StringTransformer.java diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflyAuthenticationProvider.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflyAuthenticationProvider.java new file mode 100644 index 00000000..7c62885d --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflyAuthenticationProvider.java @@ -0,0 +1,44 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.api.SSOService; +import com.payneteasy.superfly.api.SSOUser; +import com.payneteasy.superfly.api.request.AuthenticateRequest; +import com.payneteasy.superfly.security.authentication.UsernamePasswordAuthRequestInfoAuthenticationToken; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * Authentication provider which is able to authenticate against the following + * pair: . + * + * @author Roman Puchkovskiy + */ +@Component +public class SuperflyAuthenticationProvider extends AbstractSuperflyAuthenticationProvider { + + private SSOService ssoService; + + public SuperflyAuthenticationProvider() { + } + + @Autowired + public void setSsoService(SSOService ssoService) { + this.ssoService = ssoService; + } + + @Override + protected SSOUser doAuthenticate( + UsernamePasswordAuthRequestInfoAuthenticationToken authRequest, + String username, + String password + ) { + return ssoService.authenticate( + AuthenticateRequest + .builder() + .username(username) + .password(password) + .authRequestInfo(authRequest.getAuthRequestInfo()) + .build() + ); + } +} diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflyMockAuthenticationProvider.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflyMockAuthenticationProvider.java new file mode 100644 index 00000000..44a92b97 --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflyMockAuthenticationProvider.java @@ -0,0 +1,72 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.api.SSOAction; +import com.payneteasy.superfly.api.SSORole; +import com.payneteasy.superfly.api.SSOUser; +import com.payneteasy.superfly.security.authentication.UsernamePasswordAuthRequestInfoAuthenticationToken; +import com.payneteasy.superfly.security.mapbuilder.ActionsMapBuilder; +import lombok.Setter; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.BadCredentialsException; + +import java.util.Collections; +import java.util.Map; + +/** + * Authentication provider which does not use Superfly server at all. It may + * be useful for fast development. + * + * @author Roman Puchkovskiy + */ +public class SuperflyMockAuthenticationProvider extends AbstractSuperflyAuthenticationProvider { + + @Setter + private String username; + @Setter + private String password; + @Setter + private ActionsMapBuilder actionsMapBuilder; + + @Setter + private Map cachedActionsMap; + + public SuperflyMockAuthenticationProvider() { + } + + public SuperflyMockAuthenticationProvider(String username, String password, ActionsMapBuilder actionsMapBuilder) { + this.username = username; + this.password = password; + this.actionsMapBuilder = actionsMapBuilder; + } + + @Override + protected SSOUser doAuthenticate( + UsernamePasswordAuthRequestInfoAuthenticationToken authRequest, + String username, + String password + ) { + boolean ok = this.username.equals(username) && this.password.equals(password); + + if (!ok) { + throw new BadCredentialsException("Bad password"); + } + + try { + return new SSOUser(username, buildActionsMap(), buildPreferences()); + } catch (Exception e) { + throw new AuthenticationServiceException("Cannot collect action descriptions", e); + } + } + + protected Map buildActionsMap() throws Exception { + if (cachedActionsMap == null) { + cachedActionsMap = actionsMapBuilder.build(); + } + return cachedActionsMap; + } + + protected Map buildPreferences() { + return Collections.emptyMap(); + } + +} diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflyMultiMockAuthenticationProvider.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflyMultiMockAuthenticationProvider.java new file mode 100644 index 00000000..abdc1107 --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflyMultiMockAuthenticationProvider.java @@ -0,0 +1,127 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.api.SSOAction; +import com.payneteasy.superfly.api.SSORole; +import com.payneteasy.superfly.api.SSOUser; +import com.payneteasy.superfly.security.authentication.*; +import com.payneteasy.superfly.security.exception.BadOTPValueException; +import com.payneteasy.superfly.security.mapbuilder.ActionsMapBuilder; +import com.payneteasy.superfly.security.processor.AuthenticationPostProcessor; +import com.payneteasy.superfly.security.processor.IdAuthenticationPostProcessor; +import lombok.Setter; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * {@link AuthenticationProvider} which may be used for rapid development using + * local mode (with no interaction with Superfly server). + * + * @author Roman Puchkovskiy + */ +public class SuperflyMultiMockAuthenticationProvider extends + AbstractRoleTransformingAuthenticationProvider { + + private final Map usernamesToPasswords = new HashMap<>(); + @Setter + private String hotp; + @Setter + private ActionsMapBuilder actionsMapBuilder; + @Setter + private boolean enabled = true; + @Setter + private AuthenticationPostProcessor authenticationPostProcessor = new IdAuthenticationPostProcessor(); + + private Map cachedMap = null; + + public void addUsernameAndPassword(String username, String password) { + usernamesToPasswords.put(username, password); + } + + public Authentication authenticate(Authentication authentication) + throws AuthenticationException { + if (enabled) { + Authentication auth; + CompoundAuthentication compound = null; + if (authentication instanceof CompoundAuthentication) { + compound = (CompoundAuthentication) authentication; + auth = compound.getCurrentAuthenticationRequest(); + } else { + auth = authentication; + } + if (auth instanceof UsernamePasswordAuthRequestInfoAuthenticationToken) { + return processUsernamePasswordAuth(auth); + } else if (auth instanceof CheckOTPToken token) { + if (hotp.equals(token.getCredentials())) { + if (token.getSsoUser().getActionsMap().size() == 1) { + return new SSOUserAuthenticationToken(token.getSsoUser(), token.getSsoUser().getActionsMap().keySet().iterator().next(), + token.getCredentials(), token.getDetails(), roleNameTransformers, roleSource); + } + if (compound == null) { + throw new IllegalStateException("CompoundAuthentication cannot be null here"); + } + CompoundAuthentication newCompound = new CompoundAuthentication(compound.getReadyAuthentications(), auth); + newCompound.addReadyAuthentication(new OTPCheckedToken(token.getSsoUser())); + return newCompound; + } else { + throw new BadOTPValueException("Bad HOTP"); + } + } else if (auth instanceof SSOUserAndSelectedRoleAuthenticationToken token) { + final SSOUserAuthenticationToken ssoUserAuthenticationToken = new SSOUserAuthenticationToken( + token.getSsoUser(), token.getSsoRole(), + token.getCredentials(), token.getDetails(), roleNameTransformers, roleSource); + return authenticationPostProcessor.postProcess(ssoUserAuthenticationToken); + } + } + return null; + } + + protected Authentication processUsernamePasswordAuth(Authentication auth) { + String username = auth.getName(); + String password = auth.getCredentials() == null ? null : auth.getCredentials().toString(); + if (checkUsernamePassword(username, password)) { + CompoundAuthentication newCompound = new CompoundAuthentication(); + newCompound.addReadyAuthentication(new UsernamePasswordCheckedToken(createSSOUser(username))); + return newCompound; + } else { + throw new BadCredentialsException("Bad username/password"); + } + } + + protected boolean checkUsernamePassword(String username, String password) { + return Objects.equals(usernamesToPasswords.get(username), password); + } + + protected SSOUser createSSOUser(String username) { + return new SSOUser(username, getActionsMap(username), Collections.emptyMap()); + } + + protected Map getActionsMap(String username) { + if (cachedMap == null) { + try { + cachedMap = actionsMapBuilder.build(); + } catch (Exception e) { + throw new AuthenticationServiceException("Could not obtain roles and actions", e); + } + } + return cachedMap; + } + + public boolean supports(Class authentication) { + if (!enabled) { + return false; + } + return (UsernamePasswordAuthRequestInfoAuthenticationToken.class.isAssignableFrom(authentication) + || CheckOTPToken.class.isAssignableFrom(authentication) + || SSOUserAndSelectedRoleAuthenticationToken.class.isAssignableFrom(authentication) + || CompoundAuthentication.class.isAssignableFrom(authentication)); + } + +} diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProcessingFilter.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProcessingFilter.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProcessingFilter.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProcessingFilter.java diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProvider.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProvider.java new file mode 100644 index 00000000..2eac4055 --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProvider.java @@ -0,0 +1,67 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.api.SSOService; +import com.payneteasy.superfly.api.SSOUser; +import com.payneteasy.superfly.api.exceptions.SsoDecryptException; +import com.payneteasy.superfly.api.request.CheckOtpRequest; +import com.payneteasy.superfly.security.authentication.CheckOTPToken; +import com.payneteasy.superfly.security.authentication.OTPCheckedToken; +import com.payneteasy.superfly.security.exception.BadOTPValueException; +import lombok.Setter; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; + +/** + * {@link AuthenticationProvider} which authenticates using the Superfly HOTP. + * + * @author Igor Vasilyev + */ +public class SuperflyOTPAuthenticationProvider implements AuthenticationProvider { + + @Setter + private SSOService ssoService; + private Class supportedAuthenticationClass = CheckOTPToken.class; + + public void setSupportedAuthenticationClass(Class clazz) { + this.supportedAuthenticationClass = clazz; + } + + public SuperflyOTPAuthenticationProvider() { + } + + public Authentication authenticate(Authentication authentication) + throws AuthenticationException { + Authentication result = null; + if (authentication instanceof CheckOTPToken authRequest) { + if (authRequest.getCredentials() == null) { + throw new BadOTPValueException("Null OTP secret"); + } + boolean ok; + try { + ok = ssoService.checkOtp( + new CheckOtpRequest( + authRequest.getSsoUser(), + authRequest.getCredentials().toString() + ) + ); + } catch (SsoDecryptException e) { + throw new BadOTPValueException(e.getMessage()); + } + if (!ok) { + throw new BadOTPValueException("Invalid OTP secret"); + } + result = createAuthentication(authRequest.getSsoUser()); + } + return result; + } + + public boolean supports(Class authentication) { + return supportedAuthenticationClass.isAssignableFrom(authentication); + } + + protected Authentication createAuthentication(SSOUser ssoUser) { + return new OTPCheckedToken(ssoUser); + } + +} diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProcessingFilter.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProcessingFilter.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProcessingFilter.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProcessingFilter.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProvider.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProvider.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProvider.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProvider.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProcessingFilter.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProcessingFilter.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProcessingFilter.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProcessingFilter.java diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProvider.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProvider.java new file mode 100644 index 00000000..50e00f17 --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProvider.java @@ -0,0 +1,36 @@ +package com.payneteasy.superfly.security; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; + +import com.payneteasy.superfly.security.authentication.SSOUserAndSelectedRoleAuthenticationToken; +import com.payneteasy.superfly.security.authentication.SSOUserAuthenticationToken; + +/** + * {@link AuthenticationProvider} which does not authenticate anything but + * just creates {@link SSOUserAuthenticationToken} based on a role selected by + * the user. + * + * @author Roman Puchkovskiy + */ +public class SuperflySelectRoleAuthenticationProvider extends AbstractRoleTransformingAuthenticationProvider { + + public SuperflySelectRoleAuthenticationProvider() { + } + + public Authentication authenticate(Authentication authentication) + throws AuthenticationException { + Authentication result = null; + if (authentication instanceof SSOUserAndSelectedRoleAuthenticationToken) { + SSOUserAndSelectedRoleAuthenticationToken token = (SSOUserAndSelectedRoleAuthenticationToken) authentication; + return new SSOUserAuthenticationToken(token.getSsoUser(), token.getSsoRole(), + token.getCredentials(), token.getDetails(), roleNameTransformers, roleSource); + } + return result; + } + + public boolean supports(Class authentication) { + return SSOUserAndSelectedRoleAuthenticationToken.class.isAssignableFrom(authentication); + } +} diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProcessingFilter.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProcessingFilter.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProcessingFilter.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProcessingFilter.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProvider.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProvider.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProvider.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProvider.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/TwoStepAuthenticationProcessingFilter.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/TwoStepAuthenticationProcessingFilter.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/TwoStepAuthenticationProcessingFilter.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/TwoStepAuthenticationProcessingFilter.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/TwoStepAuthenticationProcessingFilterEntryPoint.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/TwoStepAuthenticationProcessingFilterEntryPoint.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/TwoStepAuthenticationProcessingFilterEntryPoint.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/TwoStepAuthenticationProcessingFilterEntryPoint.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/UppercaseTransformer.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/UppercaseTransformer.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/UppercaseTransformer.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/UppercaseTransformer.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/CheckOTPToken.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/CheckOTPToken.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/CheckOTPToken.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/CheckOTPToken.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/CompoundAuthentication.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/CompoundAuthentication.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/CompoundAuthentication.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/CompoundAuthentication.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/EmptyAuthenticationToken.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/EmptyAuthenticationToken.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/EmptyAuthenticationToken.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/EmptyAuthenticationToken.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/FastAuthentication.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/FastAuthentication.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/FastAuthentication.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/FastAuthentication.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/OTPCheckedToken.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/OTPCheckedToken.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/OTPCheckedToken.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/OTPCheckedToken.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/OtpUsernamePasswordCheckedToken.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/OtpUsernamePasswordCheckedToken.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/OtpUsernamePasswordCheckedToken.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/OtpUsernamePasswordCheckedToken.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/SSOAuthenticationRequest.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/SSOAuthenticationRequest.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/SSOAuthenticationRequest.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/SSOAuthenticationRequest.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/SSOUserAndSelectedRoleAuthenticationToken.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/SSOUserAndSelectedRoleAuthenticationToken.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/SSOUserAndSelectedRoleAuthenticationToken.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/SSOUserAndSelectedRoleAuthenticationToken.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/SSOUserAuthenticationToken.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/SSOUserAuthenticationToken.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/SSOUserAuthenticationToken.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/SSOUserAuthenticationToken.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/SSOUserTransportAuthenticationToken.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/SSOUserTransportAuthenticationToken.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/SSOUserTransportAuthenticationToken.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/SSOUserTransportAuthenticationToken.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/UsernamePasswordAuthRequestInfoAuthenticationToken.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/UsernamePasswordAuthRequestInfoAuthenticationToken.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/UsernamePasswordAuthRequestInfoAuthenticationToken.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/UsernamePasswordAuthRequestInfoAuthenticationToken.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/UsernamePasswordCheckedToken.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/UsernamePasswordCheckedToken.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/authentication/UsernamePasswordCheckedToken.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/authentication/UsernamePasswordCheckedToken.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/csrf/CsrfValidator.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/csrf/CsrfValidator.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/csrf/CsrfValidator.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/csrf/CsrfValidator.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/csrf/CsrfValidatorImpl.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/csrf/CsrfValidatorImpl.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/csrf/CsrfValidatorImpl.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/csrf/CsrfValidatorImpl.java diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/exception/AuthenticationCarrier.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/exception/AuthenticationCarrier.java new file mode 100644 index 00000000..aa4afd1d --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/exception/AuthenticationCarrier.java @@ -0,0 +1,12 @@ +package com.payneteasy.superfly.security.exception; + +import org.springframework.security.core.Authentication; + +/** + * Carries {@link Authentication}. + * + * @author rpuch + */ +public interface AuthenticationCarrier { + Authentication getAuthentication(); +} diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/exception/AuthenticationCarryingException.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/exception/AuthenticationCarryingException.java new file mode 100644 index 00000000..58f4be63 --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/exception/AuthenticationCarryingException.java @@ -0,0 +1,37 @@ +package com.payneteasy.superfly.security.exception; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; + +/** + * This is added because we need to transport Authentication + * instance in our multi-step login flows. + * In Spring Security (about version 4), Authentication + * was removed from AuthenticationException to avoid data leakage. + * We only use this for two classes: {@link StepTwoException} and + * {@link InsufficientAuthenticationException} for which leakage + * does not make any harm. + * + * @author rpuch + */ +abstract class AuthenticationCarryingException extends AuthenticationException + implements AuthenticationCarrier { + private Authentication authentication; + + protected AuthenticationCarryingException(String message, Throwable cause) { + super(message, cause); + } + + protected AuthenticationCarryingException(String message) { + super(message); + } + + @Override + public Authentication getAuthentication() { + return authentication; + } + + public void setAuthentication(Authentication authentication) { + this.authentication = authentication; + } +} diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/exception/BadOTPValueException.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/exception/BadOTPValueException.java new file mode 100644 index 00000000..2b229839 --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/exception/BadOTPValueException.java @@ -0,0 +1,21 @@ +package com.payneteasy.superfly.security.exception; + +import org.springframework.security.authentication.BadCredentialsException; + +/** + * Exception that is thrown when a one-time password check fails. + * + * @author Roman Puchkovskiy + */ +public class BadOTPValueException extends BadCredentialsException { + private static final long serialVersionUID = 7345118080712359843L; + + public BadOTPValueException(String msg, Throwable t) { + super(msg, t); + } + + public BadOTPValueException(String msg) { + super(msg); + } + +} diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/exception/CsrfLoginTokenException.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/exception/CsrfLoginTokenException.java new file mode 100644 index 00000000..fda2d1a5 --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/exception/CsrfLoginTokenException.java @@ -0,0 +1,21 @@ +package com.payneteasy.superfly.security.exception; + +import org.springframework.security.core.AuthenticationException; + +public class CsrfLoginTokenException extends AuthenticationException { + + private String publicMsg; + + public CsrfLoginTokenException(String msg, Throwable t) { + super(msg, t); + } + + public CsrfLoginTokenException(String msg, String publicMsg) { + super(msg); + this.publicMsg = publicMsg; + } + + public String getPublicMsg() { + return publicMsg; + } +} diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/exception/InsufficientAuthenticationException.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/exception/InsufficientAuthenticationException.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/exception/InsufficientAuthenticationException.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/exception/InsufficientAuthenticationException.java diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/exception/PreconditionsException.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/exception/PreconditionsException.java new file mode 100644 index 00000000..3473f018 --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/exception/PreconditionsException.java @@ -0,0 +1,22 @@ +package com.payneteasy.superfly.security.exception; + +import org.springframework.security.core.AuthenticationException; + +/** + * Thrown when a precondition fails (for instance, the order of authentication + * actions is wrong). + * + * @author Roman Puchkovskiy + */ +public class PreconditionsException extends AuthenticationException { + private static final long serialVersionUID = 7098950681593983707L; + + public PreconditionsException(String msg, Throwable t) { + super(msg, t); + } + + public PreconditionsException(String msg) { + super(msg); + } + +} diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/exception/StepTwoException.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/exception/StepTwoException.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/exception/StepTwoException.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/exception/StepTwoException.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/handler/UnauthorizedFailureHandler.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/handler/UnauthorizedFailureHandler.java similarity index 95% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/handler/UnauthorizedFailureHandler.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/handler/UnauthorizedFailureHandler.java index 109377a6..750e0b25 100644 --- a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/handler/UnauthorizedFailureHandler.java +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/handler/UnauthorizedFailureHandler.java @@ -23,7 +23,7 @@ private void writeTextMessage(HttpServletResponse response, AuthenticationExcept response.setHeader("Content-Type", "text/plain"); PrintWriter writer = response.getWriter(); - writer.print(exception.getMessage()); + writer.print("Authentication failed"); writer.close(); } } diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/mapbuilder/ActionsMapBuilder.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/mapbuilder/ActionsMapBuilder.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/mapbuilder/ActionsMapBuilder.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/mapbuilder/ActionsMapBuilder.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/mapbuilder/ActionsSource.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/mapbuilder/ActionsSource.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/mapbuilder/ActionsSource.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/mapbuilder/ActionsSource.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/mapbuilder/CollectingActionsSource.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/mapbuilder/CollectingActionsSource.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/mapbuilder/CollectingActionsSource.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/mapbuilder/CollectingActionsSource.java diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/mapbuilder/ResourceActionsSource.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/mapbuilder/ResourceActionsSource.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/mapbuilder/ResourceActionsSource.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/mapbuilder/ResourceActionsSource.java diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/mapbuilder/SeparateActionsMapBuilder.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/mapbuilder/SeparateActionsMapBuilder.java new file mode 100644 index 00000000..7c2fdc1a --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/mapbuilder/SeparateActionsMapBuilder.java @@ -0,0 +1,32 @@ +package com.payneteasy.superfly.security.mapbuilder; + +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; + +import com.payneteasy.superfly.api.SSOAction; +import com.payneteasy.superfly.api.SSORole; + +/** + * {@link ActionsMapBuilder} implementation in which it's possible to specify + * actions for each role separately. + * + * @author Roman Puchkovskiy + */ +public class SeparateActionsMapBuilder implements ActionsMapBuilder { + + private final Map roleNamesToActionsSources; + + public SeparateActionsMapBuilder(Map roleNamesToActionsSources) { + this.roleNamesToActionsSources = roleNamesToActionsSources; + } + + public Map build() throws Exception { + Map map = new HashMap<>(); + for (Entry entry : roleNamesToActionsSources.entrySet()) { + map.put(new SSORole(entry.getKey()), entry.getValue().getActions()); + } + return map; + } + +} diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/processor/AuthenticationPostProcessor.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/processor/AuthenticationPostProcessor.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/processor/AuthenticationPostProcessor.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/processor/AuthenticationPostProcessor.java diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/processor/CompoundLatestAuthUnwrappingPostProcessor.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/processor/CompoundLatestAuthUnwrappingPostProcessor.java new file mode 100644 index 00000000..af80ee9a --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/processor/CompoundLatestAuthUnwrappingPostProcessor.java @@ -0,0 +1,19 @@ +package com.payneteasy.superfly.security.processor; + +import com.payneteasy.superfly.security.authentication.CompoundAuthentication; +import org.springframework.security.core.Authentication; + +/** + * Post-processor which replaces a {@link CompoundAuthentication} with its + * latest ready {@link Authentication}. + * + * @author Roman Puchkovskiy + */ +public class CompoundLatestAuthUnwrappingPostProcessor implements AuthenticationPostProcessor { + + public Authentication postProcess(Authentication auth) { + CompoundAuthentication compound = (CompoundAuthentication) auth; + return compound.getLatestReadyAuthentication(); + } + +} diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/processor/IdAuthenticationPostProcessor.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/processor/IdAuthenticationPostProcessor.java new file mode 100644 index 00000000..ca1e2e38 --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/processor/IdAuthenticationPostProcessor.java @@ -0,0 +1,15 @@ +package com.payneteasy.superfly.security.processor; + +import org.springframework.security.core.Authentication; + +/** + * AuthenticationPostProcessor implementation which just returns its argument. + * + * @author rpuch + */ +public class IdAuthenticationPostProcessor implements AuthenticationPostProcessor { + @Override + public Authentication postProcess(Authentication auth) { + return auth; + } +} diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/processor/SSOUserShortCircuitingPostProcessor.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/processor/SSOUserShortCircuitingPostProcessor.java new file mode 100644 index 00000000..d4c55368 --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/processor/SSOUserShortCircuitingPostProcessor.java @@ -0,0 +1,68 @@ +package com.payneteasy.superfly.security.processor; + +import org.springframework.security.core.Authentication; + +import com.payneteasy.superfly.api.SSORole; +import com.payneteasy.superfly.api.SSOUser; +import com.payneteasy.superfly.security.CompoundRoleSource; +import com.payneteasy.superfly.security.RoleSource; +import com.payneteasy.superfly.security.SSOActionRoleSource; +import com.payneteasy.superfly.security.SSORoleRoleSource; +import com.payneteasy.superfly.security.StringTransformer; +import com.payneteasy.superfly.security.authentication.CompoundAuthentication; +import com.payneteasy.superfly.security.authentication.SSOUserAuthenticationToken; +import com.payneteasy.superfly.security.authentication.SSOUserTransportAuthenticationToken; + +/** + * Post-processor which allows to build a final authentication. + * + * @author Roman Puchkovskiy + */ +public class SSOUserShortCircuitingPostProcessor implements AuthenticationPostProcessor { + + private boolean finishWithSuperflyFinalAuthentication = false; + private StringTransformer[] roleNameTransformers = new StringTransformer[]{}; + private RoleSource roleSource = createDefaultRoleSource(); + + public void setFinishWithSuperflyFinalAuthentication(boolean b) { + this.finishWithSuperflyFinalAuthentication = b; + } + + public void setRoleNameTransformers(StringTransformer[] roleNameTransformers) { + this.roleNameTransformers = roleNameTransformers; + } + + public void setRoleSource(RoleSource roleSource) { + this.roleSource = roleSource; + } + + public Authentication postProcess(Authentication auth) { + CompoundAuthentication compound = (CompoundAuthentication) auth; + SSOUserTransportAuthenticationToken token = (SSOUserTransportAuthenticationToken) compound.getLatestReadyAuthentication(); + SSOUser ssoUser = token.getSsoUser(); + + Authentication result; + if (finishWithSuperflyFinalAuthentication && ssoUser.getActionsMap().size() == 1) { + result = createFinalAuthentication(token, ssoUser, + ssoUser.getActionsMap().keySet().iterator().next()); + } else { + result = auth; + } + return result; + } + + protected Authentication createFinalAuthentication(Authentication authentication, + SSOUser ssoUser, SSORole role) { + return new SSOUserAuthenticationToken(ssoUser, role, + authentication.getCredentials(), authentication.getDetails(), + roleNameTransformers, roleSource); + } + + protected RoleSource createDefaultRoleSource() { + RoleSource[] sources = new RoleSource[2]; + sources[0] = new SSOActionRoleSource(); + sources[1] = new SSORoleRoleSource(); + return new CompoundRoleSource(sources); + } + +} diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/session/ISessionAccessor.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/session/ISessionAccessor.java new file mode 100644 index 00000000..330b4da5 --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/session/ISessionAccessor.java @@ -0,0 +1,12 @@ +package com.payneteasy.superfly.security.session; + +/** + * @author rpuch + */ +public interface ISessionAccessor { + void putAttribute(String key, Object value); + + T getAttribute(String key); + + T removeAttribute(String key); +} diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/session/ServletSessionAccessor.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/session/ServletSessionAccessor.java new file mode 100644 index 00000000..92b275a9 --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/session/ServletSessionAccessor.java @@ -0,0 +1,30 @@ +package com.payneteasy.superfly.security.session; + +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; + +/** + * @author rpuch + */ +@Service +public class ServletSessionAccessor implements ISessionAccessor { + @Override + public void putAttribute(String key, Object value) { + RequestContextHolder.currentRequestAttributes().setAttribute(key, value, RequestAttributes.SCOPE_SESSION); + } + + @Override + public T getAttribute(String key) { + @SuppressWarnings("unchecked") T result = (T) RequestContextHolder.currentRequestAttributes().getAttribute(key, + RequestAttributes.SCOPE_SESSION); + return result; + } + + @Override + public T removeAttribute(String key) { + T result = getAttribute(key); + RequestContextHolder.currentRequestAttributes().removeAttribute(key, RequestAttributes.SCOPE_SESSION); + return result; + } +} diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/validator/AuthenticationValidator.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/validator/AuthenticationValidator.java new file mode 100644 index 00000000..62bb3e7e --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/validator/AuthenticationValidator.java @@ -0,0 +1,19 @@ +package com.payneteasy.superfly.security.validator; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; + +/** + * Validates authentication. + * + * @author Roman Puchkovskiy + */ +public interface AuthenticationValidator { + /** + * Carries out authentication. + * + * @param auth auth to validate + * @throws AuthenticationException if validation fails + */ + void validate(Authentication auth) throws AuthenticationException; +} diff --git a/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/validator/CompoundAuthenticationValidator.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/validator/CompoundAuthenticationValidator.java new file mode 100644 index 00000000..0cf48710 --- /dev/null +++ b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/validator/CompoundAuthenticationValidator.java @@ -0,0 +1,47 @@ +package com.payneteasy.superfly.security.validator; + +import java.util.HashSet; +import java.util.Set; + +import lombok.Setter; +import lombok.experimental.Accessors; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; + +import com.payneteasy.superfly.security.authentication.CompoundAuthentication; +import com.payneteasy.superfly.security.exception.PreconditionsException; + +/** + * Validator which validates that {@link CompoundAuthentication} contains + * all the required authentications. + * + * @author Roman Puchkovskiy + */ +@Setter +@Accessors(chain = true) +public class CompoundAuthenticationValidator implements AuthenticationValidator { + + private Class[] requiredClasses = new Class[]{}; + + public void validate(Authentication auth) throws AuthenticationException { + if (auth instanceof CompoundAuthentication compound) { + validateCompound(compound); + } else { + throw new IllegalArgumentException(auth.getClass().getName() + " is not instance of a CompoundAuthentication"); + } + } + + protected void validateCompound(CompoundAuthentication compound) { + Authentication[] auths = compound.getReadyAuthentications(); + Set> classes = new HashSet>(); + for (Authentication auth : auths) { + classes.add(auth.getClass()); + } + for (Class clazz : requiredClasses) { + if (!classes.contains(clazz)) { + throw new PreconditionsException("Unexpected authentication class before authentication"); + } + } + } + +} diff --git a/superfly-spring-security/src/main/java/com/payneteasy/superfly/security/x509/X509PreAuthenticatedAuthenticationProvider.java b/superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/x509/X509PreAuthenticatedAuthenticationProvider.java similarity index 100% rename from superfly-spring-security/src/main/java/com/payneteasy/superfly/security/x509/X509PreAuthenticatedAuthenticationProvider.java rename to superfly-spring-security-ee10/src/main/java/com/payneteasy/superfly/security/x509/X509PreAuthenticatedAuthenticationProvider.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/AbstractAuthenticationProcessingFilterTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/AbstractAuthenticationProcessingFilterTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/AbstractAuthenticationProcessingFilterTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/AbstractAuthenticationProcessingFilterTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/AbstractFilterTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/AbstractFilterTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/AbstractFilterTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/AbstractFilterTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/AbstractSSOUserAwareTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/AbstractSSOUserAwareTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/AbstractSSOUserAwareTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/AbstractSSOUserAwareTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/AbstractSuperflyAuthenticationProviderTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/AbstractSuperflyAuthenticationProviderTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/AbstractSuperflyAuthenticationProviderTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/AbstractSuperflyAuthenticationProviderTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/CompoundAuthenticationProviderTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/CompoundAuthenticationProviderTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/CompoundAuthenticationProviderTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/CompoundAuthenticationProviderTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/DelegatingDecisionManagerTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/DelegatingDecisionManagerTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/DelegatingDecisionManagerTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/DelegatingDecisionManagerTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/InsufficientAuthenticationAccessDecisionManagerTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/InsufficientAuthenticationAccessDecisionManagerTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/InsufficientAuthenticationAccessDecisionManagerTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/InsufficientAuthenticationAccessDecisionManagerTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/InsufficientAuthenticationHandlingFilterTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/InsufficientAuthenticationHandlingFilterTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/InsufficientAuthenticationHandlingFilterTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/InsufficientAuthenticationHandlingFilterTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/MultiStepLoginUrlAuthenticationEntryPointTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/MultiStepLoginUrlAuthenticationEntryPointTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/MultiStepLoginUrlAuthenticationEntryPointTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/MultiStepLoginUrlAuthenticationEntryPointTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflyAuthenticationProviderTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflyAuthenticationProviderTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflyAuthenticationProviderTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflyAuthenticationProviderTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflyMockAuthenticationProviderTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflyMockAuthenticationProviderTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflyMockAuthenticationProviderTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflyMockAuthenticationProviderTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflyMultiMockAuthenticationProviderTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflyMultiMockAuthenticationProviderTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflyMultiMockAuthenticationProviderTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflyMultiMockAuthenticationProviderTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProcessingFilterTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProcessingFilterTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProcessingFilterTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProcessingFilterTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProviderTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProviderTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProviderTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProviderTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProcessingFilterTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProcessingFilterTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProcessingFilterTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProcessingFilterTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProviderTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProviderTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProviderTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProviderTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProcessingFilterTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProcessingFilterTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProcessingFilterTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProcessingFilterTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProviderTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProviderTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProviderTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProviderTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProcessingFilterTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProcessingFilterTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProcessingFilterTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProcessingFilterTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProviderTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProviderTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProviderTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProviderTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/authentication/CheckHOTPTokenTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/authentication/CheckHOTPTokenTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/authentication/CheckHOTPTokenTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/authentication/CheckHOTPTokenTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/authentication/CompoundAuthenticationTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/authentication/CompoundAuthenticationTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/authentication/CompoundAuthenticationTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/authentication/CompoundAuthenticationTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/authentication/SSOUserTransportAuthenticationTokenTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/authentication/SSOUserTransportAuthenticationTokenTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/authentication/SSOUserTransportAuthenticationTokenTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/authentication/SSOUserTransportAuthenticationTokenTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/processor/SSOUserShortCircuitingPostProcessorTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/processor/SSOUserShortCircuitingPostProcessorTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/processor/SSOUserShortCircuitingPostProcessorTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/processor/SSOUserShortCircuitingPostProcessorTest.java diff --git a/superfly-spring-security/src/test/java/com/payneteasy/superfly/security/validator/CompoundAuthenticationValidatorTest.java b/superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/validator/CompoundAuthenticationValidatorTest.java similarity index 100% rename from superfly-spring-security/src/test/java/com/payneteasy/superfly/security/validator/CompoundAuthenticationValidatorTest.java rename to superfly-spring-security-ee10/src/test/java/com/payneteasy/superfly/security/validator/CompoundAuthenticationValidatorTest.java diff --git a/superfly-spring-security/src/test/resources/logback-test.xml b/superfly-spring-security-ee10/src/test/resources/logback-test.xml similarity index 100% rename from superfly-spring-security/src/test/resources/logback-test.xml rename to superfly-spring-security-ee10/src/test/resources/logback-test.xml diff --git a/superfly-spring-security-ee8/pom.xml b/superfly-spring-security-ee8/pom.xml new file mode 100644 index 00000000..b6a0bc60 --- /dev/null +++ b/superfly-spring-security-ee8/pom.xml @@ -0,0 +1,185 @@ + + 4.0.0 + + + com.payneteasy.superfly + superfly-parent + 2.0-3-SNAPSHOT + + + superfly-spring-security-ee8 + Superfly Spring Security EE8 + Java EE 8 (javax.servlet) adapter for Superfly Spring Security core + + + + + + org.springframework + spring-core + ${spring5.version} + provided + + + org.springframework + spring-beans + ${spring5.version} + provided + + + org.springframework + spring-context + ${spring5.version} + provided + + + org.springframework + spring-aop + ${spring5.version} + provided + + + org.springframework + spring-expression + ${spring5.version} + provided + + + + + + + + com.payneteasy.superfly + superfly-spring-security-core + + + + + com.payneteasy.superfly + superfly-client-ee8 + + + + + + org.springframework.security + spring-security-core + + ${spring-security5.version} + provided + + + + org.springframework.security + spring-security-web + ${spring-security5.version} + provided + + + + org.springframework.security + spring-security-config + ${spring-security5.version} + provided + + + + + org.springframework + spring-web + ${spring5.version} + provided + + + + + javax.servlet + javax.servlet-api + provided + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + ban-jakarta-in-ee8-security + enforce + + + + + jakarta.servlet:* + jakarta.servlet-api:* + + EE8 security module must not depend on jakarta.servlet (use javax.servlet) + + + true + + + + + ban-transitive-framework-leak + enforce + + + + + org.springframework:*:*:*:compile + org.springframework:*:*:*:runtime + org.springframework.security:*:*:*:compile + org.springframework.security:*:*:*:runtime + + EE8 Spring / Spring Security deps must stay scope=provided (variant B): they must not leak onto an EE8 consumer's compile/runtime classpath. The consumer brings its own javax-compatible Spring 5.x. + + + true + + + + + + + + + diff --git a/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/AbstractSingleStepAuthenticationProcessingFilter.java b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/AbstractSingleStepAuthenticationProcessingFilter.java new file mode 100644 index 00000000..bc4af161 --- /dev/null +++ b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/AbstractSingleStepAuthenticationProcessingFilter.java @@ -0,0 +1,32 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.security.authentication.CompoundAuthentication; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; + +public abstract class AbstractSingleStepAuthenticationProcessingFilter extends + AbstractAuthenticationProcessingFilter { + + protected AbstractSingleStepAuthenticationProcessingFilter(String defaultFilterProcessesUrl) { + super(defaultFilterProcessesUrl); + } + + protected CompoundAuthentication getCompoundAuthenticationOrNewOne(Authentication authentication) { + if (authentication instanceof CompoundAuthentication) { + return (CompoundAuthentication) authentication; + } else { + return new CompoundAuthentication(); + } + } + + protected Authentication extractLatestAuthOrSimpleAuth(Authentication authentication) { + if (authentication instanceof CompoundAuthentication) { + CompoundAuthentication compound = (CompoundAuthentication) authentication; + return compound.getLatestReadyAuthentication(); + } else { + return authentication; + } + } + +} + diff --git a/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/InsufficientAuthenticationHandlingFilter.java b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/InsufficientAuthenticationHandlingFilter.java new file mode 100644 index 00000000..c21aebbf --- /dev/null +++ b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/InsufficientAuthenticationHandlingFilter.java @@ -0,0 +1,54 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.security.exception.InsufficientAuthenticationException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.GenericFilterBean; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import java.io.IOException; + +/** + * Filter which intercepts non-complete authentications and throws an + * {@link InsufficientAuthenticationException}. + * + * @author Roman Puchkovskiy + */ +public class InsufficientAuthenticationHandlingFilter extends GenericFilterBean { + + private Class[] insufficientAuthenticationClasses = new Class[]{}; + + public void setInsufficientAuthenticationClasses(Class[] classes) { + insufficientAuthenticationClasses = classes; + } + + public void doFilter(ServletRequest request, ServletResponse response, + FilterChain chain) throws IOException, ServletException { + Authentication authentication = obtainAuthentication(); + if (authentication != null) { + boolean insufficient = false; + for (Class clazz : insufficientAuthenticationClasses) { + if (clazz.isAssignableFrom(authentication.getClass())) { + insufficient = true; + break; + } + } + if (insufficient) { + InsufficientAuthenticationException ex = new InsufficientAuthenticationException(authentication.getClass().getName()); + ex.setAuthentication(authentication); + throw ex; + } + } + // sufficient authentication or no authenticatio at all, just proceed + chain.doFilter(request, response); + } + + protected Authentication obtainAuthentication() { + return SecurityContextHolder.getContext().getAuthentication(); + } + +} + diff --git a/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/MultiStepLoginUrlAuthenticationEntryPoint.java b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/MultiStepLoginUrlAuthenticationEntryPoint.java new file mode 100644 index 00000000..cb196dcf --- /dev/null +++ b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/MultiStepLoginUrlAuthenticationEntryPoint.java @@ -0,0 +1,72 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.security.authentication.CompoundAuthentication; +import com.payneteasy.superfly.security.exception.AuthenticationCarrier; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.Collections; +import java.util.Map; + +/** + * {@link AuthenticationEntryPoint} which is intended to be used for multi-step + * authentication process. It redirects to URLs determined by the current + * {@link Authentication} class. + * + * @author Roman Puchkovskiy + */ +public class MultiStepLoginUrlAuthenticationEntryPoint extends + LoginUrlAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private Map, String> stepInsufficientAuthenticationMapping = Collections.emptyMap(); + + public MultiStepLoginUrlAuthenticationEntryPoint(String loginFormUrl) { + super(loginFormUrl); + } + + public void setInsufficientAuthenticationMapping( + Map, + String> mapping) { + this.stepInsufficientAuthenticationMapping = mapping; + } + + @Override + protected String determineUrlToUseForThisRequest( + HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) { + String url = null; + Authentication auth = null; + if (exception instanceof AuthenticationCarrier) { + auth = ((AuthenticationCarrier) exception).getAuthentication(); + } + if (auth == null) { + auth = SecurityContextHolder.getContext().getAuthentication(); + } + if (auth != null) { + Authentication authToChooseUrl; + if (auth instanceof CompoundAuthentication) { + CompoundAuthentication compound = (CompoundAuthentication) auth; + authToChooseUrl = compound.getLatestReadyAuthentication(); + } else { + authToChooseUrl = auth; + } + url = stepInsufficientAuthenticationMapping.get(authToChooseUrl.getClass()); + if (url != null) { + // mapping matched, so restoring authentication... + // TODO: is this correct? it's explicitly cleared before... + SecurityContextHolder.getContext().setAuthentication(auth); + } + } + if (url == null) { + url = getLoginFormUrl(); + } + return url; + } + +} + diff --git a/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/SSOUserSessionBindFilter.java b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/SSOUserSessionBindFilter.java new file mode 100644 index 00000000..af719ea6 --- /dev/null +++ b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/SSOUserSessionBindFilter.java @@ -0,0 +1,48 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.common.session.HttpSessionWrapper; +import com.payneteasy.superfly.common.session.JavaxHttpSessionWrapper; +import com.payneteasy.superfly.common.session.SessionMapping; +import com.payneteasy.superfly.common.session.SessionMappingLocator; +import com.payneteasy.superfly.security.authentication.SSOUserAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.GenericFilterBean; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.io.IOException; + +/** + * Filter which creates a correlation between a Superfly session key and an + * HttpSession. + * + * @author Roman Puchkovskiy + */ +public class SSOUserSessionBindFilter extends GenericFilterBean { + + public void doFilter(ServletRequest req, ServletResponse resp, + FilterChain chain) throws IOException, ServletException { + HttpServletRequest request = (HttpServletRequest) req; + HttpServletResponse response = (HttpServletResponse) resp; + HttpSession session = request.getSession(false); + if (session != null) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication instanceof SSOUserAuthenticationToken token) { + getSessionMapping().addSession(token.getUser().getSessionId(), new JavaxHttpSessionWrapper(session)); + } + } + chain.doFilter(request, response); + } + + protected SessionMapping getSessionMapping() { + return SessionMappingLocator.getSessionMapping(); + } + +} + diff --git a/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProcessingFilter.java b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProcessingFilter.java new file mode 100644 index 00000000..3baf3919 --- /dev/null +++ b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/SuperflyOTPAuthenticationProcessingFilter.java @@ -0,0 +1,78 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.api.SSOUser; +import com.payneteasy.superfly.security.authentication.CheckOTPToken; +import com.payneteasy.superfly.security.authentication.CompoundAuthentication; +import com.payneteasy.superfly.security.authentication.SSOUserTransportAuthenticationToken; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.Assert; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * Filter which processes an Google Auth TOTP authentication. It assumes that user has + * already been authenticated by password authentication. + * + * @author Igor Vasilyev + */ +public class SuperflyOTPAuthenticationProcessingFilter extends + AbstractSingleStepAuthenticationProcessingFilter { + + private static final Logger logger = LoggerFactory.getLogger(SuperflyOTPAuthenticationProcessingFilter.class); + + private String otpParameter = "j_otp"; + + public SuperflyOTPAuthenticationProcessingFilter() { + super("/j_superfly_otp_security_check"); + } + + public void setOtpParameter(String otpParameter) { + this.otpParameter = otpParameter; + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, + HttpServletResponse response) throws AuthenticationException { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + throw new BadCredentialsException("Authentication is null"); + } + Assert.notNull(authentication, "authentication cannot be null"); + + CompoundAuthentication compound = getCompoundAuthenticationOrNewOne(authentication); + authentication = extractLatestAuthOrSimpleAuth(authentication); + + String otp = obtainOtp(request); + + Authentication authRequest = createSimpleAuthRequest(authentication, otp); + + return getAuthenticationManager().authenticate(new CompoundAuthentication(compound.getReadyAuthentications(), authRequest)); + } + + protected Authentication createSimpleAuthRequest( + Authentication authentication, String otp) { + if (!(authentication instanceof SSOUserTransportAuthenticationToken)) { + String msg = "Unexpected authentication of class " + authentication.getClass() + ": " + authentication; + logger.error(msg); + throw new AuthenticationServiceException(msg); + } + SSOUserTransportAuthenticationToken token = (SSOUserTransportAuthenticationToken) authentication; + return createCheckOtpAuthRequest(otp, token.getSsoUser()); + } + + protected Authentication createCheckOtpAuthRequest(String otp, SSOUser ssoUser) { + return new CheckOTPToken(ssoUser, otp); + } + + protected String obtainOtp(HttpServletRequest request) { + return request.getParameter(otpParameter); + } +} + diff --git a/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProcessingFilter.java b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProcessingFilter.java new file mode 100644 index 00000000..0b46d3bd --- /dev/null +++ b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProcessingFilter.java @@ -0,0 +1,62 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.security.authentication.CompoundAuthentication; +import com.payneteasy.superfly.security.authentication.SSOAuthenticationRequest; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * Filter which authenticates a user using the Superfly Single Sign-on + * authentication. + * + * @author Roman Puchkovskiy + */ +public class SuperflySSOAuthenticationProcessingFilter extends + AbstractSingleStepAuthenticationProcessingFilter { + + private String subsystemTokenParameter = "subsystemToken"; + private String targetUrlParameter = "targetUrl"; + + public void setSubsystemTokenParameter(String subsystemTokenParameter) { + this.subsystemTokenParameter = subsystemTokenParameter; + } + + public void setTargetUrlParameter(String targetUrlParameter) { + this.targetUrlParameter = targetUrlParameter; + } + + public SuperflySSOAuthenticationProcessingFilter() { + super("/j_superfly_sso_security_check"); + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, + HttpServletResponse response) throws AuthenticationException { + Authentication authRequest; + + String subsystemToken = obtainSubsystemToken(request); + String targetUrl = obtainTargetUrl(request); + + authRequest = createSSOAuthRequest(request, subsystemToken); + + return getAuthenticationManager().authenticate(new CompoundAuthentication(authRequest)); + } + + protected String obtainSubsystemToken(HttpServletRequest request) { + return request.getParameter(subsystemTokenParameter); + } + + protected String obtainTargetUrl(HttpServletRequest request) { + return request.getParameter(targetUrlParameter); + } + + protected Authentication createSSOAuthRequest( + HttpServletRequest request, String subsystemToken) { + return new SSOAuthenticationRequest(subsystemToken); + } + +} + diff --git a/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProcessingFilter.java b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProcessingFilter.java new file mode 100644 index 00000000..3bd642be --- /dev/null +++ b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/SuperflySelectRoleAuthenticationProcessingFilter.java @@ -0,0 +1,76 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.api.SSORole; +import com.payneteasy.superfly.api.SSOUser; +import com.payneteasy.superfly.security.authentication.CompoundAuthentication; +import com.payneteasy.superfly.security.authentication.SSOUserAndSelectedRoleAuthenticationToken; +import com.payneteasy.superfly.security.authentication.SSOUserTransportAuthenticationToken; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * Filter which is used to show a dropdown from which user selects a role with + * which they want to log in to the system. + * + * @author Roman Puchkovskiy + */ +public class SuperflySelectRoleAuthenticationProcessingFilter extends + AbstractSingleStepAuthenticationProcessingFilter { + + private String roleParameter = "j_role"; + + protected SuperflySelectRoleAuthenticationProcessingFilter() { + super("/j_superfly_select_role"); + } + + public void setRoleParameter(String roleParameter) { + this.roleParameter = roleParameter; + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, + HttpServletResponse response) throws AuthenticationException, + IOException, ServletException { + String roleKey = obtainRoleKey(request); + SSOUser ssoUser = (SSOUser) request.getSession().getAttribute( + SSOUserTransportAuthenticationToken.SESSION_KEY); + request.getSession().removeAttribute( + SSOUserTransportAuthenticationToken.SESSION_KEY); + if (ssoUser == null) { + throw new BadCredentialsException("Session expired"); + } + SSORole selectedRole = null; + for (SSORole role : ssoUser.getActionsMap().keySet()) { + if (role.getName().equals(roleKey)) { + selectedRole = role; + break; + } + } + if (selectedRole == null) { + throw new BadCredentialsException("Unknown role: " + roleKey); + } + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + CompoundAuthentication compound = getCompoundAuthenticationOrNewOne(authentication); + + Authentication authRequest = createUserRoleAuthRequest(ssoUser, selectedRole); + return getAuthenticationManager().authenticate(new CompoundAuthentication(compound.getReadyAuthentications(), authRequest)); + } + + protected String obtainRoleKey(HttpServletRequest request) { + return request.getParameter(roleParameter); + } + + protected Authentication createUserRoleAuthRequest(SSOUser ssoUser, SSORole role) { + return new SSOUserAndSelectedRoleAuthenticationToken(ssoUser, role); + } + +} + diff --git a/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProcessingFilter.java b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProcessingFilter.java new file mode 100644 index 00000000..2bbe28ca --- /dev/null +++ b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/SuperflyUsernamePasswordAuthenticationProcessingFilter.java @@ -0,0 +1,99 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.api.AuthenticationRequestInfo; +import com.payneteasy.superfly.security.authentication.CompoundAuthentication; +import com.payneteasy.superfly.security.authentication.UsernamePasswordAuthRequestInfoAuthenticationToken; +import com.payneteasy.superfly.security.csrf.CsrfValidator; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * Filter which authenticates a user using the Superfly password authentication. + * + * @author Roman Puchkovский + */ +public class SuperflyUsernamePasswordAuthenticationProcessingFilter extends + AbstractSingleStepAuthenticationProcessingFilter { + + private String usernameParameter = "j_username"; + private String passwordParameter = "j_password"; + private String secondFactoryParameter = "j_second"; + private String subsystemIdentifier; + + private CsrfValidator csrfValidator; + + public SuperflyUsernamePasswordAuthenticationProcessingFilter() { + super("/j_superfly_password_security_check"); + } + + public void setUsernameParameter(String usernameParameter) { + this.usernameParameter = usernameParameter; + } + + public void setPasswordParameter(String passwordParameter) { + this.passwordParameter = passwordParameter; + } + + public void setSecondFactoryParameter(String secondFactoryParameter) { + this.secondFactoryParameter = secondFactoryParameter; + } + + public void setSubsystemIdentifier(String subsystemIdentifier) { + this.subsystemIdentifier = subsystemIdentifier; + } + + public void setCsrfValidator(CsrfValidator csrfValidator) { + this.csrfValidator = csrfValidator; + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, + HttpServletResponse response) throws AuthenticationException { + csrfValidator.validateToken(request); + + Authentication authRequest; + String username = obtainUsername(request); + String password = obtainPassword(request); + String secondFactory = obtainSecondFactory(request); + + authRequest = createUsernamePasswordAuthRequest(request, username, password, secondFactory); + + return getAuthenticationManager().authenticate(new CompoundAuthentication(authRequest)); + } + + protected String obtainUsername(HttpServletRequest request) { + return request.getParameter(usernameParameter); + } + + protected String obtainPassword(HttpServletRequest request) { + return request.getParameter(passwordParameter); + } + + protected String obtainSecondFactory(HttpServletRequest request) { + return request.getParameter(secondFactoryParameter); + } + + protected Authentication createUsernamePasswordAuthRequest( + HttpServletRequest request, String username, String password, String secondFactory) { + AuthenticationRequestInfo authRequestInfo = createAuthRequestInfo(request); + return new UsernamePasswordAuthRequestInfoAuthenticationToken( + username, + password, + authRequestInfo + ).withSecondFactory( + secondFactory + ); + } + + protected AuthenticationRequestInfo createAuthRequestInfo(HttpServletRequest request) { + AuthenticationRequestInfo result = new AuthenticationRequestInfo(); + result.setIpAddress(request.getRemoteAddr()); + result.setSubsystemIdentifier(subsystemIdentifier); + return result; + } + +} + diff --git a/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/TwoStepAuthenticationProcessingFilter.java b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/TwoStepAuthenticationProcessingFilter.java new file mode 100644 index 00000000..0df40566 --- /dev/null +++ b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/TwoStepAuthenticationProcessingFilter.java @@ -0,0 +1,128 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.api.AuthenticationRequestInfo; +import com.payneteasy.superfly.api.SSORole; +import com.payneteasy.superfly.api.SSOUser; +import com.payneteasy.superfly.security.authentication.SSOUserAndSelectedRoleAuthenticationToken; +import com.payneteasy.superfly.security.authentication.SSOUserTransportAuthenticationToken; +import com.payneteasy.superfly.security.authentication.UsernamePasswordAuthRequestInfoAuthenticationToken; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * Processing filter which carries out a two-step authentication process. + * On the first step, username+password are requested from user to authenticate; + * on success roles are obtained for the user. If there're more than 1 roles, + * user is forwarded to step 2, on which he selects a role. + * If there's only one role, it's selected and step 2 is ignored. + * + * @author Roman Puchkovskiy + */ +public class TwoStepAuthenticationProcessingFilter extends UsernamePasswordAuthenticationFilter { + + public static final String SPRING_SECURITY_FORM_ROLE_KEY = "j_role"; + + private String roleParameter = SPRING_SECURITY_FORM_ROLE_KEY; + private String subsystemIdentifier = null; + + public TwoStepAuthenticationProcessingFilter() { + setFilterProcessesUrl("/j_superfly_security_check"); + } + + public void setRoleParameter(String roleParameter) { + this.roleParameter = roleParameter; + } + + public void setSubsystemIdentifier(String subsystemIdentifier) { + this.subsystemIdentifier = subsystemIdentifier; + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, + HttpServletResponse response) throws AuthenticationException { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + Authentication authRequest; + if (isStepOne(request, authentication)) { + authRequest = doStepOne(request, authentication); + } else if (isStepTwo(request, authentication)) { + authRequest = doStepTwo(request, authentication); + } else { + throw new IllegalStateException("Must execute either step 1 or step 2, but they both didn't match"); + } + + return getAuthenticationManager().authenticate(authRequest); + } + + protected String obtainRoleKey(HttpServletRequest request) { + return request.getParameter(roleParameter); + } + + protected boolean isStepOne(HttpServletRequest request, + Authentication authentication) { + return obtainUsername(request) != null; + } + + protected boolean isStepTwo(HttpServletRequest request, + Authentication authentication) { + return obtainRoleKey(request) != null; + } + + protected Authentication doStepOne(HttpServletRequest request, + Authentication authentication) { + String username = obtainUsername(request); + String password = obtainPassword(request); + + return createUsernamePasswordAuthRequest(request, username, password); + } + + protected Authentication createUsernamePasswordAuthRequest( + HttpServletRequest request, String username, String password) { + AuthenticationRequestInfo authRequestInfo = createAuthRequestInfo(request); + Authentication authRequest = new UsernamePasswordAuthRequestInfoAuthenticationToken( + username, password, authRequestInfo); + return authRequest; + } + + protected Authentication doStepTwo(HttpServletRequest request, + Authentication authentication) { + String roleKey = obtainRoleKey(request); + SSOUser ssoUser = (SSOUser) request.getSession().getAttribute( + SSOUserTransportAuthenticationToken.SESSION_KEY); + request.getSession().removeAttribute( + SSOUserTransportAuthenticationToken.SESSION_KEY); + if (ssoUser == null) { + throw new BadCredentialsException("Session expired"); + } + SSORole selectedRole = null; + for (SSORole role : ssoUser.getActionsMap().keySet()) { + if (role.getName().equals(roleKey)) { + selectedRole = role; + break; + } + } + if (selectedRole == null) { + throw new BadCredentialsException("Unknown role: " + roleKey); + } + + return createUserRoleAuthRequest(ssoUser, selectedRole); + } + + protected Authentication createUserRoleAuthRequest(SSOUser ssoUser, SSORole role) { + return new SSOUserAndSelectedRoleAuthenticationToken(ssoUser, role); + } + + protected AuthenticationRequestInfo createAuthRequestInfo(HttpServletRequest request) { + AuthenticationRequestInfo result = new AuthenticationRequestInfo(); + result.setIpAddress(request.getRemoteAddr()); + result.setSubsystemIdentifier(subsystemIdentifier); + return result; + } + +} + diff --git a/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/TwoStepAuthenticationProcessingFilterEntryPoint.java b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/TwoStepAuthenticationProcessingFilterEntryPoint.java new file mode 100644 index 00000000..1d345a44 --- /dev/null +++ b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/TwoStepAuthenticationProcessingFilterEntryPoint.java @@ -0,0 +1,77 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.api.SSORole; +import com.payneteasy.superfly.security.authentication.SSOUserTransportAuthenticationToken; +import com.payneteasy.superfly.security.exception.AuthenticationCarrier; +import com.payneteasy.superfly.security.exception.StepTwoException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.Set; + +/** + * AuthenticationEntryPoint implementation for two-step authentication process. + * + * @author Roman Puchkovskiy + */ +public class TwoStepAuthenticationProcessingFilterEntryPoint extends + LoginUrlAuthenticationEntryPoint { + + private final String loginFormStepTwoUrl; + + public TwoStepAuthenticationProcessingFilterEntryPoint(String loginFormUrl, String loginFormStepTwoUrl) { + super(loginFormUrl); + this.loginFormStepTwoUrl = loginFormStepTwoUrl; + } + + public String getLoginFormStepTwoUrl() { + return loginFormStepTwoUrl; + } + + @Override + protected String determineUrlToUseForThisRequest( + HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) { + if (exception instanceof StepTwoException) { + prepareForStepTwo(request, exception); + return getLoginFormStepTwoUrl(); + } else { + return getLoginFormUrl(); + } + } + + protected void prepareForStepTwo(HttpServletRequest request, + AuthenticationException reason) { + Authentication authentication = null; + if (reason instanceof AuthenticationCarrier) { + authentication = ((AuthenticationCarrier) reason).getAuthentication(); + } + if (authentication == null) { + throw new IllegalStateException("No authentication could be extracted from exception, something is wrong"); + } + SSOUserTransportAuthenticationToken token = (SSOUserTransportAuthenticationToken) authentication; + Set roles = token.getSsoUser().getActionsMap().keySet(); + request.getSession().setAttribute(SSOUserTransportAuthenticationToken.SESSION_KEY, token.getSsoUser()); + if (!isServerSideRedirect()) { + request.getSession().setAttribute("superflyRoles", roles); + request.getSession().setAttribute("ctxPath", request.getContextPath()); + } else { + request.setAttribute("superflyRoles", roles); + request.setAttribute("ctxPath", request.getContextPath()); + } + } + + /** + * Defined as this method has disappeared from the superclass. + * + * @return true if redirect is server-side + */ + protected boolean isServerSideRedirect() { + return false; + } + +} + diff --git a/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/csrf/CsrfValidator.java b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/csrf/CsrfValidator.java new file mode 100644 index 00000000..f9297026 --- /dev/null +++ b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/csrf/CsrfValidator.java @@ -0,0 +1,11 @@ +package com.payneteasy.superfly.security.csrf; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; + +public interface CsrfValidator { + String persistTokenIntoSession(HttpSession session); + + void validateToken(HttpServletRequest request); +} + diff --git a/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/csrf/CsrfValidatorImpl.java b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/csrf/CsrfValidatorImpl.java new file mode 100644 index 00000000..1d7957a3 --- /dev/null +++ b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/csrf/CsrfValidatorImpl.java @@ -0,0 +1,74 @@ +package com.payneteasy.superfly.security.csrf; + +import com.payneteasy.superfly.security.exception.CsrfLoginTokenException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; +import java.util.UUID; + +/** + * CSRF token must be contained in parameter '_csrf' + * + * @author Igor Vasilev + */ +public class CsrfValidatorImpl implements CsrfValidator { + private static final Logger logger = LoggerFactory.getLogger(CsrfValidatorImpl.class); + private static final String ATTRIBUTE_NAME = CsrfValidatorImpl.class.getName().concat(".CSRF_TOKEN"); + + private final boolean enable; + + public CsrfValidatorImpl(boolean enable) { + this.enable = enable; + } + + @Override + public String persistTokenIntoSession(HttpSession session) { + String token = UUID.randomUUID().toString(); + session.setAttribute(ATTRIBUTE_NAME, token); + return token; + } + + @Override + public void validateToken(HttpServletRequest request) { + if (!enable) { + logger.warn("CSRF login token check is disabled"); + return; + } + + if (request == null) { + throw new IllegalStateException("Cannot get request attribute - request is null!"); + } + HttpSession session = request.getSession(false); + if (session == null) { + logger.warn("No session"); + throw new CsrfLoginTokenException("No session.", "Something was wrong with your session. Please try again."); + } + + String token = (String) session.getAttribute(ATTRIBUTE_NAME); + + if (token == null) { + logger.error("No {} value in session", ATTRIBUTE_NAME); + throw new CsrfLoginTokenException( + "No any CSRF token in the session. Please check server config.", + "Missing login token. This can be caused by ad- or script-blocking plugins, " + + "but also by the browser itself if it's not allowed to set cookies.\n"); + } + + String csrf = request.getParameter("_csrf"); + if (csrf == null || csrf.isEmpty()) { + logger.error("Field _csrf is empty in request"); + throw new CsrfLoginTokenException("Missing CSRF token. This can be caused by ad- or script-blocking plugins, but also by the browser itself if it's not allowed to set cookies.", + "Missing login token. This can be caused by ad- or script-blocking plugins, " + + "but also by the browser itself if it's not allowed to set cookies.\n"); + } + + if (!csrf.equals(token)) { + logger.error("CSRF is invalid. Expected {} but was {}", token, csrf); + throw new CsrfLoginTokenException("Invalid CSRF token.", + "Invalid login token. This can be caused if you trying to login with multiple browser tabs."); + } + } +} + diff --git a/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/handler/UnauthorizedFailureHandler.java b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/handler/UnauthorizedFailureHandler.java new file mode 100644 index 00000000..5df7bbd3 --- /dev/null +++ b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/handler/UnauthorizedFailureHandler.java @@ -0,0 +1,30 @@ +package com.payneteasy.superfly.security.handler; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; + +public class UnauthorizedFailureHandler implements AuthenticationFailureHandler { + @Override + public void onAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception + ) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + writeTextMessage(response, exception); + } + + private void writeTextMessage(HttpServletResponse response, AuthenticationException exception) throws IOException { + response.setHeader("Content-Type", "text/plain"); + + PrintWriter writer = response.getWriter(); + writer.print(exception.getMessage()); + writer.close(); + } +} + diff --git a/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/x509/X509PreAuthenticatedAuthenticationProvider.java b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/x509/X509PreAuthenticatedAuthenticationProvider.java new file mode 100644 index 00000000..c53c0305 --- /dev/null +++ b/superfly-spring-security-ee8/src/main/java/com/payneteasy/superfly/security/x509/X509PreAuthenticatedAuthenticationProvider.java @@ -0,0 +1,12 @@ +package com.payneteasy.superfly.security.x509; + +import org.springframework.security.core.userdetails.UserDetailsByNameServiceWrapper; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationProvider; + +public class X509PreAuthenticatedAuthenticationProvider extends PreAuthenticatedAuthenticationProvider { + public X509PreAuthenticatedAuthenticationProvider(UserDetailsService userDetailsService) { + setPreAuthenticatedUserDetailsService(new UserDetailsByNameServiceWrapper<>(userDetailsService)); + } +} + diff --git a/superfly-spring-security-ee8/src/test/java/com/payneteasy/superfly/security/InsufficientAuthenticationHandlingFilterTest.java b/superfly-spring-security-ee8/src/test/java/com/payneteasy/superfly/security/InsufficientAuthenticationHandlingFilterTest.java new file mode 100644 index 00000000..042c4305 --- /dev/null +++ b/superfly-spring-security-ee8/src/test/java/com/payneteasy/superfly/security/InsufficientAuthenticationHandlingFilterTest.java @@ -0,0 +1,116 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.security.authentication.EmptyAuthenticationToken; +import com.payneteasy.superfly.security.exception.InsufficientAuthenticationException; +import org.easymock.EasyMock; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import javax.servlet.FilterChain; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; + +public class InsufficientAuthenticationHandlingFilterTest { + + private static final Logger logger = LoggerFactory.getLogger(InsufficientAuthenticationHandlingFilterTest.class); + + private InsufficientAuthenticationHandlingFilter filter; + + @Before + public void setUp() { + logger.debug("Setting up InsufficientAuthenticationHandlingFilterTest"); + filter = new InsufficientAuthenticationHandlingFilter(); + SecurityContextHolder.clearContext(); + } + + @After + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void testDoFilter_noAuthentication_proceedsToChain() throws Exception { + logger.debug("Testing doFilter proceeds when no authentication is present"); + + ServletRequest request = EasyMock.createMock(ServletRequest.class); + ServletResponse response = EasyMock.createMock(ServletResponse.class); + FilterChain chain = EasyMock.createMock(FilterChain.class); + + chain.doFilter(request, response); + EasyMock.expectLastCall().once(); + EasyMock.replay(request, response, chain); + + filter.doFilter(request, response, chain); + + EasyMock.verify(request, response, chain); + logger.debug("chain.doFilter called correctly with no authentication"); + } + + @Test + public void testDoFilter_sufficientAuthentication_proceedsToChain() throws Exception { + logger.debug("Testing doFilter proceeds when authentication class is not in insufficient list"); + + filter.setInsufficientAuthenticationClasses(new Class[]{EmptyAuthenticationToken.class}); + + // UsernamePasswordAuthenticationToken is NOT in the insufficient list + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken("user", "pass"); + SecurityContextHolder.getContext().setAuthentication(auth); + + ServletRequest request = EasyMock.createMock(ServletRequest.class); + ServletResponse response = EasyMock.createMock(ServletResponse.class); + FilterChain chain = EasyMock.createMock(FilterChain.class); + + chain.doFilter(request, response); + EasyMock.expectLastCall().once(); + EasyMock.replay(request, response, chain); + + filter.doFilter(request, response, chain); + + EasyMock.verify(request, response, chain); + logger.debug("chain.doFilter called for sufficient authentication"); + } + + @Test + public void testDoFilter_insufficientAuthentication_throwsException() throws Exception { + logger.debug("Testing doFilter throws InsufficientAuthenticationException for insufficient auth"); + + filter.setInsufficientAuthenticationClasses(new Class[]{EmptyAuthenticationToken.class}); + + // EmptyAuthenticationToken IS in the insufficient list + EmptyAuthenticationToken insufficientAuth = new EmptyAuthenticationToken(); + SecurityContextHolder.getContext().setAuthentication(insufficientAuth); + + ServletRequest request = EasyMock.createMock(ServletRequest.class); + ServletResponse response = EasyMock.createMock(ServletResponse.class); + FilterChain chain = EasyMock.createMock(FilterChain.class); + + EasyMock.replay(request, response, chain); + + try { + filter.doFilter(request, response, chain); + Assert.fail("Expected InsufficientAuthenticationException"); + } catch (InsufficientAuthenticationException e) { + logger.debug("InsufficientAuthenticationException thrown as expected: {}", e.getMessage()); + Assert.assertSame(insufficientAuth, e.getAuthentication()); + } + + EasyMock.verify(request, response, chain); + } + + @Test + public void testUsesJavaxServletApi() { + logger.debug("Verifying filter uses javax.servlet (not jakarta.servlet)"); + Class filterChainClass = javax.servlet.FilterChain.class; + Assert.assertTrue("Must use javax.servlet, not jakarta.servlet", + filterChainClass.getName().startsWith("javax.servlet")); + logger.debug("Confirmed javax.servlet.FilterChain is on classpath: {}", + filterChainClass.getName()); + } +} diff --git a/superfly-spring-security-ee8/src/test/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProcessingFilterTest.java b/superfly-spring-security-ee8/src/test/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProcessingFilterTest.java new file mode 100644 index 00000000..86dd8360 --- /dev/null +++ b/superfly-spring-security-ee8/src/test/java/com/payneteasy/superfly/security/SuperflySSOAuthenticationProcessingFilterTest.java @@ -0,0 +1,104 @@ +package com.payneteasy.superfly.security; + +import com.payneteasy.superfly.security.authentication.CompoundAuthentication; +import com.payneteasy.superfly.security.authentication.SSOAuthenticationRequest; +import org.easymock.EasyMock; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +public class SuperflySSOAuthenticationProcessingFilterTest { + + private static final Logger logger = LoggerFactory.getLogger(SuperflySSOAuthenticationProcessingFilterTest.class); + + private SuperflySSOAuthenticationProcessingFilter filter; + private AuthenticationManager authenticationManager; + + @Before + public void setUp() { + logger.debug("Setting up SuperflySSOAuthenticationProcessingFilterTest"); + filter = new SuperflySSOAuthenticationProcessingFilter(); + authenticationManager = EasyMock.createMock(AuthenticationManager.class); + filter.setAuthenticationManager(authenticationManager); + } + + @Test + public void testAttemptAuthentication_passesCompoundAuthenticationToManager() { + logger.debug("Testing attemptAuthentication delegates to AuthenticationManager with CompoundAuthentication"); + + HttpServletRequest request = EasyMock.createMock(HttpServletRequest.class); + HttpServletResponse response = EasyMock.createMock(HttpServletResponse.class); + Authentication result = EasyMock.createMock(Authentication.class); + + EasyMock.expect(request.getParameter("subsystemToken")).andReturn("test-token"); + EasyMock.expect(request.getParameter("targetUrl")).andReturn("/target"); + EasyMock.expect(authenticationManager.authenticate(EasyMock.anyObject(CompoundAuthentication.class))) + .andReturn(result); + + EasyMock.replay(request, response, authenticationManager, result); + + Authentication actual = filter.attemptAuthentication(request, response); + + Assert.assertSame(result, actual); + logger.debug("attemptAuthentication delegated correctly, result: {}", result); + + EasyMock.verify(request, response, authenticationManager, result); + } + + @Test + public void testAttemptAuthentication_usesJavaxServletApi() { + logger.debug("Verifying filter uses javax.servlet (not jakarta.servlet)"); + // Compile-time guarantee: if this class compiles with javax.servlet.http.HttpServletRequest + // on the classpath (not jakarta), the EE8 isolation is correct. + Class requestClass = javax.servlet.http.HttpServletRequest.class; + Assert.assertTrue("Must use javax.servlet, not jakarta.servlet", + requestClass.getName().startsWith("javax.servlet")); + logger.debug("Confirmed javax.servlet.http.HttpServletRequest is on classpath: {}", + requestClass.getName()); + } + + @Test + public void testDefaultFilterUrl() { + logger.debug("Verifying filter is instantiable without errors"); + Assert.assertNotNull("Filter must be instantiable", filter); + logger.debug("Filter instantiated successfully: {}", filter.getClass().getSimpleName()); + } + + @Test + public void testObtainSubsystemToken_returnsRequestParameter() { + logger.debug("Testing obtainSubsystemToken extracts correct parameter"); + + HttpServletRequest request = EasyMock.createMock(HttpServletRequest.class); + EasyMock.expect(request.getParameter("subsystemToken")).andReturn("my-token-value"); + EasyMock.replay(request, authenticationManager); + + String token = filter.obtainSubsystemToken(request); + + Assert.assertEquals("my-token-value", token); + logger.debug("obtainSubsystemToken returned: {}", token); + EasyMock.verify(request, authenticationManager); + } + + @Test + public void testCreateSSOAuthRequest_wrapsTokenInSSOAuthenticationRequest() { + logger.debug("Testing createSSOAuthRequest wraps subsystem token"); + + HttpServletRequest request = EasyMock.createMock(HttpServletRequest.class); + EasyMock.replay(request, authenticationManager); + + Authentication authRequest = filter.createSSOAuthRequest(request, "sso-token"); + + Assert.assertNotNull(authRequest); + Assert.assertTrue("Must create SSOAuthenticationRequest", + authRequest instanceof SSOAuthenticationRequest); + logger.debug("createSSOAuthRequest returned: {}", authRequest.getClass().getSimpleName()); + EasyMock.verify(request, authenticationManager); + } +} diff --git a/superfly-sql/functions.sh b/superfly-sql/functions.sh index ff0f8e3c..7822b2c4 100755 --- a/superfly-sql/functions.sh +++ b/superfly-sql/functions.sh @@ -4,9 +4,9 @@ export SSO_DB_USERNAME=${SSO_DB_USERNAME:-sso} export SSO_DB_PASSWORD=${SSO_DB_PASSWORD:-123sso123} export SSO_DB_DATABASE=${SSO_DB_DATABASE:-sso} export SSO_DB_HOST=${SSO_DB_HOST:-localhost} -export SSO_DB_PORT=${SSO_DB_PORT:-3306} +export SSO_DB_PORT=${SSO_DB_PORT:-3344} export SSO_DB_ROOT=${SSO_DB_ROOT:-root} -export SSO_DB_ROOT_PASSWORD=${SSO_DB_ROOT_PASSWORD:-charpa} +export SSO_DB_ROOT_PASSWORD=${SSO_DB_ROOT_PASSWORD:-1234} logInfo() { # or tts -s @@ -30,41 +30,96 @@ logWarn() { logger "$USER WARN: $1" } +# Trap errors globally to handle them +trap 'logError "Error occurred in ${BASH_SOURCE[0]} at line $LINENO"' ERR + +# Custom error handler without exiting the script die() { errorCode=$? errorMessage=$1 - - if [ $errorCode != 0 ] + + if [ $errorCode != 0 ] then - echo ".ERROR.: $errorCode - $errorMessage" - exit 1 - fi + logError ".ERROR.: $errorCode - $errorMessage" + return $errorCode + fi } +# Function to expand .\ commands in SQL files +expandSourceCommands() { + local inputFile=$1 + local outputFile=$2 + local baseDir=$(dirname "$inputFile") + local firstLine=true + + # Remove BOM if present and read file + sed '1s/^\xEF\xBB\xBF//' "$inputFile" | while IFS= read -r line || [ -n "$line" ]; do + # Check for .\ command (backslash followed by dot, then whitespace and filename) + if [[ "$line" =~ ^\\\.\ +(.+)$ ]]; then + # Found a .\ command - include the file + local includeFile="${BASH_REMATCH[1]}" + # Trim whitespace + includeFile=$(echo "$includeFile" | xargs) + # Resolve relative path + if [[ "$includeFile" != /* ]]; then + includeFile="$baseDir/$includeFile" + fi + if [ -f "$includeFile" ]; then + # Recursively expand the included file + expandSourceCommands "$includeFile" "$outputFile" + else + logWarn "Warning: Include file not found: $includeFile" + fi + else + # Regular line - output as is + echo "$line" >> "$outputFile" + fi + done +} runScript() { aScript=$1 + aScriptLog="target/$(basename $aScript).log" mkdir -p target echo "Installing to $SSO_DB_USERNAME@$SSO_DB_HOST:$SSO_DB_PORT/$SSO_DB_DATABASE $aScript ..." - mysql --default-character-set=utf8 --protocol=TCP --port $SSO_DB_PORT -h $SSO_DB_HOST -b -vv -u $SSO_DB_USERNAME -p$SSO_DB_PASSWORD $SSO_DB_DATABASE --show-warnings < $aScript > target/$aScript.log + + # Expand source commands if file contains .\ commands + if grep -q "^\\\." "$aScript" 2>/dev/null; then + expandedScript=$(mktemp) + expandSourceCommands "$aScript" "$expandedScript" + mysql --default-character-set=utf8mb4 --protocol=TCP --port $SSO_DB_PORT -h $SSO_DB_HOST -b -vv -u $SSO_DB_USERNAME -p$SSO_DB_PASSWORD $SSO_DB_DATABASE --show-warnings < "$expandedScript" > "$aScriptLog" + rm -f "$expandedScript" + else + mysql --default-character-set=utf8mb4 --protocol=TCP --port $SSO_DB_PORT -h $SSO_DB_HOST -b -vv -u $SSO_DB_USERNAME -p$SSO_DB_PASSWORD $SSO_DB_DATABASE --show-warnings < $aScript > "$aScriptLog" + fi die "can not process $aScript" #echo "Current DDL/DML warnings:" - #grep "Warning " target/$aScript.log - #grep "Warnings:" target/$aScript.log + #grep "Warning " target/$aScript.log + #grep "Warnings:" target/$aScript.log #grep "Note" target/$aScript.log } runScriptNoDb() { aScript=$1 + aScriptLog="target/$(basename $aScript).log" mkdir -p target echo "Installing to $SSO_DB_USERNAME@$SSO_DB_HOST:$SSO_DB_PORT $aScript ..." - mysql --default-character-set=utf8 --protocol=TCP --port $SSO_DB_PORT -h $SSO_DB_HOST -b -vv -u $SSO_DB_USERNAME -p$SSO_DB_PASSWORD --show-warnings < $aScript > target/$aScript.log + + # Expand source commands if file contains .\ commands + if grep -q "^\\\." "$aScript" 2>/dev/null; then + expandedScript=$(mktemp) + expandSourceCommands "$aScript" "$expandedScript" + mysql --default-character-set=utf8mb4 --protocol=TCP --port $SSO_DB_PORT -h $SSO_DB_HOST -b -vv -u $SSO_DB_USERNAME -p$SSO_DB_PASSWORD --show-warnings < "$expandedScript" > "$aScriptLog" + rm -f "$expandedScript" + else + mysql --default-character-set=utf8mb4 --protocol=TCP --port $SSO_DB_PORT -h $SSO_DB_HOST -b -vv -u $SSO_DB_USERNAME -p$SSO_DB_PASSWORD --show-warnings < $aScript > "$aScriptLog" + fi die "can not process $aScript" @@ -77,10 +132,20 @@ runScriptNoDb() { runRoot() { aScript=$1 + aScriptLog="target/$(basename $aScript).log" mkdir -p target echo "Installing to $SSO_DB_ROOT@$SSO_DB_HOST:$SSO_DB_PORT $aScript ..." - mysql --default-character-set=utf8 --protocol=TCP --port $SSO_DB_PORT -h $SSO_DB_HOST -b -vv -u $SSO_DB_ROOT -p$SSO_DB_ROOT_PASSWORD --show-warnings < $aScript > target/$aScript.log + + # Expand source commands if file contains .\ commands + if grep -q "^\\\." "$aScript" 2>/dev/null; then + expandedScript=$(mktemp) + expandSourceCommands "$aScript" "$expandedScript" + mysql --default-character-set=utf8mb4 --protocol=TCP --port $SSO_DB_PORT -h $SSO_DB_HOST -b -vv -u $SSO_DB_ROOT -p$SSO_DB_ROOT_PASSWORD --show-warnings < "$expandedScript" > "$aScriptLog" + rm -f "$expandedScript" + else + mysql --default-character-set=utf8mb4 --protocol=TCP --port $SSO_DB_PORT -h $SSO_DB_HOST -b -vv -u $SSO_DB_ROOT -p$SSO_DB_ROOT_PASSWORD --show-warnings < $aScript > "$aScriptLog" + fi die "can not process $aScript" diff --git a/superfly-sql/mi/R1.0.0/R1.0.0_SSO_ROOT.sql b/superfly-sql/mi/R1.0.0/R1.0.0_SSO_ROOT.sql index 9221f7e7..0c94deb1 100644 --- a/superfly-sql/mi/R1.0.0/R1.0.0_SSO_ROOT.sql +++ b/superfly-sql/mi/R1.0.0/R1.0.0_SSO_ROOT.sql @@ -1,3 +1,4 @@ drop database if exists sso; create database sso default character set utf8 collate utf8_general_ci; -grant all privileges on sso.* to 'sso'@'localhost' identified by '123sso123' with grant option; +GRANT ALL PRIVILEGES ON sso.* TO 'sso'@'%' IDENTIFIED BY '123sso123'; +FLUSH PRIVILEGES; diff --git a/superfly-sql/mi/R1.1.0/R1.1.0_SSO.sh b/superfly-sql/mi/R1.1.0/R1.1.0_SSO.sh index 3aeccce0..5127434e 100644 --- a/superfly-sql/mi/R1.1.0/R1.1.0_SSO.sh +++ b/superfly-sql/mi/R1.1.0/R1.1.0_SSO.sh @@ -2,6 +2,8 @@ . ../../functions.sh +runScript ../../src/run_install_command.sql + runScript R1.1.0_SSO.sql runScript R1.1.0_SSO_DML.sql diff --git a/superfly-sql/mi/R1.1.0/R1.1.0_SSO.sql b/superfly-sql/mi/R1.1.0/R1.1.0_SSO.sql index 4d55413f..eb1f2317 100644 --- a/superfly-sql/mi/R1.1.0/R1.1.0_SSO.sql +++ b/superfly-sql/mi/R1.1.0/R1.1.0_SSO.sql @@ -1,23 +1,19 @@ -source ../../src/run_install_command.sql - call run_install_command('alter table subsystems add column allow_list_users char(1) not null default "N"', '42S21'); drop table if exists sessions; create table sessions( - sess_id int(10) auto_increment, - start_date datetime, - user_user_id int(10), - ssys_ssys_id int(10), - callback_information varchar(100), - actions_expired varchar(1) default 'N', - session_expired varchar(1) default 'N', - role_action_list mediumtext, - action_list mediumtext, - primary key pk_sessions (sess_id), - index idx_sessions_ssys_session_expired (ssys_ssys_id, session_expired), - index idx_sessions_user_session_expired (user_user_id, session_expired) + sess_id int(10) auto_increment, + start_date datetime, + user_user_id int(10), + ssys_ssys_id int(10), + callback_information varchar(100), + actions_expired varchar(1) default 'N', + session_expired varchar(1) default 'N', + role_action_list mediumtext, + action_list mediumtext, + primary key pk_sessions (sess_id), + index idx_sessions_ssys_session_expired (ssys_ssys_id, session_expired), + index idx_sessions_user_session_expired (user_user_id, session_expired) ) engine = innodb; call run_install_command('alter table users add column email varchar(255) not null', '42S21'); - -source quartz-tables.sql diff --git a/superfly-sql/mi/R1.3.0/R1.3.0_SSO.sql b/superfly-sql/mi/R1.3.0/R1.3.0_SSO.sql index 0372543f..54fe9353 100644 --- a/superfly-sql/mi/R1.3.0/R1.3.0_SSO.sql +++ b/superfly-sql/mi/R1.3.0/R1.3.0_SSO.sql @@ -1,18 +1,18 @@ drop table if exists smtp_servers; create table smtp_servers( - ssrv_id int(10) auto_increment, - server_name varchar(32) not null, - host varchar(64) not null, - port int(10), - username varchar(64), - password varchar(64), - from_address varchar(64), - unique unq_smtp_servers_server_name (server_name), - primary key pk_smtp_servers(ssrv_id) + ssrv_id int(10) auto_increment, + server_name varchar(32) not null, + host varchar(64) not null, + port int(10), + username varchar(64), + password varchar(64), + from_address varchar(64), + unique unq_smtp_servers_server_name (server_name), + primary key pk_smtp_servers(ssrv_id) ) engine = innodb; call run_install_command('alter table subsystems add column ssrv_ssrv_id int(10)', '42S21'); -call run_install_command('alter table subsystems add foreign key fk_subsystems_smtp_servers foreign key (ssrv_ssrv_id) references smtp_servers (ssrv_id)', '42S21'); +call run_install_command('alter table subsystems add constraint fk_subsystems_smtp_servers foreign key (ssrv_ssrv_id) references smtp_servers (ssrv_id)', '42S21'); commit; \ No newline at end of file diff --git a/superfly-sql/mi/R1.7.3/R1.7.3_SSO.cmd b/superfly-sql/mi/R1.7.3/R1.7.3_SSO.cmd new file mode 100644 index 00000000..ade25257 --- /dev/null +++ b/superfly-sql/mi/R1.7.3/R1.7.3_SSO.cmd @@ -0,0 +1,3 @@ +@set PATH=C:\cygwin\bin;%PATH% +bash R1.7.3_SSO.sh + diff --git a/superfly-sql/mi/R1.7.3/R1.7.3_SSO.sh b/superfly-sql/mi/R1.7.3/R1.7.3_SSO.sh new file mode 100644 index 00000000..25755756 --- /dev/null +++ b/superfly-sql/mi/R1.7.3/R1.7.3_SSO.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +. ../../functions.sh + +runScript R1.7.3_SSO.sql + +runScript R1.7.3_SSO_DML.sql + diff --git a/superfly-sql/mi/R1.7.3/R1.7.3_SSO.sql b/superfly-sql/mi/R1.7.3/R1.7.3_SSO.sql new file mode 100644 index 00000000..df05f4c7 --- /dev/null +++ b/superfly-sql/mi/R1.7.3/R1.7.3_SSO.sql @@ -0,0 +1 @@ +commit; diff --git a/superfly-sql/mi/R1.7.3/R1.7.3_SSO_DML.sql b/superfly-sql/mi/R1.7.3/R1.7.3_SSO_DML.sql new file mode 100644 index 00000000..f9410205 --- /dev/null +++ b/superfly-sql/mi/R1.7.3/R1.7.3_SSO_DML.sql @@ -0,0 +1,5 @@ +alter table subsystems add column if not exists private_key text; +alter table subsystems add column if not exists public_key text; +alter table subsystems add column if not exists encryption_algorithm varchar(16); + +commit; diff --git a/superfly-sql/mi/R1.7.4/R1.7.4_SSO.cmd b/superfly-sql/mi/R1.7.4/R1.7.4_SSO.cmd new file mode 100644 index 00000000..c333e67d --- /dev/null +++ b/superfly-sql/mi/R1.7.4/R1.7.4_SSO.cmd @@ -0,0 +1,3 @@ +@set PATH=C:\cygwin\bin;%PATH% +bash R1.7.4_SSO.sh + diff --git a/superfly-sql/mi/R1.7.4/R1.7.4_SSO.sh b/superfly-sql/mi/R1.7.4/R1.7.4_SSO.sh new file mode 100644 index 00000000..b91a8437 --- /dev/null +++ b/superfly-sql/mi/R1.7.4/R1.7.4_SSO.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +. ../../functions.sh + +runScript R1.7.4_SSO.sql + +runScript R1.7.4_SSO_DML.sql + diff --git a/superfly-sql/mi/R1.7.4/R1.7.4_SSO.sql b/superfly-sql/mi/R1.7.4/R1.7.4_SSO.sql new file mode 100644 index 00000000..1e42cd76 --- /dev/null +++ b/superfly-sql/mi/R1.7.4/R1.7.4_SSO.sql @@ -0,0 +1,25 @@ +drop table if exists event_types; + +create table event_types ( + event_type_id int auto_increment, + event_code varchar(24) not null, + event_name varchar(64) not null, + primary key pk_event_types(event_type_id) +) engine = innodb; + +drop table if exists events; + +create table events ( + event_id bigint auto_increment, + event_time datetime not null, + event_type_id int, + event_data varchar(128) not null, + subsystem_id int null, + index idx_events_event_time (event_time), + index idx_events_subsystem_id (subsystem_id), + primary key pk_events(event_id), + constraint fk_event_types foreign key (event_type_id) references event_types (event_type_id), + constraint fk_events_subsystem foreign key (subsystem_id) references subsystems (ssys_id) +) engine = innodb; + +commit; diff --git a/superfly-sql/mi/R1.7.4/R1.7.4_SSO_DML.sql b/superfly-sql/mi/R1.7.4/R1.7.4_SSO_DML.sql new file mode 100644 index 00000000..2cb610de --- /dev/null +++ b/superfly-sql/mi/R1.7.4/R1.7.4_SSO_DML.sql @@ -0,0 +1,10 @@ +delete from event_types where event_type_id=1; +insert into event_types (event_type_id, event_code, event_name) + values (1,'PASSWORD_RESET','Password reset'); +delete from event_types where event_type_id=2; +insert into event_types (event_type_id, event_code, event_name) + values (2,'ACCOUNT_LOCK','Account lock'); +delete from event_types where event_type_id=3; +insert into event_types (event_type_id, event_code, event_name) + values (3,'ACCOUNT_SUSPEND','Account suspend'); +commit; diff --git a/superfly-sql/mi/SQL_VERIFICATION.md b/superfly-sql/mi/SQL_VERIFICATION.md new file mode 100644 index 00000000..6e9b0d60 --- /dev/null +++ b/superfly-sql/mi/SQL_VERIFICATION.md @@ -0,0 +1,38 @@ +# Проверка SQL миграций (mi) + +## Внесённые исправления + +### 1. `src/run_install_command.sql` +- **Проблема:** Двойной вызов `DEALLOCATE PREPARE v_stmt` — при срабатывании условия выполнялся дважды, что вызывало ошибку. +- **Исправление:** `DEALLOCATE PREPARE` вызывается один раз сразу после `EXECUTE`, до блока `IF`. + +### 2. `mi/R1.7.0/quartz-to-2.0.sql` +- **Проблема:** Неявная область видимости для `SET foreign_key_checks`. +- **Исправление:** Используется `SET SESSION foreign_key_checks` для явного указания сессии. + +## Проверка синтаксиса MySQL/MariaDB + +| Элемент | Статус | +|---------|--------| +| `SET SESSION foreign_key_checks` | ✓ Корректно (MySQL 5.7+, MariaDB) | +| `CREATE TABLE ... ENGINE=InnoDB DEFAULT CHARSET=utf8` | ✓ Корректно | +| `INSERT ... SELECT` с `CASE WHEN` | ✓ Корректно | +| `RENAME TABLE` | ✓ Корректно | +| `DROP TABLE` / `DROP TABLE IF EXISTS` | ✓ Корректно | +| `FOREIGN KEY` в `CREATE TABLE` | ✓ Корректно | +| `NUMERIC(13,4)` | ✓ Корректно (синоним DECIMAL) | +| `TINYINT(1)` для bool | ✓ Корректно | + +## Замечания + +### R1.0.0_SSO_ROOT.sql +- `GRANT ... IDENTIFIED BY` — устарел в MySQL 8.0.16+, но поддерживается в MariaDB. +- Для MySQL 8.0+ рекомендуется: `CREATE USER` + `GRANT` отдельно. + +### quartz-to-2.0.sql +- При запуске `all_mi.sh` база пересоздаётся в R1.0.0 (`drop database if exists sso`), поэтому миграция всегда выполняется с чистого состояния. +- Скрипт рассчитан на однократное выполнение в рамках полного прогона миграций. + +### run_install_command.sql +- `delimiter $$` — команда mysql-клиента, при `mysql < file.sql` обрабатывается корректно. +- SQLSTATE коды (`42S21`, `42S02` и т.д.) — специфичны для MySQL/MariaDB. diff --git a/superfly-sql/src/all-proc.sql b/superfly-sql/src/all-proc.sql index 9cc18750..31fa3e19 100644 --- a/superfly-sql/src/all-proc.sql +++ b/superfly-sql/src/all-proc.sql @@ -82,6 +82,8 @@ create table mysql_routines_return_arguments ( \. int/int_unlink_user_role_actions.prc +\. int/int_create_event.prc + \. ui_copy/ui_copy_action_properties.prc \. ui_filter/ui_filter_subsystems.prc @@ -218,6 +220,8 @@ create table mysql_routines_return_arguments ( \. ui_get/ui_get_expired_sessions.prc +\. ui_get/ui_get_events.prc + \. ui_update/ui_lock_user.prc \. ui_update/ui_unlock_user.prc diff --git a/superfly-sql/src/int/int_create_event.prc b/superfly-sql/src/int/int_create_event.prc new file mode 100644 index 00000000..7000e679 --- /dev/null +++ b/superfly-sql/src/int/int_create_event.prc @@ -0,0 +1,27 @@ +drop procedure if exists int_create_event; +delimiter $$ +create procedure int_create_event( + i_event_code varchar(32), + i_event_data varchar(128), + i_subsystem_id int +) +begin + declare v_event_type_id int; + declare v_message varchar(128); + + select event_type_id into v_event_type_id + from event_types + where event_code = i_event_code + limit 1; + + if v_event_type_id is null then + set v_message = concat('Unknown event_code: ', i_event_code); + signal sqlstate '45000' + set message_text = v_message; + end if; + + insert into events (event_time, event_type_id, event_data, subsystem_id) + values (now(), v_event_type_id, i_event_data, i_subsystem_id); +end +$$ +delimiter ; diff --git a/superfly-sql/src/stub/reset_password.prc b/superfly-sql/src/stub/reset_password.prc index aa90e8c0..1b74eb35 100644 --- a/superfly-sql/src/stub/reset_password.prc +++ b/superfly-sql/src/stub/reset_password.prc @@ -5,6 +5,12 @@ create procedure reset_password(i_user_id int(10), ) main_sql: begin + declare v_user_name varchar(32); + + select user_name into v_user_name + from users where user_id=i_user_id; + + update users u set u.user_password = coalesce(i_user_password,user_password), @@ -14,6 +20,8 @@ main_sql: u.hotp_logins_failed = null where u.user_id = i_user_id; + call int_create_event('PASSWORD_RESET', v_user_name, null); + select 'OK' status, null error_message; end $$ diff --git a/superfly-sql/src/ui_create/ui_create_subsystem.prc b/superfly-sql/src/ui_create/ui_create_subsystem.prc index 8c1422be..f24dad3e 100644 --- a/superfly-sql/src/ui_create/ui_create_subsystem.prc +++ b/superfly-sql/src/ui_create/ui_create_subsystem.prc @@ -10,6 +10,9 @@ create procedure ui_create_subsystem(i_subsystem_name varchar(32), i_subsystem_url varchar(255), i_landing_url varchar(255), i_login_form_css_url varchar(255), + i_private_key text, + i_public_key text, + i_encryption_algorithm varchar(16), out o_ssys_id int(10) ) main_sql: @@ -26,7 +29,10 @@ create procedure ui_create_subsystem(i_subsystem_name varchar(32), subsystem_token, subsystem_url, landing_url, - login_form_css_url + login_form_css_url, + private_key, + public_key, + encryption_algorithm ) values ( i_subsystem_name, @@ -39,7 +45,10 @@ create procedure ui_create_subsystem(i_subsystem_name varchar(32), i_subsystem_token, i_subsystem_url, i_landing_url, - i_login_form_css_url + i_login_form_css_url, + i_private_key, + i_public_key, + i_encryption_algorithm ); set o_ssys_id = last_insert_id(); diff --git a/superfly-sql/src/ui_get/ui_get_events.prc b/superfly-sql/src/ui_get/ui_get_events.prc new file mode 100644 index 00000000..f7323f0e --- /dev/null +++ b/superfly-sql/src/ui_get/ui_get_events.prc @@ -0,0 +1,31 @@ +drop procedure if exists ui_get_events; +delimiter $$ +create procedure ui_get_events(i_last_event_time datetime, i_limit int, i_subsystem_name varchar(32)) + main_sql: + begin + select e.event_id + ,e.event_time + ,e.event_data + ,et.event_code as event_type_code + from events e + inner join event_types et on et.event_type_id = e.event_type_id + where e.event_time > i_last_event_time + and ( + i_subsystem_name is null + or e.subsystem_id = (select ssys_id from subsystems where subsystem_name = i_subsystem_name limit 1) + or e.subsystem_id is null + ) + order by e.event_time + limit i_limit + ; + end +$$ +delimiter ; +call save_routine_information('ui_get_events', + concat_ws(',', + 'event_id int', + 'event_time datetime', + 'event_data varchar', + 'event_type_code varchar' + ) + ); diff --git a/superfly-sql/src/ui_get/ui_get_subsystem.prc b/superfly-sql/src/ui_get/ui_get_subsystem.prc index 348b2494..f0d9e30d 100644 --- a/superfly-sql/src/ui_get/ui_get_subsystem.prc +++ b/superfly-sql/src/ui_get/ui_get_subsystem.prc @@ -15,7 +15,10 @@ create procedure ui_get_subsystem(i_ssys_id int(10)) ss.subsystem_token, ss.subsystem_url, ss.landing_url, - ss.login_form_css_url + ss.login_form_css_url, + ss.public_key, + ss.private_key, + ss.encryption_algorithm from subsystems ss left join smtp_servers smtp on smtp.ssrv_id = ss.ssrv_ssrv_id @@ -37,6 +40,9 @@ call save_routine_information('ui_get_subsystem', 'subsystem_token varchar', 'subsystem_url varchar', 'landing_url varchar', - 'login_form_css_url varchar' + 'login_form_css_url varchar', + 'public_key varchar', + 'private_key varchar', + 'encryption_algorithm varchar' ) ); diff --git a/superfly-sql/src/ui_get/ui_get_subsystem_by_name.prc b/superfly-sql/src/ui_get/ui_get_subsystem_by_name.prc index 272667e4..e5819aec 100644 --- a/superfly-sql/src/ui_get/ui_get_subsystem_by_name.prc +++ b/superfly-sql/src/ui_get/ui_get_subsystem_by_name.prc @@ -15,7 +15,10 @@ create procedure ui_get_subsystem_by_name(i_subsystem_name varchar(32)) ss.subsystem_token, ss.subsystem_url, ss.landing_url, - ss.login_form_css_url + ss.login_form_css_url, + ss.private_key, + ss.public_key, + ss.encryption_algorithm from subsystems ss left join smtp_servers smtp on smtp.ssrv_id = ss.ssrv_ssrv_id @@ -37,6 +40,9 @@ call save_routine_information('ui_get_subsystem_by_name', 'subsystem_token varchar', 'subsystem_url varchar', 'landing_url varchar', - 'login_form_css_url varchar' + 'login_form_css_url varchar', + 'private_key varchar', + 'public_key varchar', + 'encryption_algorithm varchar' ) ); diff --git a/superfly-sql/src/ui_update/ui_edit_subsystem_properties.prc b/superfly-sql/src/ui_update/ui_edit_subsystem_properties.prc index 3f6670ff..19beb90b 100644 --- a/superfly-sql/src/ui_update/ui_edit_subsystem_properties.prc +++ b/superfly-sql/src/ui_update/ui_edit_subsystem_properties.prc @@ -10,7 +10,10 @@ create procedure ui_edit_subsystem_properties(i_ssys_id int(10), i_subsystem_token varchar(64), i_subsystem_url varchar(255), i_landing_url varchar(255), - i_login_form_css_url varchar(255) + i_login_form_css_url varchar(255), + i_private_key text, + i_public_key text, + i_encryption_algorithm varchar(16) ) main_sql: begin @@ -24,7 +27,10 @@ create procedure ui_edit_subsystem_properties(i_ssys_id int(10), subsystem_token = i_subsystem_token, subsystem_url = i_subsystem_url, landing_url = i_landing_url, - login_form_css_url = i_login_form_css_url + login_form_css_url = i_login_form_css_url, + private_key = i_private_key, + public_key = i_public_key, + encryption_algorithm = i_encryption_algorithm where ssys_id = i_ssys_id; select 'OK' status, null error_message; diff --git a/superfly-sql/src/ui_update/ui_lock_user.prc b/superfly-sql/src/ui_update/ui_lock_user.prc index 94c37f21..0282433b 100644 --- a/superfly-sql/src/ui_update/ui_lock_user.prc +++ b/superfly-sql/src/ui_update/ui_lock_user.prc @@ -1,25 +1,47 @@ -drop procedure if exists ui_lock_user; -delimiter $$ -create procedure ui_lock_user(i_user_id int(10)) - main_sql: - begin - update users - set is_account_locked = "Y" - where user_id = i_user_id; - -/* - update sessions - set session_expired = 'Y' - where user_user_id = i_user_id; -*/ - - select 'OK' status, null error_message; - end -$$ -delimiter ; -call save_routine_information('ui_lock_user', - concat_ws(',', - 'status varchar', - 'error_message varchar' - ) - ); \ No newline at end of file +drop procedure if exists ui_lock_user; +delimiter $$ +create procedure ui_lock_user(i_user_id int(10)) + main_sql: + begin + declare v_user_name varchar(32); + declare v_subsys_id int; + declare v_count int default 0; + declare done int default 0; + declare cur cursor for + select distinct r.ssys_ssys_id + from user_roles ur + join roles r on r.role_id = ur.role_role_id + where ur.user_user_id = i_user_id + and r.ssys_ssys_id is not null; + declare continue handler for not found set done = 1; + + update users + set is_account_locked = 'Y' + where user_id = i_user_id; + + select user_name into v_user_name + from users where user_id = i_user_id; + + open cur; + read_loop: loop + fetch cur into v_subsys_id; + if done then leave read_loop; end if; + call int_create_event('ACCOUNT_LOCK', v_user_name, v_subsys_id); + set v_count = v_count + 1; + end loop; + close cur; + + if v_count = 0 then + call int_create_event('ACCOUNT_LOCK', v_user_name, null); + end if; + + select 'OK' status, null error_message; + end +$$ +delimiter ; +call save_routine_information('ui_lock_user', + concat_ws(',', + 'status varchar', + 'error_message varchar' + ) + ); diff --git a/superfly-sql/src/ui_update/ui_suspend_user.prc b/superfly-sql/src/ui_update/ui_suspend_user.prc index e5102bd9..83aa8afd 100644 --- a/superfly-sql/src/ui_update/ui_suspend_user.prc +++ b/superfly-sql/src/ui_update/ui_suspend_user.prc @@ -3,10 +3,38 @@ delimiter $$ create procedure ui_suspend_user(i_user_id int(11)) main_sql: begin - update users set is_account_locked = 'Y', is_account_suspended = 'Y' - where user_id = i_user_id; - - select 'OK' status, null error_message; + declare v_user_name varchar(32); + declare v_subsys_id int; + declare v_count int default 0; + declare done int default 0; + declare cur cursor for + select distinct r.ssys_ssys_id + from user_roles ur + join roles r on r.role_id = ur.role_role_id + where ur.user_user_id = i_user_id + and r.ssys_ssys_id is not null; + declare continue handler for not found set done = 1; + + update users set is_account_locked = 'Y', is_account_suspended = 'Y' + where user_id = i_user_id; + + select user_name into v_user_name + from users where user_id = i_user_id; + + open cur; + read_loop: loop + fetch cur into v_subsys_id; + if done then leave read_loop; end if; + call int_create_event('ACCOUNT_SUSPEND', v_user_name, v_subsys_id); + set v_count = v_count + 1; + end loop; + close cur; + + if v_count = 0 then + call int_create_event('ACCOUNT_SUSPEND', v_user_name, null); + end if; + + select 'OK' status, null error_message; end $$ delimiter ; @@ -15,4 +43,4 @@ call save_routine_information('ui_suspend_user', 'status varchar', 'error_message varchar' ) - ); \ No newline at end of file + ); diff --git a/superfly-web/pom.xml b/superfly-web/pom.xml index a7028116..be2b2e76 100644 --- a/superfly-web/pom.xml +++ b/superfly-web/pom.xml @@ -28,7 +28,7 @@ com.payneteasy.superfly - superfly-spring-security + superfly-spring-security-ee10 @@ -77,29 +77,6 @@ org.springframework.security spring-security-web - - - org.quartz-scheduler - quartz - - - - - com.googlecode.xremoting - xremoting-core - - - - com.thoughtworks.xstream - xstream - - - - - com.caucho - hessian - - @@ -193,6 +170,11 @@ lombok provided + + + com.fasterxml.jackson.core + jackson-databind + diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/controller/api/RemoteApiController.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/controller/api/RemoteApiController.java index 6e37462f..3393134a 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/controller/api/RemoteApiController.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/controller/api/RemoteApiController.java @@ -52,6 +52,7 @@ ResponseEntity processRequest( bodyBuilder = ResponseEntity.accepted(); result= ExceptionWrapper.from(e.getTargetException()); } catch (Exception e) { + log.error("[FIX] Unhandled error in remoting for method={}: {}", methodName, e.getMessage(), e); bodyBuilder = ResponseEntity.internalServerError(); result = ExceptionWrapper.from(e); } diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/mvc/RemoteAuthCheckController.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/mvc/RemoteAuthCheckController.java new file mode 100644 index 00000000..67b5606f --- /dev/null +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/mvc/RemoteAuthCheckController.java @@ -0,0 +1,126 @@ +package com.payneteasy.superfly.web.mvc; + +import com.payneteasy.superfly.service.RemoteAuthService; +import com.payneteasy.superfly.service.RemoteAuthService.RemoteAuthException; +import com.payneteasy.superfly.web.mvc.model.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.*; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.UUID; + +@Component +@RestController +public class RemoteAuthCheckController { + + private final RemoteAuthService remoteAuthService; + + @Autowired + public RemoteAuthCheckController(RemoteAuthService remoteAuthService) { + this.remoteAuthService = remoteAuthService; + } + + @RequestMapping(value = "/check-password/{subsystemName}/{username}", method = RequestMethod.POST, produces = "application/json") + public CheckPasswordResponse checkPassword(@PathVariable("subsystemName") String subsystemName, + @PathVariable("username") String username, + @RequestBody CheckPasswordRequest requestBody, + @RequestHeader(value = "Authorization", required = false) String authHeader, + HttpServletRequest request) throws RemoteAuthException { + + String bearerToken = extractBearerToken(authHeader); + if (bearerToken == null) { + throw new UnauthorizedException("Missing or invalid Authorization header"); + } + + if (requestBody.getPasswordEncrypted() == null) { + throw new BadRequestException("Missing passwordEncrypted field"); + } + + if (!username.equals(requestBody.getUsername())) { + throw new BadRequestException("Username in path and body must match"); + } + + RemoteAuthService.RemoteAuthSession session = remoteAuthService.checkPassword(subsystemName, username, requestBody.getPasswordEncrypted(), bearerToken, request.getRemoteAddr(), request.getHeader("User-Agent")); + return new CheckPasswordResponse(username, session.getSessionToken(), session.isOtpRequired()); + } + + @RequestMapping(value = "/check-otp/{subsystemName}/{username}", method = RequestMethod.POST, produces = "application/json") + public CheckOtpResponse checkOtp(@PathVariable("subsystemName") String subsystemName, + @PathVariable("username") String username, + @RequestBody CheckOtpRequest requestBody, + @RequestHeader(value = "Authorization", required = false) String authHeader) throws RemoteAuthException { + + String bearerToken = extractBearerToken(authHeader); + if (bearerToken == null) { + throw new UnauthorizedException("Missing or invalid Authorization header"); + } + + if (requestBody.getOtpEncrypted() == null || requestBody.getSessionToken() == null) { + throw new BadRequestException("Missing otpEncrypted or sessionToken field"); + } + + if (!username.equals(requestBody.getUsername())) { + throw new BadRequestException("Username in path and body must match"); + } + + String result = remoteAuthService.checkOtp(subsystemName, username, requestBody.getOtpEncrypted(), requestBody.getSessionToken(), bearerToken); + return new CheckOtpResponse(username, requestBody.getSessionToken(), result); + } + + private String extractBearerToken(String authHeader) { + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return authHeader.substring(7); + } + return null; + } + + @ExceptionHandler(RemoteAuthException.class) + public ResponseEntity handleRemoteAuthException(RemoteAuthException e) { + String errorCode = e.getErrorCode(); + HttpStatus status = HttpStatus.BAD_REQUEST; + + if ("INTERNAL_ERROR".equals(errorCode)) { + status = HttpStatus.INTERNAL_SERVER_ERROR; + } else if ("UNAUTHORIZED".equals(errorCode)) { + status = HttpStatus.UNAUTHORIZED; + } + + return createErrorResponse(errorCode, e.getMessage(), status); + } + + @ExceptionHandler(UnauthorizedException.class) + public ResponseEntity handleUnauthorized(UnauthorizedException e) { + return createErrorResponse("UNAUTHORIZED", e.getMessage(), HttpStatus.UNAUTHORIZED); + } + + @ExceptionHandler(BadRequestException.class) + public ResponseEntity handleBadRequest(BadRequestException e) { + return createErrorResponse("BAD_REQUEST", e.getMessage(), HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleJsonError(HttpMessageNotReadableException e) { + return createErrorResponse("BAD_REQUEST", "Invalid JSON", HttpStatus.BAD_REQUEST); + } + + private ResponseEntity createErrorResponse(String type, String title, HttpStatus status) { + ErrorResponse error = new ErrorResponse(type, title, title); + error.setErrorId(UUID.randomUUID().toString()); + + return ResponseEntity.status(status) + .header("Content-Language", "en") + .body(error); + } + + static class UnauthorizedException extends RuntimeException { + public UnauthorizedException(String message) { super(message); } + } + + static class BadRequestException extends RuntimeException { + public BadRequestException(String message) { super(message); } + } +} diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/mvc/model/CheckOtpRequest.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/mvc/model/CheckOtpRequest.java new file mode 100644 index 00000000..3b654e28 --- /dev/null +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/mvc/model/CheckOtpRequest.java @@ -0,0 +1,32 @@ +package com.payneteasy.superfly.web.mvc.model; + +public class CheckOtpRequest { + private String username; + private String otpEncrypted; + private String sessionToken; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getOtpEncrypted() { + return otpEncrypted; + } + + public void setOtpEncrypted(String otpEncrypted) { + this.otpEncrypted = otpEncrypted; + } + + public String getSessionToken() { + return sessionToken; + } + + public void setSessionToken(String sessionToken) { + this.sessionToken = sessionToken; + } +} + diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/mvc/model/CheckOtpResponse.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/mvc/model/CheckOtpResponse.java new file mode 100644 index 00000000..00273e47 --- /dev/null +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/mvc/model/CheckOtpResponse.java @@ -0,0 +1,26 @@ +package com.payneteasy.superfly.web.mvc.model; + +public class CheckOtpResponse { + private String username; + private String sessionToken; + private String result; + + public CheckOtpResponse(String username, String sessionToken, String result) { + this.username = username; + this.sessionToken = sessionToken; + this.result = result; + } + + public String getUsername() { + return username; + } + + public String getSessionToken() { + return sessionToken; + } + + public String getResult() { + return result; + } +} + diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/mvc/model/CheckPasswordRequest.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/mvc/model/CheckPasswordRequest.java new file mode 100644 index 00000000..80fe9ec9 --- /dev/null +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/mvc/model/CheckPasswordRequest.java @@ -0,0 +1,23 @@ +package com.payneteasy.superfly.web.mvc.model; + +public class CheckPasswordRequest { + private String username; + private String passwordEncrypted; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPasswordEncrypted() { + return passwordEncrypted; + } + + public void setPasswordEncrypted(String passwordEncrypted) { + this.passwordEncrypted = passwordEncrypted; + } +} + diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/mvc/model/CheckPasswordResponse.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/mvc/model/CheckPasswordResponse.java new file mode 100644 index 00000000..d62f5c5b --- /dev/null +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/mvc/model/CheckPasswordResponse.java @@ -0,0 +1,26 @@ +package com.payneteasy.superfly.web.mvc.model; + +public class CheckPasswordResponse { + private final String username; + private final String sessionToken; + private final boolean otpRequired; + + public CheckPasswordResponse(String username, String sessionToken, boolean otpRequired) { + this.username = username; + this.sessionToken = sessionToken; + this.otpRequired = otpRequired; + } + + public String getUsername() { + return username; + } + + public String getSessionToken() { + return sessionToken; + } + + public boolean isOtpRequired() { + return otpRequired; + } +} + diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/mvc/model/ErrorResponse.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/mvc/model/ErrorResponse.java new file mode 100644 index 00000000..cf0a1bda --- /dev/null +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/mvc/model/ErrorResponse.java @@ -0,0 +1,35 @@ +package com.payneteasy.superfly.web.mvc.model; + +public class ErrorResponse { + private String type; + private String title; + private String detail; + private String errorId; + + public ErrorResponse(String type, String title, String detail) { + this.type = type; + this.title = title; + this.detail = detail; + } + + public String getType() { + return type; + } + + public String getTitle() { + return title; + } + + public String getDetail() { + return detail; + } + + public String getErrorId() { + return errorId; + } + + public void setErrorId(String errorId) { + this.errorId = errorId; + } +} + diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/obtainer/AuthenticationPrincipalSubsystemIdentifierObtainer.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/obtainer/AuthenticationPrincipalSubsystemIdentifierObtainer.java index 75fb9e1f..2b7be89e 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/obtainer/AuthenticationPrincipalSubsystemIdentifierObtainer.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/obtainer/AuthenticationPrincipalSubsystemIdentifierObtainer.java @@ -3,24 +3,23 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; import com.payneteasy.superfly.service.impl.remote.SubsystemIdentifierObtainer; /** - * Obtains subsystem identifier in the following way: returns principal - * property from Authentication stored in the current SecurityContext. - * This is to extract username (which is interpreted as subsystem identifier) - * authenticated using SSL certificates, for instance. - * - * @author Roman Puchkovskiy - * @see X509PreAuthenticatedProcessingFilter - * @see org.springframework.security.web.authentication.preauth.x509.SubjectDnX509PrincipalExtractor - * @see org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor + * Obtains subsystem identifier: uses hint when provided, otherwise falls back + * to the authenticated principal in the SecurityContext (set by SubsystemAuthenticationFilter + * via X-Subsystem-Name header). */ +@Component public class AuthenticationPrincipalSubsystemIdentifierObtainer implements SubsystemIdentifierObtainer { public String obtainSubsystemIdentifier(String hint) { + if (hint != null) { + return hint; + } Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); return getUsername(authentication); } diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SecurityAuditApplicationListener.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SecurityAuditApplicationListener.java new file mode 100644 index 00000000..e0346886 --- /dev/null +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SecurityAuditApplicationListener.java @@ -0,0 +1,111 @@ +package com.payneteasy.superfly.web.security; + +import com.payneteasy.superfly.service.LoggerSink; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationListener; +import org.springframework.security.authentication.event.AbstractAuthenticationEvent; +import org.springframework.security.authentication.event.AbstractAuthenticationFailureEvent; +import org.springframework.security.authentication.event.AuthenticationSuccessEvent; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +/** + * Централизованный PCI DSS audit trail для двух каналов аутентификации subsystem-ов: + * + *

    + *
  • SUBSYSTEM_AUTH — header-based: {@code X-Subsystem-Name} + {@code X-Subsystem-Token} + * (обрабатывается {@link SubsystemAuthenticationProvider})
  • + *
  • X509_AUTH — client certificate: CN сертификата проверяется против БД + * (обрабатывается {@code X509PreAuthenticatedAuthenticationProvider})
  • + *
+ * + *

Spring Security публикует {@link AbstractAuthenticationEvent} автоматически после + * каждого вызова {@code AuthenticationManager.authenticate()}, поэтому слушатель даёт + * единую точку аудита без изменения фильтров или провайдеров.

+ * + *

Каждое событие пишется в два канала: + *

    + *
  1. {@code log.info/warn} — попадает в обычный application log (видно ops-команде)
  2. + *
  3. {@link LoggerSink#info} — попадает в отдельный structured audit log + * (PCI DSS 10.2.1: требует отдельного, защищённого от изменения audit trail)
  4. + *
+ *

+ */ +@Component +public class SecurityAuditApplicationListener implements ApplicationListener { + + private static final Logger log = LoggerFactory.getLogger(SecurityAuditApplicationListener.class); + + private final LoggerSink loggerSink; + + public SecurityAuditApplicationListener(LoggerSink loggerSink) { + this.loggerSink = loggerSink; + } + + @Override + public void onApplicationEvent(AbstractAuthenticationEvent event) { + Authentication auth = event.getAuthentication(); + + // Аудируем только наши два канала; форм-логин и остальные провайдеры — вне scope + if (!isAuditedType(auth)) { + return; + } + + String eventType = resolveEventType(auth); + String identity = extractIdentity(auth); + String sourceIp = resolveSourceIp(); + + if (event instanceof AuthenticationSuccessEvent) { + log.info("Auth success type={} identity={} ip={}", eventType, identity, sourceIp); + loggerSink.info(log, eventType, true, identity); + } else if (event instanceof AbstractAuthenticationFailureEvent failure) { + log.warn("Auth failure type={} identity={} ip={} cause={}", + eventType, identity, sourceIp, failure.getException().getMessage()); + loggerSink.info(log, eventType, false, identity); + } + } + + private boolean isAuditedType(Authentication auth) { + return auth instanceof SubsystemAuthenticationToken + || auth instanceof PreAuthenticatedAuthenticationToken; + } + + private String resolveEventType(Authentication auth) { + if (auth instanceof SubsystemAuthenticationToken) return "SUBSYSTEM_AUTH"; + if (auth instanceof PreAuthenticatedAuthenticationToken) return "X509_AUTH"; + return "AUTH"; + } + + private String extractIdentity(Authentication auth) { + if (auth instanceof SubsystemAuthenticationToken) { + return "subsystem=" + auth.getName(); + } + // X509: Spring передаёт полный SubjectDN ("CN=billing, O=Acme, C=US") — + // логируем только CN, чтобы не утекала оргинформация из сертификата + return "cn=" + extractCn(auth.getName()); + } + + private String extractCn(String dn) { + if (dn == null) return "unknown"; + String upper = dn.toUpperCase(); + int idx = upper.indexOf("CN="); + if (idx < 0) return dn; + int end = dn.indexOf(',', idx); + String cn = end < 0 ? dn.substring(idx + 3) : dn.substring(idx + 3, end); + return cn.trim(); + } + + private String resolveSourceIp() { + try { + // RequestContextHolder заполнен только в request-потоке (не в async/scheduled) + ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + return attrs != null ? attrs.getRequest().getRemoteAddr() : "unknown"; + } catch (Exception e) { + return "unknown"; + } + } +} diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationConverter.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationConverter.java index 3bebfcf9..9cd66416 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationConverter.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationConverter.java @@ -1,71 +1,42 @@ package com.payneteasy.superfly.web.security; -import com.payneteasy.superfly.model.ui.subsystem.UISubsystem; -import com.payneteasy.superfly.security.exception.InsufficientAuthenticationException; -import com.payneteasy.superfly.service.SubsystemService; -import com.payneteasy.superfly.web.security.exception.SubsystemNotAllowedHostException; -import com.payneteasy.superfly.web.security.exception.SubsystemNotFoundException; import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.AuthenticationConverter; -import java.net.URI; - public class SubsystemAuthenticationConverter implements AuthenticationConverter { - private final SubsystemService subsystemService; - - public SubsystemAuthenticationConverter(SubsystemService subsystemService) { - this.subsystemService = subsystemService; - } + private static final Logger log = LoggerFactory.getLogger(SubsystemAuthenticationConverter.class); @Override public Authentication convert(HttpServletRequest request) { String subsystemName = request.getHeader("X-Subsystem-Name"); String subsystemToken = request.getHeader("X-Subsystem-Token"); - if (subsystemName == null || subsystemToken == null) { + if (subsystemName == null || subsystemName.isBlank() + || subsystemToken == null || subsystemToken.isBlank()) { + log.debug("Subsystem headers missing or blank, skipping subsystem auth"); return null; } - UISubsystem subsystem = subsystemService.getSubsystemByName(subsystemName); - if (subsystem == null) { - throw new SubsystemNotFoundException("Subsystem '" + subsystemName + "' not found"); - } - - if (!subsystem.getSubsystemToken().equals(subsystemToken)) { - throw new SubsystemNotAllowedHostException("Not allowed for your subsystem"); + if (containsControlChars(subsystemName) || containsControlChars(subsystemToken)) { + log.warn("Subsystem headers contain control characters, rejecting"); + return null; } - // checkRequestHost(request, subsystemName); + log.debug("Subsystem auth requested for subsystem={}", subsystemName); return new SubsystemAuthenticationToken(subsystemName, subsystemToken); } - private void checkRequestHost(HttpServletRequest request, UISubsystem subsystem) { - - String landingUrl = subsystem.getLandingUrl(); - String host = getClientHost(request); - - if (!isHostAllowed(landingUrl, host)) { - throw new SubsystemNotAllowedHostException("Host not allowed for your subsystem"); - } - } - - private String getClientHost(HttpServletRequest request) { - String host = request.getHeader("X-Forwarded-Host"); - return host != null ? host : request.getHeader("Host"); - } - - private boolean isHostAllowed(String landingUrl, String host) { - if (landingUrl == null || host == null) { - return false; - } - try { - URI landingUri = new URI(landingUrl); - URI refererUri = new URI(host); - return landingUri.getHost().equals(refererUri.getHost()); - } catch (Exception e) { - return false; + private static boolean containsControlChars(String value) { + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '\r' || c == '\n' || c == '\t' || c < 0x20) { + return true; + } } + return false; } } diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationFilter.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationFilter.java index 9e87c283..2c2ec2be 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationFilter.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationFilter.java @@ -1,7 +1,5 @@ package com.payneteasy.superfly.web.security; -import com.payneteasy.superfly.service.SubsystemService; -import com.payneteasy.superfly.web.security.handler.JsonAuthenticationFailureHandler; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.web.authentication.AuthenticationFilter; import org.springframework.security.web.util.matcher.RequestMatcher; @@ -10,10 +8,9 @@ public class SubsystemAuthenticationFilter extends AuthenticationFilter { public SubsystemAuthenticationFilter( RequestMatcher requiresAuthenticationRequestMatcher, - AuthenticationManager authenticationManager, - SubsystemService subsystemService + AuthenticationManager authenticationManager ) { - super(authenticationManager, new SubsystemAuthenticationConverter(subsystemService)); + super(authenticationManager, new SubsystemAuthenticationConverter()); setRequestMatcher(requiresAuthenticationRequestMatcher); } } diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationProvider.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationProvider.java index df1c7617..4eee22ee 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationProvider.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/security/SubsystemAuthenticationProvider.java @@ -1,16 +1,23 @@ package com.payneteasy.superfly.web.security; import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AccountStatusUserDetailsChecker; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsChecker; import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import java.security.MessageDigest; +import java.nio.charset.StandardCharsets; @Slf4j public class SubsystemAuthenticationProvider implements AuthenticationProvider { private final UserDetailsService subsystemDetailsService; + private final UserDetailsChecker userDetailsChecker = new AccountStatusUserDetailsChecker(); public SubsystemAuthenticationProvider(UserDetailsService subsystemDetailsService) { this.subsystemDetailsService = subsystemDetailsService; @@ -21,28 +28,49 @@ public Authentication authenticate(Authentication authentication) { if (!supports(authentication.getClass())) { return null; } - log.debug("Subsystem authentication request: {}", authentication); + log.debug("Subsystem authentication request for principal={}", sanitize(authentication.getPrincipal())); + if (authentication.getPrincipal() == null) { - log.debug("No subsystem principal found in request."); + log.warn("Subsystem auth rejected: no principal in request"); throw new BadCredentialsException("No subsystem principal found in request."); } if (authentication.getCredentials() == null) { - log.debug("No subsystem token found in request."); + log.warn("Subsystem auth rejected: no token in request"); throw new BadCredentialsException("No subsystem token found in request."); } String subsystemName = (String) authentication.getPrincipal(); String credentials = (String) authentication.getCredentials(); - UserDetails userDetails = subsystemDetailsService.loadUserByUsername(subsystemName); + if (credentials.isEmpty()) { + log.warn("Subsystem auth rejected: empty token in request"); + throw new BadCredentialsException("No subsystem token found in request."); + } + + log.debug("Loading subsystem details for subsystem={}", sanitize(subsystemName)); + UserDetails userDetails; + try { + userDetails = subsystemDetailsService.loadUserByUsername(subsystemName); + } catch (UsernameNotFoundException e) { + // Намеренно не различаем "не найден" и "неверный токен" — предотвращаем enumeration subsystem-ов + log.warn("Subsystem auth rejected: invalid token for subsystem={}", sanitize(subsystemName)); + throw new BadCredentialsException("Invalid subsystem token"); + } + + userDetailsChecker.check(userDetails); - if (!userDetails.getPassword().equals(credentials)) { + String storedToken = userDetails.getPassword(); + if (storedToken == null || storedToken.isEmpty() || !MessageDigest.isEqual( + storedToken.getBytes(StandardCharsets.UTF_8), + credentials.getBytes(StandardCharsets.UTF_8))) { + log.warn("Subsystem auth rejected: invalid token for subsystem={}", sanitize(subsystemName)); throw new BadCredentialsException("Invalid subsystem token"); } + log.info("Subsystem auth granted for subsystem={}", sanitize(subsystemName)); return new SubsystemAuthenticationToken( - userDetails, - authentication.getCredentials(), + subsystemName, + null, userDetails.getAuthorities() ); } @@ -51,4 +79,9 @@ public Authentication authenticate(Authentication authentication) { public boolean supports(Class authentication) { return SubsystemAuthenticationToken.class.isAssignableFrom(authentication); } + + private static String sanitize(Object value) { + if (value == null) return null; + return value.toString().replaceAll("[\\r\\n\\t]", "_"); + } } diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/security/securehandler/BasicAuthorizationParser.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/security/securehandler/BasicAuthorizationParser.java index 4ec877ee..c00d5720 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/security/securehandler/BasicAuthorizationParser.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/security/securehandler/BasicAuthorizationParser.java @@ -1,8 +1,7 @@ package com.payneteasy.superfly.web.security.securehandler; import java.nio.charset.Charset; - -import static org.apache.commons.ssl.Base64.decodeBase64; +import java.util.Base64; public class BasicAuthorizationParser implements IAuthorizationParser { @@ -18,7 +17,7 @@ public AuthorizationBearer parse(String aText) throws AuthorizationException { String base64 = tokenizer.next("base64"); - Tokenizer userPassTokenizer = new Tokenizer(new String(decodeBase64(base64), Charset.defaultCharset()), ":"); + Tokenizer userPassTokenizer = new Tokenizer(new String(Base64.getDecoder().decode(base64), Charset.defaultCharset()), ":"); return new AuthorizationBearer( userPassTokenizer.next("user") , userPassTokenizer.next("password") diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/QuartzConfig.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/QuartzConfig.java deleted file mode 100644 index 8e54a6e2..00000000 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/QuartzConfig.java +++ /dev/null @@ -1,149 +0,0 @@ -package com.payneteasy.superfly.web.spring; - -import com.payneteasy.superfly.service.SessionService; -import com.payneteasy.superfly.service.UserService; -import org.quartz.JobDetail; -import org.quartz.SimpleTrigger; -import org.quartz.Trigger; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.scheduling.quartz.MethodInvokingJobDetailFactoryBean; -import org.springframework.scheduling.quartz.SchedulerFactoryBean; -import org.springframework.scheduling.quartz.SimpleTriggerFactoryBean; - -@Configuration -//@EnableScheduling // Если планируется использовать и @Scheduled -public class QuartzConfig { - - // region Job Details - @Bean - public MethodInvokingJobDetailFactoryBean expireSessionsJobDetail(SessionService sessionService) { - return createJobDetail(sessionService, "deleteExpiredAndOldSessionsAndNotify", 86400); - } - - @Bean - public MethodInvokingJobDetailFactoryBean expirePasswordJobDetail(UserService userService) { - return createJobDetail(userService, "expirePasswords", 90); - } - - @Bean - public MethodInvokingJobDetailFactoryBean suspendUsersJobDetail(UserService userService) { - return createJobDetail(userService, "suspendUsers", 90); - } - - @Bean - public MethodInvokingJobDetailFactoryBean expireSSOSessionsJobDetail( - SessionService sessionService, - @Value("#{contextParameters['superfly-max-sso-session-age-minutes']}") int ssoSessionAge) { - - MethodInvokingJobDetailFactoryBean jobDetail = new MethodInvokingJobDetailFactoryBean(); - jobDetail.setTargetObject(sessionService); - jobDetail.setTargetMethod("deleteExpiredSSOSessions"); - jobDetail.setArguments(new Object[]{ssoSessionAge}); - return jobDetail; - } - - @Bean - public MethodInvokingJobDetailFactoryBean expireTokensJobDetail(SessionService sessionService) { - return createJobDetail(sessionService, "deleteExpiredTokens", 5); - } - // endregion - - // region Triggers - @Bean - public SimpleTriggerFactoryBean expireSessionsTrigger( - @Qualifier("expireSessionsJobDetail") JobDetail jobDetail) { - - return createTrigger(jobDetail, 60_000); // 1 minute - } - - @Bean - public SimpleTriggerFactoryBean expirePasswordsTrigger( - @Qualifier("expirePasswordJobDetail") JobDetail jobDetail) { - - return createTrigger(jobDetail, 86_400_000); // 24 hours - } - - @Bean - public SimpleTriggerFactoryBean suspendUsersTrigger( - @Qualifier("suspendUsersJobDetail") JobDetail jobDetail) { - - return createTrigger(jobDetail, 86_400_000); // 24 hours - } - - @Bean - public SimpleTriggerFactoryBean expireSSOSessionsTrigger( - @Qualifier("expireSSOSessionsJobDetail") JobDetail jobDetail) { - - return createTrigger(jobDetail, 60_000); // 1 minute - } - - @Bean - public SimpleTriggerFactoryBean expireTokensTrigger( - @Qualifier("expireTokensJobDetail") JobDetail jobDetail) { - - return createTrigger(jobDetail, 60_000); // 1 minute - } - // endregion - - // region Schedulers - @Bean - public Trigger[] triggers( - @Qualifier("expireSessionsTrigger") Trigger expireSessionsTrigger, - @Qualifier("expirePasswordsTrigger") Trigger expirePasswordsTrigger, - @Qualifier("suspendUsersTrigger") Trigger suspendUsersTrigger, - @Qualifier("expireSSOSessionsTrigger") Trigger expireSSOSessionsTrigger, - @Qualifier("expireTokensTrigger") Trigger expireTokensTrigger - ) { - return new Trigger[]{ - expireSessionsTrigger, - expirePasswordsTrigger, - suspendUsersTrigger, - expireSSOSessionsTrigger, - expireTokensTrigger - }; - } -// -// @Bean -// public NotificationSendStrategy sendStrategy(HttpClient httpClient) { -// SimpleSendStrategy sendStrategy = new SimpleSendStrategy(); -// sendStrategy.setHttpClient(httpClient); -// return sendStrategy; -// } -// -// @Bean -// public SchedulerFactoryBean scheduler(Trigger[] triggers) { -// SchedulerFactoryBean scheduler = new SchedulerFactoryBean(); -// scheduler.setTriggers(triggers); -// return scheduler; -// } - - @Bean - public SchedulerFactoryBean persistentScheduler(Trigger[] triggers) { - SchedulerFactoryBean scheduler = new SchedulerFactoryBean(); - scheduler.setApplicationContextSchedulerContextKey("applicationContext"); - scheduler.setTriggers(triggers); - return scheduler; - } - // endregion - - // region Helper Methods - private MethodInvokingJobDetailFactoryBean createJobDetail(Object target, String method, int arg) { - MethodInvokingJobDetailFactoryBean jobDetail = new MethodInvokingJobDetailFactoryBean(); - jobDetail.setTargetObject(target); - jobDetail.setTargetMethod(method); - jobDetail.setArguments(arg); - return jobDetail; - } - - private SimpleTriggerFactoryBean createTrigger(JobDetail jobDetail, long interval) { - SimpleTriggerFactoryBean trigger = new SimpleTriggerFactoryBean(); - trigger.setJobDetail(jobDetail); - trigger.setRepeatInterval(interval); - trigger.setRepeatCount(SimpleTrigger.REPEAT_INDEFINITELY); - return trigger; - } - // endregion -} diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/ScheduledTasksConfig.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/ScheduledTasksConfig.java new file mode 100644 index 00000000..3a8a3dcc --- /dev/null +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/ScheduledTasksConfig.java @@ -0,0 +1,90 @@ +package com.payneteasy.superfly.web.spring; + +import com.payneteasy.superfly.service.SessionService; +import com.payneteasy.superfly.service.UserService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.Scheduled; + +/** + * Background maintenance tasks, previously driven by Quartz + * ({@code MethodInvokingJobDetailFactoryBean} + {@code SimpleTrigger}). + * + *

Migrated to Spring {@link Scheduled} fixed-rate tasks backed by the shared + * {@code taskScheduler} bean from {@link TaskSchedulerConfig}, so no Quartz + * runtime is required anymore. + * + *

Intervals and arguments mirror the previous Quartz triggers exactly. + */ +@Configuration +public class ScheduledTasksConfig { + + private static final Logger log = LoggerFactory.getLogger(ScheduledTasksConfig.class); + + /** Repeat intervals (ms), identical to the former Quartz SimpleTrigger values. */ + private static final long ONE_MINUTE_MS = 60_000L; + private static final long ONE_DAY_MS = 86_400_000L; + + /** Method arguments, identical to the former Quartz MethodInvokingJobDetailFactoryBean values. */ + private static final int EXPIRED_SESSION_AGE_SECONDS = 86_400; + private static final int PASSWORD_EXPIRY_DAYS = 90; + private static final int SUSPEND_USERS_DAYS = 90; + private static final int EXPIRED_TOKEN_AGE_SECONDS = 5; + + private final SessionService sessionService; + private final UserService userService; + private final int ssoSessionAge; + + public ScheduledTasksConfig( + SessionService sessionService, + UserService userService, + @Value("#{contextParameters['superfly-max-sso-session-age-minutes']}") int ssoSessionAge) { + this.sessionService = sessionService; + this.userService = userService; + this.ssoSessionAge = ssoSessionAge; + log.debug("ScheduledTasksConfig initialized (ssoSessionAge={})", ssoSessionAge); + } + + @Scheduled(fixedRate = ONE_MINUTE_MS) + public void expireSessions() { + runQuietly("expireSessions", + () -> sessionService.deleteExpiredAndOldSessionsAndNotify(EXPIRED_SESSION_AGE_SECONDS)); + } + + @Scheduled(fixedRate = ONE_DAY_MS) + public void expirePasswords() { + runQuietly("expirePasswords", () -> userService.expirePasswords(PASSWORD_EXPIRY_DAYS)); + } + + @Scheduled(fixedRate = ONE_DAY_MS) + public void suspendUsers() { + runQuietly("suspendUsers", () -> userService.suspendUsers(SUSPEND_USERS_DAYS)); + } + + @Scheduled(fixedRate = ONE_MINUTE_MS) + public void expireSsoSessions() { + runQuietly("expireSsoSessions", () -> sessionService.deleteExpiredSSOSessions(ssoSessionAge)); + } + + @Scheduled(fixedRate = ONE_MINUTE_MS) + public void expireTokens() { + runQuietly("expireTokens", () -> sessionService.deleteExpiredTokens(EXPIRED_TOKEN_AGE_SECONDS)); + } + + /** + * Runs a maintenance task, logging entry/exit at DEBUG and swallowing + * exceptions so a single failed run never kills the scheduler thread + * (Quartz isolated job failures the same way). + */ + private void runQuietly(String taskName, Runnable task) { + log.debug("Scheduled task '{}' starting", taskName); + try { + task.run(); + log.debug("Scheduled task '{}' finished", taskName); + } catch (Exception e) { + log.error("Scheduled task '{}' failed", taskName, e); + } + } +} diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/SpringRootConfiguration.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/SpringRootConfiguration.java index f684ca4d..52c704a2 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/SpringRootConfiguration.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/SpringRootConfiguration.java @@ -16,7 +16,8 @@ WebConfig.class, SpringSecurityConfiguration.class, AopConfig.class, - QuartzConfig.class, + TaskSchedulerConfig.class, + ScheduledTasksConfig.class, }) @EnableTransactionManagement public class SpringRootConfiguration { diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/SpringServiceConfiguration.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/SpringServiceConfiguration.java index 7a3eecdb..35f8e49d 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/SpringServiceConfiguration.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/SpringServiceConfiguration.java @@ -3,6 +3,9 @@ import com.payneteasy.superfly.crypto.CryptoService; import com.payneteasy.superfly.crypto.CryptoServiceImpl; import com.payneteasy.superfly.common.SuperflyProperties; +import com.payneteasy.superfly.hotp.HOTPProviderUtils; +import com.payneteasy.superfly.hotp.NullHOTPProvider; +import com.payneteasy.superfly.spi.HOTPProvider; import lombok.AllArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; @@ -21,4 +24,10 @@ public CryptoService cryptoService() { properties.cryptoSalt() ); } + + @Bean + public HOTPProvider hotpProvider() { + HOTPProvider provider = HOTPProviderUtils.instantiateProvider(false); + return provider != null ? provider : new NullHOTPProvider(); + } } diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/TaskSchedulerConfig.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/TaskSchedulerConfig.java new file mode 100644 index 00000000..20477e91 --- /dev/null +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/TaskSchedulerConfig.java @@ -0,0 +1,41 @@ +package com.payneteasy.superfly.web.spring; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +/** + * Shared {@link TaskScheduler} used by {@code @Scheduled} maintenance tasks + * ({@link ScheduledTasksConfig}) and by the notification dispatch path + * ({@code DefaultNotifier}). + * + *

Kept dependency-free on purpose: the scheduler is injected into + * {@code DefaultNotifier}, which sits on the {@code sessionService -> notifier} + * wiring path. Defining the bean here (rather than on a service-dependent + * config) avoids a startup circular reference. {@code @EnableScheduling} picks + * up a bean named {@code taskScheduler} automatically. + */ +@Configuration +@EnableScheduling +public class TaskSchedulerConfig { + + private static final Logger log = LoggerFactory.getLogger(TaskSchedulerConfig.class); + + private static final int POOL_SIZE = 5; + + @Bean + public TaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(POOL_SIZE); + scheduler.setThreadNamePrefix("superfly-scheduler-"); + scheduler.setWaitForTasksToCompleteOnShutdown(true); + scheduler.setAwaitTerminationSeconds(10); + scheduler.initialize(); + log.debug("Initialized ThreadPoolTaskScheduler (poolSize={})", POOL_SIZE); + return scheduler; + } +} diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/dao/SpringUIDaoConfiguration.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/dao/SpringUIDaoConfiguration.java index 8f7fbd48..c763a0cd 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/dao/SpringUIDaoConfiguration.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/dao/SpringUIDaoConfiguration.java @@ -109,6 +109,11 @@ public ActionDao getActionDao() { return createDao(ActionDao.class); } + @Bean + public EventDao getEventDao() { + return createDao(EventDao.class); + } + @Bean public GroupDao getGroupDao() { return createDao(GroupDao.class); diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/security/SpringSecurityConfiguration.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/security/SpringSecurityConfiguration.java index 7bdec1f5..179cf63a 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/security/SpringSecurityConfiguration.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/spring/security/SpringSecurityConfiguration.java @@ -2,7 +2,6 @@ import com.payneteasy.superfly.client.ActionDescriptionCollector; import com.payneteasy.superfly.client.ScanningActionDescriptionCollector; -import com.payneteasy.superfly.client.XmlActionDescriptionCollector; import com.payneteasy.superfly.common.SuperflyProperties; import com.payneteasy.superfly.security.InsufficientAuthenticationHandlingFilter; import com.payneteasy.superfly.security.MultiStepLoginUrlAuthenticationEntryPoint; @@ -11,7 +10,6 @@ import com.payneteasy.superfly.security.csrf.CsrfValidator; import com.payneteasy.superfly.security.csrf.CsrfValidatorImpl; import com.payneteasy.superfly.service.LoggerSink; -import com.payneteasy.superfly.service.SubsystemService; import com.payneteasy.superfly.web.security.LocalNeedOTPToken; import com.payneteasy.superfly.web.security.SubsystemAuthenticationFilter; import com.payneteasy.superfly.web.security.SuperflyInitOTPAuthenticationProcessingFilter; @@ -21,7 +19,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; -import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.AccessDecisionManager; import org.springframework.security.access.AccessDecisionVoter; import org.springframework.security.access.annotation.Secured; @@ -55,18 +52,28 @@ public class SpringSecurityConfiguration { private final SuperflyProperties properties; private final LoggerSink loggerSink; private final AuthenticationManager authenticationManager; - private final SubsystemService subsystemService; - public SpringSecurityConfiguration(SuperflyProperties properties, LoggerSink loggerSink, AuthenticationManager authenticationManager, SubsystemService subsystemService) { + public SpringSecurityConfiguration(SuperflyProperties properties, LoggerSink loggerSink, AuthenticationManager authenticationManager) { this.properties = properties; this.loggerSink = loggerSink; this.authenticationManager = authenticationManager; - this.subsystemService = subsystemService; } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.securityMatcher("/**") // Обрабатываем все пути + .headers(headers -> headers + // X-Content-Type-Options, X-Frame-Options: DENY, HSTS enabled by Spring Security defaults. + // CSP: unsafe-inline required for Wicket/jQuery inline scripts; all assets served locally. + .contentSecurityPolicy(csp -> csp.policyDirectives( + "default-src 'self'; " + + "script-src 'self' 'unsafe-inline'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data:; " + + "frame-ancestors 'none'; " + + "form-action 'self'" + )) + ) .authorizeHttpRequests( auth -> auth @@ -79,11 +86,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .permitAll() .requestMatchers(antPathRequestMatcher("/remoting/sso.service/**")) .hasAuthority("ROLE_SUBSYSTEM") - .requestMatchers( - antPathRequestMatcher("/remoting/oauth2.hessian.service/**"), - antPathRequestMatcher("/remoting/basic.hessian.service/**") - ) - .permitAll() .anyRequest() .hasAnyAuthority("ROLE_ADMIN", "ROLE_ACTION_TEMP_PASSWORD")) .exceptionHandling(httpSecurity -> @@ -96,6 +98,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .logout(logout -> logout .logoutUrl("/j_spring_security_logout") .logoutSuccessHandler(logoutSuccessHandler())) + // CSRF disabled intentionally: all state-changing REST endpoints use token-based auth + // (X-Subsystem-Token or Authorization: Bearer), not cookies. Wicket admin pages are + // protected by Wicket's own stateful page-version tokens embedded in action URLs. .csrf(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) ; @@ -141,8 +146,7 @@ public X509AuthenticationFilter x509AuthenticationFilter() { public SubsystemAuthenticationFilter subsystemAuthenticationFilter() { SubsystemAuthenticationFilter filter = new SubsystemAuthenticationFilter( antPathRequestMatcher("/remoting/sso.service/**"), - authenticationManager, - subsystemService + authenticationManager ); filter.setSuccessHandler((request, response, authentication) -> {}); filter.setFailureHandler(new JsonAuthenticationFailureHandler()); @@ -224,10 +228,4 @@ public ActionDescriptionCollector scanningActionDescriptionCollector() { } - @Bean - public ActionDescriptionCollector xmlActionDescriptionCollector() { - XmlActionDescriptionCollector collector = new XmlActionDescriptionCollector(); - collector.setResource(new ClassPathResource("actions.xml")); - return collector; - } } diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/BaseApplication.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/BaseApplication.java index 66406d16..cfb2c0d7 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/BaseApplication.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/BaseApplication.java @@ -10,11 +10,10 @@ import org.apache.wicket.request.IRequestMapper; import org.apache.wicket.request.Request; import org.apache.wicket.request.Response; -import org.apache.wicket.request.Url; import org.apache.wicket.request.mapper.parameter.IPageParametersEncoder; import org.apache.wicket.request.mapper.parameter.PageParametersEncoder; import org.apache.wicket.request.mapper.parameter.UrlPathPageParametersEncoder; -import org.apache.wicket.request.resource.UrlResourceReference; +import org.apache.wicket.request.resource.ContextRelativeResourceReference; import org.apache.wicket.settings.DebugSettings; import org.apache.wicket.spring.injection.annot.SpringComponentInjector; import org.apache.wicket.util.file.Path; @@ -52,7 +51,7 @@ protected final void init() { } getJavaScriptLibrarySettings().setJQueryReference( - new UrlResourceReference(Url.parse("https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js")) + new ContextRelativeResourceReference("js/jquery-3.7.0.js", false) ); getComponentInstantiationListeners().add(new SpringComponentInjector(this)); getDebugSettings().setOutputMarkupContainerClassNameStrategy(DebugSettings.ClassOutputStrategy.NONE); diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/SuperflyApplication.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/SuperflyApplication.java index 5068cb16..0a65870d 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/SuperflyApplication.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/SuperflyApplication.java @@ -42,7 +42,6 @@ import com.payneteasy.superfly.wicket.InterceptionDecisions; import com.payneteasy.superfly.wicket.PageInterceptingRequestMapper; import org.apache.wicket.Page; -import org.apache.wicket.core.request.mapper.CryptoMapper; import org.apache.wicket.request.IRequestMapper; import org.apache.wicket.request.Request; import org.apache.wicket.settings.RequestCycleSettings; @@ -52,8 +51,8 @@ public class SuperflyApplication extends BaseApplication { @Override protected void customInit() { getSecuritySettings().setAuthorizationStrategy(new SpringSecurityAuthorizationStrategy()); - CryptoMapper requestMapper = new CryptoMapper(getRootRequestMapper(), this); - setRootRequestMapper(wrapWithInterceptingMapper(requestMapper)); + // BaseApplication.init() уже создал CryptoMapper, просто оборачиваем его в PageInterceptingRequestMapper + setRootRequestMapper(wrapWithInterceptingMapper(getRootRequestMapper())); mountBookmarkablePageWithPath("/loginbase", LoginPageWithoutHOTP.class); mountBookmarkablePageWithPath("/login", LoginPasswordStepPage.class); diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/SuperflyApplication.properties b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/SuperflyApplication.properties index db285ca2..d7d6cb2c 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/SuperflyApplication.properties +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/SuperflyApplication.properties @@ -31,6 +31,8 @@ subsystem.edit.callback=Callback Info subsystem.edit.callback.Required=Callback Info is required subsystem.edit.send-callbacks=Send callbacks subsystem.edit.subsystemToken=Token +subsystem.edit.publicKey=Public key +subsystem.add.publicKey=Public key subsystem.edit.subsystemUrl=Subsystem URL subsystem.edit.subsystemUrl.Required=Subsystem URL is required subsystem.edit.landingUrl=Landing URL diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/component/otp/GoogleAuthSetupPanel.html b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/component/otp/GoogleAuthSetupPanel.html index 9fbc2484..5c19779a 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/component/otp/GoogleAuthSetupPanel.html +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/component/otp/GoogleAuthSetupPanel.html @@ -1,17 +1,17 @@

-
+
Your secret key for google authenticator: - +
-
+
-
+
Learn more about Google authenticator diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/component/otp/GoogleAuthSetupPanel.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/component/otp/GoogleAuthSetupPanel.java index c21a973a..be50584d 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/component/otp/GoogleAuthSetupPanel.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/component/otp/GoogleAuthSetupPanel.java @@ -17,7 +17,7 @@ public class GoogleAuthSetupPanel extends Panel { private static final String TOTP_URI_FORMAT = - "https://chart.googleapis.com/chart?chs=150x150&chld=M%%7C0&cht=qr&chl=%s"; + "https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=%s"; private final String username; private final IModel totpSecret; diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/model/StickyFilters.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/model/StickyFilters.java index fe8f854e..51f764d2 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/model/StickyFilters.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/model/StickyFilters.java @@ -15,4 +15,5 @@ public class StickyFilters implements Serializable { private UISubsystemForFilter subsystem; private String actionNameSubstring = ""; + private String roleNameSubstring = ""; } diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/BasePage.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/BasePage.java index 8bdc622d..1beb8757 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/BasePage.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/BasePage.java @@ -63,8 +63,6 @@ private void init() { addNavBarItem("roles", ListRolesPage.class); addNavBarItem("sessions", ListSessionsPage.class); addNavBarItem("smtp", ListSmtpServersPage.class); - - } protected abstract String getTitle(); diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/role/ListRolesPage.html b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/role/ListRolesPage.html index afe0fb96..2f1893e3 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/role/ListRolesPage.html +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/role/ListRolesPage.html @@ -20,6 +20,9 @@ +
  • + Role Name: +
  • diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/role/ListRolesPage.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/role/ListRolesPage.java index 48994b01..35aadeec 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/role/ListRolesPage.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/role/ListRolesPage.java @@ -1,6 +1,7 @@ package com.payneteasy.superfly.web.wicket.page.role; import com.payneteasy.superfly.model.RoutineResult; +import com.payneteasy.superfly.model.ui.role.UIRoleForFilter; import com.payneteasy.superfly.model.ui.role.UIRoleForList; import com.payneteasy.superfly.model.ui.subsystem.UISubsystemForFilter; import com.payneteasy.superfly.service.RoleService; @@ -17,6 +18,7 @@ import com.payneteasy.superfly.web.wicket.utils.PageParametersBuilder; import com.payneteasy.superfly.web.wicket.utils.WicketComponentHelper; import org.apache.wicket.Page; +import org.apache.wicket.extensions.ajax.markup.html.autocomplete.AutoCompleteTextField; import org.apache.wicket.extensions.markup.html.repeater.data.sort.OrderByLink; import org.apache.wicket.extensions.markup.html.repeater.util.SortableDataProvider; import org.apache.wicket.markup.html.basic.Label; @@ -33,12 +35,15 @@ import org.apache.wicket.markup.repeater.data.DataView; import org.apache.wicket.model.PropertyModel; import org.apache.wicket.request.mapper.parameter.PageParameters; +import org.apache.wicket.util.string.Strings; + import org.apache.wicket.spring.injection.annot.SpringBean; import org.springframework.security.access.annotation.Secured; import java.io.Serializable; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashSet; import java.util.Iterator; import java.util.List; @@ -67,6 +72,27 @@ public ListRolesPage() { subsystemDropdown.setNullValid(true); filtersForm.add(subsystemDropdown); + final AutoCompleteTextField autoTextNameRole = new AutoCompleteTextField("auto", new PropertyModel(stickyFilters, "roleNameSubstring")) { + @Override + protected Iterator getChoices(String input) { + if (Strings.isEmpty(input)) { + return Collections.emptyList().iterator(); + } + List choices = new ArrayList(10); + for (UIRoleForFilter role : roleService.getRolesForFilter()) { + final String name = role.getRoleName(); + if (name.toUpperCase().startsWith(input.toUpperCase())) { + choices.add(name); + if (choices.size() == 10) { + break; + } + } + } + return choices.iterator(); + } + }; + filtersForm.add(autoTextNameRole); + final ObjectHolder> rolesHolder = new ObjectHolder>(); final InitializingModel> rolesCheckGroupModel = new InitializingModel>() { @Override @@ -92,7 +118,7 @@ public Iterator iterator(long first, long count) { subsystemId.add(subsystem.getId()); } List roles = roleService.getRoles(first, count, - getSortFieldIndex(), isAscending(), null, + getSortFieldIndex(), isAscending(), stickyFilters.getRoleNameSubstring(), subsystem == null ? null : subsystemId); rolesHolder.setObject(roles); rolesCheckGroupModel.clearInitialized(); @@ -105,7 +131,7 @@ public long size() { if (subsystem != null) { subsystemId.add(subsystem.getId()); } - return roleService.getRoleCount(null, + return roleService.getRoleCount(stickyFilters.getRoleNameSubstring(), subsystem == null ? null : subsystemId); } diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/BaseSSOPage.html b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/BaseSSOPage.html index d3c5d56d..4f685e65 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/BaseSSOPage.html +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/BaseSSOPage.html @@ -20,9 +20,9 @@ - \ No newline at end of file + diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/BaseSSOPage.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/BaseSSOPage.java index 1ff6da69..0f1789dd 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/BaseSSOPage.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/BaseSSOPage.java @@ -71,7 +71,7 @@ protected boolean validateCsrfToken() { private HttpSession getHttpSession() { HttpServletRequest request = getHttpServletRequest(); - return request.getSession(false); + return request.getSession(true); } private HttpServletRequest getHttpServletRequest() { diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/SSOChangePasswordPage.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/SSOChangePasswordPage.java index d480246e..33d2aa49 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/SSOChangePasswordPage.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/SSOChangePasswordPage.java @@ -1,7 +1,10 @@ package com.payneteasy.superfly.web.wicket.page.sso; +import com.payneteasy.superfly.api.OTPType; +import com.payneteasy.superfly.model.ui.user.UserForDescription; import com.payneteasy.superfly.service.SessionService; import com.payneteasy.superfly.service.SubsystemService; +import com.payneteasy.superfly.service.UserService; import com.payneteasy.superfly.web.wicket.page.user.ChangePasswordPanel; import org.apache.wicket.Component; import org.apache.wicket.model.Model; @@ -16,6 +19,8 @@ public class SSOChangePasswordPage extends BaseSSOPage { private SessionService sessionService; @SpringBean private SubsystemService subsystemService; + @SpringBean + private UserService userService; public SSOChangePasswordPage(final String username) { if (getSession().getSsoLoginData() == null) { @@ -40,10 +45,22 @@ protected String getCurrentUserName() { @Override protected void onPasswordChanged() { - SSOUtils.onSuccessfulLogin(username, - SSOChangePasswordPage.this, - SSOChangePasswordPage.this.getSession().getSsoLoginData(), - sessionService, subsystemService); + UserForDescription user = userService.getUserForDescription(username); + SSOLoginData loginData = SSOChangePasswordPage.this.getSession().getSsoLoginData(); + if (loginData != null) { + loginData.setUsername(username); + loginData.setOtpTypeCode(user.getOtpTypeCode()); + loginData.setOtpOptional(user.isOtpOptional()); + } + + if (OTPType.GOOGLE_AUTH.equals(user.getOtpType()) && !user.isOtpOptional()) { + getRequestCycle().setResponsePage(new SSOSetupGoogleAuthPage()); + } else { + SSOUtils.onSuccessfulLogin(username, + SSOChangePasswordPage.this, + loginData, + sessionService, subsystemService); + } } }); } diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/SSOLoginData.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/SSOLoginData.java index 6e6dea16..6c637178 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/SSOLoginData.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/SSOLoginData.java @@ -12,6 +12,9 @@ public class SSOLoginData implements Serializable { private String subsystemUrl; private String username; + private String otpTypeCode; + private boolean isOtpOptional; + public SSOLoginData() { } @@ -60,6 +63,22 @@ public void setUsername(String username) { this.username = username; } + public String getOtpTypeCode() { + return otpTypeCode; + } + + public void setOtpTypeCode(String otpTypeCode) { + this.otpTypeCode = otpTypeCode; + } + + public boolean isOtpOptional() { + return isOtpOptional; + } + + public void setOtpOptional(boolean otpOptional) { + isOtpOptional = otpOptional; + } + @Override public String toString() { return "SSOLoginData{" + diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/SSOLoginPasswordPage.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/SSOLoginPasswordPage.java index ecfe6731..d69bc136 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/SSOLoginPasswordPage.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/SSOLoginPasswordPage.java @@ -76,6 +76,9 @@ private void doOnSubmit(LoginBean loginBean, SSOLoginData loginData) { onPasswordChecked(loginBean, loginData); break; case FAILED: + // Clear SSO session and cookie after failed authentication + // to prevent reuse of old sessions + SSOUtils.clearSSOSession(this, sessionService); errorMessageModel.setObject("The username or password you entered is incorrect or user is locked."); errorMessageLabel.setVisible(true); break; @@ -88,6 +91,10 @@ private void doOnSubmit(LoginBean loginBean, SSOLoginData loginData) { private void onPasswordChecked(LoginBean loginBean, SSOLoginData loginData) { loginData.setUsername(loginBean.getUsername()); UserForDescription userDescription = userService.getUserForDescription(loginData.getUsername()); + + loginData.setOtpTypeCode(userDescription.getOtpTypeCode()); + loginData.setOtpOptional(userDescription.isOtpOptional()); + OTPType otpType = userDescription.getOtpType(); switch (otpType) { case GOOGLE_AUTH: diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/SSOUtils.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/SSOUtils.java index 75b1c05a..13c8cc7b 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/SSOUtils.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/sso/SSOUtils.java @@ -17,6 +17,7 @@ import org.apache.wicket.request.http.handler.RedirectRequestHandler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.util.StringUtils; import jakarta.servlet.http.Cookie; import java.net.URLEncoder; @@ -116,7 +117,12 @@ public static void onSuccessfulLogin(String username, ssoSession.getId(), loginData.getSubsystemIdentifier()); if (token != null) { // can login: redirecting a user to a subsystem - SSOUtils.redirectToSubsystem(page, loginData, token); + if ("no-target".equals(loginData.getTargetUrl()) || "/no-target".equals(loginData.getTargetUrl())) { + SSOUtils.anonymizeLoginData(page); + SSOUtils.redirect(page, token.getLandingUrl()); + } else { + SSOUtils.redirectToSubsystem(page, loginData, token); + } } else { // can't login: just display an error // actually, this should not happen as we've already @@ -125,6 +131,32 @@ public static void onSuccessfulLogin(String username, } } + /** + * Clears SSO session and cookie after failed authentication. + * This prevents reuse of old sessions after authentication failure. + * + * @param page the page to get request/response from + * @param sessionService service to delete SSO session + */ + public static void clearSSOSession(SessionAccessorPage page, SessionService sessionService) { + WebRequest request = (WebRequest) page.getRequest(); + String ssoSessionId = getSsoSessionIdFromCookie(request); + + if (StringUtils.hasText(ssoSessionId)) { + logger.debug("Clearing SSO session after failed authentication: {}", ssoSessionId); + sessionService.deleteSSOSession(ssoSessionId); + } + + // Clear cookie by setting maxAge to 0 + Cookie cookie = new Cookie(SSO_SESSION_ID_COOKIE_NAME, ""); + cookie.setMaxAge(0); + cookie.setPath(getApplicationRootPath(page)); + ((WebResponse) RequestCycle.get().getResponse()).addCookie(cookie); + + // Clear username from login data + anonymizeLoginData(page); + } + private static String getApplicationRootPath(SessionAccessorPage page) { String contextPath = ((ServletWebRequest) page.getRequest()).getContextPath(); String filterPath = ((ServletWebRequest) page.getRequest()).getFilterPath(); diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/subsystem/AddSubsystemPage.html b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/subsystem/AddSubsystemPage.html index a6b34cbd..df62da4e 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/subsystem/AddSubsystemPage.html +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/subsystem/AddSubsystemPage.html @@ -16,6 +16,13 @@ +
    + +
    key
    + + Generate new RSA key pair + +
    diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/subsystem/AddSubsystemPage.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/subsystem/AddSubsystemPage.java index 692a357f..eb5bb31e 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/subsystem/AddSubsystemPage.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/subsystem/AddSubsystemPage.java @@ -4,11 +4,16 @@ import com.payneteasy.superfly.model.ui.subsystem.UISubsystem; import com.payneteasy.superfly.service.SmtpServerService; import com.payneteasy.superfly.service.SubsystemService; +import com.payneteasy.superfly.service.impl.remote.check.KeyPairData; +import com.payneteasy.superfly.service.impl.remote.check.RemoteAuthEncryptionAlgorithm; import com.payneteasy.superfly.web.wicket.component.field.LabelCheckBoxRow; import com.payneteasy.superfly.web.wicket.component.field.LabelDropDownChoiceRow; import com.payneteasy.superfly.web.wicket.component.field.LabelTextFieldRow; import com.payneteasy.superfly.web.wicket.page.BasePage; import org.apache.wicket.Page; +import org.apache.wicket.ajax.AjaxRequestTarget; +import org.apache.wicket.extensions.ajax.markup.html.IndicatingAjaxLink; +import org.apache.wicket.markup.html.basic.Label; import org.apache.wicket.markup.html.form.ChoiceRenderer; import org.apache.wicket.markup.html.form.Form; import org.apache.wicket.markup.html.form.SubmitLink; @@ -16,14 +21,20 @@ import org.apache.wicket.model.CompoundPropertyModel; import org.apache.wicket.model.IModel; import org.apache.wicket.model.LoadableDetachableModel; +import org.apache.wicket.model.ResourceModel; import org.apache.wicket.spring.injection.annot.SpringBean; import org.apache.wicket.validation.validator.UrlValidator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.security.access.annotation.Secured; import java.util.List; @Secured("ROLE_ADMIN") public class AddSubsystemPage extends BasePage { + + private static final Logger logger = LoggerFactory.getLogger(AddSubsystemPage.class); + @SpringBean private SubsystemService subsystemService; @SpringBean @@ -87,6 +98,34 @@ public String getIdValue(UISmtpServerForFilter server, int index) { } }, true)); + final Label labelPublicKey = new Label("publicKey", new LoadableDetachableModel() { + @Override + protected String load() { + return subsystem.getPublicKey(); + } + }); + labelPublicKey.setOutputMarkupId(true); + + form.add(new Label("publicKeyLabel", new ResourceModel("subsystem.add.publicKey"))); + form.add(labelPublicKey); + form.add(new IndicatingAjaxLink("generateNewKeyPair") { + private static final long serialVersionUID = 1L; + + public void onClick(AjaxRequestTarget aTarget) { + try { + var keyPairData = generateKeyPair(RemoteAuthEncryptionAlgorithm.RSA); + subsystem.setPrivateKey(keyPairData.privateKey()); + subsystem.setPublicKey(keyPairData.publicKey()); + subsystem.setEncryptionAlgorithm(RemoteAuthEncryptionAlgorithm.RSA.name()); + aTarget.add(labelPublicKey); + } catch (Exception e) { + logger.error("Error while generating key pair for subsystem {}", subsystem.getName(), e); + error("Error while generating key pair: " + e.getMessage()); + } + + } + }); + form.add(new SubmitLink("submit-link")); form.add(new BookmarkablePageLink("cancel", ListSubsystemsPage.class)); } @@ -95,4 +134,8 @@ public String getIdValue(UISmtpServerForFilter server, int index) { protected String getTitle() { return "Add subsystem"; } + + private KeyPairData generateKeyPair(RemoteAuthEncryptionAlgorithm algorithm) { + return subsystemService.generateKeyPair(algorithm); + } } diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/subsystem/EditSubsystemPage.html b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/subsystem/EditSubsystemPage.html index ed7595dc..3be73bdd 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/subsystem/EditSubsystemPage.html +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/subsystem/EditSubsystemPage.html @@ -22,6 +22,14 @@
    +
    + + + + Generate new RSA key pair + +
    + diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/subsystem/EditSubsystemPage.java b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/subsystem/EditSubsystemPage.java index 2b393923..903a41cb 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/subsystem/EditSubsystemPage.java +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/subsystem/EditSubsystemPage.java @@ -4,6 +4,8 @@ import com.payneteasy.superfly.model.ui.subsystem.UISubsystem; import com.payneteasy.superfly.service.SmtpServerService; import com.payneteasy.superfly.service.SubsystemService; +import com.payneteasy.superfly.service.impl.remote.check.KeyPairData; +import com.payneteasy.superfly.service.impl.remote.check.RemoteAuthEncryptionAlgorithm; import com.payneteasy.superfly.web.wicket.component.field.LabelCheckBoxRow; import com.payneteasy.superfly.web.wicket.component.field.LabelDropDownChoiceRow; import com.payneteasy.superfly.web.wicket.component.field.LabelTextFieldRow; @@ -14,6 +16,7 @@ import org.apache.wicket.markup.html.basic.Label; import org.apache.wicket.markup.html.form.ChoiceRenderer; import org.apache.wicket.markup.html.form.Form; +import org.apache.wicket.markup.html.form.TextArea; import org.apache.wicket.markup.html.form.SubmitLink; import org.apache.wicket.markup.html.link.BookmarkablePageLink; import org.apache.wicket.model.CompoundPropertyModel; @@ -23,13 +26,17 @@ import org.apache.wicket.request.mapper.parameter.PageParameters; import org.apache.wicket.spring.injection.annot.SpringBean; import org.apache.wicket.validation.validator.UrlValidator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.security.access.annotation.Secured; import java.util.List; -import java.util.UUID; @Secured("ROLE_ADMIN") public class EditSubsystemPage extends BasePage { + + private static final Logger logger = LoggerFactory.getLogger(EditSubsystemPage.class); + @SpringBean private SubsystemService subsystemService; @SpringBean @@ -83,6 +90,36 @@ public void onClick(AjaxRequestTarget aTarget) { } }); + // Public key fields + final TextArea textAreaPublicKey = new TextArea<>("publicKey", new LoadableDetachableModel<>() { + @Override + protected String load() { + return subsystem.getPublicKey(); + } + }); + textAreaPublicKey.setOutputMarkupId(true); + textAreaPublicKey.setEnabled(false); + + form.add(new Label("publicKeyLabel", new ResourceModel("subsystem.edit.publicKey"))); + form.add(textAreaPublicKey); + form.add(new IndicatingAjaxLink("generateNewKeyPair") { + private static final long serialVersionUID = 1L; + + public void onClick(AjaxRequestTarget aTarget) { + try { + var keyPairData = generateKeyPair(RemoteAuthEncryptionAlgorithm.RSA); + subsystem.setPrivateKey(keyPairData.privateKey()); + subsystem.setPublicKey(keyPairData.publicKey()); + subsystem.setEncryptionAlgorithm(RemoteAuthEncryptionAlgorithm.RSA.name()); + aTarget.add(textAreaPublicKey); + } catch (Exception e) { + logger.error("Error while generating key pair for subsystem {}", subsystem.getName(), e); + error("Error while generating key pair: " + e.getMessage()); + } + + } + }); + LabelTextFieldRow subsystemUrlRow = new LabelTextFieldRow<>(subsystem, "subsystemUrl", "subsystem.edit.subsystemUrl", true); subsystemUrlRow.getTextField().add(urlValidator); @@ -129,4 +166,8 @@ protected String getTitle() { private String generateNewToken() { return subsystemService.generateMainSubsystemToken(); } + + private KeyPairData generateKeyPair(RemoteAuthEncryptionAlgorithm algorithm) { + return subsystemService.generateKeyPair(algorithm); + } } diff --git a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/user/ChangePasswordPanel.html b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/user/ChangePasswordPanel.html index e047dee8..533a8f95 100644 --- a/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/user/ChangePasswordPanel.html +++ b/superfly-web/src/main/java/com/payneteasy/superfly/web/wicket/page/user/ChangePasswordPanel.html @@ -9,11 +9,10 @@
    -