前言:本系列博客基于Spring Boot 2.6.x依赖的Spring Security5.6.x版本
上两篇文章介绍了Spring Security的整体架构以及认证和鉴权模块原理。本篇文章就是基于Spring Security和JWT的一个demo
一、JWT简介
JWT(JSON Web Token),是一种开放标准(RFC 7519),用于在网络应用环境间安全地传输信息。本质上是一个经过数字签名的JSON对象,能够携带并传递状态信息(如用户身份验证、授权等)
1.1、JWT的结构
JWT由三部分组成,通过点号(.)连接,这三部分分别是头部(Header)、载荷(Payload)和签名(Signature)。类似于xxxx.xxxx.xxxx格式。如下:
eyJhbGciOiJIUzUxMiJ9.eyJMT0dJTl9USU1FIjoxNzIyMzEzMDg4NTU4LCJMT0dJTl9VU0VSIjoidXNlcjIiLCJleHAiOjE3MjIzMTY2ODh9.l-mw4sWCWvIrWSRHUPdiLlgH6tIFxbwx7KwUj0Ldf4CDbdOqQlDuj-x0y6zM4R84vmnRLBBDeH_oLRxx0rcNxQ
- Header:头部,声明了JWT的类型(通常是JWT)以及所使用的加密算法(例如HMAC SHA256或RSA)
- Payload:载荷,承载实际数据的部分,可以包含预定义的声明(如iss(签发者)、exp(过期时间)、sub(主题)等)以及其它自定义的数据。这些信息都是铭文的,但不建议存放敏感信息。
- Signature:签名,通过对前两部分进行编码后的信息,使用指定的密钥通过头部(Header)中声明的加密算法生成,拥有验证数据完整性和防止篡改。
这三部分单独使用base64编码后再通过点号(.)连接。
这里只简单介绍JWT,如果需要详细了解JWT的可以参考以下文章
https://blog.csdn.net/weixin_42753193/article/details/126294904
https://www.cnblogs.com/moonlightL/p/10020732.html
JWT官网
二、Spring Security+JWT认证授权流程代码代码实例
2.1、新建Springboot项目,引入JAR包
新建好Springboot项目,引入用到的jar包
pom文件(只写出了dependencies):
<!--Springboot父工程,定义好了Springboot集成的其他jar包版本,所以引入某些jar时可以不写版本号-->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.15</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<!--使用undertow容器-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!--自定义配置生成元数据信息,这样在配置文件中可以有提示-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<!--Spring Security-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--mysql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!--mybatis-plus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!-- JSON Web Token Support -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>
application.yaml配置文件:
server:
port: 8084
servlet:
context-path: /security
mybatis-plus:
mapper-locations: classpath*:mapper/**/*Mapper.xml
# 使用驼峰命名
# 数据库表列:user_name
# 实体类属性:userName
configuration:
map-underscore-to-camel-case: true
Spring:
redis:
host: 127.0.0.1
port: 6379
lettuce:
pool:
max-idle: 16
max-active: 32
min-idle: 8
datasource:
# 数据源基本配置
username: root
password: root1234
url: jdbc:mysql://127.0.0.1:3306/test?allowPublicKeyRetrieval=true&useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai
# driver-class需要注意mysql驱动的版本(com.mysql.cj.jdbc.Driver 或 com.mysql.jdbc.Driver)
driver-class-name: com.mysql.cj.jdbc.Driver
type: com.zaxxer.hikari.HikariDataSource
hikari:
pool-name: Retail_HikariCP
minimum-idle: 5 #最小空闲连接数
idle-timeout: 180000 #空闲连接存活最长时间 默认600000(10分钟)
maximum-pool-size: 10 #连接池最大连接数,默认10
auto-commit: true #此属性控制从连接池返回的连接的默认自动提交行为,默认true
max-lifetime: 1800000 #连接的最长生命周期,0表示无限,默认1800000即30分钟
connection-timeout: 30000 #数据库连接超时时间,默认30秒,即3000
connection-test-query: SELECT 1 FROM DUAL
2.2、数据库操作相关类
数据库脚本(mysql):
create table `manager`(
`id` int NOT NULL AUTO_INCREMENT,
`login_name` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '登录名',
`password` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '密码',
`name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '姓名',
`id_number` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '身份证',
`mobile` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '手机号',
`email` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '邮箱',
PRIMARY KEY (`id`) USING BTREE
)ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic COMMENT = '管理员表';
##密码是123456
INSERT INTO `manager` (`id`, `login_name`, `password`, `name`, `id_number`, `mobile`, `email`) VALUES (1, 'user1', '$2a$10$JrdOPx3zKcNqLQnU7GrdUeE2XA3KXZgu3QqLCeBTJWPxJjOOfOHGG', '张三', NULL, NULL, NULL);
INSERT INTO `manager` (`id`, `login_name`, `password`, `name`, `id_number`, `mobile`, `email`) VALUES (2, 'user2', '$2a$10$JrdOPx3zKcNqLQnU7GrdUeE2XA3KXZgu3QqLCeBTJWPxJjOOfOHGG', '李四', NULL, NULL, NULL);
create table `role`(
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色名',
`code` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色编码',
`type` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色类别',
`description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '角色描述',
PRIMARY KEY (`id`) USING BTREE
)ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic COMMENT = '角色表';
INSERT INTO `role` (`id`, `name`, `code`, `type`, `description`) VALUES (1, '管理员角色', 'AdminManager', 'admin', NULL);
INSERT INTO `role` (`id`, `name`, `code`, `type`, `description`) VALUES (2, '审批用户角色', 'ApproveUser', 'approve', NULL);
create table `permission`(
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '权限名',
`code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '权限编码',
`type` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '权限类别',
`url` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '资源权限路径',
`anonymous` int NOT NULL COMMENT '是否可以匿名访问 1-是 0-否',
`description` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '权限描述',
PRIMARY KEY (`id`) USING BTREE
)ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic COMMENT = '权限表';
INSERT INTO `permission` (`id`, `name`, `code`, `type`, `url`, `description`, `anonymous`) VALUES (1, '主页接口', 'main', 'interface', '/main', NULL, 0);
INSERT INTO `permission` (`id`, `name`, `code`, `type`, `url`, `description`, `anonymous`) VALUES (2, '测试接口1', 'test1', 'interface', '/adminRole', NULL, 0);
INSERT INTO `permission` (`id`, `name`, `code`, `type`, `url`, `description`, `anonymous`) VALUES (3, '测试接口2', 'test2', 'interface', '/touristRole', NULL, 0);
INSERT INTO `permission` (`id`, `name`, `code`, `type`, `url`, `description`, `anonymous`) VALUES (4, '登录接口', 'login', 'interface', '/login', NULL, 1);
INSERT INTO `permission` (`id`, `name`, `code`, `type`, `url`, `description`, `anonymous`) VALUES (5, '注销接口', 'logout', 'interface', '/myLogout', NULL, 1);
create table `manager_role_rel`(
`id` int NOT NULL AUTO_INCREMENT,
`manager_id` int NOT NULL COMMENT '用户id',
`role_id` int NOT NULL COMMENT '角色id',
PRIMARY KEY (`id`) USING BTREE
)ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic COMMENT = '用户角色关联表';
INSERT INTO `manager_role_rel` (`id`, `manager_id`, `role_id`) VALUES (1, 1, 1);
INSERT INTO `manager_role_rel` (`id`, `manager_id`, `role_id`) VALUES (2, 2, 2);
INSERT INTO `manager_role_rel` (`id`, `manager_id`, `role_id`) VALUES (3, 1, 2);
create table `role_permission_rel`(
`id` int NOT NULL AUTO_INCREMENT,
`role_id` int NOT NULL COMMENT '用户id',
`permission_id` int NOT NULL COMMENT '角色id',
PRIMARY KEY (`id`) USING BTREE
)ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic COMMENT = '角色权限关联表';
INSERT INTO `role_permission_rel` (`id`, `role_id`, `permission_id`) VALUES (1, 1, 1);
INSERT INTO `role_permission_rel` (`id`, `role_id`, `permission_id`) VALUES (2, 1, 2);
INSERT INTO `role_permission_rel` (`id`, `role_id`, `permission_id`) VALUES (3, 1, 3);
INSERT INTO `role_permission_rel` (`id`, `role_id`, `permission_id`) VALUES (4, 2, 1);
INSERT INTO `role_permission_rel` (`id`, `role_id`, `permission_id`) VALUES (5, 2, 3);
实体类:
@Data
@TableName("manager")
public class ManagerDomain {
@TableId(type = IdType.AUTO)
private Integer id;
//@TableField("user_name")
private String loginName;
private String password;
private String name;
private String idNumber;
private String mobile;
private String email;
}
@Data
@TableName("permission")
public class PermissionDomain {
@TableId(type = IdType.AUTO)
private Integer id;
private String name;
private String code;
private String type;
private String url;
private String description;
private Integer anonymous;
}
@Data
@TableName("role")
public class RoleDomain {
@TableId(type = IdType.AUTO)
private Integer id;
private String name;
private String code;
private String description;
}
mybatis的Mapper接口及配置文件:
@Mapper
public interface ManagerMapper extends BaseMapper<ManagerDomain> {
}
@Mapper
public interface PermissionMapper extends BaseMapper<PermissionDomain> {
/**
* 根据角色code获取该角色的资源权限url
* @param roleCode
* @return
*/
List<String> getPermissionUrlByRole(String roleCode);
List<String> getAnonymousPermissionUrl();
}
@Mapper
public interface RoleMapper extends BaseMapper<RoleDomain> {
/**
* 根据用户id获取该用户拥有的角色的code
* @param managerId
* @return
*/
List<String> getRoleCodeByManagerId(Integer managerId);
/**
* 获取所有角色的code
* @return
*/
List<String> getAllRoleCode();
}
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.dmf.demo.jwt.security.dao.ManagerMapper">
</mapper>
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.dmf.demo.jwt.security.dao.PermissionMapper">
<select id="getPermissionUrlByRole" resultType="java.lang.String">
select p.url
from role r
LEFT JOIN role_permission_rel rpr on r.id = rpr.role_id
left join permission p on rpr.permission_id = p.id
where r.code = #{roleCode}
</select>
<select id="getAnonymousPermissionUrl" resultType="java.lang.String">
select url from permission where anonymous = 1
</select>
</mapper>
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.dmf.demo.jwt.security.dao.RoleMapper">
<select id="getRoleCodeByManagerId" resultType="java.lang.String">
select r.code
from role r
left join manager_role_rel mrr on mrr.role_id = r.id
WHERE mrr.manager_id= #{managerId}
</select>
<select id="getAllRoleCode" resultType="java.lang.String">
select code from role
</select>
</mapper>
2.3、Controller和Service
@Slf4j
@Controller
public class SystemController {
@Autowired
private SystemService systemService;
/**
* 登录
* @param userName
* @param password
* @return
*/
@RequestMapping("/login")
@ResponseBody
public String login(String userName, String password){
log.info("用户{}登录",userName);
return systemService.login(userName,password);
}
@RequestMapping("/myLogout")
@ResponseBody
public String logout(HttpServletRequest request){
systemService.logout(request);
return "success";
}
/**
* @return
*/
@RequestMapping("/adminRole")
@ResponseBody
public String adminRole(){
return "success";
}
@RequestMapping("/touristRole")
@ResponseBody
public String touristRole(){
return "success";
}
}
service接口及实现类
public interface SystemService {
String login(String userName,String password);
void logout(HttpServletRequest request);
}
SystemService 实现类:
@Slf4j
@Service
public class SystemServiceImpl implements SystemService {
@Resource
private AuthenticationManager authenticationManager;
@Resource
private RedisTemplate<String,String> stringRedisTemplate;
@Override
public String login(String userName, String password) {
//1、根据用户输入的用户名和密码创建认证凭证Authentication
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(userName, password);
//2、调用AuthenticationManager认证管理器的authenticate方法进行认证操作,返回认证成功后的凭证Authentication
Authentication authenticate = null;
try {
authenticate = authenticationManager.authenticate(authenticationToken);
} catch (AuthenticationException e) {
//这里自己捕获认证异常,自己处理,如果自己不处理的话,异常会交给自定义的AuthenticationEntryPoint处理
//如果没定义AuthenticationEntryPoint,Spring Security会默认返回403
log.error("登录失败!原因:{}",e.getMessage());
throw new RuntimeException("登录失败!");
}
//3、生成jwt
//拿到认证成功后的用户信息
LoginUserDetails userDetails = (LoginUserDetails) authenticate.getPrincipal();
String accessToken = JwtUtils.createToken(userDetails);
//4、保存用户信息到redis
LoginUserInfoDto loginUserInfoDto = LoginUserInfoDto.builder()
.loginName(userDetails.getUsername())
.id(userDetails.getManager().getId())
.name(userDetails.getManager().getName())
.mobile(userDetails.getManager().getMobile())
.roles(userDetails.getRoles())
.build();
String key = GlobalConstants.LOGIN_CACHE_KEY_PREFIX+userDetails.getUsername();
stringRedisTemplate.opsForValue().set(key, JSONObject.toJSONString(loginUserInfoDto),60, TimeUnit.MINUTES);
return accessToken;
}
@Override
public void logout(HttpServletRequest request) {
String token = request.getHeader("token");
if(StringUtils.isNotEmpty(token)){
String userName = JwtUtils.getUserName(token);
//清除redis
if(StringUtils.isNotEmpty(userName)){
String key = GlobalConstants.LOGIN_CACHE_KEY_PREFIX+userName;
stringRedisTemplate.delete(key);
}
}
}
}
用户登录信息实体类LoginUserInfoDto:
@Data
@Builder
public class LoginUserInfoDto {
private Integer id;
private String loginName;
private String name;
private String idNumber;
private String mobile;
private List<String> roles;
/**
* 组装spring security的权限
* @return
*/
public Collection<? extends GrantedAuthority> getAuthorities() {
List<SimpleGrantedAuthority> grantedAuthorities = new ArrayList<>();
if(!CollectionUtils.isEmpty(roles)){
roles.forEach(roleCode ->{
grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + roleCode));
});
}
return grantedAuthorities;
}
}
全局常数类:
public class GlobalConstants {
/**
* 请求携带的token参数,参数名
*/
public static final String HEADER_TOKEN_NAME = "token";
/**
* 用户登录信息缓存KEY前缀
*/
public static final String LOGIN_CACHE_KEY_PREFIX = "USER_INFO:";
/**
* 全局资源权限缓存key
*/
public static final String GLOBAL_PERMISSION_KEY_PREFIX = "GLOBAL_PERMISSION:";
/**
* 允许匿名访问资源缓存key
*/
public static final String GLOBAL_PERMISSION_ANONYMOUS = "GLOBAL_PERMISSION:ANONYMOUS";
}
JWT工具类:
@Slf4j
public class JwtUtils {
/** jwt加密秘钥*/
public static final String DEFAULT_SECRET = "abcdefghijk";
/** jwt数据声明里登录用户key*/
public static final String LOGIN_USER = "LOGIN_USER";
/** jwt数据声明里登录时间key*/
public static final String LOGIN_TIME = "LOGIN_TIME";
/** jwt默认过期时间*/
public static Long DEFAULT_TTL = 60*60*1000l; //一个小时
/**
* 生成jwt使用默认设置
* @param claims
* @return
*/
public static String createToken(Map<String, Object> claims){
return createToken(claims,DEFAULT_TTL,DEFAULT_SECRET);
}
/**
* 生成jwt
* @param claims
* @param ttl 过期时间 ms
* @return
*/
public static String createToken(Map<String, Object> claims,Long ttl){
return createToken(claims,ttl,DEFAULT_SECRET);
}
/**
*
* @param userDetails Spring Security用户信息
* @param ttl 过期时间 ms
* @return
*/
public static String createToken(UserDetails userDetails,Long ttl){
Map<String, Object> claims = new HashMap<>();
claims.put(LOGIN_USER,userDetails.getUsername());
claims.put(LOGIN_TIME,new Date());
return createToken(claims,ttl,DEFAULT_SECRET);
}
/**
*
* @param userDetails
* @return
*/
public static String createToken(UserDetails userDetails){
return createToken(userDetails,DEFAULT_TTL);
}
/**
* 生成jwt
* @param claims
* @return
*/
public static String createToken(Map<String, Object> claims,Long ttl,String secret){
return Jwts.builder()
.setClaims(claims) //设置数据
.setExpiration(generateExpirationDate(ttl))
.signWith(SignatureAlgorithm.HS512, secret) //签名,参数包括算法和秘钥
.compact(); //压缩生成xxx.xxx.xxx
}
/**
* 生成token的过期时间
* @param ttl 单位是毫秒
* @return
*/
private static Date generateExpirationDate(Long ttl) {
return new Date(System.currentTimeMillis() + ttl);
}
/**
* 解析jwt拿到数据,使用默认配置
* @param token
* @return
*/
public static Claims parseToken(String token){
return parseToken(token,DEFAULT_SECRET);
}
/**
* 解析jwt拿到数据
* @param token
* @return
*/
public static Claims parseToken(String token,String secret){
Claims claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
return claims;
}
/**
* 获取jwt里的用户名称
* @param token
* @return
*/
public static String getUserName(String token){
return (String)parseToken(token).get(LOGIN_USER);
}
/**
* token是否已经过期
* @param claims
* @return
*/
private static boolean isTokenExpired(Claims claims) {
Date expire = claims.getExpiration();
if(expire!=null){
return expire.before(new Date());
}
return false;
}
}
2.4、Spring Security自定义认证和鉴权
上篇文章已经介绍过Spring Security的认证和鉴权架构。
认证:
Spring Security的认证主要由AuthenticationManager -> AuthenticationProvider流程。而AuthenticationProvider调用UserDetailsService的loadUserByUsername方法先查询系统用户,再和用户输入的用户信息做比对认证。
所以自定义认证,我们只需要在配置类里定义自己的AuthenticationManager和AuthenticationProvider,以及实现UserDetailsService接口。另外UserDetails类的默认实现类User使用不方便,也可以实现自定义的UserDetails来做功能扩展
自定义的UserDetails实现类:
@Data
@Builder
public class LoginUserDetails implements UserDetails {
private ManagerDomain manager;
private Integer id;
private String username;
private String password;
private boolean enabled;
private boolean locked;
private Collection<? extends GrantedAuthority> grantedAuthorities;
private List<String> roles;
public Integer getUserId() {
return this.manager.getId();
}
// 返回当前用户的权限列表
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
if (grantedAuthorities != null)
return this.grantedAuthorities;
List<SimpleGrantedAuthority> grantedAuthorities = new ArrayList<>();
if(!CollectionUtils.isEmpty(roles)){
roles.forEach(roleCode ->{
grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + roleCode));
});
}
return grantedAuthorities;
}
@Override
public String getPassword() {
return this.password;
}
@Override
public String getUsername() {
return this.username;
}
//账号是否未过期,直接返回true 表示账户未过期,也可以在数据库中添加该字段
@Override
public boolean isAccountNonExpired() {
return true;
}
//账号是否被锁, 这里和数据库中的locked字段刚好相反,所有取反
@Override
public boolean isAccountNonLocked() {
return true;
}
//密码是否为过期,数据库中无该字段,直接返回true
@Override
public boolean isCredentialsNonExpired() {
return true;
}
//账户是否可用,从数据库中获取该字段
@Override
public boolean isEnabled() {
return true;
}
}
自定义的UserDetailsService实现类:
public class DBUserDetailsManager implements UserDetailsManager, UserDetailsPasswordService {
@Resource
private ManagerMapper managerMapper;
@Resource
private RoleMapper roleMapper;
/**
* UserDetails提供的字段如果不够的话,可以继承 User类,实现自己的UserDetails
* 用户认证时会调用
* @param username
* @return
* @throws UsernameNotFoundException
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
ManagerDomain user = managerMapper.selectOne(new LambdaQueryWrapper<ManagerDomain>()
.eq(ManagerDomain::getLoginName,username)
);
if(user == null){
throw new UsernameNotFoundException(username);
}
//查询用户角色
List<String> roles = roleMapper.getRoleCodeByManagerId(user.getId());
LoginUserDetails userDetails = LoginUserDetails.builder()
.username(user.getLoginName())
.password(user.getPassword())
.manager(user)
.roles(roles)
.build();
return userDetails;
}
@Override
public void createUser(UserDetails user) {
}
@Override
public void updateUser(UserDetails user) {
}
@Override
public void deleteUser(String username) {
}
@Override
public void changePassword(String oldPassword, String newPassword) {
}
@Override
public boolean userExists(String username) {
return false;
}
@Override
public UserDetails updatePassword(UserDetails user, String newPassword) {
return null;
}
}
AuthenticationManager和AuthenticationProvider,Spring Security提供了默认的实现ProviderManager和DaoAuthenticationProvider。直接在配置类配置这两个bean即可。
鉴权:
鉴权流程主要由AccessDecisionManager(鉴权管理器)和AccessDecisionVoter(投票器)来处理。鉴权管理器使用默认实现之一的UnanimousBased(一票反对,只要有一票反对就不能通过),然后实现自定义的投票器即可。
在实际鉴权处理前,我们还需要一个过滤器来处理jwt,通过jwt来拿到认证信息。
jwt过滤器:
@Slf4j
//@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Resource
private RedisTemplate<String,String> stringRedisTemplate;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//1、拿到token
String token = request.getHeader("token");
if(StringUtils.isNotEmpty(token)){
//2、校验token
try {
String username = JwtUtils.getUserName(token);
String key = GlobalConstants.LOGIN_CACHE_KEY_PREFIX+username;
String userInfoStr = stringRedisTemplate.opsForValue().get(key);
if(StringUtils.isNotEmpty(userInfoStr)){
//得到用户账号及权限相关信息
LoginUserInfoDto loginUserInfoDto = JSONObject.parseObject(userInfoStr,LoginUserInfoDto.class);
//设置该用户的权限上下文信息,方便后续过滤器校验
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(loginUserInfoDto,null,loginUserInfoDto.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}else{
throw new RuntimeException("token无效或已过期,请重新登录!");
}
//放行
filterChain.doFilter(request,response);
} catch (RuntimeException e) {
//自行处理认证异常,如果不处理的话,会由Spring Security处理,如果没定义异常处理handler,最后会返回403
exceptionHandle(request,response,e);
}
}else{
//放行
filterChain.doFilter(request,response);
}
}
/**
* jwt认证失败处理
* @param request
* @param response
* @param e
*/
private void exceptionHandle(HttpServletRequest request, HttpServletResponse response,Exception e) throws IOException {
log.info("jwt认证失败,原因:{}",e.getMessage());
//这里就不往下走了,直接返回失败的结果
Map<String,Object> result = new HashMap();
result.put("code",-3);
result.put("message","token认证失败!");
// 将结果对象转换成json字符串
String json = JSON.toJSONString(result);
response.setContentType("application/json;charset=UTF-8");
// 响应体
response.getWriter().println(json);
}
}
自定义AccessDecisionVoter(投票器):
@Slf4j
public class AccessDecisionProcessor implements AccessDecisionVoter<FilterInvocation> {
@Resource
private RedisTemplate<String,String> stringRedisTemplate;
@Resource
private PermissionMapper permissionMapper;
@Override
public boolean supports(ConfigAttribute attribute) {
return true;
}
@Override
public boolean supports(Class<?> clazz) {
return true;
}
@Override
public int vote(Authentication authentication, FilterInvocation object, Collection<ConfigAttribute> attributes) {
//默认否决票
int result = ACCESS_DENIED;
String requestUrl = object.getRequest().getServletPath();
String method = object.getRequest().getMethod();
log.debug("进入自定义鉴权投票器,URI : {} {}", method, requestUrl);
//判断请求是否运行匿名访问
boolean anonymous = stringRedisTemplate.opsForHash().hasKey(GlobalConstants.GLOBAL_PERMISSION_ANONYMOUS,requestUrl);
if(anonymous){
//允许匿名访问直接同意
return ACCESS_GRANTED;
}
//拿到用户的角色
Object principal = authentication.getPrincipal();
//principal不是LoginUserInfoDto表示是匿名用户或未认证的用户,且请求url未在数据库配置权限
if(principal instanceof LoginUserInfoDto){
LoginUserInfoDto dto = (LoginUserInfoDto)principal;
List<String> roles = dto.getRoles();
String keyPrefix = GlobalConstants.GLOBAL_PERMISSION_KEY_PREFIX;
if(!CollectionUtils.isEmpty(roles)){
for(String roleCode : roles){
String key = keyPrefix+roleCode;
if(stringRedisTemplate.hasKey(key)){
String val = (String)stringRedisTemplate.opsForHash().get(key,requestUrl);
if(val!=null){
//存在投同意
result = ACCESS_GRANTED;
//结束循环
break;
}
}else{
//如果缓存没有,查库
List<String> urls = permissionMapper.getPermissionUrlByRole(roleCode);
if(!CollectionUtils.isEmpty(urls)){
//存缓存
Map<String,Object> map = new HashMap<>();
urls.forEach(url ->{
map.put(url,"1");
});
stringRedisTemplate.opsForHash().putAll(key,map);
if(urls.contains(requestUrl)){
//存在投同意
result = ACCESS_GRANTED;
//结束循环
break;
}
}
}
}
}
}else{
//匿名用户请求,且请求url未在数据库配置权限,交给WebExpressionVoter处理,这里就不做处理
result = ACCESS_ABSTAIN;
}
return result;
}
}
这个投票器的主要逻辑是,去redis查询项目启动时初始化的角色权限缓存。没有缓存,则查库。拿到用户认证信息(在jwt过滤器里设置的)里的角色,判断角色权限缓存里有没有请求的url,有则表示该角色能访问该url,即用户有权访问该url。
初始化角色权限缓存:
@Component
@Slf4j
public class PermissionInitRunner implements ApplicationRunner {
@Resource
private RedisTemplate<String,String> stringRedisTemplate;
@Resource
private RoleMapper roleMapper;
@Resource
private PermissionMapper permissionMapper;
@Override
public void run(ApplicationArguments args) throws Exception {
String keyPrefix = GlobalConstants.GLOBAL_PERMISSION_KEY_PREFIX;
log.info("开始初始化全局资源权限缓存");
List<String> allRoleCode = roleMapper.getAllRoleCode();
if(!CollectionUtils.isEmpty(allRoleCode)){
for(String roleCode : allRoleCode){
List<String> urls = permissionMapper.getPermissionUrlByRole(roleCode);
if(!CollectionUtils.isEmpty(urls)){
Map<String,Object> map = new HashMap<>();
urls.forEach(url ->{
map.put(url,"1");
});
stringRedisTemplate.opsForHash().putAll(keyPrefix+roleCode,map);
}
}
}
//允许匿名访问的资源权限key
List<String> urls = permissionMapper.getAnonymousPermissionUrl();
if(!CollectionUtils.isEmpty(urls)){
String key = GlobalConstants.GLOBAL_PERMISSION_ANONYMOUS;
Map<String,Object> map = new HashMap<>();
urls.forEach(url ->{
map.put(url,"1");
});
stringRedisTemplate.opsForHash().putAll(key,map);
}
log.info("初始化全局资源权限缓存结束");
}
}
自定义认证异常和鉴权异常的处理类:
认证异常处理类:
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
String localizedMessage = "未认证,请先认证!";//authException.getLocalizedMessage();
Map<String,Object> result = new HashMap();
result.put("code",-2); // 告诉用户需要登录
result.put("message",localizedMessage); //
// 将结果对象转换成json字符串
String json = JSON.toJSONString(result);
// 返回json数据到前端
// 响应头
response.setContentType("application/json;charset=UTF-8");
// 响应体
response.getWriter().println(json);
//返回登录界面
//response.sendRedirect(request.getContextPath()+"/myLoginPage");
}
}
鉴权异常处理类:
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
Map<String,Object> result = new HashMap();
result.put("code",-1); // 没有权限
result.put("message","没有权限"); //
// 将结果对象转换成json字符串
String json = JSON.toJSONString(result);
// 返回json数据到前端
// 响应头
response.setContentType("application/json;charset=UTF-8");
// 响应体
response.getWriter().println(json);
//返回页面
//response.sendRedirect(request.getContextPath()+"/main");
}
}
Spring Security配置类:
@Configuration
public class WebSecurityConfig {
/**
* 密码编码器,会对请求传入的密码进行加密
* @return
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService(){
return new DBUserDetailsManager();
}
@Bean
public AuthenticationProvider authenticationProvider(UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder){
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(userDetailsService);
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder);
return daoAuthenticationProvider;
}
/**
* 认证管理器
* @param authenticationProvider
* @return
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationProvider authenticationProvider){
// ProviderManager 是 AuthenticationManager 最常用的实现
return new ProviderManager(authenticationProvider);
}
/**
* jwt过滤器
* @return
*/
@Bean
public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter(){
return new JwtAuthenticationTokenFilter();
}
/**
* 自定义鉴权投票器
* @return
*/
@Bean
public AccessDecisionVoter<FilterInvocation> accessDecisionProcessor() {
return new AccessDecisionProcessor();
}
/**
* 鉴权管理器
* @return
*/
@Bean
public AccessDecisionManager accessDecisionManager() {
// 构造一个新的AccessDecisionManager 放入两个投票器
//WebExpressionVoter为配置文件投票器,即在HttpSecurity 的authorizeRequests方法里定义的过滤规则,使用他是为了也可以使用配置定义好放行规则
List<AccessDecisionVoter<?>> decisionVoters = Arrays.asList(new WebExpressionVoter(), accessDecisionProcessor());
//UnanimousBased为一票否决鉴权
//AffirmativeBased为一票通过鉴权,WebExpressionVoter投票如果未配置则默认为通过,所以这里需要配置为UnanimousBased
return new UnanimousBased(decisionVoters);
}
/**
* Spring Security配置
* @param http
* @return
* @throws Exception
*/
@Bean
public SecurityFilterChain mySecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeRequests(authorize ->authorize
// 放行所有OPTIONS请求,跨域请求会先发一个OPTIONS请求
.antMatchers(HttpMethod.OPTIONS).permitAll()
.antMatchers("/login").permitAll()
.antMatchers("/myLogout").permitAll()
.anyRequest() //对所有请求开启授权保护
.authenticated() //已认证的请求会被自动授权
.accessDecisionManager(accessDecisionManager())
);
//添加自定义过滤器
http.addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class);
http.exceptionHandling(exception -> exception
.authenticationEntryPoint(new MyAuthenticationEntryPoint()) //请求未认证的处理
.accessDeniedHandler(new MyAccessDeniedHandler()) //未授权资源请求处理
);
//关闭csrf防护,否则所有的POST的请求都需要携带CSRF令牌
http.csrf(csrf -> csrf.disable());
// 关闭Session机制
//http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
return http.build();
}
}