fix #547: secure VM deployment behind HTTPS proxy
This commit is contained in:
@@ -26,6 +26,11 @@ export BACKOFFICE_DB_PASSWORD="${ADB_PASSWORD}"
|
||||
# --- (2b) Spring Boot 백오피스 ---
|
||||
export BACKOFFICE_ADMIN_USER="admin"
|
||||
export BACKOFFICE_ADMIN_PASSWORD="admin"
|
||||
# 로컬 HTTP 개발 기본값. 외부 VM 배포 스크립트는 loopback/HTTPS 안전값으로 덮어씁니다.
|
||||
export BACKOFFICE_BIND_ADDRESS="0.0.0.0"
|
||||
export BACKOFFICE_FORWARD_HEADERS_STRATEGY="none"
|
||||
export BACKOFFICE_REQUIRE_HTTPS="false"
|
||||
export BACKOFFICE_SESSION_COOKIE_SECURE="false"
|
||||
export BACKOFFICE_ORDS_BASE_URL="https://yh0olybn5pqce4n-d8aukro81636mon0.adb.ap-seoul-1.oraclecloudapps.com/ords"
|
||||
export BACKOFFICE_ORDS_TIMEOUT_SECONDS="10"
|
||||
# ORDS metadata 생성/수정 전용 계정. 비워두면 BACKOFFICE_DB_* 연결을 사용하므로
|
||||
|
||||
17
deploy/caddy/Caddyfile.template
Normal file
17
deploy/caddy/Caddyfile.template
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
email {{TLS_EMAIL}}
|
||||
}
|
||||
|
||||
{{PUBLIC_HOST}} {
|
||||
encode zstd gzip
|
||||
{{TLS_OPTIONS}}
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
X-Content-Type-Options "nosniff"
|
||||
Referrer-Policy "same-origin"
|
||||
-Server
|
||||
}
|
||||
|
||||
reverse_proxy 127.0.0.1:{{APP_PORT}}
|
||||
}
|
||||
53
deploy/caddy/install-caddy-config.sh
Executable file
53
deploy/caddy/install-caddy-config.sh
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs on the target VM after a candidate Caddyfile has been uploaded.
|
||||
set -Eeuo pipefail
|
||||
|
||||
CANDIDATE="${1:?candidate Caddyfile is required}"
|
||||
APP_DIR="${2:?application directory is required}"
|
||||
TARGET="/etc/caddy/Caddyfile"
|
||||
|
||||
[[ -f "$CANDIDATE" ]] || { echo "candidate Caddyfile not found: $CANDIDATE" >&2; exit 1; }
|
||||
command -v caddy >/dev/null 2>&1 || { echo "caddy is not installed" >&2; exit 1; }
|
||||
command -v systemctl >/dev/null 2>&1 || { echo "systemctl is not installed" >&2; exit 1; }
|
||||
sudo -n true || { echo "passwordless sudo is required for Caddy configuration" >&2; exit 1; }
|
||||
sudo test -f "$TARGET" || { echo "existing $TARGET not found; install the official Caddy package first" >&2; exit 1; }
|
||||
|
||||
BACKUP_DIR="$APP_DIR/caddy-backups"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
BACKUP="$BACKUP_DIR/Caddyfile.$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
sudo cat "$TARGET" > "$BACKUP"
|
||||
chmod 600 "$BACKUP"
|
||||
|
||||
was_active="N"
|
||||
if sudo systemctl is-active --quiet caddy; then
|
||||
was_active="Y"
|
||||
fi
|
||||
|
||||
rollback() {
|
||||
status=$?
|
||||
trap - ERR
|
||||
echo "Caddy 적용 실패, 이전 설정 복원: $BACKUP" >&2
|
||||
sudo install -o root -g root -m 644 "$BACKUP" "$TARGET"
|
||||
if [[ "$was_active" == "Y" ]]; then
|
||||
sudo systemctl reload caddy || true
|
||||
else
|
||||
sudo systemctl stop caddy || true
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
trap rollback ERR
|
||||
|
||||
sudo caddy validate --config "$CANDIDATE" --adapter caddyfile
|
||||
sudo install -o root -g root -m 644 "$CANDIDATE" "$TARGET"
|
||||
|
||||
if [[ "$was_active" == "Y" ]]; then
|
||||
sudo systemctl reload caddy
|
||||
else
|
||||
sudo systemctl enable --now caddy
|
||||
fi
|
||||
|
||||
sudo systemctl is-active --quiet caddy
|
||||
sudo systemctl is-enabled --quiet caddy
|
||||
trap - ERR
|
||||
|
||||
printf 'caddy active; backup=%s\n' "$BACKUP"
|
||||
125
docs/design/547-vm-https-hardening/README.md
Normal file
125
docs/design/547-vm-https-hardening/README.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# 설계서: 외부 VM HTTPS 및 직접 포트 차단 (#547)
|
||||
|
||||
> **상태**: Approved
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-28
|
||||
> **추적성** — Redmine: #547 · 관련 ADR: 없음
|
||||
> · 구현 파일: `src/main/resources/application.yml`, `SecurityConfig.java`, `scripts/deploy-backoffice-vm.sh`, `scripts/configure-backoffice-https-vm.sh`, `deploy/caddy/Caddyfile.template`
|
||||
> · 테스트: `TransportSecurityTest.java`, `scripts/test-backoffice-https-config.sh`
|
||||
|
||||
## 1. 목적 (Why)
|
||||
|
||||
외부 VM에서 관리자 자격증명과 세션 쿠키가 평문 HTTP로 전송되고 애플리케이션 포트 `8082`가 직접 노출되는 경로를 제거한다.
|
||||
|
||||
## 2. 범위 (Scope)
|
||||
|
||||
- **포함**: 공개 DNS 이름 또는 통제 가능한 public IPv4 기반 HTTPS, HTTP→HTTPS 전환, Caddy TLS termination과 인증서 자동 갱신, 애플리케이션 루프백 바인딩, forwarded header 처리, Secure/HttpOnly/SameSite 세션 쿠키, HSTS, 배포·검증·롤백 절차.
|
||||
- **제외**: OCI NSG와 VM firewalld의 실제 변경, DNS 레코드 생성, Caddy 패키지 설치, 기본 `admin/admin` 제거(#548), CSP/CDN 공급망 강화(#549).
|
||||
|
||||
## 3. 인수조건 (Acceptance Criteria)
|
||||
|
||||
- [ ] 외부 HTTP 요청은 HTTPS로 전환되고 로그인 폼은 HTTPS에서만 제출된다.
|
||||
- [ ] 애플리케이션은 VM의 `127.0.0.1:8082`에만 바인딩되어 외부 직접 접속이 실패한다.
|
||||
- [ ] HTTPS 로그인 응답의 `JSESSIONID`에 `Secure`, `HttpOnly`, `SameSite=Lax`가 있다.
|
||||
- [ ] HTTPS 응답에 1년 HSTS가 있고, 전달된 scheme/host가 redirect 생성에 반영된다.
|
||||
- [ ] 공개 프록시는 클라이언트가 보낸 `X-Forwarded-*`를 신뢰하지 않고 직접 다시 설정한다.
|
||||
- [ ] 인증서 갱신·기동·헬스체크·롤백 절차가 자동화 스크립트와 런북에 있다.
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
|
||||
- Caddy automatic HTTPS는 외부 80/443 접근과 지속 가능한 인증서 저장소를 전제로 인증서를 발급·갱신하고 HTTP를 HTTPS로 전환한다.
|
||||
- Let’s Encrypt public IPv4 인증서는 2026년부터 정식 지원되며 `shortlived` ACME profile과 약 6일 수명을 사용한다. Caddy가 profile과 갱신을 지속적으로 관리한다.
|
||||
- FQDN이 있으면 일반 공개 인증서를 우선하고, DNS가 준비되지 않은 단일 VM은 통제 중인 고정 public IPv4 인증서를 사용할 수 있다. Caddy 로컬 CA 인증서는 운영에 사용하지 않는다.
|
||||
- 애플리케이션의 forwarded header 처리는 연결 원본 CIDR을 자체 판별하지 않는다. 따라서 `127.0.0.1` 바인딩을 보안 경계로 삼아 같은 VM의 Caddy만 접근시킨다.
|
||||
- Caddy는 인터넷에 직접 연결되는 첫 프록시다. CDN/로드밸런서를 추가할 때만 명시적인 `trusted_proxies` CIDR 검토가 필요하다.
|
||||
- 방화벽/NSG 변경은 서비스 영향과 잠금 위험이 있어 스크립트가 자동 수행하지 않는다.
|
||||
|
||||
## 5. 아키텍처 개요
|
||||
|
||||
```text
|
||||
Internet client
|
||||
├─ HTTP :80 ───────> Caddy ── 308 HTTPS redirect
|
||||
└─ HTTPS :443 ─TLS─> Caddy ── X-Forwarded-* 재생성
|
||||
│
|
||||
└─ HTTP 127.0.0.1:8082
|
||||
Spring Boot
|
||||
├─ require-https
|
||||
├─ HSTS
|
||||
└─ Secure/HttpOnly/SameSite cookie
|
||||
|
||||
Internet client ── HTTP :8082 ──X (loopback bind + firewalld/NSG deny)
|
||||
```
|
||||
|
||||
- `deploy/caddy/Caddyfile.template`: TLS/redirect/HSTS/reverse proxy의 선언적 설정.
|
||||
- `scripts/configure-backoffice-https-vm.sh`: 입력 검증, 원격 설정 검증·백업·적용·확인.
|
||||
- `scripts/deploy-backoffice-vm.sh`: 앱 배포 시 운영 보안 환경값을 강제하고 루프백 헬스체크.
|
||||
- Spring 설정: proxy가 전달한 HTTPS scheme을 인식하고 직접 HTTP 요청을 거부한다.
|
||||
- 순수 경계 검증(호스트명, 이메일, 포트, 템플릿 렌더링)은 로컬 dry-run 테스트로 검증하고, SSH/systemd/ACME는 명시적 운영 단계에서 수행한다.
|
||||
|
||||
## 6. 설정 모델
|
||||
|
||||
| 설정 | 운영값 | 검증 |
|
||||
|---|---|---|
|
||||
| `BACKOFFICE_BIND_ADDRESS` | `127.0.0.1` | HTTPS 배포 스크립트가 고정 |
|
||||
| `BACKOFFICE_FORWARD_HEADERS_STRATEGY` | `framework` | 배포 환경 override |
|
||||
| `BACKOFFICE_REQUIRE_HTTPS` | `true` | 배포 환경 override |
|
||||
| `BACKOFFICE_SESSION_COOKIE_SECURE` | `true` | 배포 환경 override |
|
||||
| public host | 소유한 FQDN 또는 고정 public IPv4 | FQDN/IP 문법과 expected address 일치 검증 |
|
||||
| TLS email | ACME 운영 이메일 | 공백/개행/비정상 형식 거부 |
|
||||
| app port | `8082` | 1–65535 숫자 |
|
||||
|
||||
로컬 개발은 기존 HTTP 흐름을 보존하기 위해 HTTPS 강제와 Secure cookie의 기본값을 `false`로 둔다. VM 배포 산출물에서만 안전한 운영값으로 덮어쓴다.
|
||||
|
||||
## 7. 함수/스크립트 명세
|
||||
|
||||
| 함수/스크립트 | 책임 | 입력 | 출력 | 실패 |
|
||||
|---|---|---|---|---|
|
||||
| `securityFilterChain` | 인증, HTTPS channel, HSTS 구성 | 보안 설정 | servlet filter chain | 구성 오류 시 기동 실패 |
|
||||
| `deploy-backoffice-vm.sh` | jar/env/wallet 배포와 내부 헬스체크 | SSH 대상, 포트 | 루프백 앱 | SSH/build/start 실패 |
|
||||
| `configure-backoffice-https-vm.sh` | Caddy 설정 적용·검증 | FQDN, TLS email, SSH 대상 | HTTPS endpoint | DNS/Caddy/sudo/TLS 검증 실패 |
|
||||
| `test-backoffice-https-config.sh` | 입력 거부와 Caddy 템플릿 검증 | 로컬 저장소 | PASS/FAIL | assertion 실패 |
|
||||
|
||||
## 8. 적용 흐름
|
||||
|
||||
1. 소유한 FQDN의 A/AAAA 레코드를 VM에 연결하거나, 고정 public IPv4를 endpoint로 확정한다.
|
||||
2. 개별 승인으로 NSG/firewalld에서 80/443을 열고 8082 ingress를 제거한다.
|
||||
3. VM에 공식 Caddy 패키지와 systemd service가 준비됐는지 확인한다.
|
||||
4. 앱을 재배포해 `127.0.0.1:8082`, forwarded headers, HTTPS 강제, Secure cookie를 활성화한다.
|
||||
5. Caddy 설정을 문법 검증한 후 기존 설정을 timestamp backup하고 원자적으로 설치한다.
|
||||
6. HTTP redirect, TLS 신뢰, HSTS, cookie attributes, `:8082` 차단을 외부에서 검증한다.
|
||||
|
||||
## 9. 엣지케이스 & 에러 처리
|
||||
|
||||
- FQDN DNS가 아직 VM을 가리키지 않으면 Caddy 설정을 적용하지 않는다.
|
||||
- IPv4 endpoint는 expected address와 같아야 하며 `shortlived` profile 없는 설정을 허용하지 않는다.
|
||||
- wildcard hostname은 이 단일 VM 스크립트에서 거부한다.
|
||||
- Caddy 설정 검증 또는 reload가 실패하면 직전 Caddyfile을 복원하고 reload한다.
|
||||
- 외부 HTTPS 검증이 실패해도 앱의 루프백 프로세스는 유지하며 로그와 복구 명령을 출력한다.
|
||||
- Caddy 장애 시 `8082`를 공개하는 방식으로 우회하지 않는다. 직전 Caddyfile 복원 또는 앱/프록시 동시 롤백만 허용한다.
|
||||
|
||||
## 10. 테스트 계획
|
||||
|
||||
- Maven 통합 테스트:
|
||||
- 직접 HTTP 요청이 HTTPS로 redirect되는지 확인.
|
||||
- `X-Forwarded-Proto=https`, `X-Forwarded-Host`를 적용한 요청이 정상 처리되고 HSTS가 있는지 확인.
|
||||
- 운영 환경변수가 Secure/HttpOnly/SameSite cookie 설정에 결합되는지 확인.
|
||||
- 셸 테스트:
|
||||
- FQDN/email/port 입력 검증.
|
||||
- 렌더링된 Caddyfile에 public host, HSTS, 루프백 upstream이 있는지 확인.
|
||||
- 배포 dry-run이 루프백/HTTPS 운영 override를 표시하는지 확인.
|
||||
- 운영 검증:
|
||||
- `curl -I http://<fqdn>/login`
|
||||
- `curl -I https://<fqdn>/login`
|
||||
- HTTPS GET의 `Set-Cookie`
|
||||
- 외부 `curl http://<fqdn>:8082/login` 실패
|
||||
- `systemctl is-active caddy`와 Caddy journal의 갱신 오류 확인.
|
||||
|
||||
## 11. 리스크 & 대안 검토
|
||||
|
||||
- Caddy를 선택한 이유는 HTTP redirect, ACME 발급/갱신, reverse proxy를 하나의 짧은 선언으로 운영할 수 있기 때문이다.
|
||||
- Nginx+Certbot은 가능하지만 인증서 갱신 hook과 설정 검증 경로가 분리되어 이 단일 VM PoC의 운영 표면이 더 넓다.
|
||||
- Spring Boot 직접 TLS는 애플리케이션 재기동과 인증서 교체가 결합되고 80→443 처리 및 권한 분리가 불리해 제외한다.
|
||||
|
||||
## 12. 미해결 질문 (배포 전 필수 입력)
|
||||
|
||||
- 사용할 공개 FQDN 또는 `130.162.134.59` 고정 IP와 ACME 알림 이메일.
|
||||
- DNS 변경 완료 여부와 80/443 NSG/firewalld 변경 승인.
|
||||
109
docs/runbooks/547-vm-https-operations.md
Normal file
109
docs/runbooks/547-vm-https-operations.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Redmine #547 - 외부 VM HTTPS 운영 런북
|
||||
|
||||
## 운영 구조
|
||||
|
||||
- 공개 endpoint: `https://<소유 FQDN>` 또는 `https://<고정 public IPv4>`
|
||||
- Caddy: VM의 80/443에서 TLS termination, HTTP redirect, HSTS, 인증서 자동 발급·갱신
|
||||
- Spring Boot: `127.0.0.1:8082`에서만 수신
|
||||
- 외부 `8082/tcp`: OCI NSG와 VM firewalld 모두 deny
|
||||
|
||||
FQDN을 쓰면 A 레코드는 VM public IP를 가리켜야 한다. DNS가 없는 고정 public IPv4는 Let’s Encrypt `shortlived` profile 기반 약 6일 인증서를 사용하며 Caddy 자동 갱신이 정상인지 반드시 모니터링한다. 임시 공개 DNS 서비스와 Caddy 로컬 CA 인증서는 운영 endpoint로 사용하지 않는다.
|
||||
|
||||
## 최초 준비
|
||||
|
||||
아래 작업은 시스템 패키지와 방화벽을 바꾸므로 개별 승인을 받은 뒤 수행한다.
|
||||
|
||||
1. 소유 FQDN의 A 레코드를 VM public IP로 설정하거나 고정 public IPv4 사용을 확정한다.
|
||||
2. OCI NSG와 VM firewalld에서 80/443 ingress를 허용한다.
|
||||
3. 기존 8082 ingress를 OCI NSG와 firewalld에서 제거한다.
|
||||
4. Oracle Linux/RHEL 계열 VM에 공식 Caddy 패키지를 설치한다.
|
||||
|
||||
```bash
|
||||
sudo dnf install -y dnf-plugins-core
|
||||
sudo dnf copr enable -y @caddy/caddy
|
||||
sudo dnf install -y caddy
|
||||
```
|
||||
|
||||
공식 패키지는 `caddy.service`와 `/etc/caddy/Caddyfile`을 제공한다. 설정 스크립트가 service enable/start를 처리한다.
|
||||
|
||||
## 적용
|
||||
|
||||
먼저 로컬 검증과 앱 배포를 수행한다.
|
||||
|
||||
```bash
|
||||
mvn test
|
||||
scripts/test-backoffice-https-config.sh
|
||||
scripts/deploy-backoffice-vm.sh --host hermes
|
||||
```
|
||||
|
||||
HTTPS 설정을 dry-run으로 확인한 다음 적용한다.
|
||||
|
||||
```bash
|
||||
scripts/configure-backoffice-https-vm.sh \
|
||||
--host hermes \
|
||||
--public-host admin.example.com \
|
||||
--tls-email ops@example.com \
|
||||
--expected-address 130.162.134.59 \
|
||||
--dry-run
|
||||
|
||||
scripts/configure-backoffice-https-vm.sh \
|
||||
--host hermes \
|
||||
--public-host admin.example.com \
|
||||
--tls-email ops@example.com \
|
||||
--expected-address 130.162.134.59
|
||||
```
|
||||
|
||||
실제 endpoint, 이메일, 주소로 바꿔 실행한다. FQDN 대신 IP를 쓰는 현재 hermes 예시는 `--public-host 130.162.134.59 --expected-address 130.162.134.59`다. 스크립트는 DNS/IP 일치, Caddy/sudo, Caddyfile 문법, service 상태를 확인하고 기존 설정을 `~/apps/vpd-backoffice/caddy-backups`에 저장한다.
|
||||
|
||||
## 반복 검증
|
||||
|
||||
설정을 바꾸지 않고 외부 검증만 다시 수행할 수 있다.
|
||||
|
||||
```bash
|
||||
scripts/configure-backoffice-https-vm.sh \
|
||||
--host hermes \
|
||||
--public-host admin.example.com \
|
||||
--tls-email ops@example.com \
|
||||
--expected-address 130.162.134.59 \
|
||||
--verify-only
|
||||
```
|
||||
|
||||
검증 항목:
|
||||
|
||||
- HTTP `/login`이 동일 host의 HTTPS로 전환됨
|
||||
- HTTPS 인증서가 공개 신뢰됨
|
||||
- HSTS 1년
|
||||
- `JSESSIONID`의 Secure/HttpOnly/SameSite=Lax
|
||||
- 외부 `:8082` 직접 연결 실패
|
||||
|
||||
## 인증서 갱신과 모니터링
|
||||
|
||||
Caddy는 공개 DNS 이름의 인증서를 자동 갱신하며, 공식 systemd service의 인증서 상태는 `/var/lib/caddy/.local/share/caddy`에 유지된다. 별도 cron이나 certbot hook을 추가하지 않는다.
|
||||
|
||||
```bash
|
||||
ssh hermes 'systemctl is-active caddy && systemctl is-enabled caddy'
|
||||
ssh hermes 'sudo journalctl -u caddy --since "24 hours ago" --no-pager'
|
||||
ssh hermes 'sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile'
|
||||
```
|
||||
|
||||
ACME 오류, 인증서 만료 경고, 반복 reload 실패를 알림 대상으로 삼는다. VM 백업에서 Caddy data directory와 앱의 Caddyfile backup을 함께 보존한다.
|
||||
|
||||
## 장애와 롤백
|
||||
|
||||
1. 앱이 살아 있는지 VM 내부에서 확인한다.
|
||||
|
||||
```bash
|
||||
ssh hermes 'curl -sS -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: admin.example.com" -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8082/login'
|
||||
```
|
||||
|
||||
2. Caddy 로그와 설정을 확인한다.
|
||||
3. 설정 변경 직후 장애라면 가장 최근 backup을 복원한다.
|
||||
|
||||
```bash
|
||||
ssh hermes 'ls -1t ~/apps/vpd-backoffice/caddy-backups/Caddyfile.* | head -n 3'
|
||||
ssh hermes 'sudo install -o root -g root -m 644 ~/apps/vpd-backoffice/caddy-backups/Caddyfile.<timestamp> /etc/caddy/Caddyfile && sudo systemctl reload caddy'
|
||||
```
|
||||
|
||||
4. 앱 jar 롤백이 필요하면 직전 승인된 artifact를 배포하고 앱과 Caddy를 모두 재검증한다.
|
||||
|
||||
장애 우회를 위해 `8082`를 다시 공개하지 않는다. 서비스 중단이나 방화벽/NSG 롤백은 개별 승인을 받는다.
|
||||
282
scripts/configure-backoffice-https-vm.sh
Executable file
282
scripts/configure-backoffice-https-vm.sh
Executable file
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env bash
|
||||
# Configure and verify the HTTPS reverse proxy for VPD Backoffice.
|
||||
set -Eeuo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
. "$ROOT/scripts/lib/common.sh"
|
||||
|
||||
HOST="hermes"
|
||||
PUBLIC_HOST=""
|
||||
TLS_EMAIL=""
|
||||
EXPECTED_ADDRESS=""
|
||||
REMOTE_DIR='$HOME/apps/vpd-backoffice'
|
||||
APP_PORT="8082"
|
||||
IDENTITY_FILE=""
|
||||
KNOWN_HOSTS_FILE=""
|
||||
ACCEPT_HOST_KEY="N"
|
||||
DRY_RUN="N"
|
||||
VERIFY_ONLY="N"
|
||||
INSPECT_ONLY="N"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/configure-backoffice-https-vm.sh \
|
||||
--public-host FQDN_OR_IPV4 --tls-email EMAIL --expected-address IPV4 [options]
|
||||
|
||||
Options:
|
||||
--host SSH_ALIAS SSH target (default: hermes)
|
||||
--public-host FQDN_OR_IPV4 Owned public DNS name or controlled public IPv4
|
||||
--tls-email EMAIL ACME expiry/security notification address
|
||||
--expected-address IPV4 Address that the public DNS name must resolve to
|
||||
--remote-dir PATH Application directory (default: $HOME/apps/vpd-backoffice)
|
||||
--app-port PORT Loopback Spring Boot port (default: 8082)
|
||||
--identity-file KEY SSH private key
|
||||
--known-hosts-file FILE Dedicated SSH known_hosts file
|
||||
--accept-host-key Accept a new host key (not recommended for routine use)
|
||||
--dry-run Validate and render locally without network access
|
||||
--inspect Read-only VM listener/Caddy/firewalld preflight
|
||||
--verify-only Skip configuration and run external checks only
|
||||
|
||||
This script does not change DNS, OCI NSG, or firewalld rules.
|
||||
USAGE
|
||||
}
|
||||
|
||||
validate_public_host() {
|
||||
local value="${1:-}"
|
||||
if [[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
|
||||
validate_ipv4 "$value"
|
||||
return
|
||||
fi
|
||||
[[ "$value" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$ ]] \
|
||||
|| die "공개 host는 소유한 FQDN 또는 public IPv4여야 합니다: $value"
|
||||
[[ "$value" != *".."* && "$value" != *"*"* ]] \
|
||||
|| die "wildcard/빈 label host는 허용되지 않습니다: $value"
|
||||
}
|
||||
|
||||
validate_tls_email() {
|
||||
local value="${1:-}"
|
||||
[[ "$value" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$ ]] \
|
||||
|| die "유효한 TLS 운영 이메일이 필요합니다: $value"
|
||||
}
|
||||
|
||||
validate_ssh_host() {
|
||||
local value="${1:-}"
|
||||
[[ "$value" =~ ^[A-Za-z0-9._-]+$ && "$value" != -* ]] \
|
||||
|| die "SSH host alias에 허용되지 않은 문자가 있습니다: $value"
|
||||
}
|
||||
|
||||
validate_ipv4() {
|
||||
local value="${1:-}" part octet
|
||||
local -a parts
|
||||
[[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] \
|
||||
|| die "expected address는 IPv4 형식이어야 합니다: $value"
|
||||
IFS=. read -r -a parts <<< "$value"
|
||||
for part in "${parts[@]}"; do
|
||||
octet=$((10#$part))
|
||||
(( octet >= 0 && octet <= 255 )) || die "IPv4 octet 범위가 잘못됐습니다: $value"
|
||||
done
|
||||
}
|
||||
|
||||
validate_port() {
|
||||
local value="${1:-}" port
|
||||
[[ "$value" =~ ^[0-9]+$ ]] || die "port는 1..65535 숫자여야 합니다: $value"
|
||||
port=$((10#$value))
|
||||
(( port >= 1 && port <= 65535 )) \
|
||||
|| die "port는 1..65535 숫자여야 합니다: $value"
|
||||
}
|
||||
|
||||
validate_remote_dir() {
|
||||
local value="${1:-}"
|
||||
[[ "$value" =~ ^(\$HOME|~|/)[A-Za-z0-9._/-]+$ ]] \
|
||||
|| die "remote-dir에 허용되지 않은 문자가 있습니다: $value"
|
||||
[[ "$value" != *".."* && "$value" != *"//"* ]] \
|
||||
|| die "remote-dir의 상위/빈 경로 segment는 허용되지 않습니다: $value"
|
||||
}
|
||||
|
||||
render_caddyfile() {
|
||||
local output="${1:?output path is required}" tls_options=""
|
||||
if [[ "$PUBLIC_HOST" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
|
||||
tls_options=$'tls {\n\t\tissuer acme {\n\t\t\tprofile shortlived\n\t\t}\n\t}'
|
||||
fi
|
||||
awk \
|
||||
-v public_host="$PUBLIC_HOST" \
|
||||
-v tls_email="$TLS_EMAIL" \
|
||||
-v app_port="$APP_PORT" \
|
||||
-v tls_options="$tls_options" '
|
||||
{
|
||||
gsub(/{{PUBLIC_HOST}}/, public_host)
|
||||
gsub(/{{TLS_EMAIL}}/, tls_email)
|
||||
gsub(/{{APP_PORT}}/, app_port)
|
||||
gsub(/{{TLS_OPTIONS}}/, tls_options)
|
||||
print
|
||||
}
|
||||
' "$ROOT/deploy/caddy/Caddyfile.template" > "$output"
|
||||
}
|
||||
|
||||
build_ssh_args() {
|
||||
SSH_ARGS=(
|
||||
-o BatchMode=yes
|
||||
-o ConnectTimeout=8
|
||||
-o ConnectionAttempts=1
|
||||
-o ServerAliveInterval=5
|
||||
-o ServerAliveCountMax=2
|
||||
)
|
||||
if [[ -n "$IDENTITY_FILE" ]]; then
|
||||
[[ -f "$IDENTITY_FILE" ]] || die "identity file을 찾을 수 없습니다: $IDENTITY_FILE"
|
||||
SSH_ARGS+=(-i "$IDENTITY_FILE")
|
||||
fi
|
||||
if [[ -n "$KNOWN_HOSTS_FILE" ]]; then
|
||||
SSH_ARGS+=(-o "UserKnownHostsFile=$KNOWN_HOSTS_FILE")
|
||||
fi
|
||||
if [[ "$ACCEPT_HOST_KEY" == "Y" ]]; then
|
||||
SSH_ARGS+=(-o StrictHostKeyChecking=accept-new)
|
||||
else
|
||||
SSH_ARGS+=(-o StrictHostKeyChecking=yes)
|
||||
fi
|
||||
}
|
||||
|
||||
verify_dns() {
|
||||
local addresses
|
||||
if [[ "$PUBLIC_HOST" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
|
||||
[[ "$PUBLIC_HOST" == "$EXPECTED_ADDRESS" ]] \
|
||||
|| die "public IPv4와 expected address가 다릅니다: $PUBLIC_HOST != $EXPECTED_ADDRESS"
|
||||
return
|
||||
fi
|
||||
addresses="$(getent ahostsv4 "$PUBLIC_HOST" | awk '{print $1}' | sort -u)"
|
||||
[[ -n "$addresses" ]] || die "DNS A record를 조회할 수 없습니다: $PUBLIC_HOST"
|
||||
grep -Fxq "$EXPECTED_ADDRESS" <<< "$addresses" \
|
||||
|| die "$PUBLIC_HOST DNS가 expected address $EXPECTED_ADDRESS 를 가리키지 않습니다 (actual: ${addresses//$'\n'/,})"
|
||||
}
|
||||
|
||||
verify_external() {
|
||||
local tmp_dir http_headers https_headers cookie_line location
|
||||
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/vpd-backoffice-https-verify.XXXXXX")"
|
||||
http_headers="$tmp_dir/http.headers"
|
||||
https_headers="$tmp_dir/https.headers"
|
||||
|
||||
curl -sS --connect-timeout 8 --max-time 20 -D "$http_headers" -o /dev/null \
|
||||
"http://$PUBLIC_HOST/login"
|
||||
location="$(awk 'BEGIN{IGNORECASE=1} /^Location:/{sub(/\r$/, "", $2); print $2; exit}' "$http_headers")"
|
||||
[[ "$location" == "https://$PUBLIC_HOST/login" ]] \
|
||||
|| die "HTTP→HTTPS redirect가 올바르지 않습니다: ${location:-missing}"
|
||||
|
||||
curl -sS --connect-timeout 8 --max-time 20 -D "$https_headers" -o /dev/null \
|
||||
"https://$PUBLIC_HOST/login"
|
||||
grep -Eiq '^Strict-Transport-Security:.*max-age=31536000' "$https_headers" \
|
||||
|| die "HTTPS 응답에 1년 HSTS가 없습니다."
|
||||
cookie_line="$(grep -Ei '^Set-Cookie:[[:space:]]*JSESSIONID=' "$https_headers" | head -n 1 || true)"
|
||||
[[ "$cookie_line" == *"Secure"* && "$cookie_line" == *"HttpOnly"* ]] \
|
||||
|| die "JSESSIONID Secure/HttpOnly 속성을 확인할 수 없습니다."
|
||||
grep -Eiq '^Set-Cookie:.*JSESSIONID=.*SameSite=Lax' <<< "$cookie_line" \
|
||||
|| die "JSESSIONID SameSite=Lax 속성을 확인할 수 없습니다."
|
||||
|
||||
if curl -sS --connect-timeout 3 --max-time 5 -o /dev/null \
|
||||
"http://$PUBLIC_HOST:$APP_PORT/login" 2>/dev/null; then
|
||||
die "외부에서 애플리케이션 포트 $APP_PORT 에 직접 접근할 수 있습니다."
|
||||
fi
|
||||
|
||||
rm -rf "$tmp_dir"
|
||||
ok "HTTPS 검증 통과: redirect, TLS, HSTS, session cookie, direct-port deny"
|
||||
}
|
||||
|
||||
main() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--host) HOST="${2:?--host requires a value}"; shift 2 ;;
|
||||
--public-host) PUBLIC_HOST="${2:?--public-host requires a value}"; shift 2 ;;
|
||||
--tls-email) TLS_EMAIL="${2:?--tls-email requires a value}"; shift 2 ;;
|
||||
--expected-address) EXPECTED_ADDRESS="${2:?--expected-address requires a value}"; shift 2 ;;
|
||||
--remote-dir) REMOTE_DIR="${2:?--remote-dir requires a value}"; shift 2 ;;
|
||||
--app-port) APP_PORT="${2:?--app-port requires a value}"; shift 2 ;;
|
||||
--identity-file) IDENTITY_FILE="${2:?--identity-file requires a value}"; shift 2 ;;
|
||||
--known-hosts-file) KNOWN_HOSTS_FILE="${2:?--known-hosts-file requires a value}"; shift 2 ;;
|
||||
--accept-host-key) ACCEPT_HOST_KEY="Y"; shift ;;
|
||||
--dry-run) DRY_RUN="Y"; shift ;;
|
||||
--inspect) INSPECT_ONLY="Y"; shift ;;
|
||||
--verify-only) VERIFY_ONLY="Y"; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) die "알 수 없는 옵션: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$INSPECT_ONLY" == "Y" ]]; then
|
||||
validate_ssh_host "$HOST"
|
||||
need_cmd ssh "OpenSSH client"
|
||||
need_cmd timeout "bounded SSH preflight"
|
||||
build_ssh_args
|
||||
timeout 30s ssh "${SSH_ARGS[@]}" "$HOST" 'bash -s' <<'INSPECT'
|
||||
set -Eeuo pipefail
|
||||
printf 'host=%s user=%s\n' "$(hostname)" "$(whoami)"
|
||||
printf '%s\n' 'listeners:'
|
||||
ss -ltn | awk 'NR == 1 || $4 ~ /:(80|443|8082)$/'
|
||||
if command -v caddy >/dev/null 2>&1; then
|
||||
printf 'caddy=%s\n' "$(timeout 5s caddy version || true)"
|
||||
printf 'caddy_active=%s\n' "$(timeout 5s systemctl is-active caddy 2>/dev/null || true)"
|
||||
printf 'caddy_enabled=%s\n' "$(timeout 5s systemctl is-enabled caddy 2>/dev/null || true)"
|
||||
else
|
||||
printf '%s\n' 'caddy=not-installed'
|
||||
fi
|
||||
if sudo -n true >/dev/null 2>&1 && command -v firewall-cmd >/dev/null 2>&1; then
|
||||
printf 'firewalld_ports=%s\n' "$(timeout 5s sudo firewall-cmd --list-ports 2>/dev/null || true)"
|
||||
fi
|
||||
printf 'app_loopback_http_status='
|
||||
curl -sS --connect-timeout 3 -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8082/login || true
|
||||
INSPECT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
validate_public_host "$PUBLIC_HOST"
|
||||
validate_tls_email "$TLS_EMAIL"
|
||||
validate_ssh_host "$HOST"
|
||||
validate_ipv4 "$EXPECTED_ADDRESS"
|
||||
validate_port "$APP_PORT"
|
||||
validate_remote_dir "$REMOTE_DIR"
|
||||
|
||||
local tmp_dir candidate remote_stage
|
||||
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/vpd-backoffice-caddy.XXXXXX")"
|
||||
candidate="$tmp_dir/Caddyfile"
|
||||
render_caddyfile "$candidate"
|
||||
|
||||
if [[ "$DRY_RUN" == "Y" ]]; then
|
||||
log "dry-run: host=$HOST"
|
||||
log "dry-run: public_host=$PUBLIC_HOST"
|
||||
log "dry-run: expected_address=$EXPECTED_ADDRESS"
|
||||
log "dry-run: app_upstream=127.0.0.1:$APP_PORT"
|
||||
log "dry-run: remote_dir=$REMOTE_DIR"
|
||||
cat "$candidate"
|
||||
rm -rf "$tmp_dir"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
need_cmd ssh "OpenSSH client"
|
||||
need_cmd curl "HTTPS 검증"
|
||||
need_cmd getent "DNS 검증"
|
||||
verify_dns
|
||||
build_ssh_args
|
||||
|
||||
if [[ "$VERIFY_ONLY" != "Y" ]]; then
|
||||
remote_stage="${REMOTE_DIR%/}/.https-stage"
|
||||
log "Caddy 및 sudo 사전 점검: $HOST"
|
||||
ssh "${SSH_ARGS[@]}" "$HOST" \
|
||||
'command -v caddy >/dev/null && command -v systemctl >/dev/null && sudo -n true'
|
||||
|
||||
log "Caddy candidate 업로드"
|
||||
ssh "${SSH_ARGS[@]}" "$HOST" "mkdir -p \"$remote_stage\""
|
||||
cp "$ROOT/deploy/caddy/install-caddy-config.sh" "$tmp_dir/install-caddy-config.sh"
|
||||
tar -C "$tmp_dir" -czf - Caddyfile install-caddy-config.sh \
|
||||
| ssh "${SSH_ARGS[@]}" "$HOST" "tar -C \"$remote_stage\" -xzf - && chmod 700 \"$remote_stage/install-caddy-config.sh\""
|
||||
|
||||
log "Caddy 설정 검증·백업·적용"
|
||||
ssh "${SSH_ARGS[@]}" "$HOST" \
|
||||
"\"$remote_stage/install-caddy-config.sh\" \"$remote_stage/Caddyfile\" \"$REMOTE_DIR\""
|
||||
fi
|
||||
|
||||
rm -rf "$tmp_dir"
|
||||
verify_external
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
@@ -27,6 +27,10 @@ Defaults:
|
||||
--remote-dir \$HOME/apps/vpd-backoffice
|
||||
--port 8082
|
||||
|
||||
Production transport defaults are always applied on the VM:
|
||||
bind address 127.0.0.1, forwarded headers enabled,
|
||||
HTTPS required, Secure/HttpOnly/SameSite session cookie.
|
||||
|
||||
The script uploads:
|
||||
- target/*.jar as app.jar
|
||||
- .env rewritten for the VM wallet path
|
||||
@@ -111,6 +115,9 @@ if [[ "$DRY_RUN" == "Y" ]]; then
|
||||
log "dry-run: remote_dir=$REMOTE_DIR"
|
||||
log "dry-run: remote_wallet_dir=$REMOTE_WALLET_DIR"
|
||||
log "dry-run: port=$REMOTE_PORT"
|
||||
log "dry-run: bind_address=127.0.0.1"
|
||||
log "dry-run: require_https=true"
|
||||
log "dry-run: session_cookie_secure=true"
|
||||
log "dry-run: local_wallet=$TNS_ADMIN"
|
||||
exit 0
|
||||
fi
|
||||
@@ -144,6 +151,10 @@ export TNS_ADMIN="${REMOTE_WALLET_DIR}"
|
||||
export BACKOFFICE_DB_URL="jdbc:oracle:thin:@\${ADB_TNS}?TNS_ADMIN=\${TNS_ADMIN}"
|
||||
export BACKOFFICE_ORDS_DB_URL="\${BACKOFFICE_DB_URL}"
|
||||
export BACKOFFICE_PORT="${REMOTE_PORT}"
|
||||
export BACKOFFICE_BIND_ADDRESS="127.0.0.1"
|
||||
export BACKOFFICE_FORWARD_HEADERS_STRATEGY="framework"
|
||||
export BACKOFFICE_REQUIRE_HTTPS="true"
|
||||
export BACKOFFICE_SESSION_COOKIE_SECURE="true"
|
||||
ENVEOF
|
||||
chmod 600 "$TMP_DIR/.env"
|
||||
|
||||
@@ -222,7 +233,7 @@ if [[ "$START_APP" == "Y" ]]; then
|
||||
ssh "${SSH_ARGS[@]}" "$HOST" "\"$REMOTE_DIR\"/stop.sh || true; \"$REMOTE_DIR\"/start.sh; sleep 8; \"$REMOTE_DIR\"/status.sh"
|
||||
|
||||
log "원격 헬스체크"
|
||||
ssh "${SSH_ARGS[@]}" "$HOST" "curl -sS -o /tmp/vpd-backoffice-login.html -w '%{http_code}\n' http://127.0.0.1:${REMOTE_PORT}/login"
|
||||
ssh "${SSH_ARGS[@]}" "$HOST" "curl -sS -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: localhost' -o /tmp/vpd-backoffice-login.html -w '%{http_code}\n' http://127.0.0.1:${REMOTE_PORT}/login"
|
||||
fi
|
||||
|
||||
ok "배포 완료: $HOST:$REMOTE_DIR"
|
||||
|
||||
52
scripts/test-backoffice-https-config.sh
Executable file
52
scripts/test-backoffice-https-config.sh
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
. "$ROOT/scripts/configure-backoffice-https-vm.sh"
|
||||
|
||||
assert_rejected() {
|
||||
local label="${1:?label}" function_name="${2:?function}" value="${3:-}"
|
||||
if ("$function_name" "$value") >/dev/null 2>&1; then
|
||||
echo "FAIL: $label was accepted: $value" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_public_host "admin.example.com"
|
||||
validate_public_host "130.162.134.59"
|
||||
validate_tls_email "ops@example.com"
|
||||
validate_ssh_host "hermes"
|
||||
validate_ipv4 "130.162.134.59"
|
||||
validate_port "8082"
|
||||
validate_remote_dir '$HOME/apps/vpd-backoffice'
|
||||
|
||||
assert_rejected "invalid IPv4 host" validate_public_host "130.162.134.999"
|
||||
assert_rejected "wildcard host" validate_public_host "*.example.com"
|
||||
assert_rejected "single-label host" validate_public_host "hermes"
|
||||
assert_rejected "invalid email" validate_tls_email "ops at example.com"
|
||||
assert_rejected "SSH option injection" validate_ssh_host "-oProxyCommand=id"
|
||||
assert_rejected "invalid IPv4" validate_ipv4 "130.162.134.999"
|
||||
assert_rejected "privileged overflow port" validate_port "65536"
|
||||
assert_rejected "remote command injection" validate_remote_dir '$HOME/apps/x;id'
|
||||
assert_rejected "remote path traversal" validate_remote_dir '$HOME/apps/../etc'
|
||||
|
||||
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/vpd-backoffice-https-test.XXXXXX")"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
PUBLIC_HOST="admin.example.com"
|
||||
TLS_EMAIL="ops@example.com"
|
||||
APP_PORT="8082"
|
||||
render_caddyfile "$tmp_dir/Caddyfile"
|
||||
|
||||
grep -Fq 'admin.example.com {' "$tmp_dir/Caddyfile"
|
||||
grep -Fq 'email ops@example.com' "$tmp_dir/Caddyfile"
|
||||
grep -Fq 'reverse_proxy 127.0.0.1:8082' "$tmp_dir/Caddyfile"
|
||||
grep -Fq 'Strict-Transport-Security "max-age=31536000; includeSubDomains"' "$tmp_dir/Caddyfile"
|
||||
|
||||
PUBLIC_HOST="130.162.134.59"
|
||||
render_caddyfile "$tmp_dir/Caddyfile.ip"
|
||||
grep -Fq '130.162.134.59 {' "$tmp_dir/Caddyfile.ip"
|
||||
grep -Fq 'profile shortlived' "$tmp_dir/Caddyfile.ip"
|
||||
|
||||
echo "PASS: HTTPS configuration validation and rendering"
|
||||
@@ -11,7 +11,7 @@ public record BackofficeProperties(
|
||||
Ai ai
|
||||
) {
|
||||
|
||||
public record Security(String adminUser, String adminPassword) {
|
||||
public record Security(String adminUser, String adminPassword, boolean requireHttps) {
|
||||
}
|
||||
|
||||
public record Token(int maxDays) {
|
||||
|
||||
@@ -14,9 +14,19 @@ import org.springframework.security.web.SecurityFilterChain;
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
SecurityFilterChain securityFilterChain(
|
||||
HttpSecurity http,
|
||||
BackofficeProperties properties
|
||||
) throws Exception {
|
||||
if (properties.security().requireHttps()) {
|
||||
http.requiresChannel(channel -> channel.anyRequest().requiresSecure());
|
||||
}
|
||||
|
||||
return http
|
||||
.csrf(csrf -> csrf.ignoringRequestMatchers("/mcp/messages", "/mcp/*/messages"))
|
||||
.headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts
|
||||
.includeSubDomains(true)
|
||||
.maxAgeInSeconds(31_536_000)))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/css/**", "/js/**", "/webjars/**").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
|
||||
@@ -22,12 +22,21 @@ mybatis:
|
||||
map-underscore-to-camel-case: true
|
||||
|
||||
server:
|
||||
address: ${BACKOFFICE_BIND_ADDRESS:0.0.0.0}
|
||||
port: ${BACKOFFICE_PORT:8080}
|
||||
forward-headers-strategy: ${BACKOFFICE_FORWARD_HEADERS_STRATEGY:none}
|
||||
servlet:
|
||||
session:
|
||||
cookie:
|
||||
secure: ${BACKOFFICE_SESSION_COOKIE_SECURE:false}
|
||||
http-only: true
|
||||
same-site: lax
|
||||
|
||||
backoffice:
|
||||
security:
|
||||
admin-user: ${BACKOFFICE_ADMIN_USER:admin}
|
||||
admin-password: ${BACKOFFICE_ADMIN_PASSWORD:admin}
|
||||
require-https: ${BACKOFFICE_REQUIRE_HTTPS:false}
|
||||
token:
|
||||
max-days: ${BACKOFFICE_TOKEN_MAX_DAYS:365}
|
||||
ords:
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.cloudhandson.vpdbackoffice.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.web.ServerProperties;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.Binder;
|
||||
import org.springframework.boot.env.YamlPropertySourceLoader;
|
||||
import org.springframework.boot.test.util.TestPropertyValues;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
import org.springframework.core.env.StandardEnvironment;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockServletContext;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
|
||||
import org.springframework.web.filter.ForwardedHeaderFilter;
|
||||
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
|
||||
|
||||
class TransportSecurityTest {
|
||||
|
||||
private AnnotationConfigWebApplicationContext context;
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
context = new AnnotationConfigWebApplicationContext();
|
||||
context.setServletContext(new MockServletContext());
|
||||
TestPropertyValues.of(
|
||||
"backoffice.security.admin-user=admin",
|
||||
"backoffice.security.admin-password=test-password",
|
||||
"backoffice.security.require-https=true",
|
||||
"backoffice.token.max-days=365",
|
||||
"backoffice.ords.base-url=https://ords.example.test",
|
||||
"backoffice.ords.timeout=10s",
|
||||
"backoffice.ai.enabled=false",
|
||||
"backoffice.ai.timeout=30s"
|
||||
).applyTo(context);
|
||||
context.register(TestWebConfig.class);
|
||||
context.refresh();
|
||||
|
||||
mockMvc = MockMvcBuilders.webAppContextSetup(context)
|
||||
.addFilters(new ForwardedHeaderFilter())
|
||||
.apply(springSecurity())
|
||||
.build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
context.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void directHttpRequestRedirectsToHttps() throws Exception {
|
||||
mockMvc.perform(get("/login"))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrl("https://localhost/login"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardedHttpsRequestIsAcceptedAndReturnsHsts() throws Exception {
|
||||
mockMvc.perform(get("/login")
|
||||
.header("X-Forwarded-Proto", "https")
|
||||
.header("X-Forwarded-Host", "admin.example.test"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string(
|
||||
"Strict-Transport-Security",
|
||||
containsString("max-age=31536000")
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardedHostIsUsedForAuthenticationRedirect() throws Exception {
|
||||
mockMvc.perform(get("/")
|
||||
.accept(MediaType.TEXT_HTML)
|
||||
.header("X-Forwarded-Proto", "https")
|
||||
.header("X-Forwarded-Host", "admin.example.test"))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrl("https://admin.example.test/login"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void productionSessionCookieSettingsAreBound() throws Exception {
|
||||
var environment = new StandardEnvironment();
|
||||
environment.getPropertySources().addFirst(new MapPropertySource("test-overrides", Map.of(
|
||||
"BACKOFFICE_FORWARD_HEADERS_STRATEGY", "framework",
|
||||
"BACKOFFICE_SESSION_COOKIE_SECURE", "true"
|
||||
)));
|
||||
var loader = new YamlPropertySourceLoader();
|
||||
for (var source : loader.load("application", new ClassPathResource("application.yml"))) {
|
||||
environment.getPropertySources().addLast(source);
|
||||
}
|
||||
|
||||
ServerProperties properties = Binder.get(environment)
|
||||
.bind("server", ServerProperties.class)
|
||||
.orElseThrow(() -> new AssertionError("server properties were not bound"));
|
||||
var cookie = properties.getServlet().getSession().getCookie();
|
||||
|
||||
assertThat(cookie.getSecure()).isTrue();
|
||||
assertThat(cookie.getHttpOnly()).isTrue();
|
||||
assertThat(cookie.getSameSite().attributeValue()).isEqualTo("Lax");
|
||||
assertThat(properties.getForwardHeadersStrategy().name()).isEqualTo("FRAMEWORK");
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableWebMvc
|
||||
@EnableWebSecurity
|
||||
@EnableConfigurationProperties(BackofficeProperties.class)
|
||||
@Import({SecurityConfig.class, TestController.class})
|
||||
static class TestWebConfig {
|
||||
}
|
||||
|
||||
@RestController
|
||||
static class TestController {
|
||||
|
||||
@GetMapping("/login")
|
||||
String login() {
|
||||
return "login";
|
||||
}
|
||||
|
||||
@GetMapping("/")
|
||||
String home() {
|
||||
return "home";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user