# Basic access authentication 用户登录在术语中更多的称为用户认证,英文单词以authentication为关键字,也常常被简写为auth。认证的方式有很多种,比如我们常看到的用户名密码认证、手机号验证码认证、使用微信支付宝等第三方快捷认证等。在此,我们仅讲述用户名密码的认证方式。 认证的过程也可以有很多种,比如我们历史上曾经学习过将用户名、密码做为表单数据,以post方式发送给过去,继而完成用户认证。今天我们学习的是另一种更加通用的认证方式:`Basic access authentication`,有时也被简称为`Basic Auth`。 ## Basic Auth Basic Auth,顾名思义其为一种基本的认证模式,它也是最常用的HTTP认证方案。它的基本认证逻辑是:将认证信息放到Http请求的Header部分。 以用户名为`zhangsan`密码为`yunzhi.club`为例,使用Basic Auth认证流程如下: 1. 将用户名密码与`:`相连,接拼为字符串`zhangsan:yunzhi.club`。 2. 使用base64进行加密 `base64(zhangsan:yunzhi.club)`,加密结果为`emhhbmdzYW46eXVuemhpLmNsdWI=`。 3. 在http请求中的headers中增加以下项:`Authorization: Basic emhhbmdzYW46eXVuemhpLmNsdWI=` 4. 向后台发起请求 此时,用户名密码便成功的通过headers以Basic Auth的模式发送给了后台。 > 除最常用的Basic认证外,还有**Bearer**、**Digest**、**HOBA**等认证模式。 ## 后台接口 后台为我们提供了专用的认证地址(实际上并不拘泥于此),接口信息如下: ```bash GET /teacher/login ``` 认证模式:Basic。认证失败将返回状态码401,认证成功将返回用户名密码对应的教师数据。 ## 发起认证 我们来到login组件的`onSubmit`方法,按Basic Auth的步骤逐步完成代码。 ### 自动化 按前面学习的方法,我们可以利用`ng t`来启动组件测试,接着点击登录中的登录按扭,以达到调用`onSubmit`的方法。其我们还可以借助单元测试的思想,写一些自动化的代码,这样当我们每次改动代码并按`ctrl + s`保存文件后,这些代码便会自动执行。在这些自动执行的代码中实现**调用onSubmit**的方法。 是的,我们完全可以参考第一节的内容,使用模块点击V层按钮的方法。除此以外,我们还可以在单元测试代码直接调用组件的方法。为此,我们增加如下代码以协助开发用户登录。 ```typescript +++ b/first-app/src/app/login/login.component.spec.ts @@ -39,4 +39,11 @@ fdescribe('LoginComponent', () => { // 点击按钮以后,onSubmit方法应该被调用了1次。 expect(component.onSubmit).toHaveBeenCalledTimes(1); }); + + it('onSubmit 用户登录', () => { + // 启动自动变更检测 + fixture.autoDetectChanges(); + + component.onSubmit(); + }); }); ``` 使用`ng t`启动,将自动执行本方法: ![image-20210304093344967](https://img.kancloud.cn/48/cd/48cdf278ef99592125cf62b77bf449eb_912x332.png) 如果想仅仅执行当前方法,则可以在`it`前面加入`f`: ```typescript +++ b/first-app/src/app/login/login.component.spec.ts @@ -40,7 +40,7 @@ fdescribe('LoginComponent', () => { expect(component.onSubmit).toHaveBeenCalledTimes(1); }); - it('onSubmit 用户登录', () => { + fit('onSubmit 用户登录', () => { // 启动自动变更检测 fixture.autoDetectChanges(); ``` 此时,单元测试则将仅仅执行当前方法: ![image-20210304093500558](https://img.kancloud.cn/9d/40/9d401b0b0e3e14effcb936606298d47c_880x178.png) 控制台日志如下: ![image-20210304093549368](https://img.kancloud.cn/ca/8d/ca8dba700abc966092f0b29cd6c13491_882x214.png) ### 接拼认证信息 ```typescript +++ b/first-app/src/app/login/login.component.ts @@ -19,5 +19,7 @@ export class LoginComponent implements OnInit { onSubmit(): void { console.log('点击了登录按钮'); + const authString = this.teacher.username + ':' + this.teacher.password; + console.log(authString); }👈 } ``` 控制台信息如下: ![image-20210304093954438](https://img.kancloud.cn/e8/c7/e8c7670826eba3114c522b1798cddfba_778x148.png) 由于初始化的teacher并不存在用户名密码信息,所以最终在控制台打印了`undefined:undefined`。为此,在单元测试代码中,我们为`teacher`设置一个用户名、密码: ```typescript +++ b/first-app/src/app/login/login.component.spec.ts @@ -43,7 +43,7 @@ fdescribe('LoginComponent', () => { fit('onSubmit 用户登录', () => { // 启动自动变更检测 fixture.autoDetectChanges(); - + component.teacher = {username: 'zhangsan', password: 'codedemo.club'}; component.onSubmit(); }); }); ``` ![image-20210304094227330](https://img.kancloud.cn/21/ea/21ea9c2100c1019f630ff7b0453d4162_980x252.png) ### Base64加密 Base64是众多加密算法中的一种,最近也被广泛地用于在浏览器中显示图片。比如你可以将以下代码粘贴到html文件中,在对应的位置上将显示一张图片: ```` <img src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+Cjxzdmcgd2lkdGg9IjE4NHB4IiBoZWlnaHQ9IjIwMHB4IiB2aWV3Qm94PSIwIDAgMTg0IDIwMCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4bWxuczpza2V0Y2g9Imh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaC9ucyI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDMuMi4yICg5OTgzKSAtIGh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaCAtLT4KICAgIDx0aXRsZT5zaGllbGQtbGFyZ2U8L3RpdGxlPgogICAgPGRlc2M+Q3JlYXRlZCB3aXRoIFNrZXRjaC48L2Rlc2M+CiAgICA8ZGVmcz48L2RlZnM+CiAgICA8ZyBpZD0iUGFnZS0xIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBza2V0Y2g6dHlwZT0iTVNQYWdlIj4KICAgICAgICA8ZyBpZD0ic2hpZWxkLWxhcmdlIiBza2V0Y2g6dHlwZT0iTVNBcnRib2FyZEdyb3VwIj4KICAgICAgICAgICAgPGcgaWQ9IkltcG9ydGVkLUxheWVycyIgc2tldGNoOnR5cGU9Ik1TTGF5ZXJHcm91cCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoOC4wMDAwMDAsIDExLjAwMDAwMCkiPgogICAgICAgICAgICAgICAgPHBhdGggZD0iTTgzLjIsMS4wMzI0OTI3NSBMODMuMiwxLjAyODYwMTQ1IEwwLjMwNjgsMzAuNDQ4MTU5NCBMMTIuODk4NiwxMzkuNjczMTgxIEw4Mi45OTk4LDE3OC40NTAwMjkgTDE1NC4wOTgxLDEzOS4xNTA0NDkgTDE2Ny45NTg3LDI5LjkyNjcyNDYgTDgzLjIsMS4wMzI0OTI3NSIgaWQ9IkZpbGwtMSIgZmlsbD0iI0UyMzIzNyIgc2tldGNoOnR5cGU9Ik1TU2hhcGVHcm91cCI+PC9wYXRoPgogICAgICAgICAgICAgICAgPHBhdGggZD0iTTE2Ny44Mzc4LDI5LjkyNjcyNDYgTDgyLjk3MTIsMS4wMzI0OTI3NSBMODIuOTcxMiwxNzguNDUwMDI5IEwxNTQuMDgzOCwxMzkuMTUwNDQ5IEwxNjcuODM3OCwyOS45MjY3MjQ2IEwxNjcuODM3OCwyOS45MjY3MjQ2IFoiIGlkPSJGaWxsLTIiIGZpbGw9IiNCNTJFMzEiIHNrZXRjaDp0eXBlPSJNU1NoYXBlR3JvdXAiPjwvcGF0aD4KICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0wLjE5MjQsMzAuNDQ4MTU5NCBMMTIuODQxNCwxMzkuNjczMTgxIEw4Mi45NzEyLDE3OC40NTAwMjkgTDgyLjk3MTIsMS4wMjg2MDE0NSBMMC4xOTI0LDMwLjQ0ODE1OTQgTDAuMTkyNCwzMC40NDgxNTk0IFoiIGlkPSJGaWxsLTMiIGZpbGw9IiNFMjMyMzciIHNrZXRjaDp0eXBlPSJNU1NoYXBlR3JvdXAiPjwvcGF0aD4KICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0xMDAuNjgzNyw5NC4yMjY2Mzc3IEw4Mi45NzEyLDU3LjcwNTQ0OTMgTDY3LjcyNjEsOTQuMjI2NjM3NyBMMTAwLjY4MzcsOTQuMjI2NjM3NyBMMTAwLjY4MzcsOTQuMjI2NjM3NyBaIE0xMDcuMzY3LDEwOS41ODMwMjIgTDYwLjkwNSwxMDkuNTgzMDIyIEw1MC41MTE1LDEzNS41MjM3NTQgTDMxLjE3NzksMTM1Ljg3OTE1OSBMODIuOTcxMiwyMC44MTA2OTU3IEwxMzYuNjY2NCwxMzUuODc5MTU5IEwxMTguNzQ1OSwxMzUuODc5MTU5IEwxMDcuMzY3LDEwOS41ODMwMjIgTDEwNy4zNjcsMTA5LjU4MzAyMiBaIiBpZD0iRmlsbC00IiBmaWxsPSIjRkZGRkZGIiBza2V0Y2g6dHlwZT0iTVNTaGFwZUdyb3VwIj48L3BhdGg+CiAgICAgICAgICAgIDwvZz4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPg=="> ```` 上面的图片`src`的部分以`data:image/svg+xml;base64`打头,即表示使用了base64算法。简单来讲,base64算法一种在加密时将二进制串转换为ASCII字符串(实际上只选取了部分ASCII),在解密时再将ASCII字符串转换为二制进的加密解密算法。由于http中的header部分只能够携带ASCII编码的字符串,所以在没有base64算法转换之前。将用户名、密码信息放到header中传递,则仅支持英文字符;在base64的帮助下,可以将用户名、密码转换为ASCII字符串,近而可以做为header数据项中传递。 ```typescript +++ b/first-app/src/app/login/login.component.ts @@ -21,5 +21,7 @@ export class LoginComponent implements OnInit { console.log('点击了登录按钮'); const authString = this.teacher.username + ':' + this.teacher.password; console.log(authString); + const authToken = btoa(authString); 👈🏻 + console.log(authToken); } } ``` TypeScript提供了`btoa`函数来快捷的完成加密操作 👈🏻。 ![image-20210304095629649](https://img.kancloud.cn/76/dc/76dc93d9d1ce1f83ac5220978551eea1_782x184.png) ### 请求Header Angular提供了传用的HttpHeaders用于构建请求的header信息: ```typescript +++ b/first-app/src/app/login/login.component.ts -import {HttpHeaders} from '@angular/common/http'; +import {HttpClient, HttpHeaders} from '@angular/common/http'; @@ -24,5 +24,6 @@ export class LoginComponent implements OnInit { console.log(authString); const authToken = btoa(authString); console.log(authToken); + let httpHeaders = new HttpHeaders(); + httpHeaders = httpHeaders.append('Authorization', 'Basic ' + authToken); 👈 } } 注意是`Basic `不是`Basic`, 前一个存在空格 👈 ``` ### 发起请求 然后便可以在httpClient的任意方法中加入此header请求信息: ```typescript +++ b/first-app/src/app/login/login.component.ts @@ -1,5 +1,5 @@ import {Component, OnInit} from '@angular/core'; @Component({ selector: 'app-login', @@ -12,7 +12,7 @@ export class LoginComponent implements OnInit { password: string }; - constructor() { + constructor(private httpClient: HttpClient) { } ngOnInit(): void { @@ -26,5 +26,12 @@ export class LoginComponent implements OnInit { console.log(authToken); let httpHeaders = new HttpHeaders(); httpHeaders = httpHeaders.append('Authorization', 'Basic ' + authToken); + + this.httpClient + .get( + 'http://angular.api.codedemo.club:81/teacher/login', + {headers: httpHeaders}) + .subscribe(teacher => console.log(teacher), + error => console.log('发生错误, 登录失败', error)); } } ``` 此时单元测试中将触发一个错误,相信你现在有足够的能力把它解决掉,解决以后控制台将打印以下信息: ![image-20210305075729153](https://img.kancloud.cn/4a/96/4a96457295a82884f60a750834bac8b7_1428x216.png) 除使用`get`方法外,还可以使用`put`、`post`、`delete`等请求方式,比如: ```typescript this.httpClient.post(url, {}, {headers: httpHeaders}) ``` **注意:**我们的后台每日将清空一次数据,对所有的成员开放后台API,这意味着当前正在有其它的学员进行教师编辑功能的练习。这会使得用户名`zhangsan`处于失效状态(比如有学员将zhangsan改成了zhangsanfeng)。你可以在浏览器中直接访问[http://angular.api.codedemo.club:81/teacher](http://angular.api.codedemo.club:81/teacher)来获取当前有效的用户名信息,系统默认用户的密码均为`codedemo.club` 。 ## 充分的测试 一个优秀的项目离不开充分的测试,测试是保障软件质量最重要的一环,没有之一。在测试中,我们需要充分的站在用户的角度,根据自己的经验努力思索用户在实际使用过程中可能会出现的情景,然后一一把它们模拟出来。 ### 用户名密码错误 前面我们仅验证了用户名、密码正确的情况。在实际的使用过程中显然这是不够的。而用户名、密码错误时是否是按我们的预期发起的呢?与其猜、想、看、盯,不如实际用代码测试一下: ```typescript +++ b/first-app/src/app/login/login.component.spec.ts @@ -45,7 +45,7 @@ fdescribe('LoginComponent', () => { fit('onSubmit 用户登录', () => { // 启动自动变更检测 fixture.autoDetectChanges(); - component.teacher = {username: 'zhangsan', password: 'codedemo.club'}; + component.teacher = {username: 'notzhangsan', password: 'codedemo.club'}; component.onSubmit(); }); }); ``` ![image-20210305080512651](https://img.kancloud.cn/c7/22/c7224d680a59a6682607d4bba48b22b1_1490x178.png) 再验证一下密码错误的情况: ```typescript +++ b/first-app/src/app/login/login.component.spec.ts @@ -45,7 +45,7 @@ fdescribe('LoginComponent', () => { fit('onSubmit 用户登录', () => { // 启用自动变更检测 fixture.autoDetectChanges(); - component.teacher = {username: 'notzhangsan', password: 'codedemo.club'}; + component.teacher = {username: 'zhangsan', password: 'password'}; component.onSubmit(); }); }); ``` ![image-20210305080512651](https://img.kancloud.cn/c7/22/c7224d680a59a6682607d4bba48b22b1_1490x178.png) ### 中文用户名密码 虽然我个人没有将中文做为用户名密码的习惯,但是部分用户的确有这个需求,那么我们当前代码是否能够很好的处理这种情况呢? ```typescript +++ b/first-app/src/app/login/login.component.spec.ts @@ -45,7 +45,7 @@ fdescribe('LoginComponent', () => { fit('onSubmit 用户登录', () => { // 启动自动变更检测 fixture.autoDetectChanges(); - component.teacher = {username: 'zhangsan', password: 'password'}; + component.teacher = {username: '中文用户名', password: 'codedemo.club'}; component.onSubmit(); }); }); ``` ![image-20210305080955464](https://img.kancloud.cn/cf/10/cf10057c35ba2a758f6c26b9dfaf2bfd_1082x116.png) 我们得到了一个错误,该错误表明当前代码在处理中文用户名时会发生异常。那么处理中文密码是否会发生异常呢,请先给出自己的答案后验证。 当前控制台信息如下: ![image-20210305081630515](https://img.kancloud.cn/85/23/8523b0bffc7759444e03db58377f1e57_580x138.png) 由以上信息我们能够得出,上述异常发生在`btoa()`方法上,用以下关键字来搜索问题,我们可以快速的找到问题的原因及解决方案: ![image-20210305081814242](https://img.kancloud.cn/13/c1/13c1883d70e22a62632c7185f577f66c_1068x150.png) 搜索结果为我们指引到了[https://stackoverflow.com/questions/30106476/using-javascripts-atob-to-decode-base64-doesnt-properly-decode-utf-8-strings](https://stackoverflow.com/questions/30106476/using-javascripts-atob-to-decode-base64-doesnt-properly-decode-utf-8-strings)一文,该文的回答又为我们指引到了权威的[https://developer.mozilla.org/en-US/docs/Glossary/Base64](https://developer.mozilla.org/en-US/docs/Glossary/Base64)在此文章中,有一行note是这么说的: ``` Note that btoa() expects to be passed binary data, and will throw an exception if the given string contains any characters whose UTF-16 representation occupies more than one byte. For more details, see the documentation for btoa(). ``` 上面大概是说: ``` 注意btoa()方法只能传入二进制数据,如果传入的参数中包含任何UTF-16的大于1个字节的字符串,将会触发异常。 ``` 我们接着点击函数名,查看详情[https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/btoa](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/btoa),该文中又有如下的描述: ``` The btoa() function takes a JavaScript string as a parameter. In JavaScript strings are represented using the UTF-16 character encoding: in this encoding, strings are represented as a sequence of 16-bit (2 byte) units. Every ASCII character fits into the first byte of one of these units, but many other characters don't. ``` 简单翻译下我们大概明白了,原来btoa只能接收以1个字节的字符组成的字符串。而JavaScript的string是用UTF-16来编码的,该编码占用了2个字节。每个ASCII编码的字符都可以用首单元的字节来代码,但是其它的字符就不是了(言外之意,其它字符就是2个字节了)。 这就需要我们在C语言、数据结构、计算机组成原理等基础课程中学习过的ASCII编码了。ASCII编码中,0 - 127分别代表一个字符,共128个。占用了一个字节的后7位,为:`0000 0000` 至 `0111 1111`。所以每个ASCII编码的字符,必然可以用一个字节来表示。 在[WindowOrWorkerGlobalScope.btoa()](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/btoa)一文中,我们还可以找到相应测试的示例代码: ```typescript const ok = "a"; console.log(ok.codePointAt(0).toString(16)); // 61: occupies < 1 byte const notOK = "✓" console.log(notOK.codePointAt(0).toString(16)); // 2713: occupies > 1 byte console.log(btoa(ok)); // YQ== console.log(btoa(notOK)); // error ``` 我们当然也可以用中文来做下实验: ![image-20210305083642444](https://img.kancloud.cn/1f/df/1fdf6993d0a663c38498fbe0f9d68518_1156x284.png) 如上所示`zhangsan`中的首字母`z`,转换为10进制后值为`122`,该值位于`0-255`之间,占用一个字节。当然了,实际上我们完全可以在ASSCI编码表中找到字母`z`的编码: ![image-20210305083829485](https://img.kancloud.cn/d6/ee/d6eebdf7320baa5b521d530a6c8574b6_544x132.png) 继续测试中文的`张`: ![image-20210305084048576](https://img.kancloud.cn/f0/87/f0871b575190c26f949215728b945a7e_1264x536.png) 上述代码分别将`张`转换为10 16 2进制,我们能够由16进制的`5f20`快速的得出`张`占用了两个字节,实际上我们还可以在字符[编码相关的站点](https://www.fileformat.info/info/unicode/char/5f20/index.htm)上来快速的找到`张`的utf编码。 ![image-20210305085125647](https://img.kancloud.cn/07/04/0704afa089201556f1130700825760f5_1002x158.png) 错误的原因找到了,解决问题的重点便在于如何将UTF-16中占2个字节的编码变换为变1个字节的ASSCI。 ### 单元大小为1字节的字符串 mozilla给出了如何将多字节字符组成的字符串变为1个字符组成的字符串的方案: ```javascript // convert a Unicode string to a string in which // each 16-bit unit occupies only one byte function toBinary(string) { const codeUnits = new Uint16Array(string.length); for (let i = 0; i < codeUnits.length; i++) { codeUnits[i] = string.charCodeAt(i); } return String.fromCharCode(...new Uint8Array(codeUnits.buffer)); } ``` 参数上述代码,建立转换方法如下: ### encodeURI 既然已经扩展到此程度了,我们不防再多扩展一下。其实我们早早的就接触到了这种将非ASSCI编码转换为ASSCI编码的方案。以我们用的百度翻译(类似的例子有很多,基本上涉及到查询都会有)为例: ![image-20210305085523472](https://img.kancloud.cn/23/6d/236dec393f39033c7419e189cac8ad66_1618x516.png) 请跟随教程打开翻译,然后查询一个`你好`,请注意当前的URL。接下来,我们复制这个URL,然后再粘贴到任意的位置,你将得到如下链接: ``` https://fanyi.baidu.com/#zh/en/%E4%BD%A0%E5%A5%BD ``` 如果你在浏览器中打开[https://fanyi.baidu.com/#zh/en/%E4%BD%A0%E5%A5%BD](https://fanyi.baidu.com/#zh/en/%E4%BD%A0%E5%A5%BD),同样可以正常访问显示为你好。 将这个`你好`变更为`%E4%BD%A0%E5%A5%BD`的过程称为`encodeURI`,表示对URI进行编码。目的是适用于http协议中非主体部分只支持ASSCI编码的规则。`encodeURIComponent`函数则可以实现此功能。 ```typescript +++ b/first-app/src/app/login/login.component.ts @@ -20,7 +20,7 @@ export class LoginComponent implements OnInit { onSubmit(): void { console.log('点击了登录按钮'); - const authString = this.teacher.username + ':' + this.teacher.password; + const authString = encodeURIComponent(this.teacher.username) + ':' + this.teacher.password; console.log(authString); const authToken = btoa(authString); console.log(authToken); ``` ![image-20210305095434083](https://img.kancloud.cn/b7/c8/b7c8b84a8d9f04553fccaa9e5a87dd05_800x120.png) ## 本节作业 1. 一个项目前后台是统一的整体,我们刚刚在传送用户名密码时增加了encodeURI转码,那么后台是否也支持这种方式呢?请新创建一个新教师并使用`codedemo.club`做为用户名尝试登录。 2. 如果我们想使密码也支持中文的话该怎么办呢? 3. 我们往往怕的是修改好了一个新功能,同时却改坏了两个老功能。中文用户名的问题解决了,那么是否还支持英文登录呢?请测试。 4. 请思索:在团队开发中,如何保证你已有的功能不被其它团队成员误杀。 | 名称 | 地址 | 备注 | | ---------------------- | ------------------------------------------------------------ | ---- | | Http身份认证 | [https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Authentication](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Authentication) | | | RFC7617 Basic认证 | [https://tools.ietf.org/html/rfc7617](https://tools.ietf.org/html/rfc7617) | | | Base64的编码与解码 | [https://developer.mozilla.org/zh-CN/docs/Glossary/Base64](https://developer.mozilla.org/zh-CN/docs/Glossary/Base64) | | | 一个查询字符编码的网站 | [https://www.fileformat.info/info/unicode/char/68a6/index.htm](https://www.fileformat.info/info/unicode/char/68a6/index.htm) | | | ASCII | [https://zh.wikipedia.org/wiki/ASCII](https://zh.wikipedia.org/wiki/ASCII) | | | 本节源码 | [https://github.com/mengyunzhi/angular11-guild/archive/step3.3.zip](https://github.com/mengyunzhi/angular11-guild/archive/step3.3.zip) | |