Spring Security

Spring Security

简介

Spring 是非常流行和成功的 Java 应用开发框架,Spring Security 正是 Spring 家族中的成员。Spring Security为基于J2EE企业应用软件提供了全面安全服务。 特别是使用领先的J2EE解决方案-spring框架开发的企业软件项目。

安全方面的两个主要区域是“认证”和“授权”(或者访问控制),一般来说,Web 应用的安全性包括**用户认证(Authentication)和用户授权 ** **(Authorization)**两个部分,这两点也是 Spring Security 重要核心功能。

(1)用户认证指的是:验证某个用户是否为系统中的合法主体,也就是说用户能否访问该系统。用户认证一般要求用户提供用户名和密码。系统通过校验用户名和密码来完成认证过程。通俗点说就是系统认为用户是否能登录

(2)用户授权指的是验证某个用户是否有权限执行某个操作。在一个系统中,不同用户所具有的权限是不同的。比如对一个文件来说,有的用户只能进行读取,而有的用户可以进行修改。一般来说,系统会为不同的用户分配不同的角色,而每个角色则对应一系列的权限。通俗点讲就是系统判断用户是否有权限去做某些事情。

在用户认证方面,Spring Security 框架支持主流的认证方式,包括 HTTP 基本认证、HTTP 表单验证、HTTP 摘要认证、OpenID 和 LDAP 等。在用户授权方面,Spring Security 提供了基于角色的访问控制和访问控制列表(Access Control List,ACL),可以对应用中的领域对象进行细粒度的控制。

Spring Security与Shiro的比较

官方网站:

https://spring.io/projects/spring-security

Spring Security

  • 和 Spring 无缝整合。

  • 全面的权限控制。

  • 专门为 Web 开发而设计。

    ◼旧版本不能脱离 Web 环境使用。

    ◼新版本对整个框架进行了分层抽取,分成了核心模块和 Web 模块。单独引入核心模块就可以脱离 Web 环境。

  • 重量级。

Shiro

Apache 旗下的轻量级权限控制框架。Apache Shiro是一个强大且易用的Java安全框架,执行身份验证、授权、密码和会话管理。使用Shiro的易于理解的API,您可以快速、轻松地获得任何应用程序,从最小的移动应用程序到最大的网络和企业应用程序。

官网:https://shiro.apache.org/documentation.html

特点:

​ 轻量级。Shiro 主张的理念是把复杂的事情变简单。针对对性能有更高要求的互联网应用有更好表现。

通用性。

​ 好处:不局限于 Web 环境,可以脱离 Web 环境使用。

​ 缺陷:在 Web 环境下一些特定的需求需要手动编写代码定制。

入门案例

添加依赖
		<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
         <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

编写Controller

@RestController
public class TestController {

    @GetMapping("/")
    public String hello(){
        return "hello";
    }
}
启动程序访问/

因安全框架的原因会将请求拦截自动跳转到认证页面,security默认提供的username为user,password在启动页会自动生成,输入默认账号密码认证成功后才会跳转显示hello!!!!
在这里插入图片描述
在这里插入图片描述

核心过滤器认识

在这里插入图片描述

在我们添加了SpringSecurity 依赖后,在项目的启动日志中,可以直观的看到SpringSecurity 的实现是通过filter chain实现的。

org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFil

ter

org.springframework.security.web.context.SecurityContextPersistenceFilter 

org.springframework.security.web.header.HeaderWriterFilter

org.springframework.security.web.csrf.CsrfFilter

org.springframework.security.web.authentication.logout.LogoutFilter 

org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter 

org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter 

org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter

org.springframework.security.web.savedrequest.RequestCacheAwareFilter

org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter

org.springframework.security.web.authentication.AnonymousAuthenticationFilter 

org.springframework.security.web.session.SessionManagementFilter 

org.springframework.security.web.access.ExceptionTranslationFilter 

org.springframework.security.web.access.intercept.FilterSecurityInterceptor

FilterSecurityInterceptor :

动态实现根据用户访问的url进行权限管理

在动态实现根据访问的url进行权限认证时可以自定义FilterInvocationSecurityMetadataSource实现认证规则的配置(例根据用户请求url获取对应url的角色信息),修改FilterSecurityInterceptor自带MetadataSource,同时自定义AccessDecisionManager ,实现访问决策管理(根据登录用户查询到用户的角色,接着比对用户角色与当前url的角色,一致即可访问),最后在配置类中通过

http.authorizeRequests().withObjectPostProcessor(
	new ObjectPostProcessor<FilterSecurityInterceptor>() {
					@Override
					public <O extends FilterSecurityInterceptor> O postProcess(O object) {
						object.setSecurityMetadataSource(new CustomerFilterInvocationSecurityMetadataSource());
						object.setAccessDecisionManager(new CustomerAccessDecisionManger());
						return object;
					}
				}

实现根据url动态认证授权。。。。。。

//一个位于底层的针对http安全控制的拦截器
public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements
      Filter{
      //.......
      //核心方法 doFilter
      public void doFilter(ServletRequest request, ServletResponse response,
			FilterChain chain) throws IOException, ServletException {
		FilterInvocation fi = new FilterInvocation(request, response, chain);
		invoke(fi);
	}
		
	 //过滤器链执行   	
      public void invoke(FilterInvocation fi) throws IOException, ServletException {
		if ((fi.getRequest() != null)
				&& (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
				&& observeOncePerRequest) {
			// filter already applied to this request and user wants us to observe
			// once-per-request handling, so don't re-do security checking
			//进行过滤器链中过滤器的执行
			fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
		}
		else {
			// first time this request being called, so perform security checking
			if (fi.getRequest() != null && observeOncePerRequest) {
				fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
			}
			//查看之前的filter是否通过
			InterceptorStatusToken token = super.beforeInvocation(fi);

			try {
			//调用服务执行filter过滤器
				fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
			}
			finally {
				super.finallyInvocation(token);
			}

			super.afterInvocation(token, null);
		}
      } 
 } 
      

UsernamePasswordAuthenticationFilter:

通过默认认证页输入用户名密码后,对/login的post请求做拦截,校验表单数据

//核心方法:
//默认用户名密码认证的url是/login,提交方式是post方式
public UsernamePasswordAuthenticationFilter() {
   super(new AntPathRequestMatcher("/login", "POST"));
}

// ~ Methods
// 默认提供的认证机制,默认只是获取到输入的用户名和密码进行比对,没有去查询数据库

public Authentication attemptAuthentication(HttpServletRequest request,
      HttpServletResponse response) throws AuthenticationException {
   if (postOnly && !request.getMethod().equals("POST")) {
      throw new AuthenticationServiceException(
            "Authentication method not supported: " + request.getMethod());
   }

   String username = obtainUsername(request);
   String password = obtainPassword(request);

   if (username == null) {
      username = "";
   }

   if (password == null) {
      password = "";
   }

   username = username.trim();

   UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
         username, password);

   // Allow subclasses to set the "details" property
   setDetails(request, authRequest);

   return this.getAuthenticationManager().authenticate(authRequest);
}

核心接口

UserDetailsService

Security中加载用户数据的核心接口,当配置中没有账号密码时,通过实现此接口可以自定义逻辑(连接数据库)控制账号、密码认证。

在我们自定义服务时,实现此接口即可。

public interface UserDetailsService {
   // ~ Methods
   // ========================================================================================================

   /**
    * Locates the user based on the username. In the actual implementation, the search
    * may possibly be case sensitive, or case insensitive depending on how the
    * implementation instance is configured. In this case, the <code>UserDetails</code>
    * object that comes back may have a username that is of a different case than what
    * was actually requested..
    *
    * @param username the username identifying the user whose data is required.
    *
    * @return a fully populated user record (never <code>null</code>)
    *
    * @throws UsernameNotFoundException if the user could not be found or the user has no
    * GrantedAuthority
    * 根据用户名进行用户找到用户,返回UserDetails对象
    */
   UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

实现loadUserByUsername方法即可,其返回值为UserDetails

UserDetails

Security默认的用户主体

public interface UserDetails extends Serializable {
   // ~ Methods
   // ======================================================================================================
   /**
    * Returns the authorities granted to the user. Cannot return <code>null</code>.
    * 获取登录用户所有权限
    * @return the authorities, sorted by natural key (never <code>null</code>)
    */
   Collection<? extends GrantedAuthority> getAuthorities();

   /**
    * Returns the password used to authenticate the user.
    * 获取密码
    * @return the password
    */
   String getPassword();

   /**
    * Returns the username used to authenticate the user. Cannot return <code>null</code>.
    * 获取用户名
    * @return the username (never <code>null</code>)
    */
   String getUsername();

   /**
    * Indicates whether the user's account has expired. An expired account cannot be
    * authenticated.
    * 账户是否过期
    * @return <code>true</code> if the user's account is valid (ie non-expired),
    * <code>false</code> if no longer valid (ie expired)
    */
   boolean isAccountNonExpired();

   /**
    * Indicates whether the user is locked or unlocked. A locked user cannot be
    * authenticated.
    * 账户是否被锁定
    * @return <code>true</code> if the user is not locked, <code>false</code> otherwise
    */
   boolean isAccountNonLocked();

   /**
    * Indicates whether the user's credentials (password) has expired. Expired
    * credentials prevent authentication.
    * 凭证是否过期
    * @return <code>true</code> if the user's credentials are valid (ie non-expired),
    * <code>false</code> if no longer valid (ie expired)
    */
   boolean isCredentialsNonExpired();

   /**
    * Indicates whether the user is enabled or disabled. A disabled user cannot be
    * authenticated.
    * 当前用户是否可用
    * @return <code>true</code> if the user is enabled, <code>false</code> otherwise
    */
   boolean isEnabled();
}

在这里插入图片描述

其默认提供的实现类User作为我们使用的返回值即可!!!!

PasswordEncoder

在web中,密码一般是加密保存的,Security中,默认使用此接口对密码进行编码与适配。

public interface PasswordEncoder {

   /**
    * Encode the raw password. Generally, a good encoding algorithm applies a SHA-1 or
    * greater hash combined with an 8-byte or greater randomly generated salt.
    * 初始密码的编码
    */
   String encode(CharSequence rawPassword);

   /**
    * Verify the encoded password obtained from storage matches the submitted raw
    * password after it too is encoded. Returns true if the passwords match, false if
    * they do not. The stored password itself is never decoded.
    *
    * @param rawPassword the raw password to encode and match
    * @param encodedPassword the encoded password from storage to compare with
    * @return true if the raw password, after encoding, matches the encoded password from
    * storage
    * 将初始密码与编码后的密码进行适配
    */
   boolean matches(CharSequence rawPassword, String encodedPassword);

   /**
    * Returns true if the encoded password should be encoded again for better security,
    * else false. The default implementation always returns false.
    * @param encodedPassword the encoded password to check
    * @return true if the encoded password should be encoded again for better security,
    * else false.
    * 如果编码后的密码再次进行编码以达到更安全的结果就返回true,默认返回false
    */
   default boolean upgradeEncoding(String encodedPassword) {
      return false;
   }
}

常见的接口实现类:

在这里插入图片描述

BCryptPasswordEncoder:Security官方推荐的密码解析器,使用中可以直接使用或实现PasswordEncoder接口,自定义解析。

@Test
void contextLoads() {
    BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
    //对原始密码编码
    String s = encoder.encode("123456");
    //对原始密码与编码后的密码适配
    boolean b = encoder.matches("123456", s);
    System.out.println(b);
}

SpringSecurity 在web中的权限使用

1.通过application.yml文件

spring:
  security:
    user:
      name: user  #手动配置认证的用户名以及密码
      password: 123456

2.通过编写配置类

编写配置类WebSecurityConfig

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    /**
     * WebSecurityConfigurerAdapter :实现WebSecurityConfigurer的基础类,用于自定义安全配置
     * configure方法:重写添加自定义安全配置
     * AuthenticationManagerBuilder : 用于创建自定义认证管理器,用于构建身份验证
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        MyPasswordEncoder encoder = new MyPasswordEncoder();
        String s = encoder.encode("123456");
        //添加内存身份验证
        auth
                .inMemoryAuthentication()
                .withUser("user")
                .password(s)
                .roles("ADMIN")
                .and()
                .withUser("xy")
                .password(s)
                .roles("USER");
    }
}

此时注意,Security默认进行认证时对密码是要求进行编码的,如果不进行编码,会报错

IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"

自定义MyPasswordEncoder,实现PasswordEncoder,实现对认证密码的编码以及匹配(或默认使用BCryptPasswordEncoder也可以)

//PasswordEncoder :SpringSecurity中,默认会对密码进行编码处理,此接口为Security提供的默认实现编码以及匹配的方法
@Component
public class MyPasswordEncoder implements PasswordEncoder {

    //对原始密码进行编码   此处可以自定义编码
    @Override
    public String encode(CharSequence rawPassword) {
        //md5加密
        return DigestUtils.md5DigestAsHex(rawPassword.toString().getBytes());
    }

    //对原始密码以及编码后的密码进行比对   根据编码对应进行解码比较密码
    @Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        return encodedPassword.equals(DigestUtils.md5DigestAsHex(rawPassword.toString().getBytes()));
    }
    
}

修改TestController,在测试接口上添加@Secured注解,表示当前请求只能是指定角色的用户访问

@RestController
public class TestController {

    @GetMapping("/")
    @Secured("ROLE_ADMIN")
    public String test(){
        return "success";
    }
    
	@GetMapping("/user")
    @Secured({"ROLE_ADMIN","ROLE_USER"})
    public String testUser(){
        return "user_success";
    }
}

在启动类添加注解@EnableGlobalMethodSecurity(securedEnabled = true)开启权限注解。

启动项目分别访问对应接口测试即可!!!

3.通过自定义实现

上面两种都是我们手动定义的用户名密码,实际操作中,用户名密码是保存在数据库中的,此时需要通过自定义实现。

手动添加数据模拟

1、配置UserDetailsService,实现自定义用户服务,实现用户账号、密码的动态获取以及封装

@Service
public class MyUserDetailsService implements UserDetailsService {

    //通过用户名进行查找
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //定义权限集合,用于添加当前用户的角色
        List<GrantedAuthority> authorities = new ArrayList<>();
        //模拟手动封装,实际中的用户角色是需要去数据库查找
        authorities.add(new SimpleGrantedAuthority("admin"));
        //返回user对象,参数为用户名、密码以及对应的权限集合
        return new User(username,new MyPasswordEncoder().encode("123456"),authorities);
    }
}

2、修改WebSecurityConfig

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    /**
     * WebSecurityConfigurerAdapter :实现WebSecurityConfigurer的基础类,用于自定义安全配置
     * configure方法:重写添加自定义安全配置
     * AuthenticationManagerBuilder : 用于创建自定义认证管理器,用于构建身份验证
     */

    @Autowired
    private MyUserDetailsService userDetailsService;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //添加自定义userDetailsService以及passwordEncoder
        auth.
                userDetailsService(userDetailsService)
                .passwordEncoder(new MyPasswordEncoder());
    }
}

3、

整合数据库操作

整合MP+Mysql,实现数据库用户信息的登录查询
在这里插入图片描述

ay_user代表用户表,ay_role代表角色表,ay_user_role_rel代表用户与角色的关联表

application.yml:

server:
  port: 8081
spring:
  datasource:
    username: root
    password: qwer1234
    url: jdbc:mysql://127.0.0.1:3306/testspring-boot?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=PRC
    driver-class-name: com.mysql.cj.jdbc.Driver
mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

关于用户、角色、用户角色关联实体类自定义…

UserMapper:

@Mapper
@Repository
public interface UserMapper extends BaseMapper<AyUser> {

}

AyRoleMapper:

@Mapper
@Repository
public interface AyRoleMapper extends BaseMapper<AyRole> {
}

AyUserRoleRelMapper:

@Mapper
@Repository
public interface AyUserRoleRelMapper extends BaseMapper<AyUserRoleRel> {
}

MPConfig:

@Configuration
@MapperScan("com.example.security_demo.mapper")
public class MPConfig {

}

AyUserServiceImpl:

@Service
public class AyUserServiceImpl implements AyUserService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public AyUser findUserByName(String name) {
        QueryWrapper<AyUser> wrapper = new QueryWrapper<>();
        wrapper.eq("name",name);
        AyUser ayUser = userMapper.selectOne(wrapper);
        return ayUser;
    }
}

AyRoleServiceImpl:

@Service
public class AyRoleServiceImpl extends ServiceImpl<AyRoleMapper, AyRole> implements AyRoleService {
    
}

AyUserRoleRelServiceImpl:

@Service
public class AyUserRoleRelServiceImpl extends ServiceImpl<AyUserRoleRelMapper, AyUserRoleRel> implements AyUserRoleRelService {
}

MyUserDetailsService:进行修改,连接数据库查找用户信息以及角色信息

@Service
public class MyUserDetailsService implements UserDetailsService {

    @Autowired
    private AyUserService userService;

    @Autowired
    private AyRoleService roleService;

    @Autowired
    private AyUserRoleRelService userRoleRelService;

    //通过用户名进行查找
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        //数据库查找
        AyUser ayUser = userService.findUserByName(username);
        //如果用户不存在,抛出异常
        if (ayUser == null){
            throw  new UsernameNotFoundException("用户不存在!!!");
        }
        //用户存在查询其角色信息进行保存
        QueryWrapper<AyUserRoleRel> wrapper = new QueryWrapper();
        wrapper.eq("user_id",ayUser.getId());
        List<AyUserRoleRel> list = userRoleRelService.list(wrapper);

        //定义权限集合,用于添加当前用户的角色
        List<GrantedAuthority> authorities = new ArrayList<>();

        //遍历根据其角色id查询角色信息并将其封装到权限集合中
        if (list != null && list.size() > 0){
            list.forEach(ayUserRoleRel -> {
                AyRole ayRole = roleService.getById(ayUserRoleRel.getRoleId());
                //权限认证和角色认证写法一样
                authorities.add(new SimpleGrantedAuthority("ROLE_" + ayRole.getName())); //角色认证默认角色名字前有ROLE_
            });
        }

        //模拟手动封装,实际中的用户角色是需要去数据库查找
//        authorities.add(new SimpleGrantedAuthority("ADMIN"));
        //返回user对象,参数为用户名、密码以及对应的权限集合	
        return new User(ayUser.getName(),ayUser.getPassword(),authorities);
    }
}

WebSecurityConfig:添加认证、自定义登录页面、自定义访问拒绝后的自定义异常…

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    /**
     * WebSecurityConfigurerAdapter :实现WebSecurityConfigurer的基础类,用于自定义安全配置
     * configure方法:重写添加自定义安全配置
     * AuthenticationManagerBuilder : 用于创建自定义认证管理器,用于构建身份验证
     */

    @Autowired
    private MyUserDetailsService userDetailsService;

    @Autowired
    private MyAccessDeniedHandler accessDeniedHandler;

    //自定义认证策略
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //添加自定义userDetailsService以及passwordEncoder
        auth.
                userDetailsService(userDetailsService)
                .passwordEncoder(new MyPasswordEncoder());
    }
    //Security中对http的配置
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .loginPage("/tologin")    //自定义登录页面配置
                .loginProcessingUrl("/login")//登录路劲
                .successForwardUrl("/success")//成功后跳转url
                .failureForwardUrl("/fail")  //失败后跳转url
        ;

        //资源配置   hasAuthority 权限认证   hasRole 角色认证
        http.authorizeRequests()
                .antMatchers("/tologin")  //配置请求路劲
                .permitAll()  //放行,无须保护
                .antMatchers("/admin")
//                .hasAuthority("ADMIN")  //访问admin时,需要的权限,权限名一般大写,默认不写ROLE_
                .hasRole("ADMIN")  //角色认证
                .antMatchers("/user")
//                .hasAnyAuthority("ADMIN","USER") //满足其一就满足权限
                .hasAnyRole("ADMIN","USER")
                .anyRequest()  //其他请求
                .authenticated()//需要认证
                .and().exceptionHandling().accessDeniedHandler(accessDeniedHandler)  //使用自定义的访问拒绝控制器
//                .and().exceptionHandling().accessDeniedPage("")  定义拒绝访问页
                .and().csrf().disable() //关闭csrf防护
            ;
    }
 }
自定义拒绝访问异常

MyAccessDeniedHandler :

@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
    //自定义拒绝访问异常处理
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setHeader("Content-Type","application/json;charset=utf-8") ;
        PrintWriter out = response.getWriter();
        out.write("{\"status\":\"error\",\"msg\":\"权限不足,请联系管理员!!!!\"}");
        out.flush();
        out.close();
    }
}

自定义登录页面

见上面WebSecurityConfig配置即可

注意:登录请求post方式

<div th:if="${param.error}">
    无效的用户名或者密码
</div>
<div th:if="${param.logout}">
    你已经登出
</div>
<form th:action="@{/login}" method="post">
    <div>
        <div>
            <span>用户名:</span>
            <span><input type="text" name="username"></span>
        </div>
        <div>
            <span>密码:</span>
            <span><input type="text" name="password"></span>
        </div>
        <div>
            <span>记住我:</span>
            <span><input type="checkbox" name="remember-me"></span>
        </div>
        <div>
            <span><input type="submit" value="login"></span>
        </div>
    </div>
</form>

登出

Spring Security进行默认登录处理时,如果登录成功会自动生成一个cookie保存在本地,在一个会话周期内,用户可以免登录,如果要进行注销登录,可以使用自带的登出配置
在这里插入图片描述

登出配置如下:在WebSecurityConfig修改器configure配置即可

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.formLogin()
            .loginPage("/tologin")    //登录页面配置
            .loginProcessingUrl("/login")//登录路劲
            .successForwardUrl("/success")//成功后跳转url
            //.failureForwardUrl("/fail")  //失败后跳转url
            .and()
            .logout() //登出
            .logoutUrl("/logout")
            //.logoutSuccessUrl("/tologin")
            .permitAll()
    ;

添加登出按钮在登录成功后默认跳转的html中添加登出按钮,post请求

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<p th:inline="text">Hello <span sec:authentication="name"></span></p>
<form th:action="@{/logout}" method="post">
    <input type="submit" value="登出"/>
</form>
</body>
</html>

测试即可

@Secured注解实现权限控制

在控制器中对应的请求中添加Secured注解实现,此时需要在WebSecurityConfig中添加@EnableGlobalMethodSecurity(securedEnabled = true)注解

@Configuration
//@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

Controller:

//@Secured({"ROLE_ADMIN,ROLE_USER"})注解配置访问当前方法的角色信息
@GetMapping("/role/admin")
@Secured({"ROLE_ADMIN"})
@ResponseBody
public String auth_admin(){
    return "role_admin";
}

@GetMapping("/role/user")
@Secured({"ROLE_ADMIN","ROLE_USER"})
@ResponseBody
public String auth_user(){
    return "role_user";
}

prePostEnabled 实现注解添加权限认证控制

修改@EnableGlobalMethodSecurity(securedEnabled = true),添加prePostEnabled = true属性,即请求前进行认证

权限表达式:
在这里插入图片描述

在对应controller方法上添加注解

	@GetMapping("/role/admin")
//    @Secured({"ROLE_ADMIN"})
    @ResponseBody
    @PreAuthorize("hasAuthority('ROLE_ADMIN')")  //方法进入前的权限验证
    public String auth_admin(){
        return "role_admin";
    }

    @GetMapping("/role/user")
//    @Secured({"ROLE_ADMIN","ROLE_USER"})
    @ResponseBody
    @PostAuthorize("hasAnyAuthority('ROLE_ADMIN','ROLE_USER')") //方法执行后进行权限验证,使用较少,一般用于对返回值的验证
    public String auth_user(){
        return "role_user";
    }
	// @PostFilter 权限验证之后对数据进行过滤
	// @PostFilter("filterObject.username == 'jack'")
	// @PreFilter: 进入控制器之前对数据进行过滤
	

JSR-250实现限控制

JSR是Java Specification Requests的缩写,意思是Java 规范提案。是指向JCP(Java Community Process)提出新增一个标准化技术规范的正式请求。任何人都可以提交JSR,以向Java平台增添新的API和服务。JSR已成为Java界的一个重要标准。

针对安全配置上,JSR提供的注解

@RolesAllowed(value = {"ADMIN"})设定访问权限
@DenyAll :拒绝所有访问
@PermitAll: 允许所有访问

实现自动登录

使用remember-me实现

WebSecurityConfig中,添加remember-me,实现Spring Security自带的记住我功能

//security中配置记住我功能
http.authorizeRequests()
        .and()
        .rememberMe()
        .key("xueyin")  //添加生成token时的key
        .tokenValiditySeconds(60) //设置过期时间  默认过期时间2周
        ;

在登录页面中添加记住我选框:

<form th:action="@{/login}" method="post">
    <div>
        <div>
            <span>用户名:</span>
            <span><input type="text" name="username"></span>
        </div>
        <div>
            <span>密码:</span>
            <span><input type="text" name="password"></span>
        </div>
        <div>
            <span><input type="checkbox" name="remember-me"></span>
            <span>记住我:</span>
        </div>
        <div>
            <span><input type="submit" value="login"></span>
        </div>
    </div>
</form>

启动项目测试:
在这里插入图片描述

点击登录后关闭浏览器访问需授权才能请求的url,可看到此时不用登录即可实现自动跳转

登录成功后查看cookie信息,可以发现remember-me的cookie 信息
在这里插入图片描述

remember-me=JUU4JUFGJUI4JUU4JTkxJTlCOjE2MTg0NzM3NTA1NzA6NWRlYjUyZmY3NTJkZGVhZjE0NzA1ZGFmZjkxZDJjODc

Security默认的实现自动登录即根据此cookie实现的,解析此cookie,默认次用Base64算法实现的

Base64(username:expireTime:MD5(username:expireTime:password:secretKey))

@Test
void test(){
    String s = new String(Base64.getDecoder().decode("JUU4JUFGJUI4JUU4JTkxJTlCOjE2MTg0NzM3NTA1NzA6NWRlYjUyZmY3NTJkZGVhZjE0NzA1ZGFmZjkxZDJjODc"));
    System.out.println(s);
    //%E8%AF%B8%E8%91%9B:1618473750570:5deb52ff752ddeaf14705daff91d2c87
}

在浏览器关闭后,并重新打开之后,用户再去访问,此时会携带着 cookie 中的 remember-me 到服务端,服务到拿到值之后,可以方便的计算出用户名和过期时间,再根据用户名查询到用户密码,然后通过 MD5 散列函数计算出散列值,再将计算出的散列值和浏览器传递来的散列值进行对比,就能确认这个令牌是否有效。

remember-me中token生成过程:
AbstractAuthenticationProcessingFilter#doFilter -> AbstractAuthenticationProcessingFilter#successfulAuthentication -> 
AbstractRememberMeServices#loginSuccess -> 
TokenBasedRememberMeServices#onLoginSuccess。

核心在于TokenBasedRememberMeServices中的onLoginSuccess方法

@Override
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
 Authentication successfulAuthentication) {
     String username = retrieveUserName(successfulAuthentication);
     String password = retrievePassword(successfulAuthentication);
 if (!StringUtils.hasLength(password)) {
     UserDetails user = getUserDetailsService().loadUserByUsername(username);
     password = user.getPassword();
 	}
     int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
     long expiryTime = System.currentTimeMillis();
     expiryTime += 1000L * (tokenLifetime < 0 ? TWO_WEEKS_S : tokenLifetime);
     String signatureValue = makeTokenSignature(expiryTime, username, password);
     setCookie(new String[] { username, Long.toString(expiryTime), signatureValue },
     tokenLifetime, request, response);
}
protected String makeTokenSignature(long tokenExpiryTime, String username,
 String password) {
 String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
 MessageDigest digest;
 digest = MessageDigest.getInstance("MD5");
 return new String(Hex.encode(digest.digest(data.getBytes())));
}
  1. 首先从登录成功的 Authentication 中提取出用户名/密码。
  2. 由于登录成功之后,密码可能被擦除了,所以,如果一开始没有拿到密码,就再从 UserDetailsService 中重新加载用户并重新获取密码。
  3. 再接下来去获取令牌的有效期,令牌有效期默认就是两周。
  4. 再接下来调用 makeTokenSignature 方法去计算散列值,实际上就是根据 username、令牌有效期以及 password、key 一起计算一个散列值。如果我们没有自己去设置这个 key,默认是在 RememberMeConfigurer#getKey 方法中进行设置的,它的值是一个 UUID 字符串。
  5. 最后,将用户名、令牌有效期以及计算得到的散列值放入 Cookie 中。
RememberMe 功能实现的核心:

Spring Security 中提供了 RememberMeAuthenticationFilter 类专门用来做相关的事情,我们来看下 RememberMeAuthenticationFilter 的 doFilter 方法:

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
 throws IOException, ServletException {
 HttpServletRequest request = (HttpServletRequest) req;
 HttpServletResponse response = (HttpServletResponse) res;
 if (SecurityContextHolder.getContext().getAuthentication() == null) {
 Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
  response);
 if (rememberMeAuth != null) {
  rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);
  SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
  onSuccessfulAuthentication(request, response, rememberMeAuth);
  if (this.eventPublisher != null) {
   eventPublisher
    .publishEvent(new InteractiveAuthenticationSuccessEvent(
     SecurityContextHolder.getContext()
      .getAuthentication(), this.getClass()));
  }
  if (successHandler != null) {
   successHandler.onAuthenticationSuccess(request, response,
    rememberMeAuth);
   return;
  }
  }
 chain.doFilter(request, response);
 }
 else {
 chain.doFilter(request, response);
 }
}

其执行核心在于rememberMeServices.autoLogin,即如果无法从SecurityContextHolder获取登录用户实例(User),调用autoLogin进行登录处理,其核心在于获取cookie信息,对其信息进行解码,然后调用processAutoLoginCookie获取用户实例,获取用户名、过期时间,根据用户名获取到密码,再对其编码后与浏览器传递的cookie信息比对,进而判断出令牌是否有效,完成自动登录。。。。。。。

public final Authentication autoLogin(HttpServletRequest request,
  HttpServletResponse response) {
 String rememberMeCookie = extractRememberMeCookie(request);
 if (rememberMeCookie == null) {
  return null;
 }
 logger.debug("Remember-me cookie detected");
 if (rememberMeCookie.length() == 0) {
  logger.debug("Cookie was empty");
  cancelCookie(request, response);
  return null;
 }
 UserDetails user = null;
 try {
  String[] cookieTokens = decodeCookie(rememberMeCookie);
  user = processAutoLoginCookie(cookieTokens, request, response);
  userDetailsChecker.check(user);
  logger.debug("Remember-me cookie accepted");
  return createSuccessfulAuthentication(request, user);
 }
 catch (CookieTheftException cte) {
  
  throw cte;
 }
 cancelCookie(request, response);
 return null;
}

如果我们开启了 RememberMe 功能,最最核心的东西就是放在 cookie 中的令牌了,这个令牌突破了 session 的限制,即使服务器重启、即使浏览器关闭又重新打开,只要这个令牌没有过期,就能访问到数据。

一旦令牌丢失,别人就可以拿着这个令牌随意登录我们的系统了,这是一个非常危险的操作。

持久化令牌实现

Token是服务端生成的一串字符串,以作客户端进行请求的一个令牌,当第一次登录后,服务器生成一个Token便将此Token返回给客户端,以后客户端只需带上这个Token前来请求数据即可,无需再次带上用户名和密码。

使用Token的目的:Token的目的是为了减轻服务器的压力,减少频繁的查询数据库,使服务器更加健壮。

自动登录实现即:登录认证成功后将生成Token写入浏览器Cookie,同时也保存到数据库中,假如用户浏览器关闭,再次打开请求访问依然会携带cookie

在这里插入图片描述
持久化令牌:在自动登录基础上,新增校验,提高系统的安全性。在持久化令牌中,新增了两个经过 MD5 散列函数计算的校验参数,一个是 series,另一个是 token。其中,series 只有当用户在使用用户名/密码登录时,才会生成或者更新,而 token 只要有新的会话,就会重新生成。

持久化令牌的具体处理类在 PersistentTokenBasedRememberMeServices 中
在这里插入图片描述

而用来保存令牌的处理类则是 PersistentRememberMeToken,该类的定义也很简洁命令:

public class PersistentRememberMeToken {
   private final String username;
   private final String series;
   private final String tokenValue;
   private final Date date;   //上次自動登錄时间
   //......

使用:

首先我们需要一张表来记录令牌信息,这张表我们可以完全自定义,也可以使用系统默认提供的 JDBC 来操作,如果使用默认的 JDBC,即 JdbcTokenRepositoryImpl,我们可以来分析一下该类的定义:

public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements
      PersistentTokenRepository {
   // ~ Static fields/initializers
   // =====================================================================================

   /** Default SQL for creating the database table to store the tokens */
   public static final String CREATE_TABLE_SQL = "create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, "
         + "token varchar(64) not null, last_used timestamp not null)";
   /** The default SQL used by the <tt>getTokenBySeries</tt> query */
   public static final String DEF_TOKEN_BY_SERIES_SQL = "select username,series,token,last_used from persistent_logins where series = ?";
   /** The default SQL used by <tt>createNewToken</tt> */
   public static final String DEF_INSERT_TOKEN_SQL = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";
   /** The default SQL used by <tt>updateToken</tt> */
   public static final String DEF_UPDATE_TOKEN_SQL = "update persistent_logins set token = ?, last_used = ? where series = ?";
   /** The default SQL used by <tt>removeUserTokens</tt> */
   public static final String DEF_REMOVE_USER_TOKENS_SQL = "delete from persistent_logins where username = ?";

在这里默认已经对表的创建、增删改查做了定义,所以可以利用其自动生成表,也可以手动创建。

表生成脚本:

CREATE TABLE `persistent_logins` (
 `username` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
 `series` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
 `token` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
 `last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
 PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

连接数据库,修改WebSecurityConfig配置类,

//将JdbcTokenRepositoryImpl注入 到容器中,以记录令牌信息
    @Bean
    public JdbcTokenRepositoryImpl jdbcTokenRepository(){
        JdbcTokenRepositoryImpl repository = new JdbcTokenRepositoryImpl();
        repository.setDataSource(dataSource);
        return repository;
    }

//configure(HttpSecurity http)方法中修改rememberme设置
http.authorizeRequests()
        .and()
        .rememberMe()
        .key("xueyin")
        .tokenRepository(jdbcTokenRepository()) //使用jdbcTokenRepository进行令牌操作
        .tokenValiditySeconds(60)
        ;

测试:进行记住我登录测试,查看cookie信息以及数据库中信息即可验证是否持久化

CSRF

概念

跨站请求伪造(英语:Cross-site request forgery),也被称为 one-click attack 或者 session riding,通常缩写为 CSRF 或者 XSRF, 是一种挟制用户在当前已登录的Web应用程序上执行非本意的操作的攻击方法。跟跨网站脚本(XSS)相比,XSS 利用的是用户对指定网站的信任,CSRF 利用的是网站对用户网页浏览器的信任。

这是一种非常常见的 Web 攻击方式,其实是很好防御的,但是由于经常被很多开发者忽略,进而导致很多网站实际上都存在 CSRF 攻击的安全隐患。

例子

假如一家银行用以运行转账操作的URL地址如下:http://www.examplebank.com/withdraw?account=AccoutName&amount=1000&for=PayeeName

那么,一个恶意攻击者可以在另一个网站上放置如下代码:

如果有账户名为Alice的用户访问了恶意站点,而她之前刚访问过银行不久,登录信息尚未过期,那么她就会损失1000资金。

这种恶意的网址可以有很多种形式,藏身于网页中的许多地方。此外,攻击者也不需要控制放置恶意网址的网站。例如他可以将这种地址藏在论坛,博客等任何用户生成内容的网站中。这意味着如果服务端没有合适的防御措施的话,用户即使访问熟悉的可信网站也有受攻击的危险

透过例子能够看出,攻击者并不能通过CSRF攻击来直接获取用户的账户控制权,也不能直接窃取用户的任何信息。他们能做到的,是欺骗用户浏览器,让其以用户的名义运行操作

防御

Spring Security 中默认实际上就提供了 csrf 防御,即在请求发起时添加隐藏域,保存csrf的信息

hello.html: 注意请求格式 Security 针对 PATCH,POST,PUT 和 DELETE 方法进行防护

在这里插入图片描述

隐藏域的 key 是 ${_csrf.parameterName},value 则是 ${_csrf.token}

这两个值服务端会自动带过来,我们只需要在前端渲染出来即可。

同时,给前端页面适配一个控制器:

@GetMapping("/hello")
public String hello() {
  return "hello";
}

给基于post的hello请求适配一个访问接口

@PostMapping("/hello")
@ResponseBody
public String hello(){
    return "hello";
}

启动项目,访问hello时需要先进行登录验证,打开开发者模式,登录验证成功时查看传递的表单数据

在这里插入图片描述

在这可以看到自动生成的_csrf,点击hello请求时同样会自动携带_csrf,和保存的 csrfToken 做比较,进而判断当前请求是否合法。主要通过 CsrfFilter 过滤器来完成。

附:前后台分离中,一般将_csrf放到cookie中,只需在WebSecurityConfig配置如下

http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());

withHttpOnlyFalse 方法获取了 CookieCsrfTokenRepository 的实例,该方法会设置 Cookie 中的 HttpOnly 属性为 false,也就是允许前端通过 js 操作 Cookie(否则你就没有办法获取到 _csrf

前端由异步实现,在异步中获取到cookie中的XSRF-TOKEN信息,进行异步交互时自动携带服务端进行比对校验即可。

Spring Security实现同一用户只能在一台设备登录

在同一个系统中,我们可能只允许一个用户在一个终端上登录,一般来说这可能是出于安全方面的考虑,但是也有一些情况是出于业务上的考虑,遇到的需求就是业务原因要求一个用户只能在一个设备上登录。

要实现一个用户不可以同时在两台设备上登录,我们有两种思路:

  • 后来的登录自动踢掉前面的登录,就像大家在扣扣中看到的效果。

  • 如果用户已经登录,则不允许后来者登录。

    第一种方案中,只需要在WebSecurityConfig配置中开启session管理,并设置session最大数为1即可

WebSecurityConfig配置下:


//maximumSessions设置最多只能一人登录,默认后登录者会自动挤掉前登录者,maxSessionsPreventsLogin设置当前用户登录后,不能重复登录
http.sessionManagement()
        .maximumSessions(1)
    //    .maxSessionsPreventsLogin(true)
        ;

第二种方案即不让后来者登录,只需配置中添加maxSessionsPreventsLogin,同时设置session监听即可

http.sessionManagement()
        .maximumSessions(1)
        .maxSessionsPreventsLogin(true)
        ;
//注册session事件监听者,以便实现限定一人登录,用于监听session
    @Bean
    HttpSessionEventPublisher eventPublisher(){
        return new HttpSessionEventPublisher();
    }        

HttpSessionEventPublisher ,这个类实现了 HttpSessionListener 接口,在该 Bean 中,可以将 session 创建以及销毁的事件及时感知到,并且调用 Spring 中的事件机制将相关的创建和销毁事件发布出去,进而被 Spring Security 感知到

源码自行查看

RBAC权限模型

RBAC(Role-based access control)是一种以角色为基础的访问控制(Role-based access control,RBAC),它是一种较新且广为使用的权限控制机制,这种机制不是直接给用户赋予权限,而是将权限赋予角色。

RBAC 权限模型将用户按角色进行归类,通过用户的角色来确定用户对某项资源是否具备操作权限。RBAC 简化了用户与权限的管理,它将用户与角色关联、角色与权限关联、权限与资源关联,这种模式使得用户的授权管理变得非常简单和易于维护。

详见RBAC文档!!!

Spring Security下实现会话共享(redis)

集群模式下,session并发问题

在传统的单服务架构中,一般来说,只有一个服务器,那么不存在 Session 共享问题,但是在分布式/集群项目中,Session 共享则是一个必须面对的问题
在这里插入图片描述

例如客户端发起一个请求,这个请求到达 Nginx 上之后,被 Nginx 转发到 Tomcat A 上,然后在 Tomcat A 上往 session 中保存了一份数据,下次又来一个请求,这个请求被转发到 Tomcat B 上,此时再去 Session 中获取数据,发现没有之前的数据。

解决方案:

session 共享

将各个服务之间需要共享的数据,保存到一个公共的地方(主流方案就是 Redis):

在这里插入图片描述

当所有 Tomcat 需要往 Session 中写数据时,都往 Redis 中写,当所有 Tomcat 需要读数据时,都从 Redis 中读。这样,不同的服务就可以使用相同的 Session 数据了。

此时,开发者可以手动往redis中进行读写操作,或直接使用spring提供的Spring-session,Spring Session 就是使用 Spring 中的代理过滤器,将所有的 Session 操作拦截下来,自动的将数据 同步到 Redis 中,或者自动的从 Redis 中读取数据。

对于开发者来说,所有关于 Session 同步的操作都是透明的,开发者使用 Spring Session,一旦配置完成后,具体的用法就像使用一个普通的 Session 一样。

使用:

1、添加依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--spring-session共享-->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

2、yml中添加redis基本配置

redis:
  host: 127.0.0.1
  port: 6379
  database: 0
  password: 123456

3、代码示例

@RestController
public class HelloController {
  @Value("${server.port}")
  Integer port;
  @GetMapping("/set")
  public String set(HttpSession session) {
    session.setAttribute("user", "javaboy");
    return String.valueOf(port);
  }
  @GetMapping("/get")
  public String get(HttpSession session) {
    return session.getAttribute("user") + ":" + port;
  }
}

4、打包模拟测试

项目右键,run maven–>package打包后执行

java -jar demo-SNAPSHOT.jar --server.port=8080
java -jar demo-SNAPSHOT.jar --server.port=8081

在浏览器访问8080下的set,用户登录认证后查看redis中是否有信息。

访问8081下的get,查看是否获取session信息

O-1660888816999)]

当所有 Tomcat 需要往 Session 中写数据时,都往 Redis 中写,当所有 Tomcat 需要读数据时,都从 Redis 中读。这样,不同的服务就可以使用相同的 Session 数据了。

此时,开发者可以手动往redis中进行读写操作,或直接使用spring提供的Spring-session,Spring Session 就是使用 Spring 中的代理过滤器,将所有的 Session 操作拦截下来,自动的将数据 同步到 Redis 中,或者自动的从 Redis 中读取数据。

对于开发者来说,所有关于 Session 同步的操作都是透明的,开发者使用 Spring Session,一旦配置完成后,具体的用法就像使用一个普通的 Session 一样。

使用:

1、添加依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--spring-session共享-->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

2、yml中添加redis基本配置

redis:
  host: 127.0.0.1
  port: 6379
  database: 0
  password: 123456

3、代码示例

@RestController
public class HelloController {
  @Value("${server.port}")
  Integer port;
  @GetMapping("/set")
  public String set(HttpSession session) {
    session.setAttribute("user", "javaboy");
    return String.valueOf(port);
  }
  @GetMapping("/get")
  public String get(HttpSession session) {
    return session.getAttribute("user") + ":" + port;
  }
}

4、打包模拟测试

项目右键,run maven–>package打包后执行

java -jar demo-SNAPSHOT.jar --server.port=8080
java -jar demo-SNAPSHOT.jar --server.port=8081

在浏览器访问8080下的set,用户登录认证后查看redis中是否有信息。

访问8081下的get,查看是否获取session信息

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值