笔记导读:
我之前做前后端分离的项目的时候在想,第一次登录一个网站过后后续登录就可以直接访问太神奇了,后来熟练了发现是后端的过滤器和拦截器实现的, 最近在跟着视频组做项目时候我发现自己有点忘记了前端是怎么配合后端完成的,本文带5分钟过一遍登录状态的校验
后端-----登录状态凭证session
需求分析当
比如bilibili ,阿里云,用户登录后,游览器下一此访问用户信息时可以无需登录直接使用,这个时候就想到为什么没有登录时候页面发生跳转,以及登陆后在访问时候去却可以不需要再次登录,前端是根据什么判断用户状态跳转页面的?
拦截器完成校验
这里使用session存放数据完成验证,下一案列使用jwt完成(java web token)
controller:
*
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
* @RequestBody json的后台解析
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
//俩个参数是因为一个是验证码登录 验证码 loginform是密码登录
return userService.login(loginForm,session);
}
service实现类:
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1. 校验登录时候的手机号是否合法
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号格式错误");
}
// 2. 校验验证码
String scode =(String) session.getAttribute(HmConstants.CODE_BY_PHOINE);
String sphone =(String) session.getAttribute(HmConstants.PH_BY_CODE);
System.out.println(sphone+"|"+phone);
if (!phone.equals(sphone)){//避免验证码到了 用户用另一手机号填写
return Result.fail("填写手机号和发送验证码手机号不一致");
}
if(Objects.isNull(scode))
{
return Result.fail("为空");
}
if(!scode.equals(loginForm.getCode())){//之前使用的是!= string 和object 引用对象之间用equals
return Result.fail("验证码不一致");
}
// 3. 根据手机号在数据查询用户
User one = query().eq("phone", loginForm.getPhone()).one();
//判断用户是否存在
if (Objects.isNull(one)){
//4.如果不存在创建新用户 并且保存数据
one = new User();//新申请内存
one.setPhone(loginForm.getPhone());
one.setPassword(UUID.randomUUID().toString());//因为是验证码登录注册 所以密码直接uuid 后期用户自己修改
one.setNickName(HmConstants.USER_FIX+RandomUtil.randomString(10));
// one.setCreateTime(new Date().toString());
boolean b = save(one);//添加用户
System.out.println("保存结果:"+b);
}
//5.存在保存用户信息到session
//todo 优化后期直接存取redis 以及vo对象
session.setAttribute(HmConstants.USER_INFO,one);
log.debug("存储的信息:"+ session.getAttribute(HmConstants.USER_INFO));
// 返回 无需返回登录凭证
/**
* 每一个session 都有唯一session id(访问tomcat服务器自动写在cookie中)
* 请求中携带cookie(sessionid)
* 游览器根据sessionid 即可放行
*/
return Result.ok();//前端做了放行
}
用到的手机号格式判断utls
public class RegexUtils {
/**
* 是否是无效手机格式
* @param phone 要校验的手机号
* @return true:符合,false:不符合
*/
public static boolean isPhoneInvalid(String phone){
return mismatch(phone, RegexPatterns.PHONE_REGEX);
}
/**
* 是否是无效邮箱格式
* @param email 要校验的邮箱
* @return true:符合,false:不符合
*/
public static boolean isEmailInvalid(String email){
return mismatch(email, RegexPatterns.EMAIL_REGEX);
}
/**
* 是否是无效验证码格式
* @param code 要校验的验证码
* @return true:符合,false:不符合
*/
public static boolean isCodeInvalid(String code){
return mismatch(code, RegexPatterns.VERIFY_CODE_REGEX);
}
// 校验是否不符合正则格式
private static boolean mismatch(String str, String regex){
if (StrUtil.isBlank(str)) {
return true;
}
return !str.matches(regex);
}
}
userholder 用于存放 验证数据
/**
* 工具类· 用于过滤器每次处理完handler之前校验用户是否处于登录状态
*/
public class UserHolder {
private static final ThreadLocal<UserVo> tl = new ThreadLocal<>();
public static void saveUser(UserVo user){
tl.set(user);
}
public static UserVo getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}
拦截器:
对前端发送的请求进行验证
判断是否具有存入session的登录状态凭证(是否能读取到数据) 并且存入凭证
@Slf4j
@Component//交给ioc去管理配置的时候 无需new 一个内存空间 可以使用自动装配
public class loginIterceptor implements HandlerInterceptor {
// 进入controller 获取接口之前
// 每一个请求前对用户进行校验是否放行 登录状态放行
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.我们的用户信息存放在session中间的 所以我们从请求中需要获取 session
HttpSession session = request.getSession();
//2.获取session中的用户
Object sessonuser = session.getAttribute(HmConstants.USER_INFO);
//3.判断session的用户是否纯在
if (Objects.isNull(sessonuser)){
//不存在在的拦截 给前端抛出异常 或设置响应体状态码
response.setStatus(401);//response是返回给客户端的 比如第一次访问的时候 sessionid
// 就是以为cookie携带的 写入response 返回给客户端
// 客户端的request在每次请求都会携带cookie
// 服务器端根据(cookie)拿到参数session request.getSession,还是用httpsession接受都是如此2
return false;//fasle 拦截 true 放行
}
//4.存在的话把用户信息保存到ThreadLocal (当前线程线程)
UserHolder.saveUser(BeanCopyUtils.copyBean(sessonuser,UserDTO.class));
// 5. 放行
return true;
}
//controller执行之后
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
}
// 视图渲染之后
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//每次视图渲染完 在存入THreadLoccal的线程释放 避免线程堵塞
UserHolder.removeUser();
}
}
拦截器写好后需要在配置类交给ioc经行注册
@Configuration//申明为配置类
public class MvcConfig implements WebMvcConfigurer {
@Autowired
private com.hmdp.Interceptor.loginIterceptor logininterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(logininterceptor).excludePathPatterns(
"/user/code",
// 拦截器也是chain 可以链式编程 排除无需拦截的请求路径
"/shop/**",
"/shop-type/**",
// "/upload/**",上传图片接口登录后使用
"/voucher/**",
"/blog/hot",
"/user/login"
);//不用在new 拦截器 并且需要配置拦截路径 避免全部看拦截
}
}
逻辑梳理:
前端第一次访问后端,servlet会自动用cookie携带sessionID创建session,且客户端再次访问都会携带cookie,当后端接收到前端传递的登录信息,我们调用mysql查询等操作完成以后在session存放用户数据
既然session域里面有数据,那么前端页面是不是可以在页面初始化时候进行访问一个校验判断呢,如果访问到对应数据说明在对应页面是登录状态,否则跳转登录页面,并且拦截登录后才能访问的接口
上面描述是手机号登陆,实际开发中往往为了数据库安全会对保存在数据库的密码进行加密
每个用户的盐是一个uuid或者时间搓组合的唯一值
通过这种方式更为安全,当然前端也不能使用明文, 比如前端和后端用统一的盐进行加密解密在进行二次加解密密
- 要么传递的时候前端调用第三方库加密,然后配合后端的盐在二次加密
- 要么就对明文密码进行可解密的加密,后端进行解密,配合明文密码+盐进行保存
比较常见的就是非对称加密
前端公钥加密:
const publicKey = '040a302b5e4b961afb3908a4ae191266ac5866be100fc52e3b8dba9707c8620e64ae790ceffc3bfbf262dc098d293dd3e303356cb91b54861c767997799d2f0060'
/**
* sm2加密
* '04' 表示你使用的是非压缩的公钥格式。
* 非压缩格式的公钥以 0x04 开头,后跟 64 字节的公钥数据
* @param data 待加密数据
* @return 加密后的数据
*/
export const sm2Encrypt = (data: string): string => {
return '04' + sm2.doEncrypt(data, publicKey, 1)
}
后端私钥解密
public class Sm2Util {
/**
* 公钥
*/
private final static String PUBLIC_KEY = "3059301306072a8648ce3d020106082a811ccf5501822d0342000481a31a3736c3432b6dc7098764a61afb366efc3db2116aac3e6f4014280e5efdf9a9aec78be527a9ee58dd409d365390fa22286a601a003f9bc335efdb630ac8";
/**
* 私钥
*/
private final static String PRIVATE_KEY = "308193020100301306072a8648ce3d020106082a811ccf5501822d047930770201010420f99b69134df440dcc4e167304c3d24f0e5cb546958b9c44800094a481cecc2a4a00a06082a811ccf5501822da1440342000481a31a3736c3432b6dc7098764a61afb366efc3db2116aac3e6f4014280e5efdf9a9aec78be527a9ee58dd409d365390fa22286a601a003f9bc335efdb630ac8";
private final static SM2 sm2;
static {
sm2 = SmUtil.sm2(PRIVATE_KEY, PUBLIC_KEY);
}
/**
* 加密
*
* @param data 明文
* @return 加密后的密文
*/
public static String encrypt(String data) {
// 加密后的数据为base64编码
return sm2.encryptBase64(data, KeyType.PublicKey);
}
/**
* 解密
*
* @param data 加密后的密文
* @return 解密后的明文
*/
public static String decrypt(String data) {
return sm2.decryptStr(data, KeyType.PrivateKey);
}
public static void main(String[] args) {
KeyPair keyPair = SecureUtil.generateKeyPair("SM2");
System.out.println("privateKey:" + HexUtil.encodeHexStr(keyPair.getPrivate().getEncoded()));
System.out.println("publicKey:" + HexUtil.encodeHexStr(keyPair.getPublic().getEncoded()));
PublicKey publicKey = keyPair.getPublic();
if (publicKey instanceof BCECPublicKey) {
// 获取65字节非压缩缩的十六进制公钥串(0x04) 前端携带
String publicKeyHex = Hex.toHexString(((BCECPublicKey) publicKey).getQ().getEncoded(false));
System.out.println("SM2公钥:" + publicKeyHex);
}
PrivateKey privateKey = keyPair.getPrivate();
if (privateKey instanceof BCECPrivateKey) {
// 获取32字节十六进制私钥串
String privateKeyHex = ((BCECPrivateKey) privateKey).getD().toString(16);
System.out.println("SM2私钥:" + privateKeyHex);
}
String password = "admin";
String sm2Password = Sm2Util.encrypt(password);
System.out.println("sm2 加密:" + sm2Password);
System.out.println("sm2 解密:" + Sm2Util.decrypt(sm2Password));
System.out.println("sm3 解密:" + SmUtil.sm3("admin"));
}
}
那么现在采用jwt实现登陆校验
后端-----登录状态凭证(Jwt)实现
引入依赖
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
JWT工具类
public class AppJwtUtil {
// TOKEN的有效期一天(S)
private static final int TOKEN_TIME_OUT = 3_600;
// 加密KEY
// 随机生成的加密key这里使用uuid
private static final String TOKEN_ENCRY_KEY = "23ee9c693446422cb8f84bea449fca3d";
// 最小刷新间隔(S)
private static final int REFRESH_TIME = 300;
// 生产ID
public static String getToken(Long id){
Map<String, Object> claimMaps = new HashMap<>();
claimMaps.put("id",id);
long currentTime = System.currentTimeMillis();
return Jwts.builder()
.setId(UUID.randomUUID().toString())
.setIssuedAt(new Date(currentTime)) //签发时间
.setSubject("system") //说明
.setIssuer("houhou") //签发者信息
.setAudience("app") //接收用户
.compressWith(CompressionCodecs.GZIP) //数据压缩方式
.signWith(SignatureAlgorithm.HS512, generalKey()) //加密方式
.setExpiration(new Date(currentTime + TOKEN_TIME_OUT * 1000)) //过期时间戳
.addClaims(claimMaps) //cla信息 存放的个人设置信息
.compact();
}
/**
* 获取token中的claims信息
*
* @param token
* @return
*/
private static Jws<Claims> getJws(String token) {
return Jwts.parser()
.setSigningKey(generalKey())
.parseClaimsJws(token);
}
/**
* 获取payload body信息
*
* @param token
* @return
*/
public static Claims getClaimsBody(String token) {
try {
return getJws(token).getBody();
}catch (ExpiredJwtException e){
return null;
}
}
/**
* 获取hearder body信息
*
* @param token
* @return
*/
public static JwsHeader getHeaderBody(String token) {
return getJws(token).getHeader();
}
/**
* 是否过期
*
* @param claims
* @return -1:有效,0:有效,1:过期,2:过期
*/
public static int verifyToken(Claims claims) {
if(claims==null){
return 1;
}
try {
claims.getExpiration()
.before(new Date());
// 需要自动刷新TOKEN
if((claims.getExpiration().getTime()-System.currentTimeMillis())>REFRESH_TIME*1000){
return -1;
}else {
return 0;
}
} catch (ExpiredJwtException ex) {
return 1;
}catch (Exception e){
return 2;
}
}
/**
* 由字符串生成加密key
*
* @return
*/
public static SecretKey generalKey() {
byte[] encodedKey = Base64.getEncoder().encode(TOKEN_ENCRY_KEY.getBytes());
SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
return key;
}
public static void main(String[] args) {
System.out.println(UUID.randomUUID().toString().replace("-", ""));
// Map map = new HashMap();
// map.put("id","11");
// 输入id进行加密
System.out.println(AppJwtUtil.getToken(1102L));
// 解密
Jws<Claims> jws = AppJwtUtil.getJws("eyJhbGciOiJIUzUxMiIsInppcCI6IkdaSVAifQ.H4sIAAAAAAAAADWLSwrDMAwF76J1DLLkX3MbCxvqQKjBNrSU3j3KovAWbxjmC8dssEOo5BPHYGJxxbhEZJKIGPbJClYkdggbtDxhtxHZo-MQNhhLtB6fMet5-zEUn6-lU8yrKObe9dd3_6fxcadNnbVIvwvlMJ1ThAAAAA.x9g1ieEmEc6AoRTu3dtiPC96_GBg-q7KvWoFHicDU1d-3WHl1HF-SoJK2EtCvlTSydtvhEMX6N5zv1YVUjBB8A");
// 获取jwt加密的信息
Claims claims = jws.getBody();
System.out.println(claims.get("id"));
}
}
登陆逻辑
@Override
public ResponseResult login(UserDto details) {
// 最后返回给前端的数据
Map<String, Object> map=new HashMap<String, Object>();
if(details==null){
return ResponseResult.errorResult(1002,"没有登陆参数");
}
User user = lambdaQuery().eq(details.getName() != null, User::getName, details.getName()).one();
if (user== null){
return ResponseResult.errorResult(AppHttpCodeEnum.AP_USER_DATA_NOT_EXIST);
}
String salt = user.getSalt();
String s = DigestUtils.md5DigestAsHex((salt + details.getPassword()).getBytes(StandardCharsets.UTF_8));
if (!s.equals(user.getPassword())){
return ResponseResult.errorResult(1001,"密码错误");
}
// 说明登陆成功,返回jwt
String token = AppJwtUtil.getToken(user.getId().longValue());
// 返回给前端 前端登陆后的请求交给过滤器器进行携带
map.put("token", token);
// 由于返回给前端的信息有些敏感信息不饿能返回,这里需要设置为空
UserDto dto = new UserDto();
BeanUtils.copyProperties(user, dto);
dto.setPassword("");//密码设置为空
map.put("user", dto);
return ResponseResult.okResult(map);
}
拦截器和配置类注册
@Component
@Slf4j
public class AuthorInterceptor implements HandlerInterceptor {
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
ThreadUserLocalUtil.clear();
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = null;
// 如果是 OPTIONS 请求,我们就让他通过,不管他
if (request.getMethod().equals("OPTIONS")) {
response.setStatus(HttpServletResponse.SC_OK);
return true;
// 如果不是,我们就把token拿到,用来做判断
}else {
token = request.getHeader("Authorization");
log.info("token:{}",token);
if (token == null){
log.info("token为空");
response.setStatus(401);
return false;
}
Claims claims = AppJwtUtil.getClaimsBody(token);
int validationResult = AppJwtUtil.verifyToken(claims);
if (validationResult == 1 || validationResult == 2) {
response.setStatus(401);
return false;
}
// -1:有效,0:有效,1:过期,2:过期
Long id = (Long)claims.get("id");
if (id == null){
response.setStatus(401);
return false;
}
User user = new User();
user.setId(id);
ThreadUserLocalUtil.setThreadLocal(user);
return true;
}
}
}
配置类
@Configuration//申明为配置类
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new AuthorInterceptor()).order(1).excludePathPatterns(
"/login/**","/upload/**"
// 注册
);
}
}
关于threadlocal
用于在多线程环境中为每个线程提供独立的变量副本。它确实可以被视为一种线程级别的共享副本机制。
详细解读ThreadLocal:
线程封闭性(Thread Confinement):ThreadLocal允许你创建的变量只在当前线程内部可见,其他线程无法直接访问到这个变量。这样就实现了一种线程封闭性,使得每个线程都可以拥有自己独立的变量副本,互不干扰。
为每个线程存储变量副本:当你在一个线程中设置ThreadLocal变量时,这个变量只会被当前线程所拥有,其他线程无法直接访问到。每个线程都可以独立地修改自己的副本,而不会影响其他线程的副本。
线程范围内的共享副本:尽管每个线程拥有自己独立的变量副本,但在整个线程范围内,所有该线程执行的方法都可以访问到这个副本。这种方式使得线程内部的方法可以方便地共享数据,而无需通过参数传递。
避免线程安全问题:由于每个线程都拥有自己独立的变量副本,因此不同线程之间的操作不会相互干扰,从而避免了多线程并发访问时的线程安全问题。这样的机制在一些场景下非常有用,特别是在实现线程安全的单例模式、数据库连接管理等方面。
内存泄漏风险:需要注意的是,由于ThreadLocal中使用了弱引用,如果在使用完毕后没有手动清理或者及时清理ThreadLocal中的变量,可能会导致内存泄漏问题。所以用线程副本拉作用存储上下文的情况是相当合理的
如果看过threadLocal的源码,你会发现在threadLocal中,无论是他的put方法和他的get方法, 都是先从获得当前用户的线程,然后从线程中取出线程的成员变量map,只要线程不一样,map就不一样,所以可以通过这种方式来做到线程隔离
除了公用接口和login接口都经行拦截,包括校验接口(只有登录后在校验),有登录凭证再经行校验(是否session过期需要登录之类)
校验接口:
当然除了在session中存储 还可以在redis中完成该操作
核心思路分析:
> 每个tomcat(java服务端应用)中都有一份属于自己的session,但是如果我的后端是个集群假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户请求带着token访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了
但是这种方案具有两个大问题
1、每台服务器中都有完整的一份session数据,服务器压力过大。
2、session拷贝数据时,可能会出现延迟
后来采用的方案都是基于redis来完成,我们把session换成redis,redis数据本身就是共享的,就可以避免session共享(多台服务共享登录凭证)的问题了
userholder 把用户信息存放发在线程容器中
/**
* 工具类· 用于过滤器每次处理完handler之前校验用户是否处于登录状态
*/
public class UserHolder {
private static final ThreadLocal<UserVo> tl = new ThreadLocal<>();
public static void saveUser(UserVo user){
tl.set(user);
}
public static UserVo getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}
token 保存在redis中
/**
* 通过拦截器实现登录校验功能 过滤器也可以完成这个功能
* springsecurity 也可以完成对应功能
* controller 也叫做hander
* 拦截器设置好后需要配置才能生效
*/
//单一拦截器就可以做到对请求查询redis 刷新token有效期 但是连续访问的请求如果在拦截器排除的路径之外的我公共接口 时间到了以后又会过期 重而用户重新登录
// 所以我们需要把功能分成多个部分实现 特定功能
@Slf4j
/**
* 2. 这个拦截器的功能则是判断是否需要做拦截
*/
public class loginIterceptor implements HandlerInterceptor {
// 进入controller 获取接口之前
// 每一个请求前对用户进行校验是否放行 登录状态放行
//拦截器在ioc初始化之前执行 所以不能在使用的类上使用自动装配 即使加入@Compoent,Conreoller,Service,mapper等注解注入ioc也不行
private StringRedisTemplate stringRedisTemplate; //所以现在我们不装配这个对象等拦截器加入配配置类的时候作为构造函数的参数注入
public loginIterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.我们的用户信息存放在Redis中间的,token为key 所以我们从请求头中需要获取token
String token = request.getHeader("authorization");
//1.2判断是否为空
boolean b = StringUtils.isEmpty(token);
if (b){
//这个拦截器的主要目的是刷新token数据凭证的有效期
response.setStatus(401);
return false;//拦截请求 未携带token 处于未登录状态
}
//2.获取redis中的用户 entries 包含了为空判断的方法
Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(HmConstants.LOGIN_Token + token);
//3.判断存储的的用户是否纯在
if (map.isEmpty()){
//不存在在的拦截 给前端抛出异常 或设置响应体状态码
response.setStatus(401);//response是返回给客户端的 比如第一次访问的时候 sessionid
return false;//fasle 拦截 true 放行
}
//4. 获取到的是map数据需要转换为user对象 所以需要bean的转化
UserVo userVo= BeanUtil.fillBeanWithMap(map,new UserVo(), false);
//4.存在的话把用户信息保存到ThreadLocal (当前线程线程)
UserHolder.saveUser(userVo);
// 6. 放行
return true;
}
//controller执行之后
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
}
// 视图渲染之后
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//每次视图渲染完 在存入THreadLoccal的线程释放 避免线程堵塞
UserHolder.removeUser();
}
}
拦截器的工作还是从解析当前请求所属用户,并把当前用户信息保存到处理当前请求的线程
使用redis后的login
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1. 校验登录时候的手机号是否合法
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号格式错误");
}
// 2. 校验验证码 因为验证码读取校验的时候是通过手机号 取数据 所以不存在验证码正常手机号对不上
String scode =stringRedisTemplate.opsForValue().get(HmConstants.CODE_BY_PHOINE+phone);
if(Objects.isNull(scode))
{
return Result.fail("验证码失效");
}
if(!scode.equals(loginForm.getCode())){//之前使用的是!= string 和object 引用对象之间用equals
return Result.fail("验证码不一致");
}
// 3. 根据手机号在数据查询用户
User one = query().eq("phone", loginForm.getPhone()).one();
//判断用户是否存在
if (Objects.isNull(one)){
//4.如果不存在创建新用户 并且保存数据
User user = new User();//新申请内存
user.setNickName(HmConstants.USER_FIX+RandomUtil.randomString(10));
user.setPhone(loginForm.getPhone());
boolean b = save(user);//添加用户
System.out.println("保存结果:"+b);
}
//5.存在保存用户信息到session/redis
//5.1一个token发送给前端作为登录令牌 将userInfo转化为map 存储
String token = UUID.randomUUID().toString(true);//不携带-符号
Map<String, Object> hashMap = new HashMap<>();
UserVo userVo = BeanUtil.copyProperties(one, UserVo.class);
hashMap=BeanUtil.beanToMap(userVo,new HashMap<>(), CopyOptions.create()//创建复制选项 自定义
.setIgnoreNullValue(true).setFieldValueEditor(
(fieldName,fieldValue)->fieldValue.toString()));//所有fieldname 对应的value转化为toString
//上面的操作用put挨个放入map 到id时候tostring 也是一样的
stringRedisTemplate.opsForHash().putAll(HmConstants.LOGIN_Token+token,hashMap);//建议使用putAll 不需要服务端经行多次io流操作
stringRedisTemplate.expire(HmConstants.LOGIN_Token+token,60*60,TimeUnit.SECONDS); // 设置用户在redis中有效期为1小时
// 在拦截器设置用户发起一次请求更新一次有效器 让做到 只有不操作的时候才会一小时过期
return Result.ok(token);//前端做了放行 token返回给前端 使其后续访问需要登录状态的接口用token作为校验凭证
}
此时就讲前端传递的token令牌和用户前缀作为key,用户信息为value传入redis,每次请求携带的时候后端拦截器进行校验,这里就做到每个每次用户访问的时候,携带的token与用户信息无关,如果是jwt字符携带用户信息还是有可能被用户解码,为此这样做的话用户信息就不会被泄露
分析俩种方式
两种思路:
1.JWT 方案: 用户登录成功后,后端生成一个包含用户信息的 JWT(JSON Web Token),将其返回给前端。前端在后续请求中将 JWT 携带在请求头中,后端通过解码 JWT 获取用户信息,从而验证用户的身份和登录状态。
2.UUID 结合 Redis 方案: 用户登录成功后,后端生成一个带有用户信息的 UUID,并将该 UUID 作为 key,用户信息存储在 Redis 中。后端将 UUID 返回给前端,前端在后续请求中将该 UUID 携带在请求头中,后端通过 Redis 中的 key 获取用户信息,验证用户的身份和登录状态。
让我们分析一下这两种思路的优缺点:
JWT 方案:
优点:
- 无状态: JWT 是无状态的,后端不需要在服务器端存储用户信息。用户信息被编码在 JWT 中,前端发送请求时携带 JWT 即可,后端通过解码 JWT 获取用户信息。
- 扩展性: JWT 可以包含任意 JSON 数据,因此可以轻松地扩展存储其他与用户相关的信息。
缺点:
- 无法主动登出: 一旦 JWT 发送到前端,后端无法主动使其失效。需要依赖前端清除 JWT 来实现登出。
- Token 大小: 如果用户信息较多,JWT 可能变得较大,增加网络传输开销。
token 结合 Redis 方案:
登陆成功后讲token当前线程用户的token保存到redis,下次请求带着基础token解密后,去redis读取数据,如果存在说明当前用户在登陆状态,不存在则说明没有登陆
优点:
- 可控制登出: 后端可以在服务器端主动删除 Redis 中的用户信息,实现主动登出的控制。
缺点:
- 需要服务器存储: 需要在服务器端存储用户信息,增加了服务器端的负担。
- 无法扩展额外信息: 相较于 JWT,UUID 方案无法轻松地扩展存储其他用户相关信息。
共同考虑的因素:
- 安全性: JWT 内置了签名机制,能够验证 token 的真实性和完整性。而 UUID 方案需要依赖传输通道的安全性和 Redis 的安全配置。
- 时效性: JWT 可以设置过期时间,而 UUID 方案可能需要额外的机制来处理 token 的过期。redis 的expire
前后端协作: 需要确保前后端对 token 的处理方式一致,尤其是在登录和登出时的行为。
选择其中一种方案通常取决于具体的应用场景、需求和偏好。如果对于主动登出、扩展性和无状态有较高要求,JWT 可能更适合;如果对于服务器存储负担较为敏感,且需要主动登出的功能,UUID 结合 Redis 方案可能更合适。
前端实现
在需要登录后才显示的页面中加入钩子函数 页面初始化前访问校验登录状态接口
如果后端传来凭证显示该页面 否则跳转登录页面
axios拦截器
前端还可以自己写axois的拦截,根据状态码完成以上功能
import axios from 'axios'
import router from '../router';
// import {useRouter} from "vue-router";只有vue组件可以
//没用这理
const request = axios.create({
baseURL: '/api', // 注意!! 这里是全局统一加上了 '/api' 前缀,也就是说所有接口都会加上'/api'前缀在,页面里面写接口的时候就不要加 '/api'了,否则会出现2个'/api',类似 '/api/api/user'这样的报错,切记!!!
timeout: 60000 //三分终
})
// request 拦截器
//使用方式 request. 和原生axios.没区别
// 可以自请求发送前对请求做一些处理
// 比如统一加token,对请求参数统一加密
request.interceptors.request.use(config => {
config.headers['Content-Type'] = 'application/json;charset=utf-8';
// 判断是否是登录页面的URL,如果是,则不进行Token检查
if (config.url === '/login') {
return config;
}
/**
* 设置权限验真用于后期登录
*/
const token = localStorage.getItem('token');
// 如果token存在,则在请求头中添加Authorization字段
if (!token) {
console.log("拦截器没有收到token");
}
else
config.headers.Authorization = token;
return config
}, error => {
return Promise.reject(error)
});
// response 拦截器
// 可以在接口响应后统一处理结果
request.interceptors.response.use(
response => {
let res = response.data; //这里数据进行了处理 res就单指数据
// 如果是返回的文件
if (response.config.responseType === 'blob') {
return res
}
if (response.status === 401){
router.push('/');
console.log("请求权限错误")
}
if (response.status === 408){
alert('登录过期')
router.push('/');
console.log("登录过期错误")
}
if (response.status!== 200 ){
console.log("请求错误")
console.log(response.errorMessage)
}
// 兼容服务端返回的字符串数据
if (typeof res === 'string') {
try {
res = res ? JSON.parse(res) : res;
} catch (error) {
console.error('Error parsing JSON:', error);
}
}
return res;
},
error => {
if (error.response) {
const status = error.response.status;
if (status === 401) {
alert('请登录后操作')
localStorage.clear();
router.push('/');
} else if (status === 408){
alert('登录过期重新登录')
localStorage.clear();//删除过期的token
console.error('登录过期重新登录')
router.push('/');
}else {
console.error(`请求错误,状态码:${status}`, error.message);
}
} else {
console.error('请求失败,未收到响应:', error.message);
}
// 返回一个 Promise.reject(),确保错误继续向外传递
return Promise.reject(error);
}
)
export default request
拦截器实现的大体逻辑
除开采用拦截器意外,还可以使用vue的全局路由守卫
import Vue from 'vue'
import './plugins/axios'
import App from './App.vue'
import router from './router'
import store from './store'
//引用element ui
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import './plugins/element.js'
import './assets/global.css'
import request from "@/utils/request";
//不会产生生产提示
Vue.config.productionTip = false
//使用组件
Vue.use(ElementUI, { size: "mini" });
//全局引用 这样可以直接使用 this.request完成引用
Vue.prototype.request=request
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token');
// 如果token不存在且当前路径不是/login,则重定向到登录页面
if (!token && to.path !== '/login') { //否则一定跳转
next("/login");
return; // 重要: 在这里添加 return,确保不会继续执行下面的 next()
}
// 如果token存在或已经在登录页面,则继续导航
next();
});
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app') //实列化挂载
了解Springsecurity实现
这个案列使用的是在Springsecurity中的jwt完成验证
login接口
@PostMapping("/login")
public ResponseResult login(@RequestBody User user){
if(!StringUtils.hasText(user.getUserName())){
//提示 必须要传用户名
throw new SystemException(AppHttpCodeEnum.REQUIRE_USERNAME);
}
return blogLoginService.login(user);
}
对应实现
@Override
public ResponseResult login(User user) {
//生成一个包含用户名密码的token
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
Authentication authenticate = authenticationManager.authenticate(authenticationToken);//传入认证信息管理器 返回认证信息
//判断是否认证通过1
if(Objects.isNull(authenticate)){
throw new RuntimeException("用户名或密码错误");
}
//获取userid 生成token
LoginUser loginUser = (LoginUser) authenticate.getPrincipal();//login user 实现了useservice 使其不是使用内存的账户 密码
String userId = loginUser.getUser().getId().toString();
//用userid生成的token不建议
String jwt = JwtUtil.createJWT(userId);
//把用户信息存入redis
redisCache.setCacheObject("bloglogin:"+userId,loginUser);
//把token和userinfo封装 返回
//把User转换成UserInfoVo
UserInfoVo userInfoVo = BeanCopyUtils.copyBean(loginUser.getUser(), UserInfoVo.class);
BlogUserLoginVo vo = new BlogUserLoginVo(jwt,userInfoVo);
return ResponseResult.okResult(vo);
}
校验逻辑
登录后用户信息生成token,返回给前端,让前端携带token,后端则是使用该token生成前的值生成建K,用户信息作为V存入redis,校验的时候用于过滤请求
jwt过滤器
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private RedisCache redisCache;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//获取请求头中的token
String token = request.getHeader("token");
if(!StringUtils.hasText(token)){
//说明该接口登录状态 直接放行
filterChain.doFilter(request, response);
return;
}
//解析获取userid
Claims claims = null;
try {
claims = JwtUtil.parseJWT(token);
} catch (Exception e) {
e.printStackTrace();
//token超时 token非法
//响应告诉前端需要重新登录
ResponseResult result = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
WebUtils.renderString(response, JSON.toJSONString(result));
return;
}
String userId = claims.getSubject();
//从redis中获取用户信息
LoginUser loginUser = redisCache.getCacheObject("bloglogin:" + userId);
//如果获取不到
if(Objects.isNull(loginUser)){
//说明登录过期 提示重新登录
ResponseResult result = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
WebUtils.renderString(response, JSON.toJSONString(result));
return;
}
//存入SecurityContextHolder
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser,null,null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
}
前端页面访问需要登录状态的页面时候也同上,带着token发送请求交给过滤器经行解析;如果能查到用户数据说明登录状态,否则跳到登录页面
PS:这个案列有用到了springsecurity 所以security需要详细配置
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Autowired
AuthenticationEntryPoint authenticationEntryPoint;
@Autowired
AccessDeniedHandler accessDeniedHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许 匿名访问(登录状态后不能访问)
.antMatchers("/login").anonymous()
//注销接口需要认证才能访问
.antMatchers("/logout").authenticated()
.antMatchers("/user/userInfo").authenticated()
// .antMatchers("/upload").authenticated()
// 除上面外的所有请求全部不需要认证即可访问
.anyRequest().permitAll();//登不登陆都可以访问
//配置异常处理器
http.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
//关闭默认的注销功能
http.logout().disable();
//把jwtAuthenticationTokenFilter添加到SpringSecurity的过滤器链中
//过滤器铁链执行前执行
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
//允许跨域
http.cors();
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
/**
* matches("用户输入明文","数据的密文");//对比数据库输入明文,和数据库加密文
*encode()//对明文加密
* 我们只需要使用把BCryptPasswordEncoder对象注入Spring容器中,SpringSecurity就会使用该PasswordEncoder来进行密码校验。
*/
}
}
关键代码
//过滤器铁链执行前执行
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
分布式中的常见登陆逻辑
上面我们讨论的是一般单体项目的前后端登陆验证,如果我们的业务范围扩大呢,比如分布式微服务
那么前端需要nginx负载均衡,后端就要通过网关进行路由匹配来进行验证登陆了
gate-way配置文件 主要是负责服务发现nacos
bootstrap
server:
port: 51601
spring:
application:
name: gateway
cloud:
nacos:
discovery:
server-addr: 192.168.249.132:8848
config:
server-addr: 192.168.249.132:8848
file-extension: yml
网关在nacos的具体配置
spring:
cloud:
gateway:
globalcors:
add-to-simple-url-handler-mapping: true
# 将全局CORS配置添加到SimpleUrlHandlerMapping
corsConfigurations:
'[/**]':
allowedHeaders: "*"
# 允许所有请求头
allowedOrigins: "*"
# 允许所有来源
allowedMethods:
- GET
- POST
- DELETE
- PUT
- OPTION
# 允许的HTTP请求方法,包括OPTIONS(通常应该是OPTIONS而不是OPTION)
routes:
# 平台管理
- id: user
uri: lb://leadnews-user
# 将匹配 /user/** 的请求路由到名为 "user" 的服务
predicates:
- Path=/user/**
# 匹配路径是以 /user/ 开头的请求
filters:
- StripPrefix=1
# 去掉请求路径中的一个前缀(通常是去掉服务名的前缀) filters 下的 - StripPrefix=1: 当请求匹配到这个路由规则时,会执行此处定义的过滤器。StripPrefix=1 表示去掉请求路径中的一个前缀。在这个场景下,如果请求路径是 /user/example,经过这个过滤器后,会将 /user 去掉,转发到后端服务的路径就变成了 /example。
# 文章微服务
- id: article
uri: lb://leadnews-article
predicates:
- Path=/article/**
filters:
- StripPrefix= 1
#搜索微服务
- id: leadnews-search
uri: lb://leadnews-search
predicates:
- Path=/search/**
filters:
- StripPrefix= 1
微服务的登陆处理逻辑变成了网关统一入口,入口进行授权或者处理以后将重要信息放在请求头,这个请求链放给其他微服务
那么这个时候就有俩个情况,
- 登陆验证token交给网关过滤器就好,其他单体的微服务不做验证拦截
- 通过网关验证以后依旧做登陆拦截,只是拦截凭证是和网关进行协商,而不是客户端的token,比如我这里的微服务放行要求是请求带有网关处理过的用户id
网关 gate-way
过滤器代码
@Component
@Slf4j
public class AuthorizeFilter implements Ordered, GlobalFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
if (isLoginRequest(request)) {
// 不对登陆请求做处理
return chain.filter(exchange);
}
String token = request.getHeaders().getFirst("token");
if (StringUtils.isBlank(token)) {
return handleUnauthorized(response);
}
try {
//解析token
Claims claimsBody = AppJwtUtil.getClaimsBody(token);
int validationResult = AppJwtUtil.verifyToken(claimsBody);
if (validationResult == 1 || validationResult == 2) {
//用户权限校验
return handleUnauthorized(response);
}
addUserIdToHeader(exchange, claimsBody);
} catch (Exception e) {
log.error("处理身份验证时发生错误", e);
return handleUnauthorized(response);
}
return chain.filter(exchange);
}
//判断是都登陆的请求
private boolean isLoginRequest(ServerHttpRequest request) {
return request.getURI().getPath().contains("/login");
}
private Mono<Void> handleUnauthorized(ServerHttpResponse response) {
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
//添加id 到请求头
private void addUserIdToHeader(ServerWebExchange exchange, Claims claimsBody) {
Object userId = claimsBody.get("id");
exchange = exchange.mutate().request(exchange.getRequest().mutate().header("userId", userId.toString()).build()).build();
}
@Override
public int getOrder() {
return 0;
}
}
单体微服务
拦截器,我的要求是判断是否请求头带有网关处理的用户id
public class AuthInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取请求token,保存到当前线程
String userid = request.getHeader("userId");
if (userid != null){
ApUser apUser = new ApUser();
apUser.setId(Integer.valueOf(userid));
//因为只在线程保存了id,其他信息不需要建议这步骤放在redis
ThreadUserLocalUtil.setThreadLocal(apUser);
return true;
}
else
System.out.println("User not found");
return true;
}
// 处理玩请求进行销毁,避免线程堵塞
// @Override
// public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
// ThreadUserLocalUtil.clear();
// }
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
ThreadUserLocalUtil.clear();
}
}
主要目的是判断是否登陆有凭证,并且保存到当前线程,随取随用
注册拦截器
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new AuthInterceptor());
}
}
其他未演示逻辑方案
如果不适用中间件
刚才之前的登录逻辑是采用redis+jwt的形式,登录成功后将用户的jwt存入redis,然后用uuid作为这个jwt的key,用户登录时候过滤器解析鉴权,然后把这个解析的jwt用户信息存入当前线程上下文ThreadLoacal
但是现在突然不适用redis了,那么我如何实现在线统计这样的需求呢,虽然还是可以使用直接返回前端jwt然后过滤器验证的方式,但是这样应该如何统计在线请人数这样,踢人下线呢?redis又叫做分布式会话,那么是不是也是说明本地的session也可以作为kv数据库,来实现对应功能只是无法多机共享而已呢?
redis 又作为分布式会话 所以session 也是可以会做会话的,而如果要做多级集群 或者实现统计强制下线等可以采用数据库的持久化实现
安全性分析
采用Redis+JWT的登录方案,通过UUID作为JWT的key存储在Redis中,并在过滤器中解析JWT后将用户信息存入ThreadLocal,这种设计具有以下优点和缺点:
优点:
分布式会话管理:利用Redis的分布式特性,支持多节点共享会话状态。
性能高效:Redis的高性能读写操作,保证了系统的高效性。
安全性:JWT本身具备防篡改和验证功能,结合Redis可以增强会话管理的安全性。
缺点:
依赖外部服务:依赖Redis,如果Redis服务不可用,系统的登录功能会受到影响。
复杂度增加:需要额外管理Redis连接和存储逻辑。
总体来说,这种设计在分布式系统中是比较安全和高效的,但也存在一定的依赖和复杂性。
在线用户统计和踢人下线
在不使用Redis的情况下,仍然可以实现在线用户统计和踢人下线的功能。以下是几种实现方式:
- 基于本地Session
可以使用本地Session来存储用户的会话信息,实现在线用户统计和管理:
在线用户统计:在用户登录成功时,将用户信息存入Session,并维护一个全局的在线用户列表。在用户请求时更新Session的最后访问时间,通过定期扫描Session列表,移除过期的Session。
踢人下线:在需要踢人下线时,可以从全局在线用户列表中移除对应用户的Session,并使该Session失效。
public class SessionManager {
private static Map<String, HttpSession> onlineUsers = new ConcurrentHashMap<>();
//登录后入当前session,然后给前端 前端解析jwt 获取用户id的key这样
public static String loginUser(String userId, HttpSession session) {
onlineUsers.put(userId, session);
session.setAttribute("userId", userId);
return jwt;
}
public static void logoutUser(String userId) {
HttpSession session = onlineUsers.remove(userId);
if (session != null) {
session.invalidate();
}
}
public static int getOnlineUserCount() {
return onlineUsers.size();
}
public static void checkExpiredSessions() {
// 定期检查并移除过期的会话
}
}
- 使用数据库存储会话
可以将会话信息存储在数据库中,这样可以实现多节点共享会话信息:
在线用户统计:在用户登录时,将会话信息存储在数据库中。在用户请求时更新数据库中的最后访问时间。
踢人下线:在需要踢人下线时,从数据库中删除对应的会话记录。
// 伪代码示例
public class SessionService {
@Autowired
private SessionRepository sessionRepository;
public void loginUser(String userId, String sessionId) {
Session session = new Session(userId, sessionId, System.currentTimeMillis());
sessionRepository.save(session);
}
public void logoutUser(String userId) {
sessionRepository.deleteByUserId(userId);
}
public int getOnlineUserCount() {
return sessionRepository.countOnlineUsers();
}
public void checkExpiredSessions() {
// 定期检查并移除过期的会话
}
}
- 基于JWT实现
在用户登录成功后,将JWT返回给前端,并在过滤器中验证JWT,统计在线用户和踢人下线可以通过维护一个全局的在线用户列表来实现:
在线用户统计:在用户登录时,将用户信息存入在线用户列表。在用户请求时更新用户的最后访问时间。
踢人下线:在需要踢人下线时,从在线用户列表中移除对应用户的信息。
// 伪代码示例
public class JwtSessionManager {
private static Map<String, Long> onlineUsers = new ConcurrentHashMap<>();
public static void loginUser(String userId, String jwt) {
onlineUsers.put(userId, System.currentTimeMillis());
}
public static void logoutUser(String userId) {
onlineUsers.remove(userId);
}
public static int getOnlineUserCount() {
return onlineUsers.size();
}
public static void checkExpiredSessions() {
// 定期检查并移除过期的会话
}
}
不使用Redis的情况下,可以选择使用本地Session或数据库存储会话信息来实现在线用户统计和踢人下线的功能。这两种方式都可以在一定程度上满足业务需求,但需要根据具体场景选择合适的方案。基于JWT的方式虽然不依赖外部存储,但需要在应用层维护在线用户信息。