轻量级 SSO 方略:基于 OIDC 规范(二)

上一篇文章介绍了 SSO 相关的基础数据,这样有了 ClientId 和密钥后,我们就要准备客户端这边的代码。客户端当前指的便是一个网站(也就是 RP),这个网站要求有会员功能,典型地网站导航上通常会有“注册”或“登录”的链接。

在这里插入图片描述
假设我们这是最简单的网站,采用 Servlet Session 本地记录用户凭证。本身这个网站没有设计用户的模块,得通过 SSO 完成用户登录。

RP 的 Yaml 配置文件如下:

user:
  clientId: G5IFeG7Eesbny3f
  clientSecret: J1Bb4zhchfziuDipKI7sgo6iyk
  loginPage: http://127.0.0.1:8888/iam/oidc/authorization # 登录页面地址

另外我们还要准备一个登录页面,简单的例子如下。

在这里插入图片描述
这个页面存在 SSO 中心(也就是 OP)的,而不是客户端的。

标准 的 OIDC 流程

好,下面正式进入 OIDC 登录的流程,我们先大概了解一下整个流程。

OIDC 授权码模式的认证流程中涉及三方:用户、OIDC 服务器(OpenID Provider/OIDC Provider,简称 OP)、客户端业务应用(Relying Party,简称 Client)。用户、OP、RP 的交互目的分为以下几点:

  1. Client 希望用户登录,从而拿到用户身份信息。
  2. Client 发起登录,会跳转到 OP 的认证页面,OP 让用户登录,并授权自己的信息,然后 OP 将一个授权码(code)发给 Client。实际上这是在通过引用来传递用户信息。
  3. Client 收到授权码 code 后,结合 Client ID 和 Client Secret 到 OP 换取该用户的 access_token。
  4. Client 利用 access_token 到 OP 去获取用户的相关信息,从而得到一个可信的身份断言,让用户登录。

下面我们再逐一详解。

客户端发起登录

一开始,用户并未在客户端网站上登录。当用户点击“登录”按钮要求登录的时候,RP(客户端)的后台接口/user/login会组装所需的参数,形成 URI 返回303 Location=该 URI,告诉浏览器重定向到 OP /oidc/authorization

这是 RP 的接口,执行上述过程。

import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.view.RedirectView;

import com.ajaxjs.util.StrUtil;

@RestController
@RequestMapping("/user")
public class UserController {
	@Value("${user.loginPage}")
	private String userLoginCode;

	@Value("${user.clientId}")
	private String clientId;

	@GetMapping("/login")
	public ModelAndView get() {
		String url = userLoginCode + "?response_type=code&client_id=" + clientId;
		url += "&redirect_uri=http://localhost:8080/mywebsite/user/auth_code";
		url += "&state=" + StrUtil.getRandomString(6);

		return new ModelAndView(new RedirectView(url));
	}
}

浏览器的重定向,即返回:

HTTP/1.1 303 See Other
Location: http://127.0.0.1:8888/iam/oidc/authorization
?redirect_uri=http://localhost:8080/mywebsite/user/auth_code
&response_type=code
&state=J1Bb4zhchfziuDipKI7sgo6iyk
&client_id=G5IFeG7Eesbny3f

参数说明

参数说明
redirect_uri用户登录成功后,OP 回传授权码等信息给RP的接口,相当于回调地址
response_type固定值 code,表示授权码流程
client_idRP 在 OP 注册的 client_id
state不透明字符串,当 OP 重定向到 redirect_uri 时,会原样返回给 RP,用于防止 CSRF、 XSRF。由于 OP 会原样返回此参数,可将 state 值与用户在 RP 登录前最后浏览的 URI 绑定,便于登录完成后将用户重定向回最后浏览的页面

发送授权码

OP 端的/oidc/authorization接口里有这么两个分支的判断:

  • 发现用户未登录,返回 HTTP 303,通过浏览器重定向到登录页面
  • 用户已登录,于是执行授权逻辑,签发授权码

判断是否已登录

怎么判断没有登录?没有携带特定的 cookie 便是表明用户未登录。一般 Java Web 的是 JSESSIONID,这样的话可以在后台直接使用 Session 对象(比较简单,但只是“单机”)。如果是 Redis 方案,也要使用 Cookie,并且要把 Cookie 与 Redis 缓存对应起来。


参考代码如下。

接口定义。

/**
 * 1、发现用户未登录,返回 303,通过浏览器重定向到登录页面
 * 2、用户已登录,于是执行授权逻辑,签发授权码
 *
 * @param req  请求对象
 * @param resp 响应对象
 */
@GetMapping("/authorization")
void authorization(
        @RequestParam("response_type") String responseType,
        @RequestParam("client_id") String clientId, @RequestParam("redirect_uri") String redirectUri, @RequestParam(required = false) String scope, @RequestParam String state,
        HttpServletRequest req, HttpServletResponse resp);

在这里插入图片描述

用户登录

若是未登录则跳到登录页面,则是/iam/login/?response_type=code&client_id=G5IFeG7Eesbny3f&redirect_uri=http://localhost:8080/mywebsite/user/auth_code&state=tMpnBJ

我们在跳转的时候加入一个提示的页面,让用户感知更好。

在这里插入图片描述

用户输入账密,表单 AJAX 提交时触发 OP 验证账密接口 POST op.com/user/login。下面是一个比较简单的例子,注意要附上所有的 QueryString 参数(location.search)。

在这里插入图片描述

如果账密正确则重定向回 OP 授权接口(返回 303 Location=步骤1 的 Location,即/oidc/authorization),并设置 OP 的会话状态(设置 cookie)。

发送授权码

此时再度进入/oidc/authorization,发现用户成功登录了于是就可以发送授权码,返回303 Location=redirect_uri,浏览器重定向到 redirect_uri。这样 RP 就可以得到授权码。

接口定义(跟前面的一样)。

/**
 * 1、发现用户未登录,返回 303,通过浏览器重定向到登录页面
 * 2、用户已登录,于是执行授权逻辑,签发授权码
 *
 * @param req  请求对象
 * @param resp 响应对象
 */
@GetMapping("/authorization")
void authorization(
        @RequestParam("response_type") String responseType,
        @RequestParam("client_id") String clientId, @RequestParam("redirect_uri") String redirectUri, @RequestParam(required = false) String scope, @RequestParam String state,
        HttpServletRequest req, HttpServletResponse resp);

在这里插入图片描述
生成授权码的规则是 clientId 加时间戳再 SHA1 得到 code,注意要把 code 和当前用户对应起来,然后存到 5 分钟超时的缓存中。

返回给 RP 类似于这样的响应:

HTTP/1.1 303 See Other
Location: http://rp.com/user/callback
?state=DJOfvYDSDxaPzOKR
&code=Z0FBQUFBQmVjc

为什么 OIDC 授权码流程要 code 换 token 再换用户信息?

答案出处。

OIDC 协议中,用户登录成功后,OIDC 认证服务器会将用户的浏览器回调到一个回调地址,并携带一个授权码(code)。此授权码一般有效期十分钟且一次有效,用后作废。这避免了在前端暴露 access_token 或者用户信息的风险,access_token 的有效期都比较长,一般为 1~2 个小时。如果泄露会对用户造成一定影响。

后端收到这个 code 之后,需要使用 Client Id + Client Secret + Code 去 OIDC 认证服务器换取用户的 access_token。在这一步,实际上 OIDC Server 对 OAuth Client 进行了认证,能够确保来 OIDC 认证服务器获取 access_token 的机器是可信任的,而不是任何一个人拿到 code 之后都能来 OIDC 认证服务器进行 code 换 token。

如果 code 被黑客获取到,如果他没有 Client Id + Client Secret 也无法使用,就算有,也要和真正的应用服务器竞争,因为 code 一次有效,用后作废,加大了攻击难度。相反,如果不经过 code 直接返回 access_token 或用户信息,那么一旦泄露就会对用户造成影响。

获取 Token

RP 后台请求 Token

RP 接收到 OP 回传的 code 参数后,接着就是向 OP /oidc/token接口获取 AccessToken 令牌,也就是用 code 换 token。注意这是在服务端请求完成的(此处使用 Spring RestTemplate 请求)。

其中请求头Authorization字段通过Basic关键字传递 RP 在 OP 注册的client_idclient_secret。请见下面源码:

@Value("${user.tokenApi}")
private String tokenApi;

@RequestMapping("/callback")
public void token(@RequestParam String code, @RequestParam(required = false) String state) {
	RestTemplate restTemplate = new RestTemplate();
//        restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(clientId, clientSecret));// basic 认证

	HttpHeaders headers = new HttpHeaders();
	headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
	headers.setBasicAuth(clientId, clientSecret);

	MultiValueMap<String, String> bodyParams = new LinkedMultiValueMap<>();
	bodyParams.add("grant_type", "authorization_code");
	bodyParams.add("code", code);
	bodyParams.add("state", state);

	ResponseEntity<String> responseEntity = restTemplate.exchange(tokenApi, HttpMethod.POST,
			new HttpEntity<>(bodyParams, headers), String.class);


	System.out.println(responseEntity);
	// TODO 获取 Token 的后续工作……
//        if (responseEntity.getStatusCode().is2xxSuccessful()) {
//            // 处理授权成功的逻辑,例如解析并保存访问令牌和刷新令牌等
//            return "success";
//        } else {
//            // 处理授权失败的逻辑
//            return "error";
//        }
}

具体传参方式是将client_idclient_secret通过 ‘:’ 号拼接,并使用 Base64 进行编码得到字符串。将此编码字符串放到请求头 Authorization 去发送请求。不过借助框架的封装headers.setBasicAuth(clientId, clientSecret),很简单就完成了。这个封装相当于下面的逻辑:

public void requestWithBasic(String clientId, String clientSecret) {
    String clientAndSecret = clientId + ":" + clientSecret;
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
    headers.set("Authorization", "Basic " + StrUtil.base64Encode(clientAndSecret)); // 请求头

    MultiValueMap<String, Object> bodyParams = new LinkedMultiValueMap<>();
    bodyParams.add("grant_type", "client_credentials");

    ResponseEntity<String> responseEntity = restTemplate.exchange(tokenEndPoint, HttpMethod.POST, new HttpEntity<>(bodyParams, headers), String.class);

    if (responseEntity.getStatusCode().is2xxSuccessful()) {

    }
}

好,我们看看正式请求是怎么样子的:

POST op.com//oidc/token
Authorization: Basic cUZFeFZtQlE4blBZOjVjNWVkYjA2OTA2MTZjZGJkNGNmOWMwYjBlMjg3MWVkNjM2MzE2Z
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&state=DJOfvYDSDxaPzOKRoyaT
&code=Z0FBQUFBQm

有文章说要传redirect_uri参数,——我这是在服务器请求,跳转对我没用呀,——不知为何要传呢?而且这个接口已经返回 token 给 RP 了,要跳转也是我 RP 的事情呀。

最后,获取 Token 的后续工作……这里是个服务端的请求,暂且不表,看看 OP 如何颁发 Token 先。

OP 颁发 Token

颁发 Token 是/oidc/token,定义如下:

/**
 * 获取 Token
 *
 * @param authorization client 信息
 * @param code          授权码
 * @param state         不透明字符串
 * @param grantType     授权码流程
 * @return 令牌 Token
 */
@PostMapping("/token")
Result<JwtAccessToken> token(@RequestHeader String authorization, @RequestParam String code, @RequestParam String state, @RequestParam("grant_type") String grantType);

要实现的逻辑如下:

  1. 校验 RP 在请求头authorization字段通过 HTTP Basic 认证传入的client_idclient_secret
  2. 从缓存中,根据 code 获取对应的用户
  3. 如果都校验通过,则生成 access token、id token 并返回

OidcService源码:

在这里插入图片描述

返回 JSON 如下例子:

HTTP/1.1 200 OK
Content-Type: application/json

{
	"state": "DJOfvYDSDxaPzOKRoyaTaQWCoWywdeKU", 
	"scope": "openid profile email address phone", 
	"access_token":"Z0FBQUFBQmVjdWxLcFBWVn", 
	"token_type": "Bearer", 
	"id_token":"eyJhbGciOiJSUzI1NiIsIm……"
}

RP 构建自身的会话状态

RP 构建自身的会话状态(设置一个 cookie,表明用户已在 RP 登录)。找出 state 绑定的 URI ,将用户重定向回登录前最后浏览的页面。

@RequestMapping("/callback")
public ModelAndView token(@RequestParam String code, @RequestParam(required = false) String state,
		HttpSession session) {
//        restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(clientId, clientSecret));// basic 认证

	HttpHeaders headers = new HttpHeaders();
	headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
	headers.setBasicAuth(clientId, clientSecret);

	MultiValueMap<String, String> bodyParams = new LinkedMultiValueMap<>();
	bodyParams.add("grant_type", "authorization_code");
	bodyParams.add("code", code);
	bodyParams.add("state", state);

	ResponseEntity<JwtAccessToken> responseEntity = getRestTemplate().exchange(getTokenApi(), HttpMethod.POST,
			new HttpEntity<>(bodyParams, headers), new ParameterizedTypeReference<JwtAccessToken>() {
			});

//		ResponseEntity<String> responseEntity = getRestTemplate().exchange(getTokenApi(), HttpMethod.POST,
//				new HttpEntity<>(bodyParams, headers), String.class);

	if (responseEntity.getStatusCode().is2xxSuccessful()) {// 处理授权成功的逻辑,例如解析并保存访问令牌和刷新令牌等
		onAccessTokenGot(responseEntity.getBody(), session);

		return new ModelAndView("redirect:/");
	} else {
		System.out.println(responseEntity);
//			 处理授权失败的逻辑
		throw new SecurityException("获取 JWT Token 是吧");
	}
}

@Value("${user.jwtSecretKey}")
private String jwtSecretKey;

@Bean
JWebTokenMgr jWebTokenMgr() {
	JWebTokenMgr mgr = new JWebTokenMgr();
	mgr.setSecretKey(jwtSecretKey);

	return mgr;
}

@Override
public void onAccessTokenGot(JwtAccessToken token, HttpSession session) {
	String idToken = token.getId_token();
	JWebTokenMgr mgr = jWebTokenMgr();
	JWebToken jwt = mgr.parse(idToken);

	if (mgr.isValid(jwt)) {
		User user = new User();
		user.setId(Long.parseLong(jwt.getPayload().getSub()));
		user.setName(jwt.getPayload().getName());
		
		AccessToken accessToken = new AccessToken();
		BeanUtils.copyProperties((AccessToken) token, accessToken);

		user.setAccessToken(accessToken);

		session.setAttribute(UserLogined.USER_IN_SESSION, user);
	} else
		throw new SecurityException("返回非法 JWT Token");
	}

RP 整合 SSO,实际上有 SDK 提供的,是为 aj-iam-client。

获取用户信息

JWT 所包含的用户信息当前只有 userId 和 userName,若要获取用户更多的信息如手机号码的,可以查询用户信息接口。使用 Access Token 在后台向 OP 的用户详情接口GET op.com/userinfo发请求,获取用户详细信息。其中请求头authorization字段使用Bearer关键字传递 Access Token,注意这是 AccessToken 而非 JWT Token。

GET op.com/userinfo
authorization: Bearer Z0FBQUFBQmVjdHFwcW1Xc08ybG9BaG5PRGNJSTR3alFjTjJEcEF4aVl3VDZCMW5OTmFhOXdZe

小结

基本上 SSO 就可以这样跑起来了。但是一个完整 SSO 系统还有很多地方需要去完善,比如 JWT 密钥的交换,用户注销等待——我们下一节再讲!

AJ-IAM 源码:https://gitee.com/sp42_admin/ajaxjs/tree/master/aj-backend/aj-iam

参考

推荐下面文章,我也是跟他们学习的。

  • 基于 OIDC 实现单点登录 SSO、第三方登录 https://blog.csdn.net/u012324798/article/details/105612729 (强烈推荐)
  • 理解 OIDC 与 OAuth2.0 协议 https://blog.csdn.net/ruanchengshen/article/details/129166819,出处。授权码只是其中一个模式,还有其他不同的模式以适应不同的场景。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/140697.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

深度学习之基于YoloV5电梯电动车预警系统

欢迎大家点赞、收藏、关注、评论啦 &#xff0c;由于篇幅有限&#xff0c;只展示了部分核心代码。 文章目录 一项目简介 二、功能三、系统四. 总结 一项目简介 深度学习在电梯电动车预警系统中的应用是一个复杂的系统工程&#xff0c;涉及计算机视觉、机器学习、深度学习等领域…

CLIP:万物分类(视觉语言大模型)

本文来着公众号“AI大道理” ​ 论文地址&#xff1a;https://arxiv.org/abs/2103.00020 传统的分类模型需要先验的定义固定的类别&#xff0c;然后经过CNN提取特征&#xff0c;经过softmax进行分类。然而这种模式有个致命的缺点&#xff0c;那就是想加入新的一类就得重新定义…

14:00面试,14:08就出来了,问的问题有点变态

从小厂出来&#xff0c;没想到在另一家公司又寄了。 到这家公司开始上班&#xff0c;加班是每天必不可少的&#xff0c;看在钱给的比较多的份上&#xff0c;就不太计较了。没想到8月一纸通知&#xff0c;所有人不准加班&#xff0c;加班费不仅没有了&#xff0c;薪资还要降40%,…

【C++】函数指针 ④ ( 函数指针做函数参数 | 使用函数指针间接调用函数 | 函数指针做参数 | 函数指针类型的本质 | 函数指针做参数意义 )

文章目录 一、函数指针做函数参数1、使用函数指针间接调用函数2、函数指针做参数3、函数指针类型的本质4、函数指针做参数意义 二、代码示例 - 函数指针做函数参数 一、函数指针做函数参数 1、使用函数指针间接调用函数 在上一篇博客 【C】函数指针 ③ ( 函数指针语法 | 函数名…

说说React Jsx转换成真实DOM过程?

一、是什么 react通过将组件编写的JSX映射到屏幕,以及组件中的状态发生了变化之后 React会将这些「变化」更新到屏幕上 在前面文章了解中,JSX通过babel最终转化成React.createElement这种形式,例如: <div> < img src="avatar.png" className="…

VS Code设置技巧

基础设置 中文界面 安装扩展&#xff1a;Chinese(Simplified) Language Pack 自动换行 文件 - 首选项 - 设置&#xff0c;搜索wrap&#xff0c;找到Editor: Word Wrap&#xff0c;将其更改为on。

【数据结构】非递归实现二叉树的前 + 中 + 后 + 层序遍历(听说面试会考?)

&#x1f466;个人主页&#xff1a;Weraphael ✍&#x1f3fb;作者简介&#xff1a;目前学习C和算法 ✈️专栏&#xff1a;数据结构 &#x1f40b; 希望大家多多支持&#xff0c;咱一起进步&#xff01;&#x1f601; 如果文章对你有帮助的话 欢迎 评论&#x1f4ac; 点赞&…

如何在OpenWrt上部署uhttpd搭建web服务器,并实现公网远程访问

&#x1f337;&#x1f341; 博主猫头虎&#xff08;&#x1f405;&#x1f43e;&#xff09;带您 Go to New World✨&#x1f341; &#x1f984; 博客首页——&#x1f405;&#x1f43e;猫头虎的博客&#x1f390; &#x1f433; 《面试题大全专栏》 &#x1f995; 文章图文…

数据分析 - 思考题

上班路上刷到的有趣题

BEVFusion简介、环境配置与安装以及遇到的各种报错处理

BEVFusion简介、环境配置与安装以及遇到的各种报错处理 BEVFusion简介BEVFusion环境配置与安装报错解决 BEVFusion简介 针对点云投射到图像的多模态融合和图像投射到点云的多模态融合&#xff0c;前者会损失空间几何信息&#xff0c;后者会损失图像语义信息&#xff0c;这两种…

DMDEM部署说明-详细步骤-(DM8达梦数据库)

DMDEM部署说明-详细步骤-DM8达梦数据库 环境介绍1 部署DM8 数据库1.1 创建一个数据库作为DEM后台数据库1.2 创建数据库用户 DEM1.3 使用DEM用户导入dem_init.sql 2 配置tomcat2.1 配置/tomcat/conf/server.xml2.2 修改jvm启动参数 3 配置JAVA 1.8及以上版本的运行时环境3.1 配置…

【狂神说Java】SpringCloud | Netflix | Eureka | Ribbon | Feign | Zull | config | 详细笔记(全)

✅作者简介&#xff1a;CSDN内容合伙人、信息安全专业在校大学生&#x1f3c6; &#x1f525;系列专栏 &#xff1a;狂神说Java &#x1f4c3;新人博主 &#xff1a;欢迎点赞收藏关注&#xff0c;会回访&#xff01; &#x1f4ac;舞台再大&#xff0c;你不上台&#xff0c;永远…

基于连续Hopfield神经网络优化——旅行商问题优化计算

大家好&#xff0c;我是带我去滑雪&#xff01; 利用神经网络解决组合优化问题是神经网络应用的一个重要方面。所谓组合优化问题&#xff0c;就是在给定约束条件下&#xff0c;使目标函数极小&#xff08;或极大&#xff09;的变量组合问题。将Hopfield网络应用于求解组合优化问…

c++四种类型转换

首先我们要先引入上行转换和下行转换的概念 所谓上行转换&#xff0c;即将原来的子类指针转换成父类指针&#xff1b; 下行转换即将原来的父类指针转换成子类指针 由于子类对象的空间较大&#xff0c;所以把子类强制转换父类给父类指针赋值时&#xff0c;父类指针对象能读取…

Android图形系统之X11、Weston、Wayland、Mesa3D、ANGLE、SwiftShader介绍(十五)

简介&#xff1a; CSDN博客专家&#xff0c;专注Android/Linux系统&#xff0c;分享多mic语音方案、音视频、编解码等技术&#xff0c;与大家一起成长&#xff01; 优质专栏&#xff1a;Audio工程师进阶系列【原创干货持续更新中……】&#x1f680; 人生格言&#xff1a; 人生…

力扣刷题-二叉树-翻转二叉树

226.翻转二叉树 翻转一棵二叉树。 思路 参考&#xff1a; https://www.programmercarl.com/0226.%E7%BF%BB%E8%BD%AC%E4%BA%8C%E5%8F%89%E6%A0%91.html#%E6%80%9D%E8%B7%AF 如果要从整个树来看&#xff0c;翻转还真的挺复杂&#xff0c;整个树以中间分割线进行翻转&#xf…

基于springboot+vue健身管理系统

基于springbootvue健身管理系统 摘要 健身管理系统是一款基于Spring Boot和Vue.js的全栈应用&#xff0c;致力于为用户提供全面、个性化的健身管理体验。通过Spring Boot构建的后端&#xff0c;系统提供了强大的RESTful API支持&#xff0c;包括用户管理、健身计划制定和健康数…

深度学习_12_softmax_图片识别优化版代码

因为图片识别很多代码都包装在d2l库里了&#xff0c;直接调用就行了 完整代码&#xff1a; import torch from torch import nn from d2l import torch as d2l"获取训练集&获取检测集" batch_size 256 train_iter, test_iter d2l.load_data_fashion_mnist(ba…

msvcp71.dll,msvcr71.dll丢失的最简单的解决方法

在计算机使用过程中&#xff0c;我们常常会遇到一些错误提示&#xff0c;其中之一就是MSVCR71.dll缺失。这个问题可能会导致某些应用程序无法正常运行&#xff0c;给用户带来困扰。本文将介绍5个修复MSVCR71.dll缺失的方案&#xff0c;帮助用户解决这一问题。 一、重新安装相关…

2756基于微信小程序的图书商城系统

摘要 本文将详细介绍基于微信小程序的图书商城系统的设计和实现。该系统包括服务器端和客户端两部分&#xff0c;能够满足管理员和普通用户的需求。通过对用户需求和功能的分析&#xff0c;本文将详细阐述系统设计的关键环节&#xff0c;包括数据库设计和界面设计。最后&#…