第一章 完善用户相关信息

第一章 完善用户相关信息

用户注册与登录

  • 数据库表设计:用户表、用户信息表
  • 相关接口(API):获取RSA公钥、用户注册、用户登录

数据库表设计及相关实体类设计

用户表

Untitled

用户信息表

Untitled

  • 根据这两张数据库表创建对应的实体类

基于JWT的用户token验证

先来看看基于session的用户身份验证的特点

  • 验证过程:服务端验证浏览器携带的用户名和密码,验证通过后生成用户凭证保存在服务端(session),浏览器再次访问时,服务端查询session,实现登录状态保持
  • 缺点:随着用户的增多,服务端压力增大;若浏览器cookie被攻击者拦截,容易受到跨站请求伪造攻击;分布式系统下扩展性不强

基于token的用户身份验证

  • 验证过程:服务端验证浏览器携带的用户名和密码,验证通过后生成用户令牌(token)并返回给浏览器,浏览器再次访问时携带token,服务端校验token并返回相关数据
  • 优点:token不储存在服务器,不会造成服务器压力;token可以存储在非cookie中,安全性高;分布式系统下扩展性强

什么是 JWT ?

  • JWT:全称是JSONWeb Token,JWT是一个规范,用于在空间受限环境下安全传递“声明”。
  • JWT的组成:JWT分成三部分,第一部分是头部(header),第二部分是载荷(payload),第三部分是签名(signature)
  • JWT优点:跨语言支持、便于传输、易于扩展

JWT 的内部有什么 ?

  • JWT头部:声明的类型、声明的加密算法(通常使用SHA256)
  • JWT载荷:存放有效信息,一般包含签发者、所面向的用户、接受方、过期时间、签发时间以及唯一身份标识
  • JWT签名:主要由头部、载荷以及秘钥组合加密而成

用户关注与动态提醒

用户关注

  • 数据库表设计:用户关注表、用户关注分组表
  • 相关接口(API):关注用户、关注列表、粉丝列表、分页查询用户

数据库表设计及相关实体类设计

用户关注分组表

Untitled

  • 先新增3条数据

Untitled

用户关注表

Untitled

动态提醒(重点)

  • 数据库表设计:用户动态表
  • 相关接口(API):用户发布动态、用户查询订阅内容的动态
  • 设计模式:订阅发布模式

数据库表设计及相关实体类设计

用户动态表

Untitled

订阅发布模式

Untitled

订阅发布模式 与 观察者模式的区别

Untitled

实现动态提醒的工具

  • RocketMQ:纯java编写的开源消息中间件,特点是:高性能、低延迟、分布式事务
  • Redis:高性能缓存工具,数据存储在内存中,读写速度非常快
  • RocketMQ 相关工具类及配置实现

用户权限控制(重点)

  • 权限控制是什么:控制用户对系统资源(URI)的操作
  • 前端的权限控制:对页面或页面元素的权限控制
  • 前端的权限控制:对页面或页面元素的权限控制

B站会员等级权限

Untitled

  • 访问权限:哪些页面可以访问、哪些页面元素可见等等
  • 操作权限:如页面按钮是否可点击、是否可以增删改查等等
  • 接口与数据权限:接口是否可以调用、接口具体字段范围等等

RBAC权限控制模型

  • RBAC权限控制模型(Role-Based Access Control):基于角色的权限控制
  • RBAC模型的层级:RBAC0、RBAC1、RBAC2、RBAC3
  • 关键词:用户、角色、资源、权限、操作
    • 通过权限对资源操作的相关绑定,再通过角色绑定相关的资源,就能变相地让角色拥有资源和操作的相关权限
    • 最后通过用户和角色的相关绑定,来达到用户可以对资源和操作进行权限控制的目的

项目实现 RBAC权限控制模型

Untitled

  • 用户:注册用户
  • 角色:Lv0~Lv6 会员
  • 权限:视频投稿、发布动态、各种弹幕功能等等
  • 资源:页面、页面元素
  • 操作:点击、跳转、增删改查等等

相关数据库表设计

  • 数据库表设计:角色表用户角色关联表元素操作权限表角色元素操作权限关联表页面菜单权限表角色页面菜单权限关联表

Untitled

Spring AOP

  • 概念:Spring AOP是一种约定流程的编程
  • 关键词:约定(AOP的核心)
  • 典型的例子:数据库事务包括打开数据库,设置属性,执行sql语句,没有异常则提交事务,有异常则回滚事务,最后关闭数据库连接
    • 打开打开数据库和设置属性,再到异常回滚没有异常则提交,这些都是固定(约定)的流程,而执行sql语句包含的功能有很多种,不是一个固定的操作
    • Spring AOP就类似于把这些固定的流程抽出来,做成一个约定的流程,以及在我们约定的流程当中,插入我们个性化的操作的一种工具或一种框架

SpringAOP术语

  • 连接点(join point):对应的被拦截的对象
  • 切点(point cut):通过正则或指示器的规则来适配连接点
  • 切面(aspect):可以定义切点、各类通知和引入的内容
  • 通知(advice):,分为前置通知(before)、后置通知(after)、事后返回通知(afterReturning)、异常通知(afterThrowing)
  • 织入(weaving):为原有服务(service)对象生成代理对象,然后将与切点匹配的连接点拦截,并将各类通知加入约定流程
  • 目标对象(target):被代理对象

基于接口的权限控制

ApiLimitedRole.java

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
@Component
public @interface ApiLimitedRole {

    /**
     * 角色权限代码列表
     *
     * @return
     */
    String[] limitedRoleCodeList() default {};

}

ApiLimitedRoleAspect.java

/**
 * 切面类
 *
 * @author xiexu
 * @create 2022-06-17 07:37
 */
@Aspect
@Order(1)
@Component
public class ApiLimitedRoleAspect {

    @Autowired
    private UserSupport userSupport;

    @Autowired
    private UserRoleService userRoleService;

    /**
     * 切入点
     */
    @Pointcut("@annotation(com.imooc.bilibili.annotation.ApiLimitedRole)")
    public void check() {
    }

    /**
     * 前置通知
     *
     * @param joinPoint
     * @param apiLimitedRole
     */
    @Before("check() && @annotation(apiLimitedRole)")
    public void doBefore(JoinPoint joinPoint, ApiLimitedRole apiLimitedRole) {
        Long userId = userSupport.getCurrentUserId();
        // 用户具有哪些角色
        List<UserRole> userRoleList = userRoleService.getUseRoleByUserId(userId);
        // 角色权限代码列表
        String[] limitedRoleCodeList = apiLimitedRole.limitedRoleCodeList();
        // 将角色权限代码列表转换成set集合
        Set<String> limitedRoleCodeSet = new HashSet<>();
        for (String s : limitedRoleCodeList) {
            limitedRoleCodeSet.add(s);
        }
        // 用户角色列表转换成set集合
        Set<String> roleCodeSet = new HashSet<>();
        for (UserRole userRole : userRoleList) {
            roleCodeSet.add(userRole.getRoleCode());
        }
        // 对用户角色列表 和 角色权限代码列表 取交集,roleCodeSet存放的就是他们的交集
        roleCodeSet.retainAll(limitedRoleCodeSet);
        // 交集指的就是用户角色列表里面有包含,角色权限代码列表里面也有包含,说明用户有某个角色是不允许被访问的
        if (roleCodeSet.size() > 0) {
            throw new ConditionException("权限不足!");
        }
    }

}

UserMomentsApi.java

@RestController
public class UserMomentsApi {

    @Autowired
    private UserMomentsService userMomentsService;

    @Autowired
    private UserSupport userSupport;

    /**
     * 用户发布动态
     * AuthRoleConstant.ROLE_LV0表示等级0是没有权限进行发布动态的
     *
     * @param userMoment
     * @return
     */
    @ApiLimitedRole(limitedRoleCodeList = {AuthRoleConstant.ROLE_LV0})
    @PostMapping("/user-moments")
    public JsonResponse<String> addUserMoments(@RequestBody UserMoment userMoment) throws Exception {
        Long userId = userSupport.getCurrentUserId();
        userMoment.setUserId(userId);
        userMomentsService.addUserMoments(userMoment);
        return JsonResponse.success();
    }

}

基于数据的权限控制

DataLimited.java

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
@Component
public @interface DataLimited {
    
}

DataLimitedAspect.java

/**
 * 切面类
 *
 * @author xiexu
 * @create 2022-06-17 07:37
 */
@Aspect
@Order(1)
@Component
public class DataLimitedAspect {

    @Autowired
    private UserSupport userSupport;

    @Autowired
    private UserRoleService userRoleService;

    /**
     * 切入点
     */
    @Pointcut("@annotation(com.imooc.bilibili.annotation.DataLimited)")
    public void check() {
    }

    /**
     * 前置通知
     *
     * @param joinPoint
     */
    @Before("check()")
    public void doBefore(JoinPoint joinPoint) {
        Long userId = userSupport.getCurrentUserId();
        // 用户具有哪些角色
        List<UserRole> userRoleList = userRoleService.getUseRoleByUserId(userId);
        // 用户角色列表转换成set集合
        Set<String> roleCodeSet = new HashSet<>();
        for (UserRole userRole : userRoleList) {
            roleCodeSet.add(userRole.getRoleCode());
        }
        // 把切到的方法里面的参数获取到,也就是addUserMoments(@RequestBody UserMoment userMoment)方法的userMoment参数
        Object[] args = joinPoint.getArgs();
        for (Object arg : args) {
            if (arg instanceof UserMoment) {
                UserMoment userMoment = (UserMoment) arg;
                String type = userMoment.getType();
                // 用户角色为lv0的情况下,只有传参"0"给type字段,其他数字参数都报错
                if (roleCodeSet.contains(AuthRoleConstant.ROLE_LV0) && !"0".equals(type)) {
                    throw new ConditionException("参数异常!");
                }
                // 用户角色为lv1的情况下,只有传参"0"或者"1"给type字段,其他数字参数都报错
                if (roleCodeSet.contains(AuthRoleConstant.ROLE_LV1) && (!"1".equals(type) || !"0".equals(type))) {
                    throw new ConditionException("参数异常!");
                }
            }
        }
    }

}

UserMomentsApi.java

@RestController
public class UserMomentsApi {

    @Autowired
    private UserMomentsService userMomentsService;

    @Autowired
    private UserSupport userSupport;

    /**
     * 用户发布动态
     * AuthRoleConstant.ROLE_LV0表示等级0是没有权限进行发布动态的
     *
     * @param userMoment
     * @return
     * @ApiLimitedRole 接口权限控制
     * @DataLimited 数据权限控制
     */
    @ApiLimitedRole(limitedRoleCodeList = {AuthRoleConstant.ROLE_LV0})
    @DataLimited
    @PostMapping("/user-moments")
    public JsonResponse<String> addUserMoments(@RequestBody UserMoment userMoment) throws Exception {
        Long userId = userSupport.getCurrentUserId();
        userMoment.setUserId(userId);
        userMomentsService.addUserMoments(userMoment);
        return JsonResponse.success();
    }

}

双token机制

存在的问题

  • 之前我们做的登录token模块,用户在退出登录之后,如果token还没有过期失效,那么这个时候用户还是可以拿着token去访问系统的资源,这样是不合理的。
  • 用户退出登录之后,应该不能再访问系统的资源了。
  • 基于这种情况,我们引入双token机制。

什么是双token机制 ?

  • 引入双token机制可以用于对用户进行无感的有效时间的刷新
  • 单token情况,当用户登录之后生成一个token,这个token具备有效期,当这个token快要过期的时候,如果没有刷新token的话,系统会直接通知用户,您的token已经过期,请重新登录,这样子对用户会造成不是很友好的用户体验(用户可能正在观看视频,突然显示您已经退出登录,请重新登录。。。)
  • 引入刷新token,它的工作原理是:在我们用户登录成功之后,除了访问token之外,加入一个刷新token,这个刷新token的有效时间一般会比访问token长很多(访问token的有效时长可能在23小时左右,而刷新token的有效时长一般在12周左右)
  • 只要这个刷新token一直有效,我们就可以在这个时间段之内,用刷新token对我们用户的访问token进行刷新(也就是延长访问token的有效期)

数据库表设计及相关实体类设计

刷新令牌记录表

Untitled

控制层

UserApi.java

		/**
     * 用户登录
     * 双token机制:accessToken、refreshToken
     * dts的含义:double token(双token)
     *
     * @param user
     * @return
     */
    @PostMapping("/user-dts")
    public JsonResponse<Map<String, Object>> loginForDts(@RequestBody User user) throws Exception {
        Map<String, Object> map = userService.loginForDts(user);
        return new JsonResponse<>(map);
    }

    /**
     * 退出登录
     * 用户退出登录以后,需要把现有的刷新token删除掉
     *
     * @param request 包含所有请求信息的集合
     * @return
     */
    @DeleteMapping("/refresh-tokens")
    public JsonResponse<String> logout(HttpServletRequest request) {
        // 通过HttpServletRequest获取请求头中的refreshToken
        String refreshToken = request.getHeader("refreshToken");
        // 获取userId
        Long userId = userSupport.getCurrentUserId();
        userService.logout(refreshToken, userId);
        return JsonResponse.success();
    }

    /**
     * 刷新accessToken(访问token)
     * 生成一个新的访问token给前端
     * 具体流程就是:用户携带访问token访问服务器,当用户的访问token过期时,服务端会返回异常给前端,
     * 这时候前端识别到该异常为访问token过期,先不抛出提示信息给用户,而是先调用刷新accessToken的接口,
     * 传入刷新token给服务器验证,如果服务器验证刷新token没有失效,就重新生成一个访问token给用户,
     * 这样子用户就可以无感的刷新了访问token,而不会出现烦人的提示信息;如果服务器验证刷新token已经失效了,
     * 则抛出提示信息给用户(当前用户已经退出登录,请重新登录!)
     *
     * @param request
     * @return
     */
    @PostMapping("/access-tokens")
    public JsonResponse<String> refreshAccessToken(HttpServletRequest request) throws Exception {
        // 先从请求头中获取刷新token
        String refreshToken = request.getHeader("refreshToken");
        // 传入刷新token,获取刷新之后的访问token
        String accessToken = userService.refreshAccessToken(refreshToken);
        // 返回一个新的访问token给前端
        return new JsonResponse<>(accessToken);
    }

业务层

UserService.java

		/**
     * 双token用户登录
     *
     * @param user
     * @return
     * @throws Exception
     */
    public Map<String, Object> loginForDts(User user) throws Exception {
        String phone = user.getPhone() == null ? "" : user.getPhone();
        String email = user.getEmail() == null ? "" : user.getEmail();
        if (StringUtils.isNullOrEmpty(phone) && StringUtils.isNullOrEmpty(email)) {
            throw new ConditionException("参数异常!");
        }
        User dbUser = userDao.getUserByPhoneOrEmail(phone, email);
        if (dbUser == null) {
            throw new ConditionException("当前用户不存在");
        }
        // 这个密码是经过前端RSA加密之后传过来的,需要进行解密
        String password = user.getPassword();
        // 原文密码(解密后的密码)
        String rawPassword;
        try {
            rawPassword = RSAUtil.decrypt(password);
        } catch (Exception e) {
            throw new ConditionException("密码解密失败!");
        }
        // 从数据库中拿到对应用户的盐值
        String salt = dbUser.getSalt();
        String md5Password = MD5Util.sign(rawPassword, salt, "UTF-8");
        if (!md5Password.equals(dbUser.getPassword())) {
            throw new ConditionException("密码错误!");
        }
        Long userId = dbUser.getId();
        // 生成用户访问token
        String accessToken = TokenUtil.generateToken(userId);
        // 生成刷新token
        String refreshToken = TokenUtil.generateRefreshToken(userId);
        // 保存refresh token到数据库中,采用先删除,再插入的方式(插入,更新)
        /**
         * 将刷新token和userId一起保存到数据库中,这样做的目的是:
         * 方便我们后序用户在退出登录之后,或者想要延长访问token的有效期的时候,
         * 我们可以去查找跟userId相关联的刷新token,进行判断,
         * 如果刷新token存在,说明我们的刷新token依然在有效期中,可以延长我们的访问token(刷新访问token)
         * 如果刷新token不存在,说明我们的刷新token已经失效了,这个时候就要告诉前端(您已经登录失效,需要重新登录)
         */
        userDao.deleteRefreshToken(refreshToken, userId);
        userDao.addRefreshToken(refreshToken, userId, new Date());
        Map<String, Object> result = new HashMap<>();
        result.put("accessToken", accessToken);
        result.put("refreshToken", refreshToken);
        return result;
    }

    /**
     * 退出登录
     *
     * @param refreshToken 刷新token
     * @param userId       用户id
     */
    public void logout(String refreshToken, Long userId) {
        // 删除刷新令牌
        userDao.deleteRefreshToken(refreshToken, userId);
    }

    /**
     * 根据传入的刷新token,先校验刷新token是否已经失效,
     * 如果没有失效,就重新生成一个访问token,
     * 如果刷新token失效了,还是直接抛出token过期的异常给前端
     *
     * @param refreshToken
     * @return
     * @throws Exception
     */
    public String refreshAccessToken(String refreshToken) throws Exception {
        // 获取刷新令牌详情
        RefreshTokenDetail refreshTokenDetail = userDao.getRefreshTokenDetail(refreshToken);
        // 如果刷新token等于null,要告诉前端该刷新token是一个失效状态,不能刷新访问token
        if (refreshTokenDetail == null) {
            throw new ConditionException("555", "token过期!");
        }
        // 获取userId
        Long userId = refreshTokenDetail.getUserId();
        // 根据userId获取新的访问token
        return TokenUtil.generateToken(userId);
    }

    public List<UserInfo> batchGetUserInfoByUserIds(Set<Long> userIdList) {
        return userDao.batchGetUserInfoByUserIds(userIdList);
    }

    /**
     * 根据userId获取刷新token
     *
     * @param userId
     * @return
     */
    public String getRefreshTokenByUserId(Long userId) {
        return userDao.getRefreshTokenByUserId(userId);
    }

工具类

TokenUtil.java

/**
 * 生成用户令牌
 */
public class TokenUtil {

    private static final String ISSUER = "签发者";

    /**
     * 生成访问token
     *
     * @param userId
     * @return
     * @throws Exception
     */
    public static String generateToken(Long userId) throws Exception {
        Algorithm algorithm = Algorithm.RSA256(RSAUtil.getPublicKey(), RSAUtil.getPrivateKey());
        // 日历类
        Calendar calendar = Calendar.getInstance();
        calendar.setTime(new Date());
        // token设置之后1小时过期
        calendar.add(Calendar.HOUR, 1);
        return JWT.create().withKeyId(String.valueOf(userId))
                // 签发者
                .withIssuer(ISSUER)
                // 过期时间
                .withExpiresAt(calendar.getTime())
                // 使用加密算法生成签名
                .sign(algorithm);
    }

    /**
     * 生成刷新token
     *
     * @param userId
     * @return
     * @throws Exception
     */
    public static String generateRefreshToken(Long userId) throws Exception {
        Algorithm algorithm = Algorithm.RSA256(RSAUtil.getPublicKey(), RSAUtil.getPrivateKey());
        // 日历类
        Calendar calendar = Calendar.getInstance();
        calendar.setTime(new Date());
        // 刷新token有效时长为7天
        calendar.add(Calendar.DAY_OF_MONTH, 7);
        return JWT.create().withKeyId(String.valueOf(userId))
                // 签发者
                .withIssuer(ISSUER)
                // 过期时间
                .withExpiresAt(calendar.getTime())
                // 使用加密算法生成签名
                .sign(algorithm);
    }

    /**
     * 验证token令牌
     *
     * @param token
     * @return
     */
    public static Long verifyToken(String token) {
        try {
            Algorithm algorithm = Algorithm.RSA256(RSAUtil.getPublicKey(), RSAUtil.getPrivateKey());
            // 通过RSA算法生成验证类
            JWTVerifier verifier = JWT.require(algorithm).build();
            // 生成解密后的jwt
            DecodedJWT jwt = verifier.verify(token);
            String userId = jwt.getKeyId();
            return Long.valueOf(userId);
        } catch (TokenExpiredException e) {
            throw new ConditionException("555", "token过期!");
        } catch (Exception e) {
            throw new ConditionException("非法用户token!");
        }
    }

}

UserSupport.java

@Component
public class UserSupport {

    @Autowired
    private UserService userService;

    /**
     * 根据前端传过来的请求获取请求头中的token,从而获取用户id
     *
     * @return
     */
    public Long getCurrentUserId() {
        // 获取前端请求
        ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        // 从前端请求的请求头里面获取访问token,进行解析
        HttpServletRequest request = requestAttributes.getRequest();
        String accessToken = request.getHeader("token");
        // 验证访问token,返回userId
        Long userId = TokenUtil.verifyToken(accessToken);
        if (userId < 0) {
            throw new ConditionException("非法用户!");
        }
        // 验证刷新token
        this.verifyRefreshToken(userId);
        return userId;
    }

    /**
     * 验证刷新token
     * 用户在退出之后就会删除数据库中的刷新token,如果这时用户还拿着有效期内的访问token来访问服务器,
     * 服务器会校验到前端请求的刷新token和数据库中的刷新token不一致,从而抛出异常。
     * 所以双token机制也就可以保证用户在退出之后,不能使用有效期内的访问token来继续访问服务器了
     *
     * @param userId
     */
    private void verifyRefreshToken(Long userId) {
        // 获取前端请求
        ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        // 从前端请求的请求头里面获取刷新token,进行解析
        HttpServletRequest request = requestAttributes.getRequest();
        String refreshToken = request.getHeader("refreshToken");
        // 根据用户id获取数据库中存储的刷新token
        String dbRefreshToken = userService.getRefreshTokenByUserId(userId);
        // 校验前端请求的刷新token 和 数据库中的刷新token是否一致
        if (!dbRefreshToken.equals(refreshToken)) {
            throw new ConditionException("非法用户!");
        }
    }

}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

猿小羽

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值