源代码在GitHub - 629y/course: Spring Cloud + Vue前后端分离-在线课程
1.用户管理与登录:用户表设计与基本代码生成
all.sql
generatorConfig.xml
ServerGenerator.java
VueGenerator.java
admin.vue
router.js
测试
1.用户管理与登录:增加用户名是否已存在校验
2.增加自定义业务异常
登录名是不可编辑的,登录名一般会跟其它表有关联,一旦登录名改了,这些关联信息就没有了
UserService.java
根据传入登录名到数据库中查找是否有记录,有记录就说明用户名已经存在
loginName 是唯一的,所以查出来要么没有记录,要么只有一条记录
我们要的功能是校验用户名是否存在,所以也可以把返回值改成true或false,而不是返回User对象,但是这种写法不够通用,所以我们选择返回User。
BusinessException.java
抛出业务异常时,不打印堆栈信息,一方面是提高性能,另一方面是没有业务异常没必要看堆栈信息
继承RuntimeException的一个好处就是代码不需要捕获,如果是直接继承Exception,代码需要捕获,否则编译不通过。
BusinessExceptionCode.java
大家也可以自己把code加上,定义一套异常码
ControllerExceptionHandler.java
user.vue
aria-*的属性主要用于一些读屏设备,方便残障人士使用,比如盲人
测试
这里打印的异常不会打印出堆栈信息,我们之前把堆栈的打印重写了
使用方法:
1.在BusinessExceptionCode.java增加一种异常枚举;
2.在业务代码中抛出指定枚举类型的业务异常
1.用户管理与登录:侧边栏激活样式优化
当前菜单的父菜单的同级菜单,下面所有的子菜单,清空激活样式
admin.vue
可以把active清空掉
测试
随便切换都没有问题
问题:访问跟目录时,页面显示的是admin.vue,但是右边的content部分是空的,没有路由到任何一个子路由
router.js
测试
1.用户管理与登录:解决从登录页面跳到控台主页时,侧边栏失效的问题
问题:从登录页面跳转到控台主页时,菜单失效了
重新刷新,才会显示
打开登录页面时,会去加载所需的js,包括ace.min.js,这里会去做很多的初始化,包括侧边栏的点击事件,但是此时还没有侧边栏
解决方法:进入控台主页时,重新加载ace.min.js
admin.vue
测试成功,登录进来就可以点击出现子模块了
小提示:大家在用很多第三方框架或jquery插件时,如果发现有些功能不起作用,如果看不懂源码,可以尝试这种方法解决,把核心的js重新加载一遍
网站的数据库里已经把原值和密文都算好并存储起来了,如果刚好你输入的密文在数据库里有,就能解(查)出来。
盐值也叫salt值,加上盐值后,密文不容易被破解(查询)。
1.用户管理与登录:密码加密传输和加密存储
存成明文的话,至少程序员可以直接到生产上看到所有人的密码。
从路由器的日志,我可以看到所有人浏览的网站的地址、用户名、密码,如果密码刚好是明文传输,那就泄露了,如果是简单的md5,也很容易被破解,所以需要加个盐值。
md5.js
盐值可以是随机的一串值,但是所有用到盐值的地方必须是同一个值。如果你是做平台系统,有多个客户用你的平台,最好是一个客户一个盐值。
user.vue
这里可以考虑写个通用方法,把hex_md5+盐值包装起来
保险方案:对密码做两层加密
UserController.java
测试
1.用户管理与登录:增加重置密码功能;编辑用户信息的时候不修改密码
修改用户信息和修改密码应该分开,做成两个功能
密码发生改变
user.vue
UserService.java
UserMapper.java
mybatis-generator 生成的方法里,updateByPrimaryKeySelective会对字段进行非空判断,再更新,如果值为空就不更新,原理就是利用mybatis的if拼成动态sql
测试
UserService.java
UserController.java
测试
1.用户管理与登录:基本的登录功能开发,校验用户名密码
思考:我现在存到数据库里面的密码是密文,那用户登录的时候,我数据库里面的密码解密不出来了,我怎么知道用户输入的密码对不对?
login.vue
UserController.java
UserService.java
登录验证思考:是否是根据用户名+密码到数据中去查找记录?
用户名+密码去数据库查找的话,程序不知道是用户名不对,还是密码不对。程序应该要能知道,比如我如果发现有大量的用户名不对的报错,说明有人正在不断的探测我系统的用户名。
再次思考:如果用户名不对,提示给前端的是:用户名不存在。如果是密码不对,提示给前端的是:密码不对。是否是这样?
如果你直接告诉前端说用户名不存在,我作为一个黑客的话,可以拿着现成的一堆用户名,包括手机号邮箱,不断的探测哪些用户名是你系统里有了,不要给别人任何机会获取你系统的关键信息。
BusinessExceptionCode.java
LoginUserDto.java
package com.course.server.dto;
public class LoginUserDto {
/**
* id
*/
private String id;
/**
* 登录名
*/
private String loginName;
/**
* 昵称
*/
private String name;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getLoginName() {
return loginName;
}
public void setLoginName(String loginName) {
this.loginName = loginName;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(getClass().getSimpleName());
sb.append(" [");
sb.append("Hash = ").append(hashCode());
sb.append(", id=").append(id);
sb.append(", loginName=").append(loginName);
sb.append(", name=").append(name);
sb.append("]");
return sb.toString();
}
}
UserService.java
UserController.java
测试
1.用户管理与登录:登录后前端保存登录信息并显示
前端的信息保存有多种选择:h5的localStorage, sessionStorage; js的全局变量,vue 的store等。
sessionStorage在页面刷新的时候,信息不会丢;关闭页面后,信息自动清空,适合用来存储登录信息。
localStorage在关闭页面后,登录信息还是在的,适合一些内网使用的系统保存登录信息。
用js 全局变量或vue 的store,刷新浏览器的时候,信息会丢失。
login.vue
admin.vue
把登录信息的保存和读取做成通用的方法。
tool.js
小技巧:在获取一些对象的时候,加上|| {},避免获取属性值时报错。
session-storage.js
login.vue
admin.vue
测试
1.用户管理与登录:增加退出登录功能,清空前后端的会话缓存
它会动态的改变大小,适应屏幕
退出登录:清空当前登录的缓存信息,并跳到登录页面
admin.vue
一般登录信息在前后端都会保存
Constants.java
UserController.java
测试
1.用户管理与登录:增加记住登录信息功能
使用localStorage来保存输入的用户名密码
login.vue
能获取到缓存的值,说明上一次有勾选“记住我”
如果不清空本地缓存,重新打开页面时,会再次显示记住的用户名密码
测试之前要把浏览器自带的记住用户名密码清除
1.用户管理与登录:增加记住登录信息功能,安全加固,本地缓存保存密码密文
local-storage.js
login.vue
测试
从刚才的记住我这个功能,大家可以看出来,一个是记住明文,一个是记住密文,虽然实现的功能是一样的,但是安全性上不一样,所以我们写程序不只是把功能写出来,还要严谨,不要留下坑
1.用户管理与登录:集成图形验证码kaptcha
pom.xml(course)
pom.xml(server)
KaptchaConfig.java
package com.course.server.config;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.google.code.kaptcha.util.Config;
import java.util.Properties;
@Configuration
public class KaptchaConfig {
@Bean
public DefaultKaptcha getDefaultKaptcha() {
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
Properties properties = new Properties();
properties.setProperty("kaptcha.border", "no");
// properties.setProperty("kaptcha.border.color", "105,179,90");
properties.setProperty("kaptcha.textproducer.font.color", "blue");
properties.setProperty("kaptcha.image.width", "90");
properties.setProperty("kaptcha.image.height", "32");
properties.setProperty("kaptcha.textproducer.font.size", "24");
properties.setProperty("kaptcha.session.key", "code");
properties.setProperty("kaptcha.textproducer.char.length", "4");
properties.setProperty("kaptcha.textproducer.font.names", "Arial");
properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.NoNoise");
properties.setProperty("kaptcha.obscurificator.impl", "com.google.code.kaptcha.impl.WaterRipple");
Config config = new Config(properties);
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
//如果项目中有多个页面会用到验证码图片,且图片的大小,颜色等都不一样,就可以增加多个生成验证码图片方法
@Bean
public DefaultKaptcha getWebKaptcha() {
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
Properties properties = new Properties();
properties.setProperty("kaptcha.border", "no");
// properties.setProperty("kaptcha.border.color", "105,179,90");
properties.setProperty("kaptcha.textproducer.font.color", "blue");
properties.setProperty("kaptcha.image.width", "90");
properties.setProperty("kaptcha.image.height", "45");
properties.setProperty("kaptcha.textproducer.font.size", "30");
properties.setProperty("kaptcha.session.key", "code");
properties.setProperty("kaptcha.textproducer.char.length", "4");
properties.setProperty("kaptcha.textproducer.font.names", "Arial");
properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.NoNoise");
properties.setProperty("kaptcha.obscurificator.impl", "com.google.code.kaptcha.impl.WaterRipple");
Config config = new Config(properties);
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
}
如果项目中有多个页面会用到验证码图片,且图片的大小,颜色等都不一样,就可以增加多个生成验证码图片方法
KaptchaController.java
package com.course.system.controller.admin;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.imageio.ImageIO;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
@RestController
@RequestMapping("/admin/kaptcha")
public class KaptchaController {
@Qualifier("getDefaultKaptcha")
@Autowired
DefaultKaptcha defaultKaptcha;
@GetMapping("/image-code/{imageCodeToken}")
public void imageCode(@PathVariable(value = "imageCodeToken") String imageCodeToken,
HttpServletRequest request, HttpServletResponse httpServletResponse) throws Exception{
ByteArrayOutputStream jpegOutputStream = new ByteArrayOutputStream();
try {
// 生成验证码字符串
String createText = defaultKaptcha.createText();
// 将生成的验证码放入会话缓存中,后续验证的时候用到
request.getSession().setAttribute(imageCodeToken, createText);
// 使用验证码字符串生成验证码图片
BufferedImage challenge = defaultKaptcha.createImage(createText);
ImageIO.write(challenge, "jpg", jpegOutputStream);
} catch (IllegalArgumentException e) {
httpServletResponse.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
// 定义response输出类型为image/jpeg类型,使用response输出流输出图片的byte数组
byte[] captchaChallengeAsJpeg = jpegOutputStream.toByteArray();
httpServletResponse.setHeader("Cache-Control", "no-store");
httpServletResponse.setHeader("Pragma", "no-cache");
httpServletResponse.setDateHeader("Expires", 0);
httpServletResponse.setContentType("image/jpeg");
ServletOutputStream responseOutputStream = httpServletResponse.getOutputStream();
responseOutputStream.write(captchaChallengeAsJpeg);
responseOutputStream.flush();
responseOutputStream.close();
}
}
测试http://localhost:9000/system/admin/kaptcha/image-code/123
alt+enter,将字符加到字典里,就不会有波浪线警告了
1.用户管理与登录:页面显示验证码及刷新验证码
login.vue
tool.js
radix 默认63
10个数字+26个大小字母+26个小写字母,共62个字符,可以用来表示62进制的数值,也可以加入一些特殊字符,组成更大进制的数。
原理:以62进制为例,随机生成一个0~61的数值,比如41,那边就取chars数组中的第41个字符,这样重复做8遍,就生成了8位的62进制数,重复的概览是62的8次方。也可以生成更长的数值。
1.用户管理与登录:登录增加验证码校验
2.解决每次ajax请求,对应的sessionId不一致的问题
UserDto.java
增加属性后,记得alt+insert生成get, set, toString()方法。比如lombok,代码侵入性太强,如果我用了插件,那大家都得安装这个插件,否则会报错。
UserController.java
在登录里面,增加验证码校验:通过token去缓存中获取验证码字符串,并和用户输入的字符串做比较。
main.js
登录验证出错时,将密码清空,同时刷新验证码图片
login.vue
测试
刷新验证码会让网站更安全,但是会牺牲一点用户体验,需要折中选择
测试一种场景:刷新过的验证码,还能不能用。
正规的项目中,都会有专门的测试团队来编写测试用例。如果是个人开发,只能自己设计测试用例,要尽可能的覆盖各种使用场景
生产发布时,至少是双节点,防止单台宕机。
不管是在哪一台做的登录,登录完成后,会把登录信息保存到redis中。当业务请求进来时,再到redis中获取登录信息,能获取到就表示已登录,未获取到就表示未登录,拦截掉请求。
功能:只要在其中一个产品中登录过,其他关联的产品都不需要再登录。
token:登录标识,每个用户每次登录,都会生成不同的token。
单点登录(Single Sign On),简称为SSO,核心功能:session共享。
需要解决Session共享的场景:
1.同个应用多节点共享登录信息;
2.多个项目间共享登录信息。
一般我们通常说的单点登录系统,是用来解决场景2的。
1.用户管理与登录:集成redis,图片验证码的存储从session改为redis
pom.xml(server)
application.properties
KaptchaController.java
UserController.java
回归验证码功能,更换缓存不要对原有流程产生影响
测试
1.用户管理与登录:生成登录token并存储到redis中,退出登录时删除token
LoginUserDto.java
UserController.java
这里也可以直接保存loginUserDto对象,但是需要序列化。如果是跨应用使用的,比如A应用存,B应用取,一般会把值转成JSON字符串
admin.vue
KaptchaController.java
测试
1.用户管理与登录:基于Vue路由的登录拦截
此时,直接访问user页面,是可以直接跳过身份验证,这是绝对不允许的
router.js
只需要在父路由增加拦截就可以了,子路由就会都有这个拦截,不需要子路由再一个一个添加
main.js
测试
但是现在还有一个问题:界面拦住了,但是所有的接口都可以直接访问,非常危险,这个就是基于后端的请求拦截
1.用户管理与登录:在请求headers 中统一增加token
main.js
可以用这种方法给所有请求加了统一的系统参数,比如在header里加上请求流水,请求时间等。
测试
1.用户管理与登录:在gateway中增加登录拦截
application.properties
LoginAdminGatewayFilter.java
LoginAdminGatewayFilterFactory.java
测试
不是所有的请求都需要做登录拦截,比如登录接口、验证码图片接口
1.用户管理与登录:gateway实现控台登录拦截功能
pom.xml(course)
pom.xml(gateway)
gateway模块并没有依赖server模块,所以有些jar包如redis , json等,需要单独在pom.xml中增加依赖
application.properties
LoginAdminGatewayFilter.java
package com.course.gateway.filter;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.core.Ordered;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Resource;
@Component
public class LoginAdminGatewayFilter implements GatewayFilter, Ordered {
private static final Logger LOG = LoggerFactory.getLogger(LoginAdminGatewayFilter.class);
@Resource
private RedisTemplate redisTemplate;
@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
//请求地址中不包含/admin/的,不是控台请求,不需要拦截
if (!path.contains("/admin/")){
return chain.filter(exchange);
}
if (path.contains("/system/admin/user/login")
|| path.contains("/system/admin/user/logout")
|| path.contains("/system/admin/kaptcha")){
LOG.info("不需要控台登录验证:{}",path);
return chain.filter(exchange);
}
//获取header的token参数
String token = exchange.getRequest().getHeaders().getFirst("token");
LOG.info("控台登录验证开始,token:{}",token);
if (token == null || token.isEmpty()){
LOG.info("token为空,请求被拦截");
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
Object object = redisTemplate.opsForValue().get(token);
if (object == null){
LOG.warn("token无效,请求被拦截");
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}else {
LOG.info("已登录:{}",object);
return chain.filter(exchange);
}
}
@Override
public int getOrder() {
return 1;
}
}
http协议中有一些约定好的状态码,比如401未授权,404未找到,200正常处理等。
HttpStatus.java
测试
白底是前端部分,蓝底是后端部分
开始->生成验证码token唯一标识->调用服务端图形验证码接口->生成验证码字符串->以token为key将字符串放入redis中,设置时效->返回验证码图片->结束
开始->输入用户名、密码、验证码->前端密码加密(防止数据传输时泄漏)->组装登录参数包含验证码token->调用登录接口->密码加密->根据token到redis获取正确的验证码->
验证码正确?(这里的验证码错误分为两种,验证码已过期和验证码不正确)
1.->清除redis验证码根据用户名查询->用户存在?
-是>比较密码正确?
-是>生成登录token->以token为key将登录信息放入缓存中->返回用户信息-----success=true----->
-否>打印日志:密码错->返回用户名不存在或密码错误----- success=false----->success=true----->
-否>打印日志:用户名不存在->返回用户名不存在或密码错误
2.->打印日志并返回验证码错误----- success=false----->success=true----->
success=true?
-是>保存用户信息到h5 session缓存->勾选记住我
-是>保存用户名密码到h5 local缓存中->结束
-否>清空h5 local缓存中的用户密码->结束
-否>弹出登录失败提示框->结束
|