132 lines
8.6 KiB
C#
132 lines
8.6 KiB
C#
using Microsoft.AspNetCore.Http.Features;
|
|
using WebDrop.Core;
|
|
using WebDrop.Storage;
|
|
|
|
namespace WebDrop.Api;
|
|
|
|
public static partial class Endpoints
|
|
{
|
|
private static void MapAccounts(RouteGroupBuilder api, SqliteAccountStore accounts)
|
|
{
|
|
api.MapGet("/accounts/profile", (HttpContext ctx) =>
|
|
{
|
|
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
|
return Json(accounts.Profile(username), ApiJson.Default.AccountProfile);
|
|
});
|
|
api.MapPatch("/accounts/profile", async (HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
|
{
|
|
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
|
// 动图头像比其他请求大得多,单独放宽(全局上限 2 MB)
|
|
if (ctx.Features.Get<IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } size) size.MaxRequestBodySize = 8 * 1024 * 1024;
|
|
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountProfileRequest, ct);
|
|
var profile = accounts.UpdateProfile(username, req.DisplayName, req.Avatar);
|
|
svc.AccountProfileChanged(username);
|
|
return Json(profile, ApiJson.Default.AccountProfile);
|
|
});
|
|
api.MapPost("/accounts", async (HttpContext ctx, CancellationToken ct) =>
|
|
{
|
|
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountRegisterRequest, ct);
|
|
var username = NormalizeUsername(req.Username);
|
|
var salt = DecodeBase64(req.Salt, 16, 16);
|
|
var verifier = DecodeBase64(req.Verifier, 32, 32);
|
|
var vault = DecodeBase64(req.Vault, 28, 1_300_000);
|
|
ValidatePublicKey(req.PublicKey);
|
|
ValidatePublicKey(req.SigningPublicKey);
|
|
if (!accounts.Register(username, salt, verifier, vault, req.PublicKey, req.SigningPublicKey)) return Json(new ErrorBody { Error = "username_unavailable" }, ApiJson.Default.ErrorBody, StatusCodes.Status409Conflict);
|
|
return Results.NoContent();
|
|
}).RequireRateLimiting(CreatePolicy);
|
|
|
|
api.MapPost("/accounts/salt", async (HttpContext ctx, CancellationToken ct) =>
|
|
{
|
|
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountSaltRequest, ct);
|
|
var username = NormalizeUsername(req.Username);
|
|
var salt = accounts.LoginSalt(username);
|
|
return Json(new AccountSaltBody { Salt = Convert.ToBase64String(salt) }, ApiJson.Default.AccountSaltBody);
|
|
}).RequireRateLimiting(CreatePolicy);
|
|
|
|
api.MapPost("/accounts/session", async (HttpContext ctx, CancellationToken ct) =>
|
|
{
|
|
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountSessionRequest, ct);
|
|
var username = NormalizeUsername(req.Username);
|
|
var verifier = DecodeBase64(req.Verifier, 32, 32);
|
|
var token = accounts.Authenticate(username, verifier, req.DeviceId, req.DeviceName);
|
|
if (token is null) return Json(new ErrorBody { Error = "account_invalid" }, ApiJson.Default.ErrorBody, StatusCodes.Status401Unauthorized);
|
|
return Json(new AccountSessionBody { Token = token, Username = username }, ApiJson.Default.AccountSessionBody);
|
|
}).RequireRateLimiting(CreatePolicy);
|
|
|
|
api.MapGet("/accounts/vault", (HttpContext ctx) =>
|
|
{
|
|
if (!TryAccount(ctx, accounts, out var username)) return Json(new ErrorBody { Error = "account_invalid" }, ApiJson.Default.ErrorBody, StatusCodes.Status401Unauthorized);
|
|
var vault = accounts.GetVault(username);
|
|
return vault is null ? Results.NotFound() : Json(new AccountVaultBody { Revision = vault.Value.Revision, Vault = Convert.ToBase64String(vault.Value.Vault) }, ApiJson.Default.AccountVaultBody);
|
|
});
|
|
|
|
api.MapGet("/accounts/devices", (HttpContext ctx) =>
|
|
{
|
|
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
|
var current = accounts.SessionDevice(ctx.Request.Headers["X-WD-Account"].ToString());
|
|
var devices = accounts.Devices(username, current).Select(d => new AccountDeviceBody
|
|
{ Id = d.Id, Name = d.Name, CreatedAt = d.CreatedAt, LastSeen = d.LastSeen, Current = d.Current }).ToList();
|
|
return Json(devices, ApiJson.Default.ListAccountDeviceBody);
|
|
});
|
|
api.MapPatch("/accounts/devices/{id}", async (string id, HttpContext ctx, CancellationToken ct) =>
|
|
{
|
|
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
|
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountDeviceNameRequest, ct);
|
|
if (req.Name is null || !accounts.RenameDevice(username, id, req.Name.Trim())) throw new WebDropException(ErrorCode.BadRequest);
|
|
return Results.NoContent();
|
|
});
|
|
api.MapPut("/accounts/devices/signing-key", async (HttpContext ctx, SqliteAccountStore accountStore, CancellationToken ct) =>
|
|
{
|
|
if (!TryAccount(ctx, accountStore, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
|
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountDeviceSigningRequest, ct);
|
|
var token = ctx.Request.Headers["X-WD-Account"].ToString();
|
|
var deviceId = accountStore.SessionDevice(token);
|
|
if (deviceId is null || !string.Equals(deviceId, req.DeviceId, StringComparison.OrdinalIgnoreCase)) throw new WebDropException(ErrorCode.Forbidden);
|
|
ValidatePublicKey(req.PublicKey);
|
|
var rootKey = accountStore.GetSigningPublicKey(username);
|
|
if (rootKey is null) throw new WebDropException(ErrorCode.BadRequest);
|
|
using var root = System.Text.Json.JsonDocument.Parse(rootKey);
|
|
var rootObject = root.RootElement;
|
|
if (!Codec.TryDecodeExact(rootObject.GetProperty("x").GetString(), 32, out var rootX)
|
|
|| !Codec.TryDecodeExact(rootObject.GetProperty("y").GetString(), 32, out var rootY)) throw new WebDropException(ErrorCode.BadRequest);
|
|
using var device = System.Text.Json.JsonDocument.Parse(req.PublicKey);
|
|
var deviceObject = device.RootElement;
|
|
if (!Codec.TryDecodeExact(deviceObject.GetProperty("x").GetString(), 32, out var deviceX)
|
|
|| !Codec.TryDecodeExact(deviceObject.GetProperty("y").GetString(), 32, out var deviceY)) throw new WebDropException(ErrorCode.BadRequest);
|
|
var payload = SignatureCodec.DeviceCertificatePayload(username, deviceId, deviceX, deviceY);
|
|
var signature = DecodeBase64(req.Signature, 64, 64);
|
|
if (!SignatureCodec.VerifyP256(rootX, rootY, "device.certificate", payload, signature)) throw new WebDropException(ErrorCode.Forbidden);
|
|
if (!accountStore.SaveDeviceSigningKey(username, deviceId, req.PublicKey, signature)) throw new WebDropException(ErrorCode.Forbidden);
|
|
return Results.NoContent();
|
|
});
|
|
api.MapDelete("/accounts/devices/{id}", (string id, HttpContext ctx, ChannelService svc) =>
|
|
{
|
|
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
|
accounts.RevokeDevice(username, id);
|
|
return Results.NoContent();
|
|
});
|
|
|
|
api.MapPut("/accounts/vault", async (HttpContext ctx, CancellationToken ct) =>
|
|
{
|
|
if (!TryAccount(ctx, accounts, out var username)) return Json(new ErrorBody { Error = "account_invalid" }, ApiJson.Default.ErrorBody, StatusCodes.Status401Unauthorized);
|
|
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountVaultRequest, ct);
|
|
if (req.Revision < 0) return Results.BadRequest();
|
|
var vault = DecodeBase64(req.Vault, 28, 1_300_000);
|
|
var next = accounts.PutVault(username, vault, req.Revision);
|
|
return next is null ? Json(new ErrorBody { Error = "vault_conflict" }, ApiJson.Default.ErrorBody, StatusCodes.Status409Conflict) : Json(new AccountVaultBody { Revision = next.Value, Vault = req.Vault }, ApiJson.Default.AccountVaultBody);
|
|
});
|
|
|
|
api.MapDelete("/accounts/session", (HttpContext ctx) =>
|
|
{
|
|
var accountToken = ctx.Request.Headers["X-WD-Account"].ToString();
|
|
if (accountToken.Length > 0) { accounts.RevokeSession(accountToken); return Results.NoContent(); }
|
|
var header = ctx.Request.Headers["Authorization"].ToString();
|
|
if (!header.StartsWith("Bearer ", StringComparison.Ordinal)) return Results.NoContent();
|
|
accounts.RevokeSession(header[7..]);
|
|
return Results.NoContent();
|
|
});
|
|
|
|
}
|
|
}
|