개요
- EAP-TTLS의 inner method를 PAP으로 고정하고, PAP 검증 지점에 TOTP 스크립트를 끼운다.
- 사용자 데이터는 파일 기반 3개 디렉터리(비밀번호 / TOTP 시크릿 / 모드)로 분리했다.
- sites-available/이 아니라 sites-enabled/가 실동 디렉터리다. 여기 남은 백업 파일 하나가 인증을 조용히 망가뜨린다.
- TOTP 구성 후 radtest를 그냥 던지면 응답 없이 멈춘다. 서버가 죽은 게 아니다.
이 글은 누구를 위한 글인가
FreeRADIUS 3.x에서 EAP-TTLS를 세워야 하고, 여기에 상용 라이선스 없이 OTP 2단계 인증을 붙이려는 사람.
읽고 나면 인증 체인의 어느 지점에 OTP 검증을 끼워야 하는지, 그리고 검증이 안 될 때 어디를 먼저 봐야 하는지 알게 된다.
검증 환경
| OS | Rocky Linux 8.10 |
| FreeRADIUS | 3.0.20 (배포판 패키지) |
| 클라이언트(NAS) | FortiGate 201F, FortiOS 7.4.11 |
| 인증 | EAP-TTLS → inner PAP → 비밀번호 + TOTP |
설계: 인증 체인의 어디에 OTP를 끼우는가
이 구성의 핵심은 한 문장이다.
사용자는 비밀번호 + 6자리 OTP를 하나의 비밀번호 필드에 이어서 입력하고, 서버가 뒤 6자리를 떼어 검증한다.
이 방식을 고른 이유는 Part 1에서 설명했다. 요약하면, 별도 OTP 입력창을 띄우는 방식은 클라이언트 지원이 제각각이고,
이 방식은 어떤 클라이언트에서도 동작한다.
그래서 흐름은 이렇게 된다.
EAP-TTLS 터널 수립
└─ inner PAP: username / "password+123456"
└─ authorize 단계: 사용자 존재 확인, reply 속성 부여
└─ authenticate 단계: 스크립트 호출
├─ 뒤 6자리 분리 → TOTP 검증
└─ 앞부분 → 저장된 비밀번호와 비교
두 검증이 모두 통과해야 Access-Accept.
1. EAP를 TTLS/PAP으로 고정하기
/etc/raddb/mods-available/eap:
eap {
default_eap_type = ttls
timer_expire = 60
max_sessions = 4096
tls-config tls-common {
private_key_file = /etc/raddb/certs/server.key
certificate_file = /etc/raddb/certs/server.pem
ca_file = /etc/raddb/certs/ca.pem
# 왜: TLS 1.0/1.1을 명시적으로 막는다. 구형 클라이언트 호환보다 우선.
tls_min_version = "1.2"
}
ttls {
tls = tls-common
# 왜: inner method를 PAP으로 고정. TOTP 검증에 원문 문자열이 필요하다.
default_eap_type = pap
copy_request_to_tunnel = yes
use_tunneled_reply = yes
virtual_server = "inner-tunnel"
}
}
copy_request_to_tunnel과 use_tunneled_reply를 둘 다 yes로 두는 게 중요하다. 이걸 빼면 터널 내부에서 결정한 reply 속성(할당 IP 등)이 외부로 나오지 않는다. 인증은 성공하는데 IP가 안 붙는 증상의 흔한 원인이다.
2. TOTP 검증 스크립트 연결
rlm_exec로 외부 스크립트를 호출한다. /etc/raddb/mods-available/totp:
exec totp_auth {
wait = yes
program = "/usr/local/bin/totp_auth.py %{User-Name} %{User-Password}"
input_pairs = request
output_pairs = reply
shell_escape = yes
# 왜: 스크립트가 응답하지 않을 때 무한 대기를 막는다.
# wait=yes만 두고 timeout을 생략하면 이후 진단이 지옥이 된다. (아래 함정 참고)
timeout = 10
}
inner-tunnel 가상 서버의 authenticate 섹션에 등록한다.
authenticate {
Auth-Type PAP {
totp_auth
}
eap
}
스크립트는 종료 코드로 결과를 전달한다. 0 = 인증 성공, 1 = 실패. 표준 출력으로 reply 속성을 내보낼 수도 있다.
스크립트가 하는 일은 단순하다.
- User-Password에서 뒤 6자리 분리
- 해당 사용자의 TOTP 시크릿을 읽어 현재 코드 생성, 비교 (앞뒤 30초 윈도 허용)
- 남은 앞부분을 저장된 비밀번호 해시와 비교
- 둘 다 통과 시 0으로 종료
3. 사용자 데이터 구조
DB를 쓰지 않고 파일로 갔다. 초기 단계에서 의도적으로 단순하게 유지했다.
/etc/raddb/users_passwd/ # 사용자별 비밀번호 해시
/etc/raddb/users_totp/ # 사용자별 TOTP 시크릿
/etc/raddb/users_mode/ # 사용자별 인증 모드 (OTP 사용/미사용 등)
계정 등록은 스크립트로 감쌌다. 수동 편집은 반드시 오타를 낸다.
# /usr/local/bin/useradd.sh <username>
# - 비밀번호 입력받아 해시 저장
# - TOTP 시크릿 생성 + QR 출력
# - 그룹에 따라 authorize 항목 추가
authorize 파일에는 그룹 정보만 둔다.
alice Cleartext-Password := "<REDACTED>"
Fortinet-Group-Name = "staff",
Fallthrough = no
Fortinet-Group-Name은 Fortinet 벤더 속성이다. 이 값으로 FortiGate 쪽 그룹 매칭과 IP 풀 분기를 하려 했는데, 여기서 예상 못 한 벽에 부딪혔다. Part 7에서 다룬다.
파일 기반 방식의 한계를 미리 적어둔다: 서버가 2대 이상이 되면 파일 동기화가 곧 문제가 된다. 이건 알고 시작한 부채다.
(Part 12)
4. 그룹별 IP 할당
rlm_ippool로 그룹별 대역을 나눴다.
ippool staff_pool {
range-start = 10.10.60.16
range-stop = 10.10.60.127
netmask = 255.255.255.0
cache-size = 800
session-db = ${db_dir}/db.ippool.staff
ip-index = ${db_dir}/db.ipindex.staff
override = no
maximum-timeout = 0
}
FortiGate 쪽에서는 set assign-ip-from usrgrp로 받는다.
함정: FortiOS 7.4에는 assign-ip-from radius 같은 옵션이 없다.
유효한 값은 range, usrgrp, name이다. 문서를 잘못 읽고 30분을 태웠다.
특정 사용자에게 고정 IP를 줄 때는 reply 속성으로 직접 지정한다.
bob Cleartext-Password := "<REDACTED>"
Framed-IP-Address = 10.10.60.200,
Fallthrough = no
주의: Framed-IP-Address 변경은 새 IKE_AUTH 세션에만 적용된다. 접속 중인 세션은 그대로다.
바꿨는데 안 바뀐다면 재접속을 시켜야 한다. 이걸 몰라서 설정이 안 먹는 줄 알고 파일을 세 번 다시 썼다.
함정 1: sites-enabled가 실동 디렉터리다
가장 오래 잡아먹은 문제였다.
FreeRADIUS는 Apache/nginx와 같은 패턴을 쓴다. sites-available/에 정의를 두고, sites-enabled/에 심볼릭 링크를 걸어 활성화한다. 실제로 읽히는 것은 sites-enabled/다.
문제는 이랬다. default 가상 서버를 수정하기 전에 습관적으로 백업을 떴다.
cp /etc/raddb/sites-enabled/default /etc/raddb/sites-enabled/default.bak
이게 원인이었다. FreeRADIUS는 sites-enabled/ 안의 모든 파일을 읽는다. 확장자를 보지 않는다. 그래서 default.bak이 두 번째 가상 서버로 로드되고, 같은 포트에 두 개의 서버가 물려 요청이 예측 불가능하게 갈렸다.
증상이 고약했다. 서버가 뜨고, 로그도 정상이고, 어떤 요청은 통과하고 어떤 요청은 실패했다.
radiusd -X로 봐도 어느 서버가 처리 중인지 즉시 드러나지 않았다.
교훈: sites-enabled/, mods-enabled/ 안에서는 절대 백업을 만들지 않는다. 백업은 디렉터리 밖으로.
# 이렇게
cp /etc/raddb/sites-enabled/default /root/backup/default.$(date +%F)
함정 2: radtest가 응답 없이 멈춘다
TOTP를 붙인 뒤 검증하려고 평소처럼 던졌다.
radtest alice mypassword 127.0.0.1 0 <REDACTED>
응답이 없다. 그대로 멈춘다. 서버가 죽었다고 판단하고 프로세스를 뒤졌다. 살아 있었다.
원인은 TOTP 스크립트였다. 유효한 6자리 OTP 접미사가 없는 입력이 들어오면, 스크립트가 입력을 더 기다리며 블로킹된다. wait = yes인 상태에서 timeout을 지정하지 않았기 때문에 FreeRADIUS도 같이 무한 대기했다.
즉 이건 서버 장애가 아니라 테스트 방법이 틀린 것이었다.
올바른 검증:
# 방법 1: 실제 OTP 코드를 비밀번호에 붙여서
radtest alice 'mypassword123456' 127.0.0.1 0 <REDACTED>
# 방법 2: 클라이언트로 직접 접속해서 확인 (가장 확실)
그리고 앞서 언급한 timeout = 10을 반드시 설정한다. 이게 없으면 운영 중에 스크립트가 한 번 멈출 때 인증 전체가 멈춘다.
이 함정이 위험한 이유는 "RADIUS가 죽었다"는 잘못된 결론에 도달하기 쉽기 때문이다. 나는 실제로 이 오판으로 방향을 잃었고, 나중에 진짜 문제(Part 8)를 진단할 때도 같은 오판을 반복했다.
함정 3: SELinux
Rocky Linux 기본 설정에서 rlm_exec가 외부 스크립트를 호출하려면 정책이 필요하다. 증상은 "스크립트가 실행되지 않음"이고, FreeRADIUS 로그에는 명확한 원인이 안 나온다.
# 거부 로그 확인
ausearch -m avc -ts recent | grep radiusd
# 정책 생성
ausearch -c 'radiusd' --raw | audit2allow -M radiusd_totp
semodule -i radiusd_totp.pp
setenforce 0으로 넘기고 싶은 유혹이 있지만, 그러면 문제가 재부팅 후에 돌아온다.
배포 전 반드시 하는 것
# 왜: 설정 문법 오류를 재시작 전에 잡는다. 재시작 후 죽으면 인증 전체가 멈춘다.
radiusd -XC
# 문제 추적 시: 포그라운드 디버그 모드
systemctl stop radiusd
radiusd -X
radiusd -XC를 습관으로 만들면 사고의 절반이 사라진다.
여기서 막힌 사람들을 위한 확인 순서
- radiusd -XC → Configuration appears to be OK 확인
- ls -la /etc/raddb/sites-enabled/ → 심볼릭 링크 외의 파일이 있는지 확인
- ls -la /etc/raddb/mods-enabled/ → 같은 확인
- radiusd -X 실행 후 실제 클라이언트로 접속 → +group authenticate 블록에서 스크립트 호출 여부 확인
- ausearch -m avc -ts recent | grep radiusd → SELinux 거부 없는지 확인
- OTP 코드를 붙인 radtest로 검증 (붙이지 않으면 멈춘다)
이 편에서 배운 것
- 설정 디렉터리 안에 백업 파일을 만들지 않는다. 확장자를 보지 않는 로더가 세상에 많다.
- wait = yes를 쓰는 모든 외부 호출에는 timeout을 같이 쓴다. 없으면 실패가 "멈춤"으로 나타나고, 멈춤은 실패보다 진단이 훨씬 어렵다.
- 진단 도구가 침묵할 때, 도구 사용법이 틀린 경우를 먼저 의심한다. 서버 장애로 결론 내리는 게 훨씬 쉽고, 그래서 훨씬 자주 틀린다.
다음 편에서는
Part 3에서 FortiGate 쪽 Phase1/Phase2를 세우고, 그룹별 접근제어를 위한 IP 풀을 설계한다.
여기서 세운 풀 설계가 나중에 Part 7의 우회로가 된다.
'네트워크' 카테고리의 다른 글
| IKE는 붙는데 인증이 안 된다 — 원인이 하나가 아니라 다섯 개였다 (Part 5) (0) | 2026.07.25 |
|---|---|
| EMS 없이 150명에게 FortiClient 프로파일 배포하기 (Part 4) (0) | 2026.07.25 |
| FortiGate Phase1/Phase2와 IP 풀 설계 — 나중에 살아남을 대역 나누기 (Part 3) (0) | 2026.07.25 |
| 왜 IKEv2 + EAP-TTLS + FreeRADIUS + TOTP인가 — 탈락한 대안들까지 (Part 1) (1) | 2026.07.25 |
| 벤더가 터널 모드를 없앤다고 했다 - 1인 관리자의 VPN 전환기 (0) | 2026.07.25 |