技术栈
SpringBoot SpringSecurity
数据库表
创建一个简单的RBAC模型,通过用户角色来控制权限
用户表
id | username | password | role_id |
1 | admin | {noop}admin | 1 |
角色表
id | role |
1 | ROLE_ADMIN |
主要代码
pom.xml文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>security-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<parent>
<artifactId>spring-boot-parent</artifactId>
<groupId>org.springframework.boot</groupId>
<version>2.4.5</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.12</version>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>net.sf.ehcache</groupId>
<artifactId>ehcache</artifactId>
<version>2.10.6</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
</dependency>
</dependencies>
</project>
配置文件
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/security?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&allowMultiQueries=true
username: root
password: 123456
mybatis:
mapper-locations: classpath:mapper/*.xml # mybatis映射文件路径
type-aliases-package: com.audaque.security.entity # mybatis实体类别名
编写mapper文件
一个用户表,通过用户名查询,一个角色表,通过id查询
修改SpringSecurity认证逻辑
修改的方法有很多,这里选择重写Username Password AuthenticationFilter中的attemptAuthentication方法并创建UserDetailsService的实现类。登陆成功后这里求简选择直接将token存入session,可以将对应逻辑改为存入redis或数据库(登陆成功处理逻辑在源码)。
Username Password AuthenticationFilter子类
package com.audaque.security.filter;
import com.alibaba.fastjson2.JSON;
import com.audaque.security.entity.Constant;
import com.audaque.security.entity.DataResponse;
import com.audaque.security.util.ResponseUtil;
import lombok.SneakyThrows;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.Map;
public class LoginFilter extends UsernamePasswordAuthenticationFilter implements Constant {
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
if (request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE)) { // 前后端分离,json格式传送数据
BufferedReader br = new BufferedReader(new InputStreamReader(request.getInputStream()));
StringBuilder sb = new StringBuilder();
while (br.ready()) {
sb.append(br.readLine());
}
// 解析Json
Map<String,String> userMap = (Map<String, String>) JSON.parse(sb.toString());
// getUsernameParameter() 默认调用父类的,默认值为"username"
String username = userMap.get(getUsernameParameter());
username = username != null ? username : "";
username = username.trim();
// getPasswordParameter() 默认调用父类的,默认值为"password"
String password = userMap.get(getPasswordParameter());
password = password != null ? password : "";
// 照抄源码,只修改获取值得部分
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
this.setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}else {
ResponseUtil.writeJson(response,new DataResponse<>(FAILURE,null));
return null;
}
}
}
如果有需求添加验证码在此方法对request参数进行操作也可以处理
UserDetailsService实现类
package com.audaque.security.service;
import com.audaque.security.entity.LoginUser;
import com.audaque.security.entity.User;
import net.sf.ehcache.CacheManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.core.userdetails.cache.EhCacheBasedUserCache;
import org.springframework.stereotype.Service;
@Service
public class UserDerailsServiceImpl implements UserDetailsService {
// 添加ehcache缓存
private EhCacheBasedUserCache userCache = new EhCacheBasedUserCache();
private final UserService userService;
private final CacheManager cacheManager;
private final RoleService roleService;
@Autowired
public UserDerailsServiceImpl(UserService userService, CacheManager cacheManager, RoleService roleService) {
this.cacheManager = cacheManager;
this.roleService = roleService;
this.userCache.setCache(this.cacheManager.getCache("userCache"));
this.userService = userService;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserDetails userFromCache = userCache.getUserFromCache(username);
// 先从缓存中查找
if (userFromCache != null) {
return userFromCache;
}else {
User user = userService.selectUserByUsername(username);
if (user == null) {
throw new RuntimeException("用户名或密码错误");
} else {
LoginUser loginUser = new LoginUser(user, roleService.selectRoleById(user.getRoleId()));
userCache.putUserInCache(loginUser);
return loginUser;
}
}
}
}
为了提高登录效率。这里使用ehcache缓存已经登陆过的用户的信息,默认key为当前登录的用户名,可以自行查看源码中的putUserInCache()方法。
ehcache缓存配置文件,放到resources目录下即可
<?xml version="1.0" encoding="UTF-8"?>
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://ehcache.org/ehcache.xsd">
<!-- 磁盘缓存位置 -->
<diskStore path="c:/data/ehcache"/>
<!-- 默认缓存 -->
<!--
name:缓存名称。
maxElementsInMemory:缓存最大个数。
eternal:对象是否永久有效,一但设置了,timeout将不起作用。
timeToIdleSeconds:设置对象在失效前的允许闲置时间(单位:秒)。
仅当eternal=false对象不是永久有效时使用,可选属性,默认值是0,也就是可闲置时间无穷大。
timeToLiveSeconds:设置对象在失效前允许存活时间(单位:秒)。最大时间介于创建时间和失效时间之间。
仅当eternal=false对象不是永久有效时使用,默认是0.,也就是对象存活时间无穷大。
overflowToDisk:当内存中对象数量达到maxElementsInMemory时,Ehcache将会对象写到磁盘中。
diskSpoolBufferSizeMB:这个参数设置DiskStore(磁盘缓存)的缓存区大小。默认是30MB。每个Cache都应该有自己的一个缓冲区。
maxElementsOnDisk:硬盘最大缓存个数。
diskPersistent:是否缓存虚拟机重启期数据 Whether the disk store persists between restarts of the Virtual Machine. The default value is false.
diskExpiryThreadIntervalSeconds:磁盘失效线程运行时间间隔,默认是120秒。
memoryStoreEvictionPolicy:当达到maxElementsInMemory限制时,Ehcache将会根据指定的策略去清理内存。
默认策略是LRU(最近最少使用)。
你可以设置为FIFO(先进先出)
或是LFU(较少使用)。
clearOnFlush:内存数量最大时是否清除。
-->
<defaultCache
maxElementsInMemory="10"
eternal="false"
timeToIdleSeconds="3600"
timeToLiveSeconds="3600"
overflowToDisk="true"
memoryStoreEvictionPolicy="LRU"/>
<cache name="userCache"
maxElementsInMemory="100"
eternal="false"
timeToIdleSeconds="86400"
overflowToDisk="true"
memoryStoreEvictionPolicy="LRU"/>
</ehcache>
UserDetailServiceImpl返回值
package com.audaque.security.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.ArrayList;
import java.util.Collection;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginUser implements UserDetails {
private User user;
private Role role;
@Override
@JsonIgnore
public Collection<? extends GrantedAuthority> getAuthorities() {
ArrayList<GrantedAuthority> list = new ArrayList<>();
list.add(new SimpleGrantedAuthority(role.getRole()));
return list;
}
@Override
@JsonIgnore // 如果登陆成功想要将当前用户写入redis进行此操作忽略该字段的序列化
public String getPassword() {
return user.getPassword();
}
@Override
@JsonIgnore
public String getUsername() {
return user.getUsername();
}
@Override
@JsonIgnore
public boolean isAccountNonExpired() {
return true;
}
@Override
@JsonIgnore
public boolean isAccountNonLocked() {
return true;
}
@Override
@JsonIgnore
public boolean isCredentialsNonExpired() {
return true;
}
@Override
@JsonIgnore
public boolean isEnabled() {
return true;
}
}
Security配置文件
package com.audaque.security.config;
import com.audaque.security.entity.Constant;
import com.audaque.security.entity.DataResponse;
import com.audaque.security.filter.LoginFilter;
import com.audaque.security.filter.TokenFilter;
import com.audaque.security.service.UserDerailsServiceImpl;
import com.audaque.security.util.ResponseUtil;
import lombok.extern.slf4j.Slf4j;
import net.sf.ehcache.CacheManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Slf4j
@EnableGlobalMethodSecurity(prePostEnabled = true) // 开启权限控制 和@PreAuthorize,@PostAuthorize
public class SecurityConfig extends WebSecurityConfigurerAdapter implements Constant {
@Autowired
private UserDetailsService userDetailsService;
@Bean
public LoginFilter loginFilter() throws Exception {
LoginFilter loginFilter = new LoginFilter();
loginFilter.setUsernameParameter("username"); // 表单用户名input框name属性
loginFilter.setPasswordParameter("password"); // 表单密码input框name属性
loginFilter.setFilterProcessesUrl("/login");
loginFilter.setAuthenticationSuccessHandler(new LoginSuccessHandler());
loginFilter.setAuthenticationFailureHandler((req,resp,e)->{ // 登陆失败处理,实现实现AuthenticateFailureHandler接口
log.info("认证失败");
// 由于该接口只有一个函数式抽象方法,可以写为lambda表达式
ResponseUtil.writeJson(resp,new DataResponse<>(FAILURE,null));
});
loginFilter.setAuthenticationManager(authenticationManagerBean());
return loginFilter;
}
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService);
}
// @Bean
// public UserDetailsService userDetailsService() {
// InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
// inMemoryUserDetailsManager.createUser(User.withUsername("root").password("{noop}123").roles("admin").build());
// return inMemoryUserDetailsManager;
// }
// 说明,登录=认证
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.mvcMatchers("/logout","/login","/")
.permitAll()// 代表可以不认证即可访问"/logout","/login"
.anyRequest().authenticated() // 剩下所有都需要认证才能访问
.and()
.formLogin()// 表示表单登录
// .loginProcessingUrl("/login")// 登录提交的路径(security处理登录提交的路径/login,类似于@PostMapping("/login"))
// .successHandler(new LoginSuccessHandler())// 登录成功处理 实现AuthenticateSuccessHandler
// 也可以写为lambda表达式 如下面登陆失败逻辑
// .failureHandler((req,resp,e)->{ // 登陆失败处理,实现实现AuthenticateFailureHandler接口
// 由于该接口只有一个函数式抽象方法,可以写为lambda表达式
// ResponseUtil.writeJson(resp,new DataResponse<>(FAILURE,null));
// })
.and()
.logout()
.logoutUrl("/logout") // 指定登出路径
.logoutSuccessHandler((req,resp,auth)->{ // 退出登录成功逻辑
log.info("退出登录成功");
SecurityContextHolder.clearContext(); // 清空SecurityContext
ResponseUtil.writeJson(resp,new DataResponse<>(SUCCESS,null));
})
.and()
.exceptionHandling()
.accessDeniedHandler((req,resp,auth)->{ // 权限异常处理
log.info("权限不足");
ResponseUtil.writeJson(resp,new DataResponse<>(FAILURE,null));
})
.authenticationEntryPoint((req,resp,auth)->{ // 认证异常处理
log.info("认证异常");
ResponseUtil.writeJson(resp,new DataResponse<>(FAILURE,null));
})
.and()
.addFilterBefore(new TokenFilter(),UsernamePasswordAuthenticationFilter.class)//验证token过滤器
.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class) // 替换security的UsernamePasswordAuthenticationFilter
.csrf().disable();
}
}