【SpringBoot】 基于 OpenSAML 3.0 搭建 SAML 认证服务

5,058 阅读6分钟

认证场景

一个简单的场景,用户小明访问了 通过了用户域 A认证的,访问了 客户端 应用 A,此时客户端应用 A 中挂了一个 属于用户域 B 认证的 web 应用 B. 这种情况下,用户小明打开 应用 B 后,必然要重新在用户域 B 下重新登录认证,才能访问应用 B.

image-20210312084258273.png

为解决用户小明两次登录的困扰,这种情况下就要实现用户域 A 和用户域 B 的认证互信。于是用户域 A 和用户域 B 的决定双方基于 openSAML 3.0 协议实现联邦认证。

OpenSAML 3.0 认证过程

image-20210310211852653.png

用户发起请求

用户向 Service Provider(SP) 请求资源,SP 从而可以判断,用户的这次请求是否需要进行认证。可以建立起用户的访问会话。

SP 将用户请求重定向到 Identify Provider(Idp)

如果 SP 判断 用户发起的请求需要被认证,SP 会创建一个 AuthnRequest 对象,用来指定用户应如何身份验证要求的对象。AuthRequest 对象被编码,作为 HTTP URL 参数, 通过浏览器的重定向发送到 Idp.

用户被授权

Idp 解码 AuthnRequest 并且基于此对用户进行授权。

已被授权的用户被送往 SP

如果认证成功,Idp 会将授权信息关联到 SAML Artifact 具有唯一标识的对象中。Artifact 仍然是被编码,作为 URL 参数,并且通过 HTTP Redirect 被发送回 SP.

SP 请求授权信息

SP 创建一个 含有 Artifact 对象 ArtifactResolve 的对象。ArtifactResolve 通过使用 SOAP web service 被送到 Idp.

Idp 返回授权信息

Idp 接收到 ArtifactResolve 对象 并且解压出 Artifact. Idp 创建 内含授权信息的 ArtifactResponse 对象来响应 SOAP 请求,授权信息存在

SAML Assertion 中。

基于 OpenSAML 认证的方案

用户域 A 和 用户域 B 基于 SAML 3.0 协议实现联邦认证

当用户域 A 和 用户域 B 实现联邦认证后,当小明再次从应用 A 访问,应用 B 时,用户域 A 和 用户域 B 进行联邦认证,认证成功后,小明就可以实现免密登录 应用 B.

image-20210312091329967.png

用户域 A 和 用户域 B 联邦认证具体过程

image-20210312144449544.png

  1. 应用 A 携带用户域 A 颁发的令牌及 访问应用 B 的地址,发起对 用户域 A SP 服务进行访问。
  2. 用户域 A SP 服务,接收到应用 A 的请求后,到用户域 A 的认证服务进行令牌校验。
  3. 用户域 A 认证服务认证完成后,携带 SP 的签名及用户域 A 用户信息,访问 用户域 B Idp 服务。
  4. Idp 服务验证 SP 签名通过后,获取到 用户域 A 的 SP 服务携带的用户信息,到用户域 B 的认证服务进行认证。
  5. 认证完成之后,将用户域 B 的认证信息,携带用户域 B 的签名返回到 SP 服务。
  6. SP 验签成功后,根据 步骤一携带的 目标地址进行跳转,访问应用 B

基于 OpenSAML SpringBoot 实现

实现逻辑基于 open saml 认证过程 image-20210310211852653.png

Step 1: 用户请求的拦截

定义一个 AccessFilter, 拦截用户访并且根据缓存来判断是否需要开启 SAML 认证

@WebFilter(urlPatterns = "/api/*")
public class AccessFilter implements Filter

关键逻辑为:

doFilter

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    HttpServletRequest httpServletRequest = (HttpServletRequest) request;
    HttpServletResponse httpServletResponse = (HttpServletResponse) response;
    String userAccount = request.getParameter(Constants.USER_ACCOUNT_KEY);

    //校验用户token 是否存在缓存
    if (spConsumerCacheService.checkUserIdmTokenCache(userAccount)) {
        chain.doFilter(request, response);
    } else {
        // 建立用户 session
        setGotoURLOnSession(httpServletRequest);
        // 重定向用户请求进行 SAML 认证
        // 构建 SAML 认证请求 AuthnRequest
    		AuthnRequest authnRequest = buildAuthnRequest(request);
        // 重定向用户请求到 SAML IDP Server 
    		redirectUserWithRequest(httpServletResponse, authnRequest);    
    }
}

Step 2: 身份验证请求

构建 AuthnRequest

private AuthnRequest buildAuthnRequest(HttpServletRequest request) {
    //构建 AuthnRequest
    AuthnRequest authnRequest = OpenSAMLUtils.buildSAMLObject(AuthnRequest.class);
    //设置请求时间
    authnRequest.setIssueInstant(new DateTime());
    //The binding required to transmit the resulting SAML Assertion
    //绑定协议为 urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact
    authnRequest.setProtocolBinding(SAMLConstants.SAML2_ARTIFACT_BINDING_URI);
    //IDP server 认证地址
    authnRequest.setDestination(idpEndpoint.getSSOEndPoint());
    //IDP 验证成功后 接收 SAML Assertion 地址
    authnRequest.setAssertionConsumerServiceURL(idpEndpoint.getSpConsumer());
    //Setting ID of the request
    authnRequest.setID(OpenSAMLUtils.generateSecureRandomId());
    //This is the identification of the sender
    //发起人身份,SP ID
    authnRequest.setIssuer(buildIssuer(request));
    //NameID is the IDP identifier for the user
    //the requested authentication context is the SP's requirements for the authentication,
    // which includes; how the SP wants the IDP to authenticate the user
    authnRequest.setRequestedAuthnContext(buildRequestedAuthnContext());
    LOGGER.info("AuthnRequest Object is {}", authnRequest.toString());
    return authnRequest;
}

Redirect Request 到 SAML IDP Server

创建 BasicSAMLMessageContext 对象,包含

  • AuthnRequest 对象

  • SubContext 对象:

    • BindingContext 对象 —— 设置中继状态值,SP 签名材料

    • PeerEntityContext 对象 —— EndPointContext 包含 idp endpoint 信息

    • SecurityParametersContext 对象 —— SignatureSigningParameters 包含 SP 的请求签名

通过 saml2 提供的 banding encoding 实现 HTTPRedirectDeflateEncoder,进行向 Idp 重定向

private void redirectUserWithRequest(HttpServletResponse httpServletResponse, AuthnRequest authnRequest) {

    //BasicSAMLMessageContext was used to contain all the information about the SAML message
    MessageContext context = new MessageContext();
    // 存放 SAML authnRequest 请求
    context.setMessage(authnRequest);

    SAMLBindingContext bindingContext = context.getSubcontext(SAMLBindingContext.class, true);
    // 中继状态值,进行制作 SP 访问的签名
    bindingContext.setRelayState(Constants.IDENTIFY_RELAY_STATE);

    //A context containing information about a peer entity
    // in other words, the IdP for the SP and vice versa
    SAMLPeerEntityContext peerEntityContext = context.getSubcontext(SAMLPeerEntityContext.class, true);
    // peer entity itself does not contain much information, but usually contain one or more sub-context,
    // such as SAMLEndpointContext,SecurityParametersContext,SAMLMessageInfoContext
    // SAMLEndpointContext one of sub-context  - this context contains information about a specific endpoint of the peer entity
    SAMLEndpointContext endpointContext = peerEntityContext.getSubcontext(SAMLEndpointContext.class, true);
    endpointContext.setEndpoint(getIPDEndpoint());

    //携带签名
    SignatureSigningParameters signatureSigningParameters = new SignatureSigningParameters();
    signatureSigningParameters.setSigningCredential(SPCredentials.getCredential());
    signatureSigningParameters.setSignatureAlgorithm(SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256);
    context.getSubcontext(SecurityParametersContext.class, true).setSignatureSigningParameters(signatureSigningParameters);

    // SAML 2.0 HTTP Redirect encoder using the DEFLATE encoding method. 
    // This encoder only supports DEFLATE compression and DSA-SHA1 and RSA-SHA1 signatures.
    HTTPRedirectDeflateEncoder encoder = new HTTPRedirectDeflateEncoder();
    encoder.setMessageContext(context);
    encoder.setHttpServletResponse(httpServletResponse);

    try {
        encoder.initialize();
    } catch (ComponentInitializationException e) {
        throw new RuntimeException(e);
    }

    LOGGER.info("AuthnRequest: ");
    OpenSAMLUtils.logSAMLObject(authnRequest);

    LOGGER.info("Redirecting to IDP");
    try {
        encoder.encode();
    } catch (MessageEncodingException e) {
        throw new RuntimeException(e);
    }
}

Step 3: Idp 身份验证

Idp server 定义一个接口,接受 Sp 发起的请求,进行用户身份校验。

/**
 * 验证签名的逻辑在 SignatureVerifyFilter 中
 *
 * @param request
 * @param response
 */
@GetMapping("/saml/idp/sso")
public void ssoSaml(HttpServletRequest request, HttpServletResponse response)

校验签名

定义 SignatureVerifyFilter 进行 Sp 服务签名的校验

/**
 * <功能描述> idp sso 签名验证过滤器
 *
 * @date 2021/2/27 18:04
 */
@WebFilter(urlPatterns = "/saml/idp/sso")
public class SignatureVerifyFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        //获取 SP 签名
        String authRequest = request.getParameter("SAMLRequest");
        String signature = request.getParameter("Signature");
        String signatureMethod = request.getParameter("SigAlg");
        String relayState = request.getParameter("RelayState");
        byte[] signatureBytes = Base64Support.decode(signature);

        //获取 sigMaterial
        URLBuilder urlBuilder = new URLBuilder(httpServletRequest.getRequestURL().toString());
        List<Pair<String, String>> queryParams = urlBuilder.getQueryParams();
        queryParams.clear();
        queryParams.add(new Pair<>("SAMLRequest", authRequest));
        queryParams.add(new Pair<>("RelayState", relayState));
        queryParams.add(new Pair<>("SigAlg", signatureMethod));
        String sigMaterial = urlBuilder.buildQueryString();

        //进行签名验证
        try {
            boolean verified = XMLSigningUtil.verifyWithURI(SPCredentials.getCredential(), signatureMethod, signatureBytes, sigMaterial.getBytes(StandardCharsets.UTF_8));
            if (!verified) {
                throw new BizBaseException(ExceptionCode.SIGNATURE_VERIFIED_ERROR);
            }
        } catch (SecurityException e) {
            throw new BizBaseException(ExceptionCode.SIGNATURE_VERIFIED_ERROR);
        }
        chain.doFilter(request, response);
    }
}

解码 SP 发起的请求进行校验

// SAML 2.0 HTTP Redirect decoder using the DEFLATE encoding method. 
// This decoder only supports DEFLATE compression.
HTTPRedirectDeflateDecoder decoder = new HTTPRedirectDeflateDecoder();
BasicParserPool parserPool = new BasicParserPool();
try {
    parserPool.initialize();
    decoder.setParserPool(parserPool);
    decoder.setHttpServletRequest(request);
    decoder.initialize();
    decoder.decode();
} catch (ComponentInitializationException | MessageDecodingException e) {
    LOGGER.error("HTTP DECODE ERROR:{}", e.getMessage());
    throw new BizBaseException(ExceptionCode.HTTP_DECODER);
}

//获取 SP 请求信息
MessageContext<SAMLObject> messageContext = decoder.getMessageContext();
AuthnRequest authnRequest = (AuthnRequest) messageContext.getMessage();

//获取 SP 发起者信息
IdentifyIssuerDTO issuerDTO = JacksonUtil.parse(authnRequest.getIssuer().getValue(), new TypeReference<IdentifyIssuerDTO>() {
});

//TODO 校验账号是否存在

重定向 Sp Consumer

指定 Artifact 重定向到 Sp Consumer

resp.sendRedirect(SPConstants.ASSERTION_CONSUMER_SERVICE + "?SAMLart=AAQAAMFbLinlXaCM%2BFIxiDwGOLAy2T71gbpO7ZhNzAgEANlB90ECfpNEVLg%3D");

Step 4 & Step5: The Artifact and Artifact Resolution

定义 SP Consumer 接口

/**
 * Artifact 和 Artifact Resolution 处理逻辑在 ConsumerFilter
 *
 * @param userAccount
 * @param request
 * @return
 */
@GetMapping("/consumer")
public JsonResult<IdmToken> spAccessConsumer(@RequestParam(value = "userAccount") String userAccount, HttpServletRequest request)

**SP Consumer Filter **

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

    HttpServletRequest req = (HttpServletRequest) request;
    HttpServletResponse resp = (HttpServletResponse) response;

    // SP 创建一个 ArtifactResolve 对象
    // 附上 从 IDP Server 带上的 Artifact 对象
    LOGGER.info("Artifact received");
    Artifact artifact = buildArtifactFromRequest(req);
    LOGGER.info("Artifact: " + artifact.getArtifact());

    //从request session 上下文中获取 userAccount
    String userAccount = (String) req.getSession().getAttribute(Constants.USER_ACCOUNT_KEY);
    //构建 artifactResolve 对象
    ArtifactResolve artifactResolve = buildArtifactResolve(artifact, userAccount);
    LOGGER.info("Sending ArtifactResolve");
    LOGGER.info("ArtifactResolve: ");
    OpenSAMLUtils.logSAMLObject(artifactResolve);

    //向 IDP 发送 ArtifactResolve 获取 Artifact 对象
    //通过 web service
    ArtifactResponse artifactResponse = sendAndReceiveArtifactResolve(artifactResolve, resp);
    LOGGER.info("ArtifactResponse received");
    LOGGER.info("ArtifactResponse: ");
    OpenSAMLUtils.logSAMLObject(artifactResponse);

    // 验证返回 ArtifactResponse 请求地址和请求时间
    validateDestinationAndLifetime(artifactResponse, req);

    // 获取存有用户信息的 Assertion 断言
    EncryptedAssertion encryptedAssertion = getEncryptedAssertion(artifactResponse);
    Assertion assertion = decryptAssertion(encryptedAssertion);

    // 验证 IDP 签名
    verifyAssertionSignature(assertion);
    LOGGER.info("Decrypted Assertion: ");
    OpenSAMLUtils.logSAMLObject(assertion);

    //获取Assertion 用户属性
    getAssertionAttributes(assertion, req);

    chain.doFilter(req, resp);
}

Idp ArtifactResolution 关键逻辑

public void resolveArtifact(HttpServletRequest req, HttpServletResponse resp) {
    LOGGER.info("recieved artifactResolve:");
    //初始化 SOAP decoder
    HTTPSOAP11Decoder decoder = new HTTPSOAP11Decoder();
    decoder.setHttpServletRequest(req);
    try {
        BasicParserPool parserPool = new BasicParserPool();
        parserPool.initialize();
        decoder.setParserPool(parserPool);
        decoder.initialize();
        decoder.decode();
    } catch (MessageDecodingException | ComponentInitializationException e) {
        throw new BizBaseException(ExceptionCode.HTTP_SOAP11DECODER_ERROR);
    }

    //获取 artifactResolve 对象
    ArtifactResolve artifactResolve = (ArtifactResolve) decoder.getMessageContext().getMessage();
    LOGGER.info("ArtifactResolve:");
    OpenSAMLUtils.logSAMLObject(artifactResolve);

    //获取 SP 发起人身份
    Issuer issuer = artifactResolve.getIssuer();
    IdentifyIssuerDTO issuerDTO = JacksonUtil.parse(issuer.getValue(), new TypeReference<IdentifyIssuerDTO>() {
    });
    String userAccount = issuerDTO.getUserAccount();
    String spConsumerUrl = issuerDTO.getConsumerUrl();
    
    //构建 Artifact Response 对象
    ArtifactResponse artifactResponse = buildArtifactResponse(userAccount, spConsumerUrl);
    MessageContext<SAMLObject> context = new MessageContext<SAMLObject>();
    context.setMessage(artifactResponse);

    //发送 response 到 SP
    HTTPSOAP11Encoder encoder = new HTTPSOAP11Encoder();
    encoder.setMessageContext(context);
    encoder.setHttpServletResponse(resp);
    try {
        encoder.prepareContext();
        encoder.initialize();
        encoder.encode();
    } catch (MessageEncodingException | ComponentInitializationException e) {
        throw new RuntimeException(e);
    }
}

Step6: 处理结果

当整个 SAML 协议认证完成,SP 会允许用户向其访问的资源进行重定向。

// 从上下文中获取用户访问的 target 地址
// 进行重定向
private void redirectToGotoURL(HttpServletRequest req, HttpServletResponse resp) {
    String gotoURL = (String)req.getSession().getAttribute(SPConstants.GOTO_URL_SESSION_ATTRIBUTE);
    logger.info("Redirecting to requested URL: " + gotoURL);
    try {
        resp.sendRedirect(gotoURL);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

参考资料

《A Guide To OpenSAML V3》 —— Stefan Rasmusson

[OpenSAML Sample Code](