业务讲解-用户登录和退出流程解析

登录

本文重点讲解关于通过手机号或者邮箱的登录方式,如图:

image-42191f7d

说明

由于将用户表进行了分库分表的操作,在其他操作中,比如查询用户信息,修改用户信息,订单的用户信息等都是是用的userId查询的

这就产生了问题,由于登录支持使用手机号和邮箱登录,那么进行分库分表的分片键要怎么选择呢?上述中提到了很多的都是靠userId作为条件的,那么还是要以userId作为分片键,但是手机号和邮箱怎么处理?为了解决这个问题,我们使用了互联网公司常用的附属表的方案,另外设计了用户手机表和用户邮箱表,这样先用手机号和邮箱查询到userId,然后userId再去操作用户表,这样就解决了分片建的问题。

关于此问题的详细介绍,可查看用户的分库分表相关文档

分库分表-用户服务-用户表

流程

模块: damai-user-service

入参

@Data
@ApiModel(value="UserLoginDto", description ="用户登录")
public class UserLoginDto {
    
    @ApiModelProperty(name ="code", dataType ="String", value ="渠道code 0001:pc网站", required = true)
    @NotBlank
    private String code;
    
    @ApiModelProperty(name ="name", dataType ="String", value ="用户手机号")
    private String mobile;
    
    @ApiModelProperty(name ="email", dataType ="String", value ="用户邮箱")
    private String email;
    
    @ApiModelProperty(name ="password", dataType ="String", value ="密码", required = true)
    @NotBlank
    private String password;
}

com.damai.service.UserService#login

/**
 * 登录
 * @param userLoginDto 登录入参
 * @return 用户信息
 * */
public UserLoginVo login(UserLoginDto userLoginDto) {
    UserLoginVo userLoginVo = new UserLoginVo();
    String code = userLoginDto.getCode();
    String mobile = userLoginDto.getMobile();
    String email = userLoginDto.getEmail();
    String password = userLoginDto.getPassword();
    //如果手机号和邮箱同时不存在,那么直接抛出异常
    if (StringUtil.isEmpty(mobile) && StringUtil.isEmpty(email)) {
        throw new DaMaiFrameException(BaseCode.USER_MOBILE_AND_EMAIL_NOT_EXIST);
    }
    Long userId;
    if (StringUtil.isNotEmpty(mobile)) {
        //检查输入的手机号是否达到输入错误次数限制
        String errorCountStr = 
                redisCache.get(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_MOBILE_ERROR, mobile), String.class);
        //如果达到限制的阈值则不再往下执行
        if (StringUtil.isNotEmpty(errorCountStr) && Integer.parseInt(errorCountStr) >= ERROR_COUNT_THRESHOLD) {
            throw new DaMaiFrameException(BaseCode.MOBILE_ERROR_COUNT_TOO_MANY);
        }
        //如果手机号存在,则用手机号查询用户id
        LambdaQueryWrapper<UserMobile> queryWrapper = Wrappers.lambdaQuery(UserMobile.class)
                .eq(UserMobile::getMobile, mobile);
        UserMobile userMobile = userMobileMapper.selectOne(queryWrapper);
        if (Objects.isNull(userMobile)) {
            //如果查询手机号不存在,则放入redis中将手机号输入错误的计数器加1
            redisCache.incrBy(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_MOBILE_ERROR,mobile),1);
            redisCache.expire(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_MOBILE_ERROR,mobile),1,TimeUnit.MINUTES);
            throw new DaMaiFrameException(BaseCode.USER_MOBILE_EMPTY);
        }
        userId = userMobile.getUserId();
    }else {
        //检查输入的邮箱是否达到输入错误次数限制
        String errorCountStr = 
                redisCache.get(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_EMAIL_ERROR, email), String.class);
        //如果达到限制的阈值则不再往下执行
        if (StringUtil.isNotEmpty(errorCountStr) && Integer.parseInt(errorCountStr) >= ERROR_COUNT_THRESHOLD) {
            throw new DaMaiFrameException(BaseCode.EMAIL_ERROR_COUNT_TOO_MANY);
        }
        //用邮箱查询用户id    
        LambdaQueryWrapper<UserEmail> queryWrapper = Wrappers.lambdaQuery(UserEmail.class)
                .eq(UserEmail::getEmail, email);
        UserEmail userEmail = userEmailMapper.selectOne(queryWrapper);
        if (Objects.isNull(userEmail)) {
            //如果查询手机号不存在,则放入redis中将手机号输入错误的计数器加1
            redisCache.incrBy(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_EMAIL_ERROR,email),1);
            redisCache.expire(RedisKeyBuild.createRedisKey(RedisKeyManage.LOGIN_USER_EMAIL_ERROR,email),1,TimeUnit.MINUTES);
            throw new DaMaiFrameException(BaseCode.USER_EMAIL_NOT_EXIST);
        }
        userId = userEmail.getUserId();
    }
    //从库中查询用户
    LambdaQueryWrapper<User> queryUserWrapper = Wrappers.lambdaQuery(User.class)
            .eq(User::getId, userId).eq(User::getPassword, password);
    User user = userMapper.selectOne(queryUserWrapper);
    //用户不存在,抛出异常
    if (Objects.isNull(user)) {
        throw new DaMaiFrameException(BaseCode.NAME_PASSWORD_ERROR);
    }
    //将用户信息放到缓存中
    redisCache.set(RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN,code,user.getId()),user,
            tokenExpireTime,TimeUnit.MINUTES);
    userLoginVo.setUserId(userId);
    //生成token
    userLoginVo.setToken(createToken(user.getId(),getChannelDataByCode(code).getTokenSecret()));
    return userLoginVo;
}

登录逻辑很简单,每个手机号都有一个错误次数上限,不能用一个不存在的手机号一直尝试登陆。校验完手机号没有超过错误上限,且确实存在改手机号和用户,就验证密码。密码是对的就登录成功了,返回一个token给前端,作为登录凭证。

这里应该需要防止缓存雪崩和穿透吧,大量的错误手机号请求来登录,数据库不就崩了么,验证次数那里,要验证密码输入错误次数

穿透逻辑应该和注册逻辑一样

通过code查询token秘钥

private GetChannelDataVo getChannelDataByCode(String code){
    //从redis查询
    GetChannelDataVo channelDataVo = getChannelDataByRedis(code);
    if (Objects.isNull(channelDataVo)) {
        //调用base-data服务查询
        channelDataVo = getChannelDataByClient(code);
        //放到redis中
        setChannelDataRedis(code,channelDataVo);
    }
    return channelDataVo;
}

private GetChannelDataVo getChannelDataByRedis(String code){
    return redisCache.get(RedisKeyBuild.createRedisKey(RedisKeyManage.CHANNEL_DATA,code),GetChannelDataVo.class);
}

private GetChannelDataVo getChannelDataByClient(String code){
    GetChannelDataByCodeDto getChannelDataByCodeDto = new GetChannelDataByCodeDto();
    getChannelDataByCodeDto.setCode(code);
    ApiResponse<GetChannelDataVo> getChannelDataApiResponse = baseDataClient.getByCode(getChannelDataByCodeDto);
    if (Objects.equals(getChannelDataApiResponse.getCode(), BaseCode.SUCCESS.getCode())) {
        return getChannelDataApiResponse.getData();
    }
    throw new DaMaiFrameException("没有找到ChannelData");
}

生成token

public String createToken(Long userId,String tokenSecret){
    Map<String,Object> map = new HashMap<>(4);
    map.put("userId",userId);
    return TokenUtil.createToken(String.valueOf(uidGenerator.getUid()), JSON.toJSONString(map),tokenExpireTime * 60 * 1000,tokenSecret);
}

总结

  • 如果手机号存在,则去用户手机表查询用户id
  • 如果邮箱存在,则去用户邮箱表查询用户i
  • 然后根据手机号或者邮箱判断是否达到输入次数错误的限制,如果达到则锁定该用户1分钟不允许登录
  • 根据用户id,去用户表中查询用户信息
  • 将用户信息放入到缓存中作为登录信息,并根据token.expire.time配置来设置过期时间,默认是40分
  • 根据code查询到token秘钥
  • 根据用户id,token秘钥来生成token,过期时间和token.expire.time配置的相同
  • 将数据返回给前端

token工具类

生成token的工具类,用于token的生成和解析,并且提供了生成和解析的示例

模块:damai-common

com.damai.jwt.TokenUtil

@Slf4j
public class TokenUtil {
    
    /**
     * 指定签名的时候使用的签名算法,也就是header那部分。
     * 
     */
     private static final SignatureAlgorithm SIGNATURE_ALGORITHM = SignatureAlgorithm.HS256;
    /**
     * 用户登录成功后生成Jwt
     * 使用Hs256算法
     *
     * @param id        标识
     * @param info      登录成功的user对象
     * @param ttlMillis jwt过期时间
     * @param tokenSecret 私钥
     * @return
     */
    public static String createToken(String id, String info, long ttlMillis, String tokenSecret) {
        //生成JWT的时间
        long nowMillis = System.currentTimeMillis();
        
        //创建一个JwtBuilder,设置jwt的body
        JwtBuilder builder = Jwts.builder()
                //如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的
//                .setClaims(claims)
                //设置jti(JWT ID):是JWT的唯一标识,根据业务需要,这个可以设置为一个不重复的值,主要用来作为一次性token,从而回避重放攻击。
                .setId(id)
                //iat: jwt的签发时间
                .setIssuedAt(new Date(nowMillis))
                //代表这个JWT的主体,即它的所有人,这个是一个json格式的字符串。
                .setSubject(info)
                //设置签名使用的签名算法和签名使用的秘钥
                .signWith(SIGNATURE_ALGORITHM, tokenSecret);
        if (ttlMillis >= 0) {
            //设置过期时间
            builder.setExpiration(new Date(nowMillis + ttlMillis));
        }
        return builder.compact();
    }


    /**
     * Token的解密
     *
     * @param token 加密后的token
     * @param tokenSecret 私钥
     * @return
     */
    public static String parseToken(String token, String tokenSecret) {
        try {
            return Jwts.parser()
                    //设置签名的秘钥
                    .setSigningKey(tokenSecret)
                    //设置需要解析的jwt
                    .parseClaimsJws(token)
                    .getBody()
                    .getSubject();
        }catch (ExpiredJwtException jwtException) {
            log.error("parseToken error",jwtException);
            throw new DaMaiFrameException(BaseCode.TOKEN_EXPIRE);
        }
        
    }
    
    public static void main(String[] args) {
        
         String tokenSecret = "CSYZWECHAT";
        //生成token的实力
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("001key", "001value");
        jsonObject.put("002key", "001value");
		
        String token1 = TokenUtil.createToken("1", jsonObject.toJSONString(), 10000, tokenSecret);
        System.out.println("token:" + token1);
        
        //解析token的示例
        String token2 = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIxIiwiaWF0IjoxNjg4NTQyODM3LCJzdWIiOiJ7XCIwMDJrZXlcIjpcIjAwMXZhbHVlXCIsXCIwMDFrZXlcIjpcIjAwMXZhbHVlXCJ9IiwiZXhwIjoxNjg4NTQyODQ3fQ.vIKcAilTn_CR3VYssNE7rBpfuCSCH_RrkmsadLWf664";
        String subject = TokenUtil.parseToken(token2, tokenSecret);
        System.out.println("解析token后的值:" + subject);
    }
}

退出

入参

@Data
@ApiModel(value="UserLoginDto", description ="用户退出登录")
public class UserLogoutDto {
    
    @ApiModelProperty(name ="code", dataType ="String", value ="渠道code 0001:pc网站", required = true)
    @NotBlank
    private String code;
    
    @ApiModelProperty(name ="id", dataType ="Long", value ="用户id", required =true)
    @NotNull
    private Long id;
}

com.damai.service.UserService#logout

public void logout(UserLogoutDto userLogoutDto) {
    User user = userMapper.selectById(userLogoutDto.getId());
    if (Objects.isNull(user)) {
        throw new DaMaiFrameException(BaseCode.USER_EMPTY);
    }
    redisCache.del(RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN,userLogoutDto.getCode(),user.getId()));
}

退出登录的逻辑比较简单,根据用户id和code删除缓存的用户信息

疑问:这里用了jwt还要存入redis的原因只是为了服务端这边对用户登录状态的可见吗?

(1)登录跟注册在特性和风险上是不一样的,所以它们采用的“防刷”手段也不一样:

  • 注册(Register)面向的是未知用户、新手机号/邮箱,完全开放给外网
    • 要防止恶意批量注册、缓存穿透、缓存击穿,因为攻击者可以随便拿一大批手机/邮箱撞库。
    • 因此大麦在注册环节:
      • 全局 Lua+Redis 限流 → 判断「是否需要图形验证码」。
      • JVM 本地秒级限流 → 把剩余的洪峰也拦在本机,避免打穿后端 Redis/DB。
      • 真正执行「是否已注册」校验时,先用布隆过滤器粗筛(避免数据库),再回落到数据库做 100% 准确性验证。
    • 登录(Login)面向的是已有用户,调用模式不同
      • 用户登录请求是「小范围、可预期」的:只有已经注册过的手机号/邮箱才会发起登录。
      • 大麦只需要在登录失败时,防止短时间内密码爆破:
        • 对单个手机号或邮箱建立 Redis 计数器(expire=1分钟),超过阈值就拒绝,再也不用打数据库。

总结:

注册:高风险、未知 key、全局暴力撞号 → 用全局+本地双层、Bloom + DB 二次校验来保安全。

登录:低风险、已知 key、只需防暴力破解 → 用「每手机号/邮箱秒级计数器」做限速即可。

(2)当前登录逻辑问题?

  • 只有“用户不存在”才计数
    • 真实的「密码输错」分支里是没有 incrBy 的。
      • 如果手机号/邮箱查到了,接下来正确性验证(密码对不对)只会 throw new DaMaiFrameException(BaseCode.NAME_PASSWORD_ERROR),并不增加错误计数。
      • 也就是说,你输对了一个已注册的手机号但密码错 throwNAMEPASSWORDERRORthrow NAME_PASSWORD_ERRORthrowNAMEPASSWORDERROR,永远不会被计入那 5 次阈值,只会一直提示「用户名或密码错误」。
  • 暴力破解:攻击者只要用一个合法手机号,不断尝试不同密码,就永远不会触发你那段“incrBy+TTL”逻辑,也就不会被限流或者短暂锁定。
  • 字典攻击:同理可以用常见密码列表,撞库式试密码,完全没有任何限制。

(3)改进方案

  • 密码错误也要计数并短时锁定
  • 密码错误到一定次数后,强制冷静期或启用图形验证码
  • 登录成功要清零计数
// 5.将用户信息放到缓存中
redisCache.set(
        RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN,code,user.getId())
        , user , tokenExpireTime , TimeUnit.MINUTES
);
image-35f87ab2

用户缓存也没有续期的逻辑,其它地方也是直接缓存读,没有重建流程, 可以加一个每次操作都刷新缓存的逻辑

但其实不这么做也行,因为如果token被劫取了,劫取人就可以无限制使用了。


企业级项目导航:⬅️ 07-业务讲解-用户注册-使用组合模式处理复杂的验证功能 | 08-业务讲解-用户登录和退出流程解析 | ➡️ 09-业务讲解-用户私密信息如何加密存储