SpringBoot--实战开发--Spring Security(二十八)

一、Spring Security简介

Spring Security,这是一种基于 Spring AOP 和 Servlet 过滤器的安全框架。它提供全面的安全性解决方案,同时在 Web 请求级和方法调用级处理身份确认和授权。
网址:https://spring.io/projects/spring-security
在线文档

认证过程
用户使用用户名和密码进行登录。
Spring Security 将获取到的用户名和密码封装成一个实现了 Authentication 接口的 UsernamePasswordAuthenticationToken。
将上述产生的 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>

三、测试

  1. 创建控制器
@RestController
@RequestMapping("/user")
public class UserController {
    @GetMapping
    public String getUsers() {
        return "Spring Security 测试";
    }
}
  1. 访问接口


    访问接口

    默认用户:user


    密码
结果
  1. 禁用安全设置或者设置对应的用户和密码
    在main方法上配置:
@SpringBootApplication(exclude = {
        org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration.class
})
  1. 配置文件中指定用户名与密码
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(); // 暂时将防护跨站请求伪造的功能置为不可用
    }
}

加密算法 PasswordEncoder 实现类
plaintext PlaintextPasswordEncoder
sha ShaPasswordEncoder
sha-256 ShaPasswordEncoder,使用时new ShaPasswordEncoder(256)
md4 Md4PasswordEncoder
md5 Md5PasswordEncoder
{sha} LdapShaPasswordEncoder
{ssha} LdapShaPasswordEncoder
  1. 添加自定义用户类
@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"));
    }
}

  1. 测试
    访问地址:http://localhost:8000/user
    认证
结果

五、单元测试

在写单元测试时,需要模拟某个用户的登录状态
在写单元测试时,需要模拟某个用户具有某个权限,但又不想改变数据库
编写单元测试时,需求完整调用某个用户的登录

@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());
    }
}

六、自定义登录认证

1.JWT依赖

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

1. 创建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 +
                '}';
    }
}

2. 创建登录用户类

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

@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;
    }
}
  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;
    }
}
  1. 统一认证异常处理
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));
    }
}
  1. 用户业务类
@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);
    }

}
  1. 控制器类
    验证控制器:
@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


    获取令牌
  2. 资源访问
    localhost:8000/user


    资源访问

八、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匹配时fanhui true

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

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

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


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()方法进行处理。

十二、认证流程

认证流程分为登录流程和注销流程:
(注意:这里的登录方式为"帐号+密码";箭头代表整个流程执行方向)
1)登录流程
---> SecurityContextPersistenceFilter(作用在第一节已经介绍)
---> UsernamePasswordAuthenticationFilter
(先获取用户名和密码,并将其封装成UsernamePasswordToken,然后调用AuthenticationManager进行验证)
---> AuthenticationManager
(根据token类型选择合适的AuthenticationProvider来处理认证请求) [默认实现类:ProviderManager]
AuthenticationManager是一个用来处理请求的接口,它自己不直接处理认证请求,而是委托给其所配置的Authentication
Provider列表,然后会依次使用每一个 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。
2)注销流程
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表达式:
pring 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);
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容