Spring Security 基础

1、开发环境配置

2、认证

3、其他配置

4、授权

*5、内部机制探究

一、开发环境配置

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-web</artifactId>
    <version>6.1.1</version>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-config</artifactId>
    <version>6.1.1</version>
</dependency>

初始化器:

public class SecurityInitializer extends AbstractSecurityWebApplicationInitializer {
    //不用重写任何内容
      //这里实际上会自动注册一个Filter,SpringSecurity底层就是依靠N个过滤器实现的
}

配置类,添加@EnableWebSecurity注解开启WebSecurity相关功能:

@Configuration
@EnableWebSecurity   //开启WebSecurity相关功能
public class SecurityConfiguration {
        
}

在根容器中添加配置文件:

public class SecurityInitializer extends AbstractSecurityWebApplicationInitializer {
    @Override
    protected Class<?>[] getRootConfigClasses() {
        return new Class[]{MainConfiguration.class, SecurityConfiguration.class};
    }
}

二、认证

基于内存验证

只需要在Security配置类中注册一个Bean即可:

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

    @Bean   //UserDetailsService就是获取用户信息的服务
    public UserDetailsService userDetailsService() {
          //每一个UserDetails就代表一个用户信息,其中包含用户的用户名和密码以及角色
        UserDetails user = User.withDefaultPasswordEncoder()
                .username("user")
                .password("password")
                .roles("USER")  //角色目前我们不需要关心,随便写就行,后面会专门讲解
                .build();
        UserDetails admin = User.withDefaultPasswordEncoder()
                .username("admin")
                .password("password")
                .roles("ADMIN", "USER")
                .build();
        return new InMemoryUserDetailsManager(user, admin); 
          //创建一个基于内存的用户信息管理器作为UserDetailsService
    }
}

但是上面代码中,明文密码容易泄露。

通过将用户的密码进行Hash值计算,计算出来的结果一般是单向的,无法还原为原文,如果需要验证是否与此密码一致,那么需要以同样的方式加密再比较两个Hash值是否一致,这样就很好的保证了用户密码的安全性。

我们在配置用户信息的时候,可以使用官方提供的BCrypt加密工具:

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
    
      //这里将BCryptPasswordEncoder直接注册为Bean,Security会自动进行选择
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    public UserDetailsService userDetailsService(PasswordEncoder encoder) {
        UserDetails user = User
                .withUsername("user")
                .password(encoder.encode("password"))   //这里将密码进行加密后存储
                .roles("USER")
                .build();
          System.out.println(encoder.encode("password"));  //一会观察一下加密出来之后的密码长啥样
        UserDetails admin = User
                .withUsername("admin")
                .password(encoder.encode("password"))   //这里将密码进行加密后存储
                .roles("ADMIN", "USER")
                .build();
        return new InMemoryUserDetailsManager(user, admin);
    }
}

注:上面的参数一般写在application.yml配置中

csrf防护

SpringSecurity自带了csrf防护,需求我们在POST请求中携带页面中的csrfToken才可以,否则一律进行拦截操作。

axios

在页面中嵌入:

<input type="text" th:id="${_csrf.getParameterName()}" th:value="${_csrf.token}" hidden>

发送请求时携带_csrf

function pay() {
    const account = document.getElementById("account").value
    const csrf = document.getElementById("_csrf").value
    axios.post('/mvc/pay', {
        account: account,
        _csrf: csrf   //携带此信息即可,否则会被拦截
    }, {
      ...
form
<form action="/xxxx" method="post">
      ...
    <input type="text" th:name="${_csrf.getParameterName()}" th:value="${_csrf.token}" hidden>
      ...
</form>

注:现在的浏览器已经很安全了,完全不需要使用自带的csrf防护。

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

       ...
      
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                ...
                //以下是csrf相关配置
                .csrf(conf -> {
                    conf.disable();   //此方法可以直接关闭全部的csrf校验,一步到位
                    conf.ignoringRequestMatchers("/xxx/**");   //此方法可以根据情况忽略某些地址的csrf校验
                })
                .build();
    }
}

基于数据库验证

官方默认提供了可以直接使用的用户和权限表设计,直接使用即可:

create table users(username varchar(50) not null primary key,password varchar(500) not null,enabled boolean not null);
create table authorities (username varchar(50) not null,authority varchar(50) not null,constraint fk_authorities_users foreign key(username) references users(username));
create unique index ix_auth_username on authorities (username,authority);
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

    @Bean PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    public DataSource dataSource(){
          //数据源配置
        return new PooledDataSource("com.mysql.cj.jdbc.Driver",
                "jdbc:mysql://localhost:3306/test", "root", "123456");
    }

    @Bean
    public UserDetailsService userDetailsService(DataSource dataSource,
                                                 PasswordEncoder encoder) {
        JdbcUserDetailsManager manager = new JdbcUserDetailsManager(dataSource);
          //仅首次启动时创建一个新的用户用于测试,后续无需创建
               manager.createUser(User.withUsername("user")
                      .password(encoder.encode("password")).roles("USER").build());
        return manager;
    }
}

基于内存的InMemoryUserDetailsManager和基于数据库的JdbcUserDetailsManager,都是实现自UserDetailsManager接口。

// 源码:
public interface UserDetailsManager extends UserDetailsService {
    
  //创建一个新的用户
    void createUser(UserDetails user);

  //更新用户信息
    void updateUser(UserDetails user);

  //删除用户
    void deleteUser(String username);

  //修改用户密码
    void changePassword(String oldPassword, String newPassword);

  //判断是否存在指定用户
    boolean userExists(String username);
}

在SecurityConfiguration中,提供JdbcUserDetailsManager,并设置AuthenticationManager:

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

    ...

    //手动创建一个AuthenticationManager用于处理密码校验
    private AuthenticationManager authenticationManager(UserDetailsManager manager,
                                                        PasswordEncoder encoder){
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(manager);
        provider.setPasswordEncoder(encoder);
        return new ProviderManager(provider);
    }

    @Bean
    public UserDetailsManager userDetailsService(DataSource dataSource,
                                                 PasswordEncoder encoder) throws Exception {
        JdbcUserDetailsManager manager = new JdbcUserDetailsManager(dataSource);
          //为UserDetailsManager设置AuthenticationManager即可开启重置密码的时的校验
        manager.setAuthenticationManager(authenticationManager(manager, encoder));
        return manager;
    }
}

接口:

@ResponseBody
@PostMapping("/change-password")
public JSONObject changePassword(@RequestParam String oldPassword,
                                 @RequestParam String newPassword) {
    manager.changePassword(oldPassword, encoder.encode(newPassword));
    JSONObject object = new JSONObject();
    object.put("success", true);
    return object;
}

前端:

<div>
    <label>
        修改密码:
        <input type="text" id="oldPassword" placeholder="旧密码"/>
        <input type="text" id="newPassword" placeholder="新密码"/>
    </label>
    <button onclick="change()">修改密码</button>
</div>
function change() {
    const oldPassword = document.getElementById("oldPassword").value
    const newPassword = document.getElementById("newPassword").value
    const csrf = document.getElementById("_csrf").value
    axios.post('/mvc/change-password', {
        oldPassword: oldPassword,
        newPassword: newPassword,
        _csrf: csrf
    }, {
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded'
        }
    }).then(({data}) => {
        alert(data.success ? "密码修改成功" : "密码修改失败,请检查原密码是否正确")
    })
}

自定义验证

自行实现UserDetailsService或是功能更完善的UserDetailsManager接口,这里我们选择前者进行实现:

@Service
public class AuthorizeService implements UserDetailsService {

    @Resource
    UserMapper mapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Account account = mapper.findUserByName(username);
        if(account == null)
            throw new UsernameNotFoundException("用户名或密码错误");
        return User
                .withUsername(username)
                .password(account.getPassword())
                .build();
    }
}

完整认证流程是这样的:

用户提交 username + password

Spring Security 调用 UserDetailsService.loadUserByUsername(username)

返回一个 UserDetails,其中包含:

  • username
  • password(数据库中存的加密密码)

接下来由 AuthenticationProvider(实现类DaoAuthenticationProvider) 做密码校验:

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
    
    // 这里注入 UserDetailsService
    private UserDetailsService userDetailsService;
    
    // 这里注入 PasswordEncoder
    private PasswordEncoder passwordEncoder;
    
    @Override
    protected void additionalAuthenticationChecks(
            UserDetails userDetails,
            UsernamePasswordAuthenticationToken authentication) {
        
        // 获取用户输入的密码
        String presentedPassword = authentication.getCredentials().toString();
        
        // 自动进行密码比对 ← 这是核心!
        if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
            throw new BadCredentialsException("密码错误");
        }
    }
}

三、其他配置

自定义登录界面

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

      ...
  
    //新版本全部采用lambda形式进行配置,无法再使用之前的and()方法进行连接了
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                //以下是验证请求拦截和放行配置
                .authorizeHttpRequests(auth -> {
                    auth.requestMatchers("/static/**").permitAll();   //将所有的静态资源放行,一定要添加在全部请求拦截之前
                    auth.anyRequest().authenticated();    //将所有请求全部拦截,一律需要验证
                })
                //以下是表单登录相关配置
                .formLogin(conf -> {
                    conf.loginPage("/login");   //将登录页设置为我们自己的登录页面
                    conf.loginProcessingUrl("/doLogin"); //登录表单提交的地址,可以自定义
                    conf.defaultSuccessUrl("/");   //登录成功后跳转的页面
                    conf.permitAll();    //将登录相关的地址放行,否则未登录的用户连登录界面都进不去
                      //用户名和密码的表单字段名称,不过默认就是这个,可以不配置,除非有特殊需求
                    conf.usernameParameter("username");
                    conf.passwordParameter("password");
                })
                //以下是退出登录相关配置
                .logout(conf -> {
                    conf.logoutUrl("/doLogout");   //退出登录地址,跟上面一样可自定义
                    conf.logoutSuccessUrl("/login"); //退出登录后跳转的地址,这里设为登录界面
                    conf.permitAll();
                })
                .build();
    }
}

登录表单:

<form action="doLogin" method="post">
        ...
  <input type="text" name="username" placeholder="Email Address" class="ad-input">
  ...
  <input type="password" name="password" placeholder="Password" class="ad-input">
  ...
  <input type="text" th:name="${_csrf.getParameterName()}" th:value="${_csrf.token}" hidden>
  <div class="ad-auth-btn">
     <button type="submit" class="ad-btn ad-login-member">Login</button>
  </div>
    ...
</form>

退出登录按钮:

<li>
   <form action="doLogout" method="post">
        <input type="text" th:name="${_csrf.getParameterName()}" th:value="${_csrf.token}" hidden>
        <button type="submit">
           <i class="fas fa-sign-out-alt"></i> logout
        </button>
   </form>
</li>

记住我功能

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

    ...

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                ...
                .rememberMe(conf -> {
                    conf.alwaysRemember(false);  //这里不要开启始终记住,我们需要配置为用户自行勾选
                    conf.rememberMeParameter("remember-me");   //记住我表单字段,默认就是这个,可以不配置
                    conf.rememberMeCookieName("xxxx");  //记住我设置的Cookie名字,也可以自定义,不过没必要
                })
                .build();
    }
}
<div class="ad-checkbox">
    <label>
        <input type="checkbox" name="remember-me" class="ad-checkbox">
        <span>Remember Me</span>
    </label>
</div>

这个Cookie信息的过期时间并不是仅会话,而是默认保存一段时间,因此,我们关闭浏览器后下次再次访问网站时,就不需要我们再次进行登录操作了,而是直接继续上一次的登录状态。

由于记住我信息是存放在内存中的,我们需要保证服务器一直处于运行状态,如果关闭服务器的话,记住我信息会全部丢失,因此,如果我们希望记住我能够一直持久化保存,就需要进一步进行配置。

我们需要创建一个基于JDBC的TokenRepository实现:

@Bean
public PersistentTokenRepository tokenRepository(DataSource dataSource){
    JdbcTokenRepositoryImpl repository = new JdbcTokenRepositoryImpl();
      //在启动时自动在数据库中创建存储记住我信息的表,仅第一次需要,后续不需要
    repository.setCreateTableOnStartup(true);
    repository.setDataSource(dataSource);
    return repository;
}
.rememberMe(conf -> {
     conf.rememberMeParameter("remember-me");
     conf.tokenRepository(repository);      //设置刚刚的记住我持久化存储库
     conf.tokenValiditySeconds(3600 * 7);   //设置记住我有效时间为7天
})

四、授权

SpringSecurity为我们提供了两种授权方式:

  • 基于权限的授权:只要拥有某权限的用户,就可以访问某个路径。
  • 基于角色的授权:根据用户属于哪个角色来决定是否可以访问某个路径。
.authorizeHttpRequests(auth -> {
    //静态资源依然全部可以访问
    auth.requestMatchers("/static/**").permitAll();
    //只有具有以下角色的用户才能访问路径"/"
    auth.requestMatchers("/").hasAnyRole("user", "admin");
    //其他所有路径必须角色为admin才能访问
    auth.anyRequest().hasRole("admin");
})
 @Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    Account account = mapper.findUserByName(username);
    if(account == null)
        throw new UsernameNotFoundException("用户名或密码错误");
    return User
            .withUsername(username)
            .password(account.getPassword())
            .roles(account.getRole())   //添加角色,一个用户可以有一个或多个角色
            .build();
}

基于权限授权

.authorizeHttpRequests(auth -> {
    //静态资源依然全部可以访问
    auth.requestMatchers("/static/**").permitAll();
    //基于权限和基于角色其实差别并不大,使用方式是相同的
    auth.anyRequest().hasAnyAuthority("page:index");
})

使用注解权限判断

@EnableMethodSecurity 可以写在任何 @Configuration 类上。

只要这个配置类被 Spring 扫描到即可。

@Configuration
@EnableWebSecurity
@EnableMethodSecurity   //开启方法安全校验
public class SecurityConfiguration {
    ...
}
@Controller
public class HelloController {
    @PreAuthorize("hasRole('user')")  //直接使用hasRole方法判断是否包含某个角色
    @GetMapping("/")
    public String index(){
        return "index";
    }

    ...
}
@Service
public class UserService {

    @PreAuthorize("hasAnyRole('user')")
    public void test(){
        System.out.println("成功执行");
    }
}

与具有相同功能的还有@Secured,但是它不支持SpEL表达式的权限表示形式,并且需要添加"ROLE_"前缀。

@PreFilter@PostFilter

我们还可以使用@PreFilter@PostFilter对集合类型的参数或返回值进行过滤。

@PreFilter("filterObject.equals('lbwnb')")   //filterObject代表集合中每个元素,只要满足条件的元素才会留下
public void test(List<String> list){
    System.out.println("成功执行"+list);
}

注:当有多个集合时,需要使用filterTarget进行指定:

@PreFilter(value = "filterObject.equals('lbwnb')", filterTarget = "list2")
public void test(List<String> list, List<String> list2){
    System.out.println("成功执行"+list);
}
分类: Java-Backend 标签: JavaSpring

评论

暂无评论数据

暂无评论数据

目录