プログラミング
2023年6月23日金曜日
使用 Docker Compose 构建 Duende IdentityServer 6 开发生产环境
踩坑后的技术总结
一年以来最喜欢的机械键盘 Nuphy Air75

配置 IdentityServer 是个不容易的事情。我刚开始学习使用 IdentityServer 的时候,感觉真的像是走到了没有知识的荒原。与前端世界的 React,Vue关键词一比天壤之别。

我在开发过程中,遇到的第一个问题就是我需要自己搞个SSL证书,本地开发不使用HTTPS的话,最终生产环境部署和本地开发环境区别有些大,我选择尽可能保证一致性。顺便一提,在本地开发环境和生产环境我都将使用 Docker Compose,下文中的讲解也都将围绕我所使用的环境来展开。

如何搞到一个本地开发使用的证书呢?你可以使用下面的命令。我生成证书的时候写上去了两种域名,一种是为了模仿最终部署将使用的域名(xxx.local.mydomain.com),另一种是因为 docker 网络访问才写的(evrane-identity-identity-server)。之所以还要为 docker 网络访问再准备一个是因为 传统Web服务上使用OIDC的话,一方面是在 docker 网络中访问 IdentityServer 服务器必须,另一个是浏览器会被重定向到这个 docker中使用的网络的地址。第二行生成 pfx 加密证书文件是为了方便无Nginx直接启用TLS时方便,如果始终把服务放在 Nginx 后面,由 Nginx 处理 HTTPS 的话,不需要执行第二行

openssl req -x509 -newkey rsa:4096 -keyout evrane-local.key -out evrane-local.crt -nodes -subj "/CN=local.evrane.com" -addext "subjectAltName=DNS:admin.blog.local.evrane.com,DNS:admin.pine-hamster.local.evrane.com,DNS:api.pine-hamster.local.evrane.com,DNS:currency.pine-hamster.local.evrane.com,DNS:identity.local.evrane.com,DNS:currency.pine-hamster.local.evrane.com,DNS:yiran.local.evrane.com,DNS:evrane-identity-identity-server"
openssl pkcs12 -export -in evrane-local.crt -inkey evrane-local.key -out evrane-local.pfx -name "Evrane"
# Enter Export Password:password
# Verifying - Enter Export Password:password
gen_cert.sh

然后根据刚才创建自签名证书时指定的域名,修改本地 hosts 文件

# same with dns settings on Google Domains
127.0.0.1 admin.blog.local.evrane.com
127.0.0.1 admin.pine-hamster.local.evrane.com
127.0.0.1 api.pine-hamster.local.evrane.com
127.0.0.1 currency.pine-hamster.local.evrane.com
127.0.0.1 identity.local.evrane.com 
127.0.0.1 currency.pine-hamster.local.evrane.com
127.0.0.1 yiran.local.evrane.com
127.0.0.1 evrane-identity-identity-server
/etc/hosts

下一步,使用刚才生成的证书,提供给服务器使用。这里以 IdentityServer服务的说明文件为例,说明一下不使用 Nginx 而直接使用 Pfx 加密证书开启 TLS。

version: "3.9"
services:
  identity-server:
    build:
      context: "../../.."
      dockerfile: "./Evrane.IdentityServer/Dockerfile"
    environment:
      ASPNETCORE_URLS: "https://+:8000"
      ASPNETCORE_Kestrel__Certificates__Default__Password: "${ASPNETCORE_Kestrel__Certificates__Default__Password}"
      ASPNETCORE_Kestrel__Certificates__Default__Path: "/https/${ASPNETCORE_Kestrel__Pfx__FileName}"
    ports:
      - "8000:8000"
    volumes:
      - "./certs/${ASPNETCORE_Kestrel__Pfx__FileName}:/https/${ASPNETCORE_Kestrel__Pfx__FileName}:ro"
      - "./certs/${ASPNETCORE_Kestrel__Crt__FileName}:/usr/local/share/ca-certificates/${ASPNETCORE_Kestrel__Crt__FileName}:ro"
    networks:
      - shared-network
networks:
  shared-network:
    driver: bridge
identityserver/docker-compose.yml

下面以身份服务的使用者业务Server的docker compose文件为例,说明一下使用 Nginx 开启TLS

# ...
  nginx:
    image: nginx:1.23
    ports:
      - "8100:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
      - ./certs:/etc/nginx/cert
# ...
web/docker-compose.yml
# ...
    server {
        listen 443 ssl;
        server_name admin.blog.local.evrane.com;
    
        ssl_certificate /etc/nginx/cert/evrane-local.crt;
        ssl_certificate_key /etc/nginx/cert/evrane-local.key;
    
        location / {
            proxy_pass http://evrane-blog-admin-server:8000;
        }
    }
# ...
nginx.conf

此外需要额外注意的是,此时直接运行两个服务,业务服务是无法获取 identityserver 的配置并进行协议沟通的。原因是业务Server看到identity server 那边拿的是自签名证书, 不认。

解决方案有两个,其一是直接放弃自签名证书,identityserver直接放在有公网地址的服务器上,或把正式的证书下载到本地使用。第二个解决方法是在让业务Server的容器信任这个自签名证书。要么把证书打包进镜像,在build时把证书添加到白名单,要么在启动时mount证书,并修改 entry 执行添加白名单的同时启动服务(注:这种方式在Jetbrain Rider中无法 debug此 docker容器)

至此,当访问对应地址时,Nginx就会处理HTTPS并将HTTP请求发送给你的业务服务器。对于业务Server已经足够了。但是对于 IdentityServer 来说,还需要一些额外的配置(IdentityServer直接提供 HTTPS的方式不需要)。IdentityServer需要知道原本的 HTTP 请求头和使用的协议。

    server {
        # ...
        location / {
            proxy_pass http://127.0.0.1:8000;
            proxy_http_version 1.1;
            proxy_set_header   Upgrade $http_upgrade;
            proxy_set_header   Connection keep-alive;
            proxy_set_header   Host $host;
            proxy_cache_bypass $http_upgrade;
            proxy_set_header   X-Real-IP $remote_addr;
            proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header   X-Forwarded-Proto $scheme;
            proxy_set_header X-Forwarded-Host $server_name;
        }
    }
nginx.conf

在刚才的 nginx.conf 文件中增加这些 proxy相关的内容,使得Nginx会增加下面的请求头,将原始请求信息发送给 IdentityServer。此外在 IdentityServer 的 Program.cs 中需要增加下述的代码。

// ...
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = 
        ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;

    // 信任所有的网络和代理
    options.KnownNetworks.Clear(); 
    options.KnownProxies.Clear();
});

var app = builder.Build();

app.UseForwardedHeaders();
Program.cs

其中两行调用 Clear() 方法的语句也格外重要。在微软的官方文档中提到“Only loopback addresses are configured for known proxies and known networks.”,只有本地环回地址才是被信任的,因为我的 IdentityServer 跑在Docker 容器中,所以需要清空,信任所有的网络和代理。

至此构建开发生产环境时最重要的部分就配置完成了。