anything-llm에서 이중 인증을 설정하여 계정 보안 강화하기

anything-llm에서 이중 인증을 설정하여 계정 보안 강화하기

지식 기반 시스템이 AI 플랫폼으로 점차 전환되면서, 단순한 문서 검색 기능 뒤에는 수백만 단어의 영업 비밀, 내부 프로세스, 고객 정보가 숨겨져 있을 수 있습니다. anything-llm과 같은 로컬 중심의 AI 플랫폼은 점점 더 민감한 환경에 배포되고 있으며, Llama3, Ollama와 같은 로컬 모델 연결뿐만 아니라 다중 사용자 협업과 사설 저장소도 지원합니다. 계정이 침해될 경우 공격자는 업로드된 모든 문서를 읽을 수 있을 뿐 아니라 권한 상승을 통해 전체 지식 센터를 조작할 수도 있습니다.

전통적인 아이디와 패스워드 방식은 피싱 이메일이 만연하고 패스워드 재사용이 흔한 현재 환경에서는 이미 무력화되었습니다. 어떤 금융 회사는 직원이 약한 비밀번호를 사용하여 사설 AI 시스템에 로그인하면서 전체 리스크 관리 문서가 유출되어 결국 규정 위반 조사를 받게 되었습니다. 바로 이러한 이유로 현대 보안 아키텍처에서 이중 인증(MFA)은 선택사항이 아닌 필수 요소가 되었습니다.

모의 침해 시나리오를 통한 2FA 가치 이해

다음과 같은 시나리오를 상상해보겠습니다: 시스템 관리자로서 Docker를 통해 anything-llm 배포를 완료하고 팀원들을 위한 계정을 생성했습니다. 그 중 한 동료는 Company123!를 비밀번호로 사용했는데, 이 조합은 공개적으로 유출된 패스워드 목록에 포함되어 있습니다.

2FA 없이 작동하는 경우:
- 공격자는 자동화 스크립트를 통해 무차별 대입 공격 수행;
- 세션 토큰 성공적으로 획득;
- 로그인 후 재무 보고서, 인사 파일 등 고감도 문서 접근;
- 시스템 로그에는 "정상 로그인"만 표시되어 추적 어려움.

2FA 활성화 후:
- 비밀번호가 유추되더라도 시스템은 여전히 동적 인증 코드 입력 요구;
- 공격자는 해당 직원의 실제 핸드폰을 소유하지 않는 한 올바른 TOTP 코드 생성 불가능;
- 로그인 프로세스 중단, 계정 보호 유지.

이것이 제2요소의 힘입니다 - 가능한 데이터 재난을 미수에 그치는 시도로 바꾸는 것입니다.

TOTP: 경량이지만 견고한 인증 기반

현재 주류 2FA 구현 방식 중 시간 기반 일회용 비밀번호(TOTP)는 표준화, 낮은 장벽, 높은 보안성을 갖추고 있어 가장 가능성이 높은 구현 방식입니다. 하드웨어 키가 필요한 FIDO2와 달리, SMS 채널(심카드 해킹 취약)에 의존하지 않고 소프트웨어만으로 전체 인증 과정을 완료할 수 있습니다.

기본 원리는 복잡하지 않습니다: 서버와 클라이언트는 비밀키를 공유하며, 각각 현재 타임스탬프를 기반으로 독립적으로 6자리 숫자를 계산합니다. 시간이 동기화된다면 두 결과는 일치하고 인증이 성공합니다.

Google Authenticator에 anything-llm 계정을 추가할 때 스캔하는 QR코드에는 다음과 같은 키 정보가 포함되어 있습니다:

otpauth://totp/Anything-LLM:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Anything-LLM&algorithm=SHA1&digits=6&period=30

여기서 secret은 고유한 공유 비밀키이며, period=30은 30초마다 인증번호가 갱신됨을 의미합니다. 알고리즘 자체는 RFC 6238 표준을 따르며 HMAC-SHA1을 사용하여 (timestamp // 30, secret)에 대한 해시 연산을 수행한 후 최종 6자리 숫자를 추출합니다.

전체 과정은 완전 오프라인으로 실행되며 네트워크 통신을 통해 키를 전송하지 않아 중간자 공격 위험을 크게 줄입니다. 더욱 중요한 것은 각 인증번호의 유효 기간이 매우 짧아서 가로채더라도 재사용이 불가능하다는 점입니다.

물론 이 설계에는 전제 조건이 있습니다: 시간 동기화 필요. 서버와 클라이언트 시간이 ±30초 이상(기본적으로 하나의 시간 창 편차 허용) 차이나면 인증이 실패합니다. 따라서 anything-llm을 배포할 때 호스트가 NTP 시간 동기화 서비스를 활성화했는지 반드시 확인해야 합니다.

실제 통합: anything-llm이 인증 앱을 "인식"하게 만들기

anything-llm이 폐쇄형 프로젝트라도 백엔드는 Node.js + Express 기반으로 구성되어 있으며, 커뮤니티에는 여러 유사 시스템의 오픈소스 구현이 존재하여 참고할 수 있습니다. 2FA 모듈의 기술적 경로를 추측할 수 있으며 이를 기반으로 구성 논리를 이해하거나 직접 기능 확장도 가능합니다.

다음은 otplibqrcode 라이브러리를 사용한 전형적인 TOTP 통합 흐름 코드 예제입니다:

const { authenticator } = require('otplib');
const qrcode = require('qrcode');

// 바인딩 QR 코드 생성
async function initializeTwoFactorAuth(userId) {
    const secretKey = authenticator.generateSecret();
    const issuerName = 'Anything-LLM';
    const accountName = await getUserEmailById(userId);
    
    const otpUri = authenticator.keyuri(accountName, issuerName, secretKey);
    const qrCodeImage = await qrcode.toDataURL(otpUri);
    
    await storeUserSecret(userId, encryptSecret(secretKey)); // 데이터베이스에 암호화 저장
    
    return { 
        qrCodeImage, 
        backupCodes: createBackupCodes() 
    };
}

// 사용자 입력 TOTP 코드 검증
function validateTotpInput(userId, inputCode) {
    const storedEncrypted = retrieveUserSecret(userId);
    const decryptedSecret = decryptSecret(storedEncrypted);

    return authenticator.verify({
        token: inputCode,
        secret: decryptedSecret,
        window: 2  // 앞뒤로 두 주기 허용 (총 ±60초)
    });
}

// 일회용 복구 코드 생성 (장치 분실 시 비상 사용)
function createBackupCodes(totalCount = 6, codeLength = 10) {
    const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
    return Array.from({ length: totalCount }, () =>
        Array.from({ length: codeLength }, () => 
            alphabet[Math.floor(Math.random() * alphabet.length)]
        ).join('')
    );
}

이 함수들은 /api/auth 인터페이스 체인에 삽입할 수 있습니다:

  1. 사용자가 이메일과 비밀번호를 제출하면 백엔드에서 인증 검증;
  2. 해당 사용자의 2FA 활성화 여부(has_2fa_active === true) 조회;
  3. 활성화 상태라면 401 상태 코드 반환 및 "2차 인증 필요" 안내;
  4. 프론트엔드는 /validate-2fa 페이지로 전환, 인증번호 입력 대기;
  5. 제출 후 validateTotpInput() 호출하여 검증 완료;
  6. 성공 시 JWT 토큰 발급, 메인 인터페이스 진입.

주의할 점은 비밀키 저장은 반드시 암호화해야 한다는 것입니다. base32 비밀키를 평문으로 데이터베이스에 기록하는 것은 노력이 무의미해집니다. 환경 변수의 마스터 키를 사용하여 AES-256-GCM과 같은 대칭 암호화 알고리즘으로 보호하는 것을 권장합니다.

사용자 경험과 보안의 균형 예술

2FA는 보안을 향상시키지만 부적절하게 설계되면 오히려 사용자에게 부담이 될 수 있습니다. 신입 직원이 처음 시스템에 로그인할 때 흐릿한 작은 QR 코드, 명확하지 않은 수동 입력 인터페이스, 복구 코드 저장 전 강제 로그아웃 등... 이러한 세부 사항은 "보안 기능이 우회되는" 곤란한 상황을 초래할 수 있습니다.

따라서 anything-llm과 같이 사용 편의성을 중시하는 플랫폼에서는 합리적인 사용자 경험 설계가 중요합니다:

점진적 활성화 전략

모든 사용자에게 즉시 2FA를 강제해서는 안 됩니다. 이상적인 방법은 다음과 같습니다:
- 기본적으로 비활성화, 그러나 "계정 설정"에서 "활성화 권장"을 눈에 띄게 표시;
- 관리자는 .env 파일에서 REQUIRE_2FA_ADMIN_USERS=true 설정으로 핵심 역할만 강제 활성화;
- 명확한 작업 지침과 위험 설명 제공.

# .env 예제 구성
ACTIVATE_2FA=true
ISSUER_NAME="Anything-LLM"
TIME_INTERVAL=30
REQUIRE_2FA_ADMIN_USERS=true
ENCRYPTION_MASTER_KEY=your_secure_aes_key_here

백업 및 복구 메커니즘

일련의 일회용 복구 코드(보통 5~10개)를 반드시 제공하고 첫 활성화 시 다운로드 알림을 강제로 표시해야 합니다. 이 코드는:
- 사용 후 즉시 "사용됨"으로 표시되어 중복 활용 방지;
- 복사하기 쉬운 형식(예: 4자리씩 구분)으로 표시;
- 다시 볼 수 없도록 설정, 스크린샷 저장으로 인한 2차 유출 방지.

장치 교체 프로세스

사용자가 휴대폰을 교체할 경우 기존 장치의 인증 앱이 무효화됩니다. 시스템은 로그인된 세션 또는 등록 이메일을 통해 "재바인딩 요청"을 허용하고 작업 전후에 알림 이메일을 보내 계정 탈취를 방지해야 합니다.

모바일 적응 최적화

QR 코드 크기는 최소 200×200 픽셀 이상, 주변 여백 충분히 확보; 동시에 "수동 입력 옵션"도 제공하여 스캔 불가 상황 대비. 시각 장애 사용자를 위해 스크린 리더기가 비밀키 텍스트를 인식할 수 있도록 지원도 필요합니다.

보안 심층: 인증 코드 이상의 보호

진정한 보안은 단일 기능 누적이 아닌 층층이 방어하는 결과입니다. 2FA는 단지 인증 강화의 첫 번째 방어선일 뿐, 다른 메커니즘과 협력하여 완전한 보호 체계를 형성해야 합니다.

로그 감사는 필수

모든 2FA 관련 작업은 로그로 기록되어야 합니다:
- 활성화/비활성화 시간, IP 주소;
- 인증 실패 횟수(연속 5회 실패 시 임시 잠금 트리거);
- 복구 코드 사용 기록;
- 재바인딩 행동.

이 로그는 사후 추적에 도움이 될 뿐 아니라 비정상 로그인 패턴 발생 시 경보를 발생시킬 수 있습니다.

JWT 세션 관리는 엄격하게

2FA 인증을 통과하더라도 JWT 토큰의 수명 주기를 제한해야 합니다. 권장:
- 일반 로그인 토큰 유효 기간 24시간;
- "기억하기" 기능 최장 7일;
- 모든 토큰은 장치 지문 또는 IP 범위와 연결, 변경 시 재인증 요구.

데이터 계층 암호화 강화

totp_secret 암호화 저장 외에도 전체 사용자 테이블의 민감 필드(이메일, 휴대폰 번호 등)도 필드 수준 암호화를 고려해야 합니다. PostgreSQL의 pgcrypto 또는 SQLite의 암호화 확장을 결합하여 정적 데이터 보호 능력을 더욱 향상시킬 수 있습니다.

한 걸음 더 나아가: 제로 트러스트 아키텍처로

TOTP는 더 높은 보안 등급을 향한 시작점일 뿐 종착점은 아닙니다. 고감도 기업 배포의 경우 더 고급 인증 방식 도입도 점진적으로 고려할 수 있습니다:

WebAuthn + FIDO2 보안 키

YubiKey, Touch ID, Windows Hello 등의 생체 인식/하드웨어 키 로그인 지원을 통해 진정한 "패스워드 없는" 경험 실현. 이 방식은 공개 키 암호화 기반으로 자격 증명 유출 위험을 근본적으로 제거합니다.

자동화 테스트로 안정성 보장

모든 보안 기능은 신뢰성 희생 없이 제공되어야 합니다. 다음 시나리오를 커버하는 단위 테스트 작성 권장:
- 시간 창 경계 값(t=0, t=30, t=60);
- 키 복호화 실패 처리;
- 복구 코드 중복 사용 차단;
- 예외 입력(빈 값, 초과 문자열) 방어.

test('should deny reused backup code', async () => {
    const firstResult = await consumeBackupCode('DEF456UVW');
    expect(firstResult).toBe(true); // 첫 사용 가능

    const secondResult = await consumeBackupCode('DEF456UVW');
    expect(secondResult).toBe(false); // 두 번째 거부
});

태그: 2fa authentication Security anything-llm totp

9월 24일 05:04에 게시됨