A8.Spring Security简明教程与微服务的融合实践

前言

这是一个基于Java 21 的 六边形架构与领域驱动设计的一个通用项目,并且结合现有的最新版本技术架构实现了 领域驱动设计模式和六边形架构模式组件定义. 并且结合微服务,介绍了领域层,领域事件,资源库,分布式锁,序列化,安全认证,日志等,并提供了实现功能. 并且我会以日常发布文章和更新代码的形式来完善它.

简介

安全是所有系统都需要考虑的, 其处理安全的解决方案有很多,也有自己实现一套, 其中 Spring Security 是一个开源的安全解决方案,它提供了一套完整的安全解决方案,包括身份认证、授权、加密、会话管理等功能。 本篇介绍部分都处在 sample-spring-security 模块之中。

Spring Security

初探

Spring Security 中文参考文档: https://www.iokays.com/spring-security/index.html

首先我们来一个简单的例子,只需要Spring Boot Web项目中引入 spring-boot-starter-security 依赖, 添加一个请求的 /ping , 最后启动项目,在日志打印中就可以可以默认用户(user)的密码,登录即可。

Using generated security password: d18f7c55-53f5-492a-b6b7-b87457401224

我们在这里尽早的知道下这两个HTTP状态码。

  • HTTP 401 Unauthorized表示身份验证失败.

  • HTTP 403 Forbidden表示授权失败.

自定义默认配置

在默认的身份验证中,默认的 AuthenticationProvider 会使用默认自动配置的 UserDetailsService, PasswordEncoder用来查找用户并检查他们的密码.

  1. 其中UserDetailsService 契约的对象管理关于用户的详细信息。其默认实现是 InMemoryUserDetailsManager.

  2. 而 PasswordEncoder 有两个作用 1. 将密码进行编码; 2. 验证密码是否与现有编码项匹配。

@Configuration
@EnableWebSecurity
public class MyDefaultSecurityConfig {

    @Bean
    public UserDetailsService userDetailsService() {
        var result = new InMemoryUserDetailsManager();

        var user = User.withUsername("user").password("123456").authorities("READ").build();
        result.createUser(user);

        return result;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
}

这样,我们就能用自定义使用user,123456登录了。

用户体系

我们看到主要是管理 username, password 字段。对应两个字段,我们可以做一些自定义的操作,比如,我们可以通过 Spring Security 已定义的 UserDetailsService 接口来自定义。来获取我们业务系统的用户信息,大家可以查看 JdbcUserDetailsManager, CachingUserDetailsService 并结合本系统的业务来获取用户信息。

密码体系

获取到用户信息后,就需要验证密码, 那么对于password 我们可以指定特定加密的方法,同时我们也可以兼容多套密钥组件,来兼容旧密码。做法是我们将 PasswordEncoder 的注入为基于策略的 DelegatingPasswordEncoder. 替代默认注入的PasswordEncoder。

package com.iokays.sample;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.*;
import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;

import java.util.HashMap;

class DelegatingPasswordEncoderTest {

    /**
     * 测试指定加密方式和自定义匹配器
     */
    @Test
    void testCustom() {
        final var encoders = new HashMap<String, PasswordEncoder>();
        encoders.put("bcrypt", new BCryptPasswordEncoder());
        encoders.put("noop", NoOpPasswordEncoder.getInstance());
        encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5());
        encoders.put("pbkdf2@SpringSecurity_v5_8", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
        encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v4_1());
        encoders.put("scrypt@SpringSecurity_v5_8", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
        encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2());
        encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
        encoders.put("sha256", new StandardPasswordEncoder());

        final var passwordEncoder = new DelegatingPasswordEncoder("noop", encoders);

        //使用默认的加密方式进行加密
        Assertions.assertEquals("{noop}123456", passwordEncoder.encode("123456"));

        //使用指定的加密方式进行匹配, 数据库的密码添加上前缀{},并不会对密码本身泄密,只是知道加密方式而已.
        Assertions.assertTrue(passwordEncoder.matches("123456", "{noop}123456"));

    }

}

登录补充

现在我们登录,不仅仅传入用户名和密码。 我们可能会加一些其他验证参数,比如验证码。 我们看到很多的实现都是对 UsernamePasswordAuthenticationToken 下手,继承它并重写里面的实现. 这样破坏了封装, 我们可以通过组件插拔的模式,来增强登录的功能。其实在Spring Security 中,就显示了使用过滤器的方式来处理这个问题。 添加自定义过滤器 . 用例也提供了类似的方式:

package com.iokays.sample.captcha;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.log.LogMessage;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

/**
 * 验证码过滤器
 */
public class UsernameCaptchaAuthenticationFilter extends OncePerRequestFilter {

    private static final Logger log = LoggerFactory.getLogger(UsernameCaptchaAuthenticationFilter.class);

    private static final AntPathRequestMatcher requiresAuthenticationRequestMatcher = new AntPathRequestMatcher("/login", "POST");
    private boolean postOnly = true;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (!requiresAuthentication(request, response)) {
            filterChain.doFilter(request, response);
            return;
        }

        log.info("----验证码过滤----");

        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        String username = request.getParameter("username");
        username = (username != null) ? username.trim() : "";

        String captcha = request.getParameter("captcha");
        captcha = (captcha != null) ? captcha : "";

        log.info("验证码校验 username: {}, captcha: {}", username, "XXXXX");
        filterChain.doFilter(request, response);
    }

    protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
        if (this.requiresAuthenticationRequestMatcher.matches(request)) {
            return true;
        }
        if (this.logger.isTraceEnabled()) {
            this.logger.trace(LogMessage.format("Did not match request to %s", this.requiresAuthenticationRequestMatcher));
        }
        return false;
    }
}
package com.iokays.sample

import com.iokays.sample.captcha.UsernameCaptchaAuthenticationFilter
import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationEventPublisher
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.authentication.AuthenticationEventPublisher
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.DefaultAuthenticationEventPublisher
import org.springframework.security.authentication.ProviderManager
import org.springframework.security.authentication.dao.DaoAuthenticationProvider
import org.springframework.security.config.Customizer
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.crypto.password.NoOpPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter

@Configuration
@EnableWebSecurity
class MyDefaultSecurityConfig {

    @Bean
    @Throws(Exception::class)
    fun securityFilterChain(http: HttpSecurity, authenticationManager: AuthenticationManager?): SecurityFilterChain {
        http
            .sessionManagement { session -> session.maximumSessions(1) }
            .formLogin(Customizer.withDefaults())
            .authorizeHttpRequests(Customizer { auth ->
                auth
                    .requestMatchers("/login").permitAll()
                    .requestMatchers("/ping").hasAuthority("ADMIN")
                    .anyRequest().authenticated()
            })
            .addFilterBefore(UsernameCaptchaAuthenticationFilter(), UsernamePasswordAuthenticationFilter::class.java)


        //添加 oauth2Login 支持
        http.oauth2Login(Customizer.withDefaults())

        return http.build()
    }

    @Bean
    fun userDetailsService(): UserDetailsService {
        val result = InMemoryUserDetailsManager()
        result.createUser(User.withUsername("admin").password("123456").authorities("ADMIN").build())
        result.createUser(User.withUsername("user").password("123456").authorities("READ").build())
        return result
    }

    @Bean
    fun passwordEncoder(): PasswordEncoder {
        return NoOpPasswordEncoder.getInstance();
    }

    @Bean
    fun authenticationEventPublisher(applicationEventPublisher : ApplicationEventPublisher): AuthenticationEventPublisher {
        return DefaultAuthenticationEventPublisher(applicationEventPublisher);
    }

    @Bean
    fun authenticationManager(userDetailsService: UserDetailsService, passwordEncoder: PasswordEncoder): AuthenticationManager {
        //验证用户密码
        val authenticationProvider = DaoAuthenticationProvider();
        authenticationProvider.setUserDetailsService(userDetailsService);
        authenticationProvider.setPasswordEncoder(passwordEncoder);

        val providerManager = ProviderManager(authenticationProvider);
        providerManager.setEraseCredentialsAfterAuthentication(false);

        return providerManager;
    }


}

如想了解更多表单登录的原理,可以从 UsernamePasswordAuthenticationFilter 过滤器并结合下面的图,进行分析。

abstractauthenticationprocessingfilter

权限

从上面的例子,我们看到了一些URL(/login, /ping)的访问权限控制,当非业务性的接口和不可编辑的接口,我们可以通过这样的配置来控制。 如果是自己编写的业务性的接口,比如增删改查,我们可以通过注解的方式( @PreAuthorize("hasAuthority('users::write')") )来控制。 然后我们建立用户-角色-权限的映射,基本就能满足我们的需求.

Oauth2

通用

使用 Oauth2 的方式,在最新版的Spring Security 中,先添加依赖,然后只需要开启 OAuth2登录( ` http.oauth2Login(Customizer.withDefaults())` )和配置授权服务器的配置,即可实现 Oauth2 的登录。

server:
  port: 8082
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: XXXXXXXXXXXXX
            client-secret: XXXXXXXXXXXXXXXXXXXXX
          github:
            client-id: YYYYYYYYYY
            client-secret: YYYYYYYYYYYYYYYYYYYYY
          local:
            client-id: login-client
            client-secret: openid-connect
            authorization-grant-type: authorization_code
            scope: openid,profile
            redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
        provider:
          local:
            authorization-uri: http://127.0.0.1:8081/oauth2/authorize
            token-uri: http://127.0.0.1:8081/oauth2/token
            jwk-set-uri: http://127.0.0.1:8081/oauth2/jwks
            user-info-uri: http://127.0.0.1:8081/userinfo
            user-name-attribute: sub

最后访问任意地址: http://localhost:8082, 会跳转到登录页面,登录页面分为两部分, 上面部分为表单登录,下部分为Oauth2 登录。 因为我们配置了多个认证方式, 所以我们选择指定的认证方式,比如: 就可以跳转到Google OAuth2 认证。 这样知道就我们做到了表单登录和Oauth2认证共存。

其中google代表我们配置的registrationId. 我们配置了2个,两者并存。 也可以替换为github试试。

自定义

上面介绍了Spring Security Oauth2 的通用用法,如果Oauth2 Service 是我们自己搭建或者对方不是一个标准的Oauth2 Provider,我们需要怎么处理呢?

首先我们可以启动一个Oauth2 认证服务, 详细查看:sample-spring-authorization-server 模块。

spring:
  security:
    oauth2:
      client:
        registration:
          local:
            client-id: login-client
            client-secret: openid-connect
            authorization-grant-type: authorization_code
            scope: openid,profile
            redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
        provider:
          local:
            authorization-uri: http://127.0.0.1:8081/oauth2/authorize
            token-uri: http://127.0.0.1:8081/oauth2/token
            jwk-set-uri: http://127.0.0.1:8081/oauth2/jwks
            user-info-uri: http://127.0.0.1:8081/userinfo
            user-name-attribute: sub

配置完, 跳转到登录页面就会一个 local Oauth2登录认证的选项, 完成后续的登录过程。

如果是本机(localhost)验证,如果我们配置认证服务器使用 127.0.0.1 如上访问,那么Oauth客户端使用 localhost 访问。 这样能避免覆盖cookie, 导致出现 authorization_request_not_found 这个问题. 因为浏览器来说,cookie是区分域,不区分端口的,在一个ip地址下多个端口的cookie是共享的。

未完待续…​

整个Spring Security 内容非常丰富,基于安全的专业知识也比较多,需要更多的实践,虽本文只做了简单的介绍,但是大体能知道整个流程,对于一些后续基于业务自定义的配置,后续会补充更多内容。 但会导致代码不一致,大家可以查看本人的提交记录,回滚到最初的版本,来验证本文章的功能。

Spring Security简明教程与微服务的融合实践认证和鉴权的开篇到此就讲完了, 下篇我们将会介绍Spring Security Oauth2 Authorization Server。