自定义 Spring Security 功能

使用软件的版本

软件名称 软件版本
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 实战

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,294评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,780评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,001评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,593评论 1 289
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,687评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,679评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,667评论 3 415
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,426评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,872评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,180评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,346评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,019评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,658评论 3 323
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,268评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,495评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,275评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,207评论 2 352

推荐阅读更多精彩内容