作者: paul

.NetCore的ApiGateway +Consul的本機實驗

.NetCore的ApiGateway +Consul的本機實驗

一直以來都知道.netcore可以開始跨平台運作在linux上,因此運作在 docker的container中也是可行的

不過最近因為要研究apigateway,需要模擬infra的架構 ,因此需要在本機環境buildup起來多台主機的模式

沒想到入坑玩了一下,就發現,其實眉眉角角也不少,但來來回回操作後,其實是相對會愈來愈熟悉

首頁,我solution的構成包含了以下幾層 (apigateway還可做loadbalance,再加個nginx就可以, 不是這次的重點)

1.Redis(多台 instance作counter的cache)
2.WebApp(.net core)
3.ApiGateway(.net core + ocelot.net sdk)
4.Consul(service discovery service 作ha)

這次主要研究的機制為 3跟4之間的協作機制
基本上,有了3,預設走configuration就可以手動配置 ,但若要能做到動態ha的話,就必須加入service discovery的機制,剛好ocelot框架有支援 consul(還有其他套,但預設consul)

docker-compose

為了方便 重建,這次採用 docker-compose的yaml來維護(以下情境的container -name 都一般化了,只需要區分角色就好)

version: "3"

services:
  nginx:
    build: ./nginx
    container_name: core-app.local
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      - core-app-gateway1
    # network_mode: "host"
    networks:
      - backend

  consul1:
    image: consul
    container_name: node1
    command: agent -server -bootstrap-expect=2 -node=node1 -bind=0.0.0.0 -client=0.0.0.0 -datacenter=dc1
    # network_mode: "host"
    networks:
      - backend

  consul2:
    image: consul
    container_name: node2
    command: agent -server -retry-join=node1 -node=node2 -bind=0.0.0.0 -client=0.0.0.0 -datacenter=dc1
    depends_on:
      - consul1
    # network_mode: "host"
    networks:
      - backend

  consul3:
    image: consul
    hostname: discover-center
    container_name: node3
    command: agent -retry-join=node1 -node=node3 -bind=0.0.0.0 -client=0.0.0.0 -datacenter=dc1 -ui
    ports:
      - 8500:8500
    depends_on:
      - consul1
      - consul2
    #network_mode: "host"
    networks:
      - backend

  redis:
    container_name: myredis
    image: redis:6.0.6
    restart: always
    ports:
      - 6379:6379
    privileged: true
    command: redis-server /etc/redis/redis.conf --appendonly yes
    volumes:
      - $PWD/data:/data
      - $PWD/conf/redis.conf:/etc/redis/redis.conf
    networks:
      - backend

  core-app-discover-a1:
    build: ./dotnetapp/dotnetapp-discover
    hostname: webapp1
    environment:
      - HOSTNAME=http://0.0.0.0:40001
      - SERVER_HOSTNAME=http://192.168.1.115
      - SERVER_PORT=8500
      - CLIENT_HOSTNAME=http://core-app-discover-a1
      - CLIENT_PORT=40001
      - SERVICE_NAME=core-app-discover-a
      - RedisConnection=redis:6379,password=12345678,allowAdmin=true
    depends_on:
      - redis
      - consul3
    ports:
      - "40001"
    networks:
      - backend

  core-app-discover-a2:
    build: ./dotnetapp/dotnetapp-discover
    hostname: webapp2
    environment:
      - HOSTNAME=http://0.0.0.0:40001
      - SERVER_HOSTNAME=http://192.168.1.115
      - SERVER_PORT=8500
      - CLIENT_HOSTNAME=http://core-app-discover-a2
      - CLIENT_PORT=40001
      - SERVICE_NAME=core-app-discover-a
      - RedisConnection=redis:6379,password=12345678,allowAdmin=true
    depends_on:
      - redis
      - consul3
    ports:
      - "40001"
    networks:
      - backend

  core-app-discover-a3:
    build: ./dotnetapp/dotnetapp-discover
    hostname: webapp3
    environment:
      - HOSTNAME=http://0.0.0.0:40001
      - SERVER_HOSTNAME=http://192.168.1.115
      - SERVER_PORT=8500
      - CLIENT_HOSTNAME=http://core-app-discover-a3
      - CLIENT_PORT=40001
      - SERVICE_NAME=core-app-discover-a
      - RedisConnection=redis:6379,password=12345678,allowAdmin=true
    depends_on:
      - redis
      - consul3
    ports:
      - "40001"
    networks:
      - backend

  core-app-discover-b:
    build: ./dotnetapp/dotnetapp-discover
    hostname: webapp-b
    environment:
      - HOSTNAME=http://0.0.0.0:50001
      - SERVER_HOSTNAME=http://192.168.1.115
      - SERVER_PORT=8500
      - CLIENT_HOSTNAME=http://core-app-discover-b
      - CLIENT_PORT=50001
      - SERVICE_NAME=core-app-discover-b
      - RedisConnection=192.168.1.115:6379,password=xxxxxxx
    depends_on:
      - redis
      - consul3
    ports:
      - "50001:50001"
    networks:
      - backend

  core-app-discover-c:
    build: ./dotnetapp/dotnetapp-discover
    hostname: webapp-c
    environment:
      - HOSTNAME=http://0.0.0.0:50002
      - SERVER_HOSTNAME=http://192.168.1.115
      - SERVER_PORT=8500
      - CLIENT_HOSTNAME=http://core-app-discover-c
      - CLIENT_PORT=50002
      - SERVICE_NAME=core-app-discover-c
      - RedisConnection=192.168.1.115:6379,password=xxxxxxx
    depends_on:
      - redis
      - consul3
    ports:
      - "50002:50002"
    networks:
      - backend

  core-app-gateway1:
    build: ./dotnetapp/dotnetapp-gateway
    hostname: gateway1
    environment:
      - HOSTNAME=http://0.0.0.0:20001
      - SERVER_HOSTNAME=http://192.168.1.115
      - SERVER_PORT=8500
      - CLIENT_HOSTNAME=http://192.168.1.115
      - CLIENT_PORT=20001
      - SERVICE_NAME=core-app-gateway1
    depends_on:
      - consul3
    ports:
      - 20001:20001
    networks:
      - backend

networks:
  backend:
    driver: bridge

以上yaml就會建立一個network讓 container之前可以用 hostname來通訊。

這次研究的過程,會發現一件事情就是若要綁service discovery,那麼每個末端的webapp都需要跟register center做註冊,並提供一個health接口。但是若要做到auto scale,那依賴docker-compose所做的scale就會綁定動態的host_name(有序號),因此在此不建議 用scale直接長n台容器,而是先用多個端點來建置(如我的a1、a2、a3三台)
這樣就可以指定 hostname跟port給 webapp,而不會被scaling的機制而無法抓到動態的port跟名稱

.netcore application的Dockerfile

以下是webapp的dockerfile (gateway的打包方式差不多,就不貼了)

FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine AS build
WORKDIR /src
COPY ["dotnetapp-discover.csproj", "dotnetapp-discover/"]
RUN dotnet restore "dotnetapp-discover/dotnetapp-discover.csproj"
COPY . ./dotnetapp-discover
WORKDIR "/src/dotnetapp-discover"
RUN dotnet build "dotnetapp-discover.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "dotnetapp-discover.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "dotnetapp-discover.dll"]

為了計數器功能與多台sharing cache,因此這次 還 模擬了一下連redis
而redis的connection string 可以透過 environment variable 注入進來(docker-compose可以另外用.env的檔案來管理不同環境,有興趣可以看 官方文件),而在.netcore的c#程式中,要抓到docker的參數的話可以使用GetEnvironmentVariable來取得設定值

    public static IServiceCollection AddCacheService(this IServiceCollection services)
    {
        var redisConnection = Environment.GetEnvironmentVariable("RedisConnection");
#if DEBUG
        redisConnection = "192.168.1.115:6379,password=xxxxxx,allowAdmin=true";
#endif
        
        services.AddSingleton<IConnectionMultiplexer>(
            ConnectionMultiplexer.Connect(redisConnection)
        );
        return services;
    }

註∶為了反覆測試,才加上allow admin,不然這個開關是很危險的,可以直接flush掉你的所有快取(清空)

Redis記錄的Log型態

註冊Service Discovery

ApiGateway跟WebApp我都有跟Consul註冊Service Discovery
因此這次主要的寫法也是follow consul client sdk的套件,Consul的註冊管理上,同一個Name的會被Grouping起來,ID要給不一樣的,若ServiceName都不一樣,就算是一樣的程式,還是會被視為不同節點,這樣會無法作HA的判斷

static void RegisterConsul(string serviceName, string serverHostName , string serverPort, string clientHostName , string clientPort, IHostApplicationLifetime appLifeTime)
{
    using var consulClient = new Consul.ConsulClient(p => 
        { p.Address = new Uri($"{serverHostName}:{serverPort}"); });
    var registration = new AgentServiceRegistration()
    {
        Check = new AgentServiceCheck()
        {
            Interval = TimeSpan.FromSeconds(10),
            HTTP = $"{clientHostName}{(clientPort == string.Empty ? "" : $":{clientPort}")}/health",
            Timeout = TimeSpan.FromSeconds(5),
            Method = "GET",
            Name = "check",
        },
        ID = Guid.NewGuid().ToString(),
        Name = serviceName,
        Address = Dns.GetHostName(),
        Port = int.Parse(clientPort),
        
    };

    try
    {
        consulClient.Agent.ServiceRegister(registration).Wait();
    }
    catch (Exception e)
    {
        Console.WriteLine(e);
    }

    //web服務停止時,向Consul解註冊
    consulClient.Agent.ServiceDeregister(registration.ID).Wait();
}

以上function可以在program.cs的startup時期先進行註冊,而這個function所有的參數一樣可以來自於 docker-compose
範例∶

自動生成HealthCheck端點

在webapp的startup可以使用以下語法就長出health接口

builder.Services.AddHealthChecks();

app.MapHealthChecks("/health",  new HealthCheckOptions()
{
    ResultStatusCodes =
    {
        [Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Healthy] = StatusCodes.Status200OK,
        [Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Degraded] = StatusCodes.Status200OK,
        [Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable
    }
});

但還是要視服務加上MapHealthChecks的ResultStatus的設定,以consul來說,就是回200就好了,若沒有加上這個設定,其實health的回應並不是200,所以會被視為失敗

正常情況打 health的接口

Ocelot的設定檔

最後就是ocelot的json設定,傳統的Ocelot自已就有Load-Balance機制,因此若有多台Downstream的服務,本來就可以用以下設定去管理服務

不過這樣做的缺點就是若隨時要增減 容器/主機時,都要再去重新配置設定檔。造成主機可能的reset與維運上的難度,因此透過Service Discovery的好處就浮上了臺面!以下的配置,只需要注意,不用定義DownStream的Host,只要給一個ServiceName,這個ServiceName要跟WebApp在Startup的時候註冊的一樣。並在最後GloablConfiguration有註冊Service DiscoveryProvider的設定,就可以打通ApiGateway跟Service Discovery之間的便利機制。

{
  "Routes": [
    {
      "DownstreamPathTemplate": "/test",
      "DownstreamScheme": "http",
      "UpstreamPathTemplate": "/newapi/get",
      "UpstreamHttpMethod": [
        "Get"
      ],
      "ServiceName": "core-app-discover-a",
      "LoadBalancerOptions": {
        "Type": "LeastConnection"
      }
    },
    {
      "DownstreamPathTemplate": "/test/list",
      "DownstreamScheme": "http",
      "UpstreamPathTemplate": "/newapi/list",
      "UpstreamHttpMethod": [
        "Get"
      ],
      "ServiceName": "core-app-discover-a",
      "LoadBalancerOptions": {
        "Type": "LeastConnection"
      }
    },
    {
      "DownstreamPathTemplate": "/test",
      "DownstreamScheme": "http",
      "DownstreamHttpMethod": "DELETE",
      "UpstreamPathTemplate": "/newapi/reset",
      "UpstreamHttpMethod": [
        "Get"
      ],
      "ServiceName": "core-app-discover-a"
    }, 
    {
      "Key": "api1",
      "DownstreamPathTemplate": "/test/list",
      "DownstreamScheme": "http",
      "UpstreamPathTemplate": "/newapi/list1",
      "UpstreamHttpMethod": [
        "Get"
      ],
      "ServiceName": "core-app-discover-a"
    },
    {
      "Key": "api2",
      "DownstreamPathTemplate": "/test/list",
      "DownstreamScheme": "http",
      "UpstreamPathTemplate": "/newapi/list2",
      "UpstreamHttpMethod": [
        "Get"
      ],
      "ServiceName": "core-app-discover-a"
    }
  ],
  "Aggregates": [
    {
      "RouteKeys": [
        "api1",
        "api2"
      ],
      "UpstreamPathTemplate": "/newapi/listall"
    }
  ],
  "GlobalConfiguration": {
    "BaseUrl": "http://0.0.0.0:20001",
    "ServiceDiscoveryProvider": {
      "Scheme": "http",
      "Host": "192.168.1.115",
      "Port": 8500,
      "Type": "PollConsul",
      "PollingInterval": 100
    },
    "QoSOptions": {
      "ExceptionsAllowedBeforeBreaking": 0,
      "DurationOfBreak": 0,
      "TimeoutValue": 0
    },
    "LoadBalancerOptions": {
      "Type": "LeastConnection",
      "Key": null,
      "Expiry": 0
    },
    "DownstreamScheme": "http",
    "HttpHandlerOptions": {
      "AllowAutoRedirect": false,
      "UseCookieContainer": false,
      "UseTracing": false
    }
  }
}

上述的ApiGateway一樣用DockerFile把專案包成Image

整合測試

docker-compose up -d –force-recreate

會看到一次起來這麼多服務

http://localhost:20001/newapi/get(透過api gateway打到downstream的service)

這邊多請求幾次會發現machineName一直在webapp1~3跳來跑去,採用load-balance的機制,ocelot會採用你設定的策略進行請求分派

ApiGateway有Aggregation的功能,就是可以把多個Api請求Combine在一起,讓請求變成1次,減少頻寬的消耗,這邊我也做了配置,來試看看吧

出於Ocelot.json的設定檔段落:

透過api gateway的組合讓 多個 Api可以一次 回傳

最後,打了很多次請求後,來總覽一下所有的請求摘要(程式是自已另外撈Redis的Request記錄來統計)

http://localhost:20001/newapi/list(透過api gateway打到down stream的service)

嘗試Docker Stop某一台 Discover-a1的服務,Consul DashBoard馬上就看的到紅點了!

這個時候再去打 之前load-balance的端點http://localhost:20001/newapi/get,會發現machineName會避開stop掉的服務只剩下 webapp1跟webapp3在輪循,因此做到了後端服務品質的保護

小插曲

ID不小心用成了Guid.New,所以會導致重新啟動就又拿到新的ID,造成了多一個Instance。但舊的也活了,因為他們的HealthCheck是一樣的

其實ApiGateway的議題,從Infra的角度上,可以玩的東西還有很多。

像是他也支援RateLimiting, QualityOfService(Polly) , 服務聚合(Aggregation)等機制,都可以從他們的官方文件看到設定方式,算是配置上不算太複雜。

不過 Ocelot在.net這邊發展一陣子後,近一年好像停更了!取而代之的好像是其他語言寫的更強的Apigateway: Envoy。

Envoy資源
https://www.envoyproxy.io/docs/envoy/latest/intro/what_is_envoy
https://www.netfos.com.tw/Projects/netfos/pages/solution/plan/20210111_plan.html

若只是要輕量級的,或是想參考其架構設計的,倒是可以翻github出來看看~

Github∶ https://github.com/ThreeMammals/Ocelot

這次Lab程式碼

若要拿去玩的,請把Ip, Password自行調整一下

一些文章的參考

https://ocelot.readthedocs.io/en/latest/features/servicediscovery.html?highlight=ServiceDiscoveryProvider

https://ocelot.readthedocs.io/en/latest/features/servicediscovery.html?highlight=ServiceDiscoveryProvider#

https://www.uj5u.com/net/232249.html

.NET Core WebApi 加入CORS (.net 7.0 up)與allow預檢要求

.NET Core WebApi 加入CORS (.net 7.0 up)與allow預檢要求

有時候我們希望測試Client端呼叫Server的Api,但若domain不相同或是沒有合理的開放的話,Browser會直接進行安全性的封鎖

<html>
    <head>
    </head>
    <body>
        <script language="javascript">
            window.onload = function(e){
                // Make a request for a user with a given ID
                axios.get('https://localhost:5001/api/test/history')
                .then(function (response) {
                    // handle success
                    console.log(response.status);
                })
                .catch(function (error) {
                    console.log(error.response.status);
                    // handle error
                    console.log(error);
                })
                .then(function () {
                    // always executed
                });
            }
        </script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.27.2/axios.min.js" integrity="sha512-odNmoc1XJy5x1TMVMdC7EMs3IVdItLPlCeL5vSUPN2llYKMJ2eByTTAIiiuqLg+GdNr9hF6z81p27DArRFKT7A==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    </body>
</html>

若我們真的去打了一個404不存在的網頁,但實際上Server有接到請求,所以回的了404,但是js端卻會因為CORS的Policy導致報錯,這個錯誤的例外,會導致js拿到ERR_NETWORK而且也無其他訊息或完整的異常資訊

.Net Core Startup.cs中,找到以下段落
//public void ConfigureServices(IServiceCollection services)內
services.AddCors(options => { options. AddPolicy(name: "MyAllowSpecificOrigins", policy => { policy. AllowAnyOrigin() . AllowAnyHeader(); }); });
//public void Configure(IApplicationBuilder app, IWebHostEnvironment env)內

app.UseRouting();
            
app.UseCors("MyAllowSpecificOrigins");

app.UseSentryTracing(); // 要啟用 Sentry 上的 Performance Monitoring 需加這行

app.UseAuthentication();

備註:UseCors的呼叫必須放在UseRouting之後 ,但在UseAuthorization之前,若有具名的policy,就也要具名UseCors加進來,若有具名但沒有UseCors(“xxxxxx”)的話,仍視為無效

所以成功加進去後,axios套件也是可以抓到

2024/01/30更新

大部分的CORS設定若是在Azure的app service仍可以透過CORS的Portal設定去處理掉

不過若是在本機的環境,有遇到下面的錯誤訊息(host有改過所以不是localhost)

Access to XMLHttpRequest at 'https://dev.azurewebsites.net:5001/api/Auth/app-login' from origin 'https://dev.azurewebsites.net:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.

代表被preflight預檢請求擋掉了

這個時候在Api的Program.cs

var app = builder.Build(); 
//之後加上
app.UseCors(options =>
    options
        .WithOrigins("https://dev.azurewebsites.net:3000", "*") 
         //note Allow Any Origin或直接"*"不會過 
        .AllowAnyHeader()
        .AllowAnyMethod()
        .AllowCredentials());
//之前加上
app.UseHttpsRedirection();

MSDN Reference∶

https://learn.microsoft.com/zh-tw/aspnet/core/security/cors?view=aspnetcore-8.0

SQL Azure 垂直Partitioned DB Query T-SQL範例

SQL Azure 垂直Partitioned DB Query T-SQL範例

SQL Azure的連線字串雖然允許你在SQL工具上切換資料庫,但是其實要下Cross Database的T-SQL是沒有辦法允許的
因此我們需要利用Elastic database query + External Data Source來協助我們

以下假設我們有主資料庫 (A),與延伸資料庫(B)

在B的資料庫中
我們先建立以下的CREDENTIAL資訊

CREATE MASTER KEY ENCRYPTION BY PASSWORD = '{A資料庫的密碼}';
CREATE DATABASE SCOPED CREDENTIAL ds_pool WITH IDENTITY = '{A資料庫的帳號}',SECRET = '{A資料庫的密碼}';
--以下語法提供方便重新建立/測試
--drop MASTER KEY --drop DATABASE SCOPED CREDENTIAL ds_pool

ds_pool不需要用字串包起來,你可以命名成你可理解的名稱,下面我們會再用到
執行成功的話,接著執行 以下建立Remote DataSource的物件

CREATE EXTERNAL DATA SOURCE REMOTE_DS WITH ( 
   TYPE=RDBMS, 
   LOCATION='{A資料庫的Localtion}stage.database.windows.net', 
   DATABASE_NAME='{A資料庫的名稱}', 
   CREDENTIAL= ds_pool );

--以下語法提供方便重新建立/測試
--Drop EXTERNAL DATA SOURCE REMOTE_DS

這邊我們要給定一個REMOTEDS到DataSouce,並指定他的CREDENTIAL與資料庫名稱、類型與 Location,從MSDN上可以看到Location似乎可以支援檔案格式,可以填寫blob的位置,這個有機會再研究

CREATE EXTERNAL TABLE [dbo].[{A資料庫裡面的Table Or View都可以}] 
 ( [A資料庫的某一個table的欄位1] [欄位型別1], 
   [A資料庫的某一個table的欄位2] [欄位型別2], 
   .....略
 ) WITH ( DATA_SOURCE = REMOTE_DS )

--drop EXTERNAL TABLE [dbo].[{A資料庫裡面的Table Or View都可以}]
select * from [dbo].[{A資料庫裡面的Table Or View都可以}]

這邊我們必須在B資料庫端,定義 A資料庫希望 remote存取的物件的schema,可以完整也可以只撈取部分~~端看需求,在B資料庫端,也可以給定一個新的別名,語法可以參考以下。

CREATE EXTERNAL TABLE [dbo].[remote_xxxxxx]
( [A資料庫的某一個table的欄位1] [欄位型別1], 
   [A資料庫的某一個table的欄位2] [欄位型別2], 
   .....略
 ) WITH ( 
    DATA_SOURCE = REMOTE_DS
    ,SCHEMA_NAME = 'A資料庫裡面的Table Or View的schema,通常是dbo'
    ,OBJECT_NAME = '{A資料庫裡面的Table Or View}'
)

實測後,確實看起來是可以運作查詢,透過在B資料庫的連接中,直接查詢A

這與Elastic Pool似乎不一樣,而目前我們使用 到的費用也是包含在SQL Database中因此不另外計費

Currently, the elastic database query feature is included into the cost of your Azure SQL Database.

註1∶但是開一台elastic pool的主機,至少2000nt/月起跳…用途與解決問題的目的性應該不太一樣吧(沈思…)

註2∶若你的store procedure中有使用到External DataSource,語法中有使用With (nolock)的話,會出現 – “Table hints are not supported on queries that reference external tables”.的錯誤訊息

Reference

https://docs.microsoft.com/en-us/azure/azure-sql/database/elastic-query-getting-started-vertical

https://social.technet.microsoft.com/wiki/contents/articles/37170.azure-sql-database-using-external-data-sources.aspx

Line Login Jwt 驗證簽名 自行實作 c#範例

Line Login Jwt 驗證簽名 自行實作 c#範例

跟Line整合後,經過OAuth第一步驟拿到的grant_code可以再請求 Line Api拿到Open Id協定的id_token

這個是一個標準,從id_token可以解出平台授權的使用者資訊,可能包含廠商的id, 用戶的識別碼

但是若人人都可以拿這個token重新replay使用的話,那就有更大的風險

因此我們需要驗證其合法性,而這個驗證通常都要在server進行,因為我們不可能把密鑰丟到client的browser去嘛

所以還是好好的保留在Server吧。

以下Line有python3的範例∶ https://developers.line.biz/en/docs/line-login/verify-id-token/#write-original-code

今天我試著改寫一下成c#的版本,這個版本主要是驗證簽名是否合法,至於想再驗證payload上的資訊,是否到期(exp、aud)、正確性,可以再自行取出後比對。

註∶若是自行實作的jwt,可以依不用的加簽實作驗證handler,此處為line的演算法sha256為例

using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.Tokens;

var idToken =
    "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FjY2Vzcy5saW5lLm1lIiwic3ViIjoiVTVkZDllZWNjYmUzMTB..略..c3cKg";

var secret = "{請換成你的line channel secret}";

var result =TryValidateToken(idToken);

Console.WriteLine($"token is valid(only check signature): {result}");
Console.ReadKey();


string base64url_decode(string target)
{
    return Base64UrlEncoder.Decode(target);
}

bool check_signature(string key, string target, byte[] signature)
{
    var hmacsha256 = new HMACSHA256();
    hmacsha256.Key = Encoding.UTF8.GetBytes(key);
    var cal_signature = hmacsha256.ComputeHash(Encoding.UTF8.GetBytes(target));
    var signatureHashed = signature;

    return CompareArraysExhaustively(cal_signature, signatureHashed);
}

bool CompareArraysExhaustively(byte[] first, byte[] second)
{
    if (first.Length != second.Length)
    {
        return false;
    }

    bool ret = true;
    for (int i = 0; i < first.Length; i++)
    {
        ret = ret & (first[i] == second[i]);
    }

    return ret;
}


bool TryValidateToken(string token)
{
    if (string.IsNullOrWhiteSpace(token))
    {
        return false;
    }

    var handler = new JwtSecurityTokenHandler();

    try
    {
        var jwt = handler.ReadJwtToken(token); //JWT驗證物件
        if (jwt == null)
        {
            return false;
        }

        var idTokens = token.Split('.');
        var header = idTokens[0];
        var payload = idTokens[1];
        var signature = idTokens[2];

        var header_decoded = base64url_decode(header);
        var payload_decoded = base64url_decode(payload);
        var signature_decoded = Base64UrlEncoder.DecodeBytes(signature);

        var valid_signature = check_signature(secret,
            header + '.' + payload,
            signature_decoded);

        return valid_signature;
        
        //傳統驗證方法 ..
        // ClaimsPrincipal principal = null;
        // var secretBytes = Convert.FromBase64String(secret);
        //
        // var validationParameters = new TokenValidationParameters
        // {
        //     RequireExpirationTime = true,
        //     ValidateIssuer = false,
        //     ValidateAudience = false,
        //     IssuerSigningKey = new SymmetricSecurityKey(secretBytes),
        //
        //     //LifetimeValidator = LifetimeValidator
        //
        //     ClockSkew = TimeSpan.Zero
        // };
        //
        // SecurityToken securityToken;
        // principal = handler.ValidateToken(token, validationParameters, out securityToken);
    }
    catch (Exception ex)
    {
        Console.WriteLine($"msg:{ex.Message}");
        return false;
    }
}
Line Login 的Web Js程式碼實作

Line Login 的Web Js程式碼實作

以下程式的參數請結合官方文件取用

https://developers.line.biz/en/docs/line-login/integrate-line-login/#login-flow

先在畫面置入LineLogin的按鈕

        <div class="btn btn-primary btn-sm box-shadow-primary text-white border-0 mr-3" @click.prevent="handleLineLoginButtonClick">
          Line Login
        </div>
handleLineLoginButtonClick() {
      let URL = 'https://access.line.me/oauth2/v2.1/authorize?';
      URL += 'response_type=code';
      
      //channel id
      URL += '&client_id=$your channel id$'; 

      // 與Line Developer Channel設定的一樣就好,驗證完就會經過瀏覽器轉址回來
      URL += '&redirect_uri=http://localhost:3000/';

      // 狀態碼是會從client呼叫端帶到驗證端,再從驗證端帶回
      // 因此應該可以作為一個亂數卡控確保是自   已丟出去的交易碼
      URL += '&state=abcde';   
     
      //要求存取的範圍
      URL += '&scope=openid%20profile';
      window.location.href = URL;
    },

以上足以從你的網頁導頁到line login的頁面(這個版本與2.0所提供的weblogin,還是有差別,因為我試著串接 2.0的版本,導頁過去後,沒有掃qr code登入的選項)

導頁過去line驗證通過後,會透過redirect_url轉址回來,並帶有Authorization Code
這個Authorization Code並不能直接拿來存取Profile的資料
但我們先設定某個route要能接QueryString的function如下:

beforeMount() { 
  this.checkLineLoginCallBack(); 
},
methods:{
//...略
checkLineLoginCallBack() {
      const urlParams = new URLSearchParams(window.location.search);
      if (urlParams.has('state') && urlParams.has('code')) {
        const state = urlParams.get('state');
        const code = urlParams.get('code');
        if (state === 'abcde') { 
          const URL = 'https://api.line.me/oauth2/v2.1/token?';
          const getTokenUrl = `${URL}`;
          const getTokenBody = queryString.stringify({
            grant_type: 'authorization_code',
            code,
            redirect_uri: 'http://localhost:3000/',
            client_id: '$your channel id$',
            client_secret: '$your secret$',
          });
          axios.post(getTokenUrl, getTokenBody).then((e1) => {
            const token = e1.data.access_token;
            const idToken = e1.data.id_token;
            console.log(e1);
            const getProfileApiUrl = 'https://api.line.me/v2/profile';
            axios({
              method: 'GET',
              url: getProfileApiUrl,
              responseType: 'json', // responseType 也可以寫在 header 裡面
              headers: {
                Authorization: `Bearer ${token}`, // Bearer 跟 token 中間有一個空格
              },
            }).then((e2) => {
              alert(`line user id: ${e2.data.userId}, display name:${e2.data.displayName}`);
              console.log(JSON.stringify(e2));

              const getVerifyApiUrl = 'https://api.line.me/oauth2/v2.1/verify';
              const getVerifyBody = queryString.stringify({
                client_id: '1656034758',
                id_token: idToken,
              });
              axios.post(getVerifyApiUrl, getVerifyBody).then((e3) => {
                alert(`line email: ${e3.data.email}`);
                console.log(JSON.stringify(e3));
              }).catch((error) => {
                console.log(`錯誤: ${error}`);
              });
            }).catch((error) => {
              console.log(`錯誤: ${error}`);
            });
          })
            .catch((error) => {
              console.log(error);
              alert(error);
            });
        }
      }
    }
};

上述的內容代表,Line CallBack以後,透過authorization code再一次跟line取得access Token
這個時候的access token就可以拿來放在Bearer的header去跟line的api請求資訊
注意∶authorization code與access token都有存取時間,因此timeout後就要重新請求一份
在測試的時候通常也會有錯誤訊息明確的告訴你格式錯誤、參數錯誤,建議使用postman先確認錯誤訊息或參數的問題

取得email的api
https://developers.line.biz/zh-hant/docs/line-login/integrate-line-login/#verify-id-token

Microsoft Sql Server的Table與Column的MSDescription批次維護工具

Microsoft Sql Server的Table與Column的MSDescription批次維護工具

在以前,我們專案最痛苦的事情之一,莫過於生文件。

若能透過自動化的方式將程式相關的資料庫描述都產生出來的話,那著實可以省去很多人工~

其實已經有很多自動產生描述文件的工具了,也有包含pdf、word、html等格式

偏偏這個動作最優先要做的,其實是標註欄位、表的名稱與用途

SSMS工具可以透過介面”一個一個”去補,仿間應該也有許多”付費”軟體做的到

但是沒有免費可以方便一次性維護、更新MSDescription的工具。

因此之前寫過一版,先拋磚引玉~~

提供Source Code

GitHub網址在下面

https://github.com/pin0513/MSDescriptionMakeupTool

上傳大檔到Azure Blob進行SQL的Bulk Insert

上傳大檔到Azure Blob進行SQL的Bulk Insert

Download AzCopy and Unzip to C:(for Example)

下載網址:https://docs.microsoft.com/zh-tw/azure/storage/common/storage-use-azcopy-v10

注:Blob要先開啟Share的簽章設定:Shared access signature

azcopy make "https://xxxxxxx.blob.core.windows.net/dbfile?sv=2020-02-10&ss=bfqt&srt=sco&sp=rwdlacupx&se=2021-04-26T23:26:15Z&st=2021-04-26T15:26:15Z&spr=https&sig=1asdfasdbasdfasdbqasdfasdfasdf%3D"

azcopy.exe copy "C:\test.csv" "https://xxxxxxx.blob.core.windows.net/dbfile?sv=2020-02-10&ss=bfqt&srt=sco&sp=rwdlacupx&se=2021-04-26T23:26:15Z&st=2021-04-26T15:26:15Z&spr=https&sig=1asdfasdbasdfasdbqasdfasdfasdf%3D"

In Query Editor(Sql Azure)

--資料庫密碼
CREATE MASTER KEY ENCRYPTION BY PASSWORD = '!asdkfajd;skfjasldjkfalksdjflasd@' ;

--建立權杖(use Blob SAS Token)
CREATE DATABASE SCOPED CREDENTIAL AccessAzureSecret
WITH
  IDENTITY = 'SHARED ACCESS SIGNATURE',
  -- Remove ? from the beginning of the SAS token
  SECRET = 'sv=2020-02-10&ss=bfqt&srt=sco&sp=rwdlacupx&se=2021-04-26T23:44:32Z&st=2021-04-26T15:44:32Z&spr=https&sig=1asdfasdbasdfasdbqasdfasdfasdf%3D' ;
CREATE EXTERNAL DATA SOURCE dbFile
WITH ( TYPE = BLOB_STORAGE,
LOCATION = 'https://xxxxxxx.blob.core.windows.net/dbfile'
 , CREDENTIAL= AccessAzureSecret)


BULK INSERT [dbo].[learning_records] FROM 'stage_learning_record.csv' WITH (
    DATA_SOURCE = 'dbFile',
    FIELDTERMINATOR=',',rowterminator = '\n', KEEPNULLS);

註:
csv裡面,欄位是NULL的話,要先把欄位NULL取代成空字串(不是””), 若日期長度有問題的話,就也要先取代掉

若bulk insert執行發生超過10次錯誤,就會被中斷哦

從Linux主機 使用SCP +SSH Copy檔案的相關指令

從Linux主機 使用SCP +SSH Copy檔案的相關指令

1.先將產生一個本地的ssh publickey/privatekey

#產生SSH key
ssh-keygen
#類型可以輸入rsa, 稍後就會產生id_rsa
cat id_rsa.pub
#印出id_rsa.pub內容
#複製到文字檔,再上傳到對方主機

#遠端到對方主機
#將公key append到ssh允許清單
cat id_rsa.pub >> .ssh/authorized_keys

2.本地檔案複製到對方主機

#遠端到對方主機

SCP -i ~/.ssh/id_rsa /本地檔案 username@hostname:/遠端檔案

#要注意-i指定的檔案id_rsa是私key,不是id_rsa.pub公key

就可以通了!

使用Rider Debug Azure Function專案遇到的怪異問題,ex. 找不到System.IO.Pipelines 5.0.0, 無法解析FileLoggingStatusManager

使用Rider Debug Azure Function專案遇到的怪異問題,ex. 找不到System.IO.Pipelines 5.0.0, 無法解析FileLoggingStatusManager

為什麼要特別記錄這次的案例,主要Azure Functions若順順著用,不要想什麼DI,也是挺單純,就是一個Function,像是Console一樣跑進去嘛

但是為了實踐DI設計,當然主要就是為了單元測試,把相依物件隔離,本來也沒事,UT寫的好好的,整測去Trigger容器註冊也解決了,結果實際拿Azure Function專案Debug時,

除了AzureFunction剛好是Static Class,在想有哪些切入點可以註冊事件,然後不明究理的去開始debug他,結果發生了以下諸多問題

 

a.Azure 儲存體相依

b.DI容器註冊問題

c.Runtime在跑的時候其他元件相依問題

 

在此記錄一下解法!

1.Rider要先安裝azure functions core tools

image.png

Debug時,若是出現無法連到儲存體的話,若只是開啟emulator的話,也是不行

The listener for function ‘ActivateUserJob’ was unable to start. Microsoft.Azure.Storage.Common: 無法連線,因為目標電腦拒絕連線。. System.Net.Http: 無法連線,因為目標電腦拒絕連線。. System.Private.CoreLib: 無法連線,因為目標電腦拒絕連線。.

https://docs.microsoft.com/zh-tw/azure/storage/common/storage-use-emulator#how-the-storage-emulator-works
image.png

會出現 Server encountered an internal error
image.png

2.azurite將取代Azure Storage Emulator

https://docs.microsoft.com/zh-tw/azure/storage/common/storage-use-azurite#run-azurite-from-a-command-line

  • 使用 NPM 安裝和執行 Azurite
    npm install -g azurite
  • 啟用windows的azurite
    **azurite --silent --location c:\azurite --debug c:\azurite\debug.log**
  • 跑起來azurite的樣子:
    image.png

總算可以了!

3.透過DI註冊到Azure Functions

class可以註冊覆寫這個Function

public override void Configure(IFunctionsHostBuilder builder)
public override void ConfigureAppConfiguration(IFunctionsConfigurationBuilder builder)
在覆寫的時候,可以去將ServiceProvider重新指定

var configBuilder = new ConfigurationBuilder()
                .SetBasePath(Directory.GetCurrentDirectory())
                .AddJsonFile("local.settings.json", true, true)
                .AddEnvironmentVariables();

            var builderTemp = new FunctionsHostBuilder();
            builderTemp.Services = new ServiceCollection();

            builder = builderTemp;

            var configurationRoot = configBuilder.Build();
            builder.Services.AddSingleton<IConfiguration>(provider =>
            {
                return configurationRoot;
            });


怪異問題集

1.找不到System.IO.Pipelines 5.0.0版(nuget明明有裝)
image.png
解法:
https://stackoverflow.com/questions/65382540/azure-function-app-could-not-load-system-io-pipelines-connecting-to-redis

 

2.無法解析FileLoggingStatusManager

可以想像是AzureFunction在初始化注入DI的時候無法找到相對應的類別
但我看其他專案不會有這種錯,而且我在整合測試專案時,已經塞過也可作了

但一到AzureFunction跑起來的時候就怪怪的,猜想是跟容器本身有關
因此我在Startup的時候,重新初始化ServiceCollection塞進去。
但是若要塞新的ServiceCollection,就必須實作把setter改掉!

實作IFunctionHostBuilder
image.png

Replace掉該介面的實作
image.png