从零开始开发高并发商城系统之用户模块:用户登录
用户注册功能完善
主要设计思路为,用户通过邮箱进行注册,判断是否有该账号,没有的话就可以注册,同时注册密码用MD5+盐加密
用户邮箱验证
这一部分请参考上一篇文章
账号唯一性检查(高并发下)
**概述:**商用场景可能在一秒中之内有上万次的并发操作,所以可能在同一时间有几个帐号用了同一个邮箱,我们需要避免这种情况(之前面试美团问过类似的问题,但是那个是分布式场景,需要考虑的问题更复杂,考察重点是如何设计ID去保证唯一性)
**需求:**我们需要保证同个时刻注册,账号在数据库里的唯一性
解决方法:
很重要的是我们需要掌握的高并发问题扩大的思维
- 代码暂停思维:假如非原子性代码运行到某一行暂停,其他线程重新操作是否会出问题
- 时间扩大思维:1纳秒的时间,扩大到1分钟,代码逻辑是否会有问题
目前的解决方法有两种:
-
通过redis:
- 先看redis是否有邮箱的缓存,没有的话就是新的注册通过key-value存储,设置60秒过期时间
- 存在的问题:非原子性操作,存在不一致(比如当一个人在redis中没有找到自己的邮箱准备存到数据库,但是在存的过程中,另一个用户也找了redis,发现也没有,也会去写DB,这样会覆盖前一个人的内容
-
数据库唯一索引(建表时添加)
ALTER TABLE user ADD unique(`mail`)
用户注册密码加密
理论知识
-
先看些例子
- https://zhuanlan.zhihu.com/p/347964030
-
信息安全的基本目标
- 保密性:防止被不合法用户读取
- 完整性:保证数据不被不授权的修改
- 可用性:保证有授权的一方可以访问所有的资源
-
常见加密算法的分类
-
哈希算法(单向)
-
对称加密
-
非对称加密
-
-
hash算法-单项加密
加密过程中不需要使用密钥,输入明文后由系统直接经过加密算法处理成密文,密文无法解密。
只有重新输入明文,并经过同样的加密算法处理,得到相同的密文并被系统重新识别后,才能真正解密
算法:MD5/SHA1/SHA224/SHA256/
优点:快速计算m,具有单向性 one-way,不可由散列值推出原消息
场景:文件完整性校验和(Checksum)算法、常规密码等
-
彩虹表暴力破解
- 网站:https://www.cmd5.com/
- 密码存储常用方式
- 双重MD5
- MD5+加盐
- 双重MD5+加盐
- 为什么不用最安全的复杂HASH加密
- 更安全的算法,加密解密更复杂,接口性能下降更严重
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dp3G5Bkj-1691912701441)(C:\Users\lyh\Desktop\学习笔记\海量大数据学习\image-20210203134441219.png)]
实现方法
-
引入依赖
-
<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 --> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> </dependency> <!--用于加密--> <dependency> <groupId>commons-codec</groupId> <artifactId>commons-codec</artifactId> </dependency>
-
-
编写代码
-
工具类:生成长度随机的字母和数字
-
/** * 生成指定长度随机字母和数字 * * @param length * @return */ private static final String ALL_CHAR_NUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; public static String getStringNumRandom(int length) { //生成随机数字和字母, Random random = new Random(); StringBuilder saltString = new StringBuilder(length); for (int i = 1; i <= length; ++i) { saltString.append(ALL_CHAR_NUM.charAt(random.nextInt(ALL_CHAR_NUM.length()))); } return saltString.toString(); }
-
-
加盐
-
主要就是接受前端传入的user信息,并把密码通过盐加密,并设置给Userdo
-
UserDO userDO = new UserDO(); BeanUtils.copyProperties(registerRequest, userDO); userDO.setCreateTime(new Date()); userDO.setSlogan("人生需要动态规划,学习需要贪心算法"); //生成秘钥 userDO.setSecret("$1$" + CommonUtil.getStringNumRandom(8)); //密码 + 加盐处理 String cryptPwd = Md5Crypt.md5Crypt(registerRequest.getPwd().getBytes(), userDO.getSecret()); userDO.setPwd(cryptPwd);
-
-
用户登录逻辑
核心逻辑:
- 通过mail查数据库记录
- 获取盐,和当前传递的密码加密后与数据库中的密文进行比较
- 生成token令牌返回给前端
代码实现
/**
* 用户登录
* *1.根据mail去找有没有这条记录
* *2.有的话,用盐—+明文密码加密,再和数据库进行匹配
* @param userLoginRequest
* @return
*/
@Override
public JsonData login(UserLoginRequest userLoginRequest) {
List<UserDO> userDOList = userMapper.selectList(new QueryWrapper<UserDO>().eq("mail", userLoginRequest.getMail()));
if (userDOList!=null&&userDOList.size()==1){
//已经注册
UserDO userDO = userDOList.get(0);
String cryptPwd = Md5Crypt.md5Crypt(userLoginRequest.getPwd().getBytes(), userDO.getSecret());
if (cryptPwd.equals(userDO.getPwd())){
//登录成功,生成token
LoginUser loginUser = LoginUser.builder().build();
BeanUtils.copyProperties(userDO,loginUser);
String token = JWTUtil.geneJsonWebToken(loginUser);
return JsonData.buildSuccess(token);
}else {
return JsonData.buildResult(BizCodeEnum.ACCOUNT_PWD_ERROR);
}
}else {
return JsonData.buildResult(BizCodeEnum.ACCOUNT_UNREGISTER);
}
}
分布式应用下扽牢固检验解决方案JWT详解
什么是JWT
- JWT 是一个开放标准,它定义了一种用于简洁,自包含的用于通信双方之间以 JSON 对象的形式安全传递信息的方法。 可以使用 HMAC 算法或者是 RSA 的公钥密钥对进行签名
- 简单来说: 就是通过一定规范来生成token,然后可以通过解密算法逆向解密token,这样就可以获取用户信息
{
id:888,
name:'刘大能',
expire:10000
}
funtion 加密(object, appsecret){
xxxx
return base64( token);
}
function 解密(token ,appsecret){
xxxx
//成功返回true,失败返回false
}
-
优点
- 生产的token可以包含基本信息,比如id、用户昵称、头像等信息,避免再次查库
- 存储在客户端,不占用服务端的内存资源
-
缺点
-
token是经过base64编码,所以可以解码,因此token加密前的对象不应该包含敏感信息,如用户权限,密码等
-
如果没有服务端存储,则不能做登录失效处理,除非服务端改秘钥
-
JWT实战
-
引入依赖
<!-- JWT相关 --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.7.0</version> </dependency>
-
产生token,获取loginUser中的相关信息,并通过Jwts类生成token,返回给前端
private static final long EXPIRE = 60 * 1000 * 60 * 24 * 7; private static final String SECRET = "lyh.net666"; private static final String TOKEN_PREFIX = "lyhshop"; private static final String SUBJECT = "lyhclass"; /** * 生成token * @param loginUser * @return */ public static String geneJsonWebToken(LoginUser loginUser){ if (loginUser == null){ throw new NullPointerException("对象为空"); } String token = Jwts.builder().setSubject(SUBJECT) .claim("head_img", loginUser.getHeadImg()) .claim("id", loginUser.getId()) .claim("name", loginUser.getName()) .claim("mail", loginUser.getMail()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET).compact(); token = TOKEN_PREFIX + token; return token; } /**校验token * * @param token * @return */ public static Claims checkJWT(String token){ try { final Claims claims = Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token.replace(TOKEN_PREFIX,"")).getBody(); return claims; }catch (Exception e){ log.info("token解密失败{}",e); return null; } }
Token令牌过期解决方案
背景
在前后端分离的场景下,存在jwt过期后,用户无法感知,如果用户正在操作页面,突然提示登录,体验很不好,所以有了token自动刷新的需求
但是这个自动刷新方案,基本都离不开服务端状态存储,JWT推出思想是:去中心化,无状态化,所以有所违背
类似这样的业务,有阿里云首页,没有做token刷新令牌维护,但是符合对应的思想
解决方案
-
前端控制检测token,无感知刷新
-
用户登录后,给两个token,一个是accesstoeken和refreshtoken
-
AccessToken有效期较短,比如1天或者5天,用于正常请求
RefreshToken有效期可以设置长一些,例如10天、20天,作为刷新AccessToken的凭证 -
刷新方案:当AccessToken即将过期的时候,例如提前30分钟,客户端利用RefreshToken请求指定的API获取新的AccessToken并更新本地存储中的AccessToken
-
核心逻辑
1、登录成功后,jwt生成AccessToken; UUID生成RefreshToken并存储在服务端redis中,设置过期时间
2、接口返回3个字段AccessToken/RefreshToken/访问令牌过期时间戳
3、由于RefreshToken存储在服务端redis中,假如这个RefreshToken也过期,则提示重新登录 -
RefreshToken有效期那么长,和直接将AccessToken的有效期延长有什么区别
答:RefreshToken不像AccessToken那样在大多数请求中都被使用,主要是本地检测accessToken快过期的时候才使用,
一般本地存储的时候,也不叫refreshToken,前端可以取个别名,混淆代码让攻击者不能直接识别这个就是刷新令牌缺点:前端每次请求需要判断token距离过期时间
优点:后端压力小,代码逻辑改动不大
-
-
后端存储判断过期时间
- 后端存储AccessToken,每次请求过来都判断是否要过期,如果快要过期则重新生成新的token,并返回给前端重新存储,比如距离1天就过期的情况,如果用户访问对应的接口则会更新,但假如没访问则token已经过期则需要重新登录
- 优点:前端改动小,只需要存储响应http头里面是否有新的令牌产生,有的话就重新存储
缺点:后端实现复杂,且泄露后容易存在一直保活状态,且前端会存在并发请求,当并发请求收到多个jwt token时,容易生成多个token混乱使用
拦截器开发
理论
当用户发送请求过来,对请求进行拦截,先对token进行解密,获取前端传递的信息
代码
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
try {
String accessToken = request.getHeader("token");
if (accessToken == null) {
accessToken = request.getParameter("token");
}
if (StringUtils.isNotBlank(accessToken)) {
Claims claims = JWTUtil.checkJWT(accessToken);
if (claims == null) {
//告诉登录过期,重新登录
CommonUtil.sendJsonMessage(response, JsonData.buildError("登录过期,重新登录"));
return false;
}
Long id = Long.valueOf( claims.get("id").toString());
String headImg = (String) claims.get("head_img");
String mail = (String) claims.get("mail");
String name = (String) claims.get("name");
//TODO 用户信息传递
return true;
}
} catch (Exception e) {
log.error("拦截器错误:{}",e);
}
CommonUtil.sendJsonMessage(response, JsonData.buildError("token不存在,重新登录"));
return false;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
}
配置哪些路径需要拦截
@Configuration
@Slf4j
public class InterceptorConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.addPathPatterns("/api/user/*/**","api/address/*/**")
//排除不拦截路径
.excludePathPatterns("/api/user/*/send_code","/api/user/*/captcha","/api/user/*/login","/api/user/*/register","/api/user/*/upload");
}
}
当我们获取了前端传送的信息后,需要对用户信息进行传递,有两种方法,之前的话是通过写入request对象,在后面获取
// protobuf
// loginUser.setName(name);
// loginUser.setHeadImg(headImg);
// loginUser.setId(userId);
// loginUser.setMail(mail);
//通过attribute传递用户信息
//request.setAttribute("loginUser",loginUser);
//通过threadLocal传递用户登录信息
这次学了一个新的方法,就是写入threadlocal,顾名思义,就是把属性写到一个线程对象中,这次请求的线程都可以获取
public static ThreadLocal<LoginUser> threadLocal = new ThreadLocal<>();
LoginUser loginUser = new LoginUser();
loginUser.setId(id);
loginUser.setName(name);
loginUser.setMail(mail);
loginUser.setHeadImg(headImg);
threadLocal.set(loginUser);
总结
户登录信息
这次学了一个新的方法,就是写入threadlocal,顾名思义,就是把属性写到一个线程对象中,这次请求的线程都可以获取
public static ThreadLocal threadLocal = new ThreadLocal<>();
LoginUser loginUser = new LoginUser();
loginUser.setId(id);
loginUser.setName(name);
loginUser.setMail(mail);
loginUser.setHeadImg(headImg);
threadLocal.set(loginUser);
## 总结
本文主要是将本周学习的关于登录模块的相关内容进行了总结,重点内容的话包括JWT的解决方案,以及一些基础的业务开发流程,关于threadlocal会在后面一篇文章带大家一起读一下源码,进行详细的学习,这一块知识点还挺多的。还有一点关于优惠券发放业务相关的内容,也放在下一篇文章中总结