1. 程式人生 > >【.NET Core項目實戰-統一認證平臺】第六章 網關篇-自定義客戶端授權

【.NET Core項目實戰-統一認證平臺】第六章 網關篇-自定義客戶端授權

localhost 寫入 warn seo 接口 後端 配置 rect when

【.NET Core項目實戰-統一認證平臺】開篇及目錄索引

上篇文章我們介紹了網關使用Redis進行緩存,並介紹了如何進行緩存實現,緩存信息清理接口的使用。本篇我們將介紹如何實現網關自定義客戶端授權,實現可以為不同的接入客戶端設置不同的訪問權限。

.netcore項目實戰交流群(637326624),有興趣的朋友可以在群裏交流討論。

一、功能描述

網關重點功能之一鑒權,需要實現對不同的客戶端進行授權訪問,禁止訪問未經授權的路由地址,且需要對無權訪問的請求,返回通用的格式。
比如網關有1-10個可用路由,客戶端A只能訪問1-5,客戶端B只能訪問6-10,這時我們就無法通過Ocelot配置授權來進行自定義認證,這塊就需要我們增加自定義的認證管道來實現功能,盡量不影響網關已有的功能。

下面我們就該功能如何實現展開講解,希望大家先理解下功能需求,然後在延伸到具體實現。

二、數據庫設計

我在第三章 網關篇-數據庫存儲配置(1)中講解了我們網關配置信息設計,本篇將在那個基礎上增加客戶端認證需要用到的表的相關設計,設計客戶端授權結構如下。其中客戶端使用的IdentityServer4客戶端表結構。
技術分享圖片

設計好概念模型後,我們生成物理模型,然後生成數據庫腳本。

設計思想為可以添加自定義的授權組,為每一個授權分配能夠訪問的路由,然後為網關授權的客戶端分配一個或多個授權組,每次客戶端請求時,如果路由設置了授權訪問,就校驗客戶端是否存在路由訪問權限,如果無訪問權限,直接返回401未授權提醒。

感覺是不是很簡單呢?有了這個自定義的客戶端認證,那麽我們後端服務可以專註於自己的業務邏輯而無需再過多了進行權限處理了。

三、功能實現

1、功能開啟配置

網關應該支持自定義客戶端授權中間件是否啟用,因為一些小型項目是不需要對每個客戶端進行單獨授權的,中型和大型項目才有可能遇到自定義配置情況,所以我們需要在配置文件增加配置選項。在AhphOcelotConfiguration.cs配置類中增加屬性,默認不開啟,而且需要知道客戶端標識名稱。

/// <summary>
/// 金焰的世界
/// 2018-11-15
/// 是否啟用客戶端授權,默認不開啟
/// </summary>
public bool ClientAuthorization { get; set; } = false;

/// <summary>
/// 金焰的世界
/// 2018-11-15
/// 客戶端授權緩存時間,默認30分鐘
/// </summary>
public int ClientAuthorizationCacheTime { get; set; } = 1800;
/// <summary>
/// 金焰的世界
/// 2018-11-15
/// 客戶端標識,默認 client_id
/// </summary>
public string ClientKey { get; set; } = "client_id";

那我們如何把自定義的授權增加到網關流程裏呢?這塊我們就需要訂制自己的授權中間件。

2、實現客戶端授權中間件

首先我們定義一個自定義授權中間件AhphAuthenticationMiddleware,需要繼承OcelotMiddleware,然後我們要實現Invoke方法,詳細代碼如下。

using Ctr.AhphOcelot.Configuration;
using Microsoft.AspNetCore.Http;
using Ocelot.Configuration;
using Ocelot.Logging;
using Ocelot.Middleware;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading.Tasks;

namespace Ctr.AhphOcelot.Authentication.Middleware
{
    /// <summary>
    /// 金焰的世界
    /// 2018-11-15
    /// 自定義授權中間件
    /// </summary>
    public class AhphAuthenticationMiddleware : OcelotMiddleware
    {
        private readonly OcelotRequestDelegate _next;
        private readonly AhphOcelotConfiguration _options;
        private readonly IAhphAuthenticationProcessor _ahphAuthenticationProcessor;
        public AhphAuthenticationMiddleware(OcelotRequestDelegate next,
            IOcelotLoggerFactory loggerFactory,
            IAhphAuthenticationProcessor ahphAuthenticationProcessor,
            AhphOcelotConfiguration options)
            : base(loggerFactory.CreateLogger<AhphAuthenticationMiddleware>())
        {
            _next = next;
            _ahphAuthenticationProcessor = ahphAuthenticationProcessor;
            _options = options;
        }

        public async Task Invoke(DownstreamContext context)
        {
            if (!context.IsError && context.HttpContext.Request.Method.ToUpper() != "OPTIONS" && IsAuthenticatedRoute(context.DownstreamReRoute))
            {
                if (!_options.ClientAuthorization)
                {
                    Logger.LogInformation($"未啟用客戶端授權管道");
                    await _next.Invoke(context);
                }
                else
                {
                    Logger.LogInformation($"{context.HttpContext.Request.Path} 是認證路由. {MiddlewareName} 開始校驗授權信息");
                    #region 提取客戶端ID
                    var clientId = "client_cjy";
                    var path = context.DownstreamReRoute.UpstreamPathTemplate.OriginalValue; //路由地址
                    var clientClaim = context.HttpContext.User.Claims.FirstOrDefault(p => p.Type == _options.ClientKey);
                    if (!string.IsNullOrEmpty(clientClaim?.Value))
                    {//從Claims中提取客戶端id
                        clientId = clientClaim?.Value;
                    }
                    #endregion
                    if (await _ahphAuthenticationProcessor.CheckClientAuthenticationAsync(clientId, path))
                    {
                        await _next.Invoke(context);
                    }
                    else
                    {//未授權直接返回錯誤
                        var errResult = new ErrorResult() { errcode=401, errmsg= "請求地址未授權" };
                        var message = errResult.ToJson();
                        context.HttpContext.Response.StatusCode = (int)HttpStatusCode.OK;
                        await context.HttpContext.Response.WriteAsync(message);
                        return;
                    }
                }
            }
            else
            {
                await _next.Invoke(context);
            }

        }
        private static bool IsAuthenticatedRoute(DownstreamReRoute reRoute)
        {
            return reRoute.IsAuthenticated;
        }
    }
}

有了這個中間件,那麽如何添加到Ocelot的管道裏呢?這裏就需要查看Ocelot源代碼了,看是如何實現管道調用的,OcelotMiddlewareExtensions實現管道部分如下,BuildOcelotPipeline裏具體的流程。其實我在之前的Ocelot源碼解讀裏也講解過原理了,奈斯,既然找到了,那麽我們就加入我們自定義的授權中間件即可。

public static async Task<IApplicationBuilder> UseOcelot(this IApplicationBuilder builder, OcelotPipelineConfiguration pipelineConfiguration)
{
    var configuration = await CreateConfiguration(builder);

    ConfigureDiagnosticListener(builder);

    return CreateOcelotPipeline(builder, pipelineConfiguration);
}

private static IApplicationBuilder CreateOcelotPipeline(IApplicationBuilder builder, OcelotPipelineConfiguration pipelineConfiguration)
{
    var pipelineBuilder = new OcelotPipelineBuilder(builder.ApplicationServices);

    pipelineBuilder.BuildOcelotPipeline(pipelineConfiguration);

    var firstDelegate = pipelineBuilder.Build();

    /*
            inject first delegate into first piece of asp.net middleware..maybe not like this
            then because we are updating the http context in ocelot it comes out correct for
            rest of asp.net..
            */

    builder.Properties["analysis.NextMiddlewareName"] = "TransitionToOcelotMiddleware";

    builder.Use(async (context, task) =>
                {
                    var downstreamContext = new DownstreamContext(context);
                    await firstDelegate.Invoke(downstreamContext);
                });

    return builder;
}

添加使用自定義授權中間件擴展AhphAuthenticationMiddlewareExtensions,代碼如下。

using Ocelot.Middleware.Pipeline;
using System;
using System.Collections.Generic;
using System.Text;

namespace Ctr.AhphOcelot.Authentication.Middleware
{
    /// <summary>
    /// 金焰的世界
    /// 2018-11-15
    /// 使用自定義授權中間件
    /// </summary>
    public static class AhphAuthenticationMiddlewareExtensions
    {
        public static IOcelotPipelineBuilder UseAhphAuthenticationMiddleware(this IOcelotPipelineBuilder builder)
        {
            return builder.UseMiddleware<AhphAuthenticationMiddleware>();
        }
    }
}

有了這個中間件擴展後,我們就在管道的合適地方加入我們自定義的中間件。我們添加我們自定義的管道擴展OcelotPipelineExtensions,然後把自定義授權中間件加入到認證之後。

using System;
using System.Threading.Tasks;
using Ctr.AhphOcelot.Authentication.Middleware;
using Ocelot.Authentication.Middleware;
using Ocelot.Authorisation.Middleware;
using Ocelot.Cache.Middleware;
using Ocelot.Claims.Middleware;
using Ocelot.DownstreamRouteFinder.Middleware;
using Ocelot.DownstreamUrlCreator.Middleware;
using Ocelot.Errors.Middleware;
using Ocelot.Headers.Middleware;
using Ocelot.LoadBalancer.Middleware;
using Ocelot.Middleware;
using Ocelot.Middleware.Pipeline;
using Ocelot.QueryStrings.Middleware;
using Ocelot.RateLimit.Middleware;
using Ocelot.Request.Middleware;
using Ocelot.Requester.Middleware;
using Ocelot.RequestId.Middleware;
using Ocelot.Responder.Middleware;
using Ocelot.WebSockets.Middleware;

namespace Ctr.AhphOcelot.Middleware
{
    /// <summary>
    /// 金焰的世界
    /// 2018-11-15
    /// 網關管道擴展
    /// </summary>
    public static class OcelotPipelineExtensions
    {
        public static OcelotRequestDelegate BuildAhphOcelotPipeline(this IOcelotPipelineBuilder builder,
            OcelotPipelineConfiguration pipelineConfiguration)
        {
            // This is registered to catch any global exceptions that are not handled
            // It also sets the Request Id if anything is set globally
            builder.UseExceptionHandlerMiddleware();

            // If the request is for websockets upgrade we fork into a different pipeline
            builder.MapWhen(context => context.HttpContext.WebSockets.IsWebSocketRequest,
                app =>
                {
                    app.UseDownstreamRouteFinderMiddleware();
                    app.UseDownstreamRequestInitialiser();
                    app.UseLoadBalancingMiddleware();
                    app.UseDownstreamUrlCreatorMiddleware();
                    app.UseWebSocketsProxyMiddleware();
                });

            // Allow the user to respond with absolutely anything they want.
            builder.UseIfNotNull(pipelineConfiguration.PreErrorResponderMiddleware);

            // This is registered first so it can catch any errors and issue an appropriate response
            builder.UseResponderMiddleware();

            // Then we get the downstream route information
            builder.UseDownstreamRouteFinderMiddleware();

            //Expand other branch pipes
            if (pipelineConfiguration.MapWhenOcelotPipeline != null)
            {
                foreach (var pipeline in pipelineConfiguration.MapWhenOcelotPipeline)
                {
                    builder.MapWhen(pipeline);
                }
            }

            // Now we have the ds route we can transform headers and stuff?
            builder.UseHttpHeadersTransformationMiddleware();

            // Initialises downstream request
            builder.UseDownstreamRequestInitialiser();

            // We check whether the request is ratelimit, and if there is no continue processing
            builder.UseRateLimiting();

            // This adds or updates the request id (initally we try and set this based on global config in the error handling middleware)
            // If anything was set at global level and we have a different setting at re route level the global stuff will be overwritten
            // This means you can get a scenario where you have a different request id from the first piece of middleware to the request id middleware.
            builder.UseRequestIdMiddleware();

            // Allow pre authentication logic. The idea being people might want to run something custom before what is built in.
            builder.UseIfNotNull(pipelineConfiguration.PreAuthenticationMiddleware);

            // Now we know where the client is going to go we can authenticate them.
            // We allow the ocelot middleware to be overriden by whatever the
            // user wants
            if (pipelineConfiguration.AuthenticationMiddleware == null)
            {
                builder.UseAuthenticationMiddleware();
            }
            else
            {
                builder.Use(pipelineConfiguration.AuthenticationMiddleware);
            }

            //添加自定義授權中間 2018-11-15 金焰的世界
            builder.UseAhphAuthenticationMiddleware();

            // Allow pre authorisation logic. The idea being people might want to run something custom before what is built in.
            builder.UseIfNotNull(pipelineConfiguration.PreAuthorisationMiddleware);

            // Now we have authenticated and done any claims transformation we 
            // can authorise the request
            // We allow the ocelot middleware to be overriden by whatever the
            // user wants
            if (pipelineConfiguration.AuthorisationMiddleware == null)
            {
                builder.UseAuthorisationMiddleware();
            }
            else
            {
                builder.Use(pipelineConfiguration.AuthorisationMiddleware);
            }

            // Allow the user to implement their own query string manipulation logic
            builder.UseIfNotNull(pipelineConfiguration.PreQueryStringBuilderMiddleware);

            // Get the load balancer for this request
            builder.UseLoadBalancingMiddleware();

            // This takes the downstream route we retrieved earlier and replaces any placeholders with the variables that should be used
            builder.UseDownstreamUrlCreatorMiddleware();

            // Not sure if this is the best place for this but we use the downstream url 
            // as the basis for our cache key.
            builder.UseOutputCacheMiddleware();

            //We fire off the request and set the response on the scoped data repo
            builder.UseHttpRequesterMiddleware();

            return builder.Build();
        }

        private static void UseIfNotNull(this IOcelotPipelineBuilder builder,
            Func<DownstreamContext, Func<Task>, Task> middleware)
        {
            if (middleware != null)
            {
                builder.Use(middleware);
            }
        }
    }
}

有了這個自定義的管道擴展後,我們需要應用到網關啟動裏,修改我們創建管道的方法如下。

private static IApplicationBuilder CreateOcelotPipeline(IApplicationBuilder builder, OcelotPipelineConfiguration pipelineConfiguration)
{
    var pipelineBuilder = new OcelotPipelineBuilder(builder.ApplicationServices);

    //pipelineBuilder.BuildOcelotPipeline(pipelineConfiguration);
    //使用自定義管道擴展 2018-11-15 金焰的世界
    pipelineBuilder.BuildAhphOcelotPipeline(pipelineConfiguration);

    var firstDelegate = pipelineBuilder.Build();

    /*
            inject first delegate into first piece of asp.net middleware..maybe not like this
            then because we are updating the http context in ocelot it comes out correct for
            rest of asp.net..
            */

    builder.Properties["analysis.NextMiddlewareName"] = "TransitionToOcelotMiddleware";

    builder.Use(async (context, task) =>
                {
                    var downstreamContext = new DownstreamContext(context);
                    await firstDelegate.Invoke(downstreamContext);
                });

    return builder;
}

現在我們完成了網關的擴展和應用,但是是否註意到了,我們的網關接口還未實現呢?什麽接口呢?

IAhphAuthenticationProcessor這個接口雖然定義了,但是一直未實現,現在開始我們要實現下這個接口,我們回看下我們使用這個接口的什麽方法,就是檢查客戶端是否有訪問路由的權限。

3、結合數據庫實現校驗及緩存

每次請求都需要校驗客戶端是否授權,如果不緩存此熱點數據,那麽對網關開銷很大,所以我們需要增加緩存。

新建AhphAuthenticationProcessor類來實現認證接口,代碼如下。

using Ctr.AhphOcelot.Configuration;
using Ocelot.Cache;
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;

namespace Ctr.AhphOcelot.Authentication
{
    /// <summary>
    /// 金焰的世界
    /// 2018-11-15
    /// 實現自定義授權處理器邏輯
    /// </summary>
    public class AhphAuthenticationProcessor : IAhphAuthenticationProcessor
    {
        private readonly IClientAuthenticationRepository _clientAuthenticationRepository;
        private readonly AhphOcelotConfiguration _options;
        private readonly IOcelotCache<ClientRoleModel> _ocelotCache;
        public AhphAuthenticationProcessor(IClientAuthenticationRepository clientAuthenticationRepository, AhphOcelotConfiguration options, IOcelotCache<ClientRoleModel> ocelotCache)
        {
            _clientAuthenticationRepository = clientAuthenticationRepository;
            _options = options;
            _ocelotCache = ocelotCache;
        }
        /// <summary>
        /// 校驗當前的請求地址客戶端是否有權限訪問
        /// </summary>
        /// <param name="clientid">客戶端ID</param>
        /// <param name="path">請求地址</param>
        /// <returns></returns>
        public async Task<bool> CheckClientAuthenticationAsync(string clientid, string path)
        {
            var enablePrefix = _options.RedisKeyPrefix + "ClientAuthentication";
            var key = AhphOcelotHelper.ComputeCounterKey(enablePrefix, clientid, "", path);
            var cacheResult = _ocelotCache.Get(key, enablePrefix);
            if (cacheResult!=null)
            {//提取緩存數據
                return cacheResult.Role;
            }
            else
            {//重新獲取認證信息
                var result = await _clientAuthenticationRepository.ClientAuthenticationAsync(clientid, path);
                  //添加到緩存裏
                  _ocelotCache.Add(key, new ClientRoleModel() { CacheTime = DateTime.Now,Role=result }, TimeSpan.FromMinutes(_options.ClientAuthorizationCacheTime), enablePrefix);
                return result;
            }
        }
    }
}

代碼很簡單,就是從緩存中查找看是否有數據,如果存在直接返回,如果不存在,就從倉儲中提取訪問權限,然後寫入緩存,寫入緩存的時間可由配置文件寫入,默認為30分鐘,可自行根據業務需要修改。

現在我們還需要解決2個問題,這個中間件才能正常運行,第一IClientAuthenticationRepository接口未實現和註入;第二IOcelotCache<ClientRoleModel>未註入,那我們接下來實現這兩塊,然後就可以測試我們第一個中間件啦。

新建SqlServerClientAuthenticationRepository類,來實現IClientAuthenticationRepository接口,實現代碼如下。

using Ctr.AhphOcelot.Authentication;
using Ctr.AhphOcelot.Configuration;
using System;
using System.Collections.Generic;
using System.Data.SqlClient;
using System.Text;
using System.Threading.Tasks;
using Dapper;
namespace Ctr.AhphOcelot.DataBase.SqlServer
{
    /// <summary>
    /// 金焰的世界
    /// 2018-11-16
    /// 使用sqlserver實現客戶端授權倉儲
    /// </summary>
    public class SqlServerClientAuthenticationRepository : IClientAuthenticationRepository
    {
        private readonly AhphOcelotConfiguration _option;
        public SqlServerClientAuthenticationRepository(AhphOcelotConfiguration option)
        {
            _option = option;
        }
        /// <summary>
        /// 校驗獲取客戶端是否有訪問權限
        /// </summary>
        /// <param name="clientid">客戶端ID</param>
        /// <param name="path">請求路由</param>
        /// <returns></returns>
        public async Task<bool> ClientAuthenticationAsync(string clientid, string path)
        {
            using (var connection = new SqlConnection(_option.DbConnectionStrings))
            {
                string sql = @"SELECT COUNT(1) FROM  AhphClients T1 INNER JOIN AhphClientGroup T2 ON T1.Id=T2.Id INNER JOIN AhphAuthGroup T3 ON T2.GroupId = T3.GroupId INNER JOIN AhphReRouteGroupAuth T4 ON T3.GroupId = T4.GroupId INNER JOIN AhphReRoute T5 ON T4.ReRouteId = T5.ReRouteId WHERE Enabled = 1 AND ClientId = @ClientId AND T5.InfoStatus = 1 AND UpstreamPathTemplate = @Path";
                var result= await connection.QueryFirstOrDefaultAsync<int>(sql, new { ClientId = clientid, Path = path });
                return result > 0 ? true : false;
            }
        }
    }
}

現在需要註入下實現,這塊應該都知道在哪裏加入了吧?沒錯ServiceCollectionExtensions擴展又用到啦,現在梳理下流程感覺是不是很清晰呢?

builder.Services.AddSingleton<IClientAuthenticationRepository, SqlServerClientAuthenticationRepository>();

builder.Services.AddSingleton<IAhphAuthenticationProcessor, AhphAuthenticationProcessor>();

再添加緩存的註入實現,到此我們的第一個中間件全部添加完畢了,現在可以開始測試我們的中間件啦。

builder.Services.AddSingleton<IOcelotCache<ClientRoleModel>, InRedisCache<ClientRoleModel>>();

4、測試授權中間件

我們先在數據庫插入客戶端授權腳本,腳本如下。

--插入測試客戶端
INSERT INTO AhphClients(ClientId,ClientName) VALUES(‘client1‘,‘測試客戶端1‘)
INSERT INTO AhphClients(ClientId,ClientName) VALUES(‘client2‘,‘測試客戶端2‘)
--插入測試授權組
INSERT INTO AhphAuthGroup VALUES(‘授權組1‘,‘只能訪問/cjy/values路由‘,1);
INSERT INTO AhphAuthGroup VALUES(‘授權組2‘,‘能訪問所有路由‘,1);

--插入測試組權限
INSERT INTO AhphReRouteGroupAuth VALUES(1,1);

INSERT INTO AhphReRouteGroupAuth VALUES(2,1);
INSERT INTO AhphReRouteGroupAuth VALUES(2,2);

--插入客戶端授權
INSERT INTO AhphClientGroup VALUES(1,1);
INSERT INTO AhphClientGroup VALUES(2,2);

--設置測試路由只有授權才能訪問
UPDATE AhphReRoute SET AuthenticationOptions=‘{"AuthenticationProviderKey": "TestKey"}‘ WHERE ReRouteId IN(1,2);

這塊設置了客戶端2可以訪問路由/cjy/values,客戶端1可以訪問路由/cjy/values 和 /ctr/values/{id},開始使用PostMan來測試這個中間件看是否跟我設置的一毛一樣,各種dotnet run啟動吧。啟動前別忘了在我們網關配置文件裏,設置啟動客戶端授權 option.ClientAuthorization = true;,是不是很簡單呢?

為了測試授權效果,我們需要把網關項目增加認證,詳細看代碼,裏面就是定義了授權認證,啟動我們默認的認證地址。

var authenticationProviderKey = "TestKey";
Action<IdentityServerAuthenticationOptions> gatewayoptions = o =>
{
o.Authority = "http://localhost:6611";
o.ApiName = "gateway";
o.RequireHttpsMetadata = false;
};

services.AddAuthentication()
.AddIdentityServerAuthentication(authenticationProviderKey, gatewayoptions);

測試結果如下,達到我們預期目的。
技術分享圖片

技術分享圖片
技術分享圖片


終於完成了我們的自定義客戶端授權啦,此處應該掌聲不斷。


5、增加mysql支持

看過我前面的文章應該知道,支持mysql太簡單啦,直接重寫IClientAuthenticationRepository實現,然後註入到UseMySql裏,問題就解決啦。感覺是不是不可思議,這就是.netcore的魅力,簡單到我感覺到我再貼代碼就是侮辱智商一樣。

6、重構認證失敗輸出,保持與Ocelot一致風格

前面我們定義了未授權使用自定義的ClientRoleModel輸出,最後發現這樣太不優雅啦,我們需要簡單重構下,來保持與Ocelot默認管道一致風格,修改代碼如下。

//var errResult = new ErrorResult() { errcode=401, errmsg= "請求地址未授權" };
//var message = errResult.ToJson();
//context.HttpContext.Response.StatusCode = (int)HttpStatusCode.OK;
//await context.HttpContext.Response.WriteAsync(message);
//return;
var error = new UnauthenticatedError($"請求認證路由 {context.HttpContext.Request.Path}客戶端未授權");
Logger.LogWarning($"路由地址 {context.HttpContext.Request.Path} 自定義認證管道校驗失敗. {error}");
SetPipelineError(context, error);

再測試下未授權,返回狀態為401,強迫癥患者表示舒服多了。

四、總結及預告

本篇我們講解的是網關如何實現自定義客戶端授權功能,從設計到實現一步一步詳細講解,雖然只用一篇就寫完了,但是涉及的知識點還是非常多的,希望大家認真理解實現的思想,看我是如何從規劃到實現的,為了更好的幫助大家理解,從本篇開始,我的源代碼都是一個星期以後再開源,大家可以根據博客內容自己手動實現下,有利於消化,如果在操作中遇到什麽問題,可以加.NET Core項目實戰交流群(QQ群號:637326624)咨詢作者。

下一篇開始講解自定義客戶端限流,在學習下篇前可以自己先了解下限流相關內容,然後自己試著實現看看,帶著問題學習可能事半功倍哦。

【.NET Core項目實戰-統一認證平臺】第六章 網關篇-自定義客戶端授權