Spring Security(一)身份认证


随着互联网技术的蓬勃发展,正所谓道高一尺,魔高一丈,黑客技术也得到了迅猛的发展。黑客们利用各大网站的安全漏洞实施着各种出人意料的攻击,例如常见的 XSS 跨站脚本攻击,CSRF 跨站请求伪造等方式。众所周知,数据是互联网公司最重要的核心资产之一,一旦被黑客窃取,会给公司带来难以估量的损失。因此 WEB 安全方面的技术愈发收到各大互联网公司的重视,WEB 安全从最根本的角度来讲是对用户身份的识别,从 WEB 后端的角度来看需要确保每一次前端发起的请求都是安全的,用户所访问的数据不应该超过该用户的权限边界。因此用户身份认证,权限校验几乎是每一个系统需要深刻思考的问题,本文会以 Spring Security 框架为例,看看 Spring 是如何思考这个问题的?以及能带给我们什么启发?

Spring Security 设计总览


SecurityFilterChain 过滤器链

Spring Security 的设计是典型的责任链模式,整体上来说 Spring Security 是通过将一系列的 filter 组合成一个 filter chain 完成各个层面的安全检查。链条上的各个 filter 各司其职,具有良好的单一责任性,我们可以方便地将 Spring 内置或是自定义的 filter 添加到链条上扩展 Spring Security 的安全功能。需要注意的是 filter chainfilter 的排列顺序是非常重要的,Spring Security 可以保证内置的 filter 具有严格的排列顺序,当我们添加自定义 filter 时需要考虑顺序所带来的影响,一旦顺序错乱可能会带来不可预知的安全问题。

本图摘自官方文档

Spring Security 的过滤器链设计中我们看到一个有意思的现象,Spring Security 并没有将安全相关的过滤器直接嵌入到 servlet 容器层面的 FilterChain 中,而是将其包裹在一个单独的 SecurityFilterChain 中,再将这整个 chain 嵌入 servlet 容器的过滤器链条。其实这是 Spring 有意为之,这样的设计本身更加高内聚,同时把同一类的 filter 内聚到一个 SecurityFilterChain 中具有更好的配置隔离性,试想一下,对于一个 web 服务端来说,如果需要对不同的客户端采取不同的安全策略,例如 pc 端允许同时存在多个会话,而 app 端只允许同时存在单个会话,针对这样的需求,将多个 filterChain 隔离开独立配置是一种良好的选择,能够有效降低配置的复杂性。

ExceptionTranslationFilter 安全异常处理

Spring Security 中的安全异常主要分为两类,AuthenticationException 身份认证异常以及 AccessDeniedException 访问拒绝异常。这两类异常可能被 SecurityFilterChain 中的任意一个过滤器抛出,最后会被 ExceptionTranslationFilter 捕获并执行异常处理逻辑。

本图摘自官方文档

一旦 AuthenticationException 类型异常被抛出,Spring Security 会认为用户身份认证失败,例如在实际业务场景中可能为登录失败或用户 TOKEN 认证失败。此时会触发系列操作:

  • SecurityContextHolder 上下文中的用户身份信息被清除。

  • 当前请求信息被缓存至 RequestCache,当用户成功认证后,该请求会被重新执行。

  • AuthenticationEntryPoint 逻辑被执行,这是认证失败处理逻辑的核心,也是开发者往往需要自定义的部分,在实际的业务场景中,可能需要跳转至登录页亦或是返回 HTTP 错误码。

如果 AccessDeniedException 类型异常被抛出,Spring Security 会认为当前用户没有权限访问该资源,AccessDeniedHandler 处理逻辑被执行,同样这也是我们需要根据业务需求自定义的部分。

Authentication 身份认证


Spring Security 支持多种不同方式的身份认证。而不同方式的认证事实上是有迹可循的,AbstractAuthenticationProcessingFilter 作为一个模板过滤器,为我们规范了身份认证的大致流程,即使我们的需求非常特殊,内置的过滤器无法满足我们的需求,也往往可以通过扩展该模板自定义我们需要的认证方式。一般来讲 Spring Security 内置的过滤器足以支撑我们的常规业务需求,例如针对普遍存在的用户名密码方式登录,框架提供了这样的过滤器实现 UsernamePasswordAuthenticationFilter,我们可以直接使用或扩展该实现达到我们的业务需求。

AbstractAuthenticationProcessingFilter 身份认证流程

Spring Security 为我们抽象了一个身份认证的流程,身份认证过滤器作为 SecurityFilterChain 中最核心的过滤器之一,我们有必要了解其设计,并对之扩展以达成我们的业务需求。

本图摘自官方文档

  1. 用户提交认证信息(如用户名、密码等),此时框架会从 HttpServletRequest 请求对象中获取到相关参数构建 Authentication 对象,注意此时的 Authentication 对象处于未认证状态。

  2. 将未认证的 Authentication 对象传递给 AuthenticationManager 进行认证流程,例如我们可能会根据用户名密码从数据库捞取用户信息,并校验信息的合法性,这个过程需要扩展 AuthenticationManager 完成。

  3. AuthenticationException 异常被抛出,则身份认证失败,SecurityContextHolder 上下文被清除,RememberMeServices#loginFail 流程被执行,AuthenticationFailureHandler 逻辑被执行,我们可能会通过扩展此实现告知前端登录失败。

  4. 若过程中无异常抛出,并且认证后的 Authentication 对象顺利返回,此时 SessionAuthenticationStrategy 中包含的 session 策略会生效(例如校验会话数量是否超出限制),Authentication 对象被加入到当前 SecurityContextHolder 上下文中,RememberMeServices#loginSuccess 流程被执行(服务端记住用户身份,实现自动登录),Spring 容器上下文派发出认证成功事件,AuthenticationSuccessHandler 逻辑被执行,我们可能会通过扩展此实现告知前端用户登录成功。

Session 管理

一旦用户完成身份认证,服务端需要维持用户的会话,而用户则持有一个会话的凭证,一般为 cookie 形式。对于一个 web 服务端来说 session 的管理往往是尤为重要的,因为一旦用户的会话令牌被黑客窃取,带来的安全问题将是灾难性的,因此 Spring Security 内置了 SessionManagementFilter 过滤器用以支撑 session 管理相关的需求,Spring Security 主要分别几个层面为我们提供支持。

Session 并发控制

对于用户会话数量的限制往往是具有普遍性的需求,当然也有些应用允许用户无限创建会话。例如针对移动端的登录,我们可能允许用户同时在三个设备进行登录,那么我们需要限制单个用户同时最多存在三个有效的 session,如果用户再次登录,则需要踢出多余会话或者拒绝登录请求,这取决于我们的业务设计。

public class ConcurrentSessionControlAuthenticationStrategy implements
		MessageSourceAware, SessionAuthenticationStrategy {
	public void onAuthentication(Authentication authentication,
			HttpServletRequest request, HttpServletResponse response) {

		final List<SessionInformation> sessions = sessionRegistry.getAllSessions(
				authentication.getPrincipal(), false);

		int sessionCount = sessions.size();
		int allowedSessions = getMaximumSessionsForThisUser(authentication);

		if (sessionCount < allowedSessions) {
			// They haven't got too many login sessions running at present
			return;
		}

		if (allowedSessions == -1) {
			// We permit unlimited logins
			return;
		}

		if (sessionCount == allowedSessions) {
			HttpSession session = request.getSession(false);

			if (session != null) {
				// Only permit it though if this request is associated with one of the
				// already registered sessions
				for (SessionInformation si : sessions) {
					if (si.getSessionId().equals(session.getId())) {
						return;
					}
				}
			}
			// If the session is null, a new one will be created by the parent class,
			// exceeding the allowed number
		}

		allowableSessionsExceeded(sessions, allowedSessions, sessionRegistry);
	}
}

ConcurrentSessionControlAuthenticationStrategy 中我们可以看到这样的控制,当会话数量超出限制时,Spring Security 默认采取的策略是挤出最早的会话。

防会话固定攻击

会话固定攻击(session fixation attack)是利用应用系统在服务器的会话ID固定不变机制,借助他人用相同的会话ID获取认证和授权,然后利用该会话ID劫持他人的会话以成功冒充他人,造成会话固定攻击。

针对客户端已经持有会话令牌的情况,如果用户重新登录后令牌不会更改,那么可能存在用户会话被劫持的情况,进而产生严重的安全问题,因此对于用户的每一次登录行为,会话令牌应该是唯一的,AbstractSessionFixationProtectionStrategy 中为我们提供了这样的支持。

abstract class AbstractSessionFixationProtectionStrategy implements
		SessionAuthenticationStrategy, ApplicationEventPublisherAware {
	public void onAuthentication(Authentication authentication,
			HttpServletRequest request, HttpServletResponse response) {
		boolean hadSessionAlready = request.getSession(false) != null;

		if (!hadSessionAlready && !alwaysCreateSession) {
			// Session fixation isn't a problem if there's no session

			return;
		}

		// Create new session if necessary
		HttpSession session = request.getSession();

		if (hadSessionAlready && request.isRequestedSessionIdValid()) {

			String originalSessionId;
			String newSessionId;
			Object mutex = WebUtils.getSessionMutex(session);
			synchronized (mutex) {
				// We need to migrate to a new session
				originalSessionId = session.getId();

				session = applySessionFixation(request);
				newSessionId = session.getId();
			}

			if (originalSessionId.equals(newSessionId)) {
				logger.warn("Your servlet container did not change the session ID when a new session was created. You will"
						+ " not be adequately protected against session-fixation attacks");
			}

			onSessionChange(originalSessionId, session, authentication);
		}
	}
}

可以看到 Spring Security 会为用户的每次登录行为创建新的 sessionId 作为新的令牌返回,旧的令牌会失效,避免旧令牌被攻击者持有后发起攻击,当然改变 sessionId 需要 servlet 容器层面的支持。

Session 持久化

我们知道用户会话信息是存储在后端的,前端只是持有令牌进行认证,那么后端就需要将 session 进行持久化。默认状态下 Spring Security 会将用户 session 存储到应用本地内存中,在单体系统中这是可行的。如果我们需要分布式系统共享 session 方面的支持,那么必然需要集成数据库或缓存中间件对 session 进行存储,例如基于 JDBC 的或基于 Redissession 持久化方案。其实 Spring Session 框架为我们提供了这样的支持,本人在公司业务中也采取了集成 Spring Session 的方式将 session 存储到 Redis 中,此处不详细论述。

Remember-Me 记住我

在各大互联网网站的登录界面上,我们时常能够看到 记住我 这样一个选项,一般来说勾选 记住我 登陆后的一段时间内,服务端可以记住该用户的身份,下次访问该网站时,即使用户上一次的会话已经丢失,也可以实现自动登录的功能。

Remember-Me 与 Session 的区别

值得注意的是 remember-mesession 是两个不同的概念,session 指的是服务端识别用户当前持有的会话,只要这个会话存在,服务端可以直接从会话中读取到当前用户的信息,无需再进行认证的过程。而 remember-me 更多情况下是指服务端需要记住用户的认证信息,用户下次访问时实现自动登录,而不是不用登录,这两者有微妙的区别。但是当 Spring Security 集成 Spring Session 后两者在实现上其实是一致的,这是框架的设计,个人认为这一点无需深究。

基于 Token 的 Remember-Me

public interface RememberMeServices {

	Authentication autoLogin(HttpServletRequest request, HttpServletResponse response);

	void loginFail(HttpServletRequest request, HttpServletResponse response);

	void loginSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication successfulAuthentication);

}

RememberMeServices 接口抽象了 remember-me 功能所需要的行为,默认状态下当用户首次登录成功时 loginSuccess 方法会被回调,此时服务端会向客户端发放一个 token(一般以添加 cookie 的形式),这个 token 中包含了用户的认证信息(例如用户名、密码、到期时间等),当然这些信息都是经过加密的,因为 token 如果被黑客窃取将泄露用户的敏感信息,造成严重的安全问题。一旦用户持有了 token,下次访问服务时,服务端会从 token 中解析认证信息并实现自动登录授权,可以理解成程序自动帮用户发起了一次登录。这是 Spring Security 的默认实现,集成 Spring Session 后处理逻辑有所不同,有兴趣可以去翻阅源码,此处不进行赘述。

Anonymous 匿名用户

一般来说很多网站都会支持匿名用户的访问,即使在不注册成为系统的用户的情况下,也有一些模块的权限是对游客进行开放的,这一类用户我们称之为匿名用户,Spring Security 中也存在这样的支持。

public class AnonymousAuthenticationFilter extends GenericFilterBean implements
		InitializingBean {

	public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException {

		if (SecurityContextHolder.getContext().getAuthentication() == null) {
			SecurityContextHolder.getContext().setAuthentication(
					createAuthentication((HttpServletRequest) req));

			if (logger.isDebugEnabled()) {
				logger.debug("Populated SecurityContextHolder with anonymous token: '"
						+ SecurityContextHolder.getContext().getAuthentication() + "'");
			}
		}
		else {
			if (logger.isDebugEnabled()) {
				logger.debug("SecurityContextHolder not populated with anonymous token, as it already contained: '"
						+ SecurityContextHolder.getContext().getAuthentication() + "'");
			}
		}

		chain.doFilter(req, res);
	}

	protected Authentication createAuthentication(HttpServletRequest request) {
		AnonymousAuthenticationToken auth = new AnonymousAuthenticationToken(key,
				principal, authorities);
		auth.setDetails(authenticationDetailsSource.buildDetails(request));

		return auth;
	}

}

过滤器 AnonymousAuthenticationFilter 中包含了对匿名用户的认证,如果经过 filter chain 中之前所有的过滤器处理后,当前的 SecurityContextHolder 上下文中依然不存在 Authentication 用户认证信息,那么 Spring Security 会认为当前用户是一个匿名用户,并授予匿名用户的权限,当然这个权限是我们可以通过配置进行自定义的。只要当前访问的资源权限包含在匿名用户所拥有的权限范围内,请求会被放行。

实现分享


本人在工作中经历了利用 Spring Security 框架对公司原有的登录授权功能进行重构的过程,体验到了要建设一个安全稳定的系统实属不易,需要对 WEB 安全方面的理论性知识具有广泛深入的了解,近来也在阅读相关的书籍,希望可以建立较为完善的对于 WEB 安全方面的认知,本文主要对 Spring SecurityAuthentication 部分进行总结和探讨,由于 Spring Security 是一个针对 Spring Based Application 安全方面覆盖广泛的框架,其设计上具有相当的健壮性和复杂性,值得探讨的东西还有很多,希望后续可以继续补充。

package priv.just.framework.security.configuration;

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.RememberMeServices;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.session.data.redis.RedisIndexedSessionRepository;
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;

import javax.annotation.Resource;

/**
 * spring security 核心配置
 * @author Ethan Zhang
 */
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableRedisHttpSession(redisNamespace = "just:session:")
public class MyWebSecurityConfigurer extends WebSecurityConfigurerAdapter {

    public static final String PERMIT_ALL_URL = "/test/**";
    public static final String LOGIN_URL = "/user/security/login";
    public static final String LOGOUT_URL = "/user/security/logout";

    public static final PasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder();

    @Resource
    private UserDetailsService userDetailsService;
    @Resource
    private RedisIndexedSessionRepository redisIndexedSessionRepository;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                // 表单方式登录
                .formLogin()
                // 登录处理地址
                .loginProcessingUrl(LOGIN_URL)
                // 登录成功处理
                .successHandler(myAuthenticationSuccessHandler())
                // 登录失败处理
                .failureHandler(myAuthenticationFailureHandler()).and()
                // 记住用户身份
                .rememberMe()
                // 集成 spring session
                .rememberMeServices(springSessionRememberMeServices()).and()
                // 用户退出
                .logout()
                // 用户退出地址
                .logoutUrl(LOGOUT_URL)
                // 用户退出成功处理
                .logoutSuccessHandler(myLogoutSuccessHandler()).and()
                // 授权请求
                .authorizeRequests()
                // 无授权地址
                .antMatchers(PERMIT_ALL_URL).permitAll()
                // 其余所有请求都需要授权访问
                .anyRequest().authenticated().and()
                // 异常处理
                .exceptionHandling()
                // 未登录用户访问无权限资源处理
                .authenticationEntryPoint(myAuthenticationEntryPoint())
                // 已登录用户访问无权限资源处理
                .accessDeniedHandler(myAccessDeniedHandler()).and()
                // 会话管理
                .sessionManagement()
                // 最多同时存在 session 数量
                .maximumSessions(3)
                // 集成 spring session
                .sessionRegistry(springSessionBackedSessionRegistry()).and().and()
                // 跨域处理
                .cors().and()
                // CSRF(跨站请求伪造)支持
                .csrf().disable();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(PASSWORD_ENCODER);
    }

    @Bean
    public AuthenticationSuccessHandler myAuthenticationSuccessHandler() {
        return new MyAuthenticationSuccessHandler();
    }

    @Bean
    public AuthenticationFailureHandler myAuthenticationFailureHandler() {
        return new MyAuthenticationFailureHandler();
    }

    @Bean
    public LogoutSuccessHandler myLogoutSuccessHandler() {
        return new MyLogoutSuccessHandler();
    }

    @Bean
    public AuthenticationEntryPoint myAuthenticationEntryPoint() {
        return new MyAuthenticationEntryPoint();
    }

    @Bean
    public AccessDeniedHandler myAccessDeniedHandler() {
        return new MyAccessDeniedHandler();
    }

    @Bean
    public RememberMeServices springSessionRememberMeServices() {
        SpringSessionRememberMeServices rememberMeServices = new SpringSessionRememberMeServices();
        rememberMeServices.setRememberMeParameterName("remember");
        return rememberMeServices;
    }

    @Bean
    public SpringSessionBackedSessionRegistry<?> springSessionBackedSessionRegistry() {
        return new SpringSessionBackedSessionRegistry<>(redisIndexedSessionRepository);
    }

}

以上是本人实现代码的 Spring Security 核心配置部分,可以作为一种总结和分享。


文章作者: Ethan Zhang
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Ethan Zhang !
评论
 上一篇
Spring Security(二)权限管理 Spring Security(二)权限管理
在另一篇文章 Spring Security(一)身份认证 中我们讲到了 Spring Security 的 Authentication 身份认证过程,当一个用户的身份被系统识别,我们会给这个用户赋予一定的权限,这些权限决定用户能够访问
2020-12-05 Ethan Zhang
下一篇 
浅尝系统消息预警 浅尝系统消息预警
在现实的系统运行过程中,问题的产生几乎是不可避免的。因为我们要考虑的是在产生问题时如何将损失降到最低,如果快速发现,定位并解决问题。在这个过程中可以说时间是最重要的成本,所以我们期望系统能够具备自动预警的功能,将问题及时暴露给开发人员,如
2020-11-21 Ethan Zhang