业务讲解-用户登录和退出流程解析
登录
本文重点讲解关于通过手机号或者邮箱的登录方式,如图:
说明
由于将用户表进行了分库分表的操作,在其他操作中,比如查询用户信息,修改用户信息,订单的用户信息等都是是用的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 的。
- 暴力破解:攻击者只要用一个合法手机号,不断尝试不同密码,就永远不会触发你那段“incrBy+TTL”逻辑,也就不会被限流或者短暂锁定。
- 字典攻击:同理可以用常见密码列表,撞库式试密码,完全没有任何限制。
(3)改进方案
- 密码错误也要计数并短时锁定
- 密码错误到一定次数后,强制冷静期或启用图形验证码
- 登录成功要清零计数
// 5.将用户信息放到缓存中
redisCache.set(
RedisKeyBuild.createRedisKey(RedisKeyManage.USER_LOGIN,code,user.getId())
, user , tokenExpireTime , TimeUnit.MINUTES
);
用户缓存也没有续期的逻辑,其它地方也是直接缓存读,没有重建流程, 可以加一个每次操作都刷新缓存的逻辑
但其实不这么做也行,因为如果token被劫取了,劫取人就可以无限制使用了。
企业级项目导航:⬅️ 07-业务讲解-用户注册-使用组合模式处理复杂的验证功能 | 08-业务讲解-用户登录和退出流程解析 | ➡️ 09-业务讲解-用户私密信息如何加密存储
💬 评论