【前后端分离博客】学习笔记01 --- 登录模块Sa-Token

前言

用于记录自己学习博客项目的流程

基于Springboot + Vue3 开发的前后端分离博客

项目源码:Blog: 基于SpringBoot + Vue3 + TypeScript + Vite的个人博客,MySQL数据库,Redis缓存,ElasticSearch全文搜索,支持QQ、Gitee、Github第三方登录,留言、友链、评论、说说、相册等功能。

一、概述

该项目使用sa-Token框架实现的登录,权限认证

Sa-Token,一个轻量级 Java 权限认证框架,让鉴权变得简单、优雅

官方文档:https://sa-token.cc

二、登录流程

点击登录,前端发送请求

三、后端代码实现 

3.1、添加依赖

引入Sa-Token依赖

        <!-- Sa-Token -->
        <dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-spring-boot-starter</artifactId>
            <version>${saToken.version}</version>
        </dependency>
        <!-- 
            Sa-Token 整合 Redis (使用 jackson 序列化方式) 
            优点:Session 序列化后可读性强,可灵活手动修改,缺点:兼容性稍差。
        -->
        <dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-dao-redis-jackson</artifactId>
            <version>${saToken.version}</version>
        </dependency>
        <!-- Redis连接池 必须为项目提供一个 Redis 实例化方案-->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>

yml文件中配置信息

# Sa-Token 配置 (文档: https://sa-token.cc)
sa-token:
  # token名称 (同时也是cookie名称)
  token-name: Authorization
  # token前缀
  token-prefix: Bearer
  # token有效期,单位s 默认30天, -1代表永不过期
  timeout: 43200
  # token临时有效期 (指定时间内无操作就视为token过期) 单位: 秒
  activity-timeout: -1
  # 关闭自动续签
  auto-renew: false
  # 是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录)
  is-concurrent: false
  # token风格
  token-style: uuid
  # 在多人登录同一账号时,是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token)
  is-share: false
  # 是否从cookie中读取token
  is-read-cookie: false
  # 是否从请求体里读取token
  is-read-body: false
  # 是否从header中读取token
  is-read-header: true
  # 是否输出操作日志
  is-log: false

spring:
  # redis配置
  redis:
    # Redis服务器地址
    host: localhost
    # Redis服务器连接端口
    port: 6379
    # Redis服务器连接密码(默认为空)
    # password: redis密码
    # 连接超时时间
    timeout: 10s
    lettuce:
      pool:
        # 连接池最大连接数
        max-active: 150
        # 连接池最大阻塞等待时间(使用负值表示没有限制)
        max-wait: 5000ms
        # 连接池中的最大空闲连接
        max-idle: 100
        # 连接池中的最小空闲连接
        min-idle: 50

Sa-Token 默认将数据保存在内存中,此模式读写速度最快,且避免了序列化与反序列化带来的性能消耗,但是此模式也有一些缺点,比如:

  1. 重启后数据会丢失。
  2. 无法在分布式环境中共享数据。

为此,Sa-Token 提供了扩展接口,你可以轻松将会话数据存储在 RedisMemcached等专业的缓存中间件中, 做到重启数据不丢失,而且保证分布式环境下多节点的会话一致性。

集成 Redis 后,是我额外手动保存数据,还是框架自动保存?

  • 框架自动保存。集成 Redis 只需要引入对应的 pom依赖 即可,框架所有上层 API 保持不变。

3.2、sa-Token配置类

全局侦听器


Sa-Token 提供一种侦听器机制,通过注册侦听器,你可以订阅框架的一些关键性事件,例如:用户登录、退出、被踢下线等。

事件触发流程大致如下:

要注册自定义的侦听器也非常简单:

  1. 新建类实现 SaTokenListener 接口。
  2. 将实现类注册到 SaTokenEventCenter 事件发布中心。

该类实现功能:

  • 获取登录用户的浏览器和系统信息、IP地址等
  • 更新用户表中信息
  • 将在线的用户信息由框架存入Redis,
/**
 * 自定义侦听器的实现
 *
 * @author DarkClouds
 * @date 2023/05/11
 */
@Component //自动将实现类注册到 SaTokenEventCenter 事件发布中心。
public class MySaTokenListener implements SaTokenListener {

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private HttpServletRequest request;

    /**
     * 每次登录时触发
     */
    @Override
    public void doLogin(String loginType, Object loginId, String tokenValue, SaLoginModel loginModel) {
        // 查询用户昵称
        User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
                .select(User::getAvatar, User::getNickname)
                .eq(User::getId, loginId));
        // 解析browser和os
        Map<String, String> userAgentMap = UserAgentUtils.parseOsAndBrowser(request.getHeader("User-Agent"));
        // 获取登录ip地址
        String ipAddress = IpUtils.getIpAddress(request);
        // 获取登录地址
        String ipSource = IpUtils.getIpSource(ipAddress);
        // 获取登录时间
        LocalDateTime loginTime = LocalDateTime.now(ZoneId.of(SHANGHAI.getZone()));
        OnlineVO onlineVO = OnlineVO.builder()
                .id((Integer) loginId)
                .token(tokenValue)
                .avatar(user.getAvatar())
                .nickname(user.getNickname())
                .ipAddress(ipAddress)
                .ipSource(ipSource)
                .os(userAgentMap.get("os"))
                .browser(userAgentMap.get("browser"))
                .loginTime(loginTime)
                .build();
        // 更新用户登录信息
        User newUser = User.builder()
                .id((Integer) loginId)
                .ipAddress(ipAddress)
                .ipSource(ipSource)
                .loginTime(loginTime)
                .build();
        userMapper.updateById(newUser);
        // 用户在线信息存入tokenSession
        SaSession tokenSession = StpUtil.getTokenSessionByToken(tokenValue);
        //由框架自动将用户信息存放到了Redis中
        tokenSession.set(ONLINE_USER, onlineVO);
    }

    /**
     * 每次注销时触发
     */
    @Override
    public void doLogout(String loginType, Object loginId, String tokenValue) {
        // 删除缓存中的用户信息
        StpUtil.logoutByTokenValue(tokenValue);
    }

    /**
     * 每次被踢下线时触发
     */
    @Override
    public void doKickout(String loginType, Object loginId, String tokenValue) {
    }

    /**
     * 每次被顶下线时触发
     */
    @Override
    public void doReplaced(String loginType, Object loginId, String tokenValue) {
        // 删除缓存中的用户信息
        StpUtil.logoutByTokenValue(tokenValue);
    }

    /**
     * 每次被封禁时触发
     */
    @Override
    public void doDisable(String loginType, Object loginId, String service, int level, long disableTime) {

    }

    /**
     * 每次被解封时触发
     */
    @Override
    public void doUntieDisable(String loginType, Object loginId, String service) {

    }

    /**
     * 每次二级认证时触发
     */
    @Override
    public void doOpenSafe(String loginType, String tokenValue, String service, long safeTime) {

    }

    /**
     * 每次退出二级认证时触发
     */
    @Override
    public void doCloseSafe(String loginType, String tokenValue, String service) {

    }

    /**
     * 每次创建Session时触发
     */
    @Override
    public void doCreateSession(String id) {

    }

    /**
     * 每次注销Session时触发
     */
    @Override
    public void doLogoutSession(String id) {

    }

    /**
     * 每次Token续期时触发
     */
    @Override
    public void doRenewTimeout(String tokenValue, Object loginId, long timeout) {

    }
}

全局过滤器


利用Sa-Token全局过滤器来实现路由拦截器鉴权 

  1. 相比于拦截器,过滤器更加底层,执行时机更靠前,有利于防渗透扫描。
  2. 过滤器可以拦截静态资源,方便我们做一些权限控制。
  3. 部分Web框架根本就没有提供拦截器功能,但几乎所有的Web框架都会提供过滤器机制。

同拦截器一样,为了避免不必要的性能浪费,Sa-Token全局过滤器默认处于关闭状态,若要使用过滤器组件,首先你需要注册它到项目中

该类实现功能:

  • 注册分页拦截器
  • 注册Redis限流器
  • 注册 Sa-Token 的注解拦截器,打开注解式鉴权功能

/**
 * SaToken配置
 * 全局过滤器
 * @author DarkClouds
 * @date 2023/05/10
 */
@Component
public class SaTokenConfig implements WebMvcConfigurer {

    @Autowired
    private AccessLimitInterceptor accessLimitInterceptor;

    private final String[] EXCLUDE_PATH_PATTERNS = {
            "/swagger-resources",
            "/webjars/**",
            "/v2/api-docs",
            "/doc.html",
            "/favicon.ico",
            "/login",
            "/oauth/*",
    };

    private final long timeout = 600;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 注册分页拦截器
        registry.addInterceptor(new PageableInterceptor());
        // 注册Redis限流器
        registry.addInterceptor(accessLimitInterceptor);
        // 注册 Sa-Token 的注解拦截器,打开注解式鉴权功能
        registry.addInterceptor(new SaInterceptor()).addPathPatterns("/**");
    }

    @Bean
    public SaServletFilter getSaServletFilter() {
        return new SaServletFilter()
                // 拦截路径
                .addInclude("/**")
                // 放开路径
                .addExclude(EXCLUDE_PATH_PATTERNS)
                // 前置函数:在每次认证函数之前执行
                .setBeforeAuth(obj -> {
                    SaHolder.getResponse()
                            // 允许指定域访问跨域资源
                            .setHeader("Access-Control-Allow-Origin", "*")
                            // 允许所有请求方式
                            .setHeader("Access-Control-Allow-Methods", "*")
                            // 有效时间
                            .setHeader("Access-Control-Max-Age", "3600")
                            // 允许的header参数
                            .setHeader("Access-Control-Allow-Headers", "*");
                    // 如果是预检请求,则立即返回到前端
                    SaRouter.match(SaHttpMethod.OPTIONS)
                            .free(r -> System.out.println("--------OPTIONS预检请求,不做处理"))
                            .back();
                })
                // 认证函数: 每次请求执行
                .setAuth(obj -> {
                    // 检查是否登录
                    SaRouter.match("/admin/**").check(r -> StpUtil.checkLogin());
                    // 刷新token有效期
                    if (StpUtil.getTokenTimeout() < timeout) {
                        StpUtil.renewTimeout(1800);
                    }
                })
                //  异常处理函数:每次认证函数发生异常时执行此函数
                .setError(e -> {
                    // 设置响应头
                    SaHolder.getResponse().setHeader("Content-Type", "application/json;charset=UTF-8");
                    if (e instanceof NotLoginException) {
                        return JSONUtil.toJsonStr(Result.fail(UNAUTHORIZED.getCode(), UNAUTHORIZED.getMsg()));
                    }
                    return SaResult.error(e.getMessage());
                });
    }

}

自定义权限验证


/**
 * 自定义权限验证接口扩展
 *
 * @author DarkClouds
 * @date 2023/05/10
 */
@Component // 保证此类被SpringBoot扫描,完成Sa-Token的自定义权限验证扩展
@RequiredArgsConstructor
public class StpInterfaceImpl implements StpInterface {

    private final MenuMapper menuMapper;

    private final RoleMapper roleMapper;

    /**
     * 返回一个账号所拥有的权限码集合
     *
     * @param loginId   登录用户id
     * @param loginType 登录账号类型
     * @return 权限集合
     */
    @Override
    public List<String> getPermissionList(Object loginId, String loginType) {
        // 声明权限码集合
        List<String> permissionList = new ArrayList<>();
        // 遍历角色列表,查询拥有的权限码
        for (String roleId : getRoleList(loginId, loginType)) {
            //如果该用户角色为管理员,则拥有所有权限
            if (ADMIN.equals(roleId)) {
                permissionList.add("*");
                return permissionList;
            }
            SaSession roleSession = SaSessionCustomUtil.getSessionById("role-" + roleId);
            List<String> list = roleSession.get("Permission_List", () -> menuMapper.selectPermissionByRoleId(roleId));
            permissionList.addAll(list);
        }
        // 返回权限码集合
        return permissionList;
    }

    /**
     * 返回一个账号所拥有的可用角色标识集合
     *
     * @param loginId   登录用户id
     * @param loginType 登录账号类型
     * @return 角色集合
     */
    @Override
    public List<String> getRoleList(Object loginId, String loginType) {
        SaSession session = StpUtil.getSessionByLoginId(loginId);
        // 从数据库查询这个账号id拥有的角色列表
        return session.get("Role_List", () -> roleMapper.selectRoleListByUserId(loginId));
    }

}

3.3、用户登录退出业务代码

1、创建LoginDTO类,用于接收前端发送的参数

/**
 * 登录信息
 *
 * @author DarkClouds
 * @date 2023/05/09
 */
@Data
@ApiModel(description = "登录信息")
public class LoginDTO {

    /**
     * 用户名
     */
    @NotBlank(message = "用户名不能为空")
    @ApiModelProperty(value = "用户名")
    private String username;

    /**
     * 用户密码
     */
    @NotBlank(message = "密码不能为空")
    @Size(min = 6, message = "密码不能少于6位")
    @ApiModelProperty(value = "用户密码")
    private String password;


}

2、创建结果返回类,用于统一返回信息给前端

/**
 * 结果返回类
 *
 * @author DarkClouds
 * @date 2023/05/09
 */
@Data
@ApiModel(description = "结果返回类")
public class Result<T> {

    /**
     * 返回状态
     */
    @ApiModelProperty(value = "返回状态")
    private Boolean flag;

    /**
     * 状态码
     */
    @ApiModelProperty(value = "状态码")
    private Integer code;

    /**
     * 返回信息
     */
    @ApiModelProperty(value = "返回信息")
    private String msg;

    /**
     * 返回数据
     */
    @ApiModelProperty(value = "返回数据")
    private T data;

    public static <T> Result<T> success() {
        return buildResult(true, null, SUCCESS.getCode(), SUCCESS.getMsg());
    }

    public static <T> Result<T> success(T data) {
        return buildResult(true, data, SUCCESS.getCode(), SUCCESS.getMsg());
    }

    public static <T> Result<T> fail(String message) {
        return buildResult(false, null, FAIL.getCode(), message);
    }

    public static <T> Result<T> fail(Integer code, String message) {
        return buildResult(false, null, code, message);
    }

    private static <T> Result<T> buildResult(Boolean flag, T data, Integer code, String message) {
        Result<T> r = new Result<>();
        r.setFlag(flag);
        r.setData(data);
        r.setCode(code);
        r.setMsg(message);
        return r;
    }

}

3、创建UserMapper接口操作数据库

/**
 * 用户映射器
 *
 * @author DarkClouds
 * @date 2023/05/09
 */
@Repository
public interface UserMapper extends BaseMapper<User> {


}

4、编写Service,ServiceImpl实现业务代码

/**
 * 登录业务接口
 *
 * @author DarkClouds
 * @date 2023/05/09
 */
public interface LoginService {
    /**
     * 用户登录
     *
     * @param login 登录参数
     * @return token
     */
    String login(LoginDTO login);
}


/**
 *  登录业务接口实现类
 *
 * @author DarkClouds
 * @date 2023/05/09
 */
@Service
//代替@Autowired,需要加上final修饰
@RequiredArgsConstructor
public class LoginServiceImpl implements LoginService {

    private final UserMapper userMapper;

    @Override
    public String login(LoginDTO login) {

        User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
                .select(User::getId)
                .eq(User::getUsername, login.getUsername())
                .eq(User::getPassword, SecurityUtils.sha256Encrypt(login.getPassword())));
        // 进行断言,如果查询结果为空,则抛出异常
        Assert.notNull(user,"用户不存在或密码错误");
        // 校验指定账号是否已被封禁,如果被封禁则抛出异常 `DisableServiceException`
        StpUtil.checkDisable(user.getId());
        // 通过校验后,再进行登录
        StpUtil.login(user.getId());
        //返回ToKen
        return StpUtil.getTokenValue();
    }
}

StpUtil.login(Object id)  ----  直接调用框架方法实现登录

会话登录:参数填写要登录的账号id,建议的数据类型:long | int | String, 不可以传入复杂类型,如:User、Admin 等等

只此一句代码,便可以使会话登录成功,实际上,Sa-Token 在背后做了大量的工作,包括但不限于:

  1. 检查此账号是否之前已有登录
  2. 为账号生成 Token 凭证与 Session 会话
  3. 通知全局侦听器,xx 账号登录成功
  4. 将 Token 注入到请求上下文
  5. 等等其它工作……

Sa-Token 为这个账号创建了一个Token凭证,且通过 Cookie 上下文返回给了前端

StpUtil.login(id) 方法利用了 Cookie 自动注入的特性,省略了手写返回 Token 的代码

5、controller实现

/**
 * 登录控制器
 *
 * @author DarkClouds
 * @date 2023/05/09
 */
@Api(tags = "登录模块")
@RestController
@RequiredArgsConstructor
public class LoginController {

    private final LoginService loginService;

    /**
     * 用户登录
     *
     * @param loginDTO 登录参数
     * @return {@link String} Token
     */
    @ApiOperation(value = "用户登录")
    @PostMapping("/login")
    public Result<String> login(@Validated @RequestBody LoginDTO loginDTO) {
        return Result.success(loginService.login(loginDTO));
    }

    /**
     * 用户退出
     */
    @SaCheckLogin //登录校验 —— 只有登录之后才能进入该方法。
    @ApiOperation(value = "用户退出")
    @GetMapping("/logout")
    public Result<?> logout() {
        StpUtil.logout();
        return Result.success();
    }

}

 StpUtil.logout(); ---  当前会话注销登录

3.4、注解鉴权

注解鉴权 —— 优雅的将鉴权与业务代码分离!

  • @SaCheckLogin: 登录校验 —— 只有登录之后才能进入该方法。
  • @SaCheckRole("admin"): 角色校验 —— 必须具有指定角色标识才能进入该方法。
  • @SaCheckPermission("user:add"): 权限校验 —— 必须具有指定权限才能进入该方法。
  • @SaCheckSafe: 二级认证校验 —— 必须二级认证之后才能进入该方法。
  • @SaCheckBasic: HttpBasic校验 —— 只有通过 Basic 认证后才能进入该方法。
  • @SaIgnore:忽略校验 —— 表示被修饰的方法或类无需进行注解鉴权和路由拦截器鉴权。
  • @SaCheckDisable("comment"):账号服务封禁校验 —— 校验当前账号指定服务是否被封禁。

Sa-Token 使用全局拦截器完成注解鉴权功能,为了不为项目带来不必要的性能负担,拦截器默认处于关闭状态
因此,为了使用注解鉴权,你必须手动将 Sa-Token 的全局拦截器注册到你项目中

        // 注册 Sa-Token 的注解拦截器,打开注解式鉴权功能
        registry.addInterceptor(new SaInterceptor()).addPathPatterns("/**");

使用注解鉴权

// 登录校验:只有登录之后才能进入该方法 
@SaCheckLogin                        
@RequestMapping("info")
public String info() {
    return "查询用户信息";
}

// 角色校验:必须具有指定角色才能进入该方法 
@SaCheckRole("super-admin")        
@RequestMapping("add")
public String add() {
    return "用户增加";
}

// 权限校验:必须具有指定权限才能进入该方法 
@SaCheckPermission("user-add")        
@RequestMapping("add")
public String add() {
    return "用户增加";
}

猜你喜欢

转载自blog.csdn.net/a1404359447/article/details/130645454