feat(ads/seo/release): v1.2.0 with live Google Ads, isolated ad profile, ads.txt, and SEO/AEO/GEO optimization
This commit is contained in:
parent
a88aafa1e5
commit
0e568f1f0a
975 changed files with 130593 additions and 16783 deletions
183
tests/Paca.Tests/Server/ServerPasskeyAuthTests.cs
Normal file
183
tests/Paca.Tests/Server/ServerPasskeyAuthTests.cs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Paca.Core.Security;
|
||||
using Paca.Server;
|
||||
using Xunit;
|
||||
|
||||
namespace Paca.Tests.Server;
|
||||
|
||||
public class ServerPasskeyAuthTests : IDisposable
|
||||
{
|
||||
private readonly string _libDir = Path.Combine(Path.GetTempPath(), $"vd_passkey_{Guid.NewGuid():N}");
|
||||
|
||||
public ServerPasskeyAuthTests() => Directory.CreateDirectory(_libDir);
|
||||
|
||||
private sealed class EmptyQueue : IQueueAdapter
|
||||
{
|
||||
public Task<IReadOnlyList<QueueItemDto>> GetItemsAsync() => Task.FromResult<IReadOnlyList<QueueItemDto>>(Array.Empty<QueueItemDto>());
|
||||
public Task<string> AddAsync(string url, string? title) => Task.FromResult(Guid.NewGuid().ToString("N"));
|
||||
public Task PauseAsync(string id) => Task.CompletedTask;
|
||||
public Task ResumeAsync(string id) => Task.CompletedTask;
|
||||
public Task CancelAsync(string id) => Task.CompletedTask;
|
||||
public event Action? Changed { add { } remove { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetChallenge_ReturnsNewValidChallenge()
|
||||
{
|
||||
await using var server = await ServerHost.StartAsync(new ServerOptions
|
||||
{
|
||||
Port = 0,
|
||||
Token = "secret-passkey-token",
|
||||
LibraryFolder = _libDir,
|
||||
Queue = new EmptyQueue()
|
||||
});
|
||||
|
||||
using var client = new HttpClient();
|
||||
var resp = await client.GetAsync($"http://127.0.0.1:{server.Port}/api/auth/passkey/challenge");
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
|
||||
var doc = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.True(doc.GetProperty("ok").GetBoolean());
|
||||
var challenge = doc.GetProperty("challenge").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(challenge));
|
||||
Assert.True(doc.TryGetProperty("rpId", out var rpId));
|
||||
Assert.Equal("paca.local", rpId.GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_WithValidFido2Assertion_ReturnsSessionToken()
|
||||
{
|
||||
await using var server = await ServerHost.StartAsync(new ServerOptions
|
||||
{
|
||||
Port = 0,
|
||||
Token = "secret-passkey-token-2",
|
||||
DeviceName = "MyTestPc",
|
||||
LibraryFolder = _libDir,
|
||||
Queue = new EmptyQueue()
|
||||
});
|
||||
|
||||
using var client = new HttpClient();
|
||||
|
||||
// 1. 챌린지 요청
|
||||
var chalResp = await client.GetAsync($"http://127.0.0.1:{server.Port}/api/auth/passkey/challenge");
|
||||
var chalDoc = await chalResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var challenge = chalDoc.GetProperty("challenge").GetString()!;
|
||||
|
||||
// 2. 가상 FIDO2 자격증명 생성 및 어설션 서명
|
||||
var authenticator = VirtualFido2Authenticator.Default;
|
||||
var cred = authenticator.CreateCredential("paca.local", "user-1", "test-user");
|
||||
var clientDataHash = SHA256.HashData(Encoding.UTF8.GetBytes(challenge));
|
||||
var assertion = authenticator.SignChallenge(cred.CredentialId, clientDataHash);
|
||||
|
||||
// 3. Verify 요청
|
||||
var verifyReq = new
|
||||
{
|
||||
challenge = challenge,
|
||||
credentialId = cred.CredentialId,
|
||||
clientDataHash = WebEncoders.Base64UrlEncode(clientDataHash),
|
||||
authenticatorData = WebEncoders.Base64UrlEncode(assertion.AuthenticatorData),
|
||||
signature = WebEncoders.Base64UrlEncode(assertion.Signature)
|
||||
};
|
||||
|
||||
var verifyResp = await client.PostAsJsonAsync($"http://127.0.0.1:{server.Port}/api/auth/passkey/verify", verifyReq);
|
||||
Assert.Equal(HttpStatusCode.OK, verifyResp.StatusCode);
|
||||
|
||||
var verifyDoc = await verifyResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.True(verifyDoc.GetProperty("ok").GetBoolean());
|
||||
Assert.Equal("secret-passkey-token-2", verifyDoc.GetProperty("token").GetString());
|
||||
Assert.Equal("MyTestPc", verifyDoc.GetProperty("device").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_WithInvalidSignature_Returns401()
|
||||
{
|
||||
await using var server = await ServerHost.StartAsync(new ServerOptions
|
||||
{
|
||||
Port = 0,
|
||||
Token = "secret-passkey-token-3",
|
||||
LibraryFolder = _libDir,
|
||||
Queue = new EmptyQueue()
|
||||
});
|
||||
|
||||
using var client = new HttpClient();
|
||||
|
||||
var chalResp = await client.GetAsync($"http://127.0.0.1:{server.Port}/api/auth/passkey/challenge");
|
||||
var chalDoc = await chalResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var challenge = chalDoc.GetProperty("challenge").GetString()!;
|
||||
|
||||
var authenticator = VirtualFido2Authenticator.Default;
|
||||
var cred = authenticator.CreateCredential("paca.local", "user-2", "test-user-2");
|
||||
var clientDataHash = SHA256.HashData(Encoding.UTF8.GetBytes(challenge));
|
||||
var assertion = authenticator.SignChallenge(cred.CredentialId, clientDataHash);
|
||||
|
||||
// 위조 서명
|
||||
var tamperedSignature = (byte[])assertion.Signature.Clone();
|
||||
tamperedSignature[0] ^= 0xFF;
|
||||
|
||||
var verifyReq = new
|
||||
{
|
||||
challenge = challenge,
|
||||
credentialId = cred.CredentialId,
|
||||
clientDataHash = WebEncoders.Base64UrlEncode(clientDataHash),
|
||||
authenticatorData = WebEncoders.Base64UrlEncode(assertion.AuthenticatorData),
|
||||
signature = WebEncoders.Base64UrlEncode(tamperedSignature)
|
||||
};
|
||||
|
||||
var verifyResp = await client.PostAsJsonAsync($"http://127.0.0.1:{server.Port}/api/auth/passkey/verify", verifyReq);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, verifyResp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_ReplayAttack_Returns401()
|
||||
{
|
||||
await using var server = await ServerHost.StartAsync(new ServerOptions
|
||||
{
|
||||
Port = 0,
|
||||
Token = "secret-passkey-token-4",
|
||||
LibraryFolder = _libDir,
|
||||
Queue = new EmptyQueue()
|
||||
});
|
||||
|
||||
using var client = new HttpClient();
|
||||
|
||||
var chalResp = await client.GetAsync($"http://127.0.0.1:{server.Port}/api/auth/passkey/challenge");
|
||||
var chalDoc = await chalResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var challenge = chalDoc.GetProperty("challenge").GetString()!;
|
||||
|
||||
var authenticator = VirtualFido2Authenticator.Default;
|
||||
var cred = authenticator.CreateCredential("paca.local", "user-3", "test-user-3");
|
||||
var clientDataHash = SHA256.HashData(Encoding.UTF8.GetBytes(challenge));
|
||||
var assertion = authenticator.SignChallenge(cred.CredentialId, clientDataHash);
|
||||
|
||||
var verifyReq = new
|
||||
{
|
||||
challenge = challenge,
|
||||
credentialId = cred.CredentialId,
|
||||
clientDataHash = WebEncoders.Base64UrlEncode(clientDataHash),
|
||||
authenticatorData = WebEncoders.Base64UrlEncode(assertion.AuthenticatorData),
|
||||
signature = WebEncoders.Base64UrlEncode(assertion.Signature)
|
||||
};
|
||||
|
||||
// 1차 성공
|
||||
var first = await client.PostAsJsonAsync($"http://127.0.0.1:{server.Port}/api/auth/passkey/verify", verifyReq);
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
|
||||
// 동일 챌린지 재사용 (Replay 공격) -> 401 거부
|
||||
var second = await client.PostAsJsonAsync($"http://127.0.0.1:{server.Port}/api/auth/passkey/verify", verifyReq);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, second.StatusCode);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_libDir, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue