文章目录
1.环境
SpringBoot3.2.8、JDK17、SpringSecurity6.1.11、Redis6.0.8
认证涉及:
- 验证码过滤器及用户名密码认证
- 密码加密及密码自动升级更新
- 记住我
- 并发会话
- 会话存储
- csrf防护
- cros跨域(未配置,有兴趣自行实现)
- 统一异常处理
2.pom
xml
<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.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<!-- <dependency>-->
<!-- <groupId>org.springframework.boot</groupId>-->
<!-- <artifactId>spring-boot-starter-thymeleaf</artifactId>-->
<!-- </dependency>-->
<!--springboot3.0接入mybatisPlus的依赖-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.5</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.15</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.51</version>
</dependency>
<!--redis会话存储相关-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>3.2.4</version>
</dependency>
</dependencies>
3.application.yml
yml
server:
port: 8080
servlet:
session:
timeout: 30
spring:
security:
user:
name: root
password: 123456
thymeleaf:
cache: false
prefix: classpath:/templates/
suffix: .html
datasource:
url: jdbc:mysql://192.168.159.100:3306/ssm?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
# 最大连接池数量
maximum-pool-size: 20
# 最小空闲线程数量
minimum-idle: 10
# 配置获取连接等待超时的时间
connectionTimeout: 30000
# 校验超时时间
validationTimeout: 5000
# 空闲连接存活最大时间,默认10分钟
idleTimeout: 600000
# 此属性控制池中连接的最长生命周期,值0表示无限生命周期,默认30分钟
maxLifetime: 1800000
# 连接测试query(配置检测连接是否有效)
connectionTestQuery: SELECT 1
type: com.zaxxer.hikari.HikariDataSource
data:
redis:
port: 6379
host: 192.168.159.100
logging:
level:
org:
springframework:
security: TRACE
mybatis-plus:
mapper-locations: classpath:com.linging.mapper/*Mapper.xml
4.SpringSecurity6主配置类
java
@Configuration
@EnableWebSecurity // 高版本使用注解
@EnableRedisIndexedHttpSession // 注入FindByIndexNameSessionRepository,实现redis持久化会话
public class SecurityConfig {
@Resource
private MyUserDetailService myUserDetailService;
@Resource
private DataSource dataSource;
@Resource
private FindByIndexNameSessionRepository<? extends Session> sessionRepository;
public PasswordEncoder passwordEncoder(){
// 该类的升级算法由springSecurity指定,无法直接修改,不过可以复制出来,自行修改
//PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
String encodingId = "MD5"; //指定升级的加密算法
Map<String, PasswordEncoder> encoders = new HashMap();
encoders.put("bcrypt", new BCryptPasswordEncoder());
encoders.put("ldap", new LdapShaPasswordEncoder());
encoders.put("MD4", new Md4PasswordEncoder());
encoders.put("MD5", new MessageDigestPasswordEncoder("MD5"));
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5());
encoders.put("pbkdf2@SpringSecurity_v5_8", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v4_1());
encoders.put("scrypt@SpringSecurity_v5_8", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("SHA-1", new MessageDigestPasswordEncoder("SHA-1"));
encoders.put("SHA-256", new MessageDigestPasswordEncoder("SHA-256"));
encoders.put("sha256", new StandardPasswordEncoder());
encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2());
encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
// SpringSecurity默认的加密代理就是DelegatingPasswordEncoder
return new DelegatingPasswordEncoder(encodingId, encoders);
}
/**
* 全局配置AuthenticationManager
* 定义AuthenticationManager,加入两种AuthenticationProvider
*/
@Bean
public AuthenticationManager authenticationManager() {
// 保留原来账号密码登录的AuthenticationProvider
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(myUserDetailService); // 指定用户认证的服务
daoAuthenticationProvider.setUserDetailsPasswordService(myUserDetailService); // 指定加密密码自动更新的服务
//daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
//daoAuthenticationProvider.setPasswordEncoder(bCryptPasswordEncoder);
ProviderManager providerManager = new ProviderManager(daoAuthenticationProvider);
providerManager.setAuthenticationEventPublisher(new DefaultAuthenticationEventPublisher());
return providerManager;
}
// 这种加密方式,只能指定唯一的加密算法,无法自动升级
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder(){
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
return passwordEncoder;
}
// token持久化
@Bean
public PersistentTokenRepository myPersistentTokenRepository(){
JdbcTokenRepositoryImpl repository = new JdbcTokenRepositoryImpl();
repository.setDataSource(dataSource);
//repository.setCreateTableOnStartup(true); //启动创建表,mysql创建语句有问题,可以重写或者手动创建
/**
create table persistent_logins (
username varchar(64) not null,
series varchar(64) primary key,
token varchar(64) not null,
last_used timestamp NOT null DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
*/
return repository;
}
// 记住我持久化实现逻辑
@Bean
public RememberMeServices rememberMeServices(){
// myUserDetailService 记住我自动登录逻辑
// new InMemoryTokenRepositoryImpl() 记住我持久化实现,内存
return new PersistentTokenBasedRememberMeServices(
UUID.randomUUID().toString(),
myUserDetailService, // 指定记住我时的认证的调用服务
myPersistentTokenRepository()); // 记住我token持久化的实现
}
// 验证码+用户密码认证 过滤器
@Bean
public AuthCodeFilter authCodeFilter(){
AuthCodeFilter authCodeFilter = new AuthCodeFilter();
authCodeFilter.setFilterProcessesUrl("/doLogin");
authCodeFilter.setAuthenticationManager(authenticationManager());
authCodeFilter.setAuthenticationSuccessHandler(new MyAuthenticationSuccessHandler());
authCodeFilter.setAuthenticationFailureHandler(new MyAuthenticationFailureHandler());
// 记住我,需配置两个地方
authCodeFilter.setRememberMeServices(rememberMeServices());
// springSecurity6需要手动设置,不然默认使用RequestAttributeSecurityContextRepository
authCodeFilter.setSecurityContextRepository(securityContextRepository());
// 会话策略
authCodeFilter.setSessionAuthenticationStrategy(compositeSessionAuthenticationStrategy());
return authCodeFilter;
}
// 代理认证上下文,DelegatingSecurityContextRepository,认证后的用户信息存放在哪里
@Bean
public SecurityContextRepository securityContextRepository(){
return new DelegatingSecurityContextRepository(
new RequestAttributeSecurityContextRepository(),
new HttpSessionSecurityContextRepository()
);
}
// 返回会话策略代理
@Bean
public CompositeSessionAuthenticationStrategy compositeSessionAuthenticationStrategy(){
List<SessionAuthenticationStrategy> strategies = new ArrayList<>();
// 顺序:先 并发控制会话 再 注册会话,顺序不可变
strategies.add(controlAuthenticationStrategy());
strategies.add(changeSessionIdAuthenticationStrategy());
strategies.add(registerSessionAuthenticationStrategy());
// 多个会话策略构成,按顺序执行
return new CompositeSessionAuthenticationStrategy(strategies);
}
// 并发会话策略
@Bean
public ConcurrentSessionControlAuthenticationStrategy controlAuthenticationStrategy(){
ConcurrentSessionControlAuthenticationStrategy authenticationStrategy =
new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry());
authenticationStrategy.setMaximumSessions(1);
// ExceptionIfMaximumExceeded 默认false,当超过最大session数时
// true: 不允许新session, 保持旧session
// false: 销毁旧session, 新session生效
authenticationStrategy.setExceptionIfMaximumExceeded(false);
return authenticationStrategy;
}
// 会话注册策略
@Bean
public RegisterSessionAuthenticationStrategy registerSessionAuthenticationStrategy(){
return new RegisterSessionAuthenticationStrategy(sessionRegistry());
}
// 默认会话策略
@Bean
public ChangeSessionIdAuthenticationStrategy changeSessionIdAuthenticationStrategy(){
return new ChangeSessionIdAuthenticationStrategy();
}
// 在线会话存储
@Bean
public SessionRegistry sessionRegistry(){
// new SessionRegistryImpl(); 实现为内存,使用ConcurrentHashMap维护
// 使用redis来维护session
return new SpringSessionBackedSessionRegistry(sessionRepository);
}
// 会话事件发布,监听会话的创建、销毁、并更,发布对应事件
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
// 并发会话过滤器
@Bean
public ConcurrentSessionFilter concurrentSessionFilter(){
return new ConcurrentSessionFilter(sessionRegistry(), strategy -> {
HttpServletResponse response = strategy.getResponse();
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.getWriter().write("已在另一个地方登录");
});
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(authz -> authz
//.requestMatchers("/index/**", "/login.html", "/error.html").permitAll() // 放行的路径,不认证就可以访问
.anyRequest().authenticated() // 剩余的其他路径都要认证
)
// .formLogin(configurer -> configurer
// .loginProcessingUrl("/doLogin") //登录接口
// .successHandler(new MyAuthenticationSuccessHandler()) // 登录成功,返回json
// .failureHandler(new MyAuthenticationFailureHandler()) //登录失败,返回json
// )
.addFilterAt(authCodeFilter(), UsernamePasswordAuthenticationFilter.class) // 替换过滤器
.addFilterAt(concurrentSessionFilter(), ConcurrentSessionFilter.class)
.exceptionHandling(ex -> {
ex.authenticationEntryPoint((request, response, authException) -> {
// 当未认证的用户尝试访问受保护资源时的处理逻辑
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("未认证");
}).accessDeniedHandler((request, response, authException) -> {
//当已认证的用户尝试访问其无权限访问的资源时,会触发访问拒绝处理器
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("权限不足");
});
})
//.authenticationManager(authenticationManager) //本地配置
//.userDetailsService(myUserDetailService) //可以不注入,只要容器中有实例,security会自动注入
.rememberMe(cus -> { //记住我,需配置两个地方
cus.rememberMeServices(rememberMeServices());
})
.securityContext(cus -> {
cus.securityContextRepository(securityContextRepository()).requireExplicitSave(true);
})
.httpBasic(withDefaults())
// .csrf(cus -> { // 开启csrf跨站脚本攻击防护
// cus.csrfTokenRepository(new CookieCsrfTokenRepository()) // csrftoken保存在cookie中
// .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()); // csrfToken设置请求处理器,默认为XorCsrfTokenRequestAttributeHandler,此为加密token,需要额外处理传递的csrftoken
// });
.csrf(AbstractHttpConfigurer::disable); //测试暂时关闭
return http.build();
}
}
5.其他配置类
5.1.MyUserDetailService
java
/**
* 自定义用户认证介质UserDetailsService
* 自定义用户密码自动更新介质UserDetailsPasswordService
*/
@Component
public class MyUserDetailService implements UserDetailsService, UserDetailsPasswordService {
@Resource
private UserService userService;
@Resource
private RoleService roleService;
/**
* 加载用户名称
* @param username
* @return
* @throws UsernameNotFoundException
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userService.getOne(new QueryWrapper<User>().eq("username", username), false);
if(user == null){
throw new UsernameNotFoundException("用户名不正确");
}
MyUser myUser = new MyUser();
BeanUtils.copyProperties(user, myUser);
List<Role> roleList = roleService.getByUserId(user.getId());
myUser.setRoles(roleList);
return myUser;
}
/**
* 当加密策略变更时,会更新数据库的密码为新密码
* @param user
* @param newPassword
* @return
*/
@Override
public UserDetails updatePassword(UserDetails user, String newPassword) {
String username = user.getUsername();
boolean update = userService.update(new UpdateWrapper<User>()
.set("password", newPassword)
.eq("username", username));
if(update){
MyUser myUser = (MyUser) user;
myUser.setPassword(newPassword);
}
return user;
}
}
5.2.MyAuthenticationSuccessHandler
java
/**
* 自定义认证成功json返回
*/
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
Map<Object, Object> map = new HashMap<>();
map.put("msg", "登录成功");
map.put("code", 200);
map.put("authentication", authentication);
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(new ObjectMapper().writeValueAsString(map));
}
}
5.3.MyAuthenticationFailureHandler
java
/**
* 自定义认证失败json返回
*/
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
Map<Object, Object> map = new HashMap<>();
map.put("msg", "出现错误");
map.put("code", 500);
map.put("exception", exception.getMessage());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(new ObjectMapper().writeValueAsString(map));
}
}
5.4.VerifyCodeAuthenticationException
java
/**
* 验证码异常,集成AuthenticationException,否则无法被框架捕获
*/
public class VerifyCodeAuthenticationException extends AuthenticationException {
public VerifyCodeAuthenticationException(String msg, Throwable cause) {
super(msg, cause);
}
public VerifyCodeAuthenticationException(String msg) {
super(msg);
}
}
5.5.AuthCodeFilter
java
/**
* 验证码+用户名和密码过滤器
*/
@Component
public class AuthCodeFilter extends UsernamePasswordAuthenticationFilter {
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String authCode = request.getParameter("authCode");
if(authCode == null){
throw new VerifyCodeAuthenticationException("验证码不能为空");
}
if(!authCode.equalsIgnoreCase(getAuthCode(obtainUsername(request)))){
throw new VerifyCodeAuthenticationException("验证码错误");
}
return super.attemptAuthentication(request,response);
}
private String getAuthCode(String username) {
return "123456";
}
}
5.6.User
java
@Data
public class User implements Serializable {
private static final long serialVersionUID = 1L;
/**
* id
*/
@TableId(value = "id", type = IdType.AUTO)
protected Integer id;
/**
* 账号
*/
protected String username;
/**
* 密码
*/
protected String password;
/**
* 是否启用
*/
protected boolean enabled;
/**
* 账号是否过期
*/
protected boolean accountNonExpired;
/**
* 账号是否锁定
*/
protected boolean accountNonLocked;
/**
* 密码是否过期
*/
protected boolean credentialsNonExpired;
}
5.7.MyUser
java
@Data
public class MyUser extends User implements UserDetails {
private List<Role> roles = new ArrayList<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return roles.stream().map(role ->
new SimpleGrantedAuthority(role.getName())).collect(Collectors.toSet());
}
@Override
public boolean isAccountNonExpired() {
return super.isAccountNonExpired();
}
@Override
public boolean isAccountNonLocked() {
return super.isAccountNonLocked();
}
@Override
public boolean isCredentialsNonExpired() {
return super.isCredentialsNonExpired();
}
@Override
public boolean isEnabled() {
return super.isEnabled();
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(super.username, user.getUsername());
}
@Override
public int hashCode() {
return Objects.hashCode(super.username);
}
}
6.测试
登录接口,此处登录参数使用表单的方式,如要使用json传递参数,可对上面AuthCodeFilter extends UsernamePasswordAuthenticationFilter中将UsernamePasswordAuthenticationFilter中获取参数的方式进行重写,也是比较简单的,可自行实现。
持久化token表结构:
user表结构:
role表结构:
user_role表结构: