# 简介

Spring Security,这是一种基于 Spring AOP 和 Servlet 过滤器的安全框架。它提供全面的安全性解决方案,同时在 Web 请求级和方法调用级处理身份确认和授权。

官网

https://spring.io/projects/spring-security (opens new window)

在线文档

https://springcloud.cc/spring-security-zhcn.html (opens new window)

# 认证过程

  1. 用户使用用户名和密码进行登录。

  2. Spring Security 将获取到的用户名和密码封装成一个实现了 Authentication 接口的 UsernamePasswordAuthenticationToken。

  3. 将上述产生的 token 对象传递给 AuthenticationManager 进行登录认证。

    AuthenticationManager 认证成功后将会返回一个封装了用户权限等信息的 Authentication 对象。

    通过调用 SecurityContextHolder.getContext().setAuthentication(...) 将 AuthenticationManager 返回的 Authentication 对象赋予给当前的 SecurityContext。

# 二、Maven 依赖

<!--spring security-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>

# 三、默认配置

@RestController
@RequestMapping("/user")
public class UserController {
    @GetMapping
    public String getUsers() {
        return "Spring Security 测试";
    }
}

默认用户:user,密码: 在启动日志中找

在默认情况下在自动跳转到登录界面,进行登录后就可以访问.

# 禁用安全设置

@SpringBootApplication(exclude = {
        org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration.class
})

加入security的依赖,暂时不使用

# 自定义用户名和密码

spring.security.user.name=admin
spring.security.user.password=123456

# 四、自定义用户认证逻辑

# 1. 添加配置类

@Configuration
public class SecurityConfig  extends WebSecurityConfigurerAdapter {
    @Bean
    UserDetailsService customUserService() {
        return new CustomUserDetailsService();
    }
    // 配置PasswordEncoder
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(customUserService()).passwordEncoder(passwordEncoder());
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin() // 表单登录
                .and()
                .authorizeRequests() // 对请求做授权, 定义哪些URL需要被保护、哪些不需要被保护
                .anyRequest() // 任何请求
          			// ... // 可以添加自定义的权限规则
                .authenticated() // 上面没匹配的都要身份认证
                .and().csrf().disable(); // 暂时将防护跨站请求伪造的功能置为不可用
    }
}
  • configure(AuthenticationManagerBuilder auth)定义如果控制权限 AuthenticationManagerBuilder是spring security的切入点.
  • configure(HttpSecurity http) 定义客户端如何访问 csrf是指在提交表单的时生成 csrf token. 用户防止黑客伪造,本身是一个 随机 的属性在 cookie 中.关闭是为了前后端分离,或者使用jwt请求,如果是表单登录,csrf 可以开启.

可以使用加密算法规则

加密算法 PasswordEncoder 实现类
plaintext PlaintextPasswordEncoder
sha ShaPasswordEncoder
sha-256 ShaPasswordEncoder,使用时 new ShaPasswordEncoder(256)
md4 Md4PasswordEncoder
md5 Md5PasswordEncoder
{sha} LdapShaPasswordEncoder
{ssha} LdapShaPasswordEncoder

# 2. 添加自定义用户类

@Slf4j
public class CustomUserDetailsService implements UserDetailsService {
    // 自带加密类
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("登录用户名:" + username);
        // 根据用户名查找用户信息
        /**
         * 简单的对123456进行了加密的处理。我们可以进行测试,
         * 发现每次打印出来的password都是不一样的,这就是配置的BCryptPasswordEncoder所起到的作用。
         */
        String password = passwordEncoder.encode("123456");
        // 参数:1.账号 2.密码 3.账户是否可用(删除) 4.账户是否过期  5.密码是否过期  6.账户是否被锁定(冻结)7.角色
        return new User(username, password,
                true, true, true, true,
                AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

UserDetailsService是一个关键点. AuthenticationManagerBuilder 是 spring security 的切入点,则 UserDetailsService就是security 框架和本地自定义的权限架构相结合的点.通过实现UserDetailsService,实现程序的权限功能.

# 五、单元测试

在写单元测试时

  1. 需要模拟某个用户的登录状态
  2. 需要模拟某个用户具有某个权限,但又不想改变数据库
  3. 需求完整调用某个用户的登录

需要使用的注解

  • @WithMockUser 模拟用户,手动指定用户名和授权
  • @WithAnonymousUser 模拟匿名用户
  • @WithUserDetails 模拟用户,给定用户名,通过自定义 UserDetails 来认证
  • @WithSecurityContext 通过 SecurityContext 构造器模拟用户
  1. 测试基类
@RunWith(SpringRunner.class)
@SpringBootTest
@Slf4j
public class BaseAdminApplicationTest {
    // 模拟MVC对象,通过MockMvcBuilders.webAppContextSetup(this.wac).build()初始化。
    public MockMvc mockMvc;
    // 注入WebApplicationContext
    @Autowired
    private WebApplicationContext wac;

    // 在测试开始前初始化工作
    @Before
    public void setup() {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).apply(springSecurity()).build();
    }
}
  1. 单元测试类
@Slf4j
public class AuthTest extends BaseAdminApplicationTest {
    /**
     * 1.登录测试
     *
     * @throws Exception
     */
    @Test
    public void testFormLoginSuccess() throws Exception {
        // 测试登录成功
        mockMvc
                .perform(formLogin("/login").user("admin").password("123456"))
                .andExpect(authenticated());
    }

    /**
     * 2. 测试登录失败
     *
     * @throws Exception
     */
    @Test
    public void testFormLoginFail() throws Exception {
        mockMvc
                .perform(formLogin("/login").user("admin").password("invalid"))
                .andExpect(unauthenticated());
    }

    /**
     * 测试退出登录
     *
     * @throws Exception
     */
    @Test
    public void testLogoutFail() throws Exception {
        // 测试退出登录
        mockMvc.perform(logout("/logout")).andExpect(unauthenticated());
    }
}

# 六、spring-security-jwt

# 1. JWT 依赖

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

# 2. 创建 JWT 用户类

@Data
public class JwtUser implements UserDetails {

    private Long id;
    private String username;
    private String password;
    private Collection<? extends GrantedAuthority> authorities;

    public JwtUser() {
    }

    // 写一个能直接使用user创建jwtUser的构造器
    public JwtUser(User user) {
        id = user.getId();
        username = user.getUsername();
        password = user.getPassword();
        authorities = Collections.singleton(new SimpleGrantedAuthority(user.getRole()));
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    @Override
    public String toString() {
        return "JwtUser{" +
                "id=" + id +
                ", username='" + username + '\'' +
                ", password='" + password + '\'' +
                ", authorities=" + authorities +
                '}';
    }
}

注意: 实现 UserDetails, spring security有一个默认的实现,org.springframework.security.core.userdetails.User

这个类也同样是可以使用.

再次实现只是方便 数据库定义的 用户对象 转化成 UserDetails,方便操作.

# 2. 创建登录用户类

作用:获取表单登录信息与数据库实体不一定对应。

这个类的总用是保存用户登录信息,可以写为 LoginUserDTO 更合适.

@Data
public class LoginUser {
    private String username;
    private String password;
    private Integer rememberMe;
}

# 3. 数据库用户类

@Data
public class User {
    private Long id;
    private String username;
    private String password;
    private String role;
}

# 4. JWT 工具类

@Slf4j
public class JwtTokenUtils {
    public static final String TOKEN_HEADER = "Authorization";
    public static final String TOKEN_PREFIX = "Bearer ";

    private static  String SECRET = "xxxxxxxx";
    private static final String ISS = "echisan";

    // 角色的key
    private static final String ROLE_CLAIMS = "role";

    // 过期时间是3600秒,既是1个小时
    private static final long EXPIRATION = 60 * 60L;

    // 选择了记住我之后的过期时间为7天
    private static final long EXPIRATION_REMEMBER = 7 * 24 * 60 * 60L;

    /**
     * 创建token
     *
     * @param username
     * @param role
     * @param isRememberMe
     * @return
     */
    public static String createToken(String username, String role, boolean isRememberMe,String secret) {
        long expiration = isRememberMe ? EXPIRATION_REMEMBER : EXPIRATION;
        HashMap<String, Object> map = new HashMap<>();
        if(!secret.isEmpty()){
            SECRET=secret;
        }
        map.put(ROLE_CLAIMS, role);
        return Jwts.builder()
                .signWith(SignatureAlgorithm.HS512, SECRET)
                .setClaims(map)
                .setIssuer(ISS)
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
                .compact();
    }

    /**
     * 从token中获取用户名
     *
     * @param token
     * @return
     */
    public static String getUsername(String token) {
        return getTokenBody(token).getSubject();
    }

    /**
     * 获取用户角色
     *
     * @param token
     * @return
     */
    public static String getUserRole(String token) {
        return (String) getTokenBody(token).get(ROLE_CLAIMS);
    }

    /**
     * 是否已过期
     *
     * @param token
     * @return
     */
    public static boolean isExpiration(String token) {
        return getTokenBody(token).getExpiration().before(new Date());
    }

    /**
     * 获取令牌体
     *
     * @param token
     * @return
     */
    private static Claims getTokenBody(String token) {
        return Jwts.parser()
                .setSigningKey(SECRET)
                .parseClaimsJws(token)
                .getBody();
    }
}

# 5. 创建认证过滤器

@Slf4j
public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private ThreadLocal<Integer> rememberMe = new ThreadLocal<>();
    private AuthenticationManager authenticationManager;

    public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
        // 设置过滤器处理地址
        super.setFilterProcessesUrl("/auth/login");
    }

    /**
     *
     * @param request
     * @param response
     * @return
     * @throws AuthenticationException
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,HttpServletResponse response) throws AuthenticationException {
        try {
            // 获取登录信息
            LoginUser loginUser = new ObjectMapper().readValue(request.getInputStream(), LoginUser.class);
             log.info("登录用户:"+loginUser.getUsername()+"密码:"+loginUser.getPassword());
            // 记住登录信息
            rememberMe.set(loginUser.getRememberMe());
            // 进行认证
            return authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(loginUser.getUsername(), loginUser.getPassword(), new ArrayList<>())
            );
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }
    }
    /**
     *  成功验证后调用的方法
     *  如果验证成功,就生成token并返回
     * @param request
     * @param response
     * @param chain
     * @param authResult
     * @throws IOException
     * @throws ServletException
     */
    @Override
    protected void successfulAuthentication(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain chain,
        Authentication authResult) throws IOException, ServletException {
        // 获取Jwt用户信息
        JwtUser jwtUser = (JwtUser) authResult.getPrincipal();
        System.out.println("用户:" + jwtUser.toString()+"密码:"+jwtUser.getPassword());
        // 记住登录信息
        boolean isRemember = rememberMe.get() == 1;
        // 获取授权信息
        String role = "";
        Collection<? extends GrantedAuthority> authorities = jwtUser.getAuthorities();
        for (GrantedAuthority authority : authorities) {
            role = authority.getAuthority();
        }
        // 创建令牌
        String token = JwtTokenUtils.createToken(jwtUser.getUsername(), role, isRemember,jwtUser.getPassword());
//        String token = JwtTokenUtils.createToken(jwtUser.getUsername(), false);
        // 返回创建成功的token
        // 但是这里创建的token只是单纯的token
        // 按照jwt的规定,最后请求的时候应该是 `Bearer token`
        response.setHeader("token", JwtTokenUtils.TOKEN_PREFIX + token);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        response.getWriter().write("认证失败, 原因: " + failed.getMessage());
    }
}

# 6. 创建授权过滤器

public class JWTAuthorizationFilter extends BasicAuthenticationFilter {

    public JWTAuthorizationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    /**
     *
     * @param request
     * @param response
     * @param chain
     * @throws IOException
     * @throws ServletException
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws IOException, ServletException {
        // 获取请求头信息
        String tokenHeader = request.getHeader(JwtTokenUtils.TOKEN_HEADER);
        // 如果请求头中没有Authorization信息则直接放行了
        if (tokenHeader == null || !tokenHeader.startsWith(JwtTokenUtils.TOKEN_PREFIX)) {
            chain.doFilter(request, response);
            return;
        }
        // 如果请求头中有token,则进行解析,并且设置认证信息
        SecurityContextHolder.getContext().setAuthentication(getAuthentication(tokenHeader));
        super.doFilterInternal(request, response, chain);
    }

    /**
     * 这里从token中获取用户信息并新建一个token
     * @param tokenHeader
     * @return
     */
    private UsernamePasswordAuthenticationToken getAuthentication(String tokenHeader) {
        // 获取令牌
        String token = tokenHeader.replace(JwtTokenUtils.TOKEN_PREFIX, "");
        // 获取用户信息
        String username = JwtTokenUtils.getUsername(token);
        // 获取角色信息
        String role = JwtTokenUtils.getUserRole(token);
        if (username != null){
            return new UsernamePasswordAuthenticationToken(username, null,
                    Collections.singleton(new SimpleGrantedAuthority(role))
            );
        }
        return null;
    }
}

不要声明成 Bean(即不加@Component)

# 1. 创建配置

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    @Qualifier("userDetailsServiceImpl")
    private UserDetailsService userDetailsService;

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder(){
        return new BCryptPasswordEncoder();
    }

    /**
     * 设置加密方式
     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
    }

    /**
     * 配置http权限认证
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable()
                .authorizeRequests()
                // 测试用资源,需要验证了的用户才能访问
                .antMatchers("/user/**").authenticated()
                .antMatchers(HttpMethod.DELETE, "/user/**").hasRole("ADMIN")
                // 其它的都可以访问
                .anyRequest().permitAll()
                .and()
                // 添加认证过滤器
                .addFilter(new JWTAuthenticationFilter(authenticationManager()))
                // 添加授权过滤器
                .addFilter(new JWTAuthorizationFilter(authenticationManager()))
                // 不需要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                // 添加异常处理
                .exceptionHandling().authenticationEntryPoint(new JWTAuthenticationEntryPoint());
    }

    /**
     * 跨域请求配置
     * @return
     */
    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", new CorsConfiguration().applyPermitDefaultValues());
        return source;
    }
}

# 2. 统一认证异常处理

public class JWTAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=utf-8");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        String reason = "认证失败,原因:"+authException.getMessage();
        response.getWriter().write(new ObjectMapper().writeValueAsString(reason));
    }
}

# 3. 用户业务类

@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    private BCryptPasswordEncoder bCryptPasswordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
      // 通过username在数据库中查找用户,这里模拟
        User user=new User();
        user.setUsername("admin");
        user.setPassword(bCryptPasswordEncoder.encode("123456"));
        user.setId(1L);
        user.setRole("admin");
        return new JwtUser(user);
    }

}

# 4. 控制器类

验证控制器:

@RestController
@RequestMapping("/auth")
@Slf4j
public class AuthController {
    @Autowired
    private BCryptPasswordEncoder bCryptPasswordEncoder;

    @PostMapping("/register")
    public String registerUser(@RequestBody Map<String,String> registerUser){
        User user = new User();
        user.setUsername(registerUser.get("username"));
        user.setPassword(bCryptPasswordEncoder.encode(registerUser.get("password")));
        user.setRole("ROLE_USER");
        return "注册成功";
    }
}

用户控制器

@RestController
@RequestMapping("/user")
public class UserController {
    @GetMapping
    public String getUsers() {
        return "Spring Security 测试";
    }
}

# 七、认证测试

  1. 获取令牌 localhost:8000/auth/login

img

获取令牌

  1. 资源访问 localhost:8000/user

img

资源访问

# 八、JWTToken 超时刷新

# 九、spring security 退出功能

注销默认地址:/logout

spring security 实现注销功能涉及的三个核心类为 LogoutFilter,LogoutHandler,LogoutSuccessHandler

  • LoginFilter 是实现注销功能的过滤器,默认拦截/logout 或者 logout 属性 logout-url 指定的 url
  • LogoutHandler 接口定义了退出登录操作的方法
  • LogoutSuccessHandler 接口定义了注销之后的操作方法

登出处理器:

@Component
@Slf4j
public class LogoutAuthenticationHandler implements LogoutSuccessHandler {
    @Override
    public void onLogoutSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
        // TODO 注意:要想在此获取authentication数据,登录成功后,必须调用一次资源,才可获取。
        // 与 Authentication auth = SecurityContextHolder.getContext().getAuthentication(); 等同
        log.info(authentication.getPrincipal() + "");
        // 在此可以删除redis中保存的token数据
        log.info("注销成功");
        httpServletResponse.setContentType("application/json;charset=utf-8");
        httpServletResponse.getWriter().write(new ObjectMapper().writeValueAsString("ok"));
    }
}

logout 配置属性:

属性名 作用
invalidate-session 表示是否要在退出登录后让当前 session 失效,默认为 true。
delete-cookies 指定退出登录后需要删除的 cookie 名称,多个 cookie 之间以逗号分隔。
logout-success-url 指定成功退出登录后要重定向的 URL。需要注意的是对应的 URL 应当是不需要登录就可以访问的。
success-handler-ref 指定用来处理成功退出登录的 LogoutSuccessHandler 的引用。

退出登录:

 Authentication auth = SecurityContextHolder.getContext().getAuthentication();
   if (auth != null){
       new SecurityContextLogoutHandler().logout(request, response, auth);
   }

# 十、授权表达式

表达式 说明
permitAll 永远返回 true
denyAll 永远返回 false
anonyous 当前用户若是匿名用户返回 true
rememberMe 当前用户若是 rememberMe 用户返回 true
authenticated 当前用户若不是匿名(已认证)用户返回 true
fullAuthenticated 当前用户若既不是匿名用户又不是 rememberMe 用户时返回 true
hasRole(role) 当前用户权限集合中若拥有指定的 role 角色权限(匹配时会在你所指定的权限前加'ROLE_',即判断是否有“ROLE_role”权限)时返回 true
hasAnyRole(role1, role2, ...) 当前用户权限集合中若拥有任意一个角色权限时返回 true
hasAuthority(authority) 当前用户权限集合中若具有 authority 权限(匹配是否有“authority”权限)时返回 true
hasAnyAuthority(authority) 当前用户权限集合中若拥有任意一个权限时返回 true
hasIpAddress("192.168.1.0/24") 发送请求的 IP 匹配时 返回true

基于角色的访问控制 RBAC 数据模型(Role-Based Access Control)

# 十一、Spring Security 过滤器链及认证流程

spring Security 功能的实现主要是由一系列过滤器链相互配合完成。

img

Spring Security 过滤器链及认证流程

过滤器链中主要的几个过滤器及其作用:

  1. SecurityContextPersistenceFilter 会在请求开始时从配置好的 SecurityContextRepository 中获取 SecurityContext,然后把它设置给 SecurityContextHolder。在请求完成后将 SecurityContextHolder 持有的 SecurityContext 再保存到配置好的 SecurityContextRepository,同时清除 securityContextHolder 所持有的 SecurityContext;

  2. UsernamePasswordAuthenticationFilter 用于处理来自表单提交的认证。该表单必须提供对应的用户名和密码,其内部还有登录成功或失败后进行处理的 AuthenticationSuccessHandler 和 AuthenticationFailureHandler,这些都可以根据需求做相关改变;

  3. FilterSecurityInterceptor 是用于保护 Http 资源的,它需要一个 AccessDecisionManager 和一个 AuthenticationManager 的引用。它会从 SecurityContextHolder 获取 Authentication,然后通过 SecurityMetadataSource 可以得知当前请求是否在请求受保护的资源。对于请求那些受保护的资源,如果 Authentication.isAuthenticated()返回 false 或者 FilterSecurityInterceptor 的 alwaysReauthenticate 属性为 true,那么将会使用其引用的 AuthenticationManager 再认证一次,认证之后再使用认证后的 Authentication 替换 SecurityContextHolder 中拥有的那个。然后就是利用 AccessDecisionManager 进行权限的检查;

  4. ExceptionTranslationFilter 能够捕获来自 FilterChain 所有的异常,并进行处理。

    但是它只会处理两类异常:AuthenticationException 和 AccessDeniedException,其它的异常它会继续抛出。

    --- 如果捕获到的是 AuthenticationException,那么将会使用其对应的 AuthenticationEntryPoint 的 commence()处理。在处理之前,ExceptionTranslationFilter 先使用 RequestCache 将当前的 HttpServerletRequest 的信息保存起来,以至于用户成功登录后可以跳转到之前的界面;

    --- 如果捕获到的是 AccessDeniedException,那么将视当前访问的用户是否已经登录认证做不同的处理,如果未登录,则会使用关联的 AuthenticationEntryPoint 的 commence()方法进行处理,否则将使用关联的 AccessDeniedHandler 的 handle()方法进行处理。

# 十二、认证流程

认证流程分为登录流程和注销流程: (注意:这里的登录方式为"帐号+密码";箭头代表整个流程执行方向)

# 登录流程

---> SecurityContextPersistenceFilter(作用在第一节已经介绍)

---> UsernamePasswordAuthenticationFilter (先获取用户名和密码,并将其封装成 UsernamePasswordToken,然后调用 AuthenticationManager 进行验证)

---> AuthenticationManager (根据 token 类型选择合适的 AuthenticationProvider 来处理认证请求) [默认实现类:ProviderManager] AuthenticationManager 是一个用来处理请求的接口,它自己不直接处理认证请求,而是委托给其所配置的 AuthenticationProvider 列表,然后会依次使用每一个 AuthenticationProvider 进行认证,如果有一个 AuthenticationProvider 认证后的结果不为 null,则表示该 AuthenticationProvider 已经认证成功,之后的 AuthenticationProvider 将不再继续认证。然后直接以该 AuthenticationProvider 的认证结果作为 ProviderManager 的认证结果。如果所有的 AuthenticationProvider 的认证结果都为 null,则表示认证失败,将抛出一个 ProviderNotFoundException。

---> AuthenticationProvider (请求认证处理) [默认实现类:DaoAuthencationProvider] DaoAuthenticationProvider 认证过程:

DaoAuthenticationProvider 先调用 UserDetailsService 的 loadUserByUsername()方法获取 UserDetails,获取后再与 UsernamePasswordAuthenticationFilter 获取的 username 和 password 进行比较;如果认证通过后会将该 UserDetails 赋给认证通过的 Authentication 的 principal,然后再把该 Authentication 存入到 SecurityContext 中。默认情况下,在认证成功后 ProviderManager 也将清除返回的 Authentication 中的凭证信息。

注意:在这里面根据需要增加[自定义关键类(UserDetailService):实现 UserDetailService 接口并复写 loadUserByUsername()]

问:为什么 AuthenticationManager 不直接认证请求?

答:因为 token 有多种类型。比如最简单的 UsernamePasswordAuthenticationToken,还有 spring social 的 token;

---> Authentication 对象

Spring Security 使用一个 Authentication 对象来描述当前用户的相关信息。SecurityContextHolder 中持有的是当前用户的 SecurityContext,而 SecurityContext 持有的是代表当前用户相关信息的 Authentication 的引用。这个 Authentication 对象不需要我们自己去创建,在与系统交互的过程中,Spring Security 会自动为我们创建相应的 Authentication 对象,然后赋值给当前的 SecurityContext。

# 注销流程

  1. 使 HttpSession 失效;
  2. 清除所有已经配置的 remember-me 认证;
  3. 清除 SecurityContextHolder 中的 user 信息,并设置 Authentication 中的 Authenticated 属性为 false;
  4. 跳转到指定 url;

#

认证提供:

/**
 * @描 述:
 *  AuthenticationProvider(身份验证提供者) 顾名思义,可以提供一个 Authentication 供Spring Security的上下文使用
 *  1. 创建CustomAuthenticationProvider类
 *  2. 当 CustomAuthenticationProvider 认证成功之后,JWTLoginFilter 中的 successfulAuthentication() 方法机会执行
 *  本类没有用到
 */
@Slf4j
public class CustomAuthenticationProvider  implements AuthenticationProvider {

    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private BCryptPasswordEncoder bCryptPasswordEncoder;
    /**
     *  验证登录信息,若登陆成功,设置 Authentication
     * @param authentication
     * @return
     * @throws AuthenticationException
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // 获取认证的用户名 & 密码
        String username = authentication.getName();
        String password = authentication.getCredentials().toString();
        //通过用户名从数据库中查询该用户
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        //判断密码是否正确
        String dbPassword = userDetails.getPassword();
        String encoderPassword = bCryptPasswordEncoder.encode(password);
        if (!dbPassword.equals(encoderPassword)) {
//            throw new UsernameIsExitedException("密码错误");
            log.info("密码错误");
        }
        Authentication auth = new UsernamePasswordAuthenticationToken(username, password, userDetails.getAuthorities());
        return auth;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        // 进行验证
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }
}


# 十三、数据权限校验和访问限制

Spring Security 为我们定义了 hasPermission 的两种使用方式,它们分别对应着 PermissionEvaluator 的两个不同的 hasPermission()方法。Spring Security 默认处理 Web、方法的表达式处理器分别为 DefaultWebSecurityExpressionHandler 和 DefaultMethodSecurityExpressionHandler,它们都继承自 AbstractSecurityExpressionHandler,其所持有的 PermissionEvaluator 是 DenyAllPermissionEvaluator,其对于所有的 hasPermission 表达式都将返回 false。

需要注意的是,Spring Security 默认的角色前缀是”ROLE*”,使用 hasRole 方法时已经默认加上了,因此我们在数据库里面的用户角色应该是“ROLE_user”,在 user 前面加上”ROLE*”前缀。

  1. 自定义 PermissionEvaluator
@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {
    @Autowired
    private IRoleService iRoleService;

    /**
     * 在 hasPermission() 方法中,参数 1 代表用户的权限身份,参数 2 参数 3 分别和
     * @PreAuthorize("hasPermission('/admin','r')") 中的参数对应,即访问 url 和权限。
     * @param authentication
     * @param targetUrl
     * @param targetPermission
     * @return
     */
    @Override
    public boolean hasPermission(Authentication authentication, Object targetUrl, Object targetPermission) {
        // 获得loadUserByUsername()方法的结果
        JwtUser jwtUser = (JwtUser)authentication.getPrincipal();
        // 获得loadUserByUsername()中注入的角色
        Collection<GrantedAuthority> authorities = jwtUser.getAuthorities();
// 遍历用户所有角色
        for(GrantedAuthority authority : authorities) {
            String roleName = authority.getAuthority();

//            Integer roleId = iRoleService.selectByName(roleName).getId();
//            // 得到角色所有的权限
//            List<SysPermission> permissionList = permissionService.listByRoleId(roleId);
//
//            // 遍历permissionList
//            for(SysPermission sysPermission : permissionList) {
//                // 获取权限集
//                List permissions = sysPermission.getPermissions();
//                // 如果访问的Url和权限用户符合的话,返回true
//                if(targetUrl.equals(sysPermission.getUrl())
//                        && permissions.contains(targetPermission)) {
//                    return true;
//                }
//            }

        }
        return false;
    }

    @Override
    public boolean hasPermission(Authentication authentication, Serializable serializable, String s, Object o) {
        return false;
    }
}
  1. 添加配置类

注意:

@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)

注解只能添加一次。

@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {
    @Autowired
    private CustomPermissionEvaluator customPermissionEvaluator;

    /**
     * 注入自定义PermissionEvaluator
     */
    @Override
    protected MethodSecurityExpressionHandler createExpressionHandler() {
        DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
        expressionHandler.setPermissionEvaluator(customPermissionEvaluator);
        return expressionHandler;
    }
}

  1. 添加注解进行测试
// 要与CustomPermissionEvaluator中的方法参数对应
@PreAuthorize("hasPermission('/system/user/users','read')")


@PreAuthorize: 在方法调用前,基于表达式计算结果来限制方法访问

@PostAuthorize: 允许方法调用,但是如果表达式结果为 fasle 则抛出异常

@PostFilter :允许方法调用,但必须按表达式过滤方法结果。

@PreFilter:允许方法调用,但必须在进入方法前过滤输入值 Spring 的 @PreAuthorize/@PostAuthorize 注解更适合方法级的安全,也支持 Spring 表达式语言,提供了基于表达式的访问控制。

@PreAuthorize 注解适合进入方法前的权限验证, @PreAuthorize 可以将登录用户的 roles/permissions 参数传到方法中。

@PostAuthorize 注解使用并不多,在方法执行后再进行权限验证。 所以它适合验证带有返回值的权限。

    @PostAuthorize ("returnObject.type == authentication.name")
    User findById(int id);

    @PreAuthorize("hasRole('ADMIN')")
    void updateUser(User user);

    @PreAuthorize("hasRole('ADMIN') AND hasRole('DBA')")
    void deleteUser(int id);

Spring Security 允许我们在定义 URL 访问或方法访问所应有的权限时使用 Spring EL 表达式,在定义所需的访问权限时如果对应的表达式返回结果为 true 则表示拥有对应的权限,反之则无。Spring Security 可用表达式对象的基类是 SecurityExpressionRoot,其为我们提供了如下在使用 Spring EL 表达式对 URL 或方法进行权限控制时通用的内置表达式。

表达式 描述
hasRole([role]) 当前用户是否拥有指定角色。
hasAnyRole([role1,role2]) 多个角色是一个以逗号进行分隔的字符串。如果当前用户拥有指定角色中的任意一个则返回 true。
hasAuthority([auth]) 等同于 hasRole
hasAnyAuthority([auth1,auth2]) 等同于 hasAnyRole
Principle 代表当前用户的 principle 对象
authentication 直接从 SecurityContext 获取的当前 Authentication 对象
permitAll 总是返回 true,表示允许所有的
denyAll 总是返回 false,表示拒绝所有的
isAnonymous() 当前用户是否是一个匿名用户
isRememberMe() 表示当前用户是否是通过 Remember-Me 自动登录的
isAuthenticated() 表示当前用户是否已经登录认证成功了。
isFullyAuthenticated() 如果当前用户既不是一个匿名用户,同时又不是通过 Remember-Me 自动登录的,则返回 true。

hasPermission 表达式:

Spring Security 为我们定义了 hasPermission 的两种使用方式,它们分别对应着 PermissionEvaluator 的两个不同的 hasPermission()方法。Spring Security 默认处理 Web、方法的表达式处理器分别为 DefaultWebSecurityExpressionHandler 和 DefaultMethodSecurityExpressionHandler,它们都继承自 AbstractSecurityExpressionHandler,其所持有的 PermissionEvaluator 是 DenyAllPermissionEvaluator,其对于所有的 hasPermission 表达式都将返回 false。

所以当我们要使用表达式 hasPermission 时,我们需要自已手动定义 SecurityExpressionHandler 对应的 bean 定义,然后指定其 PermissionEvaluator 为我们自己实现的 PermissionEvaluator,然后通过 global-method-security 元素或 http 元素下的 expression-handler 元素指定使用的 SecurityExpressionHandler 为我们自己手动定义的那个 bean。

# 十四、会话管理 Session Management

  1. 配置 SecurityConfig.java
    @Autowired
    SessionRegistry sessionRegistry;
// ============配置http权限认证方法
// protected void configure(HttpSecurity http) throws Exception{}
// 添加
// .and()
// .sessionManagement()
// .maximumSessions(1)                     // 控制单个用户只能创建一个session,也就只能在服务器登录一次
// .sessionRegistry(sessionRegistry)     // 注册session
// .and()
// .and()
// .logout()
// ============
    /**
     * Session 注册
     * @return
     */
    @Bean
    public SessionRegistry sessionRegistry(){
        SessionRegistry sessionRegistry=new SessionRegistryImpl();
        return sessionRegistry;
    }
  1. SessionRegistry 保存了所有认证成功后用户的 SessionInformation 信息,每次用户访问服务器的会从 sessionRegistry 中查询出当前用户的 session 信息 ,判断是否过期以及刷新最后一次方法时间,默认的实现类 SessionRegistryImpl,监听了 session 的销毁事件,若销毁,那么删除掉 session 信息。

  2. SessionInformation

    • SessionInformation :记录认证用户的 session 信息 。
    • lastRequest:最后一次访问次数
    • principal:认证用户信息
    • sessionId:session 的 id
    • expired:是否过期
  3. SessionAuthenticationStrategy 实现类:

    • ChangeSessionIdAuthenticationStrategy:

      调用 HttpServletRequest 的 changeSessionId 方法改变 sessionid

    • SessionFixationProtectionStrategy:

      首先让原来的 session 过期,然后创建一个新的 session,把原来 session 的属性拷贝到新的 session 中

    • RegisterSessionAuthenticationStrategy: 用户认证成功后 sessionRegistry 调用 registerNewSession,保存用户的信息和 session

    • ConcurrentSessionControlAuthenticationStrategy: 允许用户同时在线数,有一个 maximumSessions 属性,默认是 1。通过 sessionRegistry 判断用户数是否已经超过了最大允许数,若超过了,那么就让最近一个的 session 过期(让上一个用户强制下线)。

    • 默认创建的 SessionAuthenticationStrategy 是组合 CompositeSessionAuthenticationStrategy。

    @Autowired
    SessionRegistry sessionRegistry;
    @Autowired
    ConcurrentSessionControlAuthenticationStrategy concurrentSessionControlAuthenticationStrategy;
// 添加验证策略
.and()
.sessionManagement()
.sessionAuthenticationStrategy(concurrentSessionControlAuthenticationStrategy) // 验证策略
.maximumSessions(1)                     // 控制单个用户只能创建一个session,也就只能在服务器登录一次
.sessionRegistry(sessionRegistry)     // 注册session
.and()
.and()

/**
 * Session 注册
 *
 * @return
 */
@Bean
public SessionRegistry sessionRegistry() {
    SessionRegistry sessionRegistry = new SessionRegistryImpl();
    return sessionRegistry;
}

@Bean
public ConcurrentSessionControlAuthenticationStrategy concurrentSessionControlAuthenticationStrategy() {
    return new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry);
}

  1. 自定义策略 CompositeSessionAuthenticationStrategy:组合使用多个 SessionAuthenticationStrategy
@Slf4j
public class ControlAuthenticationStrategy  implements SessionAuthenticationStrategy {
    @Override
    public void onAuthentication(Authentication authentication, HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws SessionAuthenticationException {
        JwtUser jwtUser=(JwtUser)authentication.getPrincipal();
        if(条件不满足){
            // 抛出异常
            throw new SessionAuthenticationException("同一个账号不可以,在两个地方登录");
        }
    }
}

  1. 并发用户示例

# 1)实体类(重写 equals 与 hashCode 方法)

    @Override
    public boolean equals(Object rhs) {
        if (rhs instanceof JwtUser) {
            return username.equals(((JwtUser) rhs).username);
        }
        return false;
    }
    @Override
    public int hashCode() {
        return username.hashCode();
    }

# 2)工具类

public class SessionUtils {
    /**
     * 辨别用户是否已经登录
     *
     * @param request
     * @param sessionRegistry
     * @param loginedUser
     */
    public static boolean userIsLogin(HttpServletRequest request, HttpServletResponse response, SessionRegistry sessionRegistry, JwtUser loginedUser) throws IOException {
        // 获取上下文
        SecurityContext sc = SecurityContextHolder.getContext();

        // TODO 获取当前用户的所有session信息
        List<SessionInformation> sessionsInfo = sessionRegistry.getAllSessions(sc.getAuthentication().getPrincipal(), false);
        // 如果Sessiong不为空,大小为0
        if (null != sessionsInfo && sessionsInfo.size() == 0) {
            saveOrDeleteOnlineUser(Type.SAVE);
            sessionRegistry.registerNewSession(request.getSession().getId(), sc.getAuthentication().getPrincipal());
        }
        // TODO 获取当前sessionID
        String currentSessionId = request.getSession().getId();
        // 获取所有注册用户列表
        List<Object> o = sessionRegistry.getAllPrincipals();
        // 遍历所有用户
        for (Object principal : o) {
            // 比较当前登录用户loginedUser 与  用户列表中的用户principal 用户名是否一致
            if (principal instanceof JwtUser && (loginedUser.getUsername().equals(((JwtUser) principal).getUsername()))) {
                // 获取principal 用户的session列表
                List<SessionInformation> oldSessionsInfo = sessionRegistry.getAllSessions(principal, false);
                // 如果有会话存在,且sessionId与当前session不相同,则认为有两个用户登录
                if (null != oldSessionsInfo && oldSessionsInfo.size() > 0 && !oldSessionsInfo.get(0).getSessionId().equals(currentSessionId)) {
                    return true;
                }
            }
        }
        return false;
    }
}

# 十五、logout 属性详解

  • logout-url LogoutFilter 要读取的 url,也就是指定 spring security 拦截的注销 url
  • logout-success-url 用户退出后要被重定向的 url
  • invalidate-session 默认为 true,用户在退出后 Http session 失效
  • success-handler-ref 对一个 LogoutSuccessHandler 的引用,用来自定义退出成功后的操作 4.x 则默认使用/logout

spring security 退出功能相关类

spring security 实现注销功能涉及的三个核心类为 LogoutFilter,LogoutHandler,LogoutSuccessHandler

LoginFilter 是实现注销功能的过滤器,默认拦截/logout 或者 logout 属性 logout-url 指定的 url

LogoutHandler 接口定义了退出登录操作的方法:

void logout(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication);

LogoutSuccessHandler 接口定义了注销之后的操作方法:

void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) throws IOException, ServletException;

spring security 退出功能实现流程

spring security 在实现注销功能时,大致流程如下

  1. 使得 HTTP session 失效(如果 invalidate-session 属性被设置为 true);
  2. 清除 SecurityContex(真正使得用户退出)
  3. 将页面重定向至 logout-success-url 指明的 URL。

# 十六、常见问题

  1. Encoded password does not look like BCrypt 添加配置:
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(customUserService()).passwordEncoder(passwordEncoder());

    }
  1. Spring Security 拦截器引起 Java CORS 跨域失败的问题

response 响应头:

响应头字段名称 作用
Access-Control-Allow-Origin 允许访问的客户端的域名
Access-Control-Allow-Credentials 是否允许请求带有验证信息,若要获取客户端域下的 cookie 时,需要将其设置为 true。
Access-Control-Allow-Headers 允许服务端访问的客户端请求头
Access-Control-Allow-Methods 允许访问的 HTTP 请求方法
Access-Control-Max-Age 用来指定预检请求的有效期(秒),在有效期内不在发送预检请求直接请求。

解决:

@Configuration
public class WebAppConfigurer extends WebMvcConfigurationSupport {
    private CorsConfiguration buildConfig() {
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedOrigin("*");
        corsConfiguration.addAllowedHeader("*");
        corsConfiguration.addAllowedMethod("*");
        return corsConfiguration;
    }

    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", buildConfig());
        return new CorsFilter(source);
    }
}