技术收藏-开发篇

自定义 Spring Security 功能

2019-11-15  本文已影响0人  一生逍遥一生

使用软件的版本

软件名称 软件版本
MySQL 5.7
spring-boot 2.1.9.RELEASE

自定义数据库模型的认证与授权

在spring-security框架中,提供了两种不同的认证与授权方式:基于内存、基于数据库。
在spring-security源码中有创建表的代码(位置为:spring-security/core/src/main/resources/org/springframework/security/core/userdetails/jdbc/users.ddl,版本为5.1.6.RELEASE):

create table users(username varchar_ignorecase(50) not null primary key,password varchar_ignorecase(500) not null,enabled boolean not null);
create table authorities (username varchar_ignorecase(50) not null,authority varchar_ignorecase(50) not null,constraint fk_authorities_users foreign key(username) references users(username));
create unique index ix_auth_username on authorities (username,authority);

如果有需要的话,也可以创建组,每个组有不同的权限:

create table group(id bigint primary key,group_name varchar_ignorecase(50) not null);
create table group_member(id bigint primary key,group_id bigint,username varchar_ignorecase(50) not null);
create table group_authorities(group_id bigint, authority varchar_ignorecase(50) not null);

相关的类:JdbcUserDetailsManager、JdbcDaoImpl。
在添加权限数据的时候,需要添加前缀ROLE_,在验证用户的权限的过程中,系统会自动添加响应的前缀。

# 文件位置:spring-security/core/src/main/java/org/springframework/security/core/userdetails/User.java
public UserBuilder roles(String... roles) {
    List<GrantedAuthority> authorities = new ArrayList<>(
            roles.length);
    for (String role : roles) {
        Assert.isTrue(!role.startsWith("ROLE_"), () -> role
                + " cannot start with ROLE_ (it is automatically added)");
        authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
    }
    return authorities(authorities);
}

自定义Filter

在进行自定义Filter的过程中,需要实现Filter接口,然后在SpringSecurity的HttpSecurity配置中进行自定义,然后确定filter的执行顺序。

package com.edu.springsecurity.filter;

import com.edu.springsecurity.exception.VerificationCodeException;
import com.edu.springsecurity.handler.CustomAuthenticationFailureHandler;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
//保证一次请求只会通过一次该filter
public class VerificationCodeFilter extends OncePerRequestFilter {

    private AuthenticationFailureHandler authenticationFailureHandler = new CustomAuthenticationFailureHandler();

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
        // 非登录请求不校验验证码
        if (!"/login".equals(httpServletRequest.getRequestURI())) {
            filterChain.doFilter(httpServletRequest, httpServletResponse);
        } else {
            try {
                verificationCode(httpServletRequest);
                filterChain.doFilter(httpServletRequest, httpServletResponse);
            } catch (VerificationCodeException e) {
                authenticationFailureHandler.onAuthenticationFailure(httpServletRequest, httpServletResponse, e);
            }
        }
    }

    public void verificationCode (HttpServletRequest httpServletRequest) throws VerificationCodeException {
        String requestCode = httpServletRequest.getParameter("captcha");
        HttpSession session = httpServletRequest.getSession();
        String savedCode = (String) session.getAttribute("captcha");
        if (!StringUtils.isEmpty(savedCode)) {
            // 随手清除验证码,不管是失败还是成功,所以客户端应在登录失败时刷新验证码
            session.removeAttribute("captcha");
        }
        // 校验不通过抛出异常
        if (StringUtils.isEmpty(requestCode) || StringUtils.isEmpty(savedCode) || !requestCode.equals(savedCode)) {
            throw new VerificationCodeException();
        }
    }
}

自定义AuthenticationProvider

在AbstractUserDetailsAuthenticationProvider中实现了基本的认证流程,通过继承AbstractUserDetailsAuthenticationProvider,并实现retrieveUser和
additionalAuthenticationChecks两个抽象方法即可自定义核心认证过程,灵活性非常高。DaoAuthenticationProvider继承自AbstractUserDetailsAuthenticationProvider,
DaoAuthenticationProvider的用户信息来源于UserDetailsService,并且整合了密码编程的实现。所有的AuthenticationProvider包含的Authentication都来源于UsernamePasswordAuthenticationFilter。

package com.edu.springsecurity.model;

import org.apache.commons.lang3.StringUtils;
import org.springframework.security.web.authentication.WebAuthenticationDetails;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

public class CustomWebAuthenticationDetails extends WebAuthenticationDetails {
    private String imageCode;
    private String savedImageCode;
    public String getImageCode() {
        return imageCode;
    }
    public String getSavedImageCode() {
        return savedImageCode;
    }
    // 补充用户提交的验证码和session保存的验证码
    public CustomWebAuthenticationDetails(HttpServletRequest request) {
        super(request);
        this.imageCode = request.getParameter("captcha");
        HttpSession session = request.getSession();
        this.savedImageCode = (String) session.getAttribute("captcha");
        if (!StringUtils.isEmpty(this.savedImageCode)) {
            // 随手清除验证码,不管是失败还是成功,所以客户端应在登录失败时刷新验证码
            session.removeAttribute("captcha");
        }
    }
}
package com.edu.springsecurity.model;

import org.springframework.security.authentication.AuthenticationDetailsSource;
import org.springframework.security.web.authentication.WebAuthenticationDetails;

import javax.servlet.http.HttpServletRequest;

public class CustomWebAuthenticationDetailsSource implements AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> {
    @Override
    public WebAuthenticationDetails buildDetails(HttpServletRequest context) {
        return new CustomWebAuthenticationDetails(context);
    }
}
package com.edu.springsecurity.provider;

import com.edu.springsecurity.exception.VerificationCodeException;
import com.edu.springsecurity.model.CustomWebAuthenticationDetails;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

@Component
public class CustomAuthenticationProvider extends DaoAuthenticationProvider {
    public CustomAuthenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
        this.setUserDetailsService(userDetailsService);
        this.setPasswordEncoder(passwordEncoder);
    }
    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        //根据用户提交的账号信息封装而来的UsernamePasswordAuthenticationToken。
        CustomWebAuthenticationDetails details = (CustomWebAuthenticationDetails) authentication.getDetails();
        String imageCode = details.getImageCode();
        String saveImageCode = details.getSavedImageCode();
        if (StringUtils.isEmpty(imageCode) || StringUtils.isEmpty(saveImageCode) || !imageCode.equals(saveImageCode)) {
            throw new VerificationCodeException();
        }
        super.additionalAuthenticationChecks(userDetails, authentication);
    }
}

自动登录

自动登录是将用户的信息保存在用户浏览器的cookie中,当用户下次访问时,自动实现校验并建立登录态的一种机制。
Spring Security提供了两种非常好的令牌:1.用散列算法加密用户必要的登录信息并生成令牌;2.数据库等持久性数据存储机制用的持久化令牌。
散列算法在Spring Security中是通过加密几个关键信息实现的:

hashInfo = md5hex(username+":"+expirationtime+":"+password+":"+key)
rememberCookie=base64(username+":"+expirationtime+":"+hashInfo)

expirationTime指本次自动登录的有效期,key为指定的一个散列盐值,用于防止令牌被修改。如果重启服务,key会重新生成,自动登录的cookie失效。

持久化令牌方案中,最核心的是series和token两个值,它们都是用MD5散列过的随机字符串。不同的是,series仅用户使用密码重新登录时更新,而token会在
每一个新的series中都会重新生成。这样可以解决散列加密方案中一个令牌可以同时在多端登录的问题。每个会话都会引发token的更新。其次,自动登录不会导致series
变更,而每次自动登录都需要同时验证series和token两值。
根据Spring Security中PersistenRememberMeToken,可以创建一个persisten_logins表(摘自JdbcTokenRepositoryImpl.java中):

create table persistent_logins (
    username varchar(64) not null, 
    series varchar(64) primary key, 
    token varchar(64) not null, 
    last_used timestamp not null)

注销登录

Spring Security默认注册了一个/logout路由,用户通过访问该路由可以安全地注销其登录状态,包含httpsession失效、清空已配置的Remember-me验证,以及清空
SecurityContextHolder,并在注销成功之后重定向到/login?logout页面。

会话管理

SessionManager是一个会话管理的配置器,其中,防御会话固定攻击的策略有四种:

  • none: 不做任何变动,登录之后沿用旧的session。
  • newSession: 登录之后创建一个新的session。
  • migrateSession(默认启用): 登录之后创建一个新的session,并将旧的session中的数据复制过来。
  • changeSessionId: 不创建新的会话,而使用由Servlet容器提供的会话固定保护。

默认情况下,会话在30分钟内没有活动便会失效。可以自定义session失效策略。

package com.edu.springsecurity.strategy;
import org.springframework.security.web.session.InvalidSessionStrategy;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class CustomInvalidSessionStrategy implements InvalidSessionStrategy {
    @Override
    public void onInvalidSessionDetected(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().write("invalid session!");
    }
}

会话的时间最小为1分钟,如果设置数小于60秒,SpringBoot也会修改为60秒。
在Spring Security中,会话管理最完善的是会话并发控制(maximumSessions用于设置单个用户允许同时在线的最大会话数,如果没有额外配额,新登录的会话踢掉旧的会话)。
需要配置maxSessionPreventsLogin(true)。
使用HttpSessionListener接口监听Session相关事件,并在系统中注册该监听器。HttpSessionEventPublisher类中实现HttpSessionEventPublisher接口,
并转换为Spring的事件机制。

@Bean
public HttpSessionEventPublisher httpSessionEventPublisher(){
    return new HttpSessionEventPublisher();
}

Spring Security为了实现会话并发控制,采用会话信息表来管理用户的会话状态,具体实现见SessionRegistryImpl类。
principals采用了以用户信息为key的设计,如果使用自定义的UserDetails,需要自定义hashCode和equals。

集群会话的解决方法:

  • session保持:粘滞会话,采用IP哈希策略将相同客户端的请求妆发只相同服务器上进行处理。
  • session复制:服务器之间同步session数据,消耗数据贷款,占用大量的资源。
  • session共享:将session存储在独立的地方,在服务器之间共享,增加网络交互、数据容器的读/写性能、稳定性、网络I/O。

Spring Session就是专门用于解决集群会话问题。

package com.edu.springsecurity.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.security.web.session.HttpSessionEventPublisher;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
import org.springframework.session.security.SpringSessionBackedSessionRegistry;
import org.springframework.session.security.web.authentication.SpringSessionRememberMeServices;
@EnableRedisHttpSession
// @EnableSpringWebSession webflux 应用程序
public class HttpSessionConfig {
    @Bean
    public RedisConnectionFactory connectionFactory(){
        return new JedisConnectionFactory();
    }
    @Autowired
    private FindByIndexNameSessionRepository sessionRepository;
    //SpringSessionBackedSessionRegistry是session为spring security提供的
    //用于集群环境下控制会话并发的会话注册表实现类
    @Bean
    public SpringSessionBackedSessionRegistry sessionBackedSessionRegistry(){
        return new SpringSessionBackedSessionRegistry(sessionRepository);
    }
    //httpsession的事件监听,改用session提供的会话注册表
    @Bean
    public HttpSessionEventPublisher httpSessionEventPublisher(){
        return new HttpSessionEventPublisher();
    }
    @Bean
    public SpringSessionRememberMeServices rememberMeServices(){
        SpringSessionRememberMeServices rememberMeServices = new SpringSessionRememberMeServices();
        rememberMeServices.setAlwaysRemember(true);
        return rememberMeServices;
    }   
}

Redis存储用户session相关的数据,默认命名为spring:session。

跨域与CORS(跨站点资源分享)

浏览器解决跨域的方法有多种,包括jsonp、nginx转发和cors等。jsonp和cors都需要后端参与。
jsonp利用script标签可以实现跨域,只支持GET请求。
Access-Control-Allow-Origin:允许来自指定域请求。
Access-Control-Allow-Method:允许请求的方法(GET,POST)。
Access-Control-Allow-Headers:允许携带的头部字段。
Access-Control-Max-Age:本次预检请求的有效期,单位为秒。
Access-Control-Allow-Credentials:携带用户认证信息,Access-Control-Allow-Origin就需要指定url访问。

Spring Security在启用CORS之后,需要添加配置,设置哪些URL、Method可以支持跨域。

@Bean
public CorsConfigurationSource corsConfigurationSource(){
    CorsConfiguration corsConfiguration = new CorsConfiguration();
    //允许谷歌跨域
    corsConfiguration.setAllowedOrigins(Arrays.asList("http://wwww.google.com"));
    //允许使用GET方法和POST方法
    corsConfiguration.setAllowedMethods(Arrays.asList("GET","POST"));
    //允许携带凭证
    corsConfiguration.setAllowCredentials(true);
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    //对所有URL生效
    source.registerCorsConfiguration("/**",corsConfiguration);
    return source;
}

CSRF

防御CSRF的方式:

  • Http Referer:由浏览器添加的一个请求头字段,用于标识请求来源。
  • CsrfToken:系统分发一个CsrfToken,并记录下来,然后和用户的用户名、密码一块校验。

CSRF攻击完全是基于浏览器进行的。Spring Security通过注册一个CsrfFilter来专门处理CSRF攻击。
默认情况下,Spring Security加载的是一个HttpSessionCsrfTokenRepository。HttpSessionCsrfTokenRepository将CsrfToken值存储在HttpSession中,
并指定前段把CsrfToken值放入名为_csrf的请求参数或为X-CSRF-TOKEN的请求头字段里,前端必须用服务器渲染的方式注入CsrfToken值。
服务器端渲染,指的是后台语言通过一些模板引擎生成 html。浏览器端渲染,指的是用 js 去生成 html,前端做路由。

CookieCsrfTokenRepository是一种更加灵活可行的方案,它将CsrfToken存储在用户的cookie内。这样可以减少服务器HttpSession存储的内存消耗;
用cookie存储CsrfToken值时,前端可以用Javascript读取(需要设置该cookie的httpOnly属性为false)。
cookie只有在同域的情况下才能被读取。
LazyCsrfTokenRepository用来延时保存CsrfToken值(允许创建,但只有真正使用时才会被保存),没有独立使用一个csrfTokenRepository,而是专门用于
包裹其他csrfTokenRepository,默认包裹使用HttpSessionCsrfTokenRepository。LazyCsrfTokenRepository覆盖原先csrfTokenRepository的saveToken方法,
使得CsrfFilter中的saveToken方法失去实际的保存效果;然后修改了generateToken,使得CsrfToken在首次调用getToken时,才能真正调用saveToken方法对CsrfToken
进行保存。
从Spring Security 4.1.0.RELEASE的CsrfConfigure默认使用LazyCsrfTokenRepository。

@EnableWebSecurity与过滤器链机制

@EnableWebSecurity是开启Spring Security的默认行为,通过@Import导入了WebSecurityConfiguration类(放入Spring的IOC容器里)。
Spring Boot自动配置web.xml。

参考文献

Spring Session
服务器渲染和浏览器渲染的区别
Spring Security 实战

上一篇下一篇

猜你喜欢

热点阅读