Spring Authorization Server入门 (十九) 基于Redis的Token、客户端信息和授权确认信息存储

怎么使用Spring Data Redis实现Spring Authorization Server的核心services?

本文对应的是文档中的How-to: Implement core services with JPA,文档中使用Jpa实现了核心的三个服务类:授权信息、客户端信息和授权确认的服务;本文会使用Spring Data Redis参考文档来添加新的实现。在这里也放一下文档中的一句话: 本指南的目的是为您自己实现这些服务提供一个起点,以便您可以根据自己的需要进行修改。

实现步骤

因为本文使用的是Spring Data,所以需要先定义对应的实体,然后根据实体定义对应的Repository(Spring Data Repository),最后实现核心的service,使用这些Repository操作Redis。

  1. 定义实体
  2. 定义Redis Repositories
  3. 实现核心服务类

具体实现

定义实体

标题中的类是框架中对应的默认实体,下方代码中的类都是从标题后边的类中映射数据从而保存至Redis。

客户端实体(RegisteredClient)

package com.example.entity.security;import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.index.Indexed;import java.io.Serializable;
import java.time.Instant;/*** 基于redis存储的客户端实体** @author vains*/
@Data
@RedisHash(value = "client")
public class RedisRegisteredClient implements Serializable {/*** 主键*/@Idprivate String id;/*** 客户端id*/@Indexedprivate String clientId;/*** 客户端id签发时间*/private Instant clientIdIssuedAt;/*** 客户端秘钥*/private String clientSecret;/*** 客户端秘钥过期时间*/private Instant clientSecretExpiresAt;/*** 客户端名称*/private String clientName;/*** 客户端支持的认证方式*/private String clientAuthenticationMethods;/*** 客户端支持的授权申请方式*/private String authorizationGrantTypes;/*** 回调地址*/private String redirectUris;/*** 登出回调地址*/private String postLogoutRedirectUris;/*** 客户端拥有的scope*/private String scopes;/*** 客户端配置*/private String clientSettings;/*** 通过该客户端签发的access token设置*/private String tokenSettings;}

授权信息实体(OAuth2Authorization)

该类中包括了授权码、access_token、refresh_token、设备码和id_token等数据。

package com.example.entity.security;import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.TimeToLive;
import org.springframework.data.redis.core.index.Indexed;import java.io.Serializable;
import java.time.Instant;
import java.util.concurrent.TimeUnit;/*** 使用Repository将授权申请的认证信息缓存至redis的实体** @author vains*/
@Data
@RedisHash(value = "authorization")
public class RedisOAuth2Authorization implements Serializable {/*** 主键*/@Idprivate String id;/*** 授权申请时使用的客户端id*/private String registeredClientId;/*** 授权用户姓名*/private String principalName;/*** 授权申请时使用 grant_type*/private String authorizationGrantType;/*** 授权申请的scope*/private String authorizedScopes;/*** 授权的认证信息(当前用户)、请求信息(授权申请请求)*/private String attributes;/*** 授权申请时的state*/@Indexedprivate String state;/*** 授权码的值*/@Indexedprivate String authorizationCodeValue;/*** 授权码签发时间*/private Instant authorizationCodeIssuedAt;/*** 授权码过期时间*/private Instant authorizationCodeExpiresAt;/*** 授权码元数据*/private String authorizationCodeMetadata;/*** access token的值*/@Indexedprivate String accessTokenValue;/*** access token签发时间*/private Instant accessTokenIssuedAt;/*** access token过期时间*/private Instant accessTokenExpiresAt;/*** access token元数据*/private String accessTokenMetadata;/*** access token的类型*/private String accessTokenType;/*** access token中包含的scope*/private String accessTokenScopes;/*** refresh token的值*/@Indexedprivate String refreshTokenValue;/*** refresh token签发使劲*/private Instant refreshTokenIssuedAt;/*** refresh token过期时间*/private Instant refreshTokenExpiresAt;/*** refresh token元数据*/private String refreshTokenMetadata;/*** id token的值*/@Indexedprivate String oidcIdTokenValue;/*** id token签发时间*/private Instant oidcIdTokenIssuedAt;/*** id token过期时间*/private Instant oidcIdTokenExpiresAt;/*** id token元数据*/private String oidcIdTokenMetadata;/*** id token中包含的属性*/private String oidcIdTokenClaims;/*** 用户码的值*/@Indexedprivate String userCodeValue;/*** 用户码签发时间*/private Instant userCodeIssuedAt;/*** 用户码过期时间*/private Instant userCodeExpiresAt;/*** 用户码元数据*/private String userCodeMetadata;/*** 设备码的值*/@Indexedprivate String deviceCodeValue;/*** 设备码签发时间*/private Instant deviceCodeIssuedAt;/*** 设备码过期时间*/private Instant deviceCodeExpiresAt;/*** 设备码元数据*/private String deviceCodeMetadata;/*** 当前对象在Redis中的过期时间*/@TimeToLive(unit = TimeUnit.MINUTES)private Long timeout;}

授权确认信息实体(OAuth2AuthorizationConsent)

package com.example.entity.security;import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.index.Indexed;import java.io.Serializable;/*** 基于redis的授权确认存储实体** @author vains*/
@Data
@RedisHash(value = "authorizationConsent")
public class RedisAuthorizationConsent implements Serializable {/*** 额外提供的主键*/@Idprivate String id;/*** 当前授权确认的客户端id*/@Indexedprivate String registeredClientId;/*** 当前授权确认用户的 username*/@Indexedprivate String principalName;/*** 授权确认的scope*/private String authorities;}

注解解释

  1. @RedisHash标注这是一个Spring Data Redis的实体类,同时也指定了该类保存在Redis时的key前缀。
  2. @Id指定id属性,id属性也会被当做key的一部分,本注解和上个这两个注解项负责创建用于持久化哈希的实际键。
  3. @Indexed注解标注的字段会创建一个基于该字段的索引,让Repository支持findBy被注解标注的字段名等方法。
  4. @TimeToLive注解标注的字段会被用来当做该对象在Redis的过期时间,虽然RedisHash也支持设置过期时间,但是不够灵活,所以额外添加一个字段针对某条数据设置过期时间。

定义Spring Data Repositories(Redis Repositories)

Spring Data Repository是Spring Data抽象出来的一个增删改查的interface接口,适用于Spring Data的不同实现,框架提供了支持增删改查的公共Repository: CrudRepository<实体类,主键类型>

客户端Repository

像下边的接口中有一个findByClientId方法,但是ClientId属性并不是主键,如果不加@Indexed注解,则该方法就不会生效。

package com.example.repository;import com.example.entity.security.RedisRegisteredClient;
import org.springframework.data.repository.CrudRepository;import java.util.Optional;/*** 基于Spring Data Redis的客户端repository** @author vains*/
public interface RedisClientRepository extends CrudRepository<RedisRegisteredClient, String> {/*** 根据客户端Id查询客户端信息** @param clientId 客户端id* @return 客户端信息*/Optional<RedisRegisteredClient> findByClientId(String clientId);}

授权信息Repository

提供根据state, authorizationCodeValue, accessTokenValue, refreshTokenValue, userCodeValuedeviceCodeValue属性查询的方法,在service中组合使用。

package com.example.repository;import com.example.entity.security.RedisOAuth2Authorization;
import org.springframework.data.repository.CrudRepository;import java.util.Optional;/*** oauth2授权管理** @author vains*/
public interface RedisOAuth2AuthorizationRepository extends CrudRepository<RedisOAuth2Authorization, String> {/*** 根据授权码获取认证信息** @param token 授权码* @return 认证信息*/Optional<RedisOAuth2Authorization> findByAuthorizationCodeValue(String token);/*** 根据access token获取认证信息** @param token access token* @return 认证信息*/Optional<RedisOAuth2Authorization> findByAccessTokenValue(String token);/*** 根据刷新token获取认证信息** @param token 刷新token* @return 认证信息*/Optional<RedisOAuth2Authorization> findByRefreshTokenValue(String token);/*** 根据id token获取认证信息** @param token id token* @return 认证信息*/Optional<RedisOAuth2Authorization> findByOidcIdTokenValue(String token);/*** 根据用户码获取认证信息** @param token 用户码* @return 认证信息*/Optional<RedisOAuth2Authorization> findByUserCodeValue(String token);/*** 根据设备码获取认证信息** @param token 设备码* @return 认证信息*/Optional<RedisOAuth2Authorization> findByDeviceCodeValue(String token);/*** 根据state获取认证信息** @param token 授权申请时的state* @return 认证信息*/Optional<RedisOAuth2Authorization> findByState(String token);
}

授权确认信息Repository

提供一个根据客户端Id和授权确认用户的username查询的方法。

package com.example.repository;import com.example.entity.security.RedisAuthorizationConsent;
import org.springframework.data.repository.CrudRepository;import java.util.Optional;/*** 基于redis的授权确认repository** @author vains*/
public interface RedisAuthorizationConsentRepository extends CrudRepository<RedisAuthorizationConsent, String> {/*** 根据客户端id和授权确认用户的 username 查询授权确认信息** @param registeredClientId 客户端id* @param principalName      授权确认用户的 username* @return 授权确认记录*/Optional<RedisAuthorizationConsent> findByRegisteredClientIdAndPrincipalName(String registeredClientId, String principalName);}

以下内容摘抄自文档内容

查询方法允许从方法名自动派生简单的查找器查询,请确保在查找器方法中使用的属性已设置为索引。

下表提供了Redis支持的关键字概述,以及包含该关键字的方法本质上是什么:

KeywordSampleRedis snippet
AndfindByLastnameAndFirstnameSINTER …:firstname:rand …:lastname:al’thor
OrfindByLastnameOrFirstnameSUNION …:firstname:rand …:lastname:al’thor
Is, EqualsfindByFirstname, findByFirstnameIs, findByFirstnameEqualsSINTER …:firstname:rand
IsTrueFindByAliveIsTrueSINTER …:alive:1
IsFalsefindByAliveIsFalseSINTER …:alive:0
Top,FirstfindFirst10ByFirstname,findTop5ByFirstname

实现核心service

客户端Repository(RegisteredClientRepository)

小tip:我也不知道为什么这个这么特殊是Repository…

package com.example.repository;import com.example.entity.security.RedisRegisteredClient;
import com.example.service.impl.RedisOAuth2AuthorizationService;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.Module;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.jackson2.SecurityJackson2Modules;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.jackson2.OAuth2AuthorizationServerJackson2Module;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import org.springframework.stereotype.Repository;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;/*** 基于redis的客户端repository实现** @author vains*/
@Slf4j
@Repository
@RequiredArgsConstructor
public class RedisRegisteredClientRepository implements RegisteredClientRepository {/*** 提供给客户端初始化使用(不需要可删除)*/private final PasswordEncoder passwordEncoder;private final RedisClientRepository repository;private final static ObjectMapper MAPPER = new ObjectMapper();static {// 初始化序列化配置ClassLoader classLoader = RedisOAuth2AuthorizationService.class.getClassLoader();// 加载security提供的ModulesList<Module> modules = SecurityJackson2Modules.getModules(classLoader);MAPPER.registerModules(modules);// 加载Authorization Server提供的ModuleMAPPER.registerModule(new OAuth2AuthorizationServerJackson2Module());}@Overridepublic void save(RegisteredClient registeredClient) {Assert.notNull(registeredClient, "registeredClient cannot be null");this.repository.findByClientId(registeredClient.getClientId()).ifPresent(existingRegisteredClient -> this.repository.deleteById(existingRegisteredClient.getId()));this.repository.save(toEntity(registeredClient));}@Overridepublic RegisteredClient findById(String id) {Assert.hasText(id, "id cannot be empty");return this.repository.findById(id).map(this::toObject).orElse(null);}@Overridepublic RegisteredClient findByClientId(String clientId) {Assert.hasText(clientId, "clientId cannot be empty");return this.repository.findByClientId(clientId).map(this::toObject).orElse(null);}private RegisteredClient toObject(RedisRegisteredClient client) {Set<String> clientAuthenticationMethods = StringUtils.commaDelimitedListToSet(client.getClientAuthenticationMethods());Set<String> authorizationGrantTypes = StringUtils.commaDelimitedListToSet(client.getAuthorizationGrantTypes());Set<String> redirectUris = StringUtils.commaDelimitedListToSet(client.getRedirectUris());Set<String> postLogoutRedirectUris = StringUtils.commaDelimitedListToSet(client.getPostLogoutRedirectUris());Set<String> clientScopes = StringUtils.commaDelimitedListToSet(client.getScopes());RegisteredClient.Builder builder = RegisteredClient.withId(client.getId()).clientId(client.getClientId()).clientIdIssuedAt(client.getClientIdIssuedAt()).clientSecret(client.getClientSecret()).clientSecretExpiresAt(client.getClientSecretExpiresAt()).clientName(client.getClientName()).clientAuthenticationMethods(authenticationMethods ->clientAuthenticationMethods.forEach(authenticationMethod ->authenticationMethods.add(resolveClientAuthenticationMethod(authenticationMethod)))).authorizationGrantTypes((grantTypes) ->authorizationGrantTypes.forEach(grantType ->grantTypes.add(resolveAuthorizationGrantType(grantType)))).redirectUris((uris) -> uris.addAll(redirectUris)).postLogoutRedirectUris((uris) -> uris.addAll(postLogoutRedirectUris)).scopes((scopes) -> scopes.addAll(clientScopes));Map<String, Object> clientSettingsMap = parseMap(client.getClientSettings());builder.clientSettings(ClientSettings.withSettings(clientSettingsMap).build());Map<String, Object> tokenSettingsMap = parseMap(client.getTokenSettings());builder.tokenSettings(TokenSettings.withSettings(tokenSettingsMap).build());return builder.build();}private RedisRegisteredClient toEntity(RegisteredClient registeredClient) {List<String> clientAuthenticationMethods = new ArrayList<>(registeredClient.getClientAuthenticationMethods().size());registeredClient.getClientAuthenticationMethods().forEach(clientAuthenticationMethod ->clientAuthenticationMethods.add(clientAuthenticationMethod.getValue()));List<String> authorizationGrantTypes = new ArrayList<>(registeredClient.getAuthorizationGrantTypes().size());registeredClient.getAuthorizationGrantTypes().forEach(authorizationGrantType ->authorizationGrantTypes.add(authorizationGrantType.getValue()));RedisRegisteredClient entity = new RedisRegisteredClient();entity.setId(registeredClient.getId());entity.setClientId(registeredClient.getClientId());entity.setClientIdIssuedAt(registeredClient.getClientIdIssuedAt());entity.setClientSecret(registeredClient.getClientSecret());entity.setClientSecretExpiresAt(registeredClient.getClientSecretExpiresAt());entity.setClientName(registeredClient.getClientName());entity.setClientAuthenticationMethods(StringUtils.collectionToCommaDelimitedString(clientAuthenticationMethods));entity.setAuthorizationGrantTypes(StringUtils.collectionToCommaDelimitedString(authorizationGrantTypes));entity.setRedirectUris(StringUtils.collectionToCommaDelimitedString(registeredClient.getRedirectUris()));entity.setPostLogoutRedirectUris(StringUtils.collectionToCommaDelimitedString(registeredClient.getPostLogoutRedirectUris()));entity.setScopes(StringUtils.collectionToCommaDelimitedString(registeredClient.getScopes()));entity.setClientSettings(writeMap(registeredClient.getClientSettings().getSettings()));entity.setTokenSettings(writeMap(registeredClient.getTokenSettings().getSettings()));return entity;}private Map<String, Object> parseMap(String data) {try {return MAPPER.readValue(data, new TypeReference<>() {});} catch (Exception ex) {throw new IllegalArgumentException(ex.getMessage(), ex);}}private String writeMap(Map<String, Object> data) {try {return MAPPER.writeValueAsString(data);} catch (Exception ex) {throw new IllegalArgumentException(ex.getMessage(), ex);}}private static AuthorizationGrantType resolveAuthorizationGrantType(String authorizationGrantType) {if (AuthorizationGrantType.AUTHORIZATION_CODE.getValue().equals(authorizationGrantType)) {return AuthorizationGrantType.AUTHORIZATION_CODE;} else if (AuthorizationGrantType.CLIENT_CREDENTIALS.getValue().equals(authorizationGrantType)) {return AuthorizationGrantType.CLIENT_CREDENTIALS;} else if (AuthorizationGrantType.REFRESH_TOKEN.getValue().equals(authorizationGrantType)) {return AuthorizationGrantType.REFRESH_TOKEN;}// Custom authorization grant typereturn new AuthorizationGrantType(authorizationGrantType);}private static ClientAuthenticationMethod resolveClientAuthenticationMethod(String clientAuthenticationMethod) {if (ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue().equals(clientAuthenticationMethod)) {return ClientAuthenticationMethod.CLIENT_SECRET_BASIC;} else if (ClientAuthenticationMethod.CLIENT_SECRET_POST.getValue().equals(clientAuthenticationMethod)) {return ClientAuthenticationMethod.CLIENT_SECRET_POST;} else if (ClientAuthenticationMethod.NONE.getValue().equals(clientAuthenticationMethod)) {return ClientAuthenticationMethod.NONE;}// Custom client authentication methodreturn new ClientAuthenticationMethod(clientAuthenticationMethod);}/*** 容器启动后初始化客户端* (不需要可删除)*/@PostConstructpublic void initClients() {log.info("Initialize client information to Redis.");// 默认需要授权确认ClientSettings.Builder builder = ClientSettings.builder().requireAuthorizationConsent(Boolean.TRUE);TokenSettings tokenSettings = TokenSettings.builder()// 自包含token(jwt).accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)// Access Token 存活时间:2小时.accessTokenTimeToLive(Duration.ofHours(2L))// 授权码存活时间:5分钟.authorizationCodeTimeToLive(Duration.ofMinutes(5L))// 设备码存活时间:5分钟.deviceCodeTimeToLive(Duration.ofMinutes(5L))// Refresh Token 存活时间:7天.refreshTokenTimeToLive(Duration.ofDays(7L))// 刷新 Access Token 后是否重用 Refresh Token.reuseRefreshTokens(Boolean.TRUE)// 设置 Id Token 加密方式.idTokenSignatureAlgorithm(SignatureAlgorithm.RS256).build();// 正常授权码客户端RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())// 客户端id.clientId("messaging-client")// 客户端名称.clientName("授权码")// 客户端秘钥,使用密码解析器加密.clientSecret(passwordEncoder.encode("123456"))// 客户端认证方式,基于请求头的认证.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)// 配置资源服务器使用该客户端获取授权时支持的方式.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE).authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN).authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)// 授权码模式回调地址,oauth2.1已改为精准匹配,不能只设置域名,并且屏蔽了localhost,本机使用127.0.0.1访问.redirectUri("http://127.0.0.1:8000/login/oauth2/code/messaging-client-oidc").redirectUri("https://www.baidu.com")// 该客户端的授权范围,OPENID与PROFILE是IdToken的scope,获取授权时请求OPENID的scope时认证服务会返回IdToken.scope(OidcScopes.OPENID).scope(OidcScopes.PROFILE)// 指定scope.scope("message.read").scope("message.write")// 客户端设置,设置用户需要确认授权.clientSettings(builder.build())// token相关配置.tokenSettings(tokenSettings).build();// 设备码授权客户端RegisteredClient deviceClient = RegisteredClient.withId(UUID.randomUUID().toString()).clientId("device-message-client").clientName("普通公共客户端")// 公共客户端.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)// 设备码授权.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE).authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)// 指定scope.scope("message.read").scope("message.write")// token相关配置.tokenSettings(tokenSettings).build();// PKCE客户端RegisteredClient pkceClient = RegisteredClient.withId(UUID.randomUUID().toString()).clientId("pkce-message-client").clientName("PKCE流程")// 公共客户端.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)// 设备码授权.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE).authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)// 授权码模式回调地址,oauth2.1已改为精准匹配,不能只设置域名,并且屏蔽了localhost,本机使用127.0.0.1访问.redirectUri("http://127.0.0.1:8000/login/oauth2/code/messaging-client-oidc")// 开启 PKCE 流程.clientSettings(builder.requireProofKey(Boolean.TRUE).build())// 指定scope.scope("message.read").scope("message.write")// token相关配置.tokenSettings(tokenSettings).build();// 初始化客户端this.save(registeredClient);this.save(deviceClient);this.save(pkceClient);}}

类中初始化客户端信息的操作针对第一次使用启动的项目,同时每次启动也是更新客户端的操作,如果不需要读者可自行去除。

授权信息的service

package com.example.service.impl;import com.example.entity.security.RedisOAuth2Authorization;
import com.example.repository.RedisOAuth2AuthorizationRepository;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.Module;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataRetrievalFailureException;
import org.springframework.security.jackson2.SecurityJackson2Modules;
import org.springframework.security.oauth2.core.*;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.jackson2.OAuth2AuthorizationServerJackson2Module;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.function.Consumer;/*** 基于redis的授权管理服务** @author vains*/
@Service
@RequiredArgsConstructor
public class RedisOAuth2AuthorizationService implements OAuth2AuthorizationService {private final RegisteredClientRepository registeredClientRepository;private final RedisOAuth2AuthorizationRepository oAuth2AuthorizationRepository;private final static ObjectMapper MAPPER = new ObjectMapper();static {// 初始化序列化配置ClassLoader classLoader = RedisOAuth2AuthorizationService.class.getClassLoader();// 加载security提供的ModulesList<Module> modules = SecurityJackson2Modules.getModules(classLoader);MAPPER.registerModules(modules);// 加载Authorization Server提供的ModuleMAPPER.registerModule(new OAuth2AuthorizationServerJackson2Module());}@Overridepublic void save(OAuth2Authorization authorization) {Optional<RedisOAuth2Authorization> existingAuthorization = oAuth2AuthorizationRepository.findById(authorization.getId());// 如果已存在则删除后再保存existingAuthorization.map(RedisOAuth2Authorization::getId).ifPresent(oAuth2AuthorizationRepository::deleteById);// 过期时间,默认永不过期long maxTimeout = -1L;// 所有code的过期时间,方便计算最大值List<Instant> expiresAtList = new ArrayList<>();RedisOAuth2Authorization entity = toEntity(authorization);// 如果有过期时间就存入Optional.ofNullable(entity.getAuthorizationCodeExpiresAt()).ifPresent(expiresAtList::add);// 如果有过期时间就存入Optional.ofNullable(entity.getAccessTokenExpiresAt()).ifPresent(expiresAtList::add);// 如果有过期时间就存入Optional.ofNullable(entity.getRefreshTokenExpiresAt()).ifPresent(expiresAtList::add);// 如果有过期时间就存入Optional.ofNullable(entity.getOidcIdTokenExpiresAt()).ifPresent(expiresAtList::add);// 如果有过期时间就存入Optional.ofNullable(entity.getUserCodeExpiresAt()).ifPresent(expiresAtList::add);// 如果有过期时间就存入Optional.ofNullable(entity.getDeviceCodeExpiresAt()).ifPresent(expiresAtList::add);// 获取最大的日期Optional<Instant> maxInstant = expiresAtList.stream().max(Comparator.comparing(Instant::getEpochSecond));if (maxInstant.isPresent()) {// 计算时间差Duration between = Duration.between(Instant.now(), maxInstant.get());// 转为分钟maxTimeout = between.toMinutes();}// 设置过期时间entity.setTimeout(maxTimeout);// 保存至redisoAuth2AuthorizationRepository.save(entity);}@Overridepublic void remove(OAuth2Authorization authorization) {Assert.notNull(authorization, "authorization cannot be null");oAuth2AuthorizationRepository.deleteById(authorization.getId());}@Overridepublic OAuth2Authorization findById(String id) {Assert.hasText(id, "id cannot be empty");return oAuth2AuthorizationRepository.findById(id).map(this::toObject).orElse(null);}@Overridepublic OAuth2Authorization findByToken(String token, OAuth2TokenType tokenType) {Assert.hasText(token, "token cannot be empty");Optional<RedisOAuth2Authorization> result;if (tokenType == null) {result = oAuth2AuthorizationRepository.findByState(token).or(() -> oAuth2AuthorizationRepository.findByAuthorizationCodeValue(token)).or(() -> oAuth2AuthorizationRepository.findByAccessTokenValue(token)).or(() -> oAuth2AuthorizationRepository.findByOidcIdTokenValue(token)).or(() -> oAuth2AuthorizationRepository.findByRefreshTokenValue(token)).or(() -> oAuth2AuthorizationRepository.findByUserCodeValue(token)).or(() -> oAuth2AuthorizationRepository.findByDeviceCodeValue(token));} else if (OAuth2ParameterNames.STATE.equals(tokenType.getValue())) {result = oAuth2AuthorizationRepository.findByState(token);} else if (OAuth2ParameterNames.CODE.equals(tokenType.getValue())) {result = oAuth2AuthorizationRepository.findByAuthorizationCodeValue(token);} else if (OAuth2TokenType.ACCESS_TOKEN.equals(tokenType)) {result = oAuth2AuthorizationRepository.findByAccessTokenValue(token);} else if (OidcParameterNames.ID_TOKEN.equals(tokenType.getValue())) {result = oAuth2AuthorizationRepository.findByOidcIdTokenValue(token);} else if (OAuth2TokenType.REFRESH_TOKEN.equals(tokenType)) {result = oAuth2AuthorizationRepository.findByRefreshTokenValue(token);} else if (OAuth2ParameterNames.USER_CODE.equals(tokenType.getValue())) {result = oAuth2AuthorizationRepository.findByUserCodeValue(token);} else if (OAuth2ParameterNames.DEVICE_CODE.equals(tokenType.getValue())) {result = oAuth2AuthorizationRepository.findByDeviceCodeValue(token);} else {result = Optional.empty();}return result.map(this::toObject).orElse(null);}/*** 将redis中存储的类型转为框架所需的类型** @param entity redis中存储的类型* @return 框架所需的类型*/private OAuth2Authorization toObject(RedisOAuth2Authorization entity) {RegisteredClient registeredClient = this.registeredClientRepository.findById(entity.getRegisteredClientId());if (registeredClient == null) {throw new DataRetrievalFailureException("The RegisteredClient with id '" + entity.getRegisteredClientId() + "' was not found in the RegisteredClientRepository.");}OAuth2Authorization.Builder builder = OAuth2Authorization.withRegisteredClient(registeredClient).id(entity.getId()).principalName(entity.getPrincipalName()).authorizationGrantType(resolveAuthorizationGrantType(entity.getAuthorizationGrantType())).authorizedScopes(StringUtils.commaDelimitedListToSet(entity.getAuthorizedScopes())).attributes(attributes -> attributes.putAll(parseMap(entity.getAttributes())));if (entity.getState() != null) {builder.attribute(OAuth2ParameterNames.STATE, entity.getState());}if (entity.getAuthorizationCodeValue() != null) {OAuth2AuthorizationCode authorizationCode = new OAuth2AuthorizationCode(entity.getAuthorizationCodeValue(),entity.getAuthorizationCodeIssuedAt(),entity.getAuthorizationCodeExpiresAt());builder.token(authorizationCode, metadata -> metadata.putAll(parseMap(entity.getAuthorizationCodeMetadata())));}if (entity.getAccessTokenValue() != null) {OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,entity.getAccessTokenValue(),entity.getAccessTokenIssuedAt(),entity.getAccessTokenExpiresAt(),StringUtils.commaDelimitedListToSet(entity.getAccessTokenScopes()));builder.token(accessToken, metadata -> metadata.putAll(parseMap(entity.getAccessTokenMetadata())));}if (entity.getRefreshTokenValue() != null) {OAuth2RefreshToken refreshToken = new OAuth2RefreshToken(entity.getRefreshTokenValue(),entity.getRefreshTokenIssuedAt(),entity.getRefreshTokenExpiresAt());builder.token(refreshToken, metadata -> metadata.putAll(parseMap(entity.getRefreshTokenMetadata())));}if (entity.getOidcIdTokenValue() != null) {OidcIdToken idToken = new OidcIdToken(entity.getOidcIdTokenValue(),entity.getOidcIdTokenIssuedAt(),entity.getOidcIdTokenExpiresAt(),parseMap(entity.getOidcIdTokenClaims()));builder.token(idToken, metadata -> metadata.putAll(parseMap(entity.getOidcIdTokenMetadata())));}if (entity.getUserCodeValue() != null) {OAuth2UserCode userCode = new OAuth2UserCode(entity.getUserCodeValue(),entity.getUserCodeIssuedAt(),entity.getUserCodeExpiresAt());builder.token(userCode, metadata -> metadata.putAll(parseMap(entity.getUserCodeMetadata())));}if (entity.getDeviceCodeValue() != null) {OAuth2DeviceCode deviceCode = new OAuth2DeviceCode(entity.getDeviceCodeValue(),entity.getDeviceCodeIssuedAt(),entity.getDeviceCodeExpiresAt());builder.token(deviceCode, metadata -> metadata.putAll(parseMap(entity.getDeviceCodeMetadata())));}return builder.build();}/*** 将框架所需的类型转为redis中存储的类型** @param authorization 框架所需的类型* @return redis中存储的类型*/private RedisOAuth2Authorization toEntity(OAuth2Authorization authorization) {RedisOAuth2Authorization entity = new RedisOAuth2Authorization();entity.setId(authorization.getId());entity.setRegisteredClientId(authorization.getRegisteredClientId());entity.setPrincipalName(authorization.getPrincipalName());entity.setAuthorizationGrantType(authorization.getAuthorizationGrantType().getValue());entity.setAuthorizedScopes(StringUtils.collectionToDelimitedString(authorization.getAuthorizedScopes(), ","));entity.setAttributes(writeMap(authorization.getAttributes()));entity.setState(authorization.getAttribute(OAuth2ParameterNames.STATE));OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode =authorization.getToken(OAuth2AuthorizationCode.class);setTokenValues(authorizationCode,entity::setAuthorizationCodeValue,entity::setAuthorizationCodeIssuedAt,entity::setAuthorizationCodeExpiresAt,entity::setAuthorizationCodeMetadata);OAuth2Authorization.Token<OAuth2AccessToken> accessToken =authorization.getToken(OAuth2AccessToken.class);setTokenValues(accessToken,entity::setAccessTokenValue,entity::setAccessTokenIssuedAt,entity::setAccessTokenExpiresAt,entity::setAccessTokenMetadata);if (accessToken != null && accessToken.getToken().getScopes() != null) {entity.setAccessTokenScopes(StringUtils.collectionToDelimitedString(accessToken.getToken().getScopes(), ","));}OAuth2Authorization.Token<OAuth2RefreshToken> refreshToken =authorization.getToken(OAuth2RefreshToken.class);setTokenValues(refreshToken,entity::setRefreshTokenValue,entity::setRefreshTokenIssuedAt,entity::setRefreshTokenExpiresAt,entity::setRefreshTokenMetadata);OAuth2Authorization.Token<OidcIdToken> oidcIdToken =authorization.getToken(OidcIdToken.class);setTokenValues(oidcIdToken,entity::setOidcIdTokenValue,entity::setOidcIdTokenIssuedAt,entity::setOidcIdTokenExpiresAt,entity::setOidcIdTokenMetadata);if (oidcIdToken != null) {entity.setOidcIdTokenClaims(writeMap(oidcIdToken.getClaims()));}OAuth2Authorization.Token<OAuth2UserCode> userCode =authorization.getToken(OAuth2UserCode.class);setTokenValues(userCode,entity::setUserCodeValue,entity::setUserCodeIssuedAt,entity::setUserCodeExpiresAt,entity::setUserCodeMetadata);OAuth2Authorization.Token<OAuth2DeviceCode> deviceCode =authorization.getToken(OAuth2DeviceCode.class);setTokenValues(deviceCode,entity::setDeviceCodeValue,entity::setDeviceCodeIssuedAt,entity::setDeviceCodeExpiresAt,entity::setDeviceCodeMetadata);return entity;}/*** 设置token的值** @param token              Token实例* @param tokenValueConsumer set方法* @param issuedAtConsumer   set方法* @param expiresAtConsumer  set方法* @param metadataConsumer   set方法*/private void setTokenValues(OAuth2Authorization.Token<?> token,Consumer<String> tokenValueConsumer,Consumer<Instant> issuedAtConsumer,Consumer<Instant> expiresAtConsumer,Consumer<String> metadataConsumer) {if (token != null) {OAuth2Token oAuth2Token = token.getToken();tokenValueConsumer.accept(oAuth2Token.getTokenValue());issuedAtConsumer.accept(oAuth2Token.getIssuedAt());expiresAtConsumer.accept(oAuth2Token.getExpiresAt());metadataConsumer.accept(writeMap(token.getMetadata()));}}/*** 处理授权申请时的 GrantType** @param authorizationGrantType 授权申请时的 GrantType* @return AuthorizationGrantType的实例*/private static AuthorizationGrantType resolveAuthorizationGrantType(String authorizationGrantType) {if (AuthorizationGrantType.AUTHORIZATION_CODE.getValue().equals(authorizationGrantType)) {return AuthorizationGrantType.AUTHORIZATION_CODE;} else if (AuthorizationGrantType.CLIENT_CREDENTIALS.getValue().equals(authorizationGrantType)) {return AuthorizationGrantType.CLIENT_CREDENTIALS;} else if (AuthorizationGrantType.REFRESH_TOKEN.getValue().equals(authorizationGrantType)) {return AuthorizationGrantType.REFRESH_TOKEN;} else if (AuthorizationGrantType.DEVICE_CODE.getValue().equals(authorizationGrantType)) {return AuthorizationGrantType.DEVICE_CODE;}// Custom authorization grant typereturn new AuthorizationGrantType(authorizationGrantType);}/*** 将json转为map** @param data json* @return map对象*/private Map<String, Object> parseMap(String data) {try {return MAPPER.readValue(data, new TypeReference<>() {});} catch (Exception ex) {throw new IllegalArgumentException(ex.getMessage(), ex);}}/*** 将map对象转为json字符串** @param metadata map对象* @return json字符串*/private String writeMap(Map<String, Object> metadata) {try {return MAPPER.writeValueAsString(metadata);} catch (Exception ex) {throw new IllegalArgumentException(ex.getMessage(), ex);}}}

授权确认信息的service

package com.example.service.impl;import com.example.entity.security.RedisAuthorizationConsent;
import com.example.repository.RedisAuthorizationConsentRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataRetrievalFailureException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;import java.util.HashSet;
import java.util.Set;
import java.util.UUID;/*** 基于redis的授权确认服务实现** @author vains*/
@Service
@RequiredArgsConstructor
public class RedisOAuth2AuthorizationConsentService implements OAuth2AuthorizationConsentService {private final RegisteredClientRepository registeredClientRepository;private final RedisAuthorizationConsentRepository authorizationConsentRepository;@Overridepublic void save(OAuth2AuthorizationConsent authorizationConsent) {Assert.notNull(authorizationConsent, "authorizationConsent cannot be null");// 如果存在就先删除this.authorizationConsentRepository.findByRegisteredClientIdAndPrincipalName(authorizationConsent.getRegisteredClientId(), authorizationConsent.getPrincipalName()).ifPresent(existingConsent -> this.authorizationConsentRepository.deleteById(existingConsent.getId()));// 保存RedisAuthorizationConsent entity = toEntity(authorizationConsent);entity.setId(UUID.randomUUID().toString());this.authorizationConsentRepository.save(entity);}@Overridepublic void remove(OAuth2AuthorizationConsent authorizationConsent) {Assert.notNull(authorizationConsent, "authorizationConsent cannot be null");// 如果存在就删除this.authorizationConsentRepository.findByRegisteredClientIdAndPrincipalName(authorizationConsent.getRegisteredClientId(), authorizationConsent.getPrincipalName()).ifPresent(existingConsent -> this.authorizationConsentRepository.deleteById(existingConsent.getId()));}@Overridepublic OAuth2AuthorizationConsent findById(String registeredClientId, String principalName) {Assert.hasText(registeredClientId, "registeredClientId cannot be empty");Assert.hasText(principalName, "principalName cannot be empty");return this.authorizationConsentRepository.findByRegisteredClientIdAndPrincipalName(registeredClientId, principalName).map(this::toObject).orElse(null);}private OAuth2AuthorizationConsent toObject(RedisAuthorizationConsent authorizationConsent) {String registeredClientId = authorizationConsent.getRegisteredClientId();RegisteredClient registeredClient = this.registeredClientRepository.findById(registeredClientId);if (registeredClient == null) {throw new DataRetrievalFailureException("The RegisteredClient with id '" + registeredClientId + "' was not found in the RegisteredClientRepository.");}OAuth2AuthorizationConsent.Builder builder = OAuth2AuthorizationConsent.withId(registeredClientId, authorizationConsent.getPrincipalName());if (authorizationConsent.getAuthorities() != null) {for (String authority : StringUtils.commaDelimitedListToSet(authorizationConsent.getAuthorities())) {builder.authority(new SimpleGrantedAuthority(authority));}}return builder.build();}private RedisAuthorizationConsent toEntity(OAuth2AuthorizationConsent authorizationConsent) {RedisAuthorizationConsent entity = new RedisAuthorizationConsent();entity.setRegisteredClientId(authorizationConsent.getRegisteredClientId());entity.setPrincipalName(authorizationConsent.getPrincipalName());Set<String> authorities = new HashSet<>();for (GrantedAuthority authority : authorizationConsent.getAuthorities()) {authorities.add(authority.getAuthority());}entity.setAuthorities(StringUtils.collectionToCommaDelimitedString(authorities));return entity;}}

去除认证服务配置文件中这三个核心service的注入

/*** 配置客户端Repository** @param jdbcTemplate db 数据源信息* @return 基于数据库的repository*/
@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {// 基于db存储客户端,还有一个基于内存的实现 InMemoryRegisteredClientRepositoryreturn new JdbcRegisteredClientRepository(jdbcTemplate);
}/*** 配置基于db的oauth2的授权管理服务** @param jdbcTemplate               db数据源信息* @param registeredClientRepository 上边注入的客户端repository* @return JdbcOAuth2AuthorizationService*/
@Bean
public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {// 基于db的oauth2认证服务,还有一个基于内存的服务实现InMemoryOAuth2AuthorizationServicereturn new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
}/*** 配置基于db的授权确认管理服务** @param jdbcTemplate               db数据源信息* @param registeredClientRepository 客户端repository* @return JdbcOAuth2AuthorizationConsentService*/
@Bean
public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {// 基于db的授权确认管理服务,还有一个基于内存的服务实现InMemoryOAuth2AuthorizationConsentServicereturn new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
}

写在最后

    到此为止基本就结束了,本文章和前边的所有系列文章没有必要的关联,如果是第一次看到文章的读者也是可以很顺畅的将文章中的内容引入项目,当然,因为引用了Spring Data Redis,所以项目必须要先有Redis支持。

文章看起来很长,但是实际上就是定义三个实体类,定义三个Repository,然后实现核心的service;逻辑并不复杂,操作Redis的内容因为使用了Spring Data Repositories,所以这两部分内容很少,内容多得地方就在每个service中实体与默认实体的转换中,一大堆的转换内容导致文章看起来内容很多,但是这些内容在文档中都已经实现,所以说这部分内容直接Copy就行,哈哈。

附录

  1. How-to: Implement core services with JPA
  2. Spring Data Redis
  3. Redis Repositories
  4. @TimeToLive
  5. @Indexed
  6. 代码仓库:Gitee、Github

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【Linux】第九站:make和makefile

文章目录 一、 Linux项目自动化构建工具make/Makefile1.make/makefile工作现象2.依赖关系与依赖方法3.如何清理4.为什么这里我们需要带上clean5.连续的make6.特殊符号 二、Linux下实现一个简单的进度条1.回车换行2.缓冲区3.倒计时的实现 一、 Linux项目自动化构建工具make/Make…

【Docker 内核详解】cgroups 资源限制(三):实现方式及工作原理简介

实现方式及工作原理简介 1.cgroups 如何判断资源超限及超出限额之后的措施2.cgroup 与任务之间的关联关系3.Docker 在使用 cgroup 时的注意事项4./sys/fs/cgroup/cpu/docker/[container-ID] 下文件的作用 在对 cgroups 规则和子系统有了一定了解以后&#xff0c;下面简单介绍操…

图解刘润2023年度演讲--进化的力量思维导图精华

大家好&#xff0c;我是老原。 周末&#xff0c;商业顾问刘润发表了年度演讲&#xff1a;《进化的力量&#xff1a;寒武纪大爆发》。 这两天出差期间&#xff0c;陆陆续续看完了这个长达4小时的演讲&#xff0c;梳理了2023年到底发生了些什么&#xff0c;现在的环境如何…… …

splice,slice,split傻傻分不清?

做个笔记&#x1f4d2; 一、splice数组方法&#xff0c;更改原数组 使用方法&#xff1a; splice(start) splice(start, deleteCount) splice(start, deleteCount, item1) splice(start, deleteCount, item1, item2, itemN) start: 开始索引 deleteCount&#xff1a;需要删除的…

深入理解强化学习——强化学习的历史:时序差分学习

分类目录&#xff1a;《深入理解强化学习》总目录 相关文章&#xff1a; 强化学习的历史&#xff1a;最优控制 强化学习的历史&#xff1a;试错学习 强化学习的历史&#xff1a;试错学习的发展 强化学习的历史&#xff1a;K臂赌博机、统计学习理论和自适应系统 强化学习的…

如何使用 Docker 搭建 Jenkins 环境?从安装到精通

不少兄弟搭 jenkins 环境有问题&#xff0c;有的同学用 window, 有的同学用 mac&#xff0c; 有的同学用 linux。 还有的同学公司用 window, 家里用 mac&#xff0c;搭个环境头发掉了一地。。。 这回我们用 docker 去搭建 jenkins 环境&#xff0c;不管你是用的是什么系统&…

opencv在linux上调用usb摄像头进行拍照

功能 1.按照指定的文件名创建文件夹&#xff0c;创建之前判断该文件夹是否存在 2.调用摄像头按可调整窗口大小的方式显示 3.按esc退出摄像头画面 4.按p保存当前摄像头的画面&#xff0c;并按当前时间为照片的名字进行保存打开终端查看是否有摄像头 ls /dev/video*一般video1就…

面试了字节、美团、腾讯等30几家公司后,才知道软件测试面试全是这个套路......

一、Linux系统应用和环境配置&#xff1a; 1、Linux系统的操作命令给我说10个&#xff0c;一般用什么工具远程连接Linux服务器&#xff1f; 2、Linux中的日志存储在哪里&#xff1f;怎么查看日志内容&#xff1f; 3、Linux中top和ps命令的区别&#xff1f; 4、Linux命令运行…

从前序与中序遍历序列构造二叉树

代码如下&#xff0c;开袋即食 class Solution {private Map<Integer,Integer> map;public TreeNode buildTree(int[] preorder, int[] inorder) {map new HashMap<>();for(int i 0;i<preorder.length;i){map.put(inorder[i],i);}return build(preorder,inord…

Qt5 安装 phonon

Qt5 安装 phonon Qt5 安装 phonon问题描述安装组件 Qt5 安装 phonon 开发环境&#xff1a;Qt Creator 4.6.2 Based on Qt 5.9.6 问题描述 在运行 Qt5 项目时&#xff0c;显示错误&#xff1a; error: Unknown module(s) in QT: phonon这是缺少组件的原因&#xff0c;QT: pho…

解析mfc100u.dll文件丢失的修复方法,快速解决mfc100u.dll问题

在计算机使用过程中&#xff0c;我们经常会遇到一些错误提示&#xff0c;其中最常见的就是“缺少某个文件”的错误。最近&#xff0c;我也遇到了一个这样的问题&#xff0c;那就是“mfc100u.dll丢失”。这个问题可能会导致某些应用程序无法正常运行&#xff0c;给我们带来困扰。…

Chatgpt的嵌入

1.前言 最近两年的ai发展迅速&#xff0c;更加智能的ai对话出现可以解决人们在生活中的问题。 2.使用场景 1&#xff09;编码中 在编码中难免会出现突然忘记的关键词或关键字&#xff0c;可以根据自身需求去询问对应的问题。 2&#xff09;未知的领域 领导提出需求需要用…

【广州华锐互动】VR虚拟现实旅游:改变游客旅游方式,提升旅游体验

虚拟现实&#xff08;Virtual Reality&#xff0c;简称VR&#xff09;是一种利用电脑模拟产生一个三维的虚拟世界&#xff0c;提供用户关于视觉、听觉、触觉等感官的模拟体验。随着科技的进步&#xff0c;VR虚拟现实技术已逐渐进入各行各业&#xff0c;尤其在旅游行业中的应用&…

陪诊系统|挂号陪护搭建二开陪诊师入驻就医小程序

我们的陪诊小程序拥有丰富多样的功能&#xff0c;旨在最大程度满足现代人的需求。首先&#xff0c;我们采用了智能排队系统&#xff0c;通过扫描二维码获取排号信息&#xff0c;让您从繁琐的排队过程中解放出来。其次&#xff0c;我们提供了多种支付方式&#xff0c;不仅可以实…

1985-2020年我国30m土地利用覆盖数据介绍

土地覆盖(LC)决定了地球各圈层之间的能量交换、水和碳循环。准确的 LC 信息是环境和气候研究的基本参数。考虑到在过去几十年中&#xff0c;随着经济建设的发展&#xff0c;中国发生了巨大的变化&#xff0c;连续和精细的 LC 监测是迫切需要的。然而&#xff0c;目前&#xff0…

【Python爬虫】selenium4新版本使用指南

Selenium是一个用于Web应用程序测试的工具。Selenium测试直接运行在浏览器中&#xff0c;就像真正的用户在操作一样。支持的浏览器包括IE&#xff08;7, 8, 9, 10, 11&#xff09;&#xff0c;Mozilla Firefox&#xff0c;Safari&#xff0c;Google Chrome&#xff0c;Opera&am…

真实经历分享:性能测试需求分析详解

在实际的工作中系统的性能需求通常是一个笼统的需求&#xff0c;而且有可能给提需求的人并不知道具体的性能需要&#xff0c;所以只能含糊的列出。如果测试人员不搞清楚&#xff0c;就会出现实际要把杀猪刀&#xff0c;需求标明能屠龙&#xff01;&#xff01;&#xff01; 下面…

【烧火柴问题】奇思妙想火柴

&#x1f49d;&#x1f49d;&#x1f49d;欢迎来到我的博客&#xff0c;很高兴能够在这里和您见面&#xff01;希望您在这里可以感受到一份轻松愉快的氛围&#xff0c;不仅可以获得有趣的内容和知识&#xff0c;也可以畅所欲言、分享您的想法和见解。 推荐:kwan 的首页,持续学…

坚持#第420天~阿里云轻量服务器内存受AliYunDunMonito影响占用解决方法

阿里云轻量服务器内存受AliYunDunMonito影响占用解决方法&#xff0c;亲测有效&#xff1a; Mobax好卡啊&#xff0c;那就直接在阿里云后台操作即可&#xff0c;阿里云后台也可以上传文件。 Navicat mysql好卡啊&#xff0c;那就直接在阿里云后台最上面帮助的右边有个数据库&…

前端基础之JavaScript

JavaScript是一种能够在网页上添加交互效果的脚本语言&#xff0c;也被称为客户端语言。它可以在网页中操作HTML元素、改变CSS样式&#xff0c;以及处理用户的交互事件等。 以下是JavaScript的常见基础知识点&#xff1a; 变量和数据类型&#xff1a;JavaScript中的变量可以存…