开始使用Spring Boot和SAML的超详细指南

4,048 阅读7分钟

什么是SAML?

安全 断言 标记 语言是一种基于XML的网络认证和授权方式。它可以跨域工作,所以SaaS应用和其他企业软件通常都支持它。

Nick Gamb在《SAML开发者指南》中有一个很好的概述。

如果你想了解Spring Security是如何实现SAML的,请阅读其SAML 2.0登录文档

在Okta上添加一个SAML应用

要开始,你需要一个Okta开发者账户。你可以在developer.okta.com/signup创建一个,或者安装Okta CLI并运行okta register

然后,登录你的账户,进入应用程序>创建应用程序集成。选择SAML 2.0并点击下一步。命名你的应用程序,如 Spring Boot SAML并点击下一步

使用以下设置:

  • 单一签到URL。 http://localhost:8080/login/saml2/sso/okta
  • 收件人URL和目的地URL使用这个。✅(默认)。
  • Audience URI: http://localhost:8080/saml2/service-provider-metadata/okta

然后点击下一步,选择以下选项:

  • 我是一个Okta客户,正在添加一个内部应用
  • 这是一个我们已经创建的内部应用

选择完成

Okta将创建您的应用,您将被重定向到它的Sign On标签。向下滚动到SAML签名证书,进入SHA-2>操作>查看IdP元数据。你可以右击并复制这个菜单项的链接或打开其URL。把产生的链接复制到你的剪贴板上。它应该看起来像下面这样:

https://dev-13337.okta.com/app/<random-characters>/sso/saml/metadata

转到你的应用程序的分配选项卡,将访问权分配给Everyone组。

创建一个支持SAML的Spring Boot应用程序

Spring Boot 3需要Java 17,你可以用SDKMAN安装它:

sdk install java 17-open

做这个教程的最简单方法是克隆我创建的现有Spring Boot示例应用程序

git clone https://github.com/oktadev/okta-spring-boot-saml-example.git

如果你愿意从头开始,你可以使用start.spring.io创建一个全新的Spring Boot应用。选择以下选项:

  • 项目:Gradle
  • Spring Boot:3.0.0 (快照)
  • 依赖性:Spring Web,Spring Security,Thymeleaf

spring initializr

你也可以使用这个URL或HTTPie。

https start.spring.io/starter.zip bootVersion==3.0.0-SNAPSHOT \
  dependencies==web,security,thymeleaf type==gradle-project \
  baseDir==spring-boot-saml | tar -xzvf -

如果你创建了一个全新的应用程序,你需要完成以下步骤:

  1. 添加 src/main/java/com/example/demo/HomeController.java来填充认证用户的信息。
package com.example.demo;

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
public class HomeController {

    @RequestMapping("/")
    public String home(@AuthenticationPrincipal Saml2AuthenticatedPrincipal principal, Model model) {
        model.addAttribute("name", principal.getName());
        model.addAttribute("emailAddress", principal.getFirstAttribute("email"));
        model.addAttribute("userAttributes", principal.getAttributes());
        return "home";
    }

}
  1. 创建一个 src/main/resources/templates/home.html文件来呈现用户的信息。
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org"
      xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity6">
<head>
    <title>Spring Boot and SAML</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>

<h1>Welcome</h1>
<p>You are successfully logged in as <span sec:authentication="name"></span></p>
<p>Your email address is <span th:text="${emailAddress}"></span>.</p>
<p>Your authorities are <span sec:authentication="authorities"></span>.</p>
<h2>All Your Attributes</h2>
<dl th:each="userAttribute : ${userAttributes}">
    <dt th:text="${userAttribute.key}"></dt>
    <dd th:text="${userAttribute.value}"></dd>
</dl>

<form th:action="@{/logout}" method="post">
    <button id="logout" type="submit">Logout</button>
</form>

</body>
</html>
  1. 创建一个 src/main/resources/application.yml文件,包含您在添加Okta上的SAML应用程序中复制的元数据URI。这个值应该以 /sso/saml/metadata.
spring:
  security:
    saml2:
      relyingparty:
        registration:
          okta:
            assertingparty:
              metadata-uri: <your-metadata-uri>
  1. 然后,改变 build.gradle改为使用 thymeleaf-extras-springsecurity6而不是 thymeleaf-extras-springsecurity5并添加Spring Security SAML的依赖性。
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
implementation 'org.springframework.security:spring-security-saml2-service-provider'

如果你是从GitHub克隆的,你只需要更新 application.yml以包括你的元数据URI。你可以删除其他属性,因为它们可能导致问题。

运行应用程序并进行认证

从你的IDE或使用命令行运行你的Spring Boot应用:

./gradlew bootRun

打开 http://localhost:8080在你喜欢的浏览器中打开,用你创建账户时使用的凭证登录。

你应该在浏览器中看到一个成功的结果:

welcome

如果你试图注销,就不会成功。让我们来解决这个问题。

增加一个注销功能

Spring Security的SAML支持有一个注销功能,需要花点时间来配置。首先,在Okta上编辑你的应用程序,并导航到通用>SAML设置>编辑

继续到配置SAML步骤,显示高级设置。选择启用单一注销,并使用以下数值:

  • Single Logout URL。 http://localhost:8080/logout/saml2/slo
  • SP发行者。 http://localhost:8080/saml2/service-provider-metadata/okta

你需要创建一个证书来签署流出的注销请求。你可以使用OpenSSL创建一个私钥和证书。至少用一个值回答其中一个问题,它应该可以工作:

openssl req -newkey rsa:2048 -nodes -keyout local.key -x509 -days 365 -out local.crt

将生成的文件复制到你的应用程序的 src/main/resources目录。在signingsinglelogout 中配置 application.yml:

spring:
  security:
    saml2:
      relyingparty:
        registration:
          okta:
            assertingparty:
              ...
            signing:
              credentials:
                - private-key-location: classpath:local.key
                  certificate-location: classpath:local.crt
            singlelogout:
              binding: POST
              response-url: "{baseUrl}/logout/saml2/slo"

上传 local.crt到你的Okta应用程序并完成其配置。重新启动,注销按钮应该可以工作:

logout

用Spring Security SAML定制当局

你可能会注意到当你登录时,结果页面显示你有一个 ROLE_USER权限。然而,当你给用户分配应用程序时,你给了Everyone 。 你可以在Okta上配置你的SAML应用程序,将用户的组作为一个属性发送。你也可以添加其他属性,如姓名和电子邮件。

编辑你的Okta应用程序的SAML设置,填写组属性声明部分:

  • 名称:groups
  • 名字的格式:Unspecified
  • 过滤器:Matches regex ,并使用 .*的值为

就在上面,你可以添加其他属性声明比如说:

  • 电子邮件 > user.email
  • 名 > user.firstName
  • 姓氏 > user.lastName

保存这些更改。

然后,创建一个SecurityConfiguration 类,该类覆盖了默认配置,并使用转换器将groups 属性中的值转换为Spring Security权限。

src/main/java/com/example/demo/SecurityConfiguration.java

package com.example.demo;

import java.util.HashSet;
import java.util.List;
import java.util.Set;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider.ResponseToken;
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal;
import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
public class SecurityConfiguration {

    @Bean
    SecurityFilterChain configure(HttpSecurity http) throws Exception {

        OpenSaml4AuthenticationProvider authenticationProvider = new OpenSaml4AuthenticationProvider();
        authenticationProvider.setResponseAuthenticationConverter(groupsConverter());

        // @formatter:off
        http
            .authorizeHttpRequests(authorize -> authorize
                .mvcMatchers("/favicon.ico").permitAll()
                .anyRequest().authenticated()
            )
            .saml2Login(saml2 -> saml2
                .authenticationManager(new ProviderManager(authenticationProvider))
            )
            .saml2Logout(withDefaults());
        // @formatter:on

        return http.build();
    }

    private Converter<OpenSaml4AuthenticationProvider.ResponseToken, Saml2Authentication> groupsConverter() {

        Converter<ResponseToken, Saml2Authentication> delegate =
            OpenSaml4AuthenticationProvider.createDefaultResponseAuthenticationConverter();

        return (responseToken) -> {
            Saml2Authentication authentication = delegate.convert(responseToken);
            Saml2AuthenticatedPrincipal principal = (Saml2AuthenticatedPrincipal) authentication.getPrincipal();
            List<String> groups = principal.getAttribute("groups");
            Set<GrantedAuthority> authorities = new HashSet<>();
            if (groups != null) {
                groups.stream().map(SimpleGrantedAuthority::new).forEach(authorities::add);
            } else {
                authorities.addAll(authentication.getAuthorities());
            }
            return new Saml2Authentication(principal, authentication.getSaml2Response(), authorities);
        };
    }
}

你也许可以删除 permitAll()因为这个问题最近在Spring Security中被修复了

最后,修改你的 build.gradle文件,以强制使用最新版本的Open SAML,并与Spring Security 6一起使用:

repositories {
    ...
    maven { url "https://build.shibboleth.net/nexus/content/repositories/releases/" }
}

dependencies {
    constraints {
        implementation "org.opensaml:opensaml-core:4.1.1"
        implementation "org.opensaml:opensaml-saml-api:4.1.1"
        implementation "org.opensaml:opensaml-saml-impl:4.1.1"
    }
    ...
}

现在,如果你重新启动你的应用程序并登录,你应该看到你的用户组是授权的。欢呼吧!

groups-as-authorities

增加对Auth0的支持

你知道Auth0也提供对SAML应用程序的支持吗?Auth0让配置变得更加容易,因为它的默认网络应用支持OIDCSAML。

注册一个Auth0账户或用你现有的账户登录。导航到应用程序>创建应用程序>常规Web应用程序>创建

选择 "设置"选项卡,将名称改为 Spring Boot SAML.添加 http://localhost:8080/login/saml2/sso/auth0作为允许的回调URL

滚动到底部,展开高级设置,然后进入端点。复制SAML Metadata URL的值。你很快就会需要这个。选择保存更改

如果你把你的应用程序配置为使用这些值,认证就会工作,但你将无法注销。滚动到页面顶部,选择Addons,并启用SAML。

选择 "设置"选项卡,将(有注释的)JSON改成如下内容:

{
  "logout": {
    "callback": "http://localhost:8080/logout/saml2/slo",
    "slo_enabled": true
  }
}

滚动到底部,点击启用

改变你的 application.yml以使用auth0 ,而不是okta ,并将你的SAML元数据URL复制到其中:

spring:
  security:
    saml2:
      relyingparty:
        registration:
          auth0:
            assertingparty:
              metadata-uri: <your-auth0-metadata-uri>
            signing:
              credentials:
                - private-key-location: classpath:local.key
                  certificate-location: classpath:local.crt
            singlelogout:
              binding: POST
              response-url: "{baseUrl}/logout/saml2/slo"

重新启动你的应用程序,你应该能够用Auth0登录。

auth0 login

你可能会注意到,电子邮件和授权没有正确计算。这是因为Auth0的索赔名称已经改变。更新 SecurityConfiguration#groupsConverter()以允许Okta和Auth0的组名。

private Converter<OpenSaml4AuthenticationProvider.ResponseToken, Saml2Authentication> groupsConverter() {

    ...

    return (responseToken) -> {
        ...
        List<String> groups = principal.getAttribute("groups");
        // if groups is not preset, try Auth0 attribute name
        if (groups == null) {
            groups = principal.getAttribute("http://schemas.auth0.com/roles");
        }
        ...
    };
}

要使Auth0填充用户组,请导航到Auth Pipeline>Rules并创建一个新规则。选择 "空 "规则模板。提供一个有意义的名字,如Groups claim ,用下面的内容替换Script ,然后保存

function(user, context, callback) {
  user.preferred_username = user.email;
  const roles = (context.authorization || {}).roles;

  function prepareCustomClaimKey(claim) {
    return `${claim}`;
  }

  const rolesClaim = prepareCustomClaimKey('roles');

  if (context.idToken) {
    context.idToken[rolesClaim] = roles;
  }

  if (context.accessToken) {
    context.accessToken[rolesClaim] = roles;
  }

  callback(null, user, context);
}

接下来,修改HomeController ,允许Auth0的电子邮件属性名称。

public class HomeController {

    @RequestMapping("/")
    public String home(@AuthenticationPrincipal Saml2AuthenticatedPrincipal principal, Model model) {
        model.addAttribute("name", principal.getName());
        String email = principal.getFirstAttribute("email");
        // if email is not preset, try Auth0 attribute name
        if (email == null) {
            email = principal.getFirstAttribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress");
        }
        model.addAttribute("emailAddress", email);
        model.addAttribute("userAttributes", principal.getAttributes());
        return "home";
    }

}

重新启动你的应用程序,登录,一切都应该按预期工作。

auth0 groups

支持Okta和Auth0

您也可以同时支持Okta和Auth0!修改你的 application.yml修改为如下内容,Spring Security会提示你用哪种方式登录。的 &name*name值用于设置和检索YAML块,以避免重复。

spring:
  security:
    saml2:
      relyingparty:
        registration:
          auth0:
            assertingparty:
              metadata-uri: <your-auth0-metadata-uri>
            signing:
              credentials: &signing-credentials
                - private-key-location: classpath:local.key
                  certificate-location: classpath:local.crt
            singlelogout: &logout-settings
              binding: POST
              response-url: "{baseUrl}/logout/saml2/slo"
          okta:
            assertingparty:
              metadata-uri: <your-okta-metadata-uri>
            signing:
              credentials: *signing-credentials
            singlelogout: *logout-settings

如果你用这些设置重启你的应用程序,当你第一次点击时,你会被提示使用这两个值 http://localhost:8080.

Login with SAML 2.0

部署到生产中

看这个应用在生产环境中工作的一个快速方法是把它部署到Heroku。安装Heroku CLI并创建一个账户来开始。然后,按照下面的步骤准备和部署你的应用程序:

  1. 使用heroku create ,在Heroku上创建一个新的应用程序。
  2. 创建一个 system.properties文件,以强制执行Java 17。
java.runtime.version=17
  1. 创建一个Procfile ,指定如何运行你的应用程序:
web: java -Xmx256m -jar build/libs/*.jar --server.port=$PORT
  1. 提交你的修改:
git add .
git commit -m "Add Heroku configuration"
  1. 设置Gradle任务来构建你的应用程序:
heroku config:set GRADLE_TASK="bootJar"
  1. 使用Git部署到生产中:
git push heroku main

为了使认证与SAML一起工作,你需要更新你的Okta和Auth0应用程序,以使用你的Heroku应用程序的URL来代替 http://localhost:8080替换,只要适用。

了解更多关于Spring Boot和Spring Security的信息

我希望你喜欢学习如何使用Spring Security来添加SAML认证。集成就像配置元数据URI一样简单,只有当你添加注销功能时才会变得更加复杂。从你的身份提供者转换群组到权威机构的能力也是非常巧妙的!