在Java企业级开发中,Spring Security 是一个广泛使用的安全框架,它提供了身份验证、授权以及防止攻击等安全性功能。SpringBoot3 与 SpringSecurity 的整合能够极大简化安全配置和管理的复杂性。
本片文章基于JDK17+springboot3.3.0
开发工具使用到hutool
以下将详细介绍如何在 SpringBoot3 项目中整合 SpringSecurity。
1. 引入依赖
首先,你需要在 SpringBoot 项目的 pom.xml
文件中引入 Spring Security 的依赖。对于 SpringBoot3,确保使用的是与 SpringBoot 版本兼容的 Spring Security 版本。
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
<relativePath />
</parent>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JWT 相关 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
2. 配置 Spring Security
接下来配置 Spring Security 以满足安全需求。通常涉及到设置用户验证、请求授权以及配置各种过滤器等。
2.1 编写安全性配置类(核心类)
创建一个配置类来扩展 WebSecurityConfigurerAdapter
并覆盖其方法来配置安全性。
@Slf4j
@Configuration
@EnableWebSecurity //开启SpringSecurity的默认行为
@RequiredArgsConstructor//bean注解
// 新版不需要继承WebSecurityConfigurerAdapter
public class WebSecurityConfig {
// 这个类主要是获取库中的用户信息,交给security
private final UserDetailServiceImpl userDetailsService;
// 这个的类是认证失败处理(我在这里主要是把错误消息以json方式返回)
private final JwtAuthenticationEntryPoint authenticationEntryPoint;
// 鉴权失败的时候的处理类
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
// 登录成功处理
private final LoginSuccessHandler loginSuccessHandler;
// 登录失败处理
private final LoginFailureHandler loginFailureHandler;
// 登出成功处理
private final LoginLogoutSuccessHandler loginLogoutSuccessHandler;
// token过滤器
private final JwtTokenFilter jwtTokenFilter;
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration
) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
// 加密方式
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 核心配置
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
log.info("------------filterChain------------");
http
// 禁用basic明文验证
.httpBasic(Customizer.withDefaults())
// 基于 token ,不需要 csrf
.csrf(AbstractHttpConfigurer::disable)
// 禁用默认登录页
.formLogin(fl ->
fl.loginPage(PathMatcherUtil.FORM_LOGIN_URL)
.loginProcessingUrl(PathMatcherUtil.TO_LOGIN_URL)
.usernameParameter("username")
.passwordParameter("password")
.successHandler(loginSuccessHandler)
.failureHandler(loginFailureHandler)
.permitAll())
// 禁用默认登出页
.logout(lt -> lt.logoutSuccessHandler(loginLogoutSuccessHandler))
// 基于 token , 不需要 session
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 设置 处理鉴权失败、认证失败
.exceptionHandling(
exceptions -> exceptions.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
)
// 下面开始设置权限
.authorizeHttpRequests(authorizeHttpRequest -> authorizeHttpRequest
// 允许所有 OPTIONS 请求
.requestMatchers(PathMatcherUtil.AUTH_WHITE_LIST).permitAll()
// 允许直接访问 授权登录接口
// .requestMatchers(HttpMethod.POST, "/web/authenticate").permitAll()
// 允许 SpringMVC 的默认错误地址匿名访问
// .requestMatchers("/error").permitAll()
// 其他所有接口必须有Authority信息,Authority在登录成功后的UserDetailImpl对象中默认设置“ROLE_USER”
//.requestMatchers("/**").hasAnyAuthority("ROLE_USER")
// .requestMatchers("/heartBeat/**", "/main/**").permitAll()
// 允许任意请求被已登录用户访问,不检查Authority
.anyRequest().authenticated()
)
// 添加过滤器
.addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class);
//可以加载fram嵌套页面
http.headers( headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin));
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
return userDetailsService::loadUserByUsername;
}
/**
* 调用loadUserByUserName获取userDetail信息,在AbstractUserDetailsAuthenticationProvider里执行用户状态检查
*
* @return
*/
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
/**
* 配置跨源访问(CORS)
*
* @return
*/
@Bean
CorsConfigurationSource corsConfigurationSource() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", new CorsConfiguration().applyPermitDefaultValues());
return source;
}
}
2.2 自定义用户登录认证
从数据库中查询用户信息,再进行用户验证逻辑,实现 UserDetailsService
接口,并在 AuthenticationManagerBuilder
中配置。
/**
* 自定义登录接口服务类
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class UserDetailServiceImpl implements UserDetailsService {
// 注入管理员信息service
private final ManagerService managerService;
// 注入角色信息service
private final RoleService roleService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
ManagerPo mng = managerService.getByUsername(username);
if (mng == null) {
log.info("用户名不存在!userName=" + username);
throw new UsernameNotFoundException("用户名不存在" + username);
}
if (mng.getState() != 1) {
log.info("用户已被冻结!userName=" + username);
throw new LockedException("该用户已被冻结" + username);
}
// 角色集合
Set<GrantedAuthority> authorities = new HashSet<>();
// 查询用户角色
List<RolePo> roleList = roleService.getByManager(mng.getId());
for (RolePo role : roleList) {
authorities.add(new SimpleGrantedAuthority(role.getRole()));
}
JwtMngBo jwtMng = new JwtMngBo(mng.getId(), mng.getUsername(), mng.getTrueName(), mng.getPassword(),
mng.getGroupMark(), authorities);
return jwtMng;
}
}
2.3 认证失败处理
用户未登录处理类 自定义身份验证失败的handler,包括跳转页面并统计拦截次数
@Slf4j
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
//统计用户错误登陆日志service
@Autowired
private MngLoginlogService mngLoginlogService;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
log.error("[commence][访问 URL({}) 时,没有登录]", request.getRequestURI(), authException);
String requestUri = request.getRequestURI();
String browser = request.getHeader("user-agent");
// 请求login 或者 又admin字段都判断未登录 需要重新登录
if (isAdminLogin(requestUri)) {
JwtTokenGetUtil.deleteCookieToken(response);
String token = JwtTokenGetUtil.getToken(request);
log.info("认证失败后,后台不是登陆地址,则进入后台登录界面。requestURI={},token={}", requestUri, token);
mngLoginlogService.recordLog(request, MngLoginLogDic.login_no, "", "browser=" + browser + ",token=" + token);
response.sendRedirect("/xxx/login.html");
return;
}
log.info("认证失败!--requestURI={},ip={},userAgent:{}", requestUri, IpUtil.getIp(request), browser);
mngLoginlogService.recordLog(request, MngLoginLogDic.login_no, "", browser);
LoginResultUtil.reJson(response, MsgCode.SYSTEM_TOKEN_AUTH_ERROR);
}
/**
* 在用户身份认证失败后,判断为正确的后台地址,又不是登录页面,则返回true 防止用户身份过期后,无法跳转到登录页面处理
*
* @param url
* @return
*/
private boolean isAdminLogin(String url) {
if (url.contains("admin") || url.contains("ADMIN")) {
if (!(url.contains("login") || url.contains("LOGIN"))) {
return true;
}
}
return false;
}
}
2.4 鉴权失败的时候的处理(暂无权限处理类)
@Slf4j
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Autowired
private MngLoginlogService mngLoginlogService;
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
mngLoginlogService.recordLog(request, MngLoginLogDic.perm_no, "", accessDeniedException.getMessage());
LoginResultUtil.reJson(response, 70001, MsgCode.PERMISSION_NO_ACCESS);
log.warn("[commence][访问 URL({}) 时,用户({}) 权限不够]", request.getRequestURI(),
"", accessDeniedException);
}
}
2.5 鉴权成功后(token生成管理)
@Slf4j
@Component
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
@Autowired
private ManagerService managerService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
// 组装JWT
JwtMngBo jwtMng = (JwtMngBo) authentication.getPrincipal();
String token = MngJwtTokenUtil.generateToken(jwtMng);
//存放token到cookie中,最好时直接返回json
JwtTokenGetUtil.setCookieToken(response, token);
//返回json
//response.sendRedirect(request.getContextPath() + PathMatcherUtil.INDEX_URL);
}
}
说明:jwt生成比较常见,这里忽略。生成token如何返回,根据自己场景而定,只要在随后得请求中带上即可。
用户安全模型(专供安全管理使用):
@Data
public class JwtMngBo implements UserDetails {
private Integer id;
private String password;
private String username;
private String trueName;
/**
* @Description 得到用户的角色列表
*/
private Collection<? extends GrantedAuthority> authorities;
/**
* 判断用户是否为过期
*/
private boolean accountNonExpired = true;
/**
* 判断用户是否为锁定
*/
private boolean accountNonLocked = true;
/**
* 判断密码是否未过期
*/
private boolean credentialsNonExpired = true;
/**
* 判断账户是否激活
*/
private boolean enabled = true;
public JwtMngBo(Integer id, String username, String trueName, String password,
Collection<? extends GrantedAuthority> authorities) {
this.id = id;
this.username = username;
this.trueName = trueName;
this.password = password;
this.authorities = authorities;
}
}
2.6 登陆失败处理
@Slf4j
@Component
public class LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
public LoginFailureHandler() {
this.setDefaultFailureUrl("/xxx/login.html");
}
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) {
if (exception instanceof UsernameNotFoundException) {
log.info("【用户名不存在】" + exception.getMessage());
//用户名不存在
redirectLogin(response, request.getContextPath() + "/xxx/login.html?error=userNotExis");
return;
}
if (exception instanceof LockedException) {
log.info("【用户被冻结】" + exception.getMessage());
//用户被冻结
redirectLogin(response, request.getContextPath() + "/xxx/login.html?error=userFrozen");
return;
}
if (exception instanceof BadCredentialsException) {
log.info("【用户名密码不正确】" + exception.getMessage());
//用户名密码不正确
throw new BusinessException(MsgCode.USER_LOGIN_ERROR);
}
log.info("-----------登录验证失败,其他登录失败错误");
//其他登录失败错误
redirectLogin(response, request.getContextPath() + "/xxx/login.html?error=loginFailed");
}
private void redirectLogin(HttpServletResponse res,String url){
try {
res.sendRedirect(url);
} catch (IOException e1) {
logger.error(e1.getMessage());
// e1.printStackTrace();
}
}
}
2.7 登出处理
@Component
public class LoginLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
SecurityContextHolder.clearContext();
//清楚token储存
JwtTokenGetUtil.deleteCookieToken(response);
//跳转登陆页面
LoginResultUtil.reLoginHtml(response, "登出时");
}
}
2.8 Token过滤器
此过滤器很重要,主要负责资源放过,拦截,以及token验证
@Slf4j
@Component
public class JwtTokenFilter extends OncePerRequestFilter {
@Autowired
private LoginFilterMng loginFilterMng;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
String requestUri = request.getRequestURI();
// Url白名单,正常放过
if (PathMatcherUtil.passWhiteUrl(requestUri)) {
chain.doFilter(request, response);
return;
}
// Url黑名单,直接拦截返回
if (PathMatcherUtil.passBlackUrl(requestUri)) {
log.info("进入黑名单!requestUri={},ip={},userAgent={}", requestUri, IpUtil.getIp(request),
request.getHeader("user-agent"));
response.sendRedirect("/xxx/404");
return;
}
//验证码拦截验证
if (PathMatcherUtil.TO_LOGIN_URL.equals(requestUri)) {
// 验证前端传来的验证码
if (loginFilterMng.verifyCode(request, response)) {
log.info("验证码拦截!");
chain.doFilter(request, response);
return ;
}else{
chain.doFilter(request, response);
return ;
}
}
// 验证token是否有效
String token = JwtTokenGetUtil.getToken(request);
if (StringUtils.isEmpty(token)) {
log.info("[后台token]为空!requestUri={},ip={},userAgent={}", requestUri, IpUtil.getIp(request),
request.getHeader("user-agent"));
response.sendRedirect("/xxx/login.html");
// chain.doFilter(request, response);
return;
}
UsernamePasswordAuthenticationToken authentication = getAuthenticationToken(token, requestUri);
if (authentication == null) {
chain.doFilter(request, response);
return;
}
SecurityContextHolder.getContext().setAuthentication(authentication);
if (requestUri.contains("/xxx/login.html")) {
response.sendRedirect("/xxx/index.html");
return;
}
chain.doFilter(request, response);
}
private UsernamePasswordAuthenticationToken getAuthenticationToken(String token, String requestUri) {
try {
Claims claims = MngJwtTokenUtil.getClaimsFromToken(token);
if (claims==null){
log.error("token中过期,claims为空! requestUri={}", requestUri);
return null;
}
String username = claims.getSubject();
String userId = claims.getId();
if (StringUtils.isEmpty(username) || StringUtils.isEmpty(userId)) {
log.error("token中username或userId为空! requestUri={}", requestUri);
return null;
}
// 获取角色
List<GrantedAuthority> authorities = new ArrayList<>();
String authority = claims.get("authorities").toString();
if (!StringUtils.isEmpty(authority)) {
@SuppressWarnings("unchecked")
List<Map<String, String>> authorityMap = JSONObject.parseObject(authority, List.class);
for (Map<String, String> role : authorityMap) {
if (!role.isEmpty()) {
authorities.add(new SimpleGrantedAuthority(role.get("authority")));
}
}
}
String trueName = claims.get("trueName").toString();
JwtMngBo jwtMng = new JwtMngBo(Integer.parseInt(userId), username, trueName, "", authorities);
return new UsernamePasswordAuthenticationToken(jwtMng, userId, authorities);
} catch (ExpiredJwtException e) {
logger.error("Token已过期: {} " + e);
/* throw new TokenException("Token已过期"); */
} catch (UnsupportedJwtException e) {
logger.error("requestURI=" + requestUri + ",token=" + token + ",Token格式错误: {} " + e);
/* throw new TokenException("Token格式错误"); */
} catch (MalformedJwtException e) {
logger.error("requestURI=" + requestUri + ",token=" + token + ",Token没有被正确构造: {} " + e);
/* throw new TokenException("Token没有被正确构造"); */
} catch (SignatureException e) {
logger.error("requestURI=" + requestUri + ",token=" + token + ",签名失败: {} " + e);
/* throw new TokenException("签名失败"); */
} catch (IllegalArgumentException e) {
logger.error("requestURI=" + requestUri + ",token=" + token + ",非法参数异常: {} " + e);
/* throw new TokenException("非法参数异常"); */
}
return null;
}
}
3. 控制器
编写控制器来处理登录和注销请求。
@Controller
@RequestMapping("/xxx")
public class AdminLoginAction {
//登录入口
@RequestMapping(value = "/login.html")
public String login(HttpServletRequest request, HttpServletResponse response) {
return "/xxx/loginForm";
}
/**
* 生成验证码
*/
@RequestMapping(value = "/getVerify")
public void getVerify(HttpServletRequest request, HttpServletResponse response) {
try {
response.setContentType("image/jpeg");// 设置相应类型,告诉浏览器输出的内容为图片
response.setHeader("Pragma", "No-cache");// 设置响应头信息,告诉浏览器不要缓存此内容
response.setHeader("Cache-Control", "no-cache");
response.setDateHeader("Expire", 0);
RandomValidateCodeUtil randomValidateCode = new RandomValidateCodeUtil();
randomValidateCode.getRandcode(request, response);// 输出验证码图片方法
} catch (Exception e) {
log.error("获取验证码失败>>>> ", e);
}
}
@GetMapping(value = "/404.html")
public String error404(HttpServletRequest request, HttpServletResponse response) {
return "/error/404";
}
}
4. 创建登录页面
创建一个简单的HTML页面loginForm作为登录页面,通常放在 src/main/resources/templates
目录下。
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>登录</title>
<meta name="renderer" content="webkit|ie-comp|ie-stand">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport"
content="width=device-width,user-scalable=yes, minimum-scale=0.4, initial-scale=0.8,target-densitydpi=low-dpi" />
<meta http-equiv="Cache-Control" content="no-siteapp" />
</head>
<body>
<div class="login layui-anim layui-anim-up">
<div class="message">登录</div>
<div id="darkbannerwrap"></div>
<form method="post" class="layui-form" id="login-form" action="">
<input name="username" placeholder="用户名" type="text" lay-verify="required" class="layui-input">
<input name="password" lay-verify="required" placeholder="密码" type="password" class="layui-input">
<input class="form-inline" name="verifyCode" autocomplete="off"
style="width: 50%" type="text" id="verify_input" placeholder="请输入验证码" maxlength="4">
<a href="javascript:void(0);" rel="external nofollow" title="点击更换验证码"> <img id="imgVerify" src="" alt="更换验证码"
style="vertical-align: bottom; float: right" height="46" width="40%" onclick="getVerify(this);">
</a>
<hr class="hr15">
<input value="登录" style="width: 100%;" type="button" onclick="login(this);">
<span id="info" style="color: red"></span>
</form>
</div>
</body>
</html>
说明:以上HTML代码只做主要功能展示,基于安全考虑,提出了静态资源文件,所以不能直接使用,可以根据自己得界面设计参照使用。
其中,οnclick="getVerify(this);"主要作用时更新验证码,若无验证码需求,可去除。
οnclick="login(this);"为js登陆方法,里面主要时使用ajax调用访问登陆地址即可。