SpringSecurity自定义登录
适用于5.7.5及6.X版本,以密码账号登录方式通过过滤器验证方式实现
先看看大致流程:
-
- 请求经过用户名密码认证的过滤器,在过滤器中获取用户名,密码,验证码等参数并封装成Authentication对象,然后调用安全管理器进行认证(需要我们写自己的过滤器和Authentication实现类);
-
- 安全管理器会遍历配置好的认证逻辑集合(AuthenticationProvider),通过AuthenticationProvider的supports方法找到合适的AuthenticationProvider进行认证的操作(SpringSecurity管理,不需要我们写)
-
- 安全管理器找到合适的认证AuthenticationProvider后会执行他的authenticate方法,这个方法就是用来判断是否通过认证的,通过则返回Authentication对象即可(需要实现AuthenticationProvider接口,重写里面的方法)
1. 重写UserDetails(相当于用户实体类)
/**
* 自定义UserDetails,用于Spring Security的认证
* @author tanglang
* @Date 2024-04-02
*/
@Data
public class LoginUser implements UserDetails {
private User user;
private Collection<? extends GrantedAuthority> authorities;
private String token;
public LoginUser(User user, Collection<? extends GrantedAuthority> authorities) {
this.user = user;
this.authorities = authorities;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@JSONField(serialize = false)
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@JSONField(serialize = false)
@Override
public boolean isAccountNonExpired() {
return !user.getIsExpired();
}
@JSONField(serialize = false)
@Override
public boolean isAccountNonLocked() {
return !user.getIsLocked();
}
@JSONField(serialize = false)
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@JSONField(serialize = false)
@Override
public boolean isEnabled() {
return user.getIsActive();
}
}
2. 重写AbstractAuthenticationToken
主要是用来存储认证信息的(全部认证都会用到它),也要根据他的当前子类型判断用哪个AuthenticationProvider类来进行认证 列如:UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication)
/**
* 注意我这个类名与SpringSecurity自带的有重名,后续不要引入错包了
*/
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
private final Object principal;
private Object credentials;
/**
* 验证码
*/
private String captcha;
public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true);
}
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(false);
}
public static UsernamePasswordAuthenticationToken unauthenticated(Object principal, Object credentials) {
return new UsernamePasswordAuthenticationToken(principal, credentials);
}
public static UsernamePasswordAuthenticationToken authenticated(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
return new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
}
@Override
public Object getCredentials() {
return credentials;
}
@Override
public Object getPrincipal() {
return principal;
}
public String getCaptcha() {
return captcha;
}
public void setCaptcha(String captcha) {
this.captcha = captcha;
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
this.credentials = null;
}
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
if (isAuthenticated) {
throw new IllegalArgumentException("无法将此令牌设置为受信任,只能使用构造函数,该构造函数接受GrantedAuthority列表。");
}
super.setAuthenticated(false);
}
}
3. 重写AuthenticationProvider
重写里面两个方法,authenticate方法就是用来验证用户是否正确的,supports方法是用来给认证管理器(ProviderManager)来决定使用你这个AuthenticationProvider类来进行认证的,返回true这表示使用这个类来处理认证
/**
* 用户名密码认证提供者
* @author tanglang
* @date 2024-04-16
*/
@Component
public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider {
@Autowired
private RedisCache redisCache;
/**
* 这个是查询用户信息的服务类,自行实现
*/
@Autowired
private SecurityService securityService;
/**
* 密码加密器,用于对密码进行加密和密码验证
*/
private PasswordEncoder bCryptPasswordEncoder;
/**
* 用户状态检查器,用于判断用户是否锁定、过期等,抛出AccountStatusException的子类异常,自行实现
*/
private final UserDetailsChecker userDetailsChecker = new AccountStatusUserDetailsChecker();
/**
* 尝试使用用户名和密码对用户进行身份验证。
* @param authentication the authentication request object.
* @return 包含凭据的完全经过身份验证的对象。
* @throws AuthenticationException 认证失败异常。
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
// TODO: 编写自定义业务逻辑,验证用户名和密码是否正确(大致写的,仅供参考)
// 这里写验证码、用户名、密码验证的逻辑,都知道怎么写吧
// if (StringUtils.isEmpty(token.getCaptcha())
// || !token.getCaptcha().equals(redisCache.getCacheObject(CacheConstants.LOGIN_CAPTCHA_KEY + "预留key"))) { //TODO 暂时没加本次验证码在redis中的key,自己加上哦
// throw new BadCredentialsException("验证码错误");
// }
// 验证码验证成功后删除验证码
// redisCache.deleteObject(CacheConstants.LOGIN_CAPTCHA_KEY + "预留key");
if (token.getName() == null || token.getCredentials() == null) {
throw new BadCredentialsException("用户名或密码为空");
}
// 调用业务逻辑,查询用户信息
UserDetails userDetails = securityService.loadUserByUsername(token.getName());
// 验证密码是否正确
if (!bCryptPasswordEncoder.matches(token.getCredentials().toString(), userDetails.getPassword())) {
throw new BadCredentialsException("用户名或密码错误");
}
// 验证用户状态 如是否锁定,是否过期等
userDetailsChecker.check(userDetails);
// 认证成功,返回Authentication对象
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, token.getCredentials(), userDetails.getAuthorities());
authenticationToken.setDetails(token.getDetails());
return authenticationToken;
}
/**
* ProviderManager会调用这个方法判断是否使用此Provider进行认证,返回true则使用此Provider进行认证,false则跳过此Provider。
* 此处表示UsernamePasswordAuthenticationToken类型的认证请求对象才会使用此Provider进行认证。
* @param authentication 身份验证请求对象。
* @return true则使用此Provider进行认证,false则跳过此Provider。
*/
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
public void setBCryptPasswordEncoder(PasswordEncoder passwordEncoder) {
this.bCryptPasswordEncoder = passwordEncoder;
}
}
4. 重写AbstractAuthenticationProcessingFilter(可选项)
继承此过滤器,我们就只需实现attemptAuthentication方法即可,他会帮我们实现管理安全管理器,路径验证,捕获认证异常并调用认证成功或失败的后续操作,这里可以实现自定义登录接口和获取登录相关数据如username,password等,该功能也可以直接写个controller(此方法别忘了需要配置放行url)实现
/**
* 用于处理用户名密码登录请求的过滤器。
* @author tanglang
* @date 2024-04-16
*/
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
/**
* 此筛选器的默认请求匹配器。
*/
private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/user/login", "POST");
/**
* 请求参数名,用于在请求对象中获取用户名。
*/
public static final String USERNAME_PARAM = "username";
/**
* 请求参数名,用于在请求对象中获取密码。
*/
public static final String PASSWORD_PARAM = "password";
/**
* 请求参数名,用于在请求对象中获取验证码。
*/
public static final String CAPTCHA_PARAM = "captcha";
/**
* 是否仅支持POST请求
*/
private boolean postOnly = true;
public UsernamePasswordAuthenticationFilter() {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
// 如果仅支持POST请求,并且不是POST请求,则抛出异常
if (postOnly && !"POST".equals(request.getMethod())) {
throw new AuthenticationServiceException("不支持身份验证方法: " + request.getMethod());
}
// 获取用户名、密码、验证码
String username = obtainUsername(request);
username = (username != null) ? username.trim() : "";
String password = obtainPassword(request);
password = (password != null) ? password : "";
String captcha = obtainCaptcha(request);
captcha = (captcha != null) ? captcha : "";
// 创建UsernamePasswordAuthenticationToken,存入用户名、密码并设置验证码
UsernamePasswordAuthenticationToken authenticationToken = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
authenticationToken.setCaptcha(captcha);
// 存储细节,可以是IP地址、设备信息等
authenticationToken.setDetails(this.authenticationDetailsSource.buildDetails(request));
// 调用AuthenticationManager进行身份验证
// AuthenticationManager就会根据他管理的provider的supports方法来判断是否支持该UsernamePasswordAuthenticationToken
return this.getAuthenticationManager().authenticate(authenticationToken);
}
@Nullable
private String obtainUsername(HttpServletRequest request) {
return request.getParameter(USERNAME_PARAM);
}
@Nullable
private String obtainPassword(HttpServletRequest request) {
return request.getParameter(PASSWORD_PARAM);
}
@Nullable
private String obtainCaptcha(HttpServletRequest request) {
return request.getParameter(CAPTCHA_PARAM);
}
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
}
5. 配置SpringSecurity相关配置
/**
* 安全配置类
* @author tanglang
* @Date 2024-04-16
*/
@Configuration
public class SecurityConfigTest {
/**
* 注入AuthenticationManagerBuilder,用于配置认证提供者
*/
@Autowired
private AuthenticationManagerBuilder authenticationManagerBuilder;
/**
* 注入UsernamePasswordAuthenticationProvider,用于提供用户密码认证
*/
@Autowired
private UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider;
/**
* 注入自定义的AuthenticationSuccessHandler
*/
@Autowired
private AuthenticationSuccessHandler authenticationSuccessHandler;
/**
* 注入自定义的AuthenticationFailureHandler
*/
@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;
/**
* 配置密码加密方式
* 定义SpringSecurity的密码加密方式为BCryptPasswordEncoder
* 比如在UsernamePasswordAuthenticationProvider就有用到他来对密码进行加密验证
*/
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
/**
* 配置认证管理器
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration, BCryptPasswordEncoder bCryptPasswordEncoder) throws Exception {
//为安全管理器配置认证提供者,加入自定义的UsernamePasswordAuthenticationProvider,他使用list保存provider的所以可以添加多个provider
//为usernamePasswordAuthenticationProvider设置密码验证器
usernamePasswordAuthenticationProvider.setBCryptPasswordEncoder(bCryptPasswordEncoder);
authenticationManagerBuilder.authenticationProvider(usernamePasswordAuthenticationProvider);
//可以继续添加其他的provider
// authenticationManagerBuilder.authenticationProvider(phoneNumberAuthenticationProvider);
return authenticationConfiguration.getAuthenticationManager();
}
/**
* 配置认证过滤器
*/
@Bean
public UsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
UsernamePasswordAuthenticationFilter authenticationFilter = new UsernamePasswordAuthenticationFilter();
//设置认证管理器
authenticationFilter.setAuthenticationManager(authenticationManager);
//设置登录成功和失败的处理器,分别对应AuthenticationSuccessHandler和AuthenticationFailureHandler,自行实现里面的方法就行,认证成功或失败时AbstractAuthenticationProcessingFilter会帮我们调用这两个处理器
authenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
authenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler);
//改变登录页面的url,过滤器写了默认的,就可以不用加
// authenticationFilter.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/user/login", "POST"));
//必须是post请求
authenticationFilter.setPostOnly(true);
return authenticationFilter;
}
/**
* 配置安全过滤器
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, UsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter, AuthenticationManager authenticationManager) throws Exception {
httpSecurity
.authorizeHttpRequests(
registry ->
registry
//放行的请求
.requestMatchers(new AntPathRequestMatcher("/user/captchaImage", "GET"), new AntPathRequestMatcher("/favicon.ico", "GET")).permitAll()
.anyRequest()
.authenticated()
)
// 将我们自己的usernamePasswordAuthenticationFilter添加到SpringSecurity的过滤器链中
.addFilterBefore(usernamePasswordAuthenticationFilter, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class)
// 自定义的认证管理器
.authenticationManager(authenticationManager)
// 禁用表单登录
.formLogin(AbstractHttpConfigurer::disable)
// 禁用httpBasic登录
.httpBasic(AbstractHttpConfigurer::disable)
// 禁用rememberMe
.rememberMe(AbstractHttpConfigurer::disable)
// 关闭csrf
.csrf(AbstractHttpConfigurer::disable)
// 允许跨域请求
.cors(withDefaults())
;
return httpSecurity.build();
}
}
以上便实现了SpringSecurity的自定义登录方式,通过发起post /user/login 请求进行登录测试,可以发现登录成功之后依然不能访问受保护的资源。
一般情况,我们需要将用户信息存入redis中并返回jwt给前端,之后前端的每次请求都携带这个token,后端在过滤器中解析jwt,从redis中获取用户信息并交个SpringSecurity管理。
生成jwt以及解析
1. 生成jwt
我们选择在登陆成功之后执行的handler中生成jwt,即实现上文有提到的AuthenticationSuccessHandler接口
/**
* 自定义登录成功处理器
* @author tanglang
* @Date 2024-04-02
*/
@Component
@Slf4j
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
/**
* jwt的工具类,自行实现,主要就是将用户存入redis中并生成uuid作为缓存的key,将key存入jwt中
*/
@Autowired
private TokenService tokenService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("登录成功:{}", authentication.getName());
// 保存用户权限
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
loginUser.setAuthorities(authentication.getAuthorities());
// 生成JWT Token
String token = tokenService.createToken(loginUser);
log.info("生成JWT Token:{}", token);
ServletUtils.writerResponse(response, R.success("登录成功", token));
}
}
2. 自定义JWT认证过滤器
/**
* 自定义JWT认证过滤器
* @author tanglang
* @date 2024-04-08
*/
@Component
public class CustomJwtAuthenticationFilter extends OncePerRequestFilter {
/**
* 自行实现,主要就是解析jwt,将用户从redis中获取出来
*/
@Autowired
private TokenService tokenService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
LoginUser loginUser = tokenService.getLoginUser(request);
if(loginUser != null && SecurityContextHolder.getContext().getAuthentication() == null){
//TODO 可以加个续期token的逻辑 暂时没写
//...
//认证
UsernamePasswordAuthenticationToken authenticationToken = UsernamePasswordAuthenticationToken.authenticated(loginUser, null, loginUser.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
filterChain.doFilter(request, response);
}
}