对于权限的管理,在企业应用程序的开发中,是必不可少的功能,但是能够灵活且强大的权限控制又不是一件容易的事情,所以在自己学习编写权限控制体系的基础上也接触一下成熟的框架,Spring 的全家桶系列 Spring Security 就进入了我们的视线。
Spring Security,这是一种基于 Spring AOP 和 Servlet 过滤器的安全框架。它提供全面的安全性解决方案,同时在 Web 请求级和方法调用级处理身份确认和授权。
- 认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是那个用户。
- 授权:经过认证后判断当前用户是否有权限进行某个操作。
而认证和授权也是 Spring Security 作为安全框架的核心功能。
入门案例
我们通过一个简单的入门案例来了解 Spring Security 。
引入依赖
我们已父子项目方式搭建,所有的依赖都在父项目中书写,子项目只需继承父项目即可。父项目中的写法:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.8</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
</dependencies>
控制类
书写一个普通的 controller 类
@RestController
@RequestMapping("/guanwei")
public class GuanweiController {
@GetMapping
public String execute() {
return "This is GuanweiController`execute!";
}
}
启动类
@SpringBootApplication
public class Security01Application {
public static void main(String[] args) {
SpringApplication.run(Security01Application.class, args);
}
}
运行效果
当我们还按照过去的方式访问 http://localhost:8081/guanwei 路径时,发现弹出了登录框,这个是 Spring Security 自带的登录页面,要求我们必须登录后才能访问系统资源。
并且在项目启动后,在控制台能看到初始生成的密码。
使用 Spring Security 后访问系统任意资源时,就会跳转到默认登录页面,默认账号是 user ,登录成功后,才能访问才能对目标接口进行访问。以后只要不关闭浏览器或者服务器,都可以直接访问。也可以手动登出,路径是:logout 。
登陆认证
我们刚才通过一个简单的案例,了解了 Spring Security 的基本概念。也发现 Spring Security 会在服务器启动时随机生成密码,那么有的童鞋就会想到,能不能自己去定义这个密码,甚至于使用数据库来校验用户登陆。
自定义账号密码
这种方式其实很简单,只需要在配置文件 (application.yml) 中设置账号密码就行。
spring:
security:
user:
name: root
password: guanwei
还可以在配置类中进行设置,注释上边的写法,在 config 包下创建一个配置类 SecurityConfig 来配置账号密码信息。
@Configuration
@EnableWebSecurity
public class SpringSecurityConfig {
@Bean
public UserDetailsService userDetailsService() {
UserDetails userDetails = User.withUsername("admin")
.password(getPasswordEncoder().encode("123456"))
.roles("admin")
.build();
return new InMemoryUserDetailsManager(userDetails);
}
@Bean
PasswordEncoder getPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
BCryptPasswordEncoder 类是 Spring Security 中的一个加密方法类。BCryptPasswordEncoder 方法采用了 SHA-256+随机盐+密钥对密码进行加密。 SHA 是一种安全 Hash 函数(SHA),是使用最广泛的Hash函数。
加密算法与 hash 算法的区别:
- 加密算法是可逆的,加密算法的基本过程是对原来为明文的数据按某种算法进行处理,使其成为不可读的一段代码为“密文”,但在用相应的密钥进行操作之后就可以得到原来的内容 。
- hash 算法是一种单向密码体制,即它是一个从明文到密文的不可逆的映射,只有加密过程,没有解密过程。同时,哈希函数可以将任意长度的输入经过变化以后得到固定长度的输出。
数据库校验方式
前面两种都是写死的,可能对于固定的超级管理员可以用,我们真实的项目场景肯定都是数据库里面的,肯定需要自定义查询,我们先把SecurityConfig 注释了,重新创建一个类 SpringSecurityConfig 来从数据库中判断账号密码。
引入数据库相关依赖
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.7</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-3-starter</artifactId>
<version>1.2.20</version>
</dependency>
书写 bean 包的类
@Data
public class Users implements Serializable {
@TableId(value = "id",type = IdType.AUTO)
private Integer id;
private String userName;
private String passWord;
private String nickName;
}
书写 mapper 包的类
@Mapper
public interface UsersMapper extends BaseMapper<Users> {
}
书写一个 service 类继承 UserDetailsService
@Slf4j
@Service
public class UsersServiceImpl extends ServiceImpl<UsersMapper, Users> implements UsersService, UserDetailsService {
@Resource
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("用户开始登陆");
QueryWrapper<Users> wrapper = new QueryWrapper<>();
wrapper.eq("name", username);
Users one = this.getOne(wrapper);
if (one == null) {
log.info("账号没有找到");
throw new UsernameNotFoundException("账号没有找到");
}
String password = one.getPassword();
password = passwordEncoder.encode(password);
// 这里是加载用户权限,这里先模拟个 admin 权限,更详细的在后边会说到
List<GrantedAuthority> auths = AuthorityUtils.createAuthorityList("admin");
return new User(username, password, auths);
}
}
书写 SpringSecurityConfig 配置类
@Configuration
@EnableWebSecurity
public class SpringSecurityConfig {
@Bean
PasswordEncoder getPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
引入我们自己已经实现了 UserDetailsService 的类,不需要再进行注册了。这时候继续访问刚才的页面,输入数据库的账号和密码就行了。
自定义登录页面
我们刚才一直使用 security 自带的登陆页面,但是在实际使用中我们更多的是使用我们自己书写的登陆页面,要想设置其实很简单,如下几步就行。
创建登录页面
<!--
表单提交用户信息,注意
1.账号和密码的名字
2.action的提交地址和配置类中设置一致
-->
<form action="/user/login" method="post">
账号:<input type="text" name="username"/><br/>
密码:<input type="password" name="password"/><br/>
<button>登录</button>
</form>
在配置类中设置登录页面
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
//表单提交
http.formLogin((formLogin) -> formLogin
//指定自定义登录页面地址
.loginPage("/login.html")
//登录访问路径:前台界面提交表单之后跳转到这个路径进行UserDetailsService的验证,必须和表单提交接口一样
.loginProcessingUrl("/user/login")
//认证成功之后跳转的路径
.defaultSuccessUrl("/index.html")
);
//对请求进行访问控制设置
http.authorizeHttpRequests((a) -> a
//设置哪些路径可以直接访问,不需要认证
.requestMatchers("/login.html", "/user/login").permitAll()
//其他路径的请求都需要认证
.anyRequest().authenticated()
);
//关闭跨站点请求伪造csrf防护
http.csrf(AbstractHttpConfigurer::disable);
return http.build();
}
授权管理
在项目中,有很多接口是针对不同角色权限的,如果角色是超级管理员,就拥有访问所有权限的能力,如果不是超级管理员,访问其他接口是不能允许的。 有些用户具有部分权限,就可以访问这些权限所能访问的内容,如果要实现这种效果,我们就需要授权管理。
基于权限
hasAuthority()方法:如果当前的主体具有指定的权限,则返回 true,否则返回 false。
配置类
//对请求进行访问控制设置
http.authorizeHttpRequests((a) -> a
//设置哪些路径可以直接访问,不需要认证
.requestMatchers("/login.html", "/user/login").permitAll()
// 访问这个路径需要manager1或者manager2的权限
.requestMatchers("/first/a").hasAnyAuthority("manager1", "manager2")
// 访问这个路径需要manager1的权限
.requestMatchers("first/b").hasAuthority("manager1")
//其他路径的请求都需要认证但不需要权限
.anyRequest().authenticated()
);
这里我们可以看到访问 index.html 页面需要登陆,而访问/first/a 只要有 manager1或manager2 权限之一就行。
业务类还是使用刚才的权限 manager2,然后看看运行结果。
运行结果
访问 index.html 页面
访问 /first/b 页面
我们更换权限为 manager1 来试一试
这里就可以直接访问了。
基于角色
配置类
业务类
运行结果
配置 403 页面
当没有权限的时候访问会报403,我们可以自定义一个页面。
创建一个 403.html 页面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>403</title>
</head>
<body>
<h2>你没有相应的权限访问这个页面!</h2>
</body>
</html>
配置类中设置
// 配置403页面 该方法已废弃 后边我们有更好的方式
http.exceptionHandling().accessDeniedPage("/403.html");
运行结果
用户注销
SpringSecurity 的注销功能很简单,只需要一个超链接地址为 /logout 就行。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
我是注销页面!
<a href="/logout">注销</a>
</body>
</html>
分离项目
通过刚才的案例我们了解到了 SpringSecurity 认证授权的效果。可是刚才通过的是表单方式进行提交,在其后的项目中我们可能更多的使用前后端分离效果,那么我们就要返回给前端对应的消息来告诉前端认证授权的情况。
前期准备
前后端分离项目,需要后端返回消息来标注相应状态,这时候我们创建三个类来对返回的消息统一格式。
统一返回格式
/**
* @Author: Dailyblue
* @Description: 统一返回实体
* @Date Create in 2022/06/21 19:28
*/
@Data
public class JsonResult<T> implements Serializable {
private Boolean success;
private Integer errorCode;
private String errorMsg;
private T data;
public JsonResult() {
}
public JsonResult(boolean success) {
this.success = success;
this.errorCode = success ? ResultCode.SUCCESS.getCode() : ResultCode.COMMON_FAIL.getCode();
this.errorMsg = success ? ResultCode.SUCCESS.getMessage() : ResultCode.COMMON_FAIL.getMessage();
}
public JsonResult(boolean success, ResultCode resultEnum) {
this.success = success;
this.errorCode = success ? ResultCode.SUCCESS.getCode() : (resultEnum == null ? ResultCode.COMMON_FAIL.getCode() : resultEnum.getCode());
this.errorMsg = success ? ResultCode.SUCCESS.getMessage() : (resultEnum == null ? ResultCode.COMMON_FAIL.getMessage() : resultEnum.getMessage());
}
public JsonResult(boolean success, T data) {
this.success = success;
this.errorCode = success ? ResultCode.SUCCESS.getCode() : ResultCode.COMMON_FAIL.getCode();
this.errorMsg = success ? ResultCode.SUCCESS.getMessage() : ResultCode.COMMON_FAIL.getMessage();
this.data = data;
}
public JsonResult(boolean success, ResultCode resultEnum, T data) {
this.success = success;
this.errorCode = success ? ResultCode.SUCCESS.getCode() : (resultEnum == null ? ResultCode.COMMON_FAIL.getCode() : resultEnum.getCode());
this.errorMsg = success ? ResultCode.SUCCESS.getMessage() : (resultEnum == null ? ResultCode.COMMON_FAIL.getMessage() : resultEnum.getMessage());
this.data = data;
}
}
返回状态码定义
/**
* @Author: Dailyblue
* @Description: 返回码定义
* 规定:
* #1表示成功
* #1001~1999 区间表示参数错误
* #2001~2999 区间表示用户错误
* #3001~3999 区间表示接口异常
* @Date Create in 2022/06/21 19:28
*/
public enum ResultCode {
/* 成功 */
SUCCESS(200, "成功"),
/* 默认失败 */
COMMON_FAIL(999, "失败"),
/* 参数错误:1000~1999 */
PARAM_NOT_VALID(1001, "参数无效"),
PARAM_IS_BLANK(1002, "参数为空"),
PARAM_TYPE_ERROR(1003, "参数类型错误"),
PARAM_NOT_COMPLETE(1004, "参数缺失"),
/* 用户错误 */
USER_NOT_LOGIN(2001, "用户未登录"),
USER_ACCOUNT_EXPIRED(2002, "账号已过期"),
USER_CREDENTIALS_ERROR(2003, "密码错误"),
USER_CREDENTIALS_EXPIRED(2004, "密码过期"),
USER_ACCOUNT_DISABLE(2005, "账号不可用"),
USER_ACCOUNT_LOCKED(2006, "账号被锁定"),
USER_ACCOUNT_NOT_EXIST(2007, "账号不存在"),
USER_ACCOUNT_ALREADY_EXIST(2008, "账号已存在"),
USER_ACCOUNT_USE_BY_OTHERS(2009, "账号下线"),
/* 业务错误 */
NO_PERMISSION(3001, "没有权限");
private Integer code;
private String message;
ResultCode(Integer code, String message) {
this.code = code;
this.message = message;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
/**
* 根据code获取message
*
* @param code
* @return
*/
public static String getMessageByCode(Integer code) {
for (ResultCode ele : values()) {
if (ele.getCode().equals(code)) {
return ele.getMessage();
}
}
return null;
}
}
返回体构造工具
/**
* @Author: Dailyblue
* @Description: 返回体构造工具
* @Date Create in 2022/06/21 19:28
*/
public class ResultTool {
public static JsonResult success() {
return new JsonResult(true);
}
public static <T> JsonResult<T> success(T data) {
return new JsonResult(true, data);
}
public static JsonResult fail() {
return new JsonResult(false);
}
public static JsonResult fail(ResultCode resultEnum) {
return new JsonResult(false, resultEnum);
}
}
未登录效果
当用户未登录时,会自动进入当前类的 commence 方法,我们在这个方法中返回 JSON 格式的错误信息。
@Component
public class NotLoginAuthentication implements AuthenticationEntryPoint {
// 当用户未登录时 访问资源会返回结果
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
JsonResult result = ResultTool.fail(ResultCode.USER_NOT_LOGIN);
response.setContentType("text/json;charset=utf-8");
response.getWriter().write(JSON.toJSONString(result));
}
}
没有权限效果
当用户没有对应权限时,会进入对应方法。
@Component
public class NotAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
JsonResult result = ResultTool.fail(ResultCode.NO_PERMISSION);
response.setContentType("text/json;charset=utf-8");
response.getWriter().write(JSON.toJSONString(result));
}
}
登录失败效果
当用户登录失败时(不论是账号未找到,密码错误还是权限问题),都会进入这个类的指定方式,我们在这个方法中返回 JSON 格式的错误信息。
@Component
public class FailureAuthenticationHandler implements AuthenticationFailureHandler {
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)
throws IOException, ServletException {
//返回json数据
JsonResult result;
if (e instanceof AccountExpiredException) {
//账号过期
result = ResultTool.fail(ResultCode.USER_ACCOUNT_EXPIRED);
} else if (e instanceof BadCredentialsException) {
//密码错误
result = ResultTool.fail(ResultCode.USER_CREDENTIALS_ERROR);
} else if (e instanceof CredentialsExpiredException) {
//密码过期
result = ResultTool.fail(ResultCode.USER_CREDENTIALS_EXPIRED);
} else if (e instanceof DisabledException) {
//账号不可用
result = ResultTool.fail(ResultCode.USER_ACCOUNT_DISABLE);
} else if (e instanceof LockedException) {
//账号锁定
result = ResultTool.fail(ResultCode.USER_ACCOUNT_LOCKED);
} else if (e instanceof InternalAuthenticationServiceException) {
//用户不存在
result = ResultTool.fail(ResultCode.USER_ACCOUNT_NOT_EXIST);
} else {
//其他错误
result = ResultTool.fail(ResultCode.COMMON_FAIL);
}
//处理编码方式,防止中文乱码的情况
response.setContentType("text/json;charset=utf-8");
//塞到response中返回给前台
response.getWriter().write(JSON.toJSONString(result));
}
}
登录成功效果
同样的道理,对正确的消息返回 JSON 格式信息。
@Component
public class SuccessAuthenticationHandler implements AuthenticationSuccessHandler {
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
//返回json数据
JsonResult result = ResultTool.success();
//处理编码方式,防止中文乱码的情况
response.setContentType("text/json;charset=utf-8");
//塞到response中返回给前台
response.getWriter().write(JSON.toJSONString(result));
}
}
配置文件
在配置文件中注册三个效果。
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
//表单提交
http.formLogin((formLogin) -> formLogin
.loginPage("/login.html")
.loginProcessingUrl("/user/login")
.defaultSuccessUrl("/index.html")
// 登陆成功handler
.successHandler(successAuthenticationHandler)
// 登陆失败handler
.failureHandler(failureAuthenticationHandler)
);
//对请求进行访问控制设置
http.authorizeHttpRequests((a) -> a
//设置哪些路径可以直接访问,不需要认证
.requestMatchers("/login.html", "/user/login").permitAll()
// 访问这个路径需要manager1或者manager2的权限
.requestMatchers("/second/a")
.hasAnyAuthority("manager1", "manager2")
// 访问这个路径需要manager1的权限
.requestMatchers("second/b").hasAuthority("manager1")
// 访问这个路径需要manager1的角色 这里需要专门注意
.requestMatchers("/second/c").hasAnyRole("manager1")
//其他路径的请求都需要认证但不需要权限
.anyRequest().authenticated()
);
// 关闭跨站点请求伪造csrf防护
http.csrf(AbstractHttpConfigurer::disable);
// 配置403页面
// http.exceptionHandling().accessDeniedPage("/403.html");
// 配置没有登陆和未授权handler
http.exceptionHandling((e) ->
e.authenticationEntryPoint(notLoginAuthentication)
.accessDeniedHandler(notAccessDeniedHandler));
return http.build();
}
APIPost测试
登录成功的情况
登录失败的情况
没有登录就访问其他资源情况
进一步拓展
刚才的例子中,我们可以通过几个 Handler 操作未登录时、登录失败和登录成功情况。那么前后端分离情况下,如何保存用户登录状态呢?下边我们通过 Security 的几个过滤器来实现这个功能。
前期准备
我们这里通过 JWT 令牌方式来验证用户登录。JWT 登录详情可以查看另一篇博客。这里创建 JwtConfig 类。
package com.dailyblue.config;
import com.dailyblue.bean.Users;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.servlet.http.HttpServletRequest;
import java.util.Date;
/**
* @author dailyblue
* @since 2022/6/23
*/
@Component
public class JwtConfig {
//常量
public static final long EXPIRE = 1000 * 60 * 60 * 24; //token过期时间
public static final String APP_SECRET = "1234"; //秘钥,加盐
// @param id 当前用户ID
// @param issuer 该JWT的签发者,是否使用是可选的
// @param subject 该JWT所面向的用户,是否使用是可选的
// @param ttlMillis 什么时候过期,这里是一个Unix时间戳,是否使用是可选的
// @param audience 接收该JWT的一方,是否使用是可选的
// 生成json token字符串的方法
public static String getJwtToken(Users user) {
String jwtToken = Jwts.builder()
.setHeaderParam("typ", "JWT") //头部信息
.setHeaderParam("alg", "HS256") //头部信息
//下面这部分是payload部分
// 设置默认标签
.setSubject("dailyblue") //设置jwt所面向的用户
.setIssuedAt(new Date()) //设置签证生效的时间
.setExpiration(new Date(System.currentTimeMillis() + EXPIRE)) //设置签证失效的时间
//自定义的信息,这里存储id和姓名信息
.claim("id", user.getId()) //设置token主体部分 ,存储用户信息
.claim("name", user.getUserName())
.claim("nickName", user.getNickName())
//下面是第三部分
.signWith(SignatureAlgorithm.HS256, APP_SECRET)
.compact();
// 生成的字符串就是jwt信息,这个通常要返回出去
return jwtToken;
}
/**
* 判断token是否存在与有效
* 直接判断字符串形式的jwt字符串
*
* @param jwtToken
* @return
*/
public static boolean checkToken(String jwtToken) {
if (StringUtils.isEmpty(jwtToken)) return false;
try {
Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
} catch (Exception e) {
e.printStackTrace();
return false;
}
return true;
}
/**
* 判断token是否存在与有效
* 因为通常jwt都是在请求头中携带,此方法传入的参数是请求
*
* @param request
* @return
*/
public static boolean checkToken(HttpServletRequest request) {
try {
String jwtToken = request.getHeader("token");//注意名字必须为token才能获取到jwt
if (StringUtils.isEmpty(jwtToken)) return false;
Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
} catch (Exception e) {
e.printStackTrace();
return false;
}
return true;
}
/**
* 根据token字符串获取会员id
* 这个方法也直接从http的请求中获取id的
*
* @param request
* @return
*/
public static String getMemberIdByJwtToken(HttpServletRequest request) {
String jwtToken = request.getHeader("token");
if (StringUtils.isEmpty(jwtToken)) return "";
Jws<Claims> claimsJws = Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
Claims claims = claimsJws.getBody();
return (String) claims.get("id");
}
/**
* 解析JWT
* @param jwt
* @return
*/
public static Claims parseJWT(String jwt) {
Claims claims = Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwt).getBody();
return claims;
}
}
SecurityUser 类,这个类描述用户信息和它的权限信息。
package com.dailyblue.bean;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* UserDetailService 使用该类,该类必须是 UserDetails 的子类
*/
@Data
public class SecurityUser implements UserDetails {
// 登录用户的基本信息
private Users user;
//当前权限
private List<String> permissionValueList;
public SecurityUser() {
}
public SecurityUser(Users user) {
if (user != null) {
this.user = user;
}
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
permissionValueList.forEach(permission ->{
if(!StringUtils.isEmpty(permission)){
SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permission);
authorities.add(authority);
}
});
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;
}
}
UsersService 类,业务类,负责调用 Mapper 的方法
package com.dailyblue.service;
import com.dailyblue.bean.SecurityUser;
import com.dailyblue.bean.Users;
import lombok.extern.slf4j.Slf4j;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.List;
@Service
@Slf4j
public class UsersService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("进入了UsersService的loadUserByUsername方法,接受传递参数:{}", username);
Users user = null;
// 这里没有连接数据库 模拟数据
if ("guanwei".equals(username)) {
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
user = new Users(1, "guanwei", passwordEncoder.encode("123456"), "关为");
}
// 判断用户是否存在
if (user == null) {
throw new UsernameNotFoundException("账户信息不存在!");
}
// 这里没有连接数据库 模拟数据
List<String> admin = Arrays.asList("manager1", "manager2");
SecurityUser securityUser = new SecurityUser(user);
securityUser.setPermissionValueList(admin);
return securityUser;
}
}
登录过滤器
这个是一个 Filter ,不需要 Spring 来注入
- attemptAuthentication 方法 用户登录时触发,获取账号和密码,传递到我们自己书写的 UsersService 中。
- successfulAuthentication 方法 登录成功后执行的方法,一般存放 token 到 Redis 中,返回成功信息。
- unsuccessfulAuthentication 方法 登录失败后执行的方法。
后两个如果书写了,上一个案例中的那两个 Handler 就可以不书写了。
package com.dailyblue.filter;
import com.alibaba.fastjson.JSONArray;
import com.dailyblue.bean.SecurityUser;
import com.dailyblue.bean.Users;
import com.dailyblue.config.JwtConfig;
import com.dailyblue.util.JsonResult;
import com.dailyblue.util.ResultCode;
import com.dailyblue.util.ResultTool;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.*;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {
private JwtConfig jwtConfig;
private AuthenticationManager authenticationManager;
public TokenLoginFilter(JwtConfig jwtConfig, AuthenticationManager authenticationManager) {
this.jwtConfig = jwtConfig;
this.authenticationManager = authenticationManager;
// 关闭登录只允许 post
this.setPostOnly(false);
// 设置登陆路径,并且post请求
this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/user/login", "POST"));
}
// 获取登录页面传递过来的账号和密码
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
log.info("用户开始登录...");
String userName = request.getParameter("userName");
String password = request.getParameter("password");
log.info("账号:{},密码:{}", userName, password);
// 登录接口 /user/login 调用请求时触发
// UsernamePasswordAuthenticationToken 封装登录时传递来的数据信息
// 交给 AuthenticationManager 进行登录认证校验
return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(userName, password));
}
// 配置成功登录
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
log.info("用户登录成功!");
// 认证成功之后,获取认证后的用户基本信息
SecurityUser securityUser = (SecurityUser) authResult.getPrincipal();
log.info("用户信息是:{}", securityUser);
Users user = securityUser.getUser();
String token = JwtConfig.getJwtToken(user);
log.info("用户token是:{}", token);
// token信息存于redis、数据库、缓存等
//返回json数据
JsonResult result = ResultTool.success();
//处理编码方式,防止中文乱码的情况
response.setContentType("text/json;charset=utf-8");
//塞到response中返回给前台
response.getWriter().write(JSONArray.toJSONString(result));
}
// 配置失败登录
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
log.info("用户登录失败!");
//返回json数据
JsonResult result;
if (e instanceof AccountExpiredException) {
//账号过期
result = ResultTool.fail(ResultCode.USER_ACCOUNT_EXPIRED);
} else if (e instanceof BadCredentialsException) {
//密码错误
result = ResultTool.fail(ResultCode.USER_CREDENTIALS_ERROR);
} else if (e instanceof CredentialsExpiredException) {
//密码过期
result = ResultTool.fail(ResultCode.USER_CREDENTIALS_EXPIRED);
} else if (e instanceof DisabledException) {
//账号不可用
result = ResultTool.fail(ResultCode.USER_ACCOUNT_DISABLE);
} else if (e instanceof LockedException) {
//账号锁定
result = ResultTool.fail(ResultCode.USER_ACCOUNT_LOCKED);
} else if (e instanceof InternalAuthenticationServiceException) {
//用户不存在
result = ResultTool.fail(ResultCode.USER_ACCOUNT_NOT_EXIST);
} else {
//其他错误
result = ResultTool.fail(ResultCode.COMMON_FAIL);
}
//处理编码方式,防止中文乱码的情况
response.setContentType("text/json;charset=utf-8");
//塞到response中返回给前台
response.getWriter().write(JSONArray.toJSONString(result));
}
}
验证过滤器
这个过滤器会在每次请求(不需要触发的可以在配置文件中设置)时去触发,主要作用是验证用户是否登录。
@Slf4j
@Component
public class TokenAuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
log.info("开始校验用户是否登录");
String token = request.getHeader("token");
log.info("token:{}", token);
if (token != null) {
// 本次模拟 这里没有校验Redis
Claims claims = JwtConfig.parseJWT(token);
String nickName = claims.get("nickName").toString();
log.info("获取的昵称是:{}", nickName);
// 本次模拟 没有连接数据库
List<String> permissionValueList = Arrays.asList("manager1", "manager2");
Collection<GrantedAuthority> authority = new ArrayList<>();
for (String permissionValue : permissionValueList) {
SimpleGrantedAuthority auth = new SimpleGrantedAuthority(permissionValue);
authority.add(auth);
}
UsernamePasswordAuthenticationToken upat = new UsernamePasswordAuthenticationToken(nickName, token, authority);
// 有权限,则放入权限上下文中
SecurityContextHolder.getContext().setAuthentication(upat);
}
chain.doFilter(request, response);
}
}
配置文件
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Resource
private SuccessAuthenticationHandler successAuthenticationHandler;
@Resource
private FailureAuthenticationHandler failureAuthenticationHandler;
@Resource
private NotLoginAuthentication notLoginAuthentication;
@Resource
private NotAccessDeniedHandler notAccessDeniedHandler;
@Resource
private TokenAuthFilter tokenAuthFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
//表单提交
http.formLogin((formLogin) -> formLogin
.loginPage("/login.html")
.loginProcessingUrl("/user/login")
.defaultSuccessUrl("/index.html")
// 登陆成功handler
.successHandler(successAuthenticationHandler)
// 登陆失败handler
.failureHandler(failureAuthenticationHandler)
);
//对请求进行访问控制设置
http.authorizeHttpRequests((a) -> a
//设置哪些路径可以直接访问,不需要认证
.requestMatchers("/login.html", "/user/login").permitAll()
// 访问这个路径需要manager1或者manager2的权限
.requestMatchers("/second/a")
.hasAnyAuthority("manager1", "manager2")
// 访问这个路径需要manager1的权限
.requestMatchers("second/b").hasAuthority("manager1")
// 访问这个路径需要manager1的角色 这里需要专门注意
.requestMatchers("/second/c").hasAnyRole("manager1")
//其他路径的请求都需要认证但不需要权限
.anyRequest().authenticated()
);
// 关闭跨站点请求伪造csrf防护
http.csrf(AbstractHttpConfigurer::disable);
// 配置403页面
// http.exceptionHandling().accessDeniedPage("/403.html");
// 配置过滤器
http.addFilterBefore(tokenAuthFilter, UsernamePasswordAuthenticationFilter.class);
// 配置没有登陆和未授权handler
http.exceptionHandling((e) ->
e.authenticationEntryPoint(notLoginAuthentication)
.accessDeniedHandler(notAccessDeniedHandler));
return http.build();
}
@Bean
public PasswordEncoder getPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
加载流程
方法级别的权限控制
添加方法级别的角色控制,可以通过注解的方式来完成,在需要具有角色或权限方法的上引入 @PreAuthorize 注解。
这里为了方便期间,没有引入数据库,账号和密码都是写死的。
开启权限控制
控制层的方法上引入注解
@RestController
@RequestMapping("/guan")
public class GuanController {
@GetMapping("/a")
@PreAuthorize("hasAnyAuthority('admin')")
public String a() {
return "Hello,world!";
}
@GetMapping("/b")
@PreAuthorize("hasAnyAuthority('admin','guan')")
public String b() {
return "This is method`b!";
}
@GetMapping("/c")
@PreAuthorize("hasAnyAuthority('team')")
public String c() {
return "This is method`c!";
}
}
效果演示
访问 a 方法
访问 b 方法
SpringSecurity 基本流程
SpringSecurity 的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器,这里我们可以看看入门案例中的过滤器。
SpringSecurity 拦截器
1 . org.springframework.security.web.context.SecurityContextPersistenceFilter
首当其冲的一个过滤器,作用之重要,自不必多言。
SecurityContextPersistenceFilter主要是使用SecurityContextRepository在session中保存或更新一个
SecurityContext,并将SecurityContext给以后的过滤器使用,来为后续filter建立所需的上下文。
SecurityContext中存储了当前用户的认证以及权限信息。
2 . org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter
此过滤器用于集成SecurityContext到Spring异步执行机制中的WebAsyncManager
3 . org.springframework.security.web.header.HeaderWriterFilter
向请求的Header中添加相应的信息,可在http标签内部使用security:headers来控制
4 . org.springframework.security.web.csrf.CsrfFilter
csrf又称跨域请求伪造,SpringSecurity会对所有post请求验证是否包含系统生成的csrf的token信息,
如果不包含,则报错。起到防止csrf攻击的效果。
5. org.springframework.security.web.authentication.logout.LogoutFilter
匹配 URL为/logout的请求,实现用户退出,清除认证信息。
6 . org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
认证操作全靠这个过滤器,默认匹配URL为/login且必须为POST请求。
7 . org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter
如果没有在配置文件中指定认证页面,则由该过滤器生成一个默认认证页面。
8 . org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter
由此过滤器可以生产一个默认的退出登录页面
9 . org.springframework.security.web.authentication.www.BasicAuthenticationFilter
此过滤器会自动解析HTTP请求中头部名字为Authentication,且以Basic开头的头信息。
10 . org.springframework.security.web.savedrequest.RequestCacheAwareFilter
通过HttpSessionRequestCache内部维护了一个RequestCache,用于缓存HttpServletRequest
11 . org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
针对ServletRequest进行了一次包装,使得request具有更加丰富的API
12 . org.springframework.security.web.authentication.AnonymousAuthenticationFilter
当SecurityContextHolder中认证信息为空,则会创建一个匿名用户存入到SecurityContextHolder中。
spring security为了兼容未登录的访问,也走了一套认证流程,只不过是一个匿名的身份。
13 . org.springframework.security.web.session.SessionManagementFilter
SecurityContextRepository限制同一用户开启多个会话的数量
14 . org.springframework.security.web.access.ExceptionTranslationFilter
异常转换过滤器位于整个springSecurityFilterChain的后方,用来转换整个链路中出现的异常
15 . org.springframework.security.web.access.intercept.FilterSecurityInterceptor
获取所配置资源访问的授权信息,根据SecurityContextHolder中存储的用户信息来决定其是否有权限。