项目搭建
前期准备
导入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 '昵称,默认是用户id',
`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=1011 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=COMPACT;
创建项目
导入依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
<version>8.0.33</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!--hutool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.17</version>
</dependency>
</dependencies>
编写启动类
@MapperScan("com.liang.mapper")
@SpringBootApplication
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
}
编写配置文件
server:
port: 8081
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: #配置自己的数据库url
username: #配置自己的数据库用户名
password: #配置自己的密码
编写实体类
/**
* 登录信息
*/
@Data
public class LoginFormDTO {
private String phone;
private String code;
}
/**
* 统一结果返回
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {
private Boolean success;
private String errorMsg;
private Object data;
private Long total;
public static Result ok(){
return new Result(true, null, null, null);
}
public static Result ok(Object data){
return new Result(true, null, data, null);
}
public static Result ok(List<?> data, Long total){
return new Result(true, null, data, total);
}
public static Result fail(String errorMsg){
return new Result(false, errorMsg, null, null);
}
}
/**
* User实体类 对应数据库表tb_user
*/
@Data
@TableName("tb_user")
public class User implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 手机号码
*/
private String phone;
/**
* 密码,加密存储
*/
private String password;
/**
* 昵称,默认是随机字符
*/
private String nickName;
/**
* 用户头像
*/
private String icon = "";
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
/**
* 存储用户非敏感信息
*/
@Data
public class UserDTO {
private Long id;
private String nickName;
private String icon;
}
编写controller层
/**
* User对象前端控制器
*/
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
@Resource
private IUserService userService;
/**
* 发送手机验证码
* @param phone 手机号
* @param session
* @return
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
return userService.sendCode(phone, session)?Result.ok():Result.fail("手机号码不合规");
}
/**
* 登录功能
* @param loginForm
* @param session
* @return
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
return userService.login(loginForm, session) ? Result.ok() : Result.fail("手机号或验证码错误");
}
编写service层
public interface IUserService extends IService<User> {
boolean sendCode(String phone, HttpSession session);
boolean login(LoginFormDTO loginForm, HttpSession session);
}
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Override
public boolean sendCode(String phone, HttpSession session) {
return true;
}
@Override
public boolean login(LoginFormDTO loginForm, HttpSession session) {
return true;
}
}
Session实现登录
基于session实现登录流程
发送验证码
校验手机号是否合法
- 合法,生成验证码,并保存到session中、发送验证码给用户
- 不合法,提示用户手机号不合法
@Override
public boolean sendCode(String phone, HttpSession session) {
//获取手机号,验证手机号是否合规
boolean mobile = PhoneUtil.isMobile(phone);
//不合规,则提示
if (!mobile){
return false;
}
//生成验证码
String code = RandomUtil.randomNumbers(6);
//将验证码保存到session中
session.setAttribute("code",code);
//发送验证码
System.out.println("验证码:" + code);
return true;
}
验证码登录、注册
验证手机号是否合法,验证验证码是否正确
- 手机号不合法或验证码不正确,提示用户
验证成功后,查看该用户信息是否在数据库中
该用户信息在数据库中,则表明该用户是登录
- 用户信息保存到session中
该用户信息不在数据库中,则表明该用户是注册
- 在数据库中存储用户信息
- 用户信息保存到session中
(将用户信息存储在session中,主要是方便后序获取当前登录信息)
@Override
public boolean login(LoginFormDTO loginForm, HttpSession session) {
//获取手机号
String phone = loginForm.getPhone();
//验证手机号是否合理
boolean mobile = PhoneUtil.isMobile(phone);
//如果不合理 提示
if (!mobile){
//提示用户手机号不合理
return false;
}
//手机号合理 进行验证码验证
String code = loginForm.getCode();
String sessionCode = session.getAttribute("code").toString();
//如果验证码输入的是错误的 提示
if (!code.equals(sessionCode)){
return false;
}
//如果验证码也正确 那么通过手机号进行查询
User user = this.getOne(new LambdaQueryWrapper<User>().eq(User::getPhone, phone));
// 数据库中没查询到用户信息
if (ObjectUtil.isNull(user)){
user = new User();
user.setPhone(phone);
user.setNickName("user_"+ RandomUtil.randomString(10));
this.save(user);
}
// 将该用户信息存入session中
// 简化user,只存储必要信息以及不重要的信息
UserDTO userDTO = BeanUtil.toBean(user, UserDTO.class);
session.setAttribute("user", userDTO);
return true;
}
校验登录状态
用户发送请求时,会从cookie中携带JsessionId到后台,后台通过JsessionId从session中获取用户信息,
- 没获取到用户信息 则拦截,需要拦截器
- 获取到用户信息,则将用户信息保存到ThreadLocal中,再放行
- 自定义拦截器,实现HandlerInterceptor接口
public class LoginInterceptor implements HandlerInterceptor {
/**
* preHandle方法的返回值决定是否放行,该方法在控制层方法执行前执行
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HttpSession session = request.getSession();
UserDTO user = (UserDTO) session.getAttribute("user");
//判断是否在session中获取到了用户
if (ObjectUtil.isNull(user)){
return false;
}
UserHolder.saveUser(user);
return true;
}
/**
* postHandle方法在控制层方法执行后,视图解析前执行(可以在这里修改控制层返回的视图和模型)
* @param request
* @param response
* @param handler
* @param modelAndView
* @throws Exception
*/
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
}
/**
* fterCompletion方法在视图解析完成后执行,多用于释放资源
* @param request
* @param response
* @param handler
* @param ex
* @throws Exception
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
}
}
- 实现WebMvcConfigurer接口,通过重写addInterceptors方法添加自定义拦截器
@Configuration
public class MvcConfig implements WebMvcConfigurer {
/**
* 添加拦截器
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
//添加拦截器
registry.addInterceptor(new LoginInterceptor())
//放行资源
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
)
// 设置拦截器优先级
.order(1);
}
}
注意隐藏用户敏感信息:
我们应当在返回用户信息之前,将用户敏感信息进行隐藏,采用的核心思路就是创建UserDTO类,该类没有用户敏感信息,在返回用户信息之前,将有用户敏感新的的User对象转换为没有敏感信息的UserDTO对象,就可以有效的避免用户信息被泄露的问题。
Session存在问题
- 当单个tomcat服务器时,服务器崩溃,无法提供足够的处理能力时,系统可能不能使用,为了避免这些情况,提高系统的可用性、可伸缩性等,tomcat将会以集群的形式部署,集群部署的主要优势有:高可用性、可伸缩性、负载均衡、无中断升级。
集群部署的tomcat又面临新的问题,即session共享问题,由于每个tomcat都有一份属于自己的session,某个用户第一次访问tomcat时,把自己的信息存放到了编号01的tomcat服务器的session中,当第二次访问时,没有访问01服务器,而是访问到了其他tomcat服务器,而其他tomcat服务器没有该用户存放的session,此时整个登录拦截都会出现问题。
解决方式:
- 早期方案是session拷贝,即每当任意一台服务器的session修改时,都会同步到其他的tomcat服务器的session中,实现session共享。但此方式存在问题:1、session数据拷贝时,可能会出现延时;2、每台服务器中都有完整的一份session数据,服务器压力较大
- 现在方案是基于redis来完成,即把session换成redis,redis数据本身就是共享的,可以避免session共享问题。而且redis中数据是 key-value方式存储 和session一样便于操作,且都默认 存储在内存 中,响应速度快。
客户端发送请求,通过nginx负载均衡到下游的tomcat服务器(一台4核8G的tomcat服务器,在优化和处理简单业务的加持下,处理的并发量很有限),经过nginx负载均衡分流后,利用集群支撑整个项目,同时nginx在部署了前端项目后,做到了动静分离,进一步降低tomcat的压力,如果让tomcat直接访问mysql,一般16、32核CPU、32/64G内存,并发量在4k~7K左右,在高并发场景下也是容易崩溃,所有一般会使用mysql集群,同时为了进一步降低mysql压力,增加访问性能,一般会加入redis集群,以提供更好地服务。
Redis代替Session
redis中设计key
- 在使用session时,每个用户都会有自己的session,这样虽然验证码的键都是“code”,但是相互不影响,从而确保每个用户获取到的验证码只能够自己使用,当使用redis时,redis的key是共享的,不分用户,就要求在redis中存储验证码时,不能直接将验证码的键设置为"code",这样无法保证其唯一性。
redis中设计value
- String结构:以Json字符串来存储,比较直观
- Hash结构:,每个对象中每个字段独立存储,可以针对单个字段做CRUD
- 到底该使用redis中什么数据类型存储数据,主要需要看数据样式和使用方式,一般会考虑使用String、Hash,String存储时,会多占一点内存空间,则相对来说Hash存储时,会少占用一点内存空间。
Redis实现登录
发送验证码
添加Redis
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
设置Redis的连接信息
spring:
redis:
host: 192.168.175.128
port: 6379
password: liang
lettuce:
pool:
max-active: 10
max-idle: 10
min-idle: 1
time-between-eviction-runs: 10s
增加相关常量
/**
* 保存验证码的redis中的key
*/
public static final String LOGIN_CODE_KEY = "login:code:";
/**
* 验证码的过期时间
*/
public static final Long LOGIN_CODE_TTL = 2L;
修改Service层
@Autowired
StringRedisTemplate stringRedisTemplate;
@Override
public boolean sendCode(String phone, HttpSession session) {
//获取手机号,验证手机号是否合规
boolean mobile = PhoneUtil.isMobile(phone);
//不合规,则提示
if (!mobile){
return false;
}
//生成验证码
String code = RandomUtil.randomNumbers(6);
//保存验证码到redis,并设置过期时间
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
//发送验证码,这里就通过打印验证码模拟了下发送验证码
System.out.println("验证码:" + code);
return true;
}
修改Controller层
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
String uuid = userService.sendCode(phone, session);
return uuid.equals("") ? Result.fail("手机号码不合规"): Result.ok(uuid);
}
验证码登录、注册
增加相关常量
public static final String LOGIN_USER_KEY = "login:token:";
public static final Long LOGIN_USER_TTL = 30L;
修改Service层
@Override
public String login(LoginFormDTO loginForm, HttpSession session) {
//获取手机号
String phone = loginForm.getPhone();
//验证手机号是否合理
boolean mobile = PhoneUtil.isMobile(phone);
//如果不合理 提示
if (!mobile){
//提示用户手机号不合理
return "";
}
//手机号合理 进行验证码验证
String code = loginForm.getCode();
//从redis中获取验证码
String redisCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
//如果验证码输入的是错误的 提示
if (!code.equals(redisCode)){
return "";
}
//如果验证码也正确 那么通过手机号进行查询
User user = this.getOne(new LambdaQueryWrapper<User>().eq(User::getPhone, phone));
// 数据库中没查询到用户信息
if (ObjectUtil.isNull(user)){
user = new User();
user.setPhone(phone);
user.setNickName("user_"+ RandomUtil.randomString(10));
this.save(user);
}
// 将用户信息保存到Redis中,注意避免保存用户敏感信息
UserDTO userDTO = BeanUtil.toBean(user, UserDTO.class);
// 设置UUID保存用户信息
String uuid = IdUtil.fastSimpleUUID();
// 将user对象转化为Map,同时将Map中的值存储为String类型的
Map<String, Object> userDTOMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create().ignoreNullValue()
.setFieldValueEditor((key, value) -> value.toString()));
stringRedisTemplate.opsForHash().putAll( LOGIN_USER_KEY + uuid, userDTOMap);
//设置过期时间
stringRedisTemplate.expire(LOGIN_USER_KEY + uuid, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 通过UUID生成简单的token
String token = uuid + userDTO.getId();
return token;
}
修改Controller层
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
String token = userService.login(loginForm, session);
return StrUtil.isNotBlank(token) ? Result.ok(token) : Result.fail("手机号或验证码错误");
}
校验登录状态
修改LoginInterceptor拦截器
private StringRedisTemplate stringRedisTemplate;
/**
* 构造函数
* @param stringRedisTemplate
*/
public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* preHandle方法的返回值决定是否放行,该方法在控制层方法执行前执行
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//从请求头中获取token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)){
return false;
}
String uuid = token.substring(0,token.lastIndexOf("-"));
System.out.println(uuid);
//从redis中获取值
Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + uuid);
if (ObjectUtil.isNull(entries)){
return false;
}
//将map转化为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(entries, new UserDTO(), true);
//将用户信息保存到 ThreadLocal
UserHolder.saveUser(userDTO);
return true;
}
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
StringRedisTemplate stringRedisTemplate;
/**
* 添加拦截器
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
//添加拦截器
registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
//放行资源
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
)
// 设置拦截器优先级
.order(1);
}
}
登录状态的刷新问题
因为设置了redis中存储的用户的有效期,所以在用户访问界面的时,需要更新token令牌的存活时间,例如修改LoginInterceptor拦截器,在此拦截器中刷新过期时间
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//从请求头中获取token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)){
return false;
}
String uuid = token.substring(0,token.lastIndexOf("-"));
System.out.println(uuid);
//从redis中获取值
Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + uuid);
if (ObjectUtil.isNull(entries)){
return false;
}
//将map转化为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(entries, new UserDTO(), true);
//将用户信息保存到 ThreadLocal
UserHolder.saveUser(userDTO);
//刷新token有效期
stringRedisTemplate.expire(LOGIN_USER_KEY + uuid, LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
但是需要注意的是,自定义的登录拦截器只是针对需要登录访问的请求进行了拦截,如果用户访问没被拦截的请求,该拦截器不会生效,则token令牌不能进行更新,当用户长时间访问不需要登录的页面,token令牌失效,再去访问被拦截的请求,则需要重新登录,这是不合理的。所有我们还需要在定义一个拦截器,进行token令牌刷新。
刷新令牌的Interceptor
/**
* 刷新令牌的拦截器
* @author liang
*/
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate redisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//从请求头中获取token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)){
return false;
}
String uuid = token.substring(0, token.lastIndexOf("-"));
//从Redis中获取值
Map<Object, Object> userMap = redisTemplate.opsForHash().entries(LOGIN_USER_KEY + uuid);
if (ObjectUtil.isNull(userMap)){
return false;
}
//将map转换为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//将用户信息保存到 ThreadLocal
UserHolder.saveUser(userDTO);
//刷新token有效期
redisTemplate.expire(LOGIN_USER_KEY + uuid, LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
修改登录的Interceptor
public class LoginInterceptor implements HandlerInterceptor {
/**
* preHandle方法的返回值决定是否放行,该方法在控制层方法执行前执行
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
UserDTO user = UserHolder.getUser();
return ObjectUtil.isNotNull(user);
}
}
修改WebMvcConfigurer配置类
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
StringRedisTemplate stringRedisTemplate;
/**
* 添加拦截器
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
//添加拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate));
registry.addInterceptor(new LoginInterceptor())
//放行资源
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
)
// 设置拦截器优先级
.order(1);
}
}