好家伙,好久没用spring-security 今天接起来差点都不认识了,在最新的版本中变化还是蛮大的 在这里记录一下
关于接口文档请看我Spring-boot3.4最新版整合swagger和Mybatis-plus
我是在cloud中使用的,做了路由转发,所以有个auth的前缀 其实和在spring boot中使用时一样的 下面是版本信息
spring cloud | spring cloud alibaba | spring boot | spring security | jdk |
---|---|---|---|---|
2023.0.1 | 2023.0.1.0 | 3.2.4 | 6.2.3 | 17 |
现在开发基本上都是前后端分离,网上的教程也都是基于表单登录的,今天给大家讲解一下禁用表单如何登录的逻辑,我们也返回个token给前端,今天时间有限只做怎么用spring-security 给认证登录,并处理一下各种奇怪的异常,授权抽空我们再给做上
1.怎么使用呢?我们直接开始
- 像开发简单的web项目一样创建controller,service,serviceImpl
controller
@Tag(name = "用户服务")
@RestController
@RequestMapping("/account")
@RequiredArgsConstructor
public class AccountController {
private final UsersService usersService;
/**
* 登录
* @param request
* @return
*/
@Operation(summary = "登录")
@PostMapping("/login")
public Result<String> login(@RequestBody UserRequest request){
return Result.success(usersService.login(request));
}
}
service和serviceimpl
public interface UsersService extends IService<Users> {
/**
* 登录接口
* @param request
* @return
*/
String login(UserRequest request);
}
@Service
@RequiredArgsConstructor
public class UsersServiceImpl extends ServiceImpl<UsersMapper, Users>implements UsersService{
public String login(UserRequest request) {
String userName = request.getUserName();
String password = request.getPassword();
//在这里登录成功生成token这是我们最简单的做法
return token;
}
}
上面这是我们最简单的做法那用security 该怎么实现呢?也很简单我们只需要加入AuthenticationManager 就可以实现security 的登录认证了,修改后的代码如下:
@Service
@RequiredArgsConstructor
public class UsersServiceImpl extends ServiceImpl<UsersMapper, Users>
implements UsersService{
//注入AuthenticationManager
private final AuthenticationManager authenticationManager;
/**
* 登录接口
*
* @param request
* @return
*/
@Override
public String login(UserRequest request) {
String userName = request.getUserName();
String password = request.getPassword();
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userName, password);
authenticationManager.authenticate(authenticationToken);
return "success";
}
现在解释一下为什么这么写 以及 UsernamePasswordAuthenticationToken是干什么用的,通俗的讲就一句话: 根据用户名和密码生成一个身份认证令牌,然后在把令牌传到 AuthenticationManager 进行验证,也就是我们上面写的代码,它其实是个接口,我们看到有5个实现类,springsecurity会提供许多AuthenticationProvider,而ProviderManager这个类就处理这些AuthenticationProvider,
在容器启动时,springsecurity已经初始化了一些provider,其中就有DaoAuthenticationProvider,其主要是用户名和密码验证的,DaoAuthenticationProvider又继承了AbstractUserDetailsAuthenticationProvider抽象类,在这个抽象类中又干了:
- 调用实现类的DaoAuthenticationProvider的retrieveUser,获取到保存在内存中的用户信息,如密码,账号状态等
- 判断用户状态是否有效
- 获取的密码跟提交过来的密码是否一致
太麻烦了… 要解释起来说一天都说不完上面的这些逻辑一直走最终会走到大家非常熟悉的一个类UserDetailsService的loadUserByUsername方法,直接写代码,源码大家有时间自己去研究吧,我们直接实现这个方法
2. 实现UserDetailsService
@Service
@RequiredArgsConstructor
public class CustomUserDetailsServiceImpl implements UserDetailsService {
private final UsersMapper usersMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//这里是查询数据库中信息
Users users = usersMapper.loadUserByUsername(username);
if (users == null) {
throw new UsernameNotFoundException("用户不存在");
}
//在这里查询权限角色信息/比如后台的动态菜单等等
return new org.springframework.security.core.userdetails.User(
users.getUserName(),
users.getPassword(),
AuthorityUtils.createAuthorityList("ROLE_USER")
);
}
}
就这么简单,然后这中间会有各种异常比如上面的UsernameNotFoundException我们只需要定义一个handler 来处理就好了
哈哈哈是不是饶了,其实前面的都没鸟用,只是给大家说下上面的这种做法适合开启表单登录的逻辑,真正的禁用表单连controller都不需要登录根本不会走到这里来,只是让大家理解一下逻辑,开始我们今天的正文,自定义登录过滤器
3.自定义登录过滤器
3.1登录认证过滤器
@Component
@RequiredArgsConstructor
public class LoginAuthenticationFilter extends OncePerRequestFilter {
private final AuthenticationManager authenticationManager;
private final AuthenticationSuccessHandler successHandler;
private final AuthenticationFailureHandler failureHandler;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
//这个就是登录的地址/account/login
if ("/account/login".equals(request.getRequestURI()) && "POST".equalsIgnoreCase(request.getMethod())) {
try {
// 解析 JSON 格式的请求体
StringBuilder requestBody = new StringBuilder();
try (BufferedReader reader = request.getReader()) {
String line;
while ((line = reader.readLine()) != null) {
requestBody.append(line);
}
}
Map bean = JSONUtil.toBean(requestBody.toString(), Map.class);
String username = (String) bean.get("username");
String password = (String) bean.get("password");
if (username == null || password == null) {
throw new AuthenticationServiceException("Username or password is missing");
}
// 创建认证请求
UsernamePasswordAuthenticationToken authRequest =
new UsernamePasswordAuthenticationToken(username, password);
// 执行认证
Authentication authentication = authenticationManager.authenticate(authRequest);
// 登录成功处理
successHandler.onAuthenticationSuccess(request, response, authentication);
} catch (AuthenticationException e) {
// 登录失败处理
failureHandler.onAuthenticationFailure(request, response, e);
}
return; // 终止过滤链
}
// 继续其他过滤器
filterChain.doFilter(request, response);
}
}
3.2 登录认证失败处理器
**
- @package com.aihe.customer.auth.handler
- @description 登录失败处理器
*/
@Component
public class CustomerAuthenticationFailureHandler implements AuthenticationFailureHandler {
/**
* 一旦登录是被会走到这里
* @param request the request during which the authentication attempt occurred.
* @param response the response.
* @param e the exception which was thrown to reject the authentication
* request.
* @throws IOException
*/
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
//修改编码格式
response.setCharacterEncoding("utf-8");
response.setContentType("application/json");
String msg = null;
if (e instanceof BadCredentialsException){
msg = "用户名或密码错误";
} else if (e instanceof UsernameNotFoundException) {
msg = e.getMessage();
}else if (e instanceof AuthenticationServiceException){
msg = e.getMessage();
}
response.getWriter().println(JSONUtil.toJsonStr(Result.error(e.getMessage())));
response.getWriter().flush();
}
}
3.3 登录认证成功处理器
@Component
public class CustomerAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
/**
* 一旦登录成功就会走到这里
* @param request
* @param response
* @param authentication
* @throws IOException
* @throws ServletException
*/
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.setCharacterEncoding("utf-8");
response.setContentType("application/json");
// 获取用户名
String userName = authentication.getName();
// 生成 token
String token = JWT.create()
.setPayload("username", userName)
.setKey("123456".getBytes(StandardCharsets.UTF_8))
.sign();
// 构造响应体
String result = JSONUtil.toJsonStr(Result.success(token));
response.getWriter().println(result);
response.getWriter().flush();
// //拿到登录用户信息
// UserDto userDetails = (UserDto)authentication.getPrincipal();
// //生成token
// Map<String, Object> map = new HashMap<>();
// map.put("userId", userDetails.getUser().getId());
// String accessToken = JwtUtils.createToken(IdUtil.simpleUUID(), JSONUtil.toJsonStr(map), null);
// //将用户信息存到缓存中
// RedisUtils.put(RedisConstant.ADMIN_USER_INFO + userDetails.getUser().getId(), JSONUtil.toJsonStr(userDetails));
// //将token存到缓存中
// RedisUtils.put(RedisConstant.ADMIN_TOKEN + userDetails.getUser().getId(), accessToken);
// //输出结果
// response.setCharacterEncoding("utf-8");
// response.setContentType("application/json");
// response.getWriter().write(JSONUtil.toJsonStr(Result.success(new LoginVo(userDetails.getUser().getUserName(), userDetails.getUser().getId(), accessToken))));
}
3.4 受保护的资源(拒接访问)处理器
@Component
public class CustomerAuthenticationEntryPointHandler implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().println(JSONUtil.toJsonStr(Result.error(HttpStatus.UNAUTHORIZED.value(), "认证失败,请重新登录")));
response.getWriter().flush();
}
}
3.5权限认证过滤
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7); // 去掉 "Bearer " 前缀
try {
// 解析 Token(用你的 JWT 工具库解析)
String username = JWT.of(token).getPayload("username").toString();
// 获取权限信息(可选)
// String roles = JWT.of(token).getPayload("roles").toString();
System.out.println("username = " + username);
// System.out.println("roles = " + roles);
// 创建用户认证信息
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(username, null,
AuthorityUtils.commaSeparatedStringToAuthorityList(null));
// 设置认证信息到 Spring Security 上下文
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception e) {
System.out.println("e.getMessage() = " + e.getMessage());
// Token 解析失败,可能是无效或过期
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Invalid or expired token");
return;
}
}
// 放行其他过滤器
filterChain.doFilter(request, response);
}
}
4 SecurityConfig的配置,网上大多都是老的,下面是最新的方法
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final CustomerAuthenticationFailureHandler customerAuthenticationFailureHandler;
private final CustomerAuthenticationSuccessHandler customerAuthenticationSuccessHandler;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CustomerAuthenticationEntryPointHandler customerAuthenticationEntryPointHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
LoginAuthenticationFilter loginAuthenticationFilter) throws Exception {
http
// 基于 token,所以不需要 CSRF 防护
.csrf(AbstractHttpConfigurer::disable)
// 基于 token,所以不需要 session
.sessionManagement(AbstractHttpConfigurer::disable)
// 禁用基本身份验证
.httpBasic(AbstractHttpConfigurer::disable)
// 禁用表单登录
.formLogin(AbstractHttpConfigurer::disable)
// 禁用缓存
.headers(AbstractHttpConfigurer::disable)
// 放行接口
.authorizeHttpRequests(authorize -> authorize
// 允许访问登录和注册接口
.requestMatchers(
"/account/login",
"/account/register",
"/swagger-resources/**",
"/webjars/**",
"/v3/**",
"/doc.html",
"/favicon.ico"
).permitAll()
// 其他请求需要身份验证
.anyRequest().authenticated()
)
// 添加自定义登录过滤器
.addFilterAt(loginAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
// 验证JWT
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
//添加 授权失败处理Handler
.exceptionHandling(exceptionHandling ->
exceptionHandling.authenticationEntryPoint(customerAuthenticationEntryPointHandler)
)
;
return http.build();
}
@Bean
public LoginAuthenticationFilter loginAuthenticationFilter(AuthenticationManager authenticationManager) {
return new LoginAuthenticationFilter(authenticationManager,
customerAuthenticationSuccessHandler,
customerAuthenticationFailureHandler);
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
这样就配置成功了 这里很奇怪 formLogin(AbstractHttpConfigurer::disable) 这是禁用表单登录,如果使用的话又需要另一种方式了,待会我们后面会写到,现在我们开启文档就可以访问了
6.接口测试
- 添加用户数据,初始化一个密码,添加到数据库中
- 访问接口文档开始测试
错误的用户名和密码记住这里了一个是密码错误一个是用户错误,为什么返回的错误信息都是一样的 后面给你解释
正确的用户名和密码
3.权限判断,咋验证呢,还记得我们的配置文件吗
我们新增一个account/test接口,上面没放行肯定就是受保护的就返回success一句话
@Tag(name = "用户服务")
@RestController
@RequestMapping("/account")
@RequiredArgsConstructor
public class AccountController {
private final UsersService usersService;
/**
* 登录
* @param request
* @return
*/
@Operation(summary = "登录")
@PostMapping("/login")
public Result<String> login(@RequestBody UserRequest request){
return Result.success(usersService.login(request));
}
@GetMapping("/test")
public Result<String> test() {
return Result.success("success");
}
}
无token请求返回401
有token返回成功
你是不是还有好多疑问,用户不存在和密码错误都是相同的返回结果
- Spring Security 在处理身份验证失败时,会对异常进行一定程度的包装,因此原始抛出的异常可能会被替换为一个通用的异常类型,比如 BadCredentialsException。以下是具体原因和解决方案
- DaoAuthenticationProvider 的异常包装Spring Security 使用 DaoAuthenticationProvider 来处理用户名和密码的验证。在 DaoAuthenticationProvider 的 authenticate 方法中,会调UserDetailsService.loadUserByUsername 加载用户信息。
- 如果 loadUserByUsername 抛出 UsernameNotFoundException,DaoAuthenticationProvider 会捕获这个异常,并转换为 BadCredentialsException,以防止泄露过多的认证信息(如用户名是否存在
所以上面的登录失败的代码可以直接改为
@Component
public class CustomerAuthenticationFailureHandler implements AuthenticationFailureHandler {
/**
* 一旦登录失败会走到这里
* @param request the request during which the authentication attempt occurred.
* @param response the response.
* @param e the exception which was thrown to reject the authentication
* request.
* @throws IOException
*/
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
//修改编码格式
response.setCharacterEncoding("utf-8");
response.setContentType("application/json");
response.getWriter().println(JSONUtil.toJsonStr(Result.error(e.getMessage())));
response.getWriter().flush();
}
}
你是不是更奇怪,我都没有校验密码,为什么会通过或者失败呢,是在哪里校验的呢
密码的比较是在 AuthenticationManager 的 authenticate 方法中进行的,具体是通过一个 密码验证器 来执行的。Spring Security 会自动处理密码验证的过程,使用的是 PasswordEncoder 接口。PasswordEncoder 用来对用户输入的密码和数据库中存储的加密密码进行比较,密码校验流程如下:
- UsernamePasswordAuthenticationToken 被创建: 在登录过程中,UsernamePasswordAuthenticationToken 被创建,它包含了用户输入的用户名和密码。这时密码是明文的。
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userName, password);
- AuthenticationManager 处理认证: AuthenticationManager 的 authenticate 方法会将 UsernamePasswordAuthenticationToken 传递给一个 AuthenticationProvider。Spring Security 默认使用 DaoAuthenticationProvider 作为默认的 AuthenticationProvider
- DaoAuthenticationProvider 处理认证: DaoAuthenticationProvider 是一个用于处理基于数据库的身份验证的认证提供者。在 DaoAuthenticationProvider 中,密码的比较是由 PasswordEncoder 来完成的。DaoAuthenticationProvider 会调用 UserDetailsService 的 loadUserByUsername 方法获取 UserDetails,然后比较输入的密码和从数据库中加载的密码
- 密码比较: DaoAuthenticationProvider 会通过 PasswordEncoder 比较 UsernamePasswordAuthenticationToken 中的明文密码和 UserDetails 中的加密密码(例如,BCryptPasswordEncoder)。这里是比较的关键部分:
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
// 获取数据库中的用户信息
UserDetails userDetails = this.getUserDetailsService().loadUserByUsername(token.getName());
// 获取存储在数据库中的密码(加密后的)
String password = userDetails.getPassword();
// 获取输入的密码
String presentedPassword = (String) token.getCredentials();
// 使用 PasswordEncoder 进行密码比较
if (!passwordEncoder.matches(presentedPassword, password)) {
throw new BadCredentialsException("密码不正确");
}
return new UsernamePasswordAuthenticationToken(userDetails, password, userDetails.getAuthorities());
}
- 认证成功: 如果密码验证通过,DaoAuthenticationProvider 会将认证通过的 Authentication 对象返回,并且认证流程继续执行,最终进入 AuthenticationSuccessHandler。
那如何配置密码加解密方式了就是配置文件中的下面这个:
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // 配置加密算法
}
上面的这种是禁用表单登录的方式 开启表单的方式也很简单
package com.aihe.customer.auth.config;
import com.aihe.customer.auth.handler.CustomerAuthenticationFailureHandler;
import com.aihe.customer.auth.handler.CustomerAuthenticationSuccessHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* @author 在下陈某
* @package com.aihe.customer.auth.config
* @description TODO
* @date 2024/11/25 19:59
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final CustomerAuthenticationFailureHandler customerAuthenticationFailureHandler;
private final CustomerAuthenticationSuccessHandler customerAuthenticationSuccessHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 基于 token,所以不需要 CSRF 防护
.csrf(AbstractHttpConfigurer::disable)
// 基于 token,所以不需要 session
.sessionManagement(AbstractHttpConfigurer::disable)
// 禁用基本身份验证
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(formLogin -> formLogin
.loginProcessingUrl("/account/login") // 登录请求接口
.failureHandler(customerAuthenticationFailureHandler) // 自定义失败处理器
.successHandler(customerAuthenticationSuccessHandler) // 自定义成功处理器
.permitAll()
)
// 禁用缓存
.headers(AbstractHttpConfigurer::disable)
.addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
// 登录
.authorizeHttpRequests(authorize -> authorize
// 允许访问登录和注册接口
.requestMatchers(
"/account/login",
"/account/register",
"/swagger-resources/**",
"/webjars/**",
"/v3/**",
"/doc.html",
"/favicon.ico"
).permitAll()
// 其他请求需要身份验证
.anyRequest().authenticated()
);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
如果这样就要按照前面所说的那样来操作了… 搞完收工 是不是很简单
一款免费的在线文档格式转换工具在线文档转换