登录实现
前言
在自学java的过程中,每次做项目都绕不开登录这个茬,虽然不难,且没啥技术含量,但登录的实现综合了许多知识,我认为实现一个完整可用的登录逻辑对于复习和巩固java基础基础是分合适,所以写下这篇笔记。
下面是文章使用的一些依赖以及版本信息
- 数据库: mysql,mybatis
- 框架: springboot 3.0.5
- 数据库相关: mybatis,druid 1.2.22,mysql 8.0.33,redis
- 工具:lombok
- 验证码工具:EasyCaptcha
思路梳理
- 在注册接口中:
- 对前端传过来的
密码需要进行加密存储
,要验证用户名的唯一性
- 对前端传过来的
- 验证码接口中:
- 指定一个验证码Vo类,包含字段
image
和key
。 - 生成的图形
验证码的文本为code
,生成一个唯一key(如UUID)
,然后以键值对形式存入redis
中,并设置过期时间
。 - 将
验证码toBase64()的值为image
,以及key封装对象传回前端。这样前端根据image显示的每个验证码图片都有唯一的key
- 指定一个验证码Vo类,包含字段
- 登录接口中:
- 指定一个登录Vo类,字段包含用户的
username,password
,以及用户输入的验证码code
,以验证码图片对应的key
- 根据key从redis中查询并验证码文本,情况有:
用户未输入验证码
,验证码已过期
,验证码错误
- 验证账号,情况有:
用户未输入username
,无该账号
,密码错误
- 登录成功,生成
token
并返回
- 指定一个登录Vo类,字段包含用户的
- 关于token:
- 创建一个工具类,用于
生成token
和解析token
- token中设置密钥,密钥不能过简单
SecretKey secretKey = Keys.hmacShaKeyFor("密钥".getBytes());
- 在token中
claim()
自定义字段中存入能标识当前登录用户的信息字段 - 解析token中,从返回结果中获取我们在body部分自定义的用户信息即可
- 创建一个工具类,用于
- 异常处理:
- 可设置全局异常处理器
@RestControllerAdvice
- 可自定义异常来返回预期消息
public class MyAdviceException extends RuntimeException
- 可设置全局异常处理器
- 拦截器
- 指定一个拦截器类实现
HandlerInterceptor
接口中的preHandle
方法 - 在拦截器中获取token,解析token,执行拦截或放行
- 在MvcConfig类中注册自定义的拦截器
- 指定一个拦截器类实现
- ThreadLocal
- 指定一个工具类,用于在Threadlocal中
存入
和移除
在token
中解析的自定义当前登录用户信息 - 在拦截器中成功解析token时存入
- 在执行完所有方法回到拦截器中执行
afterCompletion
时移除
- 指定一个工具类,用于在Threadlocal中
演示项目
数据库创建
这里只创建一个用于演示的用户表1
2
3
4
5
6
7
8
9
10
11create database if not exists loginImplementation;
use loginImplementation;
create table tb_user(
id bigint auto_increment primary key comment '主键id',
username varchar(30) unique null comment '用户名',
password varchar(100) null comment '密码',
name varchar(50) null comment '姓名',
phone varchar(11) null comment '电话号码'
)创建一个简单的maven项目,并继承
spring-boot-starter-parent
依赖,引入如下依赖1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.xnj</groupId>
<artifactId>AboutLoginImplementation</artifactId>
<version>1.0-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.0.5</version>
</parent>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!--web启动器-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 数据库相关配置启动器 jdbctemplate 事务相关-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!--mybatis启动器-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>
<!-- druid启动器的依赖 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-3-starter</artifactId>
<version>1.2.22</version>
</dependency>
<!-- mysql驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<!--EasyCaptcha依赖-->
<dependency>
<groupId>com.github.whvcse</groupId>
<artifactId>easy-captcha</artifactId>
<version>1.6.2</version>
</dependency>
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--用于加密密码-->
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!--JWT登录认证相关-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
</dependencies>
</project>在
com.xnj
下创建启动类:1
2
3
4
5
6
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
}配置信息
配置端口号,mysql数据库连接以及redis连接1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17server:
port: 8080
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
druid:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.40.101:3306/loginImplementation?useUnicode=true&characterEncoding=utf-8&useSSL=false& allowPublicKeyRetrieval=true&serverTimezone=GMT%2b8
username: root
password: Xnj.123456
data:
redis:
host: 192.168.40.101
password: 123456
port: 6379
database: 10创建实体类
- 包名:
com.xnj.pojo
User
: 代表用户,包括id,username,password,name,phone。CaptchaVo
:代表图形验证码,包括验证码的图片信息image和key。LoginVo
:用于接收登录信息,包括username和password,以及验证码的key和用户填的值code。LoginUser
:用于在ThreadLocal中存入的当前登录用户基础信息。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47//用户
public class User {
private Long id;
private String username;
private String password;
private String name;
private String phone;
}
//"图像验证码"
public class CaptchaVo {
//验证码图片信息,这里并不指图片的url,而是图片本身
private String image;
//验证码key
private String key;
}
//接收登录信息
public class LoginVo {
//用户名
private String username;
//密码
private String password;
//验证码
private String code;
//验证码的key
private String key;
}
//ThreadLocal中存入的当前登录用户信息
public class LoginUser {
private Long userId;
private String username;
}
- 包名:
Mapper接口
演示项目中用于获取用户信息的相关接口1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public interface UserMapper {
//获取所有用户信息
List<User> getAll();
//通过id查询用户信息
User selectById(Long id);
//通过用户名查找用户数
long countByUsername(String username);
//保存用户信息
void insert(User user);
//通过用户名获取用户信息
User selectByUsername(String username);
}统一结果相应result
- 包路径:
com.xnj.result
Result
: 用于统一返回结果1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50//全局统一返回结果类
public class Result<T> {
//返回码
private Integer code;
//返回消息
private String message;
//返回数据
private T data;
public Result() {
}
private static <T> Result<T> build(T data) {
Result<T> result = new Result<>();
if (data != null)
result.setData(data);
return result;
}
public static <T> Result<T> build(T body, ResultCodeEnum resultCodeEnum) {
Result<T> result = build(body);
result.setCode(resultCodeEnum.getCode());
result.setMessage(resultCodeEnum.getMessage());
return result;
}
public static <T> Result<T> ok(T data) {
return build(data, ResultCodeEnum.SUCCESS);
}
public static <T> Result<T> ok() {
return Result.ok(null);
}
public static <T> Result<T> fail() {
return build(null, ResultCodeEnum.FAIL);
}
public static <T> Result<T> fail(Integer code, String message) {
Result<T> result = build(null);
result.setCode(code);
result.setMessage(message);
return result;
}
}ResultCodeEnum
:用于统一返回结果状态信息枚举类1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26/**
* 统一返回结果状态信息类
*/
public enum ResultCodeEnum {
USER_IS_EXIST_ERROR(301,"用户已存在"),
USER_NOT_EXIST_ERROR(301, "账号不存在"),
USER_LOGIN_CODE_EMPTY(503,"验证码为空"),
USER_CAPTCHA_CODE_EXPIRED(303, "验证码已过期"),
USER_LOGIN_CODE_ERROR(302,"验证码错误"),
USER_LOGIN_PASSWORD_ERROR(307, "密码错误"),
USER_LOGIN_AUTH(600, "用户未登录"),
TOKEN_EXPIRED(601, "token过期"),
TOKEN_INVALID(602, "token非法"),
SUCCESS(200,"success"),
FAIL(500,"fail");
private final Integer code;
private final String message;
ResultCodeEnum(Integer code, String message) {
this.code = code;
this.message = message;
}
}
- 包路径:
全局异常以及自定义异常
- 包路径:
com.xnj.exception
MyAdviceException
: 继承RuntimeException,自定义异常返回信息1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MyAdviceException extends RuntimeException{
private Integer code;
public MyAdviceException(Integer code, String message) {
super(message);
this.code = code;
}
public MyAdviceException(ResultCodeEnum resultCodeEnum) {
super(resultCodeEnum.getMessage());
this.code = resultCodeEnum.getCode();
}
}GlobalExceptionHandler
: 定义全局异常@RestControllerAdvice,在里面使用我们自定义的异常1
2
3
4
5
6
7
8
9
10
11//全局异常处理类
public class GlobalExceptionHandler {
public Result handlerException(MyAdviceException e){
e.printStackTrace();
return Result.fail(e.getCode(),e.getMessage());
}
}
- 包路径:
工具类
- 包路径:
com.xnj.utils
JWTUtil
:用于提供创建token,解析token的工具类1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45public class JWTUtil {
// 密钥
private static SecretKey secretKey = Keys.hmacShaKeyFor("UG8A6u22CD0MivcGUvtl1IFkMDV2xJae".getBytes());
// 生成token
public static String createToken(Long userId, String username){
String token = Jwts.builder()
.setExpiration(new Date(System.currentTimeMillis()+3600000)) //过期时间1小时
.setSubject("LOGIN_USER") //主题
.claim("userId",userId) //用户id
.claim("userName",username) //用户名
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
return token;
}
//解析token
public static Claims parseToken(String token){
if(token==null){
// 抛出异常,未登录
throw new MyAdviceException(ResultCodeEnum.USER_LOGIN_AUTH);
}
try {
// 解析token
JwtParser jwtParser = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build();
Jws<Claims> claimsJws = jwtParser.parseClaimsJws(token);
return claimsJws.getBody();
} catch (ExpiredJwtException e) {
throw new MyAdviceException(ResultCodeEnum.TOKEN_EXPIRED);
} catch (JwtException e) {
throw new MyAdviceException(ResultCodeEnum.TOKEN_INVALID);
}
}
//测试
public static void main(String[] args) {
System.out.println(createToken(1l, "admin"));
}
}LoginUserThreadLocal
:创建ThreadLocal对象,存储当前登录用户信息的工具类1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class LoginUserThreadLocal {
public static ThreadLocal<LoginUser> threadLocal = new ThreadLocal<>();
public static void setLoginUser(LoginUser loginUser) {
threadLocal.set(loginUser);
}
public static LoginUser getLoginUser() {
return threadLocal.get();
}
public static void clear() {
threadLocal.remove();
}
}
- 包路径:
拦截器
- 包路径:
com.xnj.interceptor
LoginInterceptor
: 实现HandlerInterceptor
来实现拦截功能1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class LoginInterceptor implements HandlerInterceptor {
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1. 获取token
String token = request.getHeader("authorization");
//2. 解析token
Claims claims = JWTUtil.parseToken(token);
//3.没有抛异常说明解析成功
//3.1将token中解析的用户信息存入LocalThread中
Long userId = claims.get("userId", Long.class);
String userName = claims.get("userName", String.class);
LoginUserThreadLocal.setLoginUser(new LoginUser(userId,userName));
//放行
return true;
}
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//4. 移除LocalThread中的用户信息
LoginUserThreadLocal.clear();
}
}
- 包路径:
com.xnj.config
MvcConfig
: 实现WebMvcConfigurer
注册拦截器1
2
3
4
5
6
7
8
9
10
11
12
13
public class MvcConfig implements WebMvcConfigurer {
private LoginInterceptor loginInterceptor;
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(this.loginInterceptor)
.addPathPatterns("/user/service/**")
.excludePathPatterns("/user/login/**");
}
}
- 包路径:
controller接口
- 包路径:
com.xnj.controller
ServiceController
: 代表一般项目中除登录逻辑以外的功能1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31/**
* user/service/**用于代表user的各种服务
*/
public class ServiceController {
private UserMapper userMapper;
//获取所有用户信息
public Result<List<User>> findAllUser(){
List<User> all = userMapper.getAll();
return Result.ok(all);
}
//通过id获取用户信息
public Result<User> findById({ Long id)
User user =userMapper.selectById(id);
return Result.ok(user);
}
//获取当前登录用户信息
public Result<User> findInfo(){
User user = userMapper.selectById(LoginUserThreadLocal.getLoginUser().getUserId());
return Result.ok(user);
}
}LoginController
:代表登录相关逻辑1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
public class LoginController {
private UserMapper userMapper;
private StringRedisTemplate stringRedisTemplate;
//获取图形验证码
public Result<CaptchaVo> getCode(){
//设置图形验证码的参数:长,宽,验证码位数
SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 4);
//生成二进制验证码图片,并将其代表的验证码设置为小写
String code = specCaptcha.text().toLowerCase();
//设置验证码在redis中的key
String key = "user:login:"+ UUID.randomUUID();
//将验证码存入redis,并设置过期时间,3分钟
stringRedisTemplate.opsForValue().set(key,code,3, TimeUnit.MINUTES);
CaptchaVo captchaVo = new CaptchaVo(specCaptcha.toBase64(),key);
return Result.ok(captchaVo);
}
//注册
public Result register({ User user)
//1.判断用户名是否已经存在
long count =userMapper.countByUsername(user.getUsername());
if(count>0){
throw new MyAdviceException(ResultCodeEnum.USER_IS_EXIST_ERROR);
}
//2.将密码加密
user.setPassword(md5Hex(user.getPassword()));
//3.添加用户
userMapper.insert(user);
return Result.ok();
}
//登录
public Result login({ LoginVo loginVo)
//1. 判断前端传的验证码是否为空
if(loginVo.getCode()==null){
//抛出异常,验证码为空
throw new MyAdviceException(ResultCodeEnum.USER_LOGIN_CODE_EMPTY);
}
//2. 从redis中获取验证码,并判断验证码是否过期
String code = stringRedisTemplate.opsForValue().get(loginVo.getKey());
if(code==null){
//抛出异常,验证码国企
throw new MyAdviceException(ResultCodeEnum.USER_CAPTCHA_CODE_EXPIRED);
}
//3. 验证验证码,判断验证码是否正确
if(!loginVo.getCode().equals(code)){
//抛出异常,验证码错误
throw new MyAdviceException(ResultCodeEnum.USER_LOGIN_CODE_ERROR);
}
//2. 判断用户是否存在
User user = userMapper.selectByUsername(loginVo.getUsername());
if(user==null){
//抛出异常,用户不存在
throw new MyAdviceException(ResultCodeEnum.USER_NOT_EXIST_ERROR);
}
//3. 判断密码是否正确
if(!user.getPassword().equals(DigestUtils.md5Hex(loginVo.getPassword()))){
//抛出异常,密码错误
throw new MyAdviceException(ResultCodeEnum.USER_LOGIN_PASSWORD_ERROR);
}
//4. 登陆成功生成token
String token = JWTUtil.createToken(user.getId(), user.getUsername());
return Result.ok(token);
}
}
- 包路径:
密码处理
形式:
用户的密码不会以明文
的形式保存到数据库中,而是先经过处理,然后将处理之后的密文
保存到数据库,这样能降低数据泄露导致的用户安全问题处理:
密码通常会使用单向函数
进行处理:- 【前端】->【明文:123456】->【单向函数】->【密文:e10adc3949ba59abbe56e057f20f883e】->【数据库】
依赖:
常用的处理密码的单向函数(算法)有MD5
、SHA-256
等,Apache Commons提供了一个工具类DigestUtils
,其中就包含上述算法的实现。
引入maven依赖如下:1
2
3
4
5<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.15</version>
</dependency>基本用法
- Base64编码和解码:
- 用途:将二进制数据编码为可打印的ASCII字符串,或将Base64编码的字符串解码回原始二进制数据。
- 适用场景:常用于在网络传输中传递二进制数据,或将二进制数据存储在文本文件中。
- URL编码和解码:
- 用途:将字符串进行URL编码,以便在URL中传递参数或数据。URL编码将特殊字符转换为%xx格式,其中xx是字符的十六进制ASCII码。
- 适用场景:常用于构建URL参数、解析URL参数,以及在网络中传递URL编码的数据。
- MD5哈希生成:
- 用途:通过对任意长度的数据进行哈希计算,生成固定长度(通常是128位)的哈希值。MD5哈希算法不可逆,相同的输入将始终生成相同的哈希值。
- 适用场景:常用于验证数据的完整性、存储密码的摘要,或用于简单的数据唯一性标识。
- SHA哈希生成:
- 用途:通过对任意长度的数据进行哈希计算,生成固定长度(通常是160位或256位)的哈希值。SHA哈希算法是安全的,用于验证数据的完整性和加密。
- 适用场景:常用于数据完整性验证、数字签名、密码存储和加密等安全领域的应用。
- Base64编码和解码:
使用实例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class CommonsCodecTest {
//1. md哈希生成
public void test01(){
// 原始文本
String originalText = "Hello, World!";
// 计算MD5哈希值
String md5Hash = DigestUtils.md5Hex(originalText);
System.out.println("MD5 Hash: " + md5Hash);//MD5 Hash: 65a8e27d8879283831b664bd8b7f0ad4
}
//2. SHA哈希生成
public void test02(){
// 原始文本
String originalText = "Hello, World!";
// 计算sha哈希值
String sha1Hash = DigestUtils.sha1Hex(originalText);
System.out.println("SHA-1 Hash: " + sha1Hash);//SHA-1 Hash: 0a0a9f2a6772942557ab5355d76af442f8f65e01
}
//3. Base64编码和解码
public void test03(){
// 原始文本
String originalText = "Hello, World!";
// 计算SHA-256哈希值
byte[] encodedBytes = Base64.encodeBase64(originalText.getBytes());
// 编码为字符串
String encodedText = new String(encodedBytes);
System.out.println("Encoded Text: " + encodedText);//Encoded Text: SGVsbG8sIFdvcmxkIQ==
// 解码
byte[] decodedBytes = Base64.decodeBase64(encodedText.getBytes());
// 解码为字符串
String decodedText = new String(decodedBytes);
System.out.println("Decoded Text: " + decodedText);//Decoded Text: Hello, World!
}
//4. URL编码和解码
public void test04() throws EncoderException, DecoderException {
// 原始文本
String originalText = "Hello, World!";
// 编码和解码
URLCodec urlCodec = new URLCodec();
// 编码
String encodedText = urlCodec.encode(originalText);
System.out.println("Encoded Text: " + encodedText);//Encoded Text: Hello%2C+World%21
// 解码
String decodedText = urlCodec.decode(encodedText);
System.out.println("Decoded Text: " + decodedText);//Decoded Text: Hello, World!
}
}总结:
这些方法在实际应用中经常被使用,根据具体的需求选择合适的方法进行编码和解码操作。请注意,MD5和SHA哈希算法虽然常用,但并不适合用于密码存储,因为它们容易受到暴力破解和碰撞攻击。对于密码存储,请使用专门设计的密码哈希函数(如bcrypt、scrypt或PBKDF2)。
上面的演示项目使用的MD5加密:1
user.setPassword(DigestUtils.md5Hex(user.getPassword()));
图形验证码处理
说明
这里使用开源的验证码生成工具EasyCaptcha,其支持多种类型的验证码,例如gif、中文、算术等,并且简单易用,具体内容可参考其官方文档。
为了方便发送和验证图形验证码,以及完成验证码失效机制,往往利用redis的键值对的存储形式,创建一个类,包含key
和code
,并在存入redis中时设置有效时间
1
2
3
4
5
6
7
8
9
10
11//"图像验证码"
public class CaptchaVo {
//验证码图片信息,这里并不指图片的url,而是图片本身
private String image;
//验证码key
private String key;
}引入maven依赖
1
2
3
4
5
6<!--EasyCaptcha依赖-->
<dependency>
<groupId>com.github.whvcse</groupId>
<artifactId>easy-captcha</artifactId>
<version>1.6.2</version>
</dependency>使用
- 在创建
SpecCaptcha
对象时指定验证码图片的长宽
和验证码的位数
specCaptcha.setCharType();
来设置验证码的样式specCaptcha.text();
获取验证码文本specCaptcha.toBase64();
将生成的验证码图片转换为Base64字符串
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class CaPtchaTest {
private StringRedisTemplate stringRedisTemplate;
//生成验证码
public void test(){
// 创建SpecCaptcha实例,定义验证码图片:长,宽,验证码位数
SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 4);
// 设置验证码样式参数 TYPE_DEFAULT=>数字字母混合
specCaptcha.setCharType(SpecCaptcha.TYPE_DEFAULT);
//获取验证码文本,并将其代表的验证码设置为小写
String code = specCaptcha.text().toLowerCase();
System.out.println("验证码文本: " + code);//验证码文本: kdh7
//将生成的验证码图片转换为Base64字符串
String codeImage = specCaptcha.toBase64();
System.out.println(codeImage);
}
}- 在创建
全局异常与自定义异常
说明
在登录逻辑中,无论是业务逻辑判断还是生成token和解析token时,出现错误信息时如,没有token,密码错误等,我们都可以自定义异常,再在全局异常中捕获来返回我们需要的异常信息定义自定义异常
- 指定一个类,继承需要的异常类如
RuntimeException
- 在该类中创建我们需要传给前端的信息如
code
,message
- 常见的状态码如下:
200
: 请求成功404
: 请求资源不存在500
: 服务器错误- 更多信息:响应状态码
- 常见的状态码如下:
1
2
3
4
5
6
7
8
9
10
11
12//自定义异常
public class MyAdviceException extends RuntimeException{
private Integer code;
public MyAdviceException(Integer code, String message) {
super(message);
this.code = code;
}
}- 指定一个类,继承需要的异常类如
定义全局异常处理器
- 指定一个类,加上相应注解
@RestControllerAdvice
或@ControllerAdvice
- 在该类下面定义方法,方法上加上对应注解
@ExceptionHandler()
- 更多信息:springboo3/springboo3整合Mvc/全局异常
1
2
3
4
5
6
7
8
9
10
11//全局异常处理类
public class GlobalExceptionHandler {
public Result handlerException(MyAdviceException e){
e.printStackTrace();
return Result.fail(e.getCode(),e.getMessage());
}
}- 指定一个类,加上相应注解
拦截器
说明
- 创建一个类实现
HandlerInterceptor
接口实现登录拦截功能 - 重写
preHandle
和afterCompletion
方法 - preHandle:在每次请求到来时,进行拦截,解析验证token的合法性
若需要,可将token中解析出的信息存入ThreadLocal
- afterCompletion: 结束所有请求后,销毁
ThreadLocal
中存储的信息
- 创建一个类实现
实现
- 定义拦截器
1
2
3
4
5
6
7
8
9
10
11
12
public class MyInterceptor implements HandlerInterceptor {
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
return true;
}
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
} - 注册拦截器
1
2
3
4
5
6
7
8
9
10
11
12
13
public class MvcConfig implements WebMvcConfigurer {
private MyInterceptor myInterceptor;
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new MyInterceptor())
.addPathPatterns("拦截的请求路径")
.excludePathPatterns("排除的路径");
}
}
- 定义拦截器
Java-JWT
登录接口需要为登录成功的用户创建并返回JWT
引入maven依赖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20<!--JWT登录认证相关-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>说明
创建一个JwtUtil工具类,里面提供创建token和解析token的方法1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44public class JWTUtil {
// 密钥 密钥可以去搜索随机密码
private static SecretKey secretKey = Keys.hmacShaKeyFor("密钥".getBytes());
// 生成token
public static String createToken(Long userId, String username){
String token = Jwts.builder()
.setExpiration(new Date(System.currentTimeMillis()+3600000)) //过期时间1小时
.setSubject("LOGIN_USER") //主题
.claim("userId",userId) //用户id
.claim("userName",username) //用户名
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
return token;
}
//解析token
public static Claims parseToken(String token){
if(token==null){
// 抛出异常,未登录
throw new MyAdviceException(ResultCodeEnum.USER_LOGIN_AUTH);
}
try {
// 解析token
JwtParser jwtParser = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build();
Jws<Claims> claimsJws = jwtParser.parseClaimsJws(token);
return claimsJws.getBody();
} catch (ExpiredJwtException e) {
throw new MyAdviceException(ResultCodeEnum.TOKEN_EXPIRED);
} catch (JwtException e) {
throw new MyAdviceException(ResultCodeEnum.TOKEN_INVALID);
}
}
//测试
public static void main(String[] args) {
System.out.println(createToken(1l, "admin"));
}
}使用
- 在登录的接口中,当用户账号各校验工作后,返回token给前端
1
2
3//登陆成功生成token
String token = JWTUtil.createToken(user.getId(), user.getUsername());
return Result.ok(token); - 在拦截器中,需要对token进行解析
1
2
3
4//1. 获取token
String token = request.getHeader("authorization");
//2. 解析token
Claims claims = JWTUtil.parseToken(token);
- 在登录的接口中,当用户账号各校验工作后,返回token给前端
ThreadLocal
说明
ThreadLocal是Java中提供的线程本地存储机制,可以利用该机制将数据缓存在某个线程内部,该线程可以在任意时刻、任意方法中获取缓存的数据- ThreadMap下存一个个实体
entry
- entry:包含
key
和value
部分- key:存放threadlocal
- value: 存放obbject
- entry:包含
- 实际就是操作
当前线程
上面的ThreadLocalMap
,将threadLocal放在key上面,再将threadlocal存储的值放在map的value当中。 - 如果你在当前实体上使用了多个threadlocal,它会存储多个entry放在Map当中
- ThreadLocal只是用来操作当前线程的ThreadLocalMap的工具类。
- set就是往ThreadLocalMap中存放,Map的key就是threadLoacl对象,value为需要缓存的值
- ThreadMap下存一个个实体
关于Threadlocal内存泄漏问题
在使用了ThreadLocal对象后,手动调用ThreadLocal的remove方法,手动清除Entry对象使用场景
当一个共享变量是共享的,但是需要每个线程互不影响,相互隔离,就可以使用ThreadLocal应用实例
- 如我们上面的案例,我们需要往Thread中存储当前登录用户LoginUserVo对象,定义如下工具类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class LoginUserThreadLocal {
public static ThreadLocal<LoginUser> threadLocal = new ThreadLocal<>();
public static void setLoginUser(LoginUser loginUser) {
threadLocal.set(loginUser);
}
public static LoginUser getLoginUser() {
return threadLocal.get();
}
public static void clear() {
threadLocal.remove();
}
} - 存:当我们在拦截器中验证token完成,即将放行时,将信息存入
1
2
3
4
5
6
7
8
9
10
11
12
13//1. 获取token
String token = request.getHeader("authorization");
//2. 解析token
Claims claims = JWTUtil.parseToken(token);
//3.没有抛异常说明解析成功
//3.1将token中解析的用户信息存入LocalThread中
Long userId = claims.get("userId", Long.class);
String userName = claims.get("userName", String.class);
LoginUserThreadLocal.setLoginUser(new LoginUser(userId,userName));
//3.2 放行
return true; - 取:查询当前登录用户信息时,从thread取出关键信息,再用该信息去查询用户详细信息
1
2
3
4
5User user = userMapper.selectById(LoginUserThreadLocal.getLoginUser().getUserId());
```
- 销毁:为防止内存泄漏,在使用完后需要销毁
```java
LoginUserThreadLocal.clear();
- 如我们上面的案例,我们需要往Thread中存储当前登录用户LoginUserVo对象,定义如下工具类
短信验证码实现
说明
- 当用户在前端登录时,输入电话号码,点击发送验证码,即可获取短信验证码
- 这里使用阿里的短信发送服务
开通阿里云短信服务
实现流程
- 引入如下依赖,具体可参考官方文档。
1
2
3
4
5<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
<version>3.1.0</version>
</dependency> - 配置环境
1
2
3
4
5
6# 短信验证码配置
aliyun:
sms:
access-key-id:
access-key-secret:
endpoint: dysmsapi.aliyuncs.com - 创建一个属性类用于获取配置属性
1
2
3
4
5
6
7
8
9
10
11
12package com.xnj.sms;
public class AliyunSMSProperties {
private String accessKeyId;
private String accessKeySecret;
private String endpoint;
} - 创建一个配置类,用于获取Client对象一定要注意别引错包了,不确定可以看官网,写得很清楚
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33package com.xnj.sms;
import com.aliyun.dysmsapi20170525.Client;
import com.aliyun.teaopenapi.models.Config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
public class AliyunSmsConfiguration {
private AliyunSMSProperties properties;
public Client createClient() {
// 创建Client对象
Config config = new Config();
// 配置AccessKeyId和AccessKeySecret
config.setAccessKeyId(properties.getAccessKeyId());
config.setAccessKeySecret(properties.getAccessKeySecret());
// 配置Endpoint
config.setEndpoint(properties.getEndpoint());
try {
// 实例化Client对象
return new Client(config);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
} - 写一个工具类,用于生成我们想要的验证码格式,下面的仅作示例参考
1
2
3
4
5
6
7
8
9
10
11
12
13
public class CodeUtil {
public static String getRandomCode(Integer length){
StringBuilder builder = new StringBuilder();
Random random = new Random();
for (int i = 0; i < length; i++) {
int number = random.nextInt(10);
builder.append(number);
}
return builder.toString();
}
} - 测试发送
注意,因为为测试使用,所以电话号码只能填你在阿里云里填的测试电话号码1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class PhoneCodeTest {
private Client client;
public void test01() throws Exception {
// 手机号
String phone = "";
// 验证码
String code = CodeUtil.getRandomCode(4);
//创建一个发送短信的请求对象
SendSmsRequest request = new SendSmsRequest();
//短信接收号码
request.setPhoneNumbers(phone);
//短信签名
request.setSignName("阿里云短信测试");
//短信模板code
request.setTemplateCode("SMS_154950909");
// 短信模板变量 参数必需以json字符串的形式
request.setTemplateParam("{\"code\":\"" + code + "\"}");
// 发送短信
client.sendSms(request);
}
}
- 引入如下依赖,具体可参考官方文档。
业务中实现
- 引入依赖,参考上面3.实现流程
- 配置文件中创建属性(access-key-id,access-key-secret,endpoint),参考上面3.实现流程
- 创建配置属性类和配置类,以及生成验证码的工具类,参考上面3实现流程
- 创建发送短信服务接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33//接口类
public interface CodeService {
void sendCode(String phone,String code);
}
//实现类
public class CodeServiceImpl implements CodeService {
private Client client;
public void sendCode(String phone, String code) {
// 发送验证码
SendSmsRequest request = new SendSmsRequest();
//短信接收号码
request.setPhoneNumbers(phone);
//短信签名
request.setSignName("阿里云短信测试");
//短信模板code
request.setTemplateCode("SMS_154950909");
// 短信模板变量 参数必需以json字符串的形式
request.setTemplateParam("{\"code\":\"" + code + "\"}");
// 发送短信
try {
client.sendSms(request);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
} - 引入redis依赖
1
2
3
4
5<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency> - 在LoginService中实现具体发送验证码登录逻辑
- 前端传入用户输入的手机号,点击发送验证码后
- 我们生成验证码和验证码的key(key能标识当前用户手机号)
- 判断redis中是否已经存在该用户请求发送的验证码
- 存在,获取旧验证码的有效时间
- 用我们预先设置的验证码有效时间 减去 旧验证码的剩余有效时间 满足我们设置的最低间隔时间
- 满足,发送新验证码;不满足,返回请求频繁信息,终止这次发送
- 发送新验证码,将新验证码以及key存入redis中,用于后续校验
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44//接口
public interface LoginService {
void sentCode(String phone);
}
//实现类
public class LoginServiceImpl implements LoginService {
private CodeService codeService;
private StringRedisTemplate redisTemplate;
//发送验证码
public void sentCode(String phone) {
//生成验证码
String code = CodeUtil.getRandomCode(4);
//验证码key
String key="user:code"+phone;
//防刷
//或redis中查找该key是否存在
Boolean haskey = redisTemplate.hasKey(key);
//如果存在,说明该手机号在5分钟内已发送过验证码
if(haskey){
//获取旧验证码的剩余有效时间
Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
//如果该用户的间隔时间小于1分钟
if(60*5- ttl < 60){
throw new MyAdviceException(403,"验证码发送过于频繁,请稍后再试");
}
}
//发送验证码
codeService.sendCode(phone,code);
//保存验证码到redis,并设置有效时间5分钟
redisTemplate.opsForValue().set(key,code,60*5, TimeUnit.SECONDS);
}
}1
2
3
4
5
6
7
public Result sentCode({ String phone)
loginService.sentCode(phone);
return Result.ok();
}