本节开始时以**一卡通**为例进行认证的简单讲解。在使用**一卡通**时关键的一环在于:**一卡通**与学生的绑定及解绑。 ## 用户绑定 用户绑定实际上是建立一种映射关系。在校园生活中它是一种**一卡通**与**学生**的映射关系,由学生的学号具有唯一性,所以还可以说是一种**一卡通**与**学号**的映射关系;在前后端分离的应用中它是一种**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) | - |