当前位置: 首页 > news >正文

Redis实战 - 01 Redis 和 SpringSecurity Oauth2 实现认证授权中心

文章目录

  • 1. food-social-contact-parent 父项目
  • 2. commons 公共服务
      • 1. 全局常量类 ApiConstant
      • 2. 全局异常类 ParameterException
      • 3. 实体对象公共属性 BaseModel
      • 4. 公共返回对象 ResultInfo
      • 5. 食客实体类 Diners
      • 6. 公共返回对象工具类 ResultInfoUtil
  • 3. ms-registry 注册中心服务
  • 4. ms-oauth-server 认证授权中心服务
      • 1. 自定义认证数据源 UserDetailsService
      • 2. 配置类 SecurityConfiguration
      • 3. 搭建授权服务器
        • 1. 客户端信息配置类 ClientOAuth2DataConfiguration
        • 2. 授权服务器配置类 AuthorizationServerConfiguration
        • 3. 启动项目测试
      • 4. 重构认证授权中心增强令牌返回结果
        • 1. 自定义/oauth/token控制器 OAuthController
        • 2. 认证用户实体类 SignInIdentity
        • 3. 授权服务器配置类 AuthorizationServerConfiguration
  • 5. ms-dinners 食客服务
      • 1. 客户端信息配置类 OAuth2ClientConfiguration
      • 2. RestTemplateConfiguration
      • 3. 食客服务控制层 DinersController
      • 4. 食客服务业务逻辑层 DinersService
      • 5. 启动项目测试
  • 6. ms-oauth2-server 获取登录用户信息
      • 1. 资源服务配置类 ResourceServerConfig
      • 2. 获取登录用户信息
      • 3. 启动项目测试
      • 4. 退出登录
  • 7. ms-gateway 网关服务
      • 1. 网关白名单配置 IgnoreUrlsConfig
      • 2. RestTemplateConfiguration
      • 3. 网关全局过滤器 AuthGlobalFilter
      • 4. 启动项目测试
  • 8. 认证授权时序图

1. food-social-contact-parent 父项目

创建父项目 food-social-contact-parent,导入公共依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.imooc</groupId>
    <artifactId>food-social-contact-parent</artifactId>
    <packaging>pom</packaging>
    <version>1.0-SNAPSHOT</version>
    <modules>
        <module>ms-registry</module>
        <module>ms-gateway</module>
        <module>ms-diners</module>
        <module>ms-oauth2-server</module>
        <module>commons</module>
    </modules>

    <!-- 可以集中定义依赖资源的版本信息 -->
    <properties>
        <spring-boot-version>2.3.5.RELEASE</spring-boot-version>
        <spring-cloud-version>Hoxton.SR8</spring-cloud-version>
        <lombok-version>1.18.16</lombok-version>
        <commons-lang-version>3.11</commons-lang-version>
        <mybatis-starter-version>2.1.3</mybatis-starter-version>
        <mysql-version>8.0.22</mysql-version>
        <swagger-starter-version>2.1.5-RELEASE</swagger-starter-version>
        <hutool-version>5.4.7</hutool-version>
        <guava-version>20.0</guava-version>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <!-- 集中定义依赖,不引入 -->
    <dependencyManagement>
        <dependencies>
            <!-- spring boot 依赖 -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot-version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <!-- spring cloud 依赖 -->
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud-version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <!-- lombok 依赖 -->
            <dependency>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
                <version>${lombok-version}</version>
            </dependency>
            <!-- common-lang3 依赖 -->
            <dependency>
                <groupId>org.apache.commons</groupId>
                <artifactId>commons-lang3</artifactId>
                <version>${commons-lang-version}</version>
            </dependency>
            <!-- mybatis 依赖 -->
            <dependency>
                <groupId>org.mybatis.spring.boot</groupId>
                <artifactId>mybatis-spring-boot-starter</artifactId>
                <version>${mybatis-starter-version}</version>
            </dependency>
            <!-- swagger 依赖 -->
            <dependency>
                <groupId>com.battcn</groupId>
                <artifactId>swagger-spring-boot-starter</artifactId>
                <version>${swagger-starter-version}</version>
            </dependency>
            <!-- mysql 依赖 -->
            <dependency>
                <groupId>mysql</groupId>
                <artifactId>mysql-connector-java</artifactId>
                <version>${mysql-version}</version>
            </dependency>
            <!-- hutool 依赖 -->
            <dependency>
                <groupId>cn.hutool</groupId>
                <artifactId>hutool-all</artifactId>
                <version>${hutool-version}</version>
            </dependency>
            <!-- guava 依赖 -->
            <dependency>
                <groupId>com.google.guava</groupId>
                <artifactId>guava</artifactId>
                <version>${guava-version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <!-- 集中定义项目所需插件 -->
    <build>
        <pluginManagement>
            <plugins>
                <!-- spring boot maven 项目打包插件 -->
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>

</project>

2. commons 公共服务

在这里插入图片描述

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>food-social-contact-parent</artifactId>
        <groupId>com.imooc</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>commons</artifactId>

    <dependencies>
        <!-- lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <!-- hutool -->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
        </dependency>
        <!-- guava -->
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
        </dependency>
        <!-- swagger -->
        <dependency>
            <groupId>com.battcn</groupId>
            <artifactId>swagger-spring-boot-starter</artifactId>
        </dependency>
        <!-- security -->
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-core</artifactId>
        </dependency>
    </dependencies>

</project>

1. 全局常量类 ApiConstant

/**
 * 全局常量类
 */
public class ApiConstant {
    // 成功
    public static final int SUCCESS_CODE = 1;
    // 成功提示信息
    public static final String SUCCESS_MESSAGE = "Successful.";
    // 错误
    public static final int ERROR_CODE = 0;
    // 未登录
    public static final int NO_LOGIN_CODE = -100;
    // 请登录提示信息
    public static final String NO_LOGIN_MESSAGE = "Please login.";
    // 错误提示信息
    public static final String ERROR_MESSAGE = "Oops! Something was wrong.";
}

2. 全局异常类 ParameterException

/**
 * 全局异常类
 */
@Getter
@Setter
public class ParameterException extends RuntimeException {

    private Integer errorCode;

    public ParameterException() {
        super(ApiConstant.ERROR_MESSAGE);
        this.errorCode = ApiConstant.ERROR_CODE;
    }

    public ParameterException(Integer errorCode) {
        this.errorCode = errorCode;
    }

    public ParameterException(String message) {
        super(message);
        this.errorCode = ApiConstant.ERROR_CODE;
    }

    public ParameterException(Integer errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }

}

3. 实体对象公共属性 BaseModel

/**
 * 实体对象公共属性
 */
@Getter
@Setter
public class BaseModel implements Serializable {

    private Integer id;
    private Date createDate;
    private Date updateDate;
    private int isValid;
}

4. 公共返回对象 ResultInfo

/**
 * 公共返回对象
 */
@Getter
@Setter
@ApiModel(value = "返回说明")
public class ResultInfo<T> implements Serializable {
    
    @ApiModelProperty(value = "成功标识0=失败,1=成功")
    private Integer code;
    @ApiModelProperty(value = "描述信息")
    private String message;
    @ApiModelProperty(value = "访问路径")
    private String path;
    @ApiModelProperty(value = "返回数据对象")
    private T data;
}

5. 食客实体类 Diners

/**
 * 食客实体类
 */
@Getter
@Setter
public class Diners extends BaseModel {

    // 主键
    private Integer id;
    // 用户名
    private String username;
    // 昵称
    private String nickname;
    // 密码
    private String password;
    // 手机号
    private String phone;
    // 邮箱
    private String email;
    // 头像
    private String avatarUrl;
    // 角色
    private String roles;

}

6. 公共返回对象工具类 ResultInfoUtil

/**
 * 公共返回对象工具类
 */
public class ResultInfoUtil {

    /**
     * 请求出错返回
     *
     * @param path 请求路径
     * @param <T>
     * @return
     */
    public static <T> ResultInfo<T> buildError(String path) {
        ResultInfo<T> resultInfo = build(ApiConstant.ERROR_CODE,
                ApiConstant.ERROR_MESSAGE, path, null);
        return resultInfo;
    }

    /**
     * 请求出错返回
     *
     * @param errorCode 错误代码
     * @param message   错误提示信息
     * @param path      请求路径
     * @param <T>
     * @return
     */
    public static <T> ResultInfo<T> buildError(int errorCode, String message, String path) {
        ResultInfo<T> resultInfo = build(errorCode, message, path, null);
        return resultInfo;
    }

    /**
     * 请求成功返回
     *
     * @param path 请求路径
     * @param <T>
     * @return
     */
    public static <T> ResultInfo<T> buildSuccess(String path) {
        ResultInfo<T> resultInfo = build(ApiConstant.SUCCESS_CODE,
                ApiConstant.SUCCESS_MESSAGE, path, null);
        return resultInfo;
    }

    /**
     * 请求成功返回
     *
     * @param path 请求路径
     * @param data 返回数据对象
     * @param <T>
     * @return
     */
    public static <T> ResultInfo<T> buildSuccess(String path, T data) {
        ResultInfo<T> resultInfo = build(ApiConstant.SUCCESS_CODE,
                ApiConstant.SUCCESS_MESSAGE, path, data);
        return resultInfo;
    }

    /**
     * 构建返回对象方法
     *
     * @param code
     * @param message
     * @param path
     * @param data
     * @param <T>
     * @return
     */
    public static <T> ResultInfo<T> build(Integer code, String message, String path, T data) {
        if (code == null) {
            code = ApiConstant.SUCCESS_CODE;
        }
        if (message == null) {
            message = ApiConstant.SUCCESS_MESSAGE;
        }
        ResultInfo resultInfo = new ResultInfo();
        resultInfo.setCode(code);
        resultInfo.setMessage(message);
        resultInfo.setPath(path);
        resultInfo.setData(data);
        return resultInfo;
    }

}

3. ms-registry 注册中心服务

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>food-social-contact-parent</artifactId>
        <groupId>com.imooc</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>ms-registry</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
        </dependency>
    </dependencies>
</project>
server:
  port: 8080

spring:
  application:
    name: ms-registry

# 配置 Eureka Server 注册中心
eureka:
  client:
    register-with-eureka: false
    fetch-registry: false
    service-url:
      defaultZone: http://localhost:8080/eureka/

4. ms-oauth-server 认证授权中心服务

在这里插入图片描述

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>food-social-contact-parent</artifactId>
        <groupId>com.imooc</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>ms-oauth2-server</artifactId>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>

    <dependencies>
        <!-- eureka client -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <!-- spring web -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- spring data redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!-- mybatis -->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
        </dependency>
        <!-- mysql -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <!-- spring cloud security -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-security</artifactId>
        </dependency>
        <!-- spring cloud oauth2 -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>
        <!-- commons 公共项目 -->
        <dependency>
            <groupId>com.imooc</groupId>
            <artifactId>commons</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
        <!--自定义的元数据依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

</project>
server:
  port: 8082 # 端口

spring:
  application:
    name: ms-oauth2-server # 应用名
  # 数据库
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: root
    url: jdbc:mysql://127.0.0.1:3306/orgnization?serverTimezone=Asia/Shanghai&characterEncoding=utf8&useUnicode=true&useSSL=false
  # Redis
  redis:
    port: 6379
    host: 192.168.38.22
    timeout: 3000
    database: 1
  # swagger
  swagger:
    base-package: com.hh.oauth2
    title: 美食社交食客API接口文档

# Oauth2
client:
  oauth2:
    client-id: appId # 客户端标识 ID
    secret: 123456 # 客户端安全码
    # 授权类型
    grant_types:
      - password
      - refresh_token
    # token 有效时间,单位秒
    token-validity-time: 3600
    refresh-token-validity-time: 3600
    # 客户端访问范围
    scopes:
      - api
      - all

# 配置 Eureka Server 注册中心
eureka:
  instance:
    prefer-ip-address: true
    instance-id: ${spring.cloud.client.ip-address}:${server.port}
  client:
    service-url:
      defaultZone: http://localhost:8080/eureka/

# Mybatis
mybatis:
  configuration:
    map-underscore-to-camel-case: true # 开启驼峰映射

# 指标监控健康检查
management:
  endpoints:
    web:
      exposure:
        include: "*" # 暴露的端点

logging:
  pattern:
    console: '%d{HH:mm:ss} [%thread] %-5level %logger{50} - %msg%n'

1. 自定义认证数据源 UserDetailsService

自定义认证数据源从数据库中获取用户信息:

@Service
public class UserService implements UserDetailsService {
    @Resource
    private DinersMapper dinersMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        AssertUtil.isNotEmpty(username, "请输入用户名");
        Diners diners = dinersMapper.selectByAccountInfo(username);
        if (diners == null) {
            throw new UsernameNotFoundException("用户名或密码错误,请重新输入");
        }
        return new User(
                username,
                diners.getPassword(),
                AuthorityUtils.commaSeparatedStringToAuthorityList(diners.getRoles())
        );
    }
}
@Mapper
public interface DinersMapper {

    /**
     * 根据用户名 or 手机号 or 邮箱查询用户信息
     */
    @Select("select id, username, nickname, phone, email, " +
            "password, avatar_url, roles, is_valid from t_diners where " +
            "(username = #{account} or phone = #{account} or email = #{account})")
    Diners selectByAccountInfo(@Param("account") String account);

}

2. 配置类 SecurityConfiguration

@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    // 注入 Redis 连接工厂
    @Resource
    private RedisConnectionFactory redisConnectionFactory;

    /**
     * 初始化 RedisTokenStore 用于将 token 存储至 Redis
     */
    @Bean
    public RedisTokenStore redisTokenStore() {
        RedisTokenStore redisTokenStore = new RedisTokenStore(redisConnectionFactory);
        // 设置key的层级前缀,方便查询
        redisTokenStore.setPrefix("TOKEN:");
        return redisTokenStore;
    }

    /**
     * 初始化密码编码器,用 MD5 加密密码
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new PasswordEncoder() {
            /**
             * 密码加密
             * @param rawPassword 原始密码
             * @return 加密后的密码
             */
            @Override
            public String encode(CharSequence rawPassword) {
                return DigestUtil.md5Hex(rawPassword.toString());
            }

            /**
             * 校验密码
             * @param rawPassword       原始密码
             * @param encodedPassword   加密密码
             */
            @Override
            public boolean matches(CharSequence rawPassword, String encodedPassword) {
                return DigestUtil.md5Hex(rawPassword.toString()).equals(encodedPassword);
            }
        };
    }

    /**
     * 初始化认证管理对象
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * 放行和认证规则
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                // 放行的请求
                .antMatchers("/oauth/**", "/actuator/**").permitAll()
                // 其他请求必须认证才能访问
                .anyRequest().authenticated()
                .and()
                .csrf().disable();
    }

}

3. 搭建授权服务器

1. 客户端信息配置类 ClientOAuth2DataConfiguration

# Oauth2
client:
  oauth2:
    client-id: appId # 客户端标识 ID
    secret: 123456 # 客户端安全码
    # 授权类型
    grant_types:
      - password
      - refresh_token
    # token 有效时间,单位秒
    token-validity-time: 3600
    refresh-token-validity-time: 3600
    # 客户端访问范围
    scopes:
      - api
      - all
/**
 * 客户端配置类
 */
@Component
@ConfigurationProperties(prefix = "client.oauth2")
@Data
public class ClientOAuth2DataConfiguration {

    // 客户端标识 ID
    private String clientId;

    // 客户端安全码
    private String secret;

    // 授权类型
    private String[] grantTypes;

    // token有效期
    private int tokenValidityTime;

    // refresh-token有效期
    private int refreshTokenValidityTime;

    // 客户端访问范围
    private String[] scopes;
}

2. 授权服务器配置类 AuthorizationServerConfiguration

/**
 * 授权服务器配置类
 **/
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {

    // RedisTokenSore
    @Resource
    private RedisTokenStore redisTokenStore;

    // 认证管理对象
    @Resource
    private AuthenticationManager authenticationManager;

    // 密码编码器
    @Resource
    private PasswordEncoder passwordEncoder;

    // 客户端配置类
    @Resource
    private ClientOAuth2DataConfiguration clientOAuth2DataConfiguration;

    // 登录校验
    @Resource
    private UserService userService;

    /**
     * 客户端配置 - 授权模型
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                // 客户端标识 ID
                .withClient(clientOAuth2DataConfiguration.getClientId())
                // 客户端安全码
                .secret(passwordEncoder.encode(clientOAuth2DataConfiguration.getSecret()))
                // 授权类型
                .authorizedGrantTypes(clientOAuth2DataConfiguration.getGrantTypes())
                // token 有效期
                .accessTokenValiditySeconds(clientOAuth2DataConfiguration.getTokenValidityTime())
                // 刷新 token 的有效期
                .refreshTokenValiditySeconds(clientOAuth2DataConfiguration.getRefreshTokenValidityTime())
                // 客户端访问范围
                .scopes(clientOAuth2DataConfiguration.getScopes());
    }

    /**
     * 配置授权以及令牌的访问端点和令牌服务
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        // 认证器
        endpoints.authenticationManager(authenticationManager)
                // 刷新令牌必须配置userDetailsService,用来刷新令牌时的认证
                .userDetailsService(userService)
                // token 存储的方式:Redis
                .tokenStore(redisTokenStore);
    }

    /**
     * 配置令牌端点安全约束
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        // 允许访问 token 的公钥,默认 /oauth/token_key 是受保护的
        security.tokenKeyAccess("permitAll()")
                // 允许检查 token 的状态,默认 /oauth/check_token 是受保护的
                .checkTokenAccess("permitAll()");
    }
}

3. 启动项目测试

查看注册中心是否注册 ms-oauth2-server服务:

在这里插入图片描述

SpringSecurity Oauth 的访问令牌接口:/oauth/token

认证参数:
在这里插入图片描述
请求体参数:
在这里插入图片描述

4. 重构认证授权中心增强令牌返回结果

1. 自定义/oauth/token控制器 OAuthController

oauth2控制器,让/oauth/token请求走自己的控制器接口:

/**
 * Oauth2 控制器
 */
@RestController
@RequestMapping("oauth")
public class OAuthController {

    @Resource
    private TokenEndpoint tokenEndpoint;

    @Resource
    private HttpServletRequest request;

    @PostMapping("token")
    public ResultInfo postAccessToken(Principal principal, @RequestParam Map<String, String> parameters)
            throws HttpRequestMethodNotSupportedException {
        return custom(tokenEndpoint.postAccessToken(principal, parameters).getBody());
    }

    /**
     * 自定义 Token 返回对象
     */
    private ResultInfo custom(OAuth2AccessToken accessToken) {
        DefaultOAuth2AccessToken token = (DefaultOAuth2AccessToken) accessToken;
        Map<String, Object> data = new LinkedHashMap(token.getAdditionalInformation());
        data.put("accessToken", token.getValue());
        data.put("expireIn", token.getExpiresIn());
        data.put("scopes", token.getScope());
        if (token.getRefreshToken() != null) {
            data.put("refreshToken", token.getRefreshToken().getValue());
        }
        return ResultInfoUtil.buildSuccess(request.getServletPath(), data);
    }

}

2. 认证用户实体类 SignInIdentity

package com.hh.commons.model.domain;

@Data
public class SignInIdentity implements UserDetails {
    // 主键
    private Integer id;
    // 用户名
    private String username;
    // 昵称
    private String nickname;
    // 密码
    private String password;
    // 手机号
    private String phone;
    // 邮箱
    private String email;
    // 头像
    private String avatarUrl;
    // 角色
    private String roles;
    // 是否有效 0=无效 1=有效
    private int isValid;
    // 角色集合, 不能为空
    private List<GrantedAuthority> authorities;

    // 获取角色信息
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        if (StrUtil.isNotBlank(this.roles)) {
            // 获取数据库中的角色信息
            Lists.newArrayList();
            this.authorities = Stream.of(this.roles.split(",")).map(role -> {
                return new SimpleGrantedAuthority(role);
            }).collect(Collectors.toList());
        } else {
            // 如果角色为空则设置为 ROLE_USER
            this.authorities = AuthorityUtils
                    .commaSeparatedStringToAuthorityList("ROLE_USER");
        }
        return this.authorities;
    }

    @Override
    public String getPassword() {
        return this.password;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return this.isValid == 0 ? false : true;
    }
}

3. 授权服务器配置类 AuthorizationServerConfiguration

/**
 * 授权服务
 */
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {

    // 省略....

    /**
     * 配置授权以及令牌的访问端点和令牌服务
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        // 认证器
        endpoints.authenticationManager(authenticationManager)
                // 刷新令牌必须配置userDetailsService,用来刷新令牌时的认证
                .userDetailsService(userService)
                // token 存储的方式:Redis
                .tokenStore(redisTokenStore)
                // 令牌增强对象,增强返回的结果
                .tokenEnhancer((accessToken, authentication) -> {
                    // 获取登录用户的信息,然后设置
                    SignInIndentity signInIdentity = (SignInIndentity) authentication.getPrincipal();
                    LinkedHashMap<String, Object> map = new LinkedHashMap<>();
                    map.put("nickname", signInIdentity.getNickname());
                    map.put("avatarUrl", signInIdentity.getAvatarUrl());
                    DefaultOAuth2AccessToken token = (DefaultOAuth2AccessToken) accessToken;
                    token.setAdditionalInformation(map);
                    return token;
                });
    }
}

启动项目测试:

在这里插入图片描述

目前已经在认证授权中心的登录校验已经写完了,网关路由到ms-dinners服务,在ms-dinners服务远程调用授权认证服务完成认证功能。

5. ms-dinners 食客服务

下面就是完成食客服务ms-dinners的登录业务逻辑,网关路由到ms-dinners服务,在ms-dinners服务远程调用授权认证服务完成认证功能。

在这里插入图片描述

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>food-social-contact-parent</artifactId>
        <groupId>com.imooc</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>ms-diners</artifactId>

    <dependencies>
        <!-- eureka client -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <!-- spring web -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- mysql -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <!-- spring data redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!-- mybatis -->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
        </dependency>
        <!-- commons 公共项目 -->
        <dependency>
            <groupId>com.imooc</groupId>
            <artifactId>commons</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
        <!-- 自定义的元数据依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

</project>
server:
  port: 8081 # 端口

spring:
  application:
    name: ms-diners # 应用名
  # 数据库
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: root
    url: jdbc:mysql://127.0.0.1:3306/orgnization?serverTimezone=Asia/Shanghai&characterEncoding=utf8&useUnicode=true&useSSL=false
  # Redis
  redis:
    port: 6379
    host: 192.168.38.22
    timeout: 3000
    database: 1
  # swagger
  swagger:
    base-package: com.hh.oauth2
    title: 美食社交食客API接口文档


# Oauth2 客户端信息
oauth2:
  client:
    client-id: appId
    secret: 123456
    grant_type: password
    scope: api

# oauth2 服务地址
service:
  name:
    ms-oauth-server: http://ms-oauth2-server/

# 配置 Eureka Server 注册中心
eureka:
  instance:
    prefer-ip-address: true
    instance-id: ${spring.cloud.client.ip-address}:${server.port}
  client:
    service-url:
      defaultZone: http://localhost:8080/eureka/

logging:
  pattern:
    console: '%d{HH:mm:ss} [%thread] %-5level %logger{50} - %msg%n'

1. 客户端信息配置类 OAuth2ClientConfiguration

SpringBoot 配置文件:

# Oauth2 客户端信息
oauth2:
  client:
    client-id: appId
    secret: 123456
    grant_type: password
    scope: api
/**
 * 客户端配置类
 */
@Component
@ConfigurationProperties(prefix = "oauth2.client")
@Getter
@Setter
public class OAuth2ClientConfiguration {

    private String clientId;
    private String secret;
    private String grant_type;
    private String scope;

}

2. RestTemplateConfiguration

RestTemplate是执行HTTP请求的同步阻塞式的客户端,RestTemplate类通过为HTTP方法(例如GET,POST,PUT,DELETE等)提供重载的方法,提供了一种非常方便的方法访问基于HTTP的Web服务。

/**
 * Rest 配置类
 */
@Configuration
public class RestTemplateConfiguration {

    @LoadBalanced
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

3. 食客服务控制层 DinersController

/**
 * 食客服务控制层
 */
@RestController
@Api(tags = "食客相关接口")
public class DinersController {

    @Resource
    private DinersService dinersService;

    @Resource
    private HttpServletRequest request;

    /**
     * 登录
     */
    @GetMapping("signin")
    public ResultInfo signIn(String account, String password) {
        return dinersService.signIn(account, password, request.getServletPath());
    }
}

4. 食客服务业务逻辑层 DinersService

/**
 * 食客服务业务逻辑层
 */
@Service
public class DinersService {

    @Resource
    private RestTemplate restTemplate;

    @Value("${service.name.ms-oauth-server}")
    private String oauthServerName;

    @Resource
    private OAuth2ClientConfiguration oAuth2ClientConfiguration;

    /**
     * 登录
     *
     * @param account  帐号:用户名或手机或邮箱
     * @param password 密码
     * @param path     请求路径
     */
    public ResultInfo signIn(String account, String password, String path) {
        // 参数校验
        AssertUtil.isNotEmpty(account, "请输入登录帐号");
        AssertUtil.isNotEmpty(password, "请输入登录密码");

        // 模拟使用postForObject模拟表单数据提交,即:提交x-www-form-urlencoded格式的数据
        // 构建请求头
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        // 构建请求体(请求参数)
        MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
        body.add("username", account);
        body.add("password", password);
        body.setAll(BeanUtil.beanToMap(oAuth2ClientConfiguration));
        HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);

        // 设置 Authorization
        restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(
                        oAuth2ClientConfiguration.getClientId(),
                        oAuth2ClientConfiguration.getSecret()));
        
        // 发送请求:请求路径url,请求体,响应数据类型
        ResponseEntity<ResultInfo> result = restTemplate.postForEntity(oauthServerName + "oauth/token", entity, ResultInfo.class);
        
        // 处理返回结果
        AssertUtil.isTrue(result.getStatusCode() != HttpStatus.OK, "登录失败");
        ResultInfo resultInfo = result.getBody();
        if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) {
            // 登录失败
            resultInfo.setData(resultInfo.getMessage());
            return resultInfo;
        }
        // 这里的 Data 是一个 LinkedHashMap 转成了域对象 OAuthDinerInfo
        OAuthDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),new OAuthDinerInfo(), false);
        
        // 根据业务需求返回视图对象
        LoginDinerInfo loginDinerInfo = new LoginDinerInfo();
        loginDinerInfo.setToken(dinerInfo.getAccessToken());
        loginDinerInfo.setAvatarUrl(dinerInfo.getAvatarUrl());
        loginDinerInfo.setNickname(dinerInfo.getNickname());
        return ResultInfoUtil.buildSuccess(path, loginDinerInfo);
    }
}

访问/oauth/token接口获取token的返回结果:

package com.hh.diners.domain;

@Getter
@Setter
public class OAuthDinerInfo implements Serializable {
    private String nickname;
    private String avatarUrl;
    private String accessToken;
    private String expireIn;
    private List<String> scopes;
    private String refreshToken;
}

返回给前端的响应类:

package com.hh.diners.vo;

@Setter
@Getter
public class LoginDinerInfo implements Serializable {
    private String nickname;
    private String token;
    private String avatarUrl;
}

其实上面的操作就是使用RestTemplate发起HTTP POST请求访问oauth/token接口路径获取token的过程,即使用postForObject模拟表单数据提交x-www-form-urlencoded格式的数据,其中请求数据为:
在这里插入图片描述

在这里插入图片描述

5. 启动项目测试

启动 ms-registry 注册中心服务、ms-oauth-server 认证授权中心服务、ms-dinners 食客服务

在这里插入图片描述

6. ms-oauth2-server 获取登录用户信息

1. 资源服务配置类 ResourceServerConfig

/**
 * 资源服务
 */
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

    @Resource
    private MyAuthenticationEntryPoint authenticationEntryPoint;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        // 配置放行的资源
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .requestMatchers().antMatchers("/user/**");
    }

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.authenticationEntryPoint(authenticationEntryPoint);
    }

}

认证失败后的自定义处理逻辑:

/**
 * 认证失败处理
 */
@Component
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Resource
    private ObjectMapper objectMapper;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        // 返回 JSON
        response.setContentType("application/json;charset=utf-8");
        // 状态码 401
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        // 写出
        PrintWriter out = response.getWriter();
        String errorMessage = authException.getMessage();
        if (StringUtils.isBlank(errorMessage)) {
            errorMessage = "登录失效!";
        }
        ResultInfo result = ResultInfoUtil.buildError(ApiConstant.ERROR_CODE,
                errorMessage, request.getRequestURI());
        out.write(objectMapper.writeValueAsString(result));
        out.flush();
        out.close();
    }

}

2. 获取登录用户信息

/**
 * 用户中心
 */
@RestController
public class UserController {

    @Resource
    private HttpServletRequest request;

    @Resource
    private RedisTokenStore redisTokenStore;

    @GetMapping("user/me")
    public ResultInfo getCurrentUser(Authentication authentication) {
        // 获取登录用户的信息,然后设置
        SignInIndentity signInIdentity = (SignInIndentity) authentication.getPrincipal();
        // 转为前端可用的视图对象
        SignInDinerInfo dinerInfo = new SignInDinerInfo();
        BeanUtils.copyProperties(signInIdentity, dinerInfo);
        return ResultInfoUtil.buildSuccess(request.getServletPath(), dinerInfo);
    }
}
package com.imooc.commons.model.vo;

@Getter
@Setter
@ApiModel(value = "SignInDinerInfo", description = "登录用户信息")
public class SignInDinerInfo implements Serializable {

    @ApiModelProperty("主键")
    private Integer id;
    @ApiModelProperty("用户名")
    private String username;
    @ApiModelProperty("昵称")
    private String nickname;
    @ApiModelProperty("手机号")
    private String phone;
    @ApiModelProperty("邮箱")
    private String email;
    @ApiModelProperty("头像")
    private String avatarUrl;
    @ApiModelProperty("角色")
    private String roles;

}

3. 启动项目测试

① 首先获取access_token :
在这里插入图片描述

② 请求路径上携带 access_token 访问受限资源

在这里插入图片描述

③ Authentication中配置access_token 访问受限资源

在这里插入图片描述

4. 退出登录

/**
 * 用户中心
 */
@RestController
public class UserController {

    @Resource
    private HttpServletRequest request;

    @Resource
    private RedisTokenStore redisTokenStore;

    /**
     * 安全退出
     *
     * @param access_token
     * @param authorization
     * @return
     */
    @GetMapping("user/logout")
    public ResultInfo logout(String access_token, String authorization) {
        // 判断 access_token 是否为空,为空将 authorization 赋值给 access_token
        if (StringUtils.isBlank(access_token)) {
            access_token = authorization;
        }
        // 判断 authorization 是否为空
        if (StringUtils.isBlank(access_token)) {
            return ResultInfoUtil.buildSuccess(request.getServletPath(), "退出成功");
        }
        // 判断 bearer token 是否为空
        if (access_token.toLowerCase().contains("bearer ".toLowerCase())) {
            access_token = access_token.toLowerCase().replace("bearer ", "");
        }
        // 清除 redis token 信息
        OAuth2AccessToken oAuth2AccessToken = redisTokenStore.readAccessToken(access_token);
        if (oAuth2AccessToken != null) {
            redisTokenStore.removeAccessToken(oAuth2AccessToken);
            OAuth2RefreshToken refreshToken = oAuth2AccessToken.getRefreshToken();
            redisTokenStore.removeRefreshToken(refreshToken);
        }
        return ResultInfoUtil.buildSuccess(request.getServletPath(), "退出成功");
    }

}

在这里插入图片描述

7. ms-gateway 网关服务

所有的请求先到网关,在网关中做身份认证

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>food-social-contact-parent</artifactId>
        <groupId>com.imooc</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>ms-gateway</artifactId>

    <dependencies>
        <!-- spring cloud gateway -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>
        <!-- eureka client -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <!-- commons 公共项目 -->
        <dependency>
            <groupId>com.imooc</groupId>
            <artifactId>commons</artifactId>
            <version>1.0-SNAPSHOT</version>
            <!-- 和 webflux 冲突 -->
            <exclusions>
                <exclusion>
                    <groupId>com.battcn</groupId>
                    <artifactId>swagger-spring-boot-starter</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <!-- 自定义的元数据依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

</project>
server:
  port: 80

spring:
  application:
    name: ms-gateway
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true # 开启配置注册中心进行路由功能
          lower-case-service-id: true # 将服务名称转小写
      routes:
        - id: ms-diners
          uri: lb://ms-diners
          predicates:
            - Path=/diners/**
          filters:
            - StripPrefix=1

        - id: ms-oauth2-server
          uri: lb://ms-oauth2-server
          predicates:
            - Path=/auth/**
          filters:
            - StripPrefix=1

secure:
  ignore:
    urls: # 配置白名单路径
      - /actuator/**
      - /auth/oauth/**
      - /diners/signin

# 配置 Eureka Server 注册中心
eureka:
  instance:
    prefer-ip-address: true
    instance-id: ${spring.cloud.client.ip-address}:${server.port}
  client:
    service-url:
      defaultZone: http://localhost:8080/eureka/

logging:
  pattern:
    console: '%d{HH:mm:ss} [%thread] %-5level %logger{50} - %msg%n'

1. 网关白名单配置 IgnoreUrlsConfig

secure:
  ignore:
    urls: # 配置白名单路径
      - /actuator/**
      - /auth/oauth/**
      - /diners/signin
/**
 * 网关白名单配置
 */
@Data
@Component
@ConfigurationProperties(prefix = "secure.ignore")
public class IgnoreUrlsConfig {
    private List<String> urls;
}

2. RestTemplateConfiguration

@Configuration
public class RestTemplateConfiguration {
    
    @LoadBalanced
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

3. 网关全局过滤器 AuthGlobalFilter

/**
 * 网关全局过滤器
 */
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {

    @Resource
    private IgnoreUrlsConfig ignoreUrlsConfig;
    @Resource
    private RestTemplate restTemplate;
    @Resource
    private HandleException handleException;

    /**
     * 身份校验处理
     *
     * @param exchange
     * @param chain
     * @return
     */
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 判断当前的请求是否在白名单中
        AntPathMatcher pathMatcher = new AntPathMatcher();
        boolean flag = false;
        String path = exchange.getRequest().getURI().getPath();
        for (String url : ignoreUrlsConfig.getUrls()) {
            if (pathMatcher.match(url, path)) {
                flag = true;
                break;
            }
        }
        // 白名单放行
        if (flag) {
            return chain.filter(exchange);
        }
        
        // 获取 access_token
        String access_token = exchange.getRequest().getQueryParams().getFirst("access_token");
        // 判断 access_token 是否为空
        if (StringUtils.isBlank(access_token)) {
            return handleException.writeError(exchange, "请登录");
        }
        // 校验 token 是否有效
        String checkTokenUrl = "http://ms-oauth2-server/oauth/check_token?token=".concat(access_token);
        try {
            // 发送远程请求,验证 token
            ResponseEntity<String> entity = restTemplate.getForEntity(checkTokenUrl, String.class);
            // token 无效的业务逻辑处理
            if (entity.getStatusCode() != HttpStatus.OK) {
                return handleException.writeError(exchange,
                        "Token was not recognised, token: ".concat(access_token));
            }
            if (StringUtils.isBlank(entity.getBody())) {
                return handleException.writeError(exchange,
                        "This token is invalid: ".concat(access_token));
            }
        } catch (Exception e) {
            return handleException.writeError(exchange,
                    "Token was not recognised, token: ".concat(access_token));
        }
        // 放行
        return chain.filter(exchange);
    }

    /**
     * 网关过滤器的排序,数字越小优先级越高
     */
    @Override
    public int getOrder() {
        return 0;
    }
}
@Component
public class HandleException {

    @Resource
    private ObjectMapper objectMapper;

    public Mono<Void> writeError(ServerWebExchange exchange, String error) {

        ServerHttpResponse response = exchange.getResponse();
        ServerHttpRequest request = exchange.getRequest();
        response.setStatusCode(HttpStatus.OK);
        response.getHeaders().add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);

        // 用户没有登录,请登录
        ResultInfo resultInfo = ResultInfoUtil.buildError(ApiConstant.NO_LOGIN_CODE, ApiConstant.NO_LOGIN_MESSAGE, request.getURI().getPath());

        String resultInfoJson = null;
        DataBuffer buffer = null;
        try {
            resultInfoJson = objectMapper.writeValueAsString(resultInfo);
            buffer =  response.bufferFactory().wrap(resultInfoJson.getBytes(Charset.forName("UTF-8")));
        } catch (JsonProcessingException ex) {
            ex.printStackTrace();
        }

        return response.writeWith(Mono.just(buffer));
    }
}

4. 启动项目测试

所有的请求都经过网关,网关根据请求路径路由到指定的服务。网关路由到ms-dinners服务完成认证登录获取令牌token。

在这里插入图片描述

网关路由到 ms-oauth2-server 服务获取用户登录信息:

在这里插入图片描述

在这里插入图片描述

8. 认证授权时序图

image-20201115164057228.png

image-20201115164136838.png

相关文章:

  • 数据结构:堆
  • 基于机器学习的搜索推荐系统
  • MATLAB | 分段赋色折线图及其图例绘制
  • C#面向对象程序设计课程实验三:实验名称:C#数组和集合
  • 数据结构--(栈、队列实现及3个OJ题)
  • 实时数据同步工具<Maxwell 操作案例>
  • 【设计模式】-创建型模式-第2章第3讲-【建造者模式】
  • CS231n Module2: CNN part1:Architecture
  • 模电学习1. 三极管基础知识及常用电路
  • 优化APK体积
  • 【初学者入门C语言】之函数(八)
  • 《Linux基本常识的介绍》
  • 【云原生】Kubernetes介绍
  • C语言自定义类型【结构体】
  • springboot请求映射原理,springboot版本2.3.4.RELEASE
  • 【译】JS基础算法脚本:字符串结尾
  • 30天自制操作系统-2
  • Java 多线程编程之:notify 和 wait 用法
  • Java小白进阶笔记(3)-初级面向对象
  • LeetCode刷题——29. Divide Two Integers(Part 1靠自己)
  • nodejs实现webservice问题总结
  • PHP 程序员也能做的 Java 开发 30分钟使用 netty 轻松打造一个高性能 websocket 服务...
  • React组件设计模式(一)
  • Vue2.0 实现互斥
  • Xmanager 远程桌面 CentOS 7
  • 测试如何在敏捷团队中工作?
  • 成为一名优秀的Developer的书单
  • 使用 QuickBI 搭建酷炫可视化分析
  • 通信类
  • 我建了一个叫Hello World的项目
  • 学习HTTP相关知识笔记
  • 一些css基础学习笔记
  • [Shell 脚本] 备份网站文件至OSS服务(纯shell脚本无sdk) ...
  • 东超科技获得千万级Pre-A轮融资,投资方为中科创星 ...
  • ​LeetCode解法汇总2808. 使循环数组所有元素相等的最少秒数
  • #include<初见C语言之指针(5)>
  • #中国IT界的第一本漂流日记 传递IT正能量# 【分享得“IT漂友”勋章】
  • ()、[]、{}、(())、[[]]命令替换
  • (zhuan) 一些RL的文献(及笔记)
  • (附源码)ssm经济信息门户网站 毕业设计 141634
  • (附源码)计算机毕业设计SSM智能化管理的仓库管理
  • (全注解开发)学习Spring-MVC的第三天
  • (十二)python网络爬虫(理论+实战)——实战:使用BeautfulSoup解析baidu热搜新闻数据
  • (一)硬件制作--从零开始自制linux掌上电脑(F1C200S) <嵌入式项目>
  • (原)本想说脏话,奈何已放下
  • (原創) 是否该学PetShop将Model和BLL分开? (.NET) (N-Tier) (PetShop) (OO)
  • (转)3D模板阴影原理
  • (转)真正的中国天气api接口xml,json(求加精) ...
  • **登录+JWT+异常处理+拦截器+ThreadLocal-开发思想与代码实现**
  • . NET自动找可写目录
  • .L0CK3D来袭:如何保护您的数据免受致命攻击
  • .NET Core SkiaSharp 替代 System.Drawing.Common 的一些用法
  • .NET Core 实现 Redis 批量查询指定格式的Key
  • .net 简单实现MD5
  • .Net开发笔记(二十)创建一个需要授权的第三方组件