目录
一、简介与配置
二、实现登录注销操作 <— 你在这里 ( •̀ ω •́ )y
三、权限认证构成与鉴权方法
四、RBAC 结构实现

Sa-Token(二)实现登录注销操作

[!NOTE]

博主前言:Spring Security 的学习真是惊掉我下巴,就一个鉴权功能,搞那么多复杂概念,又是过滤器链又是注入的,花了我一个星期左右,实际上还很不好用(未知错误给前端返回 401 是真绷不住)。Sa-Token 作为国产鉴权,性能更强的同时简化开发的程度相比 Spring Security 起码有十倍不止。最近我还注意到对标 Spring 生态的 Solon 框架,也是国产,性能提升3倍多,内存占用减少 50%+,打包还更小,真不知道这些工作为什么老外都做的这么复杂。

经过刚才的配置,我们已经对 Sa-Token 有了初步的了解,接下来要实现接入数据库的,真正的登录注销操作。


一,理解概念与分析需求

  1. Cookie与前后端分离

    常规 Web 端鉴权方法,一般由Cookie完成,而Cookie有两个特性:

    1. 可由后端控制写入。
    2. 每次请求自动提交。

    这就使得我们在前端代码中,无需任何特殊操作,就能完成鉴权的全部流程(因为整个流程都是后端控制完成的)

    我们在上一章初步实现登录时,发现没有显式返回token。实际上,StpUtil.login(id)方法利用了Cookie自动注入的特性,省略了你手写返回token的代码。

    但是,对于前后端分离框架(如APP和小程序),后端无法控制Cookie的写入,我们就需要显式返回token,前端存储在本地,每次请求时,封装到header内,供后端校验。

  2. 分析需求

    • 登录:
      • 传递:账号与密码,供后端校验
      • 返回:该用户的数据(不含密码)和token
    • 登出:
      • 传递:前端只发起请求,后端要根据当前会话执行登出
      • 返回:无,可以返回一些登出成功回调信息

    另外,我们还需要配置单向密码加密,不能明文存储密码,也不能明文比对密码。


二、准备数据模型

[!IMPORTANT]

本文基于 Mybatis Plus 处理数据层逻辑,构建Service层和Mapper层的代码这里不再赘述。

我们需要自定义一个用户类:

列名 数据类型 描述
id(主键) bigint 用户主键
account varchar(32) 账号
password varchar(64) 密码

建表语句:

[!NOTE]

下述命令将创建用户admin,密码明文为 password

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` bigint NOT NULL COMMENT '主键,',
`account` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_zh_0900_as_cs NOT NULL COMMENT '账号,',
`password` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_zh_0900_as_cs NOT NULL COMMENT '密码,',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `account_unique`(`account` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_zh_0900_as_cs COMMENT = '用户' ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of user
-- ----------------------------
INSERT INTO `user` VALUES (1, 'admin', '$2a$10$94dB3lFHtaAMu9fznn2enO658xu.dvWMCn8qSg0tjfe4/1st.7tyS');

对应数据模型类代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 测试用用户
*/
@Data
public class User implements Serializable {
@Serial
private static final long serialVersionUID = 1L;

/**
* 主键
*/
@TableId
private Long id;
/**
* 账号
*/
private String account;

/**
* 密码
*/
private String password;
}

定义用户登录回调UserLoginVO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 登录成功响应体
*
* @author Amane64
*/
@Data
public class UserLoginVO {
/**
* 主键
*/
@NotNull
private Long id;
/**
* 账号
*/
@NotNull
private String account;
/**
* token
*/
@NotNull
private String token;
}

之后,准备登录Controller及相关接口:

[!TIP]

这里的返回结构为我自定,读者亦可自定

Sa-Token 亦提供了一套返回封装SaResult,读者可自行尝试

代码参考如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
/**
* 返回结果封装体
*
* @param <T> 数据体类型
* @author Amane64
*/
@Data
public class Result<T> {

/**
* 状态码
*/
@NotNull
private int code;
/**
* 状态信息
*/
@NotNull
private String message;
/**
* 请求时间
*/
@NotNull
@JSONField(format="yyyy-MM-dd HH:mm:ss")
private LocalDateTime requestTime;
/**
* 数据体
*/
@NotNull
private T data;

public Result() {
this.requestTime = LocalDateTime.now();
}

/**
* 成功回调
*
* @param data 数据体
* @param <T> 数据体类型
* @return 返回结果
*/
public static <T> Result<T> success(T data) {
var res = new Result<T>();
res.code = RequestEnum.SUCCESS.getCode();
res.message = RequestEnum.SUCCESS.getMessage();
res.data = data;
return res;
}

/**
* 成功回调(空回调)
*
* @return 返回结果
*/
public static Result<?> success() {
return Result.success(null);
}

/**
* 错误回调
*
* @param code 状态码
* @param message 状态信息
* @return 返回结果
*/
public static Result<?> error(int code, String message) {
var res = new Result<>();
res.code = code;
res.message = message;
return res;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* 登录登出控制器
*
* @author Amane64
*/
@RestController
@RequestMapping
@RequiredArgsConstructor
public class LoginController {
private final UserService userService;

/**
* 登录
*
* @param account 账号
* @param password 密码
* @return 登录成功回调
*/
@PostMapping("/login")
public Result<UserLoginVO> login(@NotNull String account, @NotNull String password) {
return Result.success(userService.login(account, password));
}

/**
* 登出
*/
@PostMapping("/logout")
public Result<?> logout() {
userService.logout();
return Result.success();
}
}

三、逻辑实现

首先,定义服务层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public interface UserService extends IService<User> {

/**
* 登录
*
* @param account 账号
* @param password 密码
* @return 登录成功回调
*/
UserLoginVO login(@NotNull String account, @NotNull String password);

/**
* 登出
*/
void logout();
}

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {

@Override
public UserLoginVO login(String account, String password) {
// todo undone
return null;
}

@Override
public void logout() {
// todo undone
}
}

实现登录逻辑:

[!TIP]

LocalStringUtils为我个人定义的字符串工具,检验一个字符串是否为null、为空或全为空格,读者可自定义或使用其它现成的包。

BaseRequestException(RequestEnum.REQUEST_EMPTY)为我个人定义的异常,旨在参数为空时抛出,之后自动向前端返回HTTP 400的欲封装回调,读者亦可自定义全局异常处理,参考这篇文章:Spring Boot 全局异常拦截配置

BCrypt.checkpw(raw, hashed)为 Sa-Token 封装的BCrypt加密验证工具,关于BCrypt算法,参考:Spring Security(三)基础自定义配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
public UserLoginVO login(String account, String password) {
// 检验数据合法性
if (LocalStringUtils.isEmpty(account)
|| LocalStringUtils.isEmpty(password))
throw new BaseRequestException(RequestEnum.REQUEST_EMPTY);

// 比对账号密码,执行登录
User user = this.getOne(new LambdaQueryWrapper<User>()
.eq(User::getAccount, account));
if (user == null)
throw new BaseRequestException(RequestEnum.USER_NOT_EXIST);
if (!BCrypt.checkpw(password, user.getPassword()))
throw new BaseRequestException(RequestEnum.ACCOUNT_PASSWORD_ERROR);
StpUtil.login(user.getId());

// 封装并返回
var vo = new UserLoginVO();
BeanUtils.copyProperties(user, vo);
vo.setToken(StpUtil.getTokenValue());
return vo;
}

实现登出逻辑:

1
2
3
4
5
@Override
public void logout() {
if (!StpUtil.isLogin()) return;
StpUtil.logout();
}

全局异常拦截里,处理未登录异常返回:

1
2
3
4
5
6
// 返回 http 401 状态码
@ExceptionHandler(NotLoginException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public Result<?> notLoginException(NotLoginException e) {
// 具体实现略...
}

四、验证

  1. 前后端不分离,Cookie方式

执行登录:

登录-Cookie

登录终端输出-Cookie

手动封装Cookie

封装-Cookie

登出,可以看到能根据Cookie正确识别当前用户:

登出-Cookie

登出终端输出-Cookie

  1. 前后端分离,Header传递token

手动清空Cookie

删除Cookie

执行登录:

登录-Header

登录终端输出-Header

封装Header

封装-Header

登出,可以看到能根据Header正确识别当前用户:

登出-Header

登出终端输出-Header