SP Application에서 Saml2 Response 검증하기


전사에서 공용으로 사용하는 SSO 로그인 서비스에서 Okta를 통해 인증을 진행하고 결과를 SP와 연동하여, SAML 응답을 가지고 ACS로 보내 해당 앱에서 로그인/회원가입 처리를 하기 때문에 해당하는 이 응답에 대한 검증이 필수적으로 필요했다.

기존에 있던 소스에는 Base64로 해싱된 Saml2Response로부터 유저 식별값(NameID)만 가지고 처리를 하고 있었는데, 이 때문에 보안적으로 취약한 상태였다. 유저 식별값을 조작하여 Base64 처리 후 로그인에 사용하면 다른 계정으로 로그인이 가능한.. 그런 구조로 되어있었다. 물론 대부분 내부서비스에서만 사용하므로 이런걸 직접 하는 사람은 없지만..

여기저기 검색해보니 응답값 내의 Signature public key만으로도 응답 자체의 위변조를 검증할 수 있었기에 방법을 개발하여 적용하였다. 별다른 설명 없이 소스만 메모한다.


SP : SAML 서비스 공급자(SP) 는 SAML(Security Assertion Markup Language )의 SSO(Single Sign-On) 프로필 과 함께 인증 어설션을 수신하고 승인하는 시스템 엔터티입니다 . ID 공급자를 신뢰하고 지정된 사용자가 요청된 리소스에 액세스할 수 있는 권한을 부여합니다.

ACS : 어설션 소비자 서비스(또는 ACS)는 어설션을 기반으로 세션을 설정할 목적으로 메시지(또는 SAML 아티팩트)를 수락하는 ServiceProvider의 위치에 대한 SAML 용어입니다 . SAML 프로토콜 메시지를 처리하고 메시지에서 추출된 정보를 나타내는 쿠키를 반환하는 웹 사이트의 HTTP 리소스(종종 가상 리소스)를 나타냅니다.

1. OktaSamlUtil.java

// OktaSamlUtil.java
import org.opensaml.Configuration;
import org.opensaml.DefaultBootstrap;
import org.opensaml.saml2.core.Response;
import org.opensaml.xml.ConfigurationException;
import org.opensaml.xml.io.Unmarshaller;
import org.opensaml.xml.io.UnmarshallerFactory;
import org.opensaml.xml.io.UnmarshallingException;
import org.opensaml.xml.util.XMLHelper;
import org.w3c.dom.Element;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.ByteArrayInputStream;
import java.util.Base64;

public class OktaSamlUtil {
    private static Element parseNodeElement(byte[] base64EncodedSamlResponse) throws Exception {
        DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
        documentBuilderFactory.setNamespaceAware(true);
        DocumentBuilder parserPool = documentBuilderFactory.newDocumentBuilder();

        return parserPool
                .parse(new ByteArrayInputStream(base64EncodedSamlResponse))
                .getDocumentElement();
    }

    private static Element parseNodeElement(String base64EncodedSamlResponse) throws Exception {
        return parseNodeElement(Base64.getDecoder().decode(base64EncodedSamlResponse));
    }

    public static String prettyPrint(Element node) {
        return XMLHelper.prettyPrintXML(node);
    }

    public static Response parseResponse(String base64EncodedSamlResponse) throws Exception {
        Element node = parseNodeElement(base64EncodedSamlResponse);
        DefaultBootstrap.bootstrap();
        UnmarshallerFactory factory = Configuration.getUnmarshallerFactory();
        Unmarshaller unmarshaller = factory.getUnmarshaller(node);

        return (Response) unmarshaller.unmarshall(node);
    }

    public static Response parseResponse(Element node) throws UnmarshallingException, ConfigurationException {
        DefaultBootstrap.bootstrap();
        UnmarshallerFactory factory = Configuration.getUnmarshallerFactory();
        Unmarshaller unmarshaller = factory.getUnmarshaller(node);

        return (Response) unmarshaller.unmarshall(node);
    }
}

2. OktaSamlValidator.java

import lombok.extern.slf4j.Slf4j;
import org.opensaml.saml2.core.Response;
import org.opensaml.xml.security.credential.BasicCredential;
import org.opensaml.xml.signature.Signature;
import org.opensaml.xml.signature.SignatureValidator;
import org.opensaml.xml.signature.X509Certificate;
import org.opensaml.xml.signature.X509Data;
import org.opensaml.xml.validation.ValidationException;
import org.springframework.security.authentication.BadCredentialsException;

import java.io.ByteArrayInputStream;
import java.security.PublicKey;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;

@Slf4j
public class OktaSamlValidator {

    private OktaSamlValidator() {

    }

    public static OktaSamlValidator getInstance() {
        return LazyHolder.ins;
    }

    private static class LazyHolder {
        private static final OktaSamlValidator ins = new OktaSamlValidator();
    }

    public void validSignature(Response samlResponse) {
        try {
            Signature signature = samlResponse.getSignature();
            PublicKey publicKey = extractPublicKey(signature);
            SignatureValidator validator = createValidator(publicKey);
            validator.validate(samlResponse.getSignature());
            log.debug("Signature validation success");
        } catch (CertificateException e) {
            log.error("Invalid certification(public key)", e);
            throw new BadCredentialsException("Invalid certification(public key)", e);
        } catch (ValidationException e) {
            log.error("Signature validation fail.", e);
            throw new BadCredentialsException("Signature validation fail", e);
        }
    }

    private PublicKey extractPublicKey(Signature signature) throws CertificateException {
        X509Data x509Data = signature.getKeyInfo().getX509Datas().get(0);
        X509Certificate cert = x509Data.getX509Certificates().get(0);
        String wrappedCert = wrapBase64String(cert.getValue());
        CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
        Certificate certificate = certFactory.generateCertificate(new ByteArrayInputStream(wrappedCert.getBytes()));
        return certificate.getPublicKey();
    }

    private String wrapBase64String(String base64String) {
        int lineLength = 64;
        char[] rawArr = base64String.toCharArray();
        int wrappedArrLength = rawArr.length + (int) Math.ceil(rawArr.length / 64d) - 1;
        char[] wrappedArr = new char[wrappedArrLength];

        int destPosition = 0;
        for (int i = 0; i < rawArr.length; i += lineLength) {
            if (rawArr.length - i > lineLength) {
                System.arraycopy(rawArr, i, wrappedArr, destPosition, lineLength);
                destPosition += lineLength;
                wrappedArr[destPosition] = '\n';
                destPosition += 1;
            } else {
                System.arraycopy(rawArr, i, wrappedArr, destPosition, rawArr.length - i);
            }
        }
        return "-----BEGIN CERTIFICATE-----\n" + String.valueOf(wrappedArr) + "\n-----END CERTIFICATE-----";
    }

    private SignatureValidator createValidator(PublicKey publicKey) {
        BasicCredential credential = new BasicCredential();
        credential.setPublicKey(publicKey);
        return new SignatureValidator(credential);
    }
}

3. 사용방법

String base64StringResponse = [ ENCODED_SAML_RESPONSE_BY_BASE64 ];

Response response = OktaSamlUtil.parseResponse(base64StringResponse);
OktaSamlValidator.getInstance().validSignature(response);

OktaSamlValidator의 validSignature를 이용하여 파싱된 Response를 간단히 검증할 수 있다. base64 -> xml로 변환 후 내용물을 수정하더라도 정상적으로 변조여부 체크됨이 확인된다.

*참고로 Response의 User Attribute들은 org.opensaml.saml2.core.Response안에 포함되어 있다. base64Response를 xml로 변환해보면 구조를 알 수 있으니 참조.(OktaSamlUtil.prettyPrint) 해봐도 나온다.


Requirements

해당 구현을 위해서는 opensaml 종속성을 추가해야 한다.

  • maven
<dependency>
    <groupId>org.opensaml</groupId>
    <artifactId>opensaml</artifactId>
    <version>2.6.0</version>
</dependency>