Remove API registration endpoint and trust forwarded host
This commit is contained in:
parent
a2a43250df
commit
473ac6e889
@ -38,7 +38,7 @@ API、Web 與 Installer 共用上述規則。
|
|||||||
| `RateLimits:Web:AuthRegister` | 5 | 900 | Web register |
|
| `RateLimits:Web:AuthRegister` | 5 | 900 | Web register |
|
||||||
| `RateLimits:Web:AuthRecovery` | 5 | 900 | Web forgot/resend |
|
| `RateLimits:Web:AuthRecovery` | 5 | 900 | Web forgot/resend |
|
||||||
| `RateLimits:Web:AuthTokenConsumption` | 10 | 600 | Web reset/verify |
|
| `RateLimits:Web:AuthTokenConsumption` | 10 | 600 | Web reset/verify |
|
||||||
| `RateLimits:Api:AuthRegister` | 5 | 900 | API register |
|
| `RateLimits:Api:AuthRegister` | 5 | 900 | 保留設定;API register endpoint 目前未開放。 |
|
||||||
| `RateLimits:Api:AuthRecovery` | 5 | 900 | API forgot/resend |
|
| `RateLimits:Api:AuthRecovery` | 5 | 900 | API forgot/resend |
|
||||||
| `RateLimits:Api:AuthTokenConsumption` | 10 | 600 | API reset/verify |
|
| `RateLimits:Api:AuthTokenConsumption` | 10 | 600 | API reset/verify |
|
||||||
| `RateLimits:Api:NewsletterSubscribe` | 20 | 600 | Newsletter subscribe |
|
| `RateLimits:Api:NewsletterSubscribe` | 20 | 600 | Newsletter subscribe |
|
||||||
@ -101,6 +101,8 @@ OAuth usage/scope mapping 的正式來源為 DB registry;audience key 只作 s
|
|||||||
|
|
||||||
`ReverseProxy:TrustForwardedHeaders=true` 會接受 `X-Forwarded-For`、`X-Forwarded-Proto`、`X-Forwarded-Host`。此模式適合 AWS ALB / managed reverse proxy private IP 會變動,但 app security group 已只允許該 proxy 連入的環境。
|
`ReverseProxy:TrustForwardedHeaders=true` 會接受 `X-Forwarded-For`、`X-Forwarded-Proto`、`X-Forwarded-Host`。此模式適合 AWS ALB / managed reverse proxy private IP 會變動,但 app security group 已只允許該 proxy 連入的環境。
|
||||||
|
|
||||||
|
接受 `X-Forwarded-Host` 是刻意支援 AWS ALB 後方的登入、OAuth callback 與 public URL 產生。Production 必須同時由 ALB / router 覆寫外部 forwarded headers、以 Security Group 阻止繞過 proxy,並將 DB `public_base_url` 設為 canonical HTTPS URL。部署驗證需包含偽造 `Host` / `X-Forwarded-Host` 的 redirect 與 email link 測試。
|
||||||
|
|
||||||
未啟用 `TrustForwardedHeaders` 時,必須設定 `KnownProxies` 或 `KnownNetworks` 才會接受 forwarded headers;allowlist 都為空時完全忽略 forwarded headers。
|
未啟用 `TrustForwardedHeaders` 時,必須設定 `KnownProxies` 或 `KnownNetworks` 才會接受 forwarded headers;allowlist 都為空時完全忽略 forwarded headers。
|
||||||
|
|
||||||
## 外部整合與測試旗標
|
## 外部整合與測試旗標
|
||||||
|
|||||||
@ -95,6 +95,9 @@ Reverse proxy 信任設定:
|
|||||||
- `ReverseProxy__TrustForwardedHeaders=true`
|
- `ReverseProxy__TrustForwardedHeaders=true`
|
||||||
- `ReverseProxy__ForwardLimit=1`
|
- `ReverseProxy__ForwardLimit=1`
|
||||||
- `ReverseProxy__TrustForwardedHeaders=true` 會接受 `X-Forwarded-For`、`X-Forwarded-Proto`、`X-Forwarded-Host`;只有在 app inbound 已由 Security Group / 私有網路限制為可信 proxy 時才可使用。
|
- `ReverseProxy__TrustForwardedHeaders=true` 會接受 `X-Forwarded-For`、`X-Forwarded-Proto`、`X-Forwarded-Host`;只有在 app inbound 已由 Security Group / 私有網路限制為可信 proxy 時才可使用。
|
||||||
|
- 接受 `X-Forwarded-Host` 是為了讓 AWS ALB 後方的登入、OAuth callback 與外部 URL 產生使用原始 public host;ALB / router 必須覆寫外部傳入的 forwarded headers,不可直接沿用任意 client 值。
|
||||||
|
- Production 的 `/admin/security` `Public base URL` 必須設定為 canonical HTTPS URL,讓驗證信與密碼重設信不依賴 request host。
|
||||||
|
- 部署後必須以偽造 `Host` / `X-Forwarded-Host` 實測登入 redirect、OAuth callback 與 email link,確認不會導向非預期網域。
|
||||||
- 若不使用 `TrustForwardedHeaders`,未設定 `ReverseProxy__KnownProxies` / `ReverseProxy__KnownNetworks` 時,API 與 Web 完全忽略 forwarded headers。
|
- 若不使用 `TrustForwardedHeaders`,未設定 `ReverseProxy__KnownProxies` / `ReverseProxy__KnownNetworks` 時,API 與 Web 完全忽略 forwarded headers。
|
||||||
- `ReverseProxy__KnownProxies` 使用逗號分隔 IP,例如 `10.0.0.10,10.0.0.11`。
|
- `ReverseProxy__KnownProxies` 使用逗號分隔 IP,例如 `10.0.0.10,10.0.0.11`。
|
||||||
- `ReverseProxy__KnownNetworks` 使用逗號分隔 CIDR,例如 `10.0.0.0/24,fd00::/64`。
|
- `ReverseProxy__KnownNetworks` 使用逗號分隔 CIDR,例如 `10.0.0.0/24,fd00::/64`。
|
||||||
|
|||||||
@ -84,24 +84,6 @@ paths:
|
|||||||
'200':
|
'200':
|
||||||
description: JSON Web Key Set
|
description: JSON Web Key Set
|
||||||
|
|
||||||
/auth/register:
|
|
||||||
post:
|
|
||||||
summary: Register user
|
|
||||||
security: []
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/RegisterRequest'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Registered
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/UserProfile'
|
|
||||||
|
|
||||||
/auth/logout:
|
/auth/logout:
|
||||||
post:
|
post:
|
||||||
summary: Logout current authenticated session
|
summary: Logout current authenticated session
|
||||||
@ -901,13 +883,6 @@ components:
|
|||||||
token_type: { type: string, example: Bearer }
|
token_type: { type: string, example: Bearer }
|
||||||
expires_in: { type: integer }
|
expires_in: { type: integer }
|
||||||
|
|
||||||
RegisterRequest:
|
|
||||||
type: object
|
|
||||||
required: [email, password]
|
|
||||||
properties:
|
|
||||||
email: { type: string, format: email }
|
|
||||||
password: { type: string }
|
|
||||||
|
|
||||||
AuthorizationCodeTokenRequest:
|
AuthorizationCodeTokenRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [grant_type, code, redirect_uri, code_verifier]
|
required: [grant_type, code, redirect_uri, code_verifier]
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
namespace MemberCenter.Api.Contracts;
|
namespace MemberCenter.Api.Contracts;
|
||||||
|
|
||||||
public sealed record RegisterRequest(string Email, string Password);
|
|
||||||
|
|
||||||
public sealed record RefreshRequest(string RefreshToken);
|
public sealed record RefreshRequest(string RefreshToken);
|
||||||
|
|
||||||
public sealed record ForgotPasswordRequest(string Email);
|
public sealed record ForgotPasswordRequest(string Email);
|
||||||
|
|||||||
@ -13,51 +13,23 @@ namespace MemberCenter.Api.Controllers;
|
|||||||
[Route("auth")]
|
[Route("auth")]
|
||||||
public class AuthController : ControllerBase
|
public class AuthController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IAccountProvisioningService _accountProvisioningService;
|
|
||||||
private readonly IAccountEmailService _accountEmailService;
|
private readonly IAccountEmailService _accountEmailService;
|
||||||
private readonly IAuditLogWriter _auditLogWriter;
|
private readonly IAuditLogWriter _auditLogWriter;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||||
|
|
||||||
public AuthController(
|
public AuthController(
|
||||||
IAccountProvisioningService accountProvisioningService,
|
|
||||||
IAccountEmailService accountEmailService,
|
IAccountEmailService accountEmailService,
|
||||||
IAuditLogWriter auditLogWriter,
|
IAuditLogWriter auditLogWriter,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
SignInManager<ApplicationUser> signInManager)
|
SignInManager<ApplicationUser> signInManager)
|
||||||
{
|
{
|
||||||
_accountProvisioningService = accountProvisioningService;
|
|
||||||
_accountEmailService = accountEmailService;
|
_accountEmailService = accountEmailService;
|
||||||
_auditLogWriter = auditLogWriter;
|
_auditLogWriter = auditLogWriter;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_signInManager = signInManager;
|
_signInManager = signInManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("register")]
|
|
||||||
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRegister)]
|
|
||||||
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
|
|
||||||
{
|
|
||||||
var result = await _accountProvisioningService.RegisterLocalAsync(request.Email, request.Password);
|
|
||||||
if (!result.Succeeded)
|
|
||||||
{
|
|
||||||
return BadRequest(result.Errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
var user = await _userManager.FindByEmailAsync(request.Email);
|
|
||||||
if (user is not null)
|
|
||||||
{
|
|
||||||
await _accountEmailService.SendVerificationEmailAsync(user.Id, GetBaseUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
id = result.UserId,
|
|
||||||
email = result.Email,
|
|
||||||
email_verified = result.EmailConfirmed,
|
|
||||||
linked_subscriptions = result.LinkedSubscriptionsCount
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("password/forgot")]
|
[HttpPost("password/forgot")]
|
||||||
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRecovery)]
|
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRecovery)]
|
||||||
public async Task<IActionResult> ForgotPassword([FromBody] ForgotPasswordRequest request)
|
public async Task<IActionResult> ForgotPassword([FromBody] ForgotPasswordRequest request)
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" />
|
||||||
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="5.7.0" />
|
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="5.7.0" />
|
||||||
|
<PackageReference Include="System.Security.Cryptography.Xml" Version="8.0.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|||||||
@ -42,7 +42,7 @@ public sealed class SecurityConfigurationTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TrustedProxyAllowlistEnablesExpectedHeaders()
|
public void TrustedProxyAllowlistIncludesForwardedHost()
|
||||||
{
|
{
|
||||||
var options = new ForwardedHeadersOptions();
|
var options = new ForwardedHeadersOptions();
|
||||||
TrustedForwardedHeaders.Configure(options, Configuration(new()
|
TrustedForwardedHeaders.Configure(options, Configuration(new()
|
||||||
@ -51,11 +51,35 @@ public sealed class SecurityConfigurationTests
|
|||||||
["ReverseProxy:KnownNetworks"] = "10.1.0.0/16"
|
["ReverseProxy:KnownNetworks"] = "10.1.0.0/16"
|
||||||
}));
|
}));
|
||||||
|
|
||||||
Assert.Equal(ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, options.ForwardedHeaders);
|
Assert.Equal(
|
||||||
|
ForwardedHeaders.XForwardedFor |
|
||||||
|
ForwardedHeaders.XForwardedProto |
|
||||||
|
ForwardedHeaders.XForwardedHost,
|
||||||
|
options.ForwardedHeaders);
|
||||||
Assert.Single(options.KnownProxies);
|
Assert.Single(options.KnownProxies);
|
||||||
Assert.Single(options.KnownNetworks);
|
Assert.Single(options.KnownNetworks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ManagedProxyModeIncludesForwardedHostForExternalUrlGeneration()
|
||||||
|
{
|
||||||
|
var options = new ForwardedHeadersOptions();
|
||||||
|
TrustedForwardedHeaders.Configure(options, Configuration(new()
|
||||||
|
{
|
||||||
|
["ReverseProxy:TrustForwardedHeaders"] = "true",
|
||||||
|
["ReverseProxy:ForwardLimit"] = "1"
|
||||||
|
}));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
ForwardedHeaders.XForwardedFor |
|
||||||
|
ForwardedHeaders.XForwardedProto |
|
||||||
|
ForwardedHeaders.XForwardedHost,
|
||||||
|
options.ForwardedHeaders);
|
||||||
|
Assert.Equal(1, options.ForwardLimit);
|
||||||
|
Assert.Empty(options.KnownProxies);
|
||||||
|
Assert.Empty(options.KnownNetworks);
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("0.0.0.0/0")]
|
[InlineData("0.0.0.0/0")]
|
||||||
[InlineData("::/0")]
|
[InlineData("::/0")]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user