目录
一. 使用 Redis 实现短信登陆功能
1.1 前言
对于我们用户来讲,我们在登陆一个APP的时候,有很多种登陆方式,比如"微信扫码"、"手机号登陆"、"支付宝扫码"、"账号密码登录",而且现在账号普遍都是使用手机号作为账号同时还能确保每个用户账号的唯一性,因此狭义上来讲账号密码登录也可以归属于手机号登陆的一种。
现在大多都会要求微信扫码登录或者是手机号验证码登录。使用传统账号密码登陆的已经很少了,当然并不是说没有了,而是不再那么流行了,而且很多人其实设置了密码基本都会忘了,还不如短信登陆来的快。那么今天我们就来看看,我们平常司空见惯的手机号登陆方式是如何通过代码来实现的吧!
1.2 简要分析短信登陆的业务流程
大致分为以下几个步骤,当然还有一些细节需要处理,我们一步一步来说。
(1)用户输入手机号,并点击验证码请求获取验证码;
(2)判断手机号是否有效,有效则生成6位随机数,将验证码存储到 Redis 同时发送给用户;
(3)用户短信接收到验证码,输入验证码请求登录;
(5)再根据用户输入的手机号和验证码做校验,看看是否一致;
(5)验证码一致,则登陆成功放行,如果验证码不一致,则登陆失败拦截;
1.3 代码书写
发送验证码的接口代码如下;
// 定义 redis 存储验证码的业务字符前缀
public static final String LOGIN_CODE_KEY = "login:code:";
// 定义验证码超时时间 2,单位为分钟
public static final Long LOGIN_CODE_TTL = 2L;
/*
* 发送验证码接口
* */
public Result sendCode(String phone) {
String input_phone = phone;
// 1. 校验手机号,因为校验手机号在后面登录业务或者其他业务中都可能用到,所以可以抽取为一个方法
if (!RegexUtils.isValidPhoneNumber(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3. 符合,生成验证码
String code = RandomUtil.randomNumbers(6);
// 4. 打印到控制台输出一下
System.out.println(code);
// 5. 保存验证码到 redis
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
// 6. 发送验证码,这里应该调用真实的第三方的短信发送API,简单用 log 日志打印输出替代一下好啦
log.debug("发送短信验证码成功,验证码:{}", code);
// 返回ok
return Result.ok();
}
// 定义一个正则常量字符串 regex 用于下面比较手机号是否合法
public static final String regex = "^1[3456789]\\d{9}$";
/**
* 是否是无效手机格式
* @param
* @return true:符合,false:不符合
*/
public static boolean isValidPhoneNumber(String phoneNumber) {
if (StrUtil.isBlank(phoneNumber)) {
return false;
}
return phoneNumber.matches(regex);
}
校验验证码登陆的接口代码如下
public Result login(String phone,String code){
// 1. 验证手机号是否合法
if (!RegexUtils.isValidPhoneNumber(phone)) {
return Result.fail("手机号格式错误!");
}
// 2. 从 redis 中获取手机号对应的验证码 校验验证码是否正确
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
// 3. 如果验证码为空或二者不相等,说明验证码已过期或输入错误,返回错误
if (cacheCode == null || !cacheCode.equals(code)) {
return Result.fail("验证码错误");
}
// 4. 验证码正确,返回登陆成功
return Result.ok();
}
1.4 页面展示效果
如下图所示,我输入"1234567890",十位数,并不是一个手机号,所以在点击发送验证码时,页面就报错"手机号格式错误"
我再输入一个正常的手机号,页面显示验证码正常发送成功,我们到项目控制台看一下,
如下图所示,随机生成的验证码就打印在了控制台
我们再到 redis 可视化连接工具查看,可以看到短信验证码已经存储到了 redis 中,TTL 过期时间还剩下 64 秒。
1.5 代码优化
优化点一:
我们日常在使用手机的时候应该都体会过,我们去登陆一个APP的时候,不管你是新用户还是老用户,如果长时间不使用一个软件,你再去使用的时候,都会让你重新登陆。
那么现在就会有一个问题,在登陆的时候系统怎么判断你是新用户还是老用户呢?如果是新用户,是需要帮你去做注册的这个操作的,而我们日常在使用的时候,大多都会发现,即便是首次登陆软件,只要验证码通过依然可以登陆成功,这其实是因为我们在登陆的时候,如果系统发现我们是新用户,会自动帮助我们完成注册的操作。因此对于用户而言是无感知的,不会说先让我们去注册灾区进行验证码登录。这一点我们目前还没有做,可以做一个简单的的优化。
优化点二:
正如我最开始的时候所说,现在主流的是采用验证码登录,但是我们也会发现有时候我们也可以使用 "手机号+密码的"的登陆方式进行登录,这一点也可以进行一个简单的优化。
1.6 优化之后的代码
(1)首先对登陆参数进行优化,采用实体类传参,更加简洁,也方便后续扩展;
@Data
public class LoginFormDTO {
private String phone;
private String code;
private String password;
}
(2)对登录方法进行优化,添加新用户注册的方法和密码校验的方式
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (!RegexUtils.isValidPhoneNumber(phone)) {
return Result.fail("手机号格式错误!");
}
// 2. 获取用户输入的验证码和密码,
String code = loginForm.getCode();
String password = loginForm.getPassword();
// 3. 根据手机号查询用户信息
User user = userMapper.selectByPhone(phone);
// 4. 先判断验证码 code 是否为空,不为空则说明用户是验证码登录
if (code != null) {
// 5. 从redis获取验证码并校验,
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
// 6. 如果redis中为空或而这不相等,说明验证码已过期或输入错误,返回登陆错误
if (cacheCode == null || !cacheCode.equals(code)) {
return Result.fail("验证码错误");
}
// 7. 判断用户是否存在,不存在,则调用 createUserWithPhone 方法创建新用户并保存
if (user == null) {
user = createUserWithPhone(phone);
}
return Result.ok();
} else if (password != null) {
// 8. 密码不为空,用户通过密码方式登录(实际开发过程中,密码是需要进行加密处理和解密处理的,这里就不做那么复杂了)
if (user.getPassword().equals(loginForm.getPassword())) {
return Result.ok();
}
}
return Result.fail("请输入验证码或密码");
}
(3)createUserWithPhone 创建用户方法如下
// 提前定义一个统一的用户昵称前缀,就像京东会以"jd_"开头,淘宝会以"tb_"开头,再加上一个随机字符串作为用户昵称,后期用户可以自己再进行修改
public static final String USER_NICK_NAME_PREFIX = "user_";
private User createUserWithPhone(String phone) {
// 1.创建用户
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
// 2.保存用户
userService.save(user);
return user;
}
下面是 user 类的建表SQL ,上面出现的几个属性都在下面SQL中
CREATE TABLE `tb_user` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`phone` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '手机号码',
`password` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT '密码,
`nick_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT '昵称
`icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT '人物头像',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uniqe_key_phone` (`phone`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1012 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=COMPACT;
二. 登录 Token
2.1 JWT简介
通常情况下,当用户登录成功之后,服务器通常会将用户的一些常用的、敏感的信息存储起来,方便后续用户在进行其他系统操作时使用,同时也会存储用户的权限,哪些操作可以做哪些操作无权限做。在以前,是通过 session 会话来控制用户与服务器的交流,现在更多的则是使用 token 。
用于在用户认证和授权过程中进行身份验证,同时也可以存储用户的信息,token 的生成方式有很多种,但最流行的是JWT。
JWT 全称 "JSON Web Token",格式为 "xxx.xxx.xxx",中间有两个小数点将字符串分为三个部分,分别对应 Header头、Payload载荷、Signature签名。
如下就是一个常见的 JWT 字符串。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
希望进一步了解 Session 和 JWT 的,可以看我最近的一篇文章
2.2 JWT的使用方式
要想使用JWT,首先需要引入依赖
<!--JWT(Json Web Token)登录支持-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
然后我们单独创建一个方法,就命名为 "generateToken";
// 设置加密私钥,就是一个随机字符串,对 Token 签名时需要用到
private static final String TOKEN_SECRET = "secret";
// 生成JWT的方法
public String generateToken(User user) {
// 1.1 JWT 头部分信息【Header】,主要存储 token 的加密算法和 token 类型
Map<String, Object> tokenHeader = new HashMap<>();
// 1.2 设置算法 Algorithm 为 HS256
tokenHeader.put("alg", "HS256");
// 1.3 设置 token 类型为 JWT
tokenHeader.put("typ", "JWT");
// 2.1 JWT 载荷部分信息【Payload】,主要存储用户信息,用户信息可以使用参数中传递过来的 user 对象
Map<String, Object> tokenPayload = new HashMap<>();
// 2.2 设置用户id
tokenPayload.put("id", user.getId());
// 2.3 设置用户手机号
tokenPayload.put("phone", user.getPhone());
// 2.4 设置用户昵称
tokenPayload.put("nickName",user.getNickName());
// 3. 声明Token失效时间,这里设置的是3600秒,实际开发按项目需求设置即可
Calendar instance = Calendar.getInstance();
instance.add(Calendar.SECOND,3600);
// 4. 调用JWT包的 builder 方法开始生成 token,tokenHeader。tokenPayload,失效时间,私钥都已准备就绪
String token = Jwts.builder()
.setHeader(tokenHeader) // 头部信息
.setClaims(tokenPayload) // 载荷信息
.setExpiration(instance.getTime()) // 失效时间
.signWith(SignatureAlgorithm.HS256,TOKEN_SECRET) // 签名算法,私钥
.compact(); // 压缩生成xxx.xxx.xxx
return token;
}
2.3 返回 token
这里我们在 login 登陆方法中添加 HttpServletResponse 响应参数,然后将生成的 token 对象添加至 response 中进行返回,客户端接收到相应之后将 token 存储到本地浏览器,后续再发送请求到服务器,都需要将 token 添加至请求头让服务器去做校验。
public Result login(LoginFormDTO loginForm, HttpSession session, HttpServletResponse response) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (!RegexUtils.isValidPhoneNumber(phone)) {
return Result.fail("手机号格式错误!");
}
// 2. 获取用户输入的验证码和密码,
String code = loginForm.getCode();
String password = loginForm.getPassword();
// 3. 根据手机号查询用户信息
User user = userMapper.selectByPhone(phone);
String tokenString = generateToken(user);
// 4. 先判断验证码 code 是否为空,不为空则说明用户是验证码登录
if (code != null) {
// 5. 从redis获取验证码并校验,
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
// 6. 如果redis中为空或而这不相等,说明验证码已过期或输入错误,返回登陆错误
if (cacheCode == null || !cacheCode.equals(code)) {
return Result.fail("验证码错误");
}
// 7. 判断用户是否存在,不存在,则调用 createUserWithPhone 方法创建新用户并保存
if (user == null) {
user = createUserWithPhone(phone);
}
response.setHeader("token", tokenString); // 将token存入响应头
return Result.ok();
} else if(password != null) {
// 8. 密码不为空,用户通过密码方式登录(实际开发过程中,密码是需要进行加密处理和解密处理的,这里就不做那么复杂了)
if (user.getPassword().equals(loginForm.getPassword())) {
response.setHeader("token", tokenString); // 将token存入响应头
return Result.ok();
}
}
return Result.fail("登陆失败");
}
2.4 JWT 的校验方式
由于我们返回给客户端的 token 中存储着 token 的加密方式,所以我们就不需要在服务器上存储 token,每次用户来进行访问是,带着 token 过来。
然后在服务器后端,我们获取用户的 token,再后端获取自定义的密钥,通过与生成 token 时同样的加密手法再次对用户发送过来的数据进行签名,将签名之后生成的新 token 与 用户携带的签名进行匹配,如果相等,说明用户校验通过,放行请求;如果匹配签名不相等,说明当前用户的签名被恶意修改过,则进行拦截禁止访问服务器。
并且这一步通常是在请求前完成的,大致逻辑如下所示;
用户发送请求——>先进入拦截器——>再做token校验——>校验通过才会到我们熟知的 Controller 层;
当然了,在实际项目中,微服务项目可能还有nginx负载均衡,Gateway网管等...总之我想表达的是,token 校验是在 请求到达 Controller 控制器层之前完成的;
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 获取请求头中的token
String token = request.getHeader("Authorization");
// 2. 获取密钥, 这里需要实现一个获取密钥的方法,当然也可以定义在工具类中直接获取
String secret = getSecret();
// 3. 使用io.jsonwebtoken依赖包已经提供的parser方法解析JWT,指定了JWT的签名算法和密钥。
try {
Claims claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
// 4. 获取token载荷中的各种用户信息,可以做一系列操作
System.out.println("User ID: " + claims.get("userId"));
System.out.println("User Name: " + claims.get("userName"));
} catch (SignatureException e) {
// 5. 如果解析失败,说明JWT不合法,直接返回false,表示拦截请求
System.out.println("Invalid JWT signature.");
}
// 6. 验证成功之后,还可以做一些其他操作,比如刷新 token 有效期等...
// 7. 返回
return true;
}