一般来说中大型的项目都是使用SpringSecurity 来做安全框架。小项目有Shiro的比较多,因为相比与SpringSecurity,Shiro的上手更加的简单。
一般Web应用的需要进行认证和授权。
认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户
授权:经过认证后判断当前用户是否有权限进行某个操作
而认证和授权也是SpringSecurity作为安全框架的核心功能。
我所学习的正是,认证和授权和流程。
另外默认SpringBoot、Redis会调用。
1.简单入门Demo
新建一个SpringBoot工程。这是使用的依赖。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--其他必须依赖,下面需要的-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.58</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jdk8</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!-- 导入4个jar包,解决jdk9缺失jar包引起的报错-->
<!-- java.lang.NoClassDefFoundError: javax/xml/bind/DatatypeConverter-->
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-impl</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-core</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
启动之后,访问localhost:8888, 出现一下页面代表SpringSecurity生效了。
此时我们发现控制台生成:
我们向登录表单输入,这段密码和用户名user,即可登录通过。
2.认证
2.1 登录校验流程
当然,我们实际情况,可能会加一个redis做缓存,登录之后,用用户id -> 用户信息,存储到redis中。
这样我们登录之后,解析token,就是从redis查询。
2.2 SpringSecurity验证流程
图中只展示了核心过滤器,其它的非核心过滤器并没有在图中展示。
UsernamePasswordAuthenticationFilter:负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要有它负责。
ExceptionTranslationFilter:处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException 。
FilterSecurityInterceptor:负责权限校验的过滤器。
2.3 认证流程详解
Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息。
AuthenticationManager接口:定义了认证Authentication的方法
UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。
UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。
2.4 思路分析
1. ...,在现在前后端分离项目,我们肯定是要自定义登录接口,不能用SpringSecurity的登录页面;
2. UserDetailService 是在内存中查找比较用户输入的用户名和密码,那我们需要是需要查询数据库比对的。
2.5 问题解决
2.5.0 前提
user.sql
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` bigint NOT NULL COMMENT '主键',
`user_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',
`nick_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '昵称',
`password` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '密码',
`status` char(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
`email` varchar(64) DEFAULT NULL COMMENT '邮箱',
`phonenumber` varchar(32) DEFAULT NULL COMMENT '手机号',
`sex` char(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
`avatar` varchar(128) DEFAULT NULL COMMENT '头像',
`user_type` char(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)',
`create_by` bigint DEFAULT NULL COMMENT '创建人的用户id',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` bigint DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`del_flag` int DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户表';
application.yaml 中数据库配置
spring:
datasource:
url: jdbc:mysql://localhost:3306/test?characterEncoding=utf-8&serverTimezone=UTC
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
UserMapper
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
User
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "user")
public class User {
/**
* 主键
*/
@TableId
private Long id;
/**
* 用户名
*/
private String userName;
/**
* 昵称
*/
private String nickName;
/**
* 密码
*/
private String password;
/**
* 账号状态(0正常 1停用)
*/
private String status;
/**
* 邮箱
*/
private String email;
/**
* 手机号
*/
private String phonenumber;
/**
* 用户性别(0男,1女,2未知)
*/
private String sex;
/**
* 头像
*/
private String avatar;
/**
* 用户类型(0管理员,1普通用户)
*/
private String userType;
/**
* 创建人的用户id
*/
private Long createBy;
/**
* 创建时间
*/
private Date createTime;
/**
* 更新人
*/
private Long updateBy;
/**
* 更新时间
*/
private Date updateTime;
/**
* 删除标志(0代表未删除,1代表已删除)
*/
private Integer delFlag;
}
UserDetailServiceImpl
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserDetailsImpl implements UserDetails {
private User user;
//private List<String> authList;
//
//@JSONField(serialize = false)
//private List<SimpleGrantedAuthority> authorities; // SimpleGrantedAuthority对象不支持序列化,无法存入redis
//
//
//public UserDetailsImpl(User user, List<String> authList) { // 将对应的权限字符串列表传入
// this.user = user;
// this.authList = authList;
//}
//
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
初始化之后,我们后续其他拦截器,也会获取; 没必要多次初始化;
//if(authorities != null){
// return authorities;
//}else{
// authorities = new ArrayList<>();
//}
//
第一次登录,封装UserDetails对象,初始化权限列表
//for (String auth : authList) {
// SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(auth);
// authorities.add(simpleGrantedAuthority); // 对,默认是个空的
//}
//return authorities;
return null;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUserName();
}
@Override
public boolean isAccountNonExpired() { // 下面bool值,全部响应为true,UserDetail对象返回校验过程中,会因为没权限报错
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
RedisConfig : redis配置类
@Configuration
public class RedisConfig {
@Bean
@SuppressWarnings(value = { "unchecked", "rawtypes" })
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory)
{
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
FastJsonRedisSerializer serializer = new FastJsonRedisSerializer(Object.class);
// 使用StringRedisSerializer来序列化和反序列化redis的key值
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
// Hash的key也采用StringRedisSerializer的序列化方式
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
}
Result : 后端统一封装响应
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Result<T> {
private T data;
private String mes;
private Integer code;
public Result(String mes, Integer _code) {
}
public static <T> Result success(T data){
return new Result(data,"操作成功",200);
}
public static <T> Result success(String _mes){
return new Result(_mes,200);
}
public static <T> Result error(String _mes,Integer _code){
return new Result(_mes,_code);
}
}
JwtUtil 工具类
/**
* JWT工具类
*/
public class JwtUtil {
//有效期为
public static final Long JWT_TTL = 60 * 60 *1000L;// 60 * 60 *1000 一个小时
//设置秘钥明文
public static final String JWT_KEY = "sangeng";
public static String getUUID(){
String token = UUID.randomUUID().toString().replaceAll("-", "");
return token;
}
/**
* 生成jtw
* @param subject token中要存放的数据(json格式)
* @return
*/
public static String createJWT(String subject) {
JwtBuilder builder = getJwtBuilder(subject, null, getUUID());// 设置过期时间
return builder.compact();
}
/**
* 生成jtw
* @param subject token中要存放的数据(json格式)
* @param ttlMillis token超时时间
* @return
*/
public static String createJWT(String subject, Long ttlMillis) {
JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID());// 设置过期时间
return builder.compact();
}
private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
SecretKey secretKey = generalKey();
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
if(ttlMillis==null){
ttlMillis=JwtUtil.JWT_TTL;
}
long expMillis = nowMillis + ttlMillis;
Date expDate = new Date(expMillis);
return Jwts.builder()
.setId(uuid) //唯一的ID
.setSubject(subject) // 主题 可以是JSON数据
.setIssuer("sg") // 签发者
.setIssuedAt(now) // 签发时间
.signWith(signatureAlgorithm, secretKey) //使用HS256对称加密算法签名, 第二个参数为秘钥
.setExpiration(expDate);
}
/**
* 创建token
* @param id
* @param subject
* @param ttlMillis
* @return
*/
public static String createJWT(String id, String subject, Long ttlMillis) {
JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id);// 设置过期时间
return builder.compact();
}
public static void main(String[] args) throws Exception {
// String jwt = createJWT("2123");
Claims claims = parseJWT("eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIyOTY2ZGE3NGYyZGM0ZDAxOGU1OWYwNjBkYmZkMjZhMSIsInN1YiI6IjIiLCJpc3MiOiJzZyIsImlhdCI6MTYzOTk2MjU1MCwiZXhwIjoxNjM5OTY2MTUwfQ.NluqZnyJ0gHz-2wBIari2r3XpPp06UMn4JS2sWHILs0");
String subject = claims.getSubject();
System.out.println(subject);
// System.out.println(claims);
}
/**
* 生成加密后的秘钥 secretKey
* @return
*/
public static SecretKey generalKey() {
byte[] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY);
SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
return key;
}
/**
* 解析
*
* @param jwt
* @return
* @throws Exception
*/
public static Claims parseJWT(String jwt) throws Exception {
SecretKey secretKey = generalKey();
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
}
}
FastJsonRedisSerializer
public class FastJsonRedisSerializer<T> implements RedisSerializer<T>
{
public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
private Class<T> clazz;
static
{
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
}
public FastJsonRedisSerializer(Class<T> clazz)
{
super();
this.clazz = clazz;
}
@Override
public byte[] serialize(T t) throws SerializationException
{
if (t == null)
{
return new byte[0];
}
return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
}
@Override
public T deserialize(byte[] bytes) throws SerializationException
{
if (bytes == null || bytes.length <= 0)
{
return null;
}
String str = new String(bytes, DEFAULT_CHARSET);
return JSON.parseObject(str, clazz);
}
protected JavaType getJavaType(Class<?> clazz)
{
return TypeFactory.defaultInstance().constructType(clazz);
}
}
2.5.1 自定义UserDetailService
我们自定义UserDatailService 实现UserDatailService接口,注入到spring容器中,这样就会在SpringSecurity的认证流程中调用我们自定义的实现类。
@Service
public class UserDetailServiceImpl extends ServiceImpl< UserMapper, User> implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 用用户名查询对应User
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getUserName,username);
// 判断是否空
User user = this.getOne(queryWrapper);
if(user == null){
throw new RuntimeException("用户名或密码错误");
}
// 将查询到user封装到自定义UserDeatail中
return new UserDetailsImpl(user);
}
}
我们看下图,我们输入的用户名和密码,最终会传入UserDetailService 对象,加载loadUserByUsername 方法,返回UserDtail对象。返回过程中,会与UserDetail中的password和username进行比对,不同则报错。
注意:如果要测试,需要往用户表中写入用户数据,并且如果你想让用户的密码是明文存储,需要在密码前加{noop}。例如 : 数据库中,username:sg, password: {noop}1234
这样登陆的时候就可以用sg作为用户名,1234作为密码来登陆了。
2.5.2 密码加密存储
在实际中项目中,我一般不会明文存储,而是采用PasswordEncoder加密的方式;
我们一般使用SpringSecurity为我们提供的BCryptPasswordEncoder。
我们只需要使用把BCryptPasswordEncoder对象注入Spring容器中,SpringSecurity就会使用该PasswordEncoder来进行密码校验
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
2.5.3 自定义登录接口
- 接下我们需要自定义登陆接口,然后让SpringSecurity对这个接口放行,让用户访问这个接口的时候不用登录也能访问。
- 在接口中我们通过AuthenticationManager的authenticate方法来进行用户认证,所以需要在SecurityConfig中配置把AuthenticationManager注入容器。
- 认证成功的话要生成一个jwt,放入响应中返回。并且为了让用户下回请求时能通过jwt识别出具体的是哪个用户,我们需要把用户信息存入redis,可以把用户id作为key。
UserController
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;
@PostMapping("/login")
Result login(@RequestBody User user){
return userService.login(user);
}
}
public interface UserService extends IService<User> {
Result login(User user);
}
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisTemplate redisTemplate;
@Override
public Result login(User user) {
// AuthenticationManager authenticationManager 进行认证
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
// 如果认证通过、给出对应提示
if(Objects.isNull(authenticate)){
throw new RuntimeException("登录失败");
}
// 认证通过使用userid生成jwt
UserDetailsImpl udi = (UserDetailsImpl) authenticate.getPrincipal();
String userId = udi.getUser().getId().toString();
String token = JwtUtil.createJWT(userId);
HashMap<String, String> map = new HashMap<>();
map.put("token",token);
// 把完整用户信息存入redis
redisTemplate.opsForValue().set("login:" +userId,udi);
return Result.success(map,"登录成功");
}
}
SpringSecurityConfig
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
spring:
# mysql配置
datasource:
url: jdbc:mysql://localhost:3306/test?characterEncoding=utf-8&serverTimezone=UTC
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
# redis配置
redis:
# 默认0库
database: 0
#连接超时时间
timeout: 10000ms
port: 6379
host: 192.168.213.136
lettuce:
pool:
# 设置最大连接数
max-active: 1024
# 最大阻塞时间
max-wait: 10000ms
# 最大空间连接,默认8
max-idle: 200
# 最小空间连接,默认5
min-idle: 5
server:
port: 8888
2.5.4 自定义jwt过滤器
以下跨域设置,postman测试是完全没有问题的。但是,我们是为了前后端分离,会有问题。
当登录之后,携带token的请求头,被jwt过滤器捕获解析之后,获得userId,用userId将从redis拿出UserDetailImpl封装到 SecurityContextHolder.getContext()中,被SpringSecurity过滤链捕获到,证明没有问题,因此放行访问资源。
/**
* jwt过滤器
*
* @author: qhx20040819
* @date: 2023-09-09 21:27
**/
@Component
public class JwtFilter extends OncePerRequestFilter {
@Autowired
private RedisTemplate redisTemplate;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 设置跨域
response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin")); // 修改携带cookie,PS
response.setHeader("Access-Control-Allow-Methods", "GET,POST,DELETE,PUT");
response.setHeader("Access-Control-Allow-Headers", "Authorization,content-type"); // PS
// 预检请求缓存时间(秒),即在这个时间内相同的预检请求不再发送,直接使用缓存结果。
response.setHeader("Access-Control-Max-Age", "3600");
//获取token
String authorization = request.getHeader("Authorization");
if(StringUtils.isEmpty(authorization)){
filterChain.doFilter(request,response);
return;
}
String token = authorization.substring(6);
//解析token
String userid;
try {
Claims claims = JwtUtil.parseJWT(token);
userid = claims.getSubject();
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("token非法");
}
//从redis中获取用户信息
String redisKey = "login:" + userid;
UserDetailsImpl udi = (UserDetailsImpl) redisTemplate.opsForValue().get(redisKey);
if(Objects.isNull(udi)){
throw new RuntimeException("用户未登录");
}
//存入SecurityContextHolder
//获取权限信息封装到Authentication中
UsernamePasswordAuthenticationToken authenticationToken = // 现在权限字段是null
new UsernamePasswordAuthenticationToken(udi,null,udi.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//放行
filterChain.doFilter(request, response);
}
}
3.授权
3.1 授权流程
在SpringSecurity中,会使用默认FiterSecuritInterceptor来进行权限校验,而它又是从SpringSecurityContex中Authentication,来获取其中的权限信息。判断当前用户是否拥有访问当前资源权限。
因此,我们只需要将权限信息存储到Authentication中即可,设置资源的访问权限。
3.2 问题解决
3.2.1 相关配置
用注解开启相关配置。
@EnableGlobalMethodSecurity(prePostEnabled = true)
在相应的资源上开启访问权限管控。
@RequestMapping("/hello")
@PreAuthorize("hasAnyAuthority('test')") // 限制访问权限字段
public String hello(){
return "hello";
}
3.2.2 封装权限字段
我们之前UserDetailServiceImpl中loadUserByUsername()中,返回的UserDetailImpl对象,我们之前创建时,并没传入权限字段,我们先自定义权限字段模拟用户权限列表。
@Service
public class UserDetailServiceImpl extends ServiceImpl< UserMapper, User> implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 用用户名查询对应User
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getUserName,username);
// 判断是否空
User user = this.getOne(queryWrapper);
if(user == null){
throw new RuntimeException("用户名或密码错误");
}
List<String> authlist = new ArrayList<>(); // 暂且封装这样权限字段
authlist.add("test");
authlist.add("test0");
return new UserDetailsImpl(user,authlist);
}
}
相应的UserDetailImpl中也需要修改,seucrity过滤器链是从getAuthorities()方法中来获取用户权限字段的。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserDetailsImpl implements UserDetails {
private User user;
private List<String> authList;
@JSONField(serialize = false)
private List<SimpleGrantedAuthority> authorities; // SimpleGrantedAuthority对象不支持序列化,无法存入redis
public UserDetailsImpl(User user, List<String> authList) { // 将对应的权限字符串列表传入
this.user = user;
this.authList = authList;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 初始化之后,我们后续其他拦截器,也会获取; 没必要多次初始化;
if(authorities != null){
return authorities;
}else{
authorities = new ArrayList<>();
}
// 第一次登录,封装UserDetails对象,初始化权限列表
for (String auth : authList) {
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(auth);
authorities.add(simpleGrantedAuthority); // 对,默认是个空的
}
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUserName();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
这是拿着携带token去请求,能访问成功。
3.2.3 数据库查询权限字段
就是我们真实的权限应该封装在数据库中。
3.2.3.1 RBAC权限模型
一个用户,可以对应多个角色;一个角色,可以对应多个用户;
一个角色,可以拥有多个权限字段; 一个权限字段,可以被多个角色所拥有;
3.2.3.2 准备工作
USE `test`;
/*Table structure for table `menu` */
DROP TABLE IF EXISTS `menu`;
CREATE TABLE `menu` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`menu_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '菜单名',
`path` varchar(200) DEFAULT NULL COMMENT '路由地址',
`component` varchar(255) DEFAULT NULL COMMENT '组件路径',
`visible` char(1) DEFAULT '0' COMMENT '菜单状态(0显示 1隐藏)',
`status` char(1) DEFAULT '0' COMMENT '菜单状态(0正常 1停用)',
`perms` varchar(100) DEFAULT NULL COMMENT '权限标识',
`icon` varchar(100) DEFAULT '#' COMMENT '菜单图标',
`create_by` bigint(20) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` bigint(20) DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`del_flag` int(11) DEFAULT '0' COMMENT '是否删除(0未删除 1已删除)',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='菜单表';
/*Table structure for table `role` */
DROP TABLE IF EXISTS `role`;
CREATE TABLE `role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(128) DEFAULT NULL,
`role_key` varchar(100) DEFAULT NULL COMMENT '角色权限字符串',
`status` char(1) DEFAULT '0' COMMENT '角色状态(0正常 1停用)',
`del_flag` int(1) DEFAULT '0' COMMENT 'del_flag',
`create_by` bigint(200) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` bigint(200) DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='角色表';
/*Table structure for table `role_menu` */
DROP TABLE IF EXISTS `role_menu`;
CREATE TABLE `role_menu` (
`role_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`menu_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '菜单id',
PRIMARY KEY (`role_id`,`menu_id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;
/*Table structure for table `user` */
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',
`nick_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '昵称',
`password` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '密码',
`status` char(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
`email` varchar(64) DEFAULT NULL COMMENT '邮箱',
`phonenumber` varchar(32) DEFAULT NULL COMMENT '手机号',
`sex` char(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
`avatar` varchar(128) DEFAULT NULL COMMENT '头像',
`user_type` char(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)',
`create_by` bigint(20) DEFAULT NULL COMMENT '创建人的用户id',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` bigint(20) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`del_flag` int(11) DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
/*Table structure for table `user_role` */
DROP TABLE IF EXISTS `user_role`;
CREATE TABLE `user_role` (
`user_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '用户id',
`role_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '角色id',
PRIMARY KEY (`user_id`,`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SELECT
DISTINCT m.`perms`
FROM
user_role ur
LEFT JOIN `role` r ON ur.`role_id` = r.`id`
LEFT JOIN `role_menu` rm ON ur.`role_id` = rm.`role_id`
LEFT JOIN `menu` m ON m.`id` = rm.`menu_id`
WHERE
user_id = 2
AND r.`status` = 0
AND m.`status` = 0
@TableName(value="menu")
@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Menu implements Serializable {
private static final long serialVersionUID = -54979041104113736L;
@TableId
private Long id;
/**
* 菜单名
*/
private String menuName;
/**
* 路由地址
*/
private String path;
/**
* 组件路径
*/
private String component;
/**
* 菜单状态(0显示 1隐藏)
*/
private String visible;
/**
* 菜单状态(0正常 1停用)
*/
private String status;
/**
* 权限标识
*/
private String perms;
/**
* 菜单图标
*/
private String icon;
private Long createBy;
private Date createTime;
private Long updateBy;
private Date updateTime;
/**
* 是否删除(0未删除 1已删除)
*/
private Integer delFlag;
/**
* 备注
*/
private String remark;
}
3.2.3.3 代码实现
@Mapper
public interface MenuMapper extends BaseMapper<Menu> {
List<String> selectPermsByUserId(Long userId);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.qhx.springsecuritydemo.mapper.MenuMapper">
<select id="selectPermsByUserId" resultType="java.lang.String">
SELECT
m.perms
FROM
`user_role` as ur
LEFT JOIN `role` as r on ur.role_id = r.id
LEFT JOIN `role_menu` as rm on ur.role_id = rm.role_id
LEFT JOIN `menu` as m on rm.menu_id = m.id
WHERE
ur.`user_id` = #{userId} AND r.`status`=0 and m.`status`=0;
</select>
</mapper>
修改UserDetailServiceImpl。
@Service
public class UserDetailServiceImpl extends ServiceImpl< UserMapper, User> implements UserDetailsService {
@Autowired
private MenuMapper menuMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 用用户名查询对应User
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getUserName,username);
// 判断是否空
User user = this.getOne(queryWrapper);
if(user == null){
throw new RuntimeException("用户名或密码错误");
}
List<String> authList = menuMapper.selectPermsByUserId(user.getId());
return new UserDetailsImpl(user,authlist);
}
}
4.异常处理
4.1 SpringSecurity异常处理
我们发现,就算我们登录输入的用户名和错误的:
1.SpringSecurity没有抛出异常 2.也有没有相关响应提示信息;
我是并不希望这样,因此要添加自定义异常处理器:
/**
* 授权异常处理器
**/
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
Result result = Result.error("权限不足", 403);
String jsonResult = JSON.toJSONString(result);
response.setStatus(200);
response.setContentType("application/json");
response.getWriter().write(jsonResult);
}
}
/**
* 认证异常处理器
**/
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
Result result = Result.error("您认证错误/请检查你的用户名或密码是否正确", 401);
String jsonResult = JSON.toJSONString(result);
response.setStatus(200);
response.setContentType("application/json");
response.getWriter().write(jsonResult);
}
}
在SecurityConfig配置中引入
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtFilter jwtFilter;
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Autowired
private AccessDeniedHandlerImpl accessDeniedHandler;
@Autowired
private AuthenticationEntryPoint authenticationEntryPoint;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
// 添加自定义token过滤器到链中
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
// 添加自定义异常处理器
http.exceptionHandling().accessDeniedHandler(accessDeniedHandler)
.authenticationEntryPoint(authenticationEntryPoint);
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
4.2 自定义异常处理
上面除了我们SpringSecurity异常,那么还有一些其他异常,我们不希望每次捕获,并手动抛出,因此可以转化为自定义异常,用SpringBoot的全局异常处理器捕获并且抛出;
5. 跨域
我们现在是用postman测试,但是未来项目是设计到前后端分离项目交互的,都伴随着跨域的问题。
后端能接受到前端原生request对象,response对象,都涉及到跨域的问题。
SpringSecurity解决跨域
SringMvc解决跨域
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
// 设置允许跨域的路径
registry.addMapping("/**")
// 设置允许跨域请求的域名
.allowedOriginPatterns("*")
// 是否允许cookie
.allowCredentials(true)
// 设置允许的请求方式
.allowedMethods("GET", "POST", "DELETE", "PUT")
// 设置允许的header属性
.allowedHeaders("*")
// 跨域允许时间
.maxAge(3600);
}
}
自定义jwt过滤器解决跨域