Spring Authorization Server 1.1 扩展实现 OAuth2 密码模式与 Spring Cloud 的整合实战

目录

    • 前言
    • 无图无真相
    • 创建数据库
    • 授权服务器
      • maven 依赖
      • application.yml
      • 授权服务器配置
        • AuthorizationServierConfig
        • DefaultSecutiryConfig
      • 密码模式扩展
        • PasswordAuthenticationToken
        • PasswordAuthenticationConverter
        • PasswordAuthenticationProvider
      • JWT 自定义字段
      • 自定义认证响应
        • 认证成功响应
        • 认证失败响应
        • 配置自定义处理器
      • 密码模式测试
        • 单元测试
        • Postman 测试
    • 资源服务器
      • maven 依赖
      • application.yml
      • 资源服务器配置
    • 认证流程测试
      • 登录认证授权
      • 获取用户信息
    • 结语
    • 源码
    • 参考文档

前言

Spring Security OAuth2 的最终版本是2.5.2,并于2022年6月5日正式宣布停止维护。Spring 官方为此推出了新的替代产品,即 Spring Authorization Server。然而,出于安全考虑,Spring Authorization Server 不再支持密码模式,因为密码模式要求客户端直接处理用户的密码。但对于受信任的第一方系统(自有APP和管理系统等),许多情况下需要使用密码模式。在这种情况下,需要在 Spring Authorization Server 的基础上扩展密码模式的支持。本文基于开源微服务商城项目 youlai-mall、Spring Boot 3 和 Spring Authorization Server 1.1 版本,演示了如何扩展密码模式,以及如何将其应用于 Spring Cloud 微服务实战。

无图无真相

通过 Spring Cloud Gateway 访问认证中心认证成功获取到访问令牌。完整源码:youlai-mall

创建数据库

Spring Authorization Server 官方提供的授权服务器示例 demo-authorizationserver 初始化数据库所使用的3个SQL脚本路径如下:

根据路径找到3张表的SQL脚本

  • 令牌发放记录表: oauth2-authorization-schema.sql
  • 授权记录表: oauth2-authorization-consent-schema.sql
  • 客户端信息表: oauth2-registered-client-schema.sql

整合后的完整数据库 SQL 脚本如下:

-- ----------------------------
-- 1. 创建数据库
-- ----------------------------
CREATE DATABASE IF NOT EXISTS oauth2_server DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci;

-- ----------------------------
-- 2. 创建表
-- ----------------------------
use oauth2_server;

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- 2.1 oauth2_authorization 令牌发放记录表
-- ----------------------------
CREATE TABLE oauth2_authorization (
    id varchar(100) NOT NULL,
    registered_client_id varchar(100) NOT NULL,
    principal_name varchar(200) NOT NULL,
    authorization_grant_type varchar(100) NOT NULL,
    authorized_scopes varchar(1000) DEFAULT NULL,
    attributes blob DEFAULT NULL,
    state varchar(500) DEFAULT NULL,
    authorization_code_value blob DEFAULT NULL,
    authorization_code_issued_at timestamp DEFAULT NULL,
    authorization_code_expires_at timestamp DEFAULT NULL,
    authorization_code_metadata blob DEFAULT NULL,
    access_token_value blob DEFAULT NULL,
    access_token_issued_at timestamp DEFAULT NULL,
    access_token_expires_at timestamp DEFAULT NULL,
    access_token_metadata blob DEFAULT NULL,
    access_token_type varchar(100) DEFAULT NULL,
    access_token_scopes varchar(1000) DEFAULT NULL,
    oidc_id_token_value blob DEFAULT NULL,
    oidc_id_token_issued_at timestamp DEFAULT NULL,
    oidc_id_token_expires_at timestamp DEFAULT NULL,
    oidc_id_token_metadata blob DEFAULT NULL,
    refresh_token_value blob DEFAULT NULL,
    refresh_token_issued_at timestamp DEFAULT NULL,
    refresh_token_expires_at timestamp DEFAULT NULL,
    refresh_token_metadata blob DEFAULT NULL,
    user_code_value blob DEFAULT NULL,
    user_code_issued_at timestamp DEFAULT NULL,
    user_code_expires_at timestamp DEFAULT NULL,
    user_code_metadata blob DEFAULT NULL,
    device_code_value blob DEFAULT NULL,
    device_code_issued_at timestamp DEFAULT NULL,
    device_code_expires_at timestamp DEFAULT NULL,
    device_code_metadata blob DEFAULT NULL,
    PRIMARY KEY (id)
);

-- ----------------------------
-- 2.2 oauth2_authorization_consent 授权记录表
-- ----------------------------
CREATE TABLE oauth2_authorization_consent (
    registered_client_id varchar(100) NOT NULL,
    principal_name varchar(200) NOT NULL,
    authorities varchar(1000) NOT NULL,
    PRIMARY KEY (registered_client_id, principal_name)
);

-- ----------------------------
-- 2.3 oauth2-registered-client OAuth2 客户端信息表
-- ----------------------------
CREATE TABLE oauth2_registered_client (
    id varchar(100) NOT NULL,
    client_id varchar(100) NOT NULL,
    client_id_issued_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
    client_secret varchar(200) DEFAULT NULL,
    client_secret_expires_at timestamp DEFAULT NULL,
    client_name varchar(200) NOT NULL,
    client_authentication_methods varchar(1000) NOT NULL,
    authorization_grant_types varchar(1000) NOT NULL,
    redirect_uris varchar(1000) DEFAULT NULL,
    post_logout_redirect_uris varchar(1000) DEFAULT NULL,
    scopes varchar(1000) NOT NULL,
    client_settings varchar(2000) NOT NULL,
    token_settings varchar(2000) NOT NULL,
    PRIMARY KEY (id)
);

授权服务器

youlai-auth 模块作为认证授权服务器

maven 依赖

在 youlai-auth 模块的 pom.xml 添加授权服务器依赖

<!-- Spring Authorization Server 授权服务器依赖 -->
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-authorization-server</artifactId>
    <version>1.1.1</version>
</dependency>

application.yml

认证中心配置 oauth2_server 数据库连接信息

spring:
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver 
    url: jdbc:mysql://localhost:3306/oauth2_server?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true
    username: root
    password: 123456

授权服务器配置

参考 Spring Authorization Server 官方示例 demo-authorizationserver

AuthorizationServierConfig

参考: Spring Authorization Server 官方示例 demo-authorizationserver 下的 AuthorizationServerConfig.java 进行授权服务器配置

package com.youlai.auth.config;

/**
 * 授权服务器配置
 *
 * @author haoxr
 * @since 3.0.0
 */
@Configuration
@RequiredArgsConstructor
@Slf4j
public class AuthorizationServerConfig {

    private final OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer;

    /**
     * 授权服务器端点配置
     */
    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authorizationServerSecurityFilterChain(
            HttpSecurity http,
            AuthenticationManager authenticationManager,
            OAuth2AuthorizationService authorizationService,
            OAuth2TokenGenerator<?> tokenGenerator

    ) throws Exception {

        OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer();

        authorizationServerConfigurer
                .tokenEndpoint(tokenEndpoint ->
                        tokenEndpoint
                                .accessTokenRequestConverters(
                                        authenticationConverters ->// <1>
                                                authenticationConverters.addAll(
                                                        // 自定义授权模式转换器(Converter)
                                                        List.of(
                                                                new PasswordAuthenticationConverter()
                                                        )
                                                )
                                )
                               .authenticationProviders(authenticationProviders ->// <2>
                                        authenticationProviders.addAll(
                                            	// 自定义授权模式提供者(Provider)
                                                List.of(
                                                        new PasswordAuthenticationProvider(authenticationManager, authorizationService, tokenGenerator)
                                                )
                                        )
                                )
                                .accessTokenResponseHandler(new MyAuthenticationSuccessHandler()) // 自定义成功响应
                                .errorResponseHandler(new MyAuthenticationFailureHandler()) // 自定义失败响应
                );


        RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();
        http.securityMatcher(endpointsMatcher)
                .authorizeHttpRequests(authorizeRequests -> authorizeRequests.anyRequest().authenticated())
                .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
                .apply(authorizationServerConfigurer);

        return http.build();
    }


    @Bean // <5>
    public JWKSource<SecurityContext> jwkSource() {
        KeyPair keyPair = generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        // @formatter:off
        RSAKey rsaKey = new RSAKey.Builder(publicKey)
                .privateKey(privateKey)
                .keyID(UUID.randomUUID().toString())
                .build();
        // @formatter:on
        JWKSet jwkSet = new JWKSet(rsaKey);
        return new ImmutableJWKSet<>(jwkSet);
    }

    private static KeyPair generateRsaKey() { // <6>
        KeyPair keyPair;
        try {
            KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
            keyPairGenerator.initialize(2048);
            keyPair = keyPairGenerator.generateKeyPair();
        } catch (Exception ex) {
            throw new IllegalStateException(ex);
        }
        return keyPair;
    }

    @Bean
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }

    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder().build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
        JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);

        // 初始化 OAuth2 客户端
        initMallAppClient(registeredClientRepository);
        initMallAdminClient(registeredClientRepository);

        return registeredClientRepository;
    }


    @Bean
    public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate,
                                                           RegisteredClientRepository registeredClientRepository) {

        JdbcOAuth2AuthorizationService service = new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
        JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper rowMapper = new JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper(registeredClientRepository);
        rowMapper.setLobHandler(new DefaultLobHandler());
        ObjectMapper objectMapper = new ObjectMapper();
        ClassLoader classLoader = JdbcOAuth2AuthorizationService.class.getClassLoader();
        List<Module> securityModules = SecurityJackson2Modules.getModules(classLoader);
        objectMapper.registerModules(securityModules);
        objectMapper.registerModule(new OAuth2AuthorizationServerJackson2Module());
        // 使用刷新模式,需要从 oauth2_authorization 表反序列化attributes字段得到用户信息(SysUserDetails)
        objectMapper.addMixIn(SysUserDetails.class, SysUserMixin.class);
        objectMapper.addMixIn(Long.class, Object.class);
        
        rowMapper.setObjectMapper(objectMapper);
        service.setAuthorizationRowMapper(rowMapper);
        return service;
    }

    @Bean
    public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate,
                                                                         RegisteredClientRepository registeredClientRepository) {
        // Will be used by the ConsentController
        return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
    }


    @Bean
    OAuth2TokenGenerator<?> tokenGenerator(JWKSource<SecurityContext> jwkSource) {
        JwtGenerator jwtGenerator = new JwtGenerator(new NimbusJwtEncoder(jwkSource));
        jwtGenerator.setJwtCustomizer(jwtCustomizer);

        OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();
        OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();
        return new DelegatingOAuth2TokenGenerator(
                jwtGenerator, accessTokenGenerator, refreshTokenGenerator);
    }


    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    /**
     * 初始化创建商城管理客户端
     *
     * @param registeredClientRepository
     */
    private void initMallAdminClient(JdbcRegisteredClientRepository registeredClientRepository) {

        String clientId = "mall-admin";
        String clientSecret = "123456";
        String clientName = "商城管理客户端";

        /*
          如果使用明文,客户端认证时会自动升级加密方式,换句话说直接修改客户端密码,所以直接使用 bcrypt 加密避免不必要的麻烦
          官方ISSUE: https://github.com/spring-projects/spring-authorization-server/issues/1099
         */
        String encodeSecret = passwordEncoder().encode(clientSecret);

        RegisteredClient registeredMallAdminClient = registeredClientRepository.findByClientId(clientId);
        String id = registeredMallAdminClient != null ? registeredMallAdminClient.getId() : UUID.randomUUID().toString();

        RegisteredClient mallAppClient = RegisteredClient.withId(id)
                .clientId(clientId)
                .clientSecret(encodeSecret)
                .clientName(clientName)
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .authorizationGrantType(AuthorizationGrantType.PASSWORD) // 密码模式
                .authorizationGrantType(CaptchaAuthenticationToken.CAPTCHA) // 验证码模式
                .redirectUri("http://127.0.0.1:8080/authorized")
                .postLogoutRedirectUri("http://127.0.0.1:8080/logged-out")
                .scope(OidcScopes.OPENID)
                .scope(OidcScopes.PROFILE)
                .tokenSettings(TokenSettings.builder().accessTokenTimeToLive(Duration.ofDays(1)).build())
                .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
                .build();
        registeredClientRepository.save(mallAppClient);
    }

    /**
     * 初始化创建商城APP客户端
     *
     * @param registeredClientRepository
     */
    private void initMallAppClient(JdbcRegisteredClientRepository registeredClientRepository) {

        String clientId = "mall-app";
        String clientSecret = "123456";
        String clientName = "商城APP客户端";

        // 如果使用明文,在客户端认证的时候会自动升级加密方式,直接使用 bcrypt 加密避免不必要的麻烦
        String encodeSecret = passwordEncoder().encode(clientSecret);

        RegisteredClient registeredMallAppClient = registeredClientRepository.findByClientId(clientId);
        String id = registeredMallAppClient != null ? registeredMallAppClient.getId() : UUID.randomUUID().toString();

        RegisteredClient mallAppClient = RegisteredClient.withId(id)
                .clientId(clientId)
                .clientSecret(encodeSecret)
                .clientName(clientName)
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .authorizationGrantType(WxMiniAppAuthenticationToken.WECHAT_MINI_APP) // 微信小程序模式
                .authorizationGrantType(SmsCodeAuthenticationToken.SMS_CODE) // 短信验证码模式
                .redirectUri("http://127.0.0.1:8080/authorized")
                .postLogoutRedirectUri("http://127.0.0.1:8080/logged-out")
                .scope(OidcScopes.OPENID)
                .scope(OidcScopes.PROFILE)
                .tokenSettings(TokenSettings.builder().accessTokenTimeToLive(Duration.ofDays(1)).build())
                .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
                .build();
        registeredClientRepository.save(mallAppClient);
    }
}
DefaultSecutiryConfig
  • 参考 Spring Authorization Server 官方示例 demo-authorizationserver 下的 DefaultSecurityConfig.java 进行安全配置
package com.youlai.auth.config;

/**
 * 授权服务器安全配置
 *
 * @author haoxr
 * @since 3.0.0
 */
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
public class DefaultSecurityConfig {
    
    /**
     * Spring Security 安全过滤器链配置
     */
    @Bean
    @Order(0)
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(requestMatcherRegistry ->
                        {
                            requestMatcherRegistry.anyRequest().authenticated();
                        }
                )
                .csrf(AbstractHttpConfigurer::disable)
                .formLogin(Customizer.withDefaults());

        return http.build();
    }

    /**
     * Spring Security 自定义安全配置
     */
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) ->
                // 不走过滤器链(场景:静态资源js、css、html)
                web.ignoring().requestMatchers(
                        "/webjars/**",
                        "/doc.html",
                        "/swagger-resources/**",
                        "/v3/api-docs/**",
                        "/swagger-ui/**"
                );
    }
}

密码模式扩展

PasswordAuthenticationToken
package com.youlai.auth.authentication.password;

/**
 * 密码授权模式身份验证令牌(包含用户名和密码等)
 *
 * @author haoxr
 * @since 3.0.0
 */
public class PasswordAuthenticationToken extends OAuth2AuthorizationGrantAuthenticationToken {

    public static final AuthorizationGrantType PASSWORD = new AuthorizationGrantType("password");


    /**
     * 令牌申请访问范围
     */
    private final Set<String> scopes;

    /**
     * 密码模式身份验证令牌
     *
     * @param clientPrincipal      客户端信息
     * @param scopes               令牌申请访问范围
     * @param additionalParameters 自定义额外参数(用户名和密码)
     */
    public PasswordAuthenticationToken(
            Authentication clientPrincipal,
            Set<String> scopes,
            @Nullable Map<String, Object> additionalParameters
    ) {
        super(PASSWORD, clientPrincipal, additionalParameters);
        this.scopes = Collections.unmodifiableSet(scopes != null ? new HashSet<>(scopes) : Collections.emptySet());

    }

    /**
     * 用户凭证(密码)
     */
    @Override
    public Object getCredentials() {
        return this.getAdditionalParameters().get(OAuth2ParameterNames.PASSWORD);
    }

    public Set<String> getScopes() {
        return scopes;
    }
}
PasswordAuthenticationConverter
package com.youlai.auth.authentication.password;

/**
 * 密码模式参数解析器
 * <p>
 * 解析请求参数中的用户名和密码,并构建相应的身份验证(Authentication)对象
 *
 * @author haoxr
 * @see org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeAuthenticationConverter
 * @since 3.0.0
 */
public class PasswordAuthenticationConverter implements AuthenticationConverter {

    @Override
    public Authentication convert(HttpServletRequest request) {

        // 授权类型 (必需)
        String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE);
        if (!AuthorizationGrantType.PASSWORD.getValue().equals(grantType)) {
            return null;
        }

        // 客户端信息
        Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication();

        // 参数提取验证
        MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getParameters(request);

        // 令牌申请访问范围验证 (可选)
        String scope = parameters.getFirst(OAuth2ParameterNames.SCOPE);
        if (StringUtils.hasText(scope) &&
                parameters.get(OAuth2ParameterNames.SCOPE).size() != 1) {
            OAuth2EndpointUtils.throwError(
                    OAuth2ErrorCodes.INVALID_REQUEST,
                    OAuth2ParameterNames.SCOPE,
                    OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI);
        }
        Set<String> requestedScopes = null;
        if (StringUtils.hasText(scope)) {
            requestedScopes = new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(scope, " ")));
        }

        // 用户名验证(必需)
        String username = parameters.getFirst(OAuth2ParameterNames.USERNAME);
        if (StrUtil.isBlank(username)) {
            OAuth2EndpointUtils.throwError(
                    OAuth2ErrorCodes.INVALID_REQUEST,
                    OAuth2ParameterNames.USERNAME,
                    OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI
            );
        }

        // 密码验证(必需)
        String password = parameters.getFirst(OAuth2ParameterNames.PASSWORD);
        if (StrUtil.isBlank(password)) {
            OAuth2EndpointUtils.throwError(
                    OAuth2ErrorCodes.INVALID_REQUEST,
                    OAuth2ParameterNames.PASSWORD,
                    OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI
            );
        }

        // 附加参数(保存用户名/密码传递给 PasswordAuthenticationProvider 用于身份认证)
        Map<String, Object> additionalParameters = parameters
                .entrySet()
                .stream()
                .filter(e -> !e.getKey().equals(OAuth2ParameterNames.GRANT_TYPE) &&
                        !e.getKey().equals(OAuth2ParameterNames.SCOPE)
                ).collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().get(0)));

        return new PasswordAuthenticationToken(
                clientPrincipal,
                requestedScopes,
                additionalParameters
        );
    }

}
PasswordAuthenticationProvider
package com.youlai.auth.authentication.password;


/**
 * 密码模式身份验证提供者
 * <p>
 * 处理基于用户名和密码的身份验证
 *
 * @author haoxr
 * @since 3.0.0
 */
@Slf4j
public class PasswordAuthenticationProvider implements AuthenticationProvider {

    private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2";
    private final AuthenticationManager authenticationManager;
    private final OAuth2AuthorizationService authorizationService;
    private final OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator;

    /**
     * Constructs an {@code OAuth2ResourceOwnerPasswordAuthenticationProviderNew} using the provided parameters.
     *
     * @param authenticationManager the authentication manager
     * @param authorizationService  the authorization service
     * @param tokenGenerator        the token generator
     * @since 0.2.3
     */
    public PasswordAuthenticationProvider(AuthenticationManager authenticationManager,
                                          OAuth2AuthorizationService authorizationService,
                                          OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator
    ) {
        Assert.notNull(authorizationService, "authorizationService cannot be null");
        Assert.notNull(tokenGenerator, "tokenGenerator cannot be null");
        this.authenticationManager = authenticationManager;
        this.authorizationService = authorizationService;
        this.tokenGenerator = tokenGenerator;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        PasswordAuthenticationToken resourceOwnerPasswordAuthentication = (PasswordAuthenticationToken) authentication;
        OAuth2ClientAuthenticationToken clientPrincipal = OAuth2AuthenticationProviderUtils
                .getAuthenticatedClientElseThrowInvalidClient(resourceOwnerPasswordAuthentication);
        RegisteredClient registeredClient = clientPrincipal.getRegisteredClient();

        // 验证客户端是否支持授权类型(grant_type=password)
        if (!registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.PASSWORD)) {
            throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);
        }

        // 生成用户名密码身份验证令牌
        Map<String, Object> additionalParameters = resourceOwnerPasswordAuthentication.getAdditionalParameters();
        String username = (String) additionalParameters.get(OAuth2ParameterNames.USERNAME);
        String password = (String) additionalParameters.get(OAuth2ParameterNames.PASSWORD);

        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(username, password);

        // 用户名密码身份验证,成功后返回带有权限的认证信息
        Authentication usernamePasswordAuthentication;
        try {
            usernamePasswordAuthentication = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
        } catch (Exception e) {
            // 需要将其他类型的异常转换为 OAuth2AuthenticationException 才能被自定义异常捕获处理,逻辑源码 OAuth2TokenEndpointFilter#doFilterInternal
            throw new OAuth2AuthenticationException(e.getCause() != null ? e.getCause().getMessage() : e.getMessage());
        }

        // 验证申请访问范围(Scope)
        Set<String> authorizedScopes = registeredClient.getScopes();
        Set<String> requestedScopes = resourceOwnerPasswordAuthentication.getScopes();
        if (!CollectionUtils.isEmpty(requestedScopes)) {
            Set<String> unauthorizedScopes = requestedScopes.stream()
                    .filter(requestedScope -> !registeredClient.getScopes().contains(requestedScope))
                    .collect(Collectors.toSet());
            if (!CollectionUtils.isEmpty(unauthorizedScopes)) {
                throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_SCOPE);
            }
            authorizedScopes = new LinkedHashSet<>(requestedScopes);
        }

        // 访问令牌(Access Token) 构造器
        DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder()
                .registeredClient(registeredClient)
                .principal(usernamePasswordAuthentication) // 身份验证成功的认证信息(用户名、权限等信息)
                .authorizationServerContext(AuthorizationServerContextHolder.getContext())
                .authorizedScopes(authorizedScopes)
                .authorizationGrantType(AuthorizationGrantType.PASSWORD) // 授权方式
                .authorizationGrant(resourceOwnerPasswordAuthentication) // 授权具体对象
                ;

        // 生成访问令牌(Access Token)
        OAuth2TokenContext tokenContext = tokenContextBuilder.tokenType((OAuth2TokenType.ACCESS_TOKEN)).build();
        OAuth2Token generatedAccessToken = this.tokenGenerator.generate(tokenContext);
        if (generatedAccessToken == null) {
            OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,
                    "The token generator failed to generate the access token.", ERROR_URI);
            throw new OAuth2AuthenticationException(error);
        }


        OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,
                generatedAccessToken.getTokenValue(), generatedAccessToken.getIssuedAt(),
                generatedAccessToken.getExpiresAt(), tokenContext.getAuthorizedScopes());

        // 权限数据(perms)比较多通过反射移除,不随令牌一起持久化至数据库
        ReflectUtil.setFieldValue(usernamePasswordAuthentication.getPrincipal(), "perms", null);

        OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization.withRegisteredClient(registeredClient)
                .principalName(usernamePasswordAuthentication.getName())
                .authorizationGrantType(AuthorizationGrantType.PASSWORD)
                .authorizedScopes(authorizedScopes)
                .attribute(Principal.class.getName(), usernamePasswordAuthentication); // attribute 字段
        if (generatedAccessToken instanceof ClaimAccessor) {
            authorizationBuilder.token(accessToken, (metadata) ->
                    metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, ((ClaimAccessor) generatedAccessToken).getClaims()));
        } else {
            authorizationBuilder.accessToken(accessToken);
        }

        // 生成刷新令牌(Refresh Token)
        OAuth2RefreshToken refreshToken = null;
        if (registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.REFRESH_TOKEN) &&
                // Do not issue refresh token to public client
                !clientPrincipal.getClientAuthenticationMethod().equals(ClientAuthenticationMethod.NONE)) {

            tokenContext = tokenContextBuilder.tokenType(OAuth2TokenType.REFRESH_TOKEN).build();
            OAuth2Token generatedRefreshToken = this.tokenGenerator.generate(tokenContext);
            if (!(generatedRefreshToken instanceof OAuth2RefreshToken)) {
                OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,
                        "The token generator failed to generate the refresh token.", ERROR_URI);
                throw new OAuth2AuthenticationException(error);
            }

            refreshToken = (OAuth2RefreshToken) generatedRefreshToken;
            authorizationBuilder.refreshToken(refreshToken);
        }

        OAuth2Authorization authorization = authorizationBuilder.build();

        // 持久化令牌发放记录到数据库
        this.authorizationService.save(authorization);
        additionalParameters = Collections.emptyMap();

        return new OAuth2AccessTokenAuthenticationToken(registeredClient, clientPrincipal, accessToken, refreshToken, additionalParameters);
    }

    /**
     * 判断传入的 authentication 类型是否与当前认证提供者(AuthenticationProvider)相匹配--模板方法
     * <p>
     * ProviderManager#authenticate 遍历 providers 找到支持对应认证请求的 provider-迭代器模式
     *
     * @param authentication
     * @return
     */
    @Override
    public boolean supports(Class<?> authentication) {
        return PasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }

}

JWT 自定义字段

参考官方 ISSUE :Adds how-to guide on adding authorities to access tokens

package com.youlai.auth.config;

/**
 * JWT 自定义字段
 *
 * @author haoxr
 * @since 3.0.0
 */
@Configuration
@RequiredArgsConstructor
public class JwtTokenClaimsConfig {

    private final RedisTemplate redisTemplate;

    @Bean
    public OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer() {
        return context -> {
            if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType()) && context.getPrincipal() instanceof UsernamePasswordAuthenticationToken) {
                // Customize headers/claims for access_token
                Optional.ofNullable(context.getPrincipal().getPrincipal()).ifPresent(principal -> {
                    JwtClaimsSet.Builder claims = context.getClaims();
                    if (principal instanceof SysUserDetails userDetails) { 
						// 系统用户添加自定义字段
                        Long userId = userDetails.getUserId();
                        claims.claim("user_id", userId);  // 添加系统用户ID

                        // 角色集合存JWT
                        var authorities = AuthorityUtils.authorityListToSet(context.getPrincipal().getAuthorities())
                                .stream()
                                .collect(Collectors.collectingAndThen(Collectors.toSet(), Collections::unmodifiableSet));
                        claims.claim(SecurityConstants.AUTHORITIES_CLAIM_NAME_KEY, authorities);

                        // 权限集合存Redis(数据多)
                        Set<String> perms = userDetails.getPerms();
                        redisTemplate.opsForValue().set(SecurityConstants.USER_PERMS_CACHE_PREFIX + userId, perms);

                    } else if (principal instanceof MemberDetails userDetails) { 
                        // 商城会员添加自定义字段
                        claims.claim("member_id", String.valueOf(userDetails.getId())); // 添加会员ID
                    }
                });
            }
        };
    }

}

自定义认证响应

🤔 如何自定义 OAuth2 认证成功或失败的响应数据结构符合当前系统统一的规范?

下图左侧部份是 OAuth2 原生返回(⬅️ ),大多数情况下,我们希望返回带有业务码的数据(➡️),以方便前端进行处理。

OAuth2 处理认证成功或失败源码坐标 OAuth2TokenEndpointFilter#doFilterInternal ,如下图:

根据源码阅读,发现只要重写✅ AuthenticationSuccessHandler 和❌ AuthenticationFailureHandler 的逻辑,就能够自定义认证成功和认证失败时的响应数据格式。

认证成功响应
package com.youlai.auth.handler;

/**
 * 认证成功处理器
 *
 * @author haoxr
 * @since 3.0.0
 */
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    /**
     * MappingJackson2HttpMessageConverter 是 Spring 框架提供的一个 HTTP 消息转换器,用于将 HTTP 请求和响应的 JSON 数据与 Java 对象之间进行转换
     */
    private final HttpMessageConverter<Object> accessTokenHttpResponseConverter = new MappingJackson2HttpMessageConverter();
    private Converter<OAuth2AccessTokenResponse, Map<String, Object>> accessTokenResponseParametersConverter = new DefaultOAuth2AccessTokenResponseMapConverter();


    /**
     * 自定义认证成功响应数据结构
     *
     * @param request the request which caused the successful authentication
     * @param response the response
     * @param authentication the <tt>Authentication</tt> object which was created during
     * the authentication process.
     * @throws IOException
     * @throws ServletException
     */

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        OAuth2AccessTokenAuthenticationToken accessTokenAuthentication =
                (OAuth2AccessTokenAuthenticationToken) authentication;

        OAuth2AccessToken accessToken = accessTokenAuthentication.getAccessToken();
        OAuth2RefreshToken refreshToken = accessTokenAuthentication.getRefreshToken();
        Map<String, Object> additionalParameters = accessTokenAuthentication.getAdditionalParameters();

        OAuth2AccessTokenResponse.Builder builder =
                OAuth2AccessTokenResponse.withToken(accessToken.getTokenValue())
                        .tokenType(accessToken.getTokenType());
        if (accessToken.getIssuedAt() != null && accessToken.getExpiresAt() != null) {
            builder.expiresIn(ChronoUnit.SECONDS.between(accessToken.getIssuedAt(), accessToken.getExpiresAt()));
        }
        if (refreshToken != null) {
            builder.refreshToken(refreshToken.getTokenValue());
        }
        if (!CollectionUtils.isEmpty(additionalParameters)) {
            builder.additionalParameters(additionalParameters);
        }
        OAuth2AccessTokenResponse accessTokenResponse = builder.build();

        Map<String, Object> tokenResponseParameters = this.accessTokenResponseParametersConverter
                .convert(accessTokenResponse);
        ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);

        this.accessTokenHttpResponseConverter.write(Result.success(tokenResponseParameters), null, httpResponse);
    }
}

认证失败响应
package com.youlai.auth.handler;

/**
 * 认证失败处理器
 *
 * @author haoxr
 * @since 2023/7/6
 */
@Slf4j
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {

    /**
     * MappingJackson2HttpMessageConverter 是 Spring 框架提供的一个 HTTP 消息转换器,用于将 HTTP 请求和响应的 JSON 数据与 Java 对象之间进行转换
     */
    private final HttpMessageConverter<Object> accessTokenHttpResponseConverter = new MappingJackson2HttpMessageConverter();


    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        OAuth2Error error = ((OAuth2AuthenticationException) exception).getError();
        ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);
        Result result = Result.failed(error.getErrorCode());
        accessTokenHttpResponseConverter.write(result, null, httpResponse);
    }
}
配置自定义处理器

AuthorizationServierConfig

public SecurityFilterChain authorizationServerSecurityFilterChain() throws Exception {

    // ...
    authorizationServerConfigurer
        .tokenEndpoint(tokenEndpoint ->
                       tokenEndpoint
                       // ...
                       .accessTokenResponseHandler(new MyAuthenticationSuccessHandler()) // 自定义成功响应
                       .errorResponseHandler(new MyAuthenticationFailureHandler()) // 自定义失败响应
                      );

}

密码模式测试

单元测试

启动 youlai-system 模块,需要从其获取系统用户信息(用户名、密码)进行认证

package com.youlai.auth.authentication;

/**
 * OAuth2 密码模式单元测试
 */
@SpringBootTest
@AutoConfigureMockMvc
@Slf4j
public class PasswordAuthenticationTests {

    @Autowired
    private MockMvc mvc;

    /**
     * 测试密码模式登录
     */
    @Test
    void testPasswordLogin() throws Exception {
        HttpHeaders headers = new HttpHeaders();
        // 客户端ID和密钥
        headers.setBasicAuth("mall-admin", "123456");

        this.mvc.perform(post("/oauth2/token")
                        .param(OAuth2ParameterNames.GRANT_TYPE, "password") // 密码模式
                        .param(OAuth2ParameterNames.USERNAME, "admin") // 用户名
                        .param(OAuth2ParameterNames.PASSWORD, "123456") // 密码
                        .headers(headers))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.data.access_token").isNotEmpty());
    }
}

单元测试通过,打印响应数据可以看到返回的 access_token 和 refresh_token

Postman 测试
  • 请求参数

  • 认证参数

    Authorization Type 选择 Basic Auth , 填写客户端ID(mall-admin)和密钥(123456),

资源服务器

youlai-system 系统管理模块也作为资源服务器

maven 依赖

<!-- Spring Authorization Server 授权服务器依赖 -->
<dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

application.yml

通过 Feign 请求 youlai-system 服务以获取系统用户认证信息(用户名和密码),在用户尚未登录的情况下,需要将此请求的路径配置到白名单中以避免拦截。

security:
  # 允许无需认证的路径列表
  whitelist-paths:
    # 获取系统用户的认证信息用于账号密码判读
    - /api/v1/users/{username}/authInfo

资源服务器配置

配置 ResourceServerConfig 位于资源服务器公共模块 common-security 中

package com.youlai.common.security.config;

import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.json.JSONUtil;
import com.youlai.common.constant.SecurityConstants;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.logging.log4j.util.Strings;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
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.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;

import java.util.List;

/**
 * 资源服务器配置
 *
 * @author haoxr
 * @since 3.0.0
 */
@ConfigurationProperties(prefix = "security")
@Configuration
@EnableWebSecurity
@Slf4j
public class ResourceServerConfig {

    /**
     * 白名单路径列表
     */
    @Setter
    private List<String> ignoreUrls;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        log.info("whitelist path:{}", JSONUtil.toJsonStr(ignoreUrls));
        http.authorizeHttpRequests(requestMatcherRegistry ->
                        {
                            if (CollectionUtil.isNotEmpty(ignoreUrls)) {
                                requestMatcherRegistry.requestMatchers(Convert.toStrArray(ignoreUrls)).permitAll();
                            }
                            requestMatcherRegistry.anyRequest().authenticated();
                        }
                )
                .csrf(AbstractHttpConfigurer::disable)
        ;
        http.oauth2ResourceServer(resourceServerConfigurer ->
                resourceServerConfigurer.jwt(jwtConfigurer -> jwtAuthenticationConverter())
        ) ;
        return http.build();
    }

    /**
     * 不走过滤器链的放行配置
     */
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring()
                .requestMatchers(
                        "/webjars/**",
                        "/doc.html",
                        "/swagger-resources/**",
                        "/v3/api-docs/**",
                        "/swagger-ui/**"
                );
    }


    /**
     * 自定义JWT Converter
     *
     * @return Converter
     * @see JwtAuthenticationProvider#setJwtAuthenticationConverter(Converter)
     */
    @Bean
    public Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        jwtGrantedAuthoritiesConverter.setAuthorityPrefix(Strings.EMPTY);
        jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(SecurityConstants.AUTHORITIES_CLAIM_NAME_KEY);

        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
        return jwtAuthenticationConverter;
    }
}

认证流程测试

分别启动 youlai-mall 的 youai-auth (认证中心)、youlai-system(系统管理模块)、youali-gateway(网关)

登录认证授权

  • 请求参数

  • 认证参数

    Authorization Type 选择 Basic Auth , 填写客户端ID(mall-admin)和密钥(123456),

  • 成功响应

    认证成功,获取到访问令牌(access_token )

获取用户信息

使用已获得的访问令牌 (access_token) 向资源服务器发送请求以获取登录用户信息

在这里插入图片描述

成功地获取登录用户信息的响应,而不是出现未授权的401错误。

结语

关于 Spring Authorization Server 1.1 版本的密码模式扩展和在 Spring Cloud 中使用新的授权方式,可以说与 Spring Security OAuth2 的代码相似度极高。如果您已经熟悉 Spring Security OAuth2,那么学习 Spring Authorization Server 将变得轻而易举。后续文章会更新其他常见授权模式的扩展,敬请期待~

源码

本文完整源码: youlai-mall

参考文档

  • Spring Security 弃用 授权服务器和资源服务器

  • Spring Security OAuth 生命周期终止通知

    Spring Security OAuth 2.0 更新路线图

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/106309.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

C++(Qt)软件调试---线程死锁调试(15)

C(Qt)软件调试—线程死锁调试&#xff08;15&#xff09; 文章目录 C(Qt)软件调试---线程死锁调试&#xff08;15&#xff09;1、前言2、常见死锁3、linux下gdb调试C死锁1.1 使用代码1.2 gdb调试 3、linux下gdb调试Qt死锁1.1 使用代码1.2 gdb调试 4、Windows下gdb调试C死锁5、W…

Selenium+Pytest自动化测试框架详解

前言 selenium自动化 pytest测试框架 本章你需要 一定的python基础——至少明白类与对象&#xff0c;封装继承&#xff1b;一定的selenium基础——本篇不讲selenium&#xff0c;不会的可以自己去看selenium中文翻译网 一、测试框架简介 测试框架有什么优点 代码复用率高&…

【LeetCode】5. 最长回文子串

题目链接 文章目录 Python3方法&#xff1a; 暴力求解 ⟮ O ( n 3 ) 、 O ( 1 ) ⟯ \lgroup O(n^3)、O(1)\rgroup ⟮O(n3)、O(1)⟯方法一&#xff1a; 动态规划 (回文串同时去掉头尾后 依然是回文串) ⟮ O ( n 2 ) ⟯ \lgroup O(n^2)\rgroup ⟮O(n2)⟯⭐ 方法二&#xff1a;…

网工内推 | 急招网工,思科、华为认证优先,法定节假日三薪

01 江苏臻云技术 招聘岗位&#xff1a;网络工程师 职责描述&#xff1a; 1、负责落实数据中心机房日常网络监测及巡检任务&#xff1b; 2、负责数据中心网络设备日常监控、变更、维护、巡检&#xff1b; 3、负责日常巡检报告、故障维护报告、变更申请的文档的编制&#xff1b;…

番外8.2 --- 后续

### 01&#xff1a;dd命令&#xff1a;在新挂载点创建swap文件大小10MB&#xff1b;&#xff08;dd if/dev/zero of/swap bs1024 count10240&#xff09; 02&#xff1a;给swap建立文件系统&#xff0c;将其分属到swap文件&#xff08;mkswap /swap&#xff1b; swapon /swap &…

下载视频号安装,下载视频号安装到手机上?

在数字化时代&#xff0c;随着社交媒体的蓬勃发展&#xff0c;视频内容正成为品牌传播和用户吸引的重要方式。而作为当下最热门的短视频平台之一&#xff0c;视频号为用户提供了创作、分享和推广优质内容的机会。如果您还不了解视频号视频或想进一步了解如何下载视频号视频&…

多测师肖sir_高级金牌讲师__接口测试之练习题(6.1)

常见的接口面试题目: 1.postman接口测试&#xff0c;它有一个功能可以设置参数化&#xff0c;你有用过吗? 用过 &#xff08;1&#xff09;新建一个csv.文件 填写user、pwd 新建一个全局变量 user、pwd 点击bodyform-data 填写user、pwd 点击run 导入csv.件 查看结果 &#x…

element-ui vue2 iframe 嵌入外链新解

效果如图 实现原理 在路由中通过 props 传值 {path: /iframe,component: Layout,meta: { title: 小助手, icon: example },children: [{path: chatglm,name: chatglm,props: { name: chatglm,url: https://chatglm.cn },component: () > import(/views/iframe/common),me…

Facebook广告效果数据获取

一、背景 公司每年在Facebook和Google上投放了大量的广告&#xff0c;我总不能让老板登录Facebook广告投放平台上去看广告效果&#xff0c;其实老板只关注每天花了多少钱引来了多少客户&#xff0c;每个客户平均花费多少钱&#xff0c;其它的他才不关心&#xff0c;有Facebook…

如何将Mysql数据库的表导出并导入到另外的架构

如何将Mysql数据库的表导出并导入到另外的架构 准备一、解决方法1.右键->导出->用mysqldump导出2.注意路径一般为&#xff1a;C:/Program Files/MySQL/MySQL Server 8.0/bin/mysqldump.exe和导出的sql文件位置3.右键->SQL脚本->运行SQL脚本4.找到SQL脚本并点击确定…

C++基础:函数模板

为了代码重用&#xff0c;代码必须是通用的&#xff1b;通用的代码就必须不受数据类型的限制。那么我们可以把数据类型改为一个设计参数&#xff0c;这种类型的程序设计称为参数化程序设计&#xff0c;软件模板有模板构造&#xff0c;包括函数模板和类模板。 函数模板可以用来…

Facebook账号被封?那是因为没做对这些事

Facebook是全球最大的社交媒体平台之一&#xff0c;拥有数十亿的全球用户。它的主要产品包括Facebook&#xff08;面向个人用户的社交媒体平台&#xff09;、Instagram、WhatsApp和Messenger。同时他也是美国数字广告市场的主要参与者之一&#xff0c;其广告平台吸引了数百万广…

H5营销观察:H5破圈传播有什么秘诀

在移动互联网时代&#xff0c;流量越加碎片化&#xff0c;场景变得相对短促和兴趣导向&#xff0c;一个营销H5产生的每一次点击、每一次互动、每一次流量停留背后都会有相应的动机&#xff0c;也是营销流量效果的成因。 今天&#xff0c;我们一起来探究下什么样的内容更容易传播…

微信小程序:点击按钮出现右侧弹窗

效果 代码 wxml <!-- 弹窗信息 --> <view class"popup-container" wx:if"{{showPopup}}"><view class"popup-content"><!-- 弹窗内容 --><text>这是一个右侧弹窗</text></view> </view> <…

系列十四、Spring如何处理线程安全问题

一、线程安全问题出现的原因 Spring中出现线程安全的原因是&#xff0c;单实例bean中存在成员变量&#xff0c;并且有对这个bean进行读写的操作&#xff0c;因此出现了线程安全的问题。 二、案例代码 2.1、MySpringConfig /*** Author : 一叶浮萍归大海* Date: 2023/10/24 1…

【软考】系统集成项目管理工程师(九)项目成本管理【4分】

一、成本概念 1、产品全生命周期成本 产品或系统的整个使用生命周期内&#xff0c;在获得阶段&#xff08;设计、生产、安装和测试等活动&#xff0c;即项目存续期间&#xff09;、运营与维护、生命周期结束时对产品的处置所发生的全部成本 2、成本类型 成本类型描述可变成…

基于 ARM+FPGA+AD平台的多类型同步信号采集仪开发及试验验证(二)板卡总体设计

2.2 板卡总体设计 本章开发了一款基于 AD7193RJ45 的多类型传感信号同步调理板卡&#xff0c;如图 2.4 所 示&#xff0c;负责将传感器传来的模拟电信号转化为数字信号&#xff0c;以供数据采集系统采集&#xff0c;实现了 单通道自由切换传感信号类型与同步采集多类型传…

在进行自动化测试,遇到验证码的问题,怎么办?

1.找开发去掉验证码或者使用万能验证码 2.使用OCR自动识别 使用OCR自动化识别&#xff0c;一般识别率不是太高&#xff0c;处理一般简单验证码还是没问题 这里使用的是Tesseract-OCR,下载地址&#xff1a;https://github.com/A9T9/Free-Ocr-Windows-Desktop/releases 怎么使…

北邮22级信通院数电:Verilog-FPGA(7)第七周实验(1):带使能端的38译码器全加器(关注我的uu们加群咯~)

北邮22信通一枚~ 跟随课程进度更新北邮信通院数字系统设计的笔记、代码和文章 持续关注作者 迎接数电实验学习~ 获取更多文章&#xff0c;请访问专栏&#xff1a; 北邮22级信通院数电实验_青山如墨雨如画的博客-CSDN博客 关注作者的uu们可以进群啦~ 目录 方法一&#xff…

huoshan device_id和iid生成记录学习分析

huoshan 和 douyin 作为字节系的产品&#xff0c;device_id 和 iid是抓包经常遇到的字段&#xff0c;代表了设备的概念。 还是熟悉的接口&#xff0c;像 device_register &#xff0c;app_alert_check 和 app_notice_status 都需要请求一遍。 这些接口跑完一遍&#xff0c;设…