六、SpringCloudGateway+JWT统一认证鉴权

参考 micro-oauth2原文博客

redis环境准备

下载

解压

把redis-server.exe的路径加到系统的环境变量path里

启动服务

redis-server.exe redis.windows.conf

连接服务

redis-cli.exe -h 127.0.0.1 -p 6379

注意有时候会有中文乱码,要在 redis-cli 后面加上 --raw:

redis-cli --raw

退出quit:

启动脚本

@echo off
F:
cd F:\Redis
redis-server.exe redis.windows.conf 
exit

放在redis安装目录:

为该脚本创建快捷方式,点击即可运行。

添加认证服务模块

Oauth2认证服务,负责对登录用户进行认证,整合Spring Security + Oauth2。

在pom中添加相关依赖

主要是Spring Security、Oauth2、JWT、Redis相关依赖:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <!--nacos客户端-->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    </dependency>
    <!-- https://mvnrepository.com/artifact/org.springframework.cloud/spring-cloud-starter-oauth2 -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-oauth2</artifactId>
        <version>2.2.4.RELEASE</version>
    </dependency>
    <!-- https://mvnrepository.com/artifact/com.nimbusds/nimbus-jose-jwt -->
    <dependency>
        <groupId>com.nimbusds</groupId>
        <artifactId>nimbus-jose-jwt</artifactId>
        <version>9.8.1</version>
    </dependency>
    <!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
        <version>2.4.4</version>
    </dependency>
</dependencies>

在application.yml中添加相关配置

主要是Nacos和Redis相关配置:

server:
  port: 9401
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql:///dgut?serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8&useSSL=true
    username: root
    password: root
  application:
    name: service-oauth2-auth
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
  jackson:
    date-format: yyyy-MM-dd HH:mm:ss
  redis:
    database: 0
    port: 6379
    host: localhost
#    password:

使用keytool生成RSA证书

使用keytool生成RSA证书jwt.jks,复制到resource目录下:

keytool -genkey -alias jwt -keyalg RSA -keystore jwt.jks

创建主类

开启服务发现,将认证服务注册到nacos中心:

package com.oauth2;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

@SpringBootApplication
@EnableDiscoveryClient
public class AuthApplication {
public static void main(String[] args) {
        SpringApplication.run(AuthApplication.class);
    }
}

创建UserDTO

数据传输对象(DTO)(Data Transfer Object),是一种设计模式之间传输数据的软件应用系统。数据传输目标往往是数据访问对象从数据库中检索数据。数据传输对象与数据交互对象或数据访问对象之间的差异是一个以不具有任何行为除了存储和检索的数据(访问和存取器)。

添加pom依赖:

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.20</version>
    <scope>provided</scope>
</dependency>

创建UserDTO类:

package com.oauth2.domain.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;

import java.util.List;


@Data
@EqualsAndHashCode(callSuper = false)
@AllArgsConstructor
public class UserDTO {
private Long id;
    private String username;
    private String password;
    private Integer status;
    private List<String> roles;

}

加载用户信息

创建UserServiceImpl类实现Spring Security的UserDetailsService接口,用于加载用户信息。

添加pom依赖:

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.6.2</version>
</dependency>

Hutool是一个小而全的Java工具类库,通过静态方法封装,降低相关API的学习成本,提高工作效率,使Java拥有函数式语言般的优雅。

注入PasswordEncoder类:

@Bean
public PasswordEncoder passwordEncoder(){
    return new BCryptPasswordEncoder();
}

创建 SecurityUser类实现UserDetails类:

package com.oauth2.domain;

import com.commons.admin.entity.Admin;
import com.commons.enterprise.entity.EnterpriseAdmin;
import com.commons.student.entity.Student;
import com.oauth2.domain.dto.UserDTO;
import lombok.Data;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;

import java.util.ArrayList;
import java.util.Collection;

/**
 * 实现UserDetails类,获取用户详细信息
 */
@Data
public class SecurityUser implements UserDetails {

    /**
     * ID
     */
    private Long id;
    /**
     * 用户名
     */
    private String username;
    /**
     * 用户密码
     */
    private String password;
    /**
     * 用户状态
     */
    private Boolean enabled;
    /**
     * 权限数据
     */
    private Collection<SimpleGrantedAuthority> authorities;

    public SecurityUser() {

    }

    public SecurityUser(UserDTO userDTO) {
        this.setId(userDTO.getId());
        this.setUsername(userDTO.getUsername());
        this.setPassword(userDTO.getPassword());
        this.setEnabled(userDTO.getStatus() == 1);
        if (userDTO.getRoles() != null) {
            authorities = new ArrayList<>();
            userDTO.getRoles().forEach(item -> authorities.add(new SimpleGrantedAuthority(item)));
        }
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.authorities;
    }

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

    @Override
    public String getUsername() {
        return this.username;
    }

    /**
     * isAccountNonExpired():当前账号是否已经过期
     *
     * isAccountNonLocked():当前账号是否被锁
     *
     * isCredentialsNonExpired():当前账号证书(密码)是否过期
     *
     * isEnabled():当前账号是否被禁用
     *
     * 都要给设成true 否则登录会报出来
     */

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

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

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

    @Override
    public boolean isEnabled() {
        return this.enabled;
    }

}

该类通过重写UserDetails接口方法来设定鉴权用户信息。

创建常量类:

package com.oauth2.constant;

public class MessageConstant {
public static final String LOGIN_SUCCESS = "登录成功!";

    public static final String USERNAME_PASSWORD_ERROR = "用户名或密码错误!";

    public static final String CREDENTIALS_EXPIRED = "该账户的登录凭证已过期,请重新登录!";

    public static final String ACCOUNT_DISABLED = "该账户已被禁用,请联系管理员!";

    public static final String ACCOUNT_LOCKED = "该账号已被锁定,请联系管理员!";

    public static final String ACCOUNT_EXPIRED = "该账号已过期,请联系管理员!";

    public static final String PERMISSION_DENIED = "没有访问权限,请联系管理员!";
}

该类定义用户登录各种情况信息,用于封装各种异常信息。

创建UserServiceImpl类实现Spring Security的UserDetailsService接口:

package com.oauth2.service.impl;

import cn.hutool.core.collection.CollUtil;
import com.oauth2.constant.MessageConstant;
import com.oauth2.domain.SecurityUser;
import com.oauth2.domain.dto.UserDTO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AccountExpiredException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class UserServiceImpl implements UserDetailsService {

private List<UserDTO> userList;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @PostConstruct
    // 该注解被用来修饰一个非静态的void()方法。
    // 被@PostConstruct修饰的方法会在服务器加载Servlet的时候运行,并且只会被服务器执行一次。
    // PostConstruct在构造函数之后执行,init()方法之前执行。
    public void initData() {
        String password = passwordEncoder.encode("123456");
        userList = new ArrayList<>();
        userList.add(new UserDTO(1L, "macro", password, 1, CollUtil.toList("ADMIN")));
        userList.add(new UserDTO(2L, "andy", password, 1, CollUtil.toList("TEST")));
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        List<UserDTO> findUserList = userList.stream().filter(item -> item.getUsername().equals(username)).collect(Collectors.toList());
        if (CollUtil.isEmpty(findUserList)) {
            throw new UsernameNotFoundException(MessageConstant.USERNAME_PASSWORD_ERROR);
        }
        SecurityUser securityUser = new SecurityUser(findUserList.get(0));
        if (!securityUser.isEnabled()) {
            throw new DisabledException(MessageConstant.ACCOUNT_DISABLED);
        } else if (!securityUser.isAccountNonLocked()) {
            throw new LockedException(MessageConstant.ACCOUNT_LOCKED);
        } else if (!securityUser.isAccountNonExpired()) {
            throw new AccountExpiredException(MessageConstant.ACCOUNT_EXPIRED);
        } else if (!securityUser.isCredentialsNonExpired()) {
            throw new CredentialsExpiredException(MessageConstant.CREDENTIALS_EXPIRED);
        }
        return securityUser;
    }

}

该初始化生成一个管理员用户和一个测试用户,通过加载方法返回一个鉴权的用户对象,如果用户登录异常,就报各种情况的异常错误。

添加认证服务相关配置Oauth2ServerConfig

需要配置加载用户信息的服务UserServiceImpl及RSA的钥匙对KeyPair。

实现TokenEnhancer接口:

package com.oauth2.component;

import com.oauth2.domain.SecurityUser;
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.Map;

@Component
public class JwtTokenEnhancer implements TokenEnhancer {
    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        SecurityUser securityUser = (SecurityUser) authentication.getPrincipal();
        Map<String, Object> info = new HashMap<>();
        //把用户ID设置到JWT中
        info.put("id", securityUser.getId());
        ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(info);
        return accessToken;
    }
}

该类用于往JWT中添加自定义信息,比如说登录用户的ID。

创建Oauth2ServerConfig:

package com.oauth2.config;

import com.oauth2.component.JwtTokenEnhancer;
import com.oauth2.service.impl.UserServiceImpl;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.KeyStoreKeyFactory;

import java.security.KeyPair;
import java.util.ArrayList;
import java.util.List;

@AllArgsConstructor
@Configuration
@EnableAuthorizationServer
public class Oauth2ServerConfig extends AuthorizationServerConfigurerAdapter {

private final PasswordEncoder passwordEncoder;
    private final UserServiceImpl userDetailsService;
    private final AuthenticationManager authenticationManager;
    private final JwtTokenEnhancer jwtTokenEnhancer;

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("client-app")
                .secret(passwordEncoder.encode("123456"))
                .scopes("all")
                .authorizedGrantTypes("password", "refresh_token")
                .accessTokenValiditySeconds(3600)
                .refreshTokenValiditySeconds(86400);
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
        List<TokenEnhancer> delegates = new ArrayList<>();
        delegates.add(jwtTokenEnhancer);
        delegates.add(accessTokenConverter());
        enhancerChain.setTokenEnhancers(delegates); //配置JWT的内容增强器
        endpoints.authenticationManager(authenticationManager)
                .userDetailsService(userDetailsService) //配置加载用户信息的服务
                .accessTokenConverter(accessTokenConverter())
                .tokenEnhancer(enhancerChain);
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.allowFormAuthenticationForClients();
    }

    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
        jwtAccessTokenConverter.setKeyPair(keyPair());
        return jwtAccessTokenConverter;
    }

    @Bean
    public KeyPair keyPair() {
        // 从classpath下的证书中获取秘钥对
        KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "123456".toCharArray());
        return keyStoreKeyFactory.getKeyPair("jwt", "123456".toCharArray());
    }

}

该类是鉴权服务器配置,用于创建一个jwt的鉴权服务端,加载jwt.jks密钥文件,分发token。

通过KeyPairController把公钥暴露出来

由于我们的网关服务需要RSA的公钥来验证签名是否合法,所以认证服务需要有个接口把公钥暴露出来。

package com.oauth2.controller;

import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.security.KeyPair;
import java.security.interfaces.RSAPublicKey;
import java.util.Map;

@RestController
public class KeyPairController {

    @Autowired
    private KeyPair keyPair;

    @GetMapping("/rsa/publicKey")
    public Map<String, Object> getKey() {
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAKey key = new RSAKey.Builder(publicKey).build();
        return new JWKSet(key).toJSONObject();
    }

}

配置Spring Security,允许获取公钥接口的访问

package com.oauth2.config;

import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll()
                .antMatchers("/rsa/publicKey").permitAll()
                .anyRequest().authenticated();
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

创建一个资源服务ResourceServiceImpl

初始化的时候把资源与角色匹配关系缓存到Redis中,方便网关服务进行鉴权的时候获取。

创建Redis常量类:

package com.oauth2.constant;

public class RedisConstant {
    public static final String RESOURCE_ROLES_MAP = "AUTH:RESOURCE_ROLES_MAP";
}

创建ResourceServiceImpl:

package com.oauth2.service.impl;

import cn.hutool.core.collection.CollUtil;
import com.oauth2.constant.RedisConstant;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

@Service
public class ResourceServiceImpl {

private Map<String, List<String>> resourceRolesMap;

    @Resource
    private RedisTemplate<String,Object> redisTemplate;

    @PostConstruct
    public void initData() {
        resourceRolesMap = new TreeMap<>();
        resourceRolesMap.put("/api/hello", CollUtil.toList("ADMIN"));
        resourceRolesMap.put("/api/user/currentUser", CollUtil.toList("ADMIN", "TEST"));
        redisTemplate.opsForHash().putAll(RedisConstant.RESOURCE_ROLES_MAP, resourceRolesMap);
    }
}

将资源与对应访问权限放到redis缓存。

AuthController自定义Oauth2获取令牌接口

封装API的错误码:

package com.oauth2.api;

public interface IErrorCode {
    long getCode();

    String getMessage();
}

枚举一些常用API操作码:

package com.oauth2.api;

public enum ResultCode implements IErrorCode {
    SUCCESS(200, "操作成功"),
    FAILED(500, "操作失败"),
    VALIDATE_FAILED(404, "参数检验失败"),
    UNAUTHORIZED(401, "暂未登录或token已经过期"),
    FORBIDDEN(403, "没有相关权限");
    private long code;
    private String message;

    private ResultCode(long code, String message) {
        this.code = code;
        this.message = message;
    }

    @Override
    public long getCode() {
        return code;
    }

    @Override
    public String getMessage() {
        return message;
    }
}

CommonResult通用返回对象:

package com.oauth2.api;

public class CommonResult<T> {
private long code;
    private String message;
    private T data;

    protected CommonResult() {
    }

    protected CommonResult(long code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }

    /**
     * 成功返回结果
     *
     * @param data 获取的数据
     */
    public static <T> CommonResult<T> success(T data) {
        return new CommonResult<T>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data);
    }

    /**
     * 成功返回结果
     *
     * @param data 获取的数据
     * @param  message 提示信息
     */
    public static <T> CommonResult<T> success(T data, String message) {
        return new CommonResult<T>(ResultCode.SUCCESS.getCode(), message, data);
    }

    /**
     * 失败返回结果
     * @param errorCode 错误码
     */
    public static <T> CommonResult<T> failed(IErrorCode errorCode) {
        return new CommonResult<T>(errorCode.getCode(), errorCode.getMessage(), null);
    }

    /**
     * 失败返回结果
     * @param errorCode 错误码
     * @param message 错误信息
     */
    public static <T> CommonResult<T> failed(IErrorCode errorCode,String message) {
        return new CommonResult<T>(errorCode.getCode(), message, null);
    }

    /**
     * 失败返回结果
     * @param message 提示信息
     */
    public static <T> CommonResult<T> failed(String message) {
        return new CommonResult<T>(ResultCode.FAILED.getCode(), message, null);
    }

    /**
     * 失败返回结果
     */
    public static <T> CommonResult<T> failed() {
        return failed(ResultCode.FAILED);
    }

    /**
     * 参数验证失败返回结果
     */
    public static <T> CommonResult<T> validateFailed() {
        return failed(ResultCode.VALIDATE_FAILED);
    }

    /**
     * 参数验证失败返回结果
     * @param message 提示信息
     */
    public static <T> CommonResult<T> validateFailed(String message) {
        return new CommonResult<T>(ResultCode.VALIDATE_FAILED.getCode(), message, null);
    }

    /**
     * 未登录返回结果
     */
    public static <T> CommonResult<T> unauthorized(T data) {
        return new CommonResult<T>(ResultCode.UNAUTHORIZED.getCode(), ResultCode.UNAUTHORIZED.getMessage(), data);
    }

    /**
     * 未授权返回结果
     */
    public static <T> CommonResult<T> forbidden(T data) {
        return new CommonResult<T>(ResultCode.FORBIDDEN.getCode(), ResultCode.FORBIDDEN.getMessage(), data);
    }

    public long getCode() {
        return code;
    }

    public void setCode(long code) {
        this.code = code;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }
}

Oauth2获取Token返回信息封装:

package com.oauth2.domain.dto;

import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;

@Data
@EqualsAndHashCode(callSuper = false)
@Builder
public class Oauth2TokenDto {
    /**
     * 访问令牌
     */
    private String token;
    /**
     * 刷新令牌
     */
    private String refreshToken;
    /**
     * 访问令牌头前缀
     */
    private String tokenHead;
    /**
     * 有效时间(秒)
     */
    private int expiresIn;
}

自定义Oauth2获取令牌接口:

package com.oauth2.controller;


import com.oauth2.api.CommonResult;
import com.oauth2.domain.dto.Oauth2TokenDto;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.endpoint.TokenEndpoint;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.security.Principal;
import java.util.Map;

@RestController
@RequestMapping("/oauth")
public class AuthController {

@Autowired
    private TokenEndpoint tokenEndpoint;

    /**
     * Oauth2登录认证
     */
    @RequestMapping(value = "/token", method = RequestMethod.POST)
    public CommonResult<Oauth2TokenDto> postAccessToken(Principal principal, @RequestParam Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
        OAuth2AccessToken oAuth2AccessToken = tokenEndpoint.postAccessToken(principal, parameters).getBody();
        Oauth2TokenDto oauth2TokenDto = Oauth2TokenDto.builder()
                .token(oAuth2AccessToken.getValue())
                .refreshToken(oAuth2AccessToken.getRefreshToken().getValue())
                .expiresIn(oAuth2AccessToken.getExpiresIn())
                .tokenHead("Bearer ").build();

        return CommonResult.success(oauth2TokenDto);
    }
}

多角色认证

增加mapper配置:

package com.oauth2.config;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@MapperScan({"com.commons.student.mapper","com.commons.enterprise.mapper","com.commons.school.mapper"})
public class MapperConfig {

}

修改SecurityUser类,重载三个角色的构造方法:

package com.oauth2.domain;

import com.commons.enterprise.entity.EnterpriseAdmin;
import com.commons.school.entity.SchoolAdmin;
import com.commons.student.entity.Student;
import com.oauth2.domain.dto.UserDTO;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;

/**
 * 实现UserDetails类,获取用户详细信息
 */
@Data
public class SecurityUser implements UserDetails {

    /**
     * ID
     */
    private Long id;
    /**
     * 用户名
     */
    private String username;
    /**
     * 用户密码
     */
    private String password;
    /**
     * 用户状态
     */
    private Boolean enabled;
    /**
     * 权限数据
     */
    private Collection<SimpleGrantedAuthority> authorities;

    public SecurityUser() {

    }

    // 原一般角色
//    public SecurityUser(UserDTO userDTO) {
//        this.setId(userDTO.getId());
//        this.setUsername(userDTO.getUsername());
//        this.setPassword(userDTO.getPassword());
//        this.setEnabled(userDTO.getStatus() == 1);
//        if (userDTO.getRoles() != null) {
//            authorities = new ArrayList<>();
//            userDTO.getRoles().forEach(item -> authorities.add(new SimpleGrantedAuthority(item)));
//        }
//    }


    // 管理员
    public SecurityUser(SchoolAdmin oauthUser) {
        this.setId(oauthUser.getId());
        this.setUsername(oauthUser.getUsername());
        this.setPassword(oauthUser.getPassword());
        this.setEnabled(oauthUser.getStatus());
        if (oauthUser.getRoles() != null) {
            authorities = new ArrayList<>();
            // 假设用户多角色,roles用逗号分割
            for(String item : oauthUser.getRoles().split(",")){
                authorities.add(new SimpleGrantedAuthority(item));
            }
        }
    }

    // 学生
    public SecurityUser(Student oauthUser) {
        this.setId(oauthUser.getId());
        this.setUsername(oauthUser.getUsername());
        this.setPassword(oauthUser.getPassword());
        this.setEnabled(oauthUser.getStatus());
        if (oauthUser.getRoles() != null) {
            authorities = new ArrayList<>();
            for(String item : oauthUser.getRoles().split(",")){
                authorities.add(new SimpleGrantedAuthority(item));
            }
        }
    }

    // 企业
    public SecurityUser(EnterpriseAdmin oauthUser) {
        this.setId(oauthUser.getId());
        this.setUsername(oauthUser.getUsername());
        this.setPassword(oauthUser.getPassword());
        this.setEnabled(oauthUser.getStatus());
        if (oauthUser.getRoles() != null) {
            authorities = new ArrayList<>();
            for(String item : oauthUser.getRoles().split(",")){
                authorities.add(new SimpleGrantedAuthority(item));
            }
        }
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.authorities;
    }

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

    @Override
    public String getUsername() {
        return this.username;
    }

    /**
     * isAccountNonExpired():当前账号是否已经过期
     *
     * isAccountNonLocked():当前账号是否被锁
     *
     * isCredentialsNonExpired():当前账号证书(密码)是否过期
     *
     * isEnabled():当前账号是否被禁用
     *
     * 都要给设成true 否则登录会报出来
     */

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

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

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

    @Override
    public boolean isEnabled() {
        return this.enabled;
    }

}

修改UserServiceImpl中的loadUserByUsername方法,使其支持三个角色的验证:

package com.oauth2.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.commons.enterprise.entity.EnterpriseAdmin;
import com.commons.enterprise.mapper.EnterpriseAdminMapper;
import com.commons.school.entity.SchoolAdmin;
import com.commons.school.mapper.SchoolAdminMapper;
import com.commons.student.entity.Student;
import com.commons.student.mapper.StudentMapper;
import com.oauth2.constant.MessageConstant;
import com.oauth2.domain.SecurityUser;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AccountExpiredException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;

@Service
@Slf4j
public class UserServiceImpl implements UserDetailsService {

    //    private List<UserDTO> userList;

    @Autowired
    private StudentMapper studentMapper;

    @Autowired
    private SchoolAdminMapper schoolAdminMapper;

    @Autowired
    private EnterpriseAdminMapper enterpriseAdminMapper;
    
//
//    @Autowired
//    private PasswordEncoder passwordEncoder;
    
    /**
     * 模拟数据库,生成两个系统用户,一个是ADMIN权限,一个是TEST权限
     */
//    @PostConstruct
    //    该注解被用来修饰一个非静态的void()方法。
//    被@PostConstruct修饰的方法会在服务器加载Servlet的时候运行,并且只会被服务器执行一次。
//    PostConstruct在构造函数之后执行,init()方法之前执行。
//    public void initData() {
//        String password = passwordEncoder.encode("123456");
//        userList = new ArrayList<>();
//        userList.add(new UserDTO(1L, "macro", password, 1, CollUtil.toList("ADMIN")));
//        userList.add(new UserDTO(2L, "andy", password, 1, CollUtil.toList("TEST")));
//    }


    // 检查用户角色
    private String checkRoles(String username){

        // 判断学生
        QueryWrapper<Student> studentQueryWrapper = new QueryWrapper<>();
        studentQueryWrapper.eq("username",username);
        //执行查询
        Student student = studentMapper.selectOne(studentQueryWrapper);
        //判断结果是否空
        if (student != null) {
            return "STUDENT";
        }

        // 判断学校管理员
        QueryWrapper<EnterpriseAdmin> enterpriseAdminQueryWrapper = new QueryWrapper<>();
        enterpriseAdminQueryWrapper.eq("username",username);
        //执行查询
        EnterpriseAdmin enterpriseAdmin = enterpriseAdminMapper.selectOne(enterpriseAdminQueryWrapper);
        //判断结果是否空
        if (enterpriseAdmin != null) {
            return "ENTERPRISE";
        }

        // 判断学校管理员
        QueryWrapper<SchoolAdmin> adminQueryWrapper = new QueryWrapper<>();
        adminQueryWrapper.eq("username",username);
        //执行查询
        SchoolAdmin admin = schoolAdminMapper.selectOne(adminQueryWrapper);
        //判断结果是否空
        if (admin != null) {
            return "ADMIN";
        }

        return "NOT_FOUND";
    }

    /**
     * 加载认证用户信息,SecurityUser implements UserDetails,安全用户类实现了用户详情类,用于返回用户信息,如名字、权限、账户是否被锁等
     * @param username
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        String role = checkRoles(username);
        SecurityUser securityUser;

        switch (role) {
            case "STUDENT":
                Student queryStudent = new Student();
                queryStudent.setUsername(username);
                QueryWrapper<Student> studentQueryWrapper = new QueryWrapper<>(queryStudent);
                //执行查询
                Student student = studentMapper.selectOne(studentQueryWrapper);
                //判断结果是否空
                if (student == null) {
                    throw new UsernameNotFoundException(MessageConstant.USERNAME_PASSWORD_ERROR);
                }
                // 最多允许一个合法用户
                securityUser = new SecurityUser(student);
                // 更新登录时间
                queryStudent.setId(student.getId());
                queryStudent.setLoginTime(LocalDateTime.now());
                studentMapper.updateById(queryStudent);
                break;
            case "ENTERPRISE":
                log.info("企业账号");
                // 从数据库中查询
                // 构造查询实体
                EnterpriseAdmin queryEnterpriseAdmin = new EnterpriseAdmin();
                queryEnterpriseAdmin.setUsername(username);
                QueryWrapper<EnterpriseAdmin> enterpriseAdminQueryWrapper = new QueryWrapper<>(queryEnterpriseAdmin);
                //执行查询
                EnterpriseAdmin enterpriseAdmin = enterpriseAdminMapper.selectOne(enterpriseAdminQueryWrapper);
                //判断结果是否空
                if (enterpriseAdmin == null) {
                    throw new UsernameNotFoundException(MessageConstant.USERNAME_PASSWORD_ERROR);
                }
                // 最多允许一个合法用户
                securityUser = new SecurityUser(enterpriseAdmin);
                // 更新登录时间
                queryEnterpriseAdmin.setId(enterpriseAdmin.getId());
                queryEnterpriseAdmin.setLoginTime(LocalDateTime.now());
                enterpriseAdminMapper.updateById(queryEnterpriseAdmin);
                break;
            case "ADMIN":
                // 从数据库中查询
                // 构造查询实体
                SchoolAdmin queryAdmin = new SchoolAdmin();
                queryAdmin.setUsername(username);
                QueryWrapper<SchoolAdmin> adminQueryWrapper = new QueryWrapper<>(queryAdmin);
                //执行查询
                SchoolAdmin admin = schoolAdminMapper.selectOne(adminQueryWrapper);
                //判断结果是否空
                if (admin == null) {
                    throw new UsernameNotFoundException(MessageConstant.USERNAME_PASSWORD_ERROR);
                }
                // 最多允许一个合法用户
                securityUser = new SecurityUser(admin);
                // 更新登录时间
                queryAdmin.setId(admin.getId());
                queryAdmin.setLoginTime(LocalDateTime.now());
                schoolAdminMapper.updateById(queryAdmin);
                break;
            default:
                // not found
                securityUser = new SecurityUser();
                break;
        }

        // 检查用户权限之类的信息
        if (!securityUser.isEnabled()) {
            throw new DisabledException(MessageConstant.ACCOUNT_DISABLED);
        } else if (!securityUser.isAccountNonLocked()) {
            throw new LockedException(MessageConstant.ACCOUNT_LOCKED);
        } else if (!securityUser.isAccountNonExpired()) {
            throw new AccountExpiredException(MessageConstant.ACCOUNT_EXPIRED);
        } else if (!securityUser.isCredentialsNonExpired()) {
            throw new CredentialsExpiredException(MessageConstant.CREDENTIALS_EXPIRED);
        }

        // 返回认证后的用户
        return securityUser;
    }

}

修改ResourceServiceImpl,增加资源与对应的访问权限:

package com.oauth2.service.impl;

import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.convert.Convert;
import com.oauth2.constant.RedisConstant;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;

@Service
@Slf4j
public class ResourceServiceImpl {

    private Map<String, List<String>> resourceRolesMap;

    @Resource
    private RedisTemplate<String,Object> redisTemplate;

    /**
     * 将访问资源对应的访问权限放到redis缓存中,在网关鉴权管理器中,从redis缓存读取对应的权限对用户访问放行
     */
    @PostConstruct
    public void initData() {
        if(redisTemplate == null){
            log.info("class ResourceServiceImpl @Resource redisTemplate is null ...");
        }else{
            log.info("class ResourceServiceImpl @Resource redisTemplate is not null ...");
        }
        resourceRolesMap = new TreeMap<>();

        // 公开的接口已经放在了网关白名单,例如 注册接口

        // 一、管理员接口与权限,仅管理员访问
        resourceRolesMap.put("/admin-serv", CollUtil.toList( "ADMIN"));

        // 二、企业接口与权限
        // 1.学校管理员 仅能 访问某些资源来获取企业申报材料
        // 2.企业管理员 仅能 访问某些接口来处理企业业务,不能越界处理管理员的业务
        // 3.学生/游客 仅能 访问企业的信息资源,以获取企业公开的信息 【该项不需要设定资源权限】

        // 校方管理员 获取 企业申报信息
        resourceRolesMap.put("/enterprise-serv/enterprise/getEnterpriseApplicationList", CollUtil.toList( "ADMIN"));
        // 校方管理员 审核 企业申报信息
        resourceRolesMap.put("/enterprise-serv/enterprise/passApplication", CollUtil.toList( "ADMIN"));

        // 企业管理员 邀请 另一个管理员账号
        resourceRolesMap.put("/enterprise-serv/enterpriseAdmin/invite", CollUtil.toList( "ENTERPRISE"));
        // 企业管理员 更新 账号信息
        resourceRolesMap.put("/enterprise-serv/enterpriseAdmin/updateInfo", CollUtil.toList( "ENTERPRISE"));
        // 企业管理员 获取 自身账号信息
        resourceRolesMap.put("/enterprise-serv/enterpriseAdmin/getInfo", CollUtil.toList( "ENTERPRISE"));
        // 企业管理员 申报 企业实体信息
        resourceRolesMap.put("/enterprise-serv/enterprise/apply", CollUtil.toList( "ENTERPRISE"));
        // 企业管理员 更新 企业实体信息
        resourceRolesMap.put("/enterprise-serv/enterprise/updateInfo", CollUtil.toList( "ENTERPRISE"));
        // 企业管理员 获取 企业实体信息
        resourceRolesMap.put("/enterprise-serv/enterprise/getInfo", CollUtil.toList( "ENTERPRISE"));
        // 企业管理员 更新 企业HR信息
        resourceRolesMap.put("/enterprise-serv/hr/updateHrList", CollUtil.toList( "ENTERPRISE"));
        // 企业管理员/学生 获取 企业HR信息
        resourceRolesMap.put("/enterprise-serv/hr/getEnterpriseHr", CollUtil.toList( "ENTERPRISE","STUDENT"));

        // 三、岗位接口与权限
        // 企业管理员调用岗位接口处理岗位相关的业务
        // 学生调用岗位信息接口查看与相关的岗位信息
        // 游客 仅能 访问简单公开的信息接口 【该项不需要设定资源权限】
        // 学生 才能 访问详细的信息接口

        // 企业管理员 发布 岗位
        resourceRolesMap.put("/jobs-serv/jobs/publishPosition", CollUtil.toList( "ENTERPRISE"));
        // 企业管理员 撤回 岗位
        resourceRolesMap.put("/jobs-serv/jobs/recallPosition", CollUtil.toList( "ENTERPRISE"));
        // 企业管理员 查看 已发布的岗位
        resourceRolesMap.put("/jobs-serv/jobs/getAll", CollUtil.toList( "ENTERPRISE"));
        // 企业管理员 更新 某个岗位信息
        resourceRolesMap.put("/jobs-serv/jobs/updatePosition", CollUtil.toList( "ENTERPRISE"));

        // 学生 标记 心仪岗位
        resourceRolesMap.put("/jobs-serv/jobs/favorPosition", CollUtil.toList( "STUDENT"));
        // 学生 取消标记 心仪岗位
        resourceRolesMap.put("/jobs-serv/jobs/cancelPosition", CollUtil.toList( "STUDENT"));
        // 学生 查看 某个企业的某个岗位信息
        resourceRolesMap.put("/jobs-serv/jobs/searchById", CollUtil.toList( "STUDENT"));
        // 学生 搜索 某个岗位信息
        resourceRolesMap.put("/jobs-serv/jobs/searchByName", CollUtil.toList( "STUDENT"));

        // 四、ws接口与权限,仅 学生/企业 能访问,实现答疑聊天功能
        resourceRolesMap.put("/websocket-serv", CollUtil.toList( "ENTERPRISE","STUDENT"));

        // 五、学生接口与权限,仅 学生 能访问
        resourceRolesMap.put("/student-serv", CollUtil.toList( "STUDENT"));
        
        // 写入redis缓存
        redisTemplate.opsForHash().putAll(RedisConstant.RESOURCE_ROLES_MAP, resourceRolesMap);
        
        // 校验
        log.info("set /student-serv => STUDENT");
        
        Object obj = redisTemplate.opsForHash().get(RedisConstant.RESOURCE_ROLES_MAP,"/student-serv");
        if (obj == null){
            log.info("redis出错,get(/student-serv)查询为空");
        }else {
            List<String> authorities = Convert.toList(String.class, obj);
            log.info("get(/student-serv)查询结果 => {}",authorities.toString());
        }
//        redisTemplate.opsForValue().set("/stu-serv",CollUtil.toList( "STUDENT",300, TimeUnit.SECONDS));
    }
}

关于如何检验用户账号属于哪个角色

url传参,在账号后自动添加角色字段,如账号@ROLE,查询前,先做字符串分割得知role,就能决定调用哪个数据库去查询,只需要查一个表。

token字段中可以增加一级缓存,字段permission对应用户role,查询前,由permission得知role,就能决定调用哪个数据库去查询,只需要查一个表。

使用redis缓存,将用户账号和角色作为键值对缓存到redis中,每次查询前先从redis中哈希查询得到role,就能决定调用哪个数据库去查询,只需要查一个表。

从用户群体考虑,账号依次查询 学生、用人单位、学校 三个角色表。

关于资源与角色访问权限

是为了接口安全,不让角色越界访问非权限内的接口。其实也可以做一级、二级、三级缓存来做前置操作以区别不同用户角色对接口的访问权限,保证接口安全。

关于引入QQ/微信第三方认证

主要还是经费不足,才采用的传统邮箱认证方式,后续版本有经费的话会加入第三方认证。

搭建网关服务

配置网关服务,它将作为Oauth2的资源服务、客户端服务使用,对访问微服务的请求进行统一的校验认证和鉴权操作。

添加pom依赖

<dependencies>
        <!--        容错组件sentinel-->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
        </dependency>
        <!--        配置解析-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        <!-- https://mvnrepository.com/artifact/com.alibaba/fastjson -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.75</version>
        </dependency>

        <!--邮箱验证码服务-->
        <!-- https://mvnrepository.com/artifact/org.apache.commons/commons-email -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-email</artifactId>
            <version>1.5</version>
        </dependency>

        <!--nacos客户端-->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
        <!--fegin组件-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>
        <!-- Feign Client for loadBalancing -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-loadbalancer</artifactId>
        </dependency>
        <!--gateway网关-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>
        <!--      gateway +  jwt-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-config</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-resource-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-jose</artifactId>
        </dependency>
        <!-- https://mvnrepository.com/artifact/com.nimbusds/nimbus-jose-jwt -->
        <dependency>
            <groupId>com.nimbusds</groupId>
            <artifactId>nimbus-jose-jwt</artifactId>
            <version>8.19</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>io.lettuce</groupId>
                    <artifactId>lettuce-core</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
        </dependency>
        <!-- 自定义的元数据依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.20</version>
            <scope>provided</scope>
        </dependency>
        <!-- https://mvnrepository.com/artifact/cn.hutool/hutool-all -->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.6.2</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

在application.yml中添加相关配置

主要是路由规则的配置、Oauth2中RSA公钥的配置及路由白名单的配置:

server:
  port: 7000
spring:
  redis:
    host: localhost
    port: 6379
    database: 0
  #    password:
  application:
    name: service-gateway
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
    gateway:
      discovery:
        locator:
          enabled: true
          lower-case-service-id: true #使用小写服务名,默认是大写
      routes: #配置路由路径
        - id: oauth2-auth-route
          uri: lb://service-oauth2-auth
          predicates:
            - Path=/auth/**
          filters:
            - StripPrefix=1
        - id: student-route
          uri: lb://service-student
          predicates:
            - Path=/student-serv/**
          filters:
            - StripPrefix=1
        - id: enterprise-route
          uri: lb://service-enterprise
          predicates:
            - Path=/enterprise-serv/**
          filters:
            - StripPrefix=1
        - id: jobs-route
          uri: lb://service-job-position
          predicates:
            - Path=/jobs-serv/**
          filters:
            - StripPrefix=1
        - id: websocket-route
          uri: lb:ws://server-websock
          predicates:
            - Path=/websocket-serv/**
          filters:
            - StripPrefix=1
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: 'http://localhost:9401/rsa/publicKey' #配置RSA的公钥访问地址
secure:
  ignore:
    urls: #配置白名单路径
      - "/auth/oauth/token"  #鉴权中心
      - "/send"  #发送验证码
      - "/check/code" #校验send
      - "/destroy/code" #销毁code
      - "/v2/api-docs" #接口文档中心
      - "/student-serv/student/register" #学生注册
      - "/enterprise-serv/enterpriseAdmin/register" #企业注册
      - "/enterprise-serv/enterprise/getEnterpriseInfoList" # 公开企业信息接口
      - "/jobs-serv/jobs/getJobsInfoList" # 公开岗位信息接口

yushanma:
  email:
    from: "ABCDEFG@163.com"
    host-name: "smtp.163.com"
    token: "1234567890"

对网关服务进行配置安全配置

创建白名单配置IgnoreUrlsConfig:

package com.gateway.config;

import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.util.List;

@Data
@EqualsAndHashCode(callSuper = false)
@Component
@ConfigurationProperties(prefix="secure.ignore")
public class IgnoreUrlsConfig {
    private List<String> urls;
}

创建白名单过滤链IgnoreUrlsRemoveJwtFilter:

package com.gateway.filter;

import com.gateway.config.IgnoreUrlsConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.PathMatcher;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;

import java.net.URI;
import java.util.List;

@Component
public class IgnoreUrlsRemoveJwtFilter implements WebFilter {
    @Autowired
    private IgnoreUrlsConfig ignoreUrlsConfig;
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        URI uri = request.getURI();
        PathMatcher pathMatcher = new AntPathMatcher();
        //白名单路径移除JWT请求头
        List<String> ignoreUrls = ignoreUrlsConfig.getUrls();
        for (String ignoreUrl : ignoreUrls) {
            if (pathMatcher.match(ignoreUrl, uri.getPath())) {
                request = exchange.getRequest().mutate().header("Authorization", "").build();
                exchange = exchange.mutate().request(request).build();
                return chain.filter(exchange);
            }
        }
        return chain.filter(exchange);
    }
}

创建常量类:

package com.gateway.constant;

public class AuthConstant {

    public static final String AUTHORITY_PREFIX = "ROLE_";

    public static final String AUTHORITY_CLAIM_NAME = "authorities";

}

package com.gateway.constant;

public class RedisConstant {
    public static final String RESOURCE_ROLES_MAP = "AUTH:RESOURCE_ROLES_MAP";
}

创建鉴权管理器,用于判断是否有资源的访问权限:

package com.gateway.authorization;

import cn.hutool.core.convert.Convert;
import com.gateway.constant.AuthConstant;
import com.gateway.constant.RedisConstant;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.server.authorization.AuthorizationContext;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

import javax.annotation.Resource;
import java.net.URI;
import java.util.List;
import java.util.stream.Collectors;

/**
 * 认证管理器,从Redis中获取 访问资源与访问权限 键值对
 */
@Component
@Slf4j
public class AuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {

    /**
     * 当你的redis数据库里面本来存的是字符串数据或者你要存取的数据就是字符串类型数据的时候,那么你就使用StringRedisTemplate即可,
     * 但是如果你的数据是复杂的对象类型,而取出的时候又不想做任何的数据转换,直接从Redis里面取出一个对象,那么使用RedisTemplate是
     * 更好的选择。
     * 当Redis当中的数据值是以可读的形式显示出来的时候,只能使用StringRedisTemplate才能获取到里面的数据。
     * 所以当你使用RedisTemplate获取不到数据的时候请检查一下是不是Redis里面的数据是可读形式而非字节数组.
     */

    @Resource
    private RedisTemplate<String, Object> redisTemplate;

//    @Resource
//    private StringRedisTemplate redisTemplate;

    @Override
    public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
        //从Redis中获取当前路径可访问角色列表
        URI uri = authorizationContext.getExchange().getRequest().getURI();
        log.info("get current uri.path => {}", uri.getPath());

        if (redisTemplate == null) {
            log.info("@Resource redisTemplate is null ...");
        } else {
            log.info("@Resource redisTemplate is not null ...");
        }
        Object obj = null;
        StringBuilder uriStr = new StringBuilder();
        for (String str : uri.getPath().split("/")) {
            if (str.length() > 0) {
                log.info("str => {}", str);
                obj = redisTemplate.opsForHash().get(RedisConstant.RESOURCE_ROLES_MAP, uriStr.append("/").append(str).toString());
//                obj = redisTemplate.opsForValue().get(uriStr.append("/").append(str).toString());
                if (obj != null) {
                    break;
                }
                log.info("get({})查询为空", uriStr);
            }
        }
//        Object obj = redisTemplate.opsForHash().get(RedisConstant.RESOURCE_ROLES_MAP, uri.getPath());

        List<String> authorities = Convert.toList(String.class, obj);
        authorities = authorities
                .stream()
                .map(i -> i = AuthConstant.AUTHORITY_PREFIX + i)
                .collect(Collectors.toList());

        //认证通过且角色匹配的用户可访问当前路径
        return mono
                .filter(Authentication::isAuthenticated)
                .flatMapIterable(Authentication::getAuthorities)
                .map(GrantedAuthority::getAuthority)
                .any(authorities::contains)
                .map(AuthorizationDecision::new)
                .defaultIfEmpty(new AuthorizationDecision(false));
    }
}

全局过滤器AuthGlobalFilter:

package com.gateway.filter;

import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONObject;
import com.nimbusds.jose.JWSObject;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.text.ParseException;
//import java.util.logging.Logger;

@Slf4j
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {

//    private static Logger LOGGER = (Logger) LoggerFactory.getLogger(AuthGlobalFilter.class);

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String token = exchange.getRequest().getHeaders().getFirst("Authorization");
        if (StrUtil.isEmpty(token)) {
            return chain.filter(exchange);
        }
        try {
            //从token中解析用户信息并设置到Header中去
            String realToken = token.replace("Bearer ", "");
            JWSObject jwsObject = JWSObject.parse(realToken);
            String userStr = jwsObject.getPayload().toString();
            log.info("AuthGlobalFilter.filter() user:{}",userStr);
//            LOGGER.info(String.format("AuthGlobalFilter.filter() user:{0}",userStr));
            // 将json格式的用户信息转成json对象
            JSONObject user= JSONObject.parseObject(userStr);
            // 将用户名加到header中,控制器通过HttpServletRequest的getHeader方法获取username
            ServerHttpRequest request = exchange.getRequest().mutate().header("username", user.getString("user_name")).build();
            exchange = exchange.mutate().request(request).build();
        } catch (ParseException e) {
            e.printStackTrace();
        }
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return 0;
    }
}

jwt鉴权测试

在此之前先启动我们的Nacos和Redis服务,然后依次启动test、api-gateway及oauth2-auth服务:

使用密码模式获取JWT令牌

方法
POST

接口
http://localhost:7000/auth/oauth/token

参数
username=admin_yushanma@163.com&password=yushanma&grant_type=password&client_id=client-app&client_secret=123456

{
"code": 200,
"message": "操作成功",
"data": {
"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbl95dXNoYW5tYUAxNjMuY29tIiwic2NvcGUiOlsiYWxsIl0sImlkIjoxLCJleHAiOjE2MjA1NzY3OTQsImF1dGhvcml0aWVzIjpbIkFETUlOIl0sImp0aSI6ImY0ODQ1Y2FhLTk4ODktNGUwZS05MzJkLTZiN2Q2M2ZhYWQyYiIsImNsaWVudF9pZCI6ImNsaWVudC1hcHAifQ.RO998KO4FOGSSrcSIbexXgZTfKrOnc_5M6txyEobxqZIKUV2Upx9TqWe8JAWP-Bi8V8cWdkPBgd3_3jrszWZza_SQluj64coolsy4Z2_W4eWXmtmVoMeU5xGqVjVxZwqKwbRRizN-WJaGkFLtbM1zUeUiIkA504K7D4A15os4EWyKxl554jxFjWkurquhlQ8JWY9uwPa5RuSad1NyiHivxSYUBkrUAHNR6UFDRs0pTEtTc7YL-vmUXEBOD3tHN2LWRojEO7sdi0mYnJEOTW1SXw5HwX1ptl6fpx46ZMSlmqnwGNyRJ7TNY5JS7QCkYnoFZqbbk6lBTCRp2QpQ1_V0w",
"refreshToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbl95dXNoYW5tYUAxNjMuY29tIiwic2NvcGUiOlsiYWxsIl0sImF0aSI6ImY0ODQ1Y2FhLTk4ODktNGUwZS05MzJkLTZiN2Q2M2ZhYWQyYiIsImlkIjoxLCJleHAiOjE2MjA2NTk1OTQsImF1dGhvcml0aWVzIjpbIkFETUlOIl0sImp0aSI6IjAxOTFhZDNlLWE2NDUtNDM5Zi04MzVlLWZhYmM2NGZjYWQxMSIsImNsaWVudF9pZCI6ImNsaWVudC1hcHAifQ.kvavjyBwv2d25xneszBfiRwJnkNiJhPu9qRdCwdCV-29F9D4rNBWsrZaZnHcnBDshE4Kyfc2aYv4nilLZ9z9kOaRKUwVdAcrSj-KaLOTTDCiMvADsbn-Un2juJBPXx24isx9VeMy6imRbGyctO1FHde53clKWSL0Rg3fzVm5BM021cNPdNvt8nwg8r3Ls2MkXM5fYLCHYiJuaSsHM3RYvmb49h358CwL9EZWzGPK27DQZDWrOwoBEpAmEbPoUuR-9UvorpZ9fqt9x6suMm3sm-aB7xX5OvBenDQ5TM6KZsW08CcOckidCoGdc1LMhoX74xFjlbo8xPYefwPiD8tlIw",
"tokenHead": "Bearer ",
"expiresIn": 3599
}
}

使用获取到的JWT令牌访问需要权限的接口

header加上key => Authorization,token之前加上“Bearer”

越权访问接口

当JWT令牌过期时,使用refresh_token获取新的JWT令牌

将grant_type改为refresh_token,无需账号密码,只需refresh_token:

{"code":200,"message":"操作成功","data":{"token":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJtYWNybyIsInNjb3BlIjpbImFsbCJdLCJpZCI6MSwiZXhwIjoxNjE4MTM2ODI2LCJhdXRob3JpdGllcyI6WyJBRE1JTiJdLCJqdGkiOiI4NDlkZmFjNi00NWY4LTQ0NzktYTdiMS1jNDlmYjkwYmRkODQiLCJjbGllbnRfaWQiOiJjbGllbnQtYXBwIn0.iDpy_AdHwVdMAsTVLZavCYlw1j6rBNc7uBF5ccymxtAYqx5lrSf5ipK1S6ZnZMWNgqhLYrU4-IHSpXZ0F7oNXrXIif1b1A6gi8_UPf9YQQrJY2B83DBjRJjIFwSSnLB1Ps3wHLhtse0kuI9shfmzXRpkh7DzOdyKcG088yh1p1YcopfQ68jeZkjr27XjCnRRmgb6ldWlRLX9UlI5Be1WSRKhmtHUPeUz7IwIAz5gNo98uSO-3euipBKxYxYYpi3TJu7jYAays4rcoYG_HJ_MTmU6XSaTyYuygSng5tDVQR0gwUC_vWEw90AExJV6df26ToRQnVpCebF1OpX2ApJAFg","refreshToken":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJtYWNybyIsInNjb3BlIjpbImFsbCJdLCJhdGkiOiI4NDlkZmFjNi00NWY4LTQ0NzktYTdiMS1jNDlmYjkwYmRkODQiLCJpZCI6MSwiZXhwIjoxNjE4MjE4NzgxLCJhdXRob3JpdGllcyI6WyJBRE1JTiJdLCJqdGkiOiI0YzJiM2U5Zi1mMDI2LTQ2MWUtYmQwNS1jZTBiNTE0YTY0ZTQiLCJjbGllbnRfaWQiOiJjbGllbnQtYXBwIn0.dL6Ggbv-1gMs_pD6OKesTITdJKVDv7_oBICYeog8-5ynK-E1cQFFqBwuoeLlx9zFPT_5mCfyf3HwZ9ptXQb3tJmpw7S9Z9t-iDvK-IeLIgHD-qrno3NXIATdjHR0ApE_6AewVjZzbXcThw04rJU_CG1qohP9xPgL8c_3_gE6hrgRXHrJ4QaCqpEc8a6CRmUqHZFIOLj-CspPYQgmHDbl-sBz1dy-W8dsLN34pUOasVPl1hpprgXSwvIzkbb3GIk3ZRTbj6ozFe7MTItJud9g5hT7yUksBYbekH75KW-1D8vTosGONsru2VcLqYgCsgmwqqfUa20u_lGn7u7uVtDeWQ","tokenHead":"Bearer ","expiresIn":3599}}

邮箱验证码服务

配置

创建配置类EmailProperties,从yml配置中读取配置:

package com.gateway.component;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Data
@Component
@ConfigurationProperties(prefix = "yushanma.email")
public class EmailProperties {
    private String from;
    private String hostName;
    private String token;
}

创建EmailConstant常量类,用于封装信息:

package com.gateway.constant;

public class EmailConstant {
    public static final String CHARSET = "utf-8";
    public static final String NAME = "【余衫马】官方";
    public static final String SUBJECT = "【机器验证】";
    public static final String SENT = "已发送验证码,请稍后再试";
    public static final String EMPTY = "收件人邮箱地址为空";
    public static final String SUCCESS = "发送成功";
    public static final String ERROR = "非法的电子邮箱地址";
    public static final String MSG = "亲爱的用户:\n" +
            "\n" +
            " \n" +
            "您好!感谢您使用莞工校招助手,您的账号正在进行邮箱验证,本次请求的验证码为:\n" +
            "\n" +
            "【%s】(为了保障您帐号的安全性,请在5分钟内完成验证。)\n" +
            "\n" +
            " \n" +
            "认证中心服务\n" +
            "\n" +
            "%s";
}

添加邮箱pom依赖:

<!--邮箱验证码服务-->
<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-email -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-email</artifactId>
    <version>1.5</version>
</dependency>

申请一个163邮箱,并开启代理服务,注意妥善保存授权密码

创建验证控制器VerifyController用于发送验证码、鉴别验证码、销毁验证码:

package com.gateway.controller.verifycode;

import cn.hutool.core.convert.Convert;
import com.gateway.component.EmailProperties;
import com.gateway.constant.EmailConstant;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.mail.EmailException;
import org.apache.commons.mail.HtmlEmail;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;

@Slf4j
@RestController
public class VerifyController {

    @Autowired
    private EmailProperties emailProperties;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 检验email合法
     *
     * @param email
     * @return
     */
    public Boolean isEmailAddress(String email) {
        //匹配邮箱地址合法性
        String pattern = "\\w+([-+.]\\w+)*@\\w+([-.]\\w+)*\\.\\w+([-.]\\w+)*";
        return Pattern.matches(pattern, email);
    }

    /**
     * 发送验证邮件
     *
     * @param to
     * @param name
     * @param subject
     * @return
     * @throws EmailException
     */
    @PostMapping("/send")
    public String send(@RequestParam("to") String to, @RequestParam("name") String name, @RequestParam("subject") String subject) throws EmailException {
        Object object = stringRedisTemplate.opsForValue().get(to);
        // 是否重复发送
        if (object != null) {
            return EmailConstant.SENT;
        } else {
            // email为空
            if (to.isEmpty()) {
                return EmailConstant.EMPTY;
            } else {
                // email非法
                if (isEmailAddress(to)) {
                    // 发送人 名称
                    if (name == null) {
                        name = EmailConstant.NAME;
                    }
                    // 发送 邮件主题
                    if (subject == null) {
                        subject = EmailConstant.SUBJECT;
                    }
                    // 验证码
                    String code = createRandomCode();
                    // 创建一个HtmlEmail实例对象
                    HtmlEmail email = new HtmlEmail();
                    // 邮箱的SMTP服务器,一般123邮箱的是smtp.123.com,qq邮箱为smtp.qq.com
                    email.setHostName(emailProperties.getHostName());
                    // 设置发送的字符类型
                    email.setCharset(EmailConstant.CHARSET);
                    // 设置收件人
                    email.addTo(to);
                    // 发送人的邮箱为自己的,用户名可以随便填
                    email.setFrom(emailProperties.getFrom(), name);
                    // 此处填写邮箱地址和客户端授权码
                    email.setAuthentication(emailProperties.getFrom(), emailProperties.getToken());
                    // 设置发送主题
                    email.setSubject(subject);
                    // 设置发送内容
                    email.setMsg(String.format(EmailConstant.MSG, code, LocalDateTime.now()));
                    // 进行发送
                    email.send();
                    // 把 to=>code 键值对放入redis缓存,使用后销毁,不使用则5分钟过期
                    stringRedisTemplate.opsForValue().set(to, code, 300, TimeUnit.SECONDS);//10s
                    return EmailConstant.SUCCESS;
                } else {
                    return EmailConstant.ERROR;
                }
            }
        }
    }

    /**
     * 验证码生成器
     *
     * @return
     */
    public String createRandomCode() {
        StringBuilder code = new StringBuilder();
        // 验证码组成库
        char[] charArray = {
                '1', '2', '3', '4', '5', '6', '7', '8', '9', '0',
                'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j',
                'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
                'u', 'v', 'w', 'x', 'y', 'z',
                'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',
                'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T',
                'U', 'V', 'W', 'X', 'Y', 'Z'
        };
        //生成6为验证码
        for (int i = 0; i < 6; i++) {
            int index = (int) (Math.random() * charArray.length);
            code.append(charArray[index]);
        }
        return code.toString();
    }

    /**
     * 检查邮箱和验证码键值对
     *
     * @param email
     * @param code
     * @return
     */
    @GetMapping("/check/code")
    public Boolean checkCode(@RequestParam("email") String email, @RequestParam("code") String code) {
        if (email.isEmpty()) {
            log.info("邮箱为空");
            return false;
        } else {
            Object object = stringRedisTemplate.opsForValue().get(email);
            List<String> realCode = Convert.toList(String.class, object);
            if (realCode.size() == 0) {
                log.info("验证码失效");
                return false;
            } else {
                if (code.equals(realCode.get(0))) {
                    log.info("验证码正确");
                    return true;
                }else {
                    log.info("验证码错误");
                    return false;
                }
            }
        }
    }

    /**
     * 使用后销毁
     *
     * @param email
     * @param code
     * @return
     */
    @GetMapping("/destroy/code")
    public Boolean destroyCode(@RequestParam("email") String email, @RequestParam("code") String code) {
        if (email.isEmpty()) {
            log.info("邮箱为空");
            return false;
        } else {
            Object object = stringRedisTemplate.opsForValue().get(email);
            List<String> realCode = Convert.toList(String.class, object);
            if (realCode.size() == 0) {
                log.info("验证码自动失效");
                return true;
            } else {
                if (code.equals(realCode.get(0))) {
                    log.info("验证码正确");
                    return stringRedisTemplate.delete(email);
                }else {
                    log.info("验证码错误");
                    return false;
                }
            }
        }
    }
}

接口测试

发送测试:

校验测试:

销毁测试:

以上测试在api文档中心进行,可以参考笔者这篇博客springcloud-alibaba基于nacos整合knife4j接口文档

为什么设计邮箱验证码

首先是为了机器验证,防止恶意脚本;其次是因为防水墙前后端二次检验有点复杂,笔者还是想从简单的事情做起,循序渐进;最后是第三方认证的问题,本来是想做微信或者QQ第三方认证登录的,后来发现经费不足,所以采用传统邮箱登录方法,验证码刚好相辅相成。

  • 2
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
SpringCloud是一个基于Spring Boot的开源微服务框架。SpringCloud GatewaySpringCloud生态中的一个组件,提供了一种基于路由的API网关解决方案。JWT是JSON WEB Token的缩写,是一种用于身份认证和授权的开放标准。OAuth2是一种授权框架,用于向用户授权第三方应用访问他们的资源。 在微服务架构中,每个服务都是独立的,网关作为服务的入口,可以实现对外的请求过滤和路由。SpringCloud Gateway使用HttpClient进行内部请求的调度和路由。同时,它还提供了一些高阶的路由和过滤功能,如重定向、URL重写、限流、熔断、重试等。 JWT是一种轻量级的认证方案,通过在HTTP请求中添加一个JSON WEB Token,实现对用户进行身份认证和授权。JWT的使用极大地简化了认证过程,前后端可以通过JWT判断用户的身份和权限。 OAuth2为开发者提供了一种授权框架,可以授权第三方应用获取用户访问他们的资源。OAuth2支持多种授权类型,如授权码模式、密码模式、客户端模式和隐式模式。使用OAuth2,可以更好地保护用户的隐私和安全。 综上所述,SpringCloud GatewayJWT和OAuth2都是现代化的解决方案,对于设计和开发微服务架构的应用程序来说,它们都是必不可少的组件。有了它们,开发人员可以更好的搭建分布式架构,确保数据安全性、隐私安全性和服务的可靠性。
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

余衫马

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值