Spring Cloud Gateway+Oauth2 实现统一认证和鉴权

1年前 (2023) 程序员胖胖胖虎阿
146 0 0

前言

应用架构:
认证服务负责认证,网关负责校验认证和鉴权,其他API服务负责处理自己的业务逻辑。
安全相关的逻辑只存在于认证服务和网关服务中,其他服务只是单纯地提供服务而没有任何安全相关逻辑。

JWT认证流程:
Spring Cloud Gateway+Oauth2 实现统一认证和鉴权
1、用户使用账号和密码发出post请求;
2、服务器使用私钥创建一个jwt;
3、服务器返回这个jwt给浏览器;
4、浏览器将该jwt串在请求头中像服务器发送请求;
5、服务器验证该jwt;
6、返回响应的资源给浏览器。
JWT解析

JWT使用场景:

  • 授权:这是JWT使用最多的场景,一旦用户登录,每个后续的请求将包括JWT,从而允许用户访问该令牌允许的路由、服务和资源。

服务划分:

  • micro-oauth2-gateway:网关服务,负责请求转发和鉴权功能,整合Spring Security+Oauth2;
  • micro-oauth2-auth:Oauth2认证服务,负责对登录用户进行认证,整合Spring Security+Oauth2;
  • micro-oauth2-api:受保护的API服务,用户鉴权通过后可以访问该服务,不整合Spring Security+Oauth2。

搭建认证服务

使用keytool生成RSA证书jwt.jks,复制到resource目录下,在JDK的bin目录下使用如下命令即可;

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

Oauth2配置类AuthorizationServerConfigurerAdapter
AuthorizationServerConfigurerAdapter中:

  • ClientDetailsServiceConfigurer:用来配置客户端详情服务(ClientDetailsService),客户端详情信息在这里进行初始化,你能够把客户端详情信息写死在这里或者是通过数据库来存储调取详情信息。
  • AuthorizationServerSecurityConfigurer:用来配置令牌端点(Token Endpoint)的安全约束。
  • AuthorizationServerEndpointsConfigurer:用来配置授权(authorization)以及令牌(token)的访问端点和令牌服务(token services)。
/**
 * 认证服务器配置
 */
@AllArgsConstructor
@Configuration
@EnableAuthorizationServer
public class Oauth2ServerConfig extends AuthorizationServerConfigurerAdapter {

    private final DataSource dataSource;
    private final PasswordEncoder passwordEncoder;
    private final UserServiceImpl userDetailsService;
    private final AuthenticationManager authenticationManager;

    public static final String CLIENT_ID = "client-app";
    public static final String CLIENT_SECRET = "12345678";

/*    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient(CLIENT_ID)
                .secret(passwordEncoder.encode(CLIENT_SECRET))
                .scopes("all")
                .authorizedGrantTypes("password", "refresh_token")
                .accessTokenValiditySeconds(3600)
                .refreshTokenValiditySeconds(86400);
    }*/

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        // 从jdbc查出数据来存储
        clients.withClientDetails(new JdbcClientDetailsService(dataSource));
    }

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

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
        enhancerChain.setTokenEnhancers(Lists.newArrayList(tokenEnhancer(), accessTokenConverter())); //配置JWT的内容增强器
        endpoints
                .authenticationManager(authenticationManager)
                // 配置加载用户信息的服务
                .userDetailsService(userDetailsService)
                // 配置JwtAccessToken转换器
                .accessTokenConverter(accessTokenConverter())
                .tokenEnhancer(enhancerChain);
    }

    /**
     * 使用非对称加密算法来对Token进行签名
     */
    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
        jwtAccessTokenConverter.setKeyPair(keyPair());
        return jwtAccessTokenConverter;
    }

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

    }

    /**
     * 往JWT中添加自定义信息
     */
    @Bean
    public TokenEnhancer tokenEnhancer() {
        return (accessToken, authentication) -> {
            SecurityUser securityUser = (SecurityUser) authentication.getPrincipal();
            Map<String, Object> info = new HashMap<>();
            // 把用户ID设置到JWT中
            info.put("userId", securityUser.getId());
            ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(info);
            return accessToken;
        };
    }

    public static void main(String[] args) {
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
        System.out.println(encoder.encode("12345678"));
    }
}

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

/**
 * 获取RSA公钥接口
 */
@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();
    }

}

允许获取公钥接口的访问;

/**
 * SpringSecurity配置
 */
@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中,方便网关服务进行鉴权的时候获取。

/**
 * 资源与角色匹配关系管理业务类
 */
@Service
public class ResourceServiceImpl {

    private Map<String, List<String>> resourceRolesMap;
    @Autowired
    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);
    }
}

搭建网关服务

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

在application.yml中添加相关配置,主要是路由规则的配置、Oauth2中RSA公钥的配置及路由白名单的配置;

server:
  port: 9201
spring:
  profiles:
    active: dev
  application:
    name: micro-oauth2-gateway
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
    gateway:
      routes: #配置路由规则
        - id: oauth2-api-route
          uri: lb://micro-oauth2-api
          predicates:
            - Path=/api/**
          filters:
            - StripPrefix=1
        - id: oauth2-auth-route
          uri: lb://micro-oauth2-auth
          predicates:
            - Path=/auth/**
          filters:
            - StripPrefix=1
      discovery:
        locator:
          enabled: true #开启从注册中心动态创建路由的功能
          lower-case-service-id: true #使用小写服务名,默认是大写
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: 'http://localhost:9401/rsa/publicKey' #配置RSA的公钥访问地址
  redis:
    database: 0
    port: 6379
    host: localhost
    password: 
secure:
  ignore:
    urls: #配置白名单路径
      - "/actuator/**"
      - "/auth/oauth/token"

对网关服务进行配置安全配置,由于Gateway使用的是WebFlux,所以需要使用@EnableWebFluxSecurity注解开启;

/**
 * 资源服务器配置
 */
@AllArgsConstructor
@Configuration
@EnableWebFluxSecurity
public class ResourceServerConfig {
    private final AuthorizationManager authorizationManager;
    private final IgnoreUrlsConfig ignoreUrlsConfig;
    private final RestfulAccessDeniedHandler restfulAccessDeniedHandler;
    private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
    private final IgnoreUrlsRemoveJwtFilter ignoreUrlsRemoveJwtFilter;

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        http.oauth2ResourceServer().jwt()
                .jwtAuthenticationConverter(jwtAuthenticationConverter());
        //自定义处理JWT请求头过期或签名错误的结果
        http.oauth2ResourceServer().authenticationEntryPoint(restAuthenticationEntryPoint);
        //对白名单路径,直接移除JWT请求头
        http.addFilterBefore(ignoreUrlsRemoveJwtFilter, SecurityWebFiltersOrder.AUTHENTICATION);
        http.authorizeExchange()
                .pathMatchers("/css/**").permitAll()
                .pathMatchers(ArrayUtil.toArray(ignoreUrlsConfig.getUrls(),String.class)).permitAll()//白名单配置
                .anyExchange().access(authorizationManager)//鉴权管理器配置
                .and().exceptionHandling()
                .accessDeniedHandler(restfulAccessDeniedHandler)//处理未授权
                .authenticationEntryPoint(restAuthenticationEntryPoint)//处理未认证
                .and().csrf().disable();
        return http.build();
    }

    @Bean
    public Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        jwtGrantedAuthoritiesConverter.setAuthorityPrefix(AuthConstant.AUTHORITY_PREFIX);
        jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(AuthConstant.AUTHORITY_CLAIM_NAME);
        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
        return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
    }

}

在WebFluxSecurity中自定义鉴权操作需要实现ReactiveAuthorizationManager接口;

/**
 * 鉴权管理器,用于判断是否有资源的访问权限
 */
@Component
public class AuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
        //从Redis中获取当前路径可访问角色列表
        URI uri = authorizationContext.getExchange().getRequest().getURI();
        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)
                // 不需要角色就可以访问
                .any(role -> true)
                .map(AuthorizationDecision::new)
                .defaultIfEmpty(new AuthorizationDecision(false));
    }

}

这里我们还需要实现一个全局过滤器AuthGlobalFilter,当鉴权通过后将JWT令牌中的用户信息解析出来,然后存入请求的Header中,这样后续服务就不需要解析JWT令牌了,可以直接从请求的Header中获取到用户信息。

/**
 * 将登录用户的JWT转化成用户信息的全局过滤器
 */
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {

    private static 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 {
            String realToken = token.replace("Bearer ", "");
            JWSObject jwsObject = JWSObject.parse(realToken);
            String userStr = jwsObject.getPayload().toString();
            LOGGER.info("AuthGlobalFilter.filter() user:{}",userStr);
            // 从token中解析用户信息并设置到Header中去
            ServerHttpRequest request = exchange.getRequest().mutate().header("user", userStr).build();
            exchange = exchange.mutate().request(request).build();
        } catch (ParseException e) {
            e.printStackTrace();
        }
        return chain.filter(exchange);
    }

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

搭建一个API服务

它不会集成和实现任何安全相关逻辑,全靠网关来保护它。

创建一个测试接口,网关验证通过即可访问;

    @GetMapping("/demo")
    public String demo(@RequestHeader("user") String user) {

        return "Hello demo." + user;
    }

创建一个LoginUserHolder组件,用于从请求的Header中直接获取登录用户信息;

/**
 * 获取登录用户信息
 */
@Component
public class LoginUserHolder {

    public UserDTO getCurrentUser() {
        //从Header中获取用户信息
        ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = servletRequestAttributes.getRequest();
        String userStr = request.getHeader("user");
        JSONObject userJsonObject = new JSONObject(userStr);
        UserDTO userDTO = new UserDTO();
        userDTO.setUsername(userJsonObject.getStr("user_name"));
        userDTO.setId(Convert.toLong(userJsonObject.get("userId")));
        userDTO.setRoles(Convert.toList(String.class, userJsonObject.get("authorities")));
        return userDTO;
    }
}

测试

微服务系统中的统一认证鉴权功能,所有请求均通过网关访问。

使用密码模式获取JWT令牌,访问地址:http://localhost:9201/auth/oauth/token
post表单模式请求。
grant_type:password
client_id:client-app
client_secret:12345678
username:macro
password:12345678
Spring Cloud Gateway+Oauth2 实现统一认证和鉴权

使用获取到的JWT令牌访问需要权限的接口,访问地址:http://localhost:9201/api/demo
Bearer
Spring Cloud Gateway+Oauth2 实现统一认证和鉴权

当JWT令牌过期时,使用refresh_token获取新的JWT令牌,访问地址:http://localhost:9201/auth/oauth/token
grant_type:refresh_token
client_id:client-app
client_secret:12345678
refresh_token:eyJhbGciOiJSUzI1NiIsInR5…
Spring Cloud Gateway+Oauth2 实现统一认证和鉴权

https://gitee.com/yyangqqian/micro-oauth2.git

参考:
三分钟带你了解JWT认证

版权声明:程序员胖胖胖虎阿 发表于 2023年3月20日 下午5:40。
转载请注明:Spring Cloud Gateway+Oauth2 实现统一认证和鉴权 | 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...