前言
用于记录自己学习博客项目的流程
基于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 默认将数据保存在内存中,此模式读写速度最快,且避免了序列化与反序列化带来的性能消耗,但是此模式也有一些缺点,比如:
- 重启后数据会丢失。
- 无法在分布式环境中共享数据。
为此,Sa-Token 提供了扩展接口,你可以轻松将会话数据存储在 Redis
、Memcached
等专业的缓存中间件中, 做到重启数据不丢失,而且保证分布式环境下多节点的会话一致性。
集成 Redis 后,是我额外手动保存数据,还是框架自动保存?
- 框架自动保存。集成
Redis
只需要引入对应的pom依赖
即可,框架所有上层 API 保持不变。
3.2、sa-Token配置类
全局侦听器
Sa-Token 提供一种侦听器机制,通过注册侦听器,你可以订阅框架的一些关键性事件,例如:用户登录、退出、被踢下线等。
事件触发流程大致如下:
要注册自定义的侦听器也非常简单:
- 新建类实现
SaTokenListener
接口。- 将实现类注册到
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全局过滤器来实现路由拦截器鉴权
- 相比于拦截器,过滤器更加底层,执行时机更靠前,有利于防渗透扫描。
- 过滤器可以拦截静态资源,方便我们做一些权限控制。
- 部分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 在背后做了大量的工作,包括但不限于:
- 检查此账号是否之前已有登录
- 为账号生成
Token
凭证与Session
会话- 通知全局侦听器,xx 账号登录成功
- 将
Token
注入到请求上下文- 等等其它工作……
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 "用户增加";
}