contexa-identity

적응형 MFA

MFA(Multi-Factor Authentication) 설정 가이드입니다. 1차 인증(Form/REST) 후 2차 요소(OTT, Passkey)를 결합하여 보안을 강화합니다. AI 기반 적응형 정책, 커스텀 페이지, Step-up 인증을 지원합니다.

개요

Contexa MFA는 1차 인증 + 2차 요소로 구성됩니다. 정책 엔진이 MFA 필요 여부를 결정하고, 상태 머신이 인증 플로우를 관리합니다.

1차 인증 Form / REST 로그인
MfaPolicyProvider
MFA 불필요
인증 완료
MFA 필요
요소 선택 OTT / Passkey
상태 머신 챌린지 → 검증
MFA 성공

기본 MFA order는 200입니다. .mfa()에서 직접 .form()이나 .rest()를 호출하면 UnsupportedOperationException이 발생합니다. 반드시 .primaryAuthentication()을 사용해야 합니다.

빠른 시작

Form 1차 인증 + Passkey 2차 인증의 가장 간단한 MFA 설정입니다.

registry
    .mfa(mfa -> mfa
        .primaryAuthentication(primary -> primary
            .formLogin(form -> form.loginPage("/login")))
        .passkey(Customizer.withDefaults())
    )
    .session(Customizer.withDefaults())
    .build();

1차 인증

primaryAuthentication()을 통해 1차 인증 방법을 설정합니다. formLogin()restLogin() 중 하나만 선택 가능합니다 (상호 배타적).

Form 1차 인증

.mfa(mfa -> mfa
    .primaryAuthentication(primary -> primary
        .formLogin(form -> form
            .loginPage("/login")
            .usernameParameter("email")
            .failureUrl("/login?error")))
    .ott(Customizer.withDefaults())
)

REST 1차 인증

.mfa(mfa -> mfa
    .primaryAuthentication(primary -> primary
        .restLogin(rest -> rest
            .loginProcessingUrl("/api/auth/login")))
    .passkey(Customizer.withDefaults())
)
1차 인증 Order
1차 인증은 자동으로 order 0이 할당됩니다. 2차 요소는 currentStepOrderCounter를 통해 자동으로 순차적 order 값(1, 2, 3...)이 할당됩니다.

MFA 요소 (2차 인증)

2차 인증 요소를 추가합니다. 최소 하나의 2차 요소가 필요하며, 여러 요소를 결합할 수 있습니다.

사용 가능한 2차 요소

메서드요소설명
.ott()One-Time Token이메일/SMS 일회용 코드
.passkey()WebAuthn / Passkey생체 인증 / 하드웨어 키

다중 요소 결합

.mfa(mfa -> mfa
    .primaryAuthentication(primary -> primary
        .formLogin(form -> form.loginPage("/login")))
    .ott(ott -> ott
        .tokenService(emailTokenService))
    .passkey(passkey -> passkey
        .rpName("My App")
        .rpId("example.com"))
)

각 요소는 currentStepOrderCounter를 통해 순차적 order(1, 2, 3...)가 자동 할당됩니다. 사용자는 요소 선택 페이지에서 원하는 요소를 선택합니다.

requiredFactors() - 필수 인증 요소 수

MFA 성공을 위해 완료해야 하는 2차 인증 요소의 수를 제어합니다. 기본값은 등록된 모든 요소를 완료해야 합니다.

// 2개 요소를 등록하되, 1개만 완료하면 MFA 성공
.mfa(mfa -> mfa
    .primaryAuthentication(primary -> primary
        .formLogin(Customizer.withDefaults()))
    .passkey(Customizer.withDefaults())
    .ott(Customizer.withDefaults())
    .requiredFactors(1)
)
설정동작
.requiredFactors(1)1개 요소만 성공하면 됩니다. 첫 번째 요소가 자동 시작되며, 실패 시 "Try another method" 링크를 통해 다른 요소로 전환할 수 있습니다.
.requiredFactors(2)2개 요소가 성공해야 합니다. 하나를 완료하면 다음 미완료 요소가 자동으로 시작됩니다.
미설정 (기본값)등록된 모든 요소를 완료해야 합니다.
요소 선택 동작
여러 요소 타입이 등록되면 각 MFA 인증 페이지에 "Try another method" 링크가 표시됩니다. 이미 완료된 요소 타입은 선택 페이지에서 제외됩니다. 동일한 요소 타입을 두 번 사용할 수 없습니다.

사용 사례: 유연한 대체 수단

여러 요소를 등록하되 하나만 요구하여 대체 수단을 제공합니다. Passkey를 사용할 수 없는 사용자(예: 생체 인증 하드웨어 없음)는 OTT 이메일 인증으로 대체할 수 있습니다.

// 관리자 포털: Passkey 우선, OTT 대체 수단
.mfa(mfa -> mfa
    .urlPrefix("/admin")
    .primaryAuthentication(primary -> primary
        .formLogin(form -> form.defaultSuccessUrl("/admin")))
    .passkey(Customizer.withDefaults())   // 첫 번째로 자동 시작
    .ott(Customizer.withDefaults())       // 대체 수단으로 사용 가능
    .requiredFactors(1)                   // 1개만 필요
)

MFA 페이지

mfaPage()를 통해 MFA 플로우의 각 단계에 대한 페이지 URL을 커스터마이즈합니다.

메서드설명
selectFactorPage(String)요소 선택 페이지 URL
ottPages(String requestUrl, String verifyUrl)OTT 요청/검증 페이지 URL (동시 설정)
ottRequestPage(String)OTT 코드 요청 페이지 URL
ottVerifyPage(String)OTT 코드 검증 페이지 URL
passkeyChallengePages(String)Passkey 챌린지 페이지 URL
configurePageUrl(String)MFA 설정 페이지 URL
failurePageUrl(String)MFA 실패 페이지 URL
.mfa(mfa -> mfa
    .primaryAuthentication(primary -> primary
        .formLogin(form -> form.loginPage("/login")))
    .ott(Customizer.withDefaults())
    .mfaPage(page -> page
        .selectFactorPage("/mfa/select")
        .ottPages("/mfa/ott/request", "/mfa/ott/verify")
        .failurePageUrl("/mfa/failed"))
)

커스텀 MFA 페이지 만들기

MFA 페이지의 CSS와 레이아웃은 자유롭게 수정할 수 있습니다. 다만 다음 요소는 반드시 유지해야 합니다.

수정 불가

  • CSRF meta 태그: <meta name="_csrf" th:content="${csrfToken}">
  • id="contexa-page-config" div와 data-* 속성
  • Input name 속성: username, password, token, _csrf, mfaSessionId
  • Form id, method, th:action
  • button id: loginButton, sendCodeButton, verifyButton, resendButton, authButton
  • th:utext="${hiddenInputsHtml}" -- CSRF + mfaSessionId hidden input을 자동 생성

수정 가능

  • CSS 색상, 레이아웃, 배경
  • 로고, 아이콘
  • 텍스트 콘텐츠
  • 폼 필드 스타일

필수 JavaScript 파일

파일대상 페이지
/js/contexa-mfa-sdk.js모든 MFA 페이지 (필수)
/js/contexa-login-page.js로그인 페이지
/js/contexa-ott-request-page.jsOTT 코드 요청 페이지
/js/contexa-ott-verify-page.jsOTT 코드 검증 페이지
/js/contexa-passkey-page.jsPasskey 챌린지 페이지

MFA 데이터 플로우

POST /login 1차 인증
/mfa/select-factor
OTT
/mfa/ott/request-code-ui
POST /mfa/ott/generate-code
/mfa/challenge/ott
POST /login/mfa-ott
성공 실패
Passkey
/mfa/challenge/passkey
navigator.credentials.get() WebAuthn API
POST /login/mfa-webauthn
성공 실패

MFA 정책 커스터마이제이션

MfaPolicyProvider를 구현하여 MFA 요구 결정을 커스터마이즈합니다. @ConditionalOnMissingBean으로 등록되므로, 동일한 타입의 빈을 정의하면 자동으로 오버라이드됩니다.

내장 구현체

구현체설명
DefaultMfaPolicyProvider정적 규칙 기반. 사용자 역할과 MFA 설정에 따라 MFA 필요 여부를 결정
AIAdaptiveMfaPolicyProviderAI 리스크 분석 기반 동적 정책. AI Core 사용 불가 시 DefaultMfaPolicyProvider로 폴백

커스텀 정책 구현 예제

@Bean
public MfaPolicyProvider mfaPolicyProvider() {
    return new MfaPolicyProvider() {
        @Override
        public MfaDecision evaluateInitialMfaRequirement(FactorContext ctx) {
            String ip = ctx.getRequest().getRemoteAddr();
            if (trustedIpRanges.contains(ip)) {
                return MfaDecision.noMfaRequired();
            }
            return MfaDecision.challenged("Untrusted IP");
        }
        // ... other methods
    };
}

MfaDecision 유형

DecisionType설명
NO_MFA_REQUIREDMFA 불필요. 인증이 즉시 완료됨
CHALLENGEDMFA 필요. 2차 요소 인증 요구
BLOCKED인증 차단. 접근 거부
ESCALATED관리자 검토로 에스컬레이션. 관리자만 해결 가능

DSL을 통한 정책 주입

.mfa(mfa -> mfa
    .policyProvider(customMfaPolicyProvider)
    .primaryAuthentication(primary -> primary
        .formLogin(form -> form.loginPage("/login")))
    .passkey(Customizer.withDefaults())
)

Step-up 인증

이미 인증된 사용자가 민감한 리소스에 접근할 때 추가 인증을 요구합니다.

인증된 사용자 민감 리소스 접근
Server: 401 MFA_CHALLENGE_REQUIRED
SDK Global Interceptor 401/403/423 응답 자동 감지
MFA 요소 챌린지 요소 선택 페이지로 리다이렉트
자동 재시도 원래 요청 자동 재시도

ContexaMFA SDK Global Interceptor는 모든 HTTP 요청에서 401/403/423 응답을 자동 감지하고 MFA 챌린지로 리다이렉트합니다. 인증이 완료되면 원래 요청이 자동으로 재시도됩니다.

MFA 상태 머신 이벤트

@EventListener를 사용하여 MFA 상태 변경 이벤트를 수신하고 감사 로그, 알림 등을 구현할 수 있습니다.

이벤트 유형

이벤트설명
StateChangeEventMFA 상태 전이 (예: FACTOR_SELECTION -> OTT_CHALLENGE)
ErrorEvent오류 발생 (SECURITY, TIMEOUT, LIMIT_EXCEEDED, SYSTEM)
PerformanceAlertEvent성능 경고 (느린 응답 등)
CustomEvent사용자 정의 이벤트

종료 상태

상태설명
MFA_SUCCESSFULMFA 성공. 완전한 인증 완료
MFA_NOT_REQUIREDMFA 불필요. 정책에 의해 건너뜀
MFA_FAILED_TERMINALMFA 영구 실패. 재시도 불가
MFA_CANCELLED사용자가 MFA 취소
MFA_TIMEOUT세션 타임아웃
MFA_SESSION_EXPIREDMFA 세션 만료
MFA_ACCOUNT_LOCKED계정 잠금
MFA_SYSTEM_ERROR시스템 오류

감사 로그 구현 예제

@EventListener
public void onMfaStateChange(MfaStateMachineEvents.StateChangeEvent event) {
    auditService.log(
        event.getUserId(),
        event.getFromState(),
        event.getToState()
    );
}

@EventListener
public void onMfaError(MfaStateMachineEvents.ErrorEvent event) {
    alertService.notify(
        event.getErrorType(),
        event.getMessage()
    );
}

Multi MFA

여러 개의 독립적인 MFA Flow를 동시에 운영할 수 있습니다. 각 Flow는 자체 SecurityFilterChain, 자체 2차 인증 요소, 자체 URL 네임스페이스를 가집니다.

수신 요청
URL 패턴 매칭
/admin/**
Chain: SecurityFilterChain (order=60)
typeName: mfa
Factors: Passkey
URLs: /admin/mfa/login, /admin/mfa/select-factor
/user/**
Chain: SecurityFilterChain (order=70)
typeName: mfa_2
Factors: OTT
URLs: /user/mfa/login, /user/mfa/select-factor
핵심 원리
각 MFA Flow는 독립적인 SecurityFilterChain으로 구성됩니다. urlPrefix()를 설정하면 모든 MFA URL에 접두사가 자동 적용되고, securityMatcher도 자동으로 설정되어 Flow 간 완전한 격리가 보장됩니다.

requiredFactors() — 다중 요소 수 설정

기본적으로 MFA는 설정된 모든 2차 요소를 완료해야 합니다. 예를 들어 .ott().passkey()를 모두 구성하면, 사용자는 두 가지 모두 완료해야 MFA가 성공합니다. requiredFactors(int count)를 사용하면 설정된 요소 수보다 적은 수만 요구하도록 재정의할 수 있습니다.

기본 동작: AbstractMfaPolicyEvaluator.getBaseFactorCountFromConfig()가 플로우 설정에서 비 Primary 단계 수를 계산합니다. requiredFactors()를 호출하지 않으면 기본값은 등록된 MFA 요소의 총 수입니다 (예: .ott() + .passkey() = 2).

Java
// 기본: OTT와 Passkey 모두 필요 (2개 요소 등록 = 2개 필요)
registry
    .mfa(mfa -> mfa
        .primaryAuthentication(auth -> auth.formLogin(Customizer.withDefaults()))
        .ott(Customizer.withDefaults())
        .passkey(Customizer.withDefaults())
    ).session(Customizer.withDefaults())

// 재정의: 2개 중 1개만 완료하면 됨
registry
    .mfa(mfa -> mfa
        .requiredFactors(1)
        .primaryAuthentication(auth -> auth.formLogin(Customizer.withDefaults()))
        .ott(Customizer.withDefaults())
        .passkey(Customizer.withDefaults())
    ).session(Customizer.withDefaults())
동작예시
미설정 (기본값)설정된 모든 요소를 완료해야 함. OTT + Passkey 등록 시 둘 다 완료 필요.OTT + Passkey 구성 → 2개 필요
1아무 하나의 요소(OTT 또는 Passkey)를 완료하면 MFA 성공.표준 2FA: 비밀번호 + 추가 요소 1개
N정확히 N개의 서로 다른 요소를 순서대로 완료해야 함.커스텀 정책: M개 중 N개 요소 요구

주의: requiredFactors(N)은 설정된 요소 수를 초과할 수 없습니다. 상태 머신의 AllFactorsCompletedGuardcompletedCount >= requiredCount를 확인하므로, required가 available을 초과하면 MFA가 절대 성공할 수 없습니다.

기본 설정

여러 .mfa()를 등록하면 자동으로 고유한 typeName이 부여됩니다.

registry
    .mfa(mfa -> mfa
        .primaryAuthentication(auth -> auth.formLogin(Customizer.withDefaults()))
        .passkey(Customizer.withDefaults())
        .order(60)
    ).session(Customizer.withDefaults())   // typeName = "mfa" (자동)

    .mfa(mfa -> mfa
        .primaryAuthentication(auth -> auth.formLogin(Customizer.withDefaults()))
        .ott(Customizer.withDefaults())
        .order(70)
    ).session(Customizer.withDefaults())   // typeName = "mfa_2" (자동)

    .build();
등록 순서자동 typeName설명
첫 번째 .mfa()mfa기존 단일 MFA와 동일 (하위 호환)
두 번째 .mfa()mfa_2자동 번호 부여
세 번째 .mfa()mfa_3자동 번호 부여

name() - 명시적 Flow 이름 지정

name()으로 의미 있는 Flow 이름을 지정할 수 있습니다. 자동 번호 대신 사용됩니다.

.mfa(mfa -> mfa
    .name("admin")     // typeName = "mfa_admin"
    .primaryAuthentication(auth -> auth.formLogin(Customizer.withDefaults()))
    .passkey(Customizer.withDefaults())
    .order(60)
).session(Customizer.withDefaults())

.mfa(mfa -> mfa
    .name("user")      // typeName = "mfa_user"
    .primaryAuthentication(auth -> auth.formLogin(Customizer.withDefaults()))
    .ott(Customizer.withDefaults())
    .order(70)
).session(Customizer.withDefaults())

urlPrefix() - URL 격리

urlPrefix()는 해당 Flow의 모든 MFA URL에 접두사를 자동 적용하고, securityMatcher도 자동으로 설정합니다. Multi MFA에서 각 Flow가 서로 다른 URL 네임스페이스를 갖도록 보장합니다.

registry
    // Admin: Passkey MFA
    .mfa(mfa -> mfa
        .urlPrefix("/admin")
        .primaryAuthentication(auth -> auth.formLogin(Customizer.withDefaults()))
        .passkey(Customizer.withDefaults())
        .order(60)
    ).session(Customizer.withDefaults())

    // User: OTT MFA
    .mfa(mfa -> mfa
        .urlPrefix("/user")
        .primaryAuthentication(auth -> auth.formLogin(Customizer.withDefaults()))
        .ott(Customizer.withDefaults())
        .order(70)
    ).session(Customizer.withDefaults())

    .build();

urlPrefix("/admin") 설정 시 URL 변환:

기본 URLurlPrefix 적용 후
/mfa/login/admin/mfa/login
/mfa/select-factor/admin/mfa/select-factor
/login/mfa-ott/admin/login/mfa-ott
/login/mfa-webauthn/admin/login/mfa-webauthn
/mfa/cancel/admin/mfa/cancel
/api/mfa/config/admin/api/mfa/config
securityMatcher 자동 설정
urlPrefix("/admin")을 설정하면 http.securityMatcher("/admin/**")가 자동으로 적용됩니다. 별도로 rawHttp()를 사용할 필요가 없습니다.

defaultLoginUrl() - 개별 URL 변경

urlPrefix() 대신 개별 URL만 변경하고 싶을 때 사용합니다. 자동 생성 로그인 폼의 URL을 변경하되, Spring Security 표준의 커스텀 페이지 동작은 유지합니다.

API동작자동 폼 생성
.loginPage("/custom")커스텀 페이지로 리다이렉트 (Spring Security 표준)X (사용자가 직접 만든 페이지)
.defaultLoginUrl("/admin/mfa/login")자동 생성 폼의 URL 변경O (Contexa 자동 폼 생성)
.mfa(mfa -> mfa
    .primaryAuthentication(auth -> auth.formLogin(form -> form
        .defaultLoginUrl("/admin/mfa/login")
        .defaultSuccessUrl("/admin/dashboard")))
    .passkey(Customizer.withDefaults())
    .order(60)
).session(Customizer.withDefaults())

Multi MFA DSL API 요약

메서드위치설명
.name(String).mfa()Flow 이름 지정. typeName = mfa_{name}
.urlPrefix(String).mfa()모든 MFA URL에 접두사 적용 + securityMatcher 자동 설정
.defaultLoginUrl(String).formLogin()자동 생성 폼의 URL 변경 (loginProcessingUrl 자동 동기화)
.order(int).mfa()SecurityFilterChain 우선순위 (낮을수록 우선)

주의사항

securityMatcher 필수
Multi MFA에서 각 Flow가 서로 다른 2차 인증 요소를 사용하는 경우 (예: Flow 1은 Passkey, Flow 2는 OTT), urlPrefix() 또는 rawHttp(http -> http.securityMatcher(...))반드시 URL 범위를 분리해야 합니다. 분리하지 않으면 한 Flow의 2차 인증 요청이 다른 Flow의 SecurityFilterChain에서 처리되어 실패합니다.

MFA + 단일 인증 동시 설정

MFA와 단일 인증을 함께 등록하여 서로 다른 요청 경로에 서로 다른 인증 수준을 적용합니다.

registry
    // 관리자: Form + OAuth2 (단일 인증)
    .form(f -> f.order(20).loginPage("/admin/login")
        .rawHttp(http -> http.securityMatcher("/admin/**")))
    .oauth2(Customizer.withDefaults())

    // 사용자: MFA (Form 1차 + Passkey) + Session
    .mfa(m -> m.order(100)
        .primaryAuthentication(auth -> auth
            .formLogin(form -> form.loginPage("/login")))
        .passkey(Customizer.withDefaults()))
    .session(Customizer.withDefaults())

    .build();

order()를 사용하여 Filter Chain 우선순위를 제어하고, rawHttp()securityMatcher()로 어떤 체인이 어떤 요청을 처리할지 결정합니다. 각 인증 방법에 대해 서로 다른 상태 관리(session/oauth2)를 설정할 수 있습니다.

MFA 핸들러

MFA 성공 및 실패에 대한 커스텀 핸들러를 지정할 수 있습니다.

.mfa(mfa -> mfa
    .mfaSuccessHandler(customSuccessHandler)
    .mfaFailureHandler(customFailureHandler)
    .primaryAuthentication(primary -> primary
        .formLogin(form -> form.loginPage("/login")))
    .passkey(Customizer.withDefaults())
)

핸들러 인터페이스 및 구현 가이드는 인증 문서의 핸들러 자동 선택 섹션을 참조하세요.

MFA REST API

메서드URL설명
GET/api/mfa/configMFA 설정 조회
POST/mfa/select-factorMFA 요소 선택
POST/mfa/ott/generate-codeMFA OTT 코드 생성
POST/login/mfa-ottMFA OTT 로그인 처리
POST/login/mfa-webauthnMFA Passkey 인증
POST/mfa/cancelMFA 취소

응답 형식

시나리오응답
인증 성공{ "success": true, "redirectUrl": "..." }
MFA 필요{ "mfaRequired": true, "selectFactorUrl": "...", "mfaSessionId": "..." }
인증 실패{ "success": false, "error": "...", "failureType": "..." }
Step-up 필요401 MFA_CHALLENGE_REQUIRED

ContexaMFA JavaScript SDK

브라우저에서 MFA 플로우를 제어하는 JavaScript SDK (v2.1.0)입니다.

빠른 시작

<script src="/ko/js/contexa-mfa-sdk.js?v=1776136758910"></script>
<script>
const mfa = new ContexaMFA.Client({
    autoInit: true,
    tokenPersistence: 'localStorage'
});
mfa.loginForm('username', 'password')
    .then(result => { /* handle result */ });
</script>

Client API

메서드반환 타입설명
init()PromiseSDK 초기화, 서버 설정 로드
login(username, password)Promise<LoginResult>JSON 로그인 요청
loginForm(username, password)Promise<LoginResult>Form POST 로그인
selectFactor(factorType)Promise<FactorResult>MFA 요소 선택
verifyOtt(token)Promise<VerifyResult>OTT 토큰 검증
verifyPasskey()Promise<VerifyResult>WebAuthn Passkey 인증
requestOttCode()Promise<RequestResult>설정된 requestOttCode 엔드포인트를 사용해 OTT 코드 재발송 요청
logout()Promise로그아웃

생성자 옵션

옵션타입설명
autoInitboolean자동 초기화 여부
autoRedirectboolean인증 후 자동 리다이렉트
tokenPersistence'memory' | 'localStorage' | 'sessionStorage'토큰 저장 전략 (memory, localStorage, sessionStorage)

Global Interceptor

모든 fetch/XMLHttpRequest 호출에서 401/403/423 응답을 자동 감지하고 Step-up 인증 페이지로 리다이렉트합니다. 인증이 완료되면 원래 요청이 자동으로 재시도됩니다.

MFA 설정 프로퍼티
spring.auth.mfa.* 네임스페이스는 타임아웃(sessionTimeoutMs, challengeTimeoutMs), 재시도 설정(maxRetryAttempts, accountLockoutDurationMs), OTP(otpTokenValiditySeconds, otpTokenLength), 세션 저장소(sessionStorageType), 저장소 선택, 페이지 URL 등 MFA 관련 설정을 제공합니다. 전체 목록은 상태 관리 문서를 참조하세요.
상태 엔드포인트 참고
spring.auth.urls.mfa.status 는 런타임 설정 모델에 존재하는 URL 슬롯이지만, 현재 OSS 런타임에는 별도의 /api/mfa/status 컨트롤러 엔드포인트가 구현되어 있지 않습니다.