Java 开发人员的跨解决方案 Microsoft 身份验证,第 3 部分:使用 MSAL 访问 Azure API





5.00/5 (3投票s)
在本文《使用 MSAL 访问 Azure API》中,我们将使用 OBO OAuth 流来调用 Azure 中的一项服务。然后,我们将通过共享数据库将结果传回前端应用程序。
在本系列的上一篇文章中,我们创建了一个微服务,代表从 Web 应用程序发起请求的用户调用 Microsoft Graph API,以检索日历事件。该教程演示了 Azure AD 和 MSAL 如何让 Spring 应用程序参与到更广泛的服务生态系统中。
在本教程中,我们将创建第二个微服务,这次代表用户调用 Azure 存储 API。当我们代表用户调用 Azure 服务时,Azure 安全层会授予或限制对单个用户的访问权限,而不是由我们的应用程序自行管理这些规则。这种方法大大简化了我们的代码,并使我们能够在 Azure 中集中管理安全策略。
这个第二个微服务然后将日志文件上传事件记录到一个 MySQL 数据库的审计表中,并将这些审计记录暴露给前端 Web 应用程序。
您可以在 mcasperson/SpringMSALDemo GitHub 仓库中找到本教程前端应用程序的源代码,并在 mcasperson/SpringMSALAzureStorageMicroservice GitHub 仓库中找到 Azure API 微服务。
创建 Azure 存储帐户
在此演示中,我们需要将微服务上传到 Azure 存储帐户。我们首先需要创建一个新的存储帐户。
请注意,仅有 Owner
权限不足以通过我们的应用程序执行上传。执行上传的用户还必须拥有 Storage Blob Data Contributor
权限。
我们通过在 Azure 中定义这些权限的事实,表明通过 MSAL 与 Azure API 交互可以将维护安全规则的负担从我们的代码中分离出来。
创建 MySQL 数据库
我们的微服务需要一个 MySQL 数据库来持久化审计条目,因此我们在 Azure 中创建了一个新的 MySQL 实例。
注册新应用程序
我们将第二个微服务注册为一个新的 Azure AD 应用程序。我们在上一篇文章中记录了如何注册新应用程序,因此我们不会再一步一步地进行演示。相反,我们将重点介绍此新微服务特有的设置。
这个新应用程序公开了一个名为 SaveFile
的范围。请注意 应用程序 ID URI,因为您稍后会用到它
该应用程序在 Azure 存储 API 上委托了 user_impersonation
权限。
与之前一样,我们需要为这些权限授予管理员同意
最后,我们必须创建一个新密钥。请注意这个新密钥,因为 Azure 不会再显示它了
构建 Spring Boot 微服务
与之前的教程一样,我们将使用 Spring Initializr 工具引导我们的 Spring 微服务。我们的新微服务需要以下依赖项,在点击 添加依赖项 后,我们将它们添加进去
- Spring Web,提供内置 Web 服务器
- OAuth2 资源服务器,允许我们将应用程序配置为资源服务器
- OAuth2 客户端,提供我们将用于发出 OAuth2 身份验证 HTTP 请求的类
- Azure Active Directory,提供与 Azure AD 的集成
- MySQL 驱动程序,提供 MySQL 数据库的驱动程序
- Spring Data JPA,提供到 MySQL 数据库的对象关系映射 (ORM) 接口
请注意,我们没有使用 Azure 存储依赖项,该依赖项通过帐户密钥连接到存储帐户。相反,此微服务使用下面所示的更通用的 Azure 存储 Java 库,我们必须将其添加到 pom.xml 中
<dependency>
<groupId>com.azure</groupId>
<artifactId>azure-storage-blob</artifactId>
<version>12.13.0</version>
</dependency>
配置 Spring 应用程序
以下是微服务的 application.yaml 文件代码
server: port: 8082 azure: activedirectory: client-id: ${CLIENT_ID} client-secret: ${CLIENT_SECRET} app-id-uri: ${API_URL} tenant-id: ${TENANT_ID} authorization-clients: storage: scopes: - https://storage.azure.com/user_impersonation spring: jpa: database-platform: org.hibernate.dialect.MySQL5InnoDBDialect datasource: url: jdbc:mysql://${DB_NAME}.mysql.database.azure.com:3306/springdemo?useSSL=true&requireSSL=false username: ${DB_USERNAME} password: ${DB_PASSWORD} logging: level: org: springframework: security: DEBUG
我们之前已经见过大部分配置。但是,我们将重点介绍一些独特的设置。
此微服务在与 Azure 存储交互时需要用户模拟权限。分配给客户端的范围反映了这一点。
authorization-clients: storage: scopes: - https://storage.azure.com/user_impersonation
由于此微服务使用 Spring Data JPA,我们必须配置数据库访问。我们的数据库运行 MySQL,因此我们定义了相应的 hibernate 方言
spring: jpa: database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
然后我们定义连接字符串和数据库凭据。我们将数据库名称保存在 DB_NAME
环境变量中,用户名保存在 DB_USERNAME
环境变量中,密码保存在 DB_PASSWORD
环境变量中
datasource: url: jdbc:mysql://${DB_NAME}.mysql.database.azure.com:3306/springdemo?useSSL=true&requireSSL=false username: ${DB_USERNAME} password: ${DB_PASSWORD}
配置 Spring Security
我们的存储 API 微服务实现了与日历 API 相同的安全规则,对所有请求进行身份验证。我们在 AuthSecurityConfig 类中配置这些规则
// src/main/java/com/matthewcasperson/azureapi/configuration/AuthSecurityConfig.java
package com.matthewcasperson.azureapi.configuration;
import com.azure.spring.aad.webapi.AADResourceServerWebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@EnableWebSecurity
public class AuthSecurityConfig extends AADResourceServerWebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
// @formatter:off
http
.authorizeRequests()
.anyRequest()
.authenticated();
// @formatter:on
}
}
创建 JPA 存储库
Spring 为处理数据库记录提供了一个便捷的解决方案。创建一个扩展 JpaRepository
接口的接口即可访问许多用于保存、删除和返回条目的标准函数。我们通过 AuditRepository 接口公开这些方法。
我们只需要将此接口注入到控制器中即可访问数据库
// src/main/java/com/matthewcasperson/azureapi/repository/AuditRepository.java
package com.matthewcasperson.azureapi.repository;
import com.matthewcasperson.azureapi.model.Audit;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AuditRepository extends JpaRepository<Audit, Long> {
}
创建审计实体
接下来,我们在 Audit 类中定义代表我们审计记录的数据库实体。此类以及相关的数据库记录有三个字段
id
,一个自动递增的主键message
,审计消息date
,审计记录的日期
我们使用此代码将这些记录保存在一个名为 audit
的表中
// src/main/java/com/matthewcasperson/azureapi/model/Audit.java
package com.matthewcasperson.azureapi.model;
import javax.persistence.*;
import java.sql.Timestamp;
@Entity
@Table(name = "audit")
public class Audit {
private Long id;
private String message;
private java.sql.Timestamp date;
public Audit() {
}
public Audit(String message) {
this.message = message;
this.date = new Timestamp(System.currentTimeMillis());
}
public void setId(Long id) {
this.id = id;
}
@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
public Long getId() {
return id;
}
public void setMessage(String message) {
this.message = message;
}
@Column(name = "message")
public String getMessage() {
return message;
}
public void setDate(Timestamp date) {
this.date = date;
}
@Column(name = "date")
public Timestamp getDate() {
return date;
}
}
创建文件上传 REST 控制器
我们可以在 UploadFileController 类中找到此微服务的大部分逻辑。此控制器接收来自前端应用程序的请求,将文件上传到 Azure 存储,并在数据库中创建审计记录。
完整的类代码如下
package com.matthewcasperson.azureapi.controllers;
import com.azure.core.credential.AccessToken;
import com.azure.core.credential.TokenCredential;
import com.azure.storage.blob.BlobContainerClient;
import com.azure.storage.blob.BlobServiceClient;
import com.azure.storage.blob.BlobServiceClientBuilder;
import com.azure.storage.blob.specialized.BlockBlobClient;
import com.matthewcasperson.azureapi.model.Audit;
import com.matthewcasperson.azureapi.repository.AuditRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.time.ZoneOffset;
@RestController
public class UploadFileController {
@Autowired
AuditRepository auditRepository;
@PutMapping("/upload/{fileName}")
public void upload(@RequestBody String content,
@PathVariable("fileName") String fileName,
BearerTokenAuthentication principal,
@RegisteredOAuth2AuthorizedClient("storage") OAuth2AuthorizedClient client) {
try {
uploadFile(client, generateContainerName(principal), fileName, content);
auditRepository.saveAndFlush(new Audit("Uploaded file " + fileName + " for user " + getPrincipalEmail(principal)));
} catch (Exception ex) {
ex.printStackTrace();
}
}
private void uploadFile(OAuth2AuthorizedClient client, String container, String fileName, String content) {
BlobServiceClient blobServiceClient = new BlobServiceClientBuilder()
.credential(createTokenCredential(client))
.endpoint("https://" + System.getenv("STORAGE_ACCOUNT_NAME") + ".blob.core.windows.net")
.buildClient();
BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(container);
if (!containerClient.exists()) containerClient.create();
BlockBlobClient blockBlobClient = containerClient.getBlobClient(fileName).getBlockBlobClient();
if (blockBlobClient.exists()) blockBlobClient.delete();
try (ByteArrayInputStream dataStream = new ByteArrayInputStream(content.getBytes())) {
blockBlobClient.upload(dataStream, content.length());
} catch (IOException e) {
e.printStackTrace();
}
}
private TokenCredential createTokenCredential(OAuth2AuthorizedClient client) {
return request -> Mono.just(new AccessToken(
client.getAccessToken().getTokenValue(),
client.getAccessToken().getExpiresAt().atOffset(ZoneOffset.UTC)));
}
private String generateContainerName(BearerTokenAuthentication principal) {
return getPrincipalEmail(principal).replaceAll("[^A-Za-z0-9\\-]", "-");
}
private String getPrincipalEmail(BearerTokenAuthentication principal) {
return principal.getTokenAttributes().get("upn").toString();
}
}
让我们检查一下这个类中有趣的部分。
我们注入了 AuditRepository
接口的实例,从而获得了数据库访问权限
@Autowired AuditRepository auditRepository;
upload
方法调用 uploadFile
将文件上传到 Azure 存储,然后调用 auditRepository.saveAndFlush
创建新的数据库记录。
请注意,我们上传文件的容器名称是从登录用户的电子邮件地址派生的
@PutMapping("/upload/{fileName}")
public void upload(@RequestBody String content,
@PathVariable("fileName") String fileName,
BearerTokenAuthentication principal,
@RegisteredOAuth2AuthorizedClient("storage") OAuth2AuthorizedClient client) {
try {
uploadFile(client, generateContainerName(principal), fileName, content);
auditRepository.saveAndFlush(new Audit("Uploaded file " + fileName + " for user " + getPrincipalEmail(principal)));
} catch (Exception ex) {
ex.printStackTrace();
}
}
uploadFile
方法首先构建一个新的 BlobServiceClient
,允许我们与 Azure 存储帐户进行交互。请注意,我们将存储帐户的名称定义在名为 STORAGE_ACCOUNT_NAME
的环境变量中
private void uploadFile(OAuth2AuthorizedClient client, String container, String fileName, String content) {
BlobServiceClient blobServiceClient = new BlobServiceClientBuilder()
.credential(createTokenCredential(client))
.endpoint("https://" + System.getenv("STORAGE_ACCOUNT_NAME") + ".blob.core.windows.net")
.buildClient();
如果容器不存在,代码会创建它。
BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(container);
if (!containerClient.exists()) containerClient.create();
如果它已存在,代码会删除要上传的文件。
BlockBlobClient blockBlobClient = containerClient.getBlobClient(fileName).getBlockBlobClient();
if (blockBlobClient.exists()) blockBlobClient.delete();
然后我们从提供的字符串创建一个新文件
try (ByteArrayInputStream dataStream = new ByteArrayInputStream(content.getBytes())) {
blockBlobClient.upload(dataStream, content.length());
} catch (IOException e) {
e.printStackTrace();
}
访问存储帐户的凭据直接来源于客户端生成的访问令牌。有趣的是,尽管 com.microsoft:azure-identity 依赖项提供了许多 TokenCredential
接口的专用实现,但没有一个接受现有的 JWT 令牌。
但是,很容易构建我们自己的实现来持有来自我们客户端的 JWT 令牌,这就是 createTokenCredential
函数的目的
private TokenCredential createTokenCredential(OAuth2AuthorizedClient client) {
return request -> Mono.just(new AccessToken(
client.getAccessToken().getTokenValue(),
client.getAccessToken().getExpiresAt().atOffset(ZoneOffset.UTC)));
}
generateContainerName
函数将电子邮件转换为适合容器名称的字符串。由于容器名称只能包含字母、数字和连字符,因此此函数会将所有无效字符替换为连字符
private String generateContainerName(BearerTokenAuthentication principal) {
return getPrincipalEmail(principal).replaceAll("[^A-Za-z0-9\\-]", "-");
}
getPrincipalEmail
函数从用户主体名 (upn
) 属性返回电子邮件地址
private String getPrincipalEmail(BearerTokenAuthentication principal) {
return principal.getTokenAttributes().get("upn").toString();
}
创建审计 REST 控制器
为了让前端 Web 应用程序能够查看审计记录,我们必须创建一个控制器来查找数据库中的记录并将其作为 HTTP 响应返回。我们使用 AuditController 类
// src/main/java/com/matthewcasperson/demo/controllers/AuditController.java
package com.matthewcasperson.azureapi.controllers;
import com.matthewcasperson.azureapi.model.Audit;
import com.matthewcasperson.azureapi.repository.AuditRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
public class AuditController {
@Autowired
AuditRepository auditRepository;
@GetMapping(value = "/audit", produces = "application/json")
public List<audit> upload() {
return auditRepository.findAll();
}
}</audit>
运行微服务
要构建并运行应用程序,我们运行以下 PowerShell 命令:
$env:CLIENT_SECRET="Application client secret"
$env:CLIENT_ID="Application client ID"
$env:TENANT_ID="Azure AD tenant ID"
$env:API_URL="Application API URI"
$env:DB_NAME="The name of the database"
$env:DB_USERNAME="The database username"
$env:DB_PASSWORD="The database password"
$env:STORAGE_ACCOUNT_NAME="The storage account name"
.\mvnw spring-boot:run
或 Bash 命令:
export CLIENT_SECRET="Application client secret"
export CLIENT_ID="Application client ID"
export TENANT_ID="Azure AD tenant ID"
export API_URL="Application API URI"
export DB_NAME="The name of the database"
export DB_USERNAME="The database username"
export DB_PASSWORD="The database password"
export STORAGE_ACCOUNT_NAME="The storage account name"
./mvnw spring-boot:run
创建前端审计控制器
接下来,我们需要更新前端 Web 应用程序以读取后端微服务上传文件时生成的审计日志。AuditController
类显示了这些记录。
在这里,我们调用后端微服务,将结果转换为 Audit
记录列表,并通过 audit
模型属性将记录传递给视图
// src/main/java/com/matthewcasperson/demo/controllers/AuditController.java
package com.matthewcasperson.demo.controllers;
import com.matthewcasperson.demo.model.Audit;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.servlet.ModelAndView;
import java.util.ArrayList;
import java.util.List;
import static org.springframework.security.oauth2.client.web.reactive.function.client.ServerOAuth2AuthorizedClientExchangeFilterFunction.oauth2AuthorizedClient;
@Controller
public class AuditController {
@Autowired
private WebClient webClient;
@GetMapping("/audit")
public ModelAndView events(
@RegisteredOAuth2AuthorizedClient("azure-api") OAuth2AuthorizedClient client) {
List<Audit> events = getAudit(client);
ModelAndView mav = new ModelAndView("audit");
mav.addObject("audit", events);
return mav;
}
private List<Audit> getAudit(OAuth2AuthorizedClient client) {
try {
if (null != client) {
System.out.println("\n" + client.getAccessToken().getTokenValue() + "\n");
return webClient
.get()
.uri("https://:8082/audit")
.attributes(oauth2AuthorizedClient(client))
.retrieve()
.bodyToMono(new ParameterizedTypeReference<List<Audit>>() {})
.block();
}
} catch (Exception ex) {
System.out.println(ex);
}
return new ArrayList<>();
}
}
创建前端审计记录
前端将后端返回的 JPA 实体表示为更精简的记录。由于我们不需要任何 JPA 注释,因此一个名为 Audit 的记录提供了一种轻量级的方法来表示审计事件。
package com.matthewcasperson.demo.model;
import java.sql.Timestamp;
public record Audit(Long id, String message, Timestamp date) {
}
创建前端审计模板
名为 audit.html 的 Thymeleaf 模板通过循环遍历 audit
模型属性中的每个项目并构建新的表行来显示审计记录
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>MSAL and Spring Demo</title>
<link href="bootstrap.min.css" rel="stylesheet">
<link href="cover.css" rel="stylesheet">
</head>
<body class="text-center">
<div class="cover-container d-flex h-100 p-3 mx-auto flex-column">
<header class="masthead mb-auto">
<div class="inner">
<nav class="nav nav-masthead justify-content-center">
<a class="nav-link" href="/">Home</a>
<a class="nav-link" href="/profile">Profile</a>
<a class="nav-link" href="/events">Events</a>
<a class="nav-link" href="/upload">Upload</a>
<a class="nav-link active" href="/audit">Audit</a>
</nav>
</div>
</header>
<main role="main" class="inner cover">
<h2>Audit Events</h2>
<table>
<tr>
<td>Message</td>
<td>Time</td>
</tr>
<tr th:each="auditItem: ${audit}">
<td th:text="${auditItem.message}" />
<td th:text="${auditItem.date}" />
</tr>
</table>
</main>
</div>
<script src="jquery-3.6.0.min.js"></script>
<script src="bootstrap.min.js"></script>
</body>
</html>
运行前端应用程序
要构建并运行应用程序,我们运行以下 PowerShell 命令:
$env:CLIENT_SECRET="Application client secret"
$env:CLIENT_ID="Application client ID"
$env:TENANT_ID="Azure AD tenant ID"
$env:CALENDAR_SCOPE="The Calendar API ReadCalendar scope e.g. api://2e6853d4-90f2-40d9-a97a-3c40d4f7bf58/ReadCalendar"
$env:AZURE_SCOPE="The Azure API SaveFile scope e.g. api://06bab64f-dc26-4156-9412-720e351259ab/SaveFile"
.\mvnw spring-boot:run
或 Bash 命令:
export CLIENT_SECRET="Application client secret"
export CLIENT_ID="Application client ID"
export TENANT_ID="Azure AD tenant ID"
export CALENDAR_SCOPE="The Calendar API ReadCalendar scope e.g. api://2e6853d4-90f2-40d9-a97a-3c40d4f7bf58/ReadCalendar"
export AZURE_SCOPE="The Azure API SaveFile scope e.g. api://06bab64f-dc26-4156-9412-720e351259ab/SaveFile"
./mvnw spring-boot:run
然后我们打开 https://:8080/upload,输入文件名,提供文件内容,然后点击 上传 按钮
前端应用程序调用后端微服务,将文件上传到 Azure 存储,并将记录保存在 MySQL 数据库中。
我们可以通过打开 https://:8080/audit 来查看这些审计记录。前端联系后端微服务获取审计事件列表。后端返回此信息,然后 Thymeleaf 模板通过循环审计记录构建一个表来显示它。
后续步骤
在完成了这三个教程之后,您将拥有三个 Spring Boot 应用程序
- 一个 Web 前端
- 一个从 Microsoft Graph API 检索日历事件的微服务
- 一个将文件上传到 Azure 存储并在 MySQL 数据库中保存审计事件的微服务
每个应用程序都受到 Azure AD 的保护,并且由于 MSAL,我们拥有流畅的登录体验,允许我们目录中的用户使用我们的网站和后端服务。
MSAL 和 Azure AD 还简化了将每个应用程序与外部资源服务器进行身份验证的过程。通过对代表用户 (on-behalf-of) OAuth 流的支持,我们的微服务可以作为登录到前端网站的用户与 Azure 和 Microsoft 365 等外部平台进行交互。这种能力使我们能够访问用户特定的信息(如日历事件),并依赖外部安全层,而不是由我们的代码实现自定义安全。
这些功能确保您的应用程序在微服务架构复杂性不断增长的情况下保持安全且易于管理。通过遵循此处提供的示例,您现在已经拥有了将这些技术应用到您的应用程序中的绝佳基础。在您的下一个企业应用程序中使用 MS Identity 和 MSAL,以实现可管理的的企业级安全性。