编程区新手,未来持续创作新项目
github仓库:https://github.com/neutron123ab/LoginTemplate
github主页:https://github.com/neutron123ab
某天突发奇想,想做一个稍微完善点的登录系统,于是就开始编写这个项目,但目前这个项目还比较粗糙,后续我会不断完善,以后写项目就可以直接拿过来用了。
首先介绍一下这个登录系统的大致思路:
前端先进行注册操作,为了数据传输安全,这里会先对前端输入的密码进行RSA加密,所用的公钥从后端接口中获取。后端接受到数据后使用密钥进行RSA解密,得到了密码明文。然后再对密码进行BCrypt
加密,和用户信息一起存入数据库中。有人可能会问为什么这里不直接将前端加密后的密码存入数据库中,我的想法是:如果直接存储前端密文,别人不就有机会能直接获取数据库里面的数据了吗,所以还是把前端密文当做是一个前后端数据传输的临时产物吧。
这里再附上我的RSA工具类:
@Getter
public class RSAUtils {
private final String publicKey;
private final String privateKey;
private static RSAUtils rsaUtils;
private static RSA rsa;
/**
* 生成密钥对
*/
private RSAUtils(){
rsa = new RSA();
publicKey = rsa.getPublicKeyBase64();
privateKey = rsa.getPrivateKeyBase64();
}
/**
* 单例模式获得工具类实例,防止频繁生成密钥对
* @return 实例
*/
public static RSAUtils getRsaUtils(){
if(rsaUtils == null){
rsaUtils = new RSAUtils();
}
return rsaUtils;
}
/**
* 解密
* @param password 密文
* @return 明文
*/
public String decodePassword(String password){
String publicKey = rsaUtils.getPublicKey();
String privateKey = rsaUtils.getPrivateKey();
RSA rsa = new RSA(privateKey, publicKey);
byte[] decrypt = rsa.decrypt(password, KeyType.PrivateKey);
return new String(decrypt);
}
}
然后就是登录功能了。我的实现方案是:用户输入完用户名、密码后,后端进行校验,校验通过后Spring Security会生成一个认证信息。然后生成一个UUID字符串,将其作为key,前面的认证信息作为value存入redis中,同时为其设置一个过期时间,这个时间就是用户登录凭证的过期时间。之后再将前面的UUID作为payload,生成一个JWT字符串,然后使用jdk自带的keytool工具生成的证书文件对其进行签名,这里我使用的是nimbus-jose-jwt
,它很适合进行RSA的签名和验签操作。
登录成功后,会将这个JWT字符串返回给前端,前端之后每次请求都会携带着这个JWT字符串,后端对其进行验签,若验签通过,则解析JWT得到payload字段中的内容,也就是上面生成的UUID,在根据这个UUID去redis中查找用户信息,若能找到,则会进行一次Spring Security的验证操作,使其能够访问接口(后面还有授权操作);若查找失败,则说明用户凭证过期,需要重新登录。
由于项目时前后端分离,所以必然存在着跨域问题,目前主要的解决方案是:
@CrossOrigin
注解可是,在引入SpringSecurity后,上面两种方法都会失效,因为SpringSecurity拦截器的优先级更高,上面两种方式都会被拦截,解决办法是提高跨域过滤器的优先级,要敢于SpringSecrity拦截器的优先级。
但Spring Security有更加专业的跨域解决方法:
//跨域
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedMethod("*");
corsConfiguration.addAllowedOrigin("*");
corsConfiguration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfiguration);
return source;
}
然后将该过滤器注册到spring security的过滤器链中即可
由于后面还要使用RBAC0
模型,所以我一个设计了七张表,分别是:
具体SQL如下:
# 用户表
CREATE TABLE IF NOT EXISTS `user`
(
`id` INT(11) NOT NULL AUTO_INCREMENT COMMENT '用户id',
`username` VARCHAR(32) DEFAULT NULL COMMENT '用户名',
`password` VARCHAR(255) DEFAULT NULL COMMENT '加密后的密码',
`enabled` TINYINT(1) DEFAULT NULL COMMENT '账户是否可用',
`accountNonExpired` TINYINT(1) DEFAULT NULL COMMENT '账户是否没有过期',
`accountNonLocked` TINYINT(1) DEFAULT NULL COMMENT '账户是否没有锁定',
`credentialNonExpired` TINYINT(1) DEFAULT NULL COMMENT '凭证是否没有过期',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;
# 角色表
CREATE TABLE IF NOT EXISTS `role`
(
`id` INT(11) NOT NULL AUTO_INCREMENT COMMENT '角色id',
`name` VARCHAR(32) DEFAULT NULL COMMENT '角色英文名',
`nameZh` VARCHAR(32) DEFAULT NULL COMMENT '角色中文名',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;
# 权限表
CREATE TABLE IF NOT EXISTS `permission`
(
`id` INT(11) NOT NULL AUTO_INCREMENT COMMENT '权限id',
`name` VARCHAR(32) DEFAULT NULL COMMENT '权限英文名',
`nameZh` VARCHAR(32) DEFAULT NULL COMMENT '权限中文名',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;
# 资源表
CREATE TABLE IF NOT EXISTS `resources`
(
`id` INT(11) NOT NULL AUTO_INCREMENT COMMENT '权限id',
`name` VARCHAR(32) DEFAULT NULL COMMENT '权限中文名',
`url` varchar(32) DEFAULT NULL COMMENT '接口地址',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;
# 用户-角色关联表
CREATE TABLE IF NOT EXISTS `user_role`
(
`id` INT(11) NOT NULL AUTO_INCREMENT COMMENT '表id',
`user_id` INT(11) DEFAULT NULL COMMENT '用户id',
`role_id` INT(11) DEFAULT NULL COMMENT '角色id',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;
# 角色-权限关联表
CREATE TABLE IF NOT EXISTS `role_permission`
(
`id` INT(11) NOT NULL AUTO_INCREMENT COMMENT '表id',
`role_id` INT(11) DEFAULT NULL COMMENT '角色id',
`permission_id` INT(11) DEFAULT NULL COMMENT '权限id',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;
# 权限-资源关联表
CREATE TABLE IF NOT EXISTS `permission-resources`
(
`id` INT(11) NOT NULL AUTO_INCREMENT COMMENT '表id',
`permission_id` INT(11) DEFAULT NULL COMMENT '权限id',
`resources_id` INT(11) DEFAULT NULL COMMENT '资源id',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;
对应的实体类如下:
用户实体类
这里主要注意一下用户实体类中获取权限的方法,因为采用的是RBAC0模型,所以权限信息都与角色相关联,我们要先遍历用户具有的所有角色,然后将该角色具有的所有权限取出,存放到SimpleGrantedAuthority集合中,最后再将该集合添加到GrantedAuthority集合中,即可完成用户与权限的绑定
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements UserDetails {
private Integer id;
private String username;
private String password;
private boolean enabled;
private boolean accountNonExpired;
private boolean accountNonLocked;
private boolean credentialNonExpired;
private List<Role> roles; //用户所具有的角色
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
for (Role role : roles) {
List<SimpleGrantedAuthority> roleAuthorities = new ArrayList<>();
for (Permission permission : role.getPermissions()) {
//保存权限信息
roleAuthorities.add(new SimpleGrantedAuthority(permission.getName()));
}
authorities.addAll(roleAuthorities);
}
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return accountNonExpired;
}
@Override
public boolean isAccountNonLocked() {
return accountNonLocked;
}
@Override
public boolean isCredentialsNonExpired() {
return credentialNonExpired;
}
@Override
public boolean isEnabled() {
return enabled;
}
}
角色实体类
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Role implements Serializable {
private Integer id;
private String name; //角色名
private String nameZh; //角色中文名
private List<Permission> permissions; //角色所具有的权限
}
权限实体类
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Permission implements Serializable {
private Integer id;
private String name; //权限名
private String nameZh; //权限中文名
}
资源实体类
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Resources implements Serializable {
private Integer id;
private String name;//权限名
private String url; //接口地址
private List<Permission> permissions; //访问受保护对象所需要的权限
}
可能会有人对资源实体类存在疑问:为什么角色关联权限是在角色实体类中使用List
,而权限关联资源却是在资源实体类中使用List
,变成了“资源关联权限”。其实,这并不是任意而为的,在后面的授权操作中,我们需要为所有的受保护资源分别关联上所有能够访问它们的权限,所以,使用“资源关联权限”这种方式能够方便后面的操作,在给受保护资源添加访问权限时,我们只需要使用resources.getPermissions()
就能获取到该资源具备的所有权限了。
为了强化自己动手写SQL的能力,我选择了MyBatis作为ORM框架,但登录功能的sql还是比较简单的。
mapper接口
@Mapper
public interface UserMapper {
//注册,添加用户
Integer addUser(@Param("username") String username, @Param("password") String password);
//根据用户名查询是否有该用户
User queryUserByUsername(String username);
//给用户绑定角色
List getRolesByUserId(@Param("user_id") Integer user_id);
//获取资源
List getAllResources();
}
mapper.xml
DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.neutron.login_backend.mapper.UserMapper">
<insert id="addUser">
INSERT INTO security.user(username, password, enabled, accountNonExpired, accountNonLocked, credentialNonExpired) VALUES(#{username}, #{password}, 1, 1, 1, 1);
insert>
<select id="queryUserByUsername" resultType="com.neutron.login_backend.entity.User">
SELECT *
FROM security.user
WHERE username = #{username}
select>
<select id="getRolesByUserId" resultMap="RoleResultMap">
SELECT role.*, permission.id as pid, permission.name as pname, permission.nameZh as pnameZh
FROM security.role role
LEFT JOIN security.user_role ur
ON role.id = ur.role_id AND ur.user_id = #{user_id}
LEFT JOIN security.role_permission rp
ON role.id = rp.role_id
LEFT JOIN security.permission permission
ON permission.id = rp.permission_id;
select>
<resultMap id="RoleResultMap" type="com.neutron.login_backend.entity.Role">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="nameZh" column="nameZh"/>
<collection property="permissions" ofType="com.neutron.login_backend.entity.Permission">
<id property="id" column="pid"/>
<result property="name" column="pname"/>
<result property="nameZh" column="pnameZh"/>
collection>
resultMap>
<select id="getAllResources" resultMap="ResourcesResultMap">
SELECT resources.*, p.id as pid, p.name as pname, p.nameZh as pnameZh
FROM security.resources resources
LEFT JOIN security.`permission-resources` pr
ON resources.id = pr.resources_id
LEFT JOIN security.permission p
ON pr.permission_id = p.id
select>
<resultMap id="ResourcesResultMap" type="com.neutron.login_backend.entity.Resources">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="url" column="url"/>
<collection property="permissions" ofType="com.neutron.login_backend.entity.Permission">
<id property="id" column="pid"/>
<result property="name" column="pname"/>
<result property="nameZh" column="pnameZh"/>
collection>
resultMap>
mapper>
前端代码,加密
这里要导入jsencrypt包
function onClickSignUp(){
if(show.value === true){
//获取rsa公钥
let publicKey;
axios({
method: 'get',
url: 'http://localhost:8081/getPublicKey',
}).then(function (resp){
publicKey = resp.data.data
//rsa加密
let encrypt = new JSEncrypt();
encrypt.setPublicKey(publicKey);
let encodePassword = encrypt.encrypt(formLabelAlign.password);
axios({
method: 'post',
url: 'http://localhost:8081/login/signUp',
data: {
username: formLabelAlign.username,
password: encodePassword
}
}).then(function (resp){
console.log(resp.data.data)
})
})
show.value = false;
} else {
show.value = true;
}
}
后端代码,解密,提供加密公钥
/**
* 注册账号
* @param user 请求体(用户名,密码)
* @return 状态码
*/
@PostMapping("/signUp")
public Result<String> signUp(@RequestBody User user){
//rsa解密
String rawPassword = loginService.decodePassword(user.getPassword());
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
//bcrypt加密
String password = encoder.encode(rawPassword);
if(userMapper.addUser(user.getUsername(), password) > 0){
return Result.success("200");
}
return Result.error("400");
}
/**
* 获取公钥
* @return 公钥
*/
@GetMapping("/getPublicKey")
public Result<String> getPublicKey(){
RSAUtils rsaUtils = RSAUtils.getRsaUtils();
return Result.success(rsaUtils.getPublicKey());
}
登录部分的难点主要是在前后端分离的情况下,怎么让前端的每次请求都能被Spring Security认证,以及JWT的签名和验签操作,下面我会具体分析。
在密码加密方案这块我选择了很久,目前主流的加密方案有:MD5,BCrypt,RSA,SCrypt等。
MD5虽然是不可逆的加密方案,但现在它很容易被彩虹表破解。而数据库中存放的密码最好是不可逆的,即无法或很难被解密,所以像RSA这类可以通过密钥解密的算法也被我舍弃了。最终我使用了安全性较好的BCrypt算法,该算法虽然也存在被彩虹表解密的风险,但需要黑客付出极大的时间成本,从性价比来看,它是可以接受的。
在最新的SpringSecurity中,由于WebSecurityConfigurerAdapter
被废弃,所以我们需要在AuthenticationManager中添加自定义的密码加密方案PasswordEncoder
,示例如下:
@Bean
public PasswordEncoder passwordEncoder() {
//将SpringSecurity的加密方案改为BCrypt
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager() {
DaoAuthenticationProvider dao = new DaoAuthenticationProvider();
dao.setUserDetailsService(userService);
//将加密方案加入到AuthenticationManager中
dao.setPasswordEncoder(passwordEncoder());
return new ProviderManager(dao);
}
public interface UserService extends UserDetailsService {
@Override
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
首先创建一个UsernamePasswordAuthenticationToken
对象,并在参数中带上前端传过来的用户名和密码,之后将该对象交给AuthenticationManager
进行认证操作
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
Authentication authenticate = authenticationManager.authenticate(token);
if(Objects.isNull(authenticate)){
throw new RuntimeException("用户名或密码错误");
}
SpringSecurity的具体认证流程是:
AuthenticationProvider
的实现类是DaoAuthenticationProvider
DaoAuthenticationProvider
中的retrieveUser
方法,在该方法中,会执行我们前面继承UserDetailsService
接口时重写的loadUserByUsername
方法,去查找是否有该用户,如果没有,则抛出异常,否则就将用户信息返回DaoAuthenticationProvider
是继承自AbstractUserDetailsAuthenticationProvider
并且没有重写authenticate
方法,所以具体的认证逻辑位于AbstractUserDetailsAuthenticationProvider
中AbstractUserDetailsAuthenticationProvider
的retrieve
方法中,会先去用户缓存中查找用户对象,如果查不到,就根据用户名调用retrieveUser
方法,从数据库中加载用户,如果没有加载出用户,则会抛出异常preAuthenticationChecks.check(user)
方法检查用户状态,这个状态就是我们在User实体类中定义的accountNonExpired
这些additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication)
方法进行密码的校验,此处正好解答了为什么在调用loadUserByUsername时也会完成对密码的校验的疑惑。postAuthenticationChecks.check
方法检查密码是否过期createSuccessAuthentication
方法创建一个认证后的UsernamePasswordAuthenticationToken
对象,该对象中包含了认证主体、凭证以及角色信息。当前面的登录成功后,我们就要生成一个JWT字符串作为前端访问凭证。
首先,我们要获得一个jks证书文件,它可以帮我们管理公钥和私钥,这里使用的是jdk自带的keytool工具,在jdk的bin目录下面就能找到,可以使用如下命令生成证书:
keytool -genkey -alias jwt -keyalg RSA -keystore jwt.jks
有人可能会问,这里的RSA密钥对为什么不使用前面前端密码加密时用的密钥对。这里我主要是想将二者分开,且前端登录时用的密钥对是不固定的,每次登录时都会重新生成密钥对,如果在这里也使用动态的密钥对,可能会带来性能问题。
这里附上我的JWT生成、前面和验签方法:
@Service
public class JwtTokenServiceImpl implements JwtTokenService {
/**
* 获取密钥对
* @return RSAKey
*/
@Override
public RSAKey generateRsaKey() {
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "123456".toCharArray());
KeyPair keyPair = keyStoreKeyFactory.getKeyPair("jwt", "123456".toCharArray());
//RSA公钥
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
//RSA私钥
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
return new RSAKey.Builder(publicKey).privateKey(privateKey).build();
}
/**
* 生成JWT字符串
* @param payloadStr 作为payload的信息
* @param rsaKey 密钥对
* @return jwt字符串
*/
@Override
public String generateTokenByRSA(String payloadStr, RSAKey rsaKey) throws JOSEException {
//JWS头
JWSHeader jwsHeader = new JWSHeader.Builder(JWSAlgorithm.RS256)
.type(JOSEObjectType.JWT)
.build();
//荷载
Payload payload = new Payload(payloadStr);
//签名
JWSObject jwsObject = new JWSObject(jwsHeader, payload);
//生成签名器
RSASSASigner rsassaSigner = new RSASSASigner(rsaKey);
jwsObject.sign(rsassaSigner);
return jwsObject.serialize();
}
/**
* 验签
* @param token jwt字符串
* @param rsaKey rsaKey
* @return 荷载信息
* @throws ParseException
* @throws JOSEException
*/
@Override
public String verifyToken(String token, RSAKey rsaKey) throws ParseException, JOSEException {
//由jwt字符串生成jwsObject对象
JWSObject jwsObject = JWSObject.parse(token);
RSAKey publicKey = rsaKey.toPublicJWK();
RSASSAVerifier verifier = new RSASSAVerifier(publicKey);
if(!jwsObject.verify(verifier)){
return null; //验证失败则返回空
}
return jwsObject.getPayload().toString();
}
}
然后,我们获得UUID
和认证之后的用户信息,并将其存入Redis
中,同时设置过期时间:
@PostMapping
public Result<String> login(@RequestBody User user) throws JOSEException {
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
Authentication authenticate = authenticationManager.authenticate(token);
if(Objects.isNull(authenticate)){
throw new RuntimeException("用户名或密码错误");
}
//登录成功,返回JWT字符串
RSAKey rsaKey = jwtTokenService.generateRsaKey();
String key = UUID.randomUUID().toString();
//将用户信息存入redis中
User userInfo = (User) authenticate.getPrincipal();
redisTemplate.opsForValue().set(key, userInfo, 3, TimeUnit.HOURS);
return Result.success(jwtTokenService.generateTokenByRSA(key, rsaKey));
}
之后前端获取到jwt,为了方便管理,我将JWT存放在了vuex的state中:
function onClick(){
axios({
method: 'post',
url: "http://localhost:8081/login",
data: {
...formLabelAlign
},
headers: {
'Access-Control-Allow-Origin': '*',
}
}).then(function (resp){
console.log(resp.data)
if(resp.data !== null){
store.commit('setAuthorization', resp.data.data) //vuex状态管理
router.push('/index')
}
})
}
vuex:
import {createStore} from "vuex";
const store = createStore({
state: {
"Authorization": ''
},
mutations: {
setAuthorization(state, newVal){
state.Authorization = newVal
}
}
})
export default store
前端获取到JWT之后需要在所有请求的请求头中都携带上Authorization
字段,这里我使用了Axios
的拦截器:
import axios from "axios";
import store from "../store";
axios.interceptors.request.use(config => {
config.headers['Authorization'] = store.state.Authorization
return config
})
这样,所有的前端工作就已经完成了,接下来需要在后端定义一个过滤器,让除了登录接口的所有接口都需要被验证JWT合法性,具体流程是:过滤器解析JWT、取出payload字段(前面存的UUID)、从redis中取出用户认证信息、将该认证信息作为参数创建一个UsernamePasswordAuthenticationToken
对象、在SecurityContextHolder
中添加该对象的认证、认证成功、过滤器放行、后端接口正常访问
过滤器代码
@Component
public class LoginFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenService jwtTokenService;
@Resource
private RedisTemplate<String, User> redisTemplate;
@Autowired
private CustomSecurityMetadataSource customSecurityMetadataSource;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String authorization = request.getHeader("Authorization");
if(request.getRequestURI().equals("/login") || request.getRequestURI().equals("/getPublicKey")){
filterChain.doFilter(request, response);
} else if(authorization == null){
throw new RuntimeException("用户未登录");
} else{
RSAKey rsaKey = jwtTokenService.generateRsaKey();
//验签失败返回false
//成功返回true
String payload = "";
try {
payload = jwtTokenService.verifyToken(authorization, rsaKey);
} catch (ParseException | JOSEException e) {
e.printStackTrace();
}
if(payload.equals("")){
throw new RuntimeException("用户未登录");
} else {
//已登录,获取用户信息,进行授权
User userInfo = redisTemplate.opsForValue().get(payload); //取缓存
if(userInfo == null){
//用户凭证过期
redisTemplate.delete(payload);//删除用户登录信息
SecurityContextHolder.clearContext(); //将用户认证信息删除
} else {
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(userInfo, null, userInfo.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(token);
System.out.println("SecurityContextHolder信息:" + SecurityContextHolder.getContext());
}
System.out.println("attributes: "+customSecurityMetadataSource.getAllConfigAttributes());
filterChain.doFilter(request, response);
}
}
}
}
至此,前端已经能通过一个JWT字符串访问后端接口了
接下来就是对接口访问的授权操作了,这里我使用的是RBAC0
权限模型,即用户关联角色,角色关联权限,权限关联资源,所有的用户、角色、权限和资源都存放在数据库中。在这里我想说的是,有很多人都使用了基于方法的权限管理,即在方法上通过注解的方式增加权限,我认为这种方式并不灵活,如果想要修改访问某个受保护资源所需要的权限时,就必须要去修改源代码,而使用基于Url的权限管理后,我们能通过直接修改数据库完成权限的修改。
用户的授权都已经在前面的User实体类中给出,下面主要看看怎么对受保护资源添加访问权限,这里我们主要是做两件事:
自定义权限元数据 继承FilterInvocationSecurityMetadataSource接口
权限元数据中存放的就是受保护资源所需要的权限,这里我们先获得当前请求的uri地址,然后与数据库中的受保护资源的地址进行比较,若相同,则将数据库中与之对应的权限信息添加进去
@Component
public class CustomSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
@Autowired
private UserMapper userMapper;
AntPathMatcher antPathMatcher = new AntPathMatcher();
@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
//获取URI
String requestURI = ((FilterInvocation) object).getRequest().getRequestURI();
List<Resources> allResources = userMapper.getAllResources();
for (Resources resource : allResources) {
if(antPathMatcher.match(resource.getUrl(), requestURI)){
String[] permissions = resource.getPermissions().stream()
.map(Permission::getName).toArray(String[]::new);
//存入资源所需要的权限
return SecurityConfig.createList(permissions);
}
}
return null;
}
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
return null;
}
@Override
public boolean supports(Class<?> clazz) {
return FilterInvocation.class.isAssignableFrom(clazz);
}
}
决策器,在前后端分离时,这是必须要添加的 继承AccessDecisionManager接口
在自定义权限元数据时,我们已经将访问受保护资源所需要的权限添加到ConfigAttribute中去了,所以在验证权限时,我们只需要将ConfigAttribute中的权限与Authentication中保存的用户权限进行比较即可
@Component
public class CustomAccessDecisionManager implements AccessDecisionManager {
@Override
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
//如果权限元数据为空,则直接放行,即不需要权限就能访问
if(CollUtil.isEmpty(configAttributes)){
return;
}
for (ConfigAttribute configAttribute : configAttributes) {
String attribute = configAttribute.getAttribute();
//取出用户具有的权限
for (GrantedAuthority authority : authentication.getAuthorities()) {
if (attribute.trim().equals(authority.getAuthority())){
return;
}
}
throw new AccessDeniedException("对不起,你没有权限");
}
}
@Override
public boolean supports(ConfigAttribute attribute) {
return true;
}
@Override
public boolean supports(Class<?> clazz) {
return true;
}
}
以上就是这个项目的大致思路了,详细代码可以参考我的github仓库
项目中可能存在一些槽点,欢迎指正。