从零开始开发高并发商城系统之用户模块:用户登录

从零开始开发高并发商城系统之用户模块:用户登录

用户注册功能完善

主要设计思路为,用户通过邮箱进行注册,判断是否有该账号,没有的话就可以注册,同时注册密码用MD5+盐加密

用户邮箱验证

这一部分请参考上一篇文章

账号唯一性检查(高并发下)

**概述:**商用场景可能在一秒中之内有上万次的并发操作,所以可能在同一时间有几个帐号用了同一个邮箱,我们需要避免这种情况(之前面试美团问过类似的问题,但是那个是分布式场景,需要考虑的问题更复杂,考察重点是如何设计ID去保证唯一性)

**需求:**我们需要保证同个时刻注册,账号在数据库里的唯一性

解决方法

很重要的是我们需要掌握的高并发问题扩大的思维

  1. 代码暂停思维:假如非原子性代码运行到某一行暂停,其他线程重新操作是否会出问题
  2. 时间扩大思维:1纳秒的时间,扩大到1分钟,代码逻辑是否会有问题

目前的解决方法有两种:

  1. 通过redis:

    1. 先看redis是否有邮箱的缓存,没有的话就是新的注册通过key-value存储,设置60秒过期时间
    2. 存在的问题:非原子性操作,存在不一致(比如当一个人在redis中没有找到自己的邮箱准备存到数据库,但是在存的过程中,另一个用户也找了redis,发现也没有,也会去写DB,这样会覆盖前一个人的内容
  2. 数据库唯一索引(建表时添加)

    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)]

实现方法
  1. 引入依赖

    1. 
             <!-- 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>
      
  2. 编写代码

    1. 工具类:生成长度随机的字母和数字

      1. 		/**
             * 生成指定长度随机字母和数字
             *
             * @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();
            }
        
    2. 加盐

      1. 主要就是接受前端传入的user信息,并把密码通过盐加密,并设置给Userdo

      2. 			  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);
        

用户登录逻辑

核心逻辑:

  1. 通过mail查数据库记录
  2. 获取盐,和当前传递的密码加密后与数据库中的密文进行比较
  3. 生成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实战
  1. 引入依赖

    <!-- JWT相关 -->
        <dependency>
          <groupId>io.jsonwebtoken</groupId>
          <artifactId>jjwt</artifactId>
          <version>0.7.0</version>
        </dependency>
    
  2. 产生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会在后面一篇文章带大家一起读一下源码,进行详细的学习,这一块知识点还挺多的。还有一点关于优惠券发放业务相关的内容,也放在下一篇文章中总结
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值