1. 权限认证方案分析

1.1. 传统单应用的用户鉴权

使用session保存登录状态,通过存放的key-value来进行鉴权,对于一台机器无法同步session到其他机器的时候,我们的问题就来了,如何进行服务应用的鉴权

1.2. 分布式环境下的解决方案

1.2.1. 同步session

session复制是最容易先想到的解决方案,可以将一台机器中的session复制到集群中其他的机器里,比如Tomcat中也有内置的session的同步方案,但是这并不是一个非常优雅的解决方案,他会带来以下两个问题

  • Timing问题:同步需要花费一定的时间,我们无法保证session同步的及时性,也就是说,当用户发起两个请求分别落在不同的机器上的时候,前一个请求写入session的信息可能还没有同步到所有的机器,后一个请求就已经开始执行业务逻辑了,这就会引起脏读和幻读
  • **数据冗余:**所有的服务器都需要保存一份session的全集,这就产生了大量的冗余数据

1.2.2. 反向代理:绑定IP或一致性hash

这个方案是在Nginx网关层来做的,我们可以指定某些ip请求落在某个指定的机器上,这样一来session始终只会存在同一个机器上,不过相比前一种session复制的方法来说,绑定IP的方式更明显缺陷如下:

  • 负载均衡:在绑定IP的情况下无法在网关层应用负载均衡策略的,而且某个服务器出现故障会对指定IP的来访用户产生较大的影响,对网关层来讲这种路由规则的配置也比较麻烦
  • IP变更:很多运营商的IP时不时就会进行切换,这就会导致更换IP后的请求被路由到不同的服务节点处理,这样一来就读不到前面设置的session信息了

为了解决第二个问题,可以通过一致性hash路由的方式来做,比如根据用户ID做hash,不同的hash值落在不同的机器上,保证足够均衡的分配,这样也就避免了IP切换的问题,但依然无法解决第一点里提到的负载均衡的问题

1.2.3. Redis解决方案

通过将session中心化,从服务器的存储上转移到redis中

在tomcat层面可以直接使用组件将容器的session放入到redis中,另一个方案可以借助springboot的管理session方式,将session存储进redis中

1.3. 分布式Session的替代方案

1.3.1. OAuth 2.0

OAuth 2.0是一个开放授权标准协议,它允许第三方应用访问该用户在某服务的特定私有资源,但不提供账号密码信息给第三方应用

1.3.2. JWT鉴权

JWT也是一种基于Token的鉴权机制,他的基本思想是通过用户名+密码换取一个Access Token

鉴权流程

1、用户名+密码访问鉴权服务

  • 验证通过:服务器返回一个Access Token给客户端,并将Token保存在服务端某个地方用于后面的访问控制(可以保存在数据库里也可以保存在Redis中)
  • 验证失败:不生成Token

2、客户端使用令牌访问资源,服务器验证令牌有效性

  • 令牌错误或过期:拦截请求,让客户端重新申请令牌
  • 令牌正确:允许放行

2. 实现JWT鉴权

通过以下几步完成鉴权操作

  • 创建auth-service(登录、鉴权服务)
  • 添加JwtService类实现token创建和验证
  • 网关层集成auth-service(添加AuthFilter到网关层,如果没有登录则返回403)

在gateway里创建一个auth-service-api的module

添加POM依赖

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>
    </dependencies>

创建一个entity包,创建一个账户实体对象

package com.icodingedu.springcloud.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Account implements Serializable {

    private String username;

    private String token;

    //当token接近失效的时候可以用refreshToken生成一个新的token
    private String refreshToken;
}

在entity包下面创建一个AuthResponseCode类

package com.icodingedu.springcloud.entity;

public class AuthResponseCode {
    
    public static final Long SUCCESS = 1L;
    
    public static final Long INCORRECT_PWD = 1000L;
    
    public static final Long USER_NOT_FOUND = 1001L;
    
    public static final Long INVALID_TOKEN = 1002L;
}

在entity包下创建一个AuthResponse处理结果类

package com.icodingedu.springcloud.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthResponse {

    private Account account;
    
    private Long code;

}

创建一个service包在里面创建接口AuthService

package com.icodingedu.springcloud.service;

import com.icodingedu.springcloud.entity.AuthResponse;
import org.springframework.cloud.openfeign.FeignClient;
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.ResponseBody;

@FeignClient("auth-service")
public interface AuthService {

    @PostMapping("/login")
    @ResponseBody
    public AuthResponse login(@RequestParam("username") String username,
                              @RequestParam("password") String password);

    @GetMapping("/verify")
    @ResponseBody
    public AuthResponse verify(@RequestParam("token") String token,
                               @RequestParam("username") String username);

    @PostMapping("/refresh")
    @ResponseBody
    public AuthResponse refresh(@RequestParam("refresh") String refreshToken);
}

创建服务实现的auth-service的module,还是放在gateway目录下

导入POM依赖

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.7.0</version>
        </dependency>
        <dependency>
            <groupId>com.icodingedu</groupId>
            <artifactId>auth-service-api</artifactId>
            <version>${project.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
    </dependencies>

创建启动类application

package com.icodingedu.springcloud;

import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

@EnableDiscoveryClient
@SpringBootApplication
public class AuthServiceApplication {

    public static void main(String[] args) {
        new SpringApplicationBuilder(AuthServiceApplication.class)
                .web(WebApplicationType.SERVLET)
                .run(args);
    }
}

创建一个service包,建立JwtService类

package com.icodingedu.springcloud.service;

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.icodingedu.springcloud.entity.Account;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.Date;

@Slf4j
@Service
public class JwtService {
    
    //生产环境中应该从外部加密后传入
    private static final String KEY = "you must change it";
    //生产环境中应该从外部加密后传入
    private static final String ISSUER = "gavin";
    //定义过期时间
    private static final long TOKEN_EXP_TIME = 60000;
    //定义传入的参数名
    private static final String USERNAME = "username";

    /**
     * 生成token
     * @param account 账户信息
     * @return token
     */
    public String token(Account account){
        //生成token时间
        Date now = new Date();
        //生成token所要用到的算法
        Algorithm algorithm = Algorithm.HMAC256(KEY);
        
        String token = JWT.create()
                          .withIssuer(ISSUER) //发行方
                          .withIssuedAt(now) //发行时间
                          .withExpiresAt(new Date(now.getTime()+TOKEN_EXP_TIME)) //token过期时间
                          .withClaim(USERNAME,account.getUsername()) //传入发行的username
                          .sign(algorithm); //用前面设置算法签发
        log.info("jwt generated user={}",account.getUsername());
        return token;
    }

    /**
     * 验证token
     * @param token
     * @param username
     * @return
     */
    public boolean verify(String token,String username){
        log.info("verify jwt - user={}",username);
        try{
            //加密解密算法一样
            Algorithm algorithm = Algorithm.HMAC256(KEY);
            //构建一个验证器:验证JWT的内容,是个接口
            JWTVerifier verifier = JWT.require(algorithm)
                    .withIssuer(ISSUER)
                    .withClaim(USERNAME,username)
                    .build();
            //进行验证,没有错误就直接通过
            verifier.verify(token);
            return true;
        }catch(Exception ex){
            log.error("auth failed",ex);
            return false;
        }
    }
}

创建controller包,建立JwtController类

package com.icodingedu.springcloud.controller;

import com.icodingedu.springcloud.entity.Account;
import com.icodingedu.springcloud.entity.AuthResponse;
import com.icodingedu.springcloud.entity.AuthResponseCode;
import com.icodingedu.springcloud.service.AuthService;
import com.icodingedu.springcloud.service.JwtService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.RestController;

import java.util.UUID;

@Slf4j
@RestController
public class JwtController implements AuthService {

    @Autowired
    private JwtService jwtService;

    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    public AuthResponse login(String username, String password) {
        Account account = Account.builder()
                .username(username)
                .build();

        //TODO 0-这一步需要验证用户名密码,一般是在数据库中,假定已经验证通过了
        //TODO 如果验证失败在这里就要return
        //1-生成token
        String token = jwtService.token(account);
        account.setToken(token);
        //2-这里保存拿到新token的key
        account.setRefreshToken(UUID.randomUUID().toString());
        //3-保存token,把token保存取来在refresh时才知道更新关联哪个token
        redisTemplate.opsForValue().set(account.getRefreshToken(),account);
        //4-返回token
        return AuthResponse.builder()
                .account(account)
                .code(AuthResponseCode.SUCCESS)
                .build();
    }

    @Override
    public AuthResponse verify(String token, String username) {
        boolean flag = jwtService.verify(token, username);

        return AuthResponse.builder()
                .code(flag?AuthResponseCode.SUCCESS:AuthResponseCode.INVALID_TOKEN)
                .build();
    }

    @Override
    public AuthResponse refresh(String refreshToken) {
        //当使用redisTemplate保存对象时,对象必须时一个可被序列化的对象
        Account account = (Account) redisTemplate.opsForValue().get(refreshToken);
        if(account == null){
            return AuthResponse.builder()
                    .code(AuthResponseCode.USER_NOT_FOUND)
                    .build();
        }
        //获取一个新token
        String token = jwtService.token(account);
        account.setToken(token);
        //更新新的refreshToken
        account.setRefreshToken(UUID.randomUUID().toString());
        //将原来的删除
        redisTemplate.delete(refreshToken);
        //添加新的token
        redisTemplate.opsForValue().set(account.getRefreshToken(),account);
        return AuthResponse.builder()
                .account(account)
                .code(AuthResponseCode.SUCCESS)
                .build();
    }
}

设置application配置文件

spring.application.name=auth-service
server.port=50081

eureka.client.serviceUrl.defaultZone=http://localhost:10080/eureka/

spring.redis.host=localhost
spring.redis.database=0
spring.redis.port=6379

info.app.name=auth-service
info.app.description=test

management.security.enabled=false
management.endpoints.web.exposure.include=*
management.endpoint.health.show-details=always

可以启动验证了:eureka-server、auth-service

在PostMan里进行了验证:login、verify、refresh

开启改造gateway-server

POM里引入依赖,增加三个依赖

        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.7.0</version>
        </dependency>
				<!--因为springcloud gateway是基于webflux的,如果需要web则是导入starter-webflux而不是starter-web-->
        <dependency>
            <groupId>com.icodingedu</groupId>
            <artifactId>auth-service-api</artifactId>
            <version>${project.version}</version>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-web</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.5</version>
        </dependency>

在gateway-server中创建鉴权的service类GatewayAuthService

package com.icodingedu.springcloud.service;

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@Slf4j
public class GatewayAuthService {

    //生产环境中应该从外部加密后传入
    private static final String KEY = "you must change it";
    //生产环境中应该从外部加密后传入
    private static final String ISSUER = "gateway";
    //定义传入的参数名
    private static final String USERNAME = "username";

    /**
     * 验证token
     * @param token
     * @param username
     * @return
     */
    public boolean verify(String token,String username){
        log.info("verify jwt - user={}",username);
        try{
            //加密解密算法一样
            Algorithm algorithm = Algorithm.HMAC256(KEY);
            //构建一个验证器:验证JWT的内容,是个接口
            JWTVerifier verifier = JWT.require(algorithm)
                    .withIssuer(ISSUER)
                    .withClaim(USERNAME,username)
                    .build();
            //进行验证,没有错误就直接通过
            verifier.verify(token);
            return true;
        }catch(Exception ex){
            log.error("auth failed",ex);
            return false;
        }
    }
}

创建一个新的类:AuthFilter

package com.icodingedu.springcloud.filter;

import com.icodingedu.springcloud.entity.AuthResponse;
import com.icodingedu.springcloud.entity.AuthResponseCode;
import com.icodingedu.springcloud.service.GatewayAuthService;
import io.netty.util.internal.StringUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

@Slf4j
@Component("authFilter")
public class AuthFilter implements GatewayFilter, Ordered {
    
    private static final String AUTH = "Authorization";
    
    private static final String USERNAME = "icodingedu-username";
    
    @Autowired
    private GatewayAuthService authService;


    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        log.info("Auth Start");
        ServerHttpRequest request = exchange.getRequest();
        HttpHeaders headers = request.getHeaders();
        String token = headers.getFirst(AUTH);
        String username = headers.getFirst(USERNAME);

        ServerHttpResponse response = exchange.getResponse();
        if(StringUtils.isBlank(token)){
            log.error("token not found");
            response.setStatusCode(HttpStatus.UNAUTHORIZED);
            return response.setComplete();
        }

        AuthResponse resp = AuthResponse.builder()
                .code(authService.verify(token,username)?AuthResponseCode.SUCCESS:AuthResponseCode.INVALID_TOKEN)
                .build();
        if(resp.getCode() != 1L){
            log.error("invalid token");
            response.setStatusCode(HttpStatus.FORBIDDEN);
            return response.setComplete();
        }
        //TODO 将用户信息再次存放在请求的header中传递给下游业务
        ServerHttpRequest.Builder mutate = request.mutate();
        mutate.header(USERNAME, username);
        ServerHttpRequest buildRequest = mutate.build();
        
        //TODO 如果响应中需要放数据,也可以放在response的header中
        response.setStatusCode(HttpStatus.OK);
        response.getHeaders().add("icoding-user",username);
        
        return chain.filter(
                exchange.mutate()
                        .request(buildRequest)
                        .response(response)
                        .build());
    }

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

将AuthFilter注入到configuration中

@Configuration
public class GatewayConfiguration {

    @Autowired
    private AuthFilter authFilter;

    @Bean
    @Order
    public RouteLocator customerRouters(RouteLocatorBuilder builder){
        LocalDateTime ldt = LocalDateTime.of(2020,10,24,21,30,10);
        return builder.routes()
                .route(r -> r.path("/gatewayjava/**")
                             .and().method(HttpMethod.GET)
                             .filters(f -> f.stripPrefix(1)
                                            .addResponseHeader("java-param","gateway-config")
                                            .filter(authFilter)
                             )
                             .uri("lb://FEIGN-CLIENT")
                )
                .route(r -> r.path("/secondkill/**")
                             .and().after(ZonedDateTime.of(ldt, ZoneId.of("Asia/Shanghai")))
                             .filters(f -> f.stripPrefix(1))
                             .uri("lb://FEIGN-CLIENT/")
                )
                .build();
    }
}

启动服务进行验证:eureka-server、auth-service、feign-client、gateway-server

12. 实现网关层限流

创建一个限流的配置类RedisLimiterConfiguration

package com.icodingedu.springcloud.config;

import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.cloud.gateway.filter.ratelimit.RedisRateLimiter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import reactor.core.publisher.Mono;

@Configuration
public class RedisLimiterConfiguration {

    //我们这里根据用户请求IP地址进行限流
    @Bean
    @Primary //一个系统不止一个KeyResolver
    public KeyResolver remoteAddressKeyResolver(){
        return exchange -> Mono.just(
            exchange.getRequest()
                    .getRemoteAddress()
                    .getAddress()
                    .getHostAddress()
        );
    }

    @Bean("redisLimiterUser")
    @Primary
    public RedisRateLimiter redisRateLimiterUser(){
        //这里相当于一个令牌桶,我们也可以自己创建一个限流脚本
        //defaultReplenishRate:限流桶速率,每秒10个
        //defaultBurstCapacity:桶的容量,60
        return new RedisRateLimiter(10,60);
    }

    @Bean("redisLimiterProduct")
    public RedisRateLimiter redisRateLimiterProduct(){
        //桶的容量100,创建令牌的速度是每秒20
        return new RedisRateLimiter(20,100);
    }
}

配置application.yaml的redis信息

spring:
  application:
    name: gateway-server
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true
          lower-case-service-id: true
  redis:
    host: localhost
    port: 6379
    database: 0
  main:
    allow-bean-definition-overriding: true

使用的时候需要在GatewayConfiguration中进行配置加入RedisLimiter的配置

@Configuration
public class GatewayConfiguration {

    @Autowired
    private KeyResolver hostNameResolver;

    @Autowired
    @Qualifier("redisLimiterUser")
    private RateLimiter rateLimiter;

    @Autowired
    private AuthFilter authFilter;

    @Bean
    @Order
    public RouteLocator customerRouters(RouteLocatorBuilder builder){
        LocalDateTime ldt = LocalDateTime.of(2020,10,24,21,30,10);
        return builder.routes()
                .route(r -> r.path("/gatewayjava/**")
                             .and().method(HttpMethod.GET)
                             .filters(f -> f.stripPrefix(1)
                                            .addResponseHeader("java-param","gateway-config")
                                            .filter(authFilter)
                                            .requestRateLimiter(
                                                    c ->{
                                                        c.setKeyResolver(hostNameResolver);
                                                        c.setRateLimiter(rateLimiter);
                                                        c.setStatusCode(HttpStatus.BAD_GATEWAY);
                                            })
                             )
                             .uri("lb://FEIGN-CLIENT")
                )
                .route(r -> r.path("/secondkill/**")
                             .and().after(ZonedDateTime.of(ldt, ZoneId.of("Asia/Shanghai")))
                             .filters(f -> f.stripPrefix(1))
                             .uri("lb://FEIGN-CLIENT/")
                )
                .build();
    }
}
Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐