本节开始时以**一卡通**为例进行认证的简单讲解。在使用**一卡通**时关键的一环在于:**一卡通**与学生的绑定及解绑。
## 用户绑定
用户绑定实际上是建立一种映射关系。在校园生活中它是一种**一卡通**与**学生**的映射关系,由学生的学号具有唯一性,所以还可以说是一种**一卡通**与**学号**的映射关系;在前后端分离的应用中它是一种**auth-token**与数据表中**teacher**的映射关系,由于teacher的id具有唯一性,所以还可以说是一种**auth-token**与**teacherId**之间的映射关系。
在java的世界里HashMap这种数据类型便是为了解决此类的映射问题而生的。下面开始打开后台来到TeacherController的Login方法查看其实现的逻辑,并对其调用的服务层进行升级。以使得服务层提供:当用户名与密码校验成功后,对应的建立**auth-token**与**teacherId**之间的关系,从而达到用户绑定的目的。
controller/TeacherController.java
```java
@PostMapping("login")
public boolean login(@RequestBody Teacher teacher) {
return this.teacherService.login(teacher.getUsername(), teacher.getPassword());
}
```
查看C层调用获取知其调用的是M层的login方法,于是找到对应的service/TeacherServiceImpl.java对应方法
service/TeacherServiceImpl.java
```java
@Override
public boolean login(String username, String password) {
Teacher teacher = this.teacherRepository.findByUsername(username);
return this.validatePassword(teacher, password);
}
```
升级如下:
```java
@Service
public class TeacherServiceImpl implements TeacherService {
/** auth-token与teacherId的映射 */
private HashMap<String, Long> authTokenTeacherIdHashMap = new HashMap<>(); ➊
...
@Override
public boolean login(String username, String password) {
Teacher teacher = this.teacherRepository.findByUsername(username);
if (!this.validatePassword(teacher, password)) {
// 认证不成功直接返回
return false;
}
// 认证成功,进行auth-token与teacherId的绑定绑定
return true;
}
```
* ➊ HashMap理解为键值对的存储类型。HashMap<String, Long>表示键的类型为String,值的类型为Long。存储示例:`{"abcd" -> 123, "bcd" -> 456}`。
向hashMap中存数据使用`put`方法,比如预将相应的`auth-token`与`teacherId`进行绑定,代码如下:
```java
...
// 认证成功,进行auth-token与teacherId的绑定绑定
this.authTokenTeacherIdHashMap.put("header中auth-token的值★"➊, teacher.getId()➋);
return true;
}
```
* put(键, 值);
* ➊ 类型是String,表示键
* ➋ 类型为Long,表示值
## 获取header
认证的代码准备好后,是否可以在TeacherServiceImpl中获取header中的auth-token的值成为了是否能够成功完成认证功能的关键因素。回想下前面在过滤器中已经成功的获取过header中的token信息:
filter/TokenFilter.java
```java
String token = request.getHeader(this.TOKEN_KEY);
```
观察上述的代码发现只要能够获取相应的request,便能够获取对应的token。在spring中,可以像注入其它的服务一样注入HttpServletRequest:
service/TeacherServiceImpl.java
```java
public class TeacherServiceImpl implements TeacherService {
...
private final➊ HttpServletRequest request;
private TeacherRepository teacherRepository;
...
@Autowired
public TeacherServiceImpl(TeacherRepository teacherRepository, HttpServletRequest➋ request) {
this.teacherRepository = teacherRepository;
this.request = request;
}
```
* ➊ 使用final关键字声明该变量不可变,这有一些当前还看不到的优点
* ➋ 将HttpServletRequest声明到以@Autowired注解的构造函数中。spring会自动将需要的HttpServletRequest注入
有了request后获取header是一件非常轻松的事情。
service/TeacherServiceImpl.java
```java
...
// 认证成功,进行auth-token与teacherId的绑定绑定
this.authTokenTeacherIdHashMap.put(this.request.getHeader("auth-token"), teacher.getId());
return true;
}
```
### 测试一下
service/TeacherServiceImpl.java
```java
public class TeacherServiceImpl implements TeacherService {
private final static Logger logger = LoggerFactory.getLogger(TeacherServiceImpl.class);
...
// 认证成功,进行auth-token与teacherId的绑定绑定
logger.info("获取到的auth-token为" + this.request.getHeader("auth-token"));
this.authTokenTeacherIdHashMap.put(this.request.getHeader("auth-token"), teacher.getId());
return true;
}
```
尝试启动后台后得到如下错误:
```
Error:(21, 45) java: 无法将类 com.mengyunzhi.springbootstudy.service.TeacherServiceImpl中的构造器 TeacherServiceImpl应用到给定类型;
需要: com.mengyunzhi.springbootstudy.repository.TeacherRepository,javax.servlet.http.HttpServletRequest
找到: com.mengyunzhi.springbootstudy.repository.TeacherRepository
原因: 实际参数列表和形式参数列表长度不同
```
提示说:单元测试文件实例化TeacherServiceImpl时,仅接收到了一个参数,还差一个。这是由于在构造函数中加入了新的`HttpServletRequest`造成的,对应修正如下:
serivce/TeacherServiceImplTest.java
```java
public class TeacherServiceImplTest {
private TeacherServiceImpl teacherService;
private TeacherRepository teacherRepository;
private HttpServletRequest httpServletRequest; ✚
@Before
public void before() {
this.teacherRepository = Mockito.mock(TeacherRepository.class);
this.httpServletRequest = Mockito.mock(HttpServletRequest.class); ✚
TeacherServiceImpl teacherService = new TeacherServiceImpl(this.teacherRepository, this.httpServletRequest✚);
this.teacherService = Mockito.spy(teacherService);
}
```
再次尝试运行后台成功。在数据库中维护一个测试教师,并在IDEA中新建http request如下:
```
POST http://localhost:8080/Teacher/login
Content-type: application/json;
{"username": "panjie", "password":"yunzhi"}
```
请求结果:
```
POST http://localhost:8080/Teacher/login
HTTP/1.1 200
auth-token: 0076f815-2b8b-4c66-8df0-48fac5a4104e
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Mon, 17 Feb 2020 10:02:14 GMT
true
Response code: 200; Time: 159ms; Content length: 4 bytes
```
日志情况:
```
2020-02-17 18:02:13.903 INFO 59059 --- [nio-8080-exec-4] c.m.springbootstudy.filter.TokenFilter : 原token无效,发布的新的token为0076f815-2b8b-4c66-8df0-48fac5a4104e
...
2020-02-17 18:02:14.056 INFO 59059 --- [nio-8080-exec-4] c.m.s.service.TeacherServiceImpl : 获取到的auth-token为0076f815-2b8b-4c66-8df0-48fac5a4104e
```
进行用户绑定时,过滤器首先为没有推带auth-token的请求分发auth-token。login方法随后将其分发的auth-token与当前的认证用户进行绑定。符合预期。
## 用户解绑
用户解绑的流程与用户绑定流程稍有不同,体现在:
一、用户绑定时需要首先验证用户名密码是否正确,只有当用户名密码正确的时候,才进行绑定。而用户解除绑定不需要,只要用户携带了分发给他的**一卡通**,后台便能够对应的解除**一卡通**与原有用户的绑定。
二、用户绑定时使用的是hashMap的put方法先添加String及Long的映射,而解绑中对应使用hashMap的remove方法。
与login方法相对应,解绑方法命名为logout:
service/TeacherService.java
```java
/**
* 用户注销
* 系统可以根据HttpServletRequest获取到header中的令牌令牌
* 所以注销方法不需要传入任何参数
*/
void logout();
```
实现类:
service/TeacherServiceImpl.java
```java
@Override
public void logout() {
// 获取auth-token
// 删除hashMap中对应auth-token的映射
}
```
补充代码:
service/TeacherServiceImpl.java
```java
@Override
public void logout() {
// 获取auth-token
String authToken = this.request.getHeader("auth-token");
logger.info("获取到的auth-token为" + this.request.getHeader("auth-token"));
// 删除hashMap中对应auth-token的映射
this.authTokenTeacherIdHashMap.remove(authToken);
}
```
### C层
增加数据转发的C层,为下一步的测试做准备:
controller/TeacherController.java
```java
@GetMapping("logout")
public void login() {
this.teacherService.logout();
}
```
### 测试
成功测试解除绑定方法的前提先进行用户的绑定。用户绑定成功后,再使用其对应的**auth-token**来进行解绑操作的测试。
重新启动后台,先发起用户绑定请求,获取对应的auth-token,请求结果如下:
```java
POST http://localhost:8080/Teacher/login
HTTP/1.1 200
auth-token: 4048387e-3d87-4553-862e-37fb2c2a81cf ★
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Tue, 18 Feb 2020 01:19:41 GMT
true
```
* ★ 记录后台下发的auth-token
接着使用得到的auth-token发起解绑操作:
```
GET http://localhost:8080/Teacher/logout
auth-token: 4048387e-3d87-4553-862e-37fb2c2a81cf
```
响应信息:
```
GET http://localhost:8080/Teacher/logout
HTTP/1.1 200
auth-token: 4048387e-3d87-4553-862e-37fb2c2a81cf
Content-Length: 0
Date: Tue, 18 Feb 2020 01:25:07 GMT
<Response body is empty>
```
结果返回了200,说明响应信息正常。
## 我是谁
虽然已经成功的完成了用户绑定与解绑功能。看总感觉心里面少点什么,原因是当前的测试仅仅能够证明绑定与解绑两个操作是可用的,但是否真正的完成用户绑定与解绑的实际工作却不得而知(通过观察源代码来判断功能是否正常是最不负责的行为)。若想验证绑定与解绑是否成功,则还需要一个"我是谁"的接口来进行验证。
* 在进行用户绑定前,访问"我是谁"接口应该获取到null信息,表示:当前没有进行用户认证,所以你谁也是不是。
* 用户绑定成功后,访问"我是谁"接口应该返回绑定的用户信息。
* 重新使用其它用户绑定后,访问"我是谁"接口应该返回刚刚绑定的用户
* 解除绑定后,访问"我是谁"接口应该返回null信息
service/TeacherService.java
```java
/**
* 我是谁
* @return 当前登录用户。用户未登录则返回null
*/
Teacher me();
```
实现类:
service/TeacherServiceImpl.java
```java
@Override
public Teacher me() {
// 获取authToken
// 获取authToken映射的teacherId
// 未获取到teacherId,说明该auth-token未与用户进行绑定,返回null
// 如获取到teacherId,则由数据库中获取teacher并返回
}
```
补充功能性代码:
service/TeacherServiceImpl.java
```java
@Override
public Teacher me() {
// 获取authToken
String authToken = this.request.getHeader("auth-token");
// 获取authToken映射的teacherId
Long teacherId = this.authTokenTeacherIdHashMap.get(authToken);
if (teacherId == null) {
// 未获取到teacherId,说明该auth-token未与用户进行绑定,返回null
return null;
}
// 如获取到teacherId,则由数据库中获取teacher并返回
Optional<Teacher> teacherOptional = this.teacherRepository.findById(teacherId);
return teacherOptional.get();
}
```
### C层数据转发
controller/TeacherController.java
```java
@GetMapping("me")
public Teacher me() {
return this.teacherService.me();
}
```
### 测试
重新启动后台后依次做如下测试:
<hr>
测试一:在进行用户绑定前,访问"我是谁"接口应该获取到null信息,表示:当前没有进行用户认证,所以你谁也是不是。
```
GET http://localhost:8080/Teacher/me
```
请求结果:未返回任何信息。
```
GET http://localhost:8080/Teacher/me
HTTP/1.1 200
auth-token: 8f03f582-4284-4b4e-85f2-258f30f016c0 ★
Content-Length: 0
Date: Tue, 18 Feb 2020 01:43:46 GMT
<Response body is empty>
```
* ★ 记录分发的令牌,在以后的测试请求中均使用该令牌
<hr>
测试二:用户绑定成功后,访问"我是谁"接口应该返回绑定的用户信息。
先访问login接口,然后再请求me接口
login
```
POST http://localhost:8080/Teacher/login
Content-type: application/json;
auth-token: 8f03f582-4284-4b4e-85f2-258f30f016c0 ➊
{"username": "panjie", "password":"yunzhi"}
```
* ➊ 使用上一步接收到的令牌发起访问。
返回信息:
```
POST http://localhost:8080/Teacher/login
HTTP/1.1 200
auth-token: 8f03f582-4284-4b4e-85f2-258f30f016c0 ➊
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Tue, 18 Feb 2020 01:46:07 GMT
true ➋
```
* ➊ 返回了原令牌,符合预期
* ➋ 返回结果为true,说明登录成功符合预期
发起对me接口的请求:
```
GET http://localhost:8080/Teacher/me
auth-token: 8f03f582-4284-4b4e-85f2-258f30f016c0
```
请求结果:
```
GET http://localhost:8080/Teacher/me
HTTP/1.1 200
auth-token: 8f03f582-4284-4b4e-85f2-258f30f016c0
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Tue, 18 Feb 2020 01:48:19 GMT
{
"id": 1,
"name": null,
"sex": true,
"username": "panjie",
"email": null,
"createTime": null,
"updateTime": null,
"password": "yunzhi"
}
```
返回了登录用户的信息,符合预期。
<hr>
测试三: 重新使用其它用户绑定后,访问"我是谁"接口应该返回刚刚绑定的用户
在数据库中新建测试教师:用户名liuyuxuan ,密码:yunzhi,然后访问login接口发起认证。
```
POST http://localhost:8080/Teacher/login
Content-type: application/json;
auth-token: 8f03f582-4284-4b4e-85f2-258f30f016c0
{"username": "liuyuxuan", "password":"yunzhi"}
```
接着访问me接口,验证是否重新绑定成功。
```
GET http://localhost:8080/Teacher/me
auth-token: 8f03f582-4284-4b4e-85f2-258f30f016c0
```
响应结果:
```
GET http://localhost:8080/Teacher/me
HTTP/1.1 200
auth-token: 8f03f582-4284-4b4e-85f2-258f30f016c0
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Tue, 18 Feb 2020 01:51:59 GMT
{
"id": 2,
"name": null,
"sex": true,
"username": "liuyuxuan", ➊
"email": null,
"createTime": null,
"updateTime": null,
"password": "yunzhi"
}
```
* ➊ 当前登录用户名由panjie变更为liuyuxuan,说明重新绑定成功
<hr>
测试四:解除绑定后,访问"我是谁"接口应该返回null信息。
首先发起logout接口访问:
```
GET http://localhost:8080/Teacher/logout
auth-token: 8f03f582-4284-4b4e-85f2-258f30f016c0
```
响应信息:
```
GET http://localhost:8080/Teacher/logout
HTTP/1.1 200
auth-token: 8f03f582-4284-4b4e-85f2-258f30f016c0
Content-Length: 0
Date: Tue, 18 Feb 2020 01:53:35 GMT
<Response body is empty>
```
再发起me接口访问:
```
GET http://localhost:8080/Teacher/me
auth-token: 8f03f582-4284-4b4e-85f2-258f30f016c0
```
响应信息为空,说明解除绑定成功。
```
GET http://localhost:8080/Teacher/me
HTTP/1.1 200
auth-token: 8f03f582-4284-4b4e-85f2-258f30f016c0
Content-Length: 0
Date: Tue, 18 Feb 2020 01:54:05 GMT
<Response body is empty>
```
至此一个基本的登录、注销、我是谁的功能开发完毕。
## 重构代码
重构代码是提高代码质量的重要一环,好的代码讲求的是:易阅读、易维护、对扩展开放、对修改关闭。本例中在获取认证令牌时,使用了大量的字符串`auth-token`。这为日志修改该认识关键字挖下了坑:我们希望在日后的维护过程中,可以很轻松的修改该字符串,比如将`auth-token`修正为`x-auth=token`、`web-auth-token`或`app-auth-token`等。而大量字符串出现在源代码中将要求以后完成该字符串修改工作的成员对项目极其熟悉,以致于不会漏掉任何一个`auth-token`(虽然这可以使用编辑器的查找替换工作完成,但此时我们更关注是一种编程的习惯)。
解决该问题的方法很简单:`auth-token`在整个项目中只出现一次,只为其它使用到该值的地方,全部引用该变量。比如本项目中`auth-token`首次出现在过滤器`TokenFilter`中,则在`TeacherServiceImpl`中所有使用字符串`auth-token`地方,均应该使用`TokenFilter`中的`auth-token`。为此,首先将`TokenFilter`中记录`auth-token`的属性声明为公有静态的,然后在`TeacherServiceImpl`中引用其值。
filter/TokenFilter.java
```java
public class TokenFilter extends HttpFilter {
private String TOKEN_KEY = "auth-token"; ✘
public static String TOKEN_KEY = "auth-token"; ✚
...
String token = request.getHeader(this.TOKEN_KEY); ✘
String token = request.getHeader(TOKEN_KEY); ✚
```
然后将`TeacherServiceImpl`所有使用`auth-token`字符串的地方替换为`TokenFilter.TOKEN_KEY`,比如:
service/TeacherServiceImpl.java
```java
String authToken = this.request.getHeader(TokenFilter.TOKEN_KEY);
```
# 参考文档
| 名称 | 链接 | 预计学习时长(分) |
| --- | --- | --- |
| 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step5.2.6](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step5.2.6) | - |
- 序言
- 第一章:Hello World
- 第一节:Angular准备工作
- 1 Node.js
- 2 npm
- 3 WebStorm
- 第二节:Hello Angular
- 第三节:Spring Boot准备工作
- 1 JDK
- 2 MAVEN
- 3 IDEA
- 第四节:Hello Spring Boot
- 1 Spring Initializr
- 2 Hello Spring Boot!
- 3 maven国内源配置
- 4 package与import
- 第五节:Hello Spring Boot + Angular
- 1 依赖注入【前】
- 2 HttpClient获取数据【前】
- 3 数据绑定【前】
- 4 回调函数【选学】
- 第二章 教师管理
- 第一节 数据库初始化
- 第二节 CRUD之R查数据
- 1 原型初始化【前】
- 2 连接数据库【后】
- 3 使用JDBC读取数据【后】
- 4 前后台对接
- 5 ng-if【前】
- 6 日期管道【前】
- 第三节 CRUD之C增数据
- 1 新建组件并映射路由【前】
- 2 模板驱动表单【前】
- 3 httpClient post请求【前】
- 4 保存数据【后】
- 5 组件间调用【前】
- 第四节 CRUD之U改数据
- 1 路由参数【前】
- 2 请求映射【后】
- 3 前后台对接【前】
- 4 更新数据【前】
- 5 更新某个教师【后】
- 6 路由器链接【前】
- 7 观察者模式【前】
- 第五节 CRUD之D删数据
- 1 绑定到用户输入事件【前】
- 2 删除某个教师【后】
- 第六节 代码重构
- 1 文件夹化【前】
- 2 优化交互体验【前】
- 3 相对与绝对地址【前】
- 第三章 班级管理
- 第一节 JPA初始化数据表
- 第二节 班级列表
- 1 新建模块【前】
- 2 初识单元测试【前】
- 3 初始化原型【前】
- 4 面向对象【前】
- 5 测试HTTP请求【前】
- 6 测试INPUT【前】
- 7 测试BUTTON【前】
- 8 @RequestParam【后】
- 9 Repository【后】
- 10 前后台对接【前】
- 第三节 新增班级
- 1 初始化【前】
- 2 响应式表单【前】
- 3 测试POST请求【前】
- 4 JPA插入数据【后】
- 5 单元测试【后】
- 6 惰性加载【前】
- 7 对接【前】
- 第四节 编辑班级
- 1 FormGroup【前】
- 2 x、[x]、{{x}}与(x)【前】
- 3 模拟路由服务【前】
- 4 测试间谍spy【前】
- 5 使用JPA更新数据【后】
- 6 分层开发【后】
- 7 前后台对接
- 8 深入imports【前】
- 9 深入exports【前】
- 第五节 选择教师组件
- 1 初始化【前】
- 2 动态数据绑定【前】
- 3 初识泛型
- 4 @Output()【前】
- 5 @Input()【前】
- 6 再识单元测试【前】
- 7 其它问题
- 第六节 删除班级
- 1 TDD【前】
- 2 TDD【后】
- 3 前后台对接
- 第四章 学生管理
- 第一节 引入Bootstrap【前】
- 第二节 NAV导航组件【前】
- 1 初始化
- 2 Bootstrap格式化
- 3 RouterLinkActive
- 第三节 footer组件【前】
- 第四节 欢迎界面【前】
- 第五节 新增学生
- 1 初始化【前】
- 2 选择班级组件【前】
- 3 复用选择组件【前】
- 4 完善功能【前】
- 5 MVC【前】
- 6 非NULL校验【后】
- 7 唯一性校验【后】
- 8 @PrePersist【后】
- 9 CM层开发【后】
- 10 集成测试
- 第六节 学生列表
- 1 分页【后】
- 2 HashMap与LinkedHashMap
- 3 初识综合查询【后】
- 4 综合查询进阶【后】
- 5 小试综合查询【后】
- 6 初始化【前】
- 7 M层【前】
- 8 单元测试与分页【前】
- 9 单选与多选【前】
- 10 集成测试
- 第七节 编辑学生
- 1 初始化【前】
- 2 嵌套组件测试【前】
- 3 功能开发【前】
- 4 JsonPath【后】
- 5 spyOn【后】
- 6 集成测试
- 7 @Input 异步传值【前】
- 8 值传递与引入传递
- 9 @PreUpdate【后】
- 10 表单验证【前】
- 第八节 删除学生
- 1 CSS选择器【前】
- 2 confirm【前】
- 3 功能开发与测试【后】
- 4 集成测试
- 5 定制提示框【前】
- 6 引入图标库【前】
- 第九节 集成测试
- 第五章 登录与注销
- 第一节:普通登录
- 1 原型【前】
- 2 功能设计【前】
- 3 功能设计【后】
- 4 应用登录组件【前】
- 5 注销【前】
- 6 保留登录状态【前】
- 第二节:你是谁
- 1 过滤器【后】
- 2 令牌机制【后】
- 3 装饰器模式【后】
- 4 拦截器【前】
- 5 RxJS操作符【前】
- 6 用户登录与注销【后】
- 7 个人中心【前】
- 8 拦截器【后】
- 9 集成测试
- 10 单例模式
- 第六章 课程管理
- 第一节 新增课程
- 1 初始化【前】
- 2 嵌套组件测试【前】
- 3 async管道【前】
- 4 优雅的测试【前】
- 5 功能开发【前】
- 6 实体监听器【后】
- 7 @ManyToMany【后】
- 8 集成测试【前】
- 9 异步验证器【前】
- 10 详解CORS【前】
- 第二节 课程列表
- 第三节 果断
- 1 初始化【前】
- 2 分页组件【前】
- 2 分页组件【前】
- 3 综合查询【前】
- 4 综合查询【后】
- 4 综合查询【后】
- 第节 班级列表
- 第节 教师列表
- 第节 编辑课程
- TODO返回机制【前】
- 4 弹出框组件【前】
- 5 多路由出口【前】
- 第节 删除课程
- 第七章 权限管理
- 第一节 AOP
- 总结
- 开发规范
- 备用