본문 바로가기

네트워크

FreeRADIUS 3.0.20에 TOTP를 붙이기 — 파일 기반으로 어디까지 갈 수 있는가 (Part 2)

개요

  • 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 속성을 내보낼 수도 있다.

스크립트가 하는 일은 단순하다.

  1. User-Password에서 뒤 6자리 분리
  2. 해당 사용자의 TOTP 시크릿을 읽어 현재 코드 생성, 비교 (앞뒤 30초 윈도 허용)
  3. 남은 앞부분을 저장된 비밀번호 해시와 비교
  4. 둘 다 통과 시 0으로 종료

3. 사용자 데이터 구조

DB를 쓰지 않고 파일로 갔다. 초기 단계에서 의도적으로 단순하게 유지했다.

/etc/raddb/users_passwd/    # 사용자별 비밀번호 해시
/etc/raddb/users_totp/      # 사용자별 TOTP 시크릿
/etc/raddb/users_mode/      # 사용자별 인증 모드 (OTP 사용/미사용 등)

계정 등록은 스크립트로 감쌌다. 수동 편집은 반드시 오타를 낸다.

bash
# /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 가상 서버를 수정하기 전에 습관적으로 백업을 떴다.

bash
cp /etc/raddb/sites-enabled/default /etc/raddb/sites-enabled/default.bak

이게 원인이었다. FreeRADIUS는 sites-enabled/ 안의 모든 파일을 읽는다. 확장자를 보지 않는다. 그래서 default.bak이 두 번째 가상 서버로 로드되고, 같은 포트에 두 개의 서버가 물려 요청이 예측 불가능하게 갈렸다.

증상이 고약했다. 서버가 뜨고, 로그도 정상이고, 어떤 요청은 통과하고 어떤 요청은 실패했다.
radiusd -X로 봐도 어느 서버가 처리 중인지 즉시 드러나지 않았다.

교훈: sites-enabled/, mods-enabled/ 안에서는 절대 백업을 만들지 않는다. 백업은 디렉터리 밖으로.

bash
# 이렇게
cp /etc/raddb/sites-enabled/default /root/backup/default.$(date +%F)

함정 2: radtest가 응답 없이 멈춘다

TOTP를 붙인 뒤 검증하려고 평소처럼 던졌다.

bash
radtest alice mypassword 127.0.0.1 0 <REDACTED>

응답이 없다. 그대로 멈춘다. 서버가 죽었다고 판단하고 프로세스를 뒤졌다. 살아 있었다.

원인은 TOTP 스크립트였다. 유효한 6자리 OTP 접미사가 없는 입력이 들어오면, 스크립트가 입력을 더 기다리며 블로킹된다. wait = yes인 상태에서 timeout을 지정하지 않았기 때문에 FreeRADIUS도 같이 무한 대기했다.

즉 이건 서버 장애가 아니라 테스트 방법이 틀린 것이었다.

올바른 검증:

bash
# 방법 1: 실제 OTP 코드를 비밀번호에 붙여서
radtest alice 'mypassword123456' 127.0.0.1 0 <REDACTED>

# 방법 2: 클라이언트로 직접 접속해서 확인 (가장 확실)

그리고 앞서 언급한 timeout = 10을 반드시 설정한다. 이게 없으면 운영 중에 스크립트가 한 번 멈출 때 인증 전체가 멈춘다.

이 함정이 위험한 이유는 "RADIUS가 죽었다"는 잘못된 결론에 도달하기 쉽기 때문이다. 나는 실제로 이 오판으로 방향을 잃었고, 나중에 진짜 문제(Part 8)를 진단할 때도 같은 오판을 반복했다.

함정 3: SELinux

Rocky Linux 기본 설정에서 rlm_exec가 외부 스크립트를 호출하려면 정책이 필요하다. 증상은 "스크립트가 실행되지 않음"이고, FreeRADIUS 로그에는 명확한 원인이 안 나온다.

bash
# 거부 로그 확인
ausearch -m avc -ts recent | grep radiusd

# 정책 생성
ausearch -c 'radiusd' --raw | audit2allow -M radiusd_totp
semodule -i radiusd_totp.pp

setenforce 0으로 넘기고 싶은 유혹이 있지만, 그러면 문제가 재부팅 후에 돌아온다.

배포 전 반드시 하는 것

bash
# 왜: 설정 문법 오류를 재시작 전에 잡는다. 재시작 후 죽으면 인증 전체가 멈춘다.
radiusd -XC

# 문제 추적 시: 포그라운드 디버그 모드
systemctl stop radiusd
radiusd -X

radiusd -XC를 습관으로 만들면 사고의 절반이 사라진다.

여기서 막힌 사람들을 위한 확인 순서

  1. radiusd -XC → Configuration appears to be OK 확인
  2. ls -la /etc/raddb/sites-enabled/ → 심볼릭 링크 외의 파일이 있는지 확인
  3. ls -la /etc/raddb/mods-enabled/ → 같은 확인
  4. radiusd -X 실행 후 실제 클라이언트로 접속 → +group authenticate 블록에서 스크립트 호출 여부 확인
  5. ausearch -m avc -ts recent | grep radiusd → SELinux 거부 없는지 확인
  6. OTP 코드를 붙인 radtest로 검증 (붙이지 않으면 멈춘다)

이 편에서 배운 것

  • 설정 디렉터리 안에 백업 파일을 만들지 않는다. 확장자를 보지 않는 로더가 세상에 많다.
  • wait = yes를 쓰는 모든 외부 호출에는 timeout을 같이 쓴다. 없으면 실패가 "멈춤"으로 나타나고, 멈춤은 실패보다 진단이 훨씬 어렵다.
  • 진단 도구가 침묵할 때, 도구 사용법이 틀린 경우를 먼저 의심한다. 서버 장애로 결론 내리는 게 훨씬 쉽고, 그래서 훨씬 자주 틀린다.

다음 편에서는

Part 3에서 FortiGate 쪽 Phase1/Phase2를 세우고, 그룹별 접근제어를 위한 IP 풀을 설계한다.
여기서 세운 풀 설계가 나중에 Part 7의 우회로가 된다.

반응형