Saml2 Response 검증하기
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>