阅读 1229

使用Gateway网关实现用户认证与鉴权

一、序言

在传统的SpringBoot项目中,优秀的用户认证和鉴权开源框架有SpringSecurity和Shiro,网上已经有大量的教程和参考资料以供学习使用,在SpringCloud分布式项目中,主流的两种网关技术为Zuul1.x和Gateway,两者最主要的区别在于Gateway是基于Spring5.x的WebFlux实现的,而WebFlux底层采用的是高性能的Reactor模式通信的Netty,反观Zuul1.x则是基于servlet3.1之前的阻塞式IO。

简单对比的话,就是Gateway使用NIO,Zuul1.x使用BIO,笔者基于"学旧不如学新"的宗旨,在尝试搭建RPC系统架构时选用了Gateway作为网关并负责对用户进行认证和鉴权。

注:为了避免重心偏移,本文并不会对NIO、Netty、Gateway以及WebFlux(实际上是笔者也没整明白WebFlux)的基本知识进行介绍,如果读者阅读时感到不易理解的话,可自行搜索这部分前驱知识进行了解

二、需求

1.第一次登陆时根据用户的账号密码进行登陆认证,如果正确则生成Token存储在Redis中

2.当请求头中携带了Token时,则判断Token是否合法,是否过期,通过认证则放行请求

3.根据用户判断是否具备访问制定请求的权限,有则放行,无则警告

三、实现思路

先说结论:笔者虽然尝试过,但最终没有使用开源框架,因为缺乏案例,查找到的资料水平也都参差不齐,不能完美实现笔者的业务需求,具体原因后文会细说

1.使用SpringSecurity实现,泳道图如下

2.笔者使用SpringSecurity时遇到的几个问题

(1).新的学习门槛:Security主配置文件中不再是@Security,而是@SecurityReactor,显然,是Security专门为Reactor单独实现的一套方法

(2).后台解析login参数难:Gateway是从一个叫ServerWebExchange的对象中获取Request和Response,获取到的request类名为ServerHttpRequest,这个类很坑,超级坑,从中获取POST请求携带的参数需要开启流,并且只可以读一次,第二次再读数据就丢失了,并且将用户名和密码取出做持久化操作也是一个很让人困扰的问题。所以笔者直接把/login改为了Get请求(逃

(3).认证结果无法获取:认证成功后Authenticate对象中的isAuthenticated值无法直接修改

3.基于上述原因,SpringSecurity在Gateway中的使用过于艰难,于是笔者根据啃源码过程中的学习成果,决定自己手写一套简单易用的认证鉴权,设计并不高明,只是能用而已

四、代码

Don`t talk too much, show me the code !

1.目录结构:

2.详细代码

代码中的注释已经写得很详细了,笔者就不做过多解释了,如下:

(1).主过滤器,采用简单暴力的if/else判断,决定下一步的程序走向,

/**
 * @author 郭超
 * Date:2020-09-29 9:50
 * Description: 认证授权主配置类,使用过滤器链需要中间存储对象
 */
@Slf4j
@Component
@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE + 2)
public class SecurityFilter implements WebFilter {

    /**
     * 处理直接放行的请求
     */
    @Resource
    private ReleaseRequestHandler releaseRequestHandler;

    /**
     * 登陆请求处理类
     */
    @Resource
    private UserPassAuthenticationHandler userPassAuthenticationHandler;

    /**
     * 根据Token完成认证的处理类
     */
    @Resource
    private TokenAuthenticationHandler tokenAuthenticationHandler;

    /**
     * 用户鉴权处理类
     */
    @Resource
    private DynamicVerification dynamicPermission;

    /**
     * 鉴权成功处理类
     */
    @Resource
    private AuthorizationSuccessHandler authorizationSuccessHandler;

    /**
     * 认证失败异常处理类
     */
    @Resource
    private AuthenticationExceptionHandler authenticationExceptionHandler;

    /**
     * 鉴权失败异常处理类
     */
    @Resource
    private AuthorizationExceptionHandler authorizationExceptionHandler;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        // 0. 判断是否为直接放行的请求
        boolean isReleaseRequest = releaseRequestHandler.isReleaseRequest(request);
        if (isReleaseRequest) {
            // 如果是则直接放行
            return chain.filter(exchange);
        }
        // 1. 为response装填JSONHeader
        ServerHttpResponse response = SecurityHelper.setResponseHeader(exchange.getResponse());

        // 2. 拦截请求,判断是否为第一次登陆
        boolean isLoginPath = userPassAuthenticationHandler.isLoginPath(request);
        boolean isStaticResourcePath = tokenAuthenticationHandler.isResourceRequest(request);
        Authentication authentication;
        if (isLoginPath) {
            // 2.1 如果是,则进入通过账号密码认证的逻辑
            authentication = userPassAuthenticationHandler.authenticate(exchange);
        } else {
            // 2.2 再判断是否访问静态资源
            if (isStaticResourcePath) {
                // 2.2.1 如果是,则直接放行
                return chain.filter(exchange);
            } else {
                // 2.2.2 如果都不是,则进入通过token认证的逻辑
                authentication = tokenAuthenticationHandler.authenticate(request);
            }
        }

        int authenticatedStatus = authentication.getAuthenticatedStatus();
        String userName = authentication.getUserName();

        // 3 根据认证结果,判断是执行鉴权还是回写警告
        DataBuffer dataBuffer;
        if (authenticatedStatus == AuthenticatedStatus.AUTHENTICATION_SUCCESS ||
                authenticatedStatus == AuthenticatedStatus.FIRST_LOGIN_SUCCESS) {
            // 3.1 认证成功,进入鉴权逻辑
            int authorizeStatus = dynamicPermission.check(userName, request);

            if (authorizeStatus == AuthorizeStatus.AUTHORIZE_SUCCESS) {
                if (authenticatedStatus == AuthenticatedStatus.AUTHENTICATION_SUCCESS) {
                    // 3.1.1 鉴权成功,且为Token,则直接放行
                    return chain.filter(exchange);
                }
                dataBuffer = authorizationSuccessHandler.onAuthorizationSuccess(userName, response);
            } else {
                // 3.1.2 鉴权失败,返回警告信息
                dataBuffer = authorizationExceptionHandler.getWarningInfo(authorizeStatus, response);
            }
        } else {
            // 3.2 认证失败,则根据返回状态,返回警告信息
            dataBuffer = authenticationExceptionHandler.getWarningInfo(authenticatedStatus, response);
        }
        return response.writeWith(Mono.just(dataBuffer));
    }

}
复制代码

(2).放行请求配置

/**
 * @author 郭超
 * Date:2020-11-03 9:40
 * Description: 处理直接放行的请求
 */
@Slf4j
@Component
public class ReleaseRequestHandler {

    private List<String> releaseRequestPath = new ArrayList<>(Arrays.asList("/agent/"));

    /**
     * 判断是否为代理模块发送的请求
     *
     * @param request request
     * @return boolean
     */
    public boolean isReleaseRequest2(ServerHttpRequest request) {
        // 获取request请求的Path
        String path = request.getPath().toString();
        AntPathMatcher antPathMatcher = new AntPathMatcher();
        boolean result = releaseRequestPath.stream().anyMatch(item -> {
            return antPathMatcher.match(item, path);
        });
        log.info("path.equals(agentPath) = " + result);
        return result;
    }
}
复制代码

(3).第一次登陆,账号密码登陆请求处理类

/**
 * @author 郭超
 * Date:2020-09-29 10:35
 * Description: 登陆请求处理类
 */
@Slf4j
@Component
public class UserPassAuthenticationHandler {

    @Resource
    private SysUserService userService;

    /**
     * 根据请求路径判断是否为第一次登陆
     * 需满足以下两个条件才可认为是第一次登陆
     * 1.请求路径为"/login"
     * 2.ContentType为JSON
     *
     * @param request 当前请求
     * @return result
     */
    public boolean isLoginPath(ServerHttpRequest request) {
        // 获取request请求的Path和ContentType
        String path = request.getPath().toString();
        HttpHeaders headers = request.getHeaders();
        MediaType contentType = headers.getContentType();
        log.info("path 0 =========== " + request.getPath());
        /*if (contentType != null) {
            log.info("contentType ============ " + contentType);
            if (contentType.toString().equals(MediaType.APPLICATION_JSON_VALUE)
                    || contentType.toString().equals(MediaType.APPLICATION_JSON_UTF8_VALUE)) {*/
        String loginPath = "/login";
        boolean result = path.equals(loginPath);
        log.info("path.equals(loginPath) = " + result);
        return result;
            /*}
        }
        return false;*/
    }

    /**
     * 账号密码登陆认证
     *
     * @param exchange ServerWebExchange
     * @return 认证结果:是否存在该用户
     */
    public Authentication authenticate(ServerWebExchange exchange) {
        Authentication authentication = new Authentication();
        MultiValueMap<String, String> params = exchange.getRequest().getQueryParams();
        log.info("userMap.toString()" + params.toString());
        String userName = params.get("username").toString();
        if (userName != null && !"".equals(userName)) {
            userName = userName.replaceAll("[\\[\\]]", "");
            authentication.setUserName(userName);
            String password = params.get("password").toString().replaceAll("[\\[\\]]", "");
            log.info("<===========用户:" + userName);
            // 先判断用户是否存在
            SysUser user = userService.getUserByUserName(userName);
            if (user != null) {
                // 执行认证
                try {
                    if (userService.checkLogin(userName, password)) {
                        log.info("<===========用户:" + userName + "存在,身份验证通过!===========>");
                        authentication.setAuthenticatedStatus(AuthenticatedStatus.FIRST_LOGIN_SUCCESS);
                    } else {
                        authentication.setAuthenticatedStatus(AuthenticatedStatus.PASSWORD_NOT_MATCH);
                    }
                } catch (Exception e) {
                    authentication.setAuthenticatedStatus(AuthenticatedStatus.UNKNOWN_EXCEPTION);
                    e.printStackTrace();
                }
            } else {
                authentication.setAuthenticatedStatus(AuthenticatedStatus.USERNAME_NOT_EXSIT);
            }
        } else {
            authentication.setAuthenticatedStatus(AuthenticatedStatus.NULL_USERNAME);
        }
        log.info("认证后得到的Authentication对象 = " + authentication.toString());
        return authentication;
    }

    private Map<String, Object> decodeBody(String body) {
        return Arrays.stream(body.split("&"))
                .map(s -> s.split("="))
                .collect(Collectors.toMap(arr -> arr[0], arr -> arr[1]));
    }

    private String encodeBody(Map<String, Object> map) {
        return map.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).collect(Collectors.joining("&"));
    }
}
复制代码

(4).根据Token完成认证的处理类

/**
 * @author 郭超
 * Date:2020-09-29 10:42
 * Description: 根据Token完成认证的处理类
 */
@Slf4j
@Component
public class TokenAuthenticationHandler {

    @Resource
    private JwtTokenUtil jwtTokenUtil;

    /**
     * 判断请求是否为访问静态资源的请求
     *
     * @param request request
     * @return 判断结果
     */
    public boolean isResourceRequest(ServerHttpRequest request) {
        String path = request.getPath().toString();
        log.info("path =========== " + request.getPath());
        String staticResourcePath = ".*/images/.*";
        boolean result = Pattern.matches(staticResourcePath, path);
        log.info("判断当前路径是否为静态资源的结果为: " + result);
        return result;
    }

    /**
     * 根据token执行认证
     *
     * @param request ServerHttpRequest
     * @return 认证结果
     */
    public Authentication authenticate(ServerHttpRequest request) {
        List<String> auth = request.getHeaders().get(HttpHeaders.AUTHORIZATION);
        Authentication authentication = new Authentication();
        if (auth != null && auth.size() > 0 && !StringUtils.isEmpty(auth.get(0))) {
            log.info("获取到的 headerToken = " + auth.get(0));
            String headerToken = auth.get(0);

            // postMan测试时,自动加入的前缀,要去掉。
            String token = headerToken.replace("Bearer", "").trim();

            // 先判断令牌是否过期
            boolean isExpired = jwtTokenUtil.isTokenExpired(token);
            if (isExpired) {
                // 过期刷新
                jwtTokenUtil.refreshToken(token);
            }
            // 通过令牌获取用户名称
            String username = jwtTokenUtil.getUsernameFromToken(token);
            log.info("从token令牌中获取到的username = " + username);
            if (username != null) {
                // 没过期则刷新令牌,重置有效期,然后放行
                jwtTokenUtil.refreshToken(token);
                authentication.setUserName(username);
                authentication.setAuthenticatedStatus(AuthenticatedStatus.AUTHENTICATION_SUCCESS);
                log.info("认证成功:Token认证成功!");
            } else {
                authentication.setAuthenticatedStatus(AuthenticatedStatus.INVALID_TOKEN);
                log.info("认证失败:无效Token,无法从中获取Token!");
            }
        } else {
            authentication.setAuthenticatedStatus(AuthenticatedStatus.NULL_TOKEN);
            log.info("认证失败:当前请求头没有Token信息!");
        }
        return authentication;
    }
}
复制代码

(5).用户鉴权处理类

/**
 * @author 郭超
 * Date:2020-09-29 10:45
 * Description: 用户鉴权处理类
 */
@Slf4j
@Component
public class DynamicVerification {

    @Resource
    private SysBackendApiService apiService;

    /**
     * 动态鉴权,根据用户名查询可访问API集合,然后与当前访问API进行匹配
     *
     * @param userName 当前登陆用户名
     * @param request  当前请求
     * @return 鉴权结果
     */
    public int check(String userName, ServerHttpRequest request) {
        log.info("动态权限校验 DynamicPermission");

        // 通过账号获取资源鉴权
        List<SysBackendApi> apiUrls = apiService.getApiUrlByUserName(userName);
        if (apiUrls != null) {
            AntPathMatcher antPathMatcher = new AntPathMatcher();

            // 当前访问路径
            RequestPath requestUri = request.getPath();
            log.info("动态权限校验 => 当前访问路径 = " + requestUri);

            // 提交类型
            HttpMethod urlMethod = request.getMethod();

            // 判断当前路径中是否在资源鉴权中
            boolean rs = apiUrls.stream().anyMatch(item -> {
                // 判断URL是否匹配
                boolean hashAntPath = antPathMatcher.match(item.getApiUrl(), String.valueOf(requestUri));

                // 判断请求方式是否和数据库中匹配(数据库存储:GET,POST,PUT,DELETE)
                String dbMethod = item.getApiMethod();

                // 处理null,万一数据库存null值
                dbMethod = (dbMethod == null) ? "" : dbMethod;
                int hasMethod = dbMethod.indexOf(String.valueOf(urlMethod));
                log.info("hashAntPath = " + hashAntPath);
                log.info("hasMethod = " + hasMethod);
                log.info("hashAntPath && hasMethod = " + (hashAntPath && hasMethod != -1));

                // 两者都成立,返回真,否则返回假
                boolean result = hashAntPath && (hasMethod != -1);
                if (result) {
                    log.info("result == " + true + "<==============URL匹配且用户具有访问权限,权限校验通过!============>");
                }
                return result;
            });
            if (rs) {
                return AuthorizeStatus.AUTHORIZE_SUCCESS;
            } else {
                return AuthorizeStatus.AUTHORIZE_INSUFFICIENT;
            }
        } else {
            return AuthorizeStatus.NULL_AUTHORIZATION;
        }
    }
}
复制代码

(6).鉴权成功处理类

/**
 * @author 郭超
 * Date:2020-09-29 11:00
 * Description: 鉴权成功处理类
 */
@Slf4j
@Component
public class AuthorizationSuccessHandler {

    @Resource
    private SysFrontendMenuService menuService;

    @Resource
    private JwtTokenUtil jwtTokenUtil;

    @Resource
    private RedisUtil redisUtil;

    /**
     * 根据用户名查询出用户详情,并转换为DataBuffer返回
     *
     * @param userName 用户名
     * @param response 响应
     * @return 用户详细信息流
     */
    public DataBuffer onAuthorizationSuccess(String userName, ServerHttpResponse response) {
        String userToken = (String) redisUtil.get(userName);
        if (userToken == null || "".equals(userToken.trim())) {
            // 如果token为空,则通过JWT创建一个新的token
            userToken = jwtTokenUtil.generateToken(userName);

            // 把新的token存储到Redis中
            redisUtil.set(userName, userToken);
            log.info("第一次登陆,token为空,创建并存储到Redis中的Token:" + userToken);
        } else {
            log.info("3.2 Redis中已有该用户, 根据用户名从Redis中取到的token = " + userToken);
        }
        
        // 设置body,封装数据
        List<SysFrontendMenu> menus = menuService.getMenusByUserName(userName);

        Map<String, Object> map = new HashMap<>(8);
        map.put("username", userName);
        map.put("menus", menus);
        map.put("token", userToken);
        log.info("4. 存入Map中返回到前台的数据内容为: " + map);
        ResponseResult result = ResponseResult.success(map);

        byte[] dataBytes;
        ObjectMapper mapper = new ObjectMapper();
        try {
            dataBytes = mapper.writeValueAsBytes(result);
        } catch (JsonProcessingException e) {
            dataBytes = ResponseResult.fail("授权异常").toString().getBytes();
        }

        return response.bufferFactory().wrap(dataBytes);
    }
}
复制代码

(7).认证失败异常定义类

/**
 * @author 郭超
 * Date:2020-09-29 11:09
 * Description: 异常定义类
 */
@Slf4j
@Component
public class AuthenticationExceptionHandler {

    /**
     * 根据认证时返回的状态值不同,返回不同的警告信息
     *
     * @param authenticatedStatus 认证异常状态
     * @param response            响应
     * @return 警告信息Buffer
     */
    public DataBuffer getWarningInfo(int authenticatedStatus, ServerHttpResponse response) {
        String exceptionMessage;
        switch (authenticatedStatus) {
            case AuthenticatedStatus.USERNAME_NOT_EXSIT:
                exceptionMessage = "认证失败: 用户不存在!";
                break;
            case AuthenticatedStatus.PASSWORD_NOT_MATCH:
                exceptionMessage = "认证失败: 密码不正确!";
                break;
            case AuthenticatedStatus.LOGIN_CONTENT_TYPE_NOT_JSON:
                exceptionMessage = "认证失败: 登陆请求必须为JSON方式!";
                break;
            case AuthenticatedStatus.NULL_USERNAME:
                exceptionMessage = "认证失败: 未能从JSON中获取用户名!";
                break;
            case AuthenticatedStatus.UNKNOWN_EXCEPTION:
                exceptionMessage = "认证失败: 登陆发生未知异常!";
                break;
            case AuthenticatedStatus.NULL_TOKEN:
                exceptionMessage = "认证失败: Token为空,请重新登陆!";
                break;
            case AuthenticatedStatus.INVALID_TOKEN:
                exceptionMessage = "认证失败: 无效Token,请尝试重新登陆!";
                break;
            case AuthenticatedStatus.TOKEN_EXPIRED:
                exceptionMessage = "认证失败: Token已过期,请重新登陆!";
                break;
            default:
                exceptionMessage = "";
                break;
        }
        return response.bufferFactory().wrap(exceptionMessage.getBytes());
    }
}
复制代码

(8).鉴权失败异常定义类

/**
 * @author 郭超
 * Date:2020-09-29 11:06
 * Description: 鉴权失败异常处理类
 */
@Slf4j
@Component
public class AuthorizationExceptionHandler {

    /**
     * 根据鉴权时返回的状态值不同,返回不同的警告信息
     *
     * @param authorizeStatus 鉴权异常状态
     * @param response        响应
     * @return 警告信息Buffer
     */
    public DataBuffer getWarningInfo(int authorizeStatus, ServerHttpResponse response) {
        String exceptionMessage;
        switch (authorizeStatus) {
            case AuthorizeStatus.AUTHORIZE_INSUFFICIENT:
                exceptionMessage = "访问失败,权限不足!";
                break;
            case AuthorizeStatus.NULL_AUTHORIZATION:
                exceptionMessage = "访问失败,权限为空!";
                break;
            default:
                exceptionMessage = "";
                break;
        }
        return response.bufferFactory().wrap(exceptionMessage.getBytes());
    }
}
复制代码

(9).认证结果状态码

/**
 * @author 郭超
 * Date:2020-09-29 11:33
 * Description: 认证结果状态码
 */
public interface AuthenticatedStatus {

    /**
     * 第一次登陆成功,特用于成功后存放用户详细信息
     */
    int FIRST_LOGIN_SUCCESS = 0;

    /**
     * 认证成功
     */
    int AUTHENTICATION_SUCCESS = 1;

    /**
     * 用户名不存在
     */
    int USERNAME_NOT_EXSIT = 2;

    /**
     * 密码不正确
     */
    int PASSWORD_NOT_MATCH = 3;

    /**
     * Login请求类型不为JSON
     */
    int LOGIN_CONTENT_TYPE_NOT_JSON = 4;

    /**
     * 未能从requestBody中获取用户名
     */
    int NULL_USERNAME = 5;

    /**
     * 未知认证异常
     */
    int UNKNOWN_EXCEPTION = 6;

    //========================以下为Token认证的异常==========================

    /**
     * 未能从header中获取Token
     */
    int NULL_TOKEN = 7;

    /**
     * 无效TOKEN,无法从中获取Token
     */
    int INVALID_TOKEN = 8;

    /**
     * Token已过期
     */
    int TOKEN_EXPIRED = 9;
}
复制代码

(10).授权结果状态码

/**
 * @author 郭超
 * Date:2020-09-29 11:25
 * Description: 授权状态码
 */
public interface AuthorizeStatus {

    /**
     * 授权成功
     */
    int AUTHORIZE_SUCCESS = 1;

    /**
     * 权限不足
     */
    int AUTHORIZE_INSUFFICIENT = 2;

    /**
     * 空权限
     */
    int NULL_AUTHORIZATION = 3;
}
复制代码

(11).自定义认证对象

/**
 * @author 郭超
 * Date:2020-09-29 11:13
 * Description: 自定义认证对象
 */
@Data
public class Authentication {

    /**
     * 认证状态
     */
    private int authenticatedStatus;

    /**
     * 正在认证的用户名
     */
    private String userName;
}
复制代码

五、数据库设计

项目前后端源码已上传至GitHub,传送门

如果这篇博客有帮助到你,希望能给笔者点个点赞和Star!

文章分类
后端
文章标签