Files
WebDrop/server/src/WebDrop.Api/RequestAuth.cs
T
justme c858bd2a09 feat(server)!: require a logged-in account for every channel request
Drop unauthenticated guests from channels: the allow_guests setting, the
/guests endpoints and the guest seat table are gone, and requests without an
account session get 401 account_required. Share links are unaffected.
Contract version is now 8.0.0.
2026-10-16 06:46:15 +08:00

106 lines
4.9 KiB
C#

using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
/// <summary>从请求头(或 WebSocket 子协议)解析凭据。这里只做格式校验,令牌是否正确由服务判断。</summary>
public static class RequestAuth
{
public const string RoleHeader = "X-WD-Role";
public const string EndpointHeader = "X-WD-Endpoint";
public const string CaptchaHeader = "X-WD-Cap";
public static Credentials FromHeaders(HttpRequest req, bool requireEndpoint)
{
var role = ParseRole(req.Headers[RoleHeader].ToString());
byte[]? token = null;
string auth = req.Headers.Authorization.ToString();
if (auth.Length > 0)
{
const string prefix = "Bearer ";
if (!auth.StartsWith(prefix, StringComparison.Ordinal)
|| !Codec.TryDecodeExact(auth[prefix.Length..], TokenHash.Size, out token))
throw new WebDropException(ErrorCode.BadRequest);
}
string? accountName = null;
string? deviceId = null;
var accountToken = req.Headers["X-WD-Account"].ToString();
if (accountToken.Length > 0)
{
var accounts = req.HttpContext.RequestServices.GetRequiredService<SqliteAccountStore>();
if (!accounts.HasSession(accountToken, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
accountName = username;
deviceId = accounts.SessionDevice(accountToken);
}
if (role == Role.Owner && accountName is null) throw new WebDropException(ErrorCode.AccountRequired);
// 账号以会话鉴权;终端密钥只用于便捷分享(未登录的创建者与读者)
return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint && accountName is null), accountName, deviceId, accountName is not null);
}
/// <summary>
/// 浏览器的 WebSocket 不能设请求头,凭据放在子协议里:
/// wd.v1, wd.role.&lt;role&gt;, wd.auth.&lt;b64url&gt;, wd.ep.&lt;b64url&gt;
/// </summary>
public static Credentials FromSubprotocols(IEnumerable<string> protocols, SqliteAccountStore? accounts = null)
{
string? role = null, auth = null, endpoint = null, accountToken = null;
foreach (string p in protocols)
{
if (p.StartsWith("wd.role.", StringComparison.Ordinal)) role = p["wd.role.".Length..];
else if (p.StartsWith("wd.auth.", StringComparison.Ordinal)) auth = p["wd.auth.".Length..];
else if (p.StartsWith("wd.ep.", StringComparison.Ordinal)) endpoint = p["wd.ep.".Length..];
else if (p.StartsWith("wd.account.", StringComparison.Ordinal)) accountToken = p["wd.account.".Length..];
}
byte[]? token = null;
if (auth is not null && !Codec.TryDecodeExact(auth, TokenHash.Size, out token))
throw new WebDropException(ErrorCode.BadRequest);
string? accountName = null;
string? deviceId = null;
if (accountToken is not null)
{
if (accounts is null || !Codec.TryDecodeExact(accountToken, 32, out var raw)
|| !accounts.HasSession(Convert.ToBase64String(raw), out var username)) throw new WebDropException(ErrorCode.AccountRequired);
accountName = username;
deviceId = accounts.SessionDevice(Convert.ToBase64String(raw));
}
var parsedRole = ParseRole(role);
if (parsedRole == Role.Owner && accountName is null) throw new WebDropException(ErrorCode.AccountRequired);
return new Credentials(parsedRole, token, ParseEndpoint(endpoint, requireEndpoint: false), accountName, deviceId, accountName is not null);
}
public static byte[] ParseEndpointHash(string? header) => ParseEndpoint(header, requireEndpoint: true);
public static byte[] ParseEndpointHashOptional(string? header) => ParseEndpoint(header, requireEndpoint: false);
public static Credentials ShareFromHeaders(HttpRequest req, bool requireEndpoint)
{
var credentials = FromHeaders(req, requireEndpoint);
if (credentials.ClaimedRole == Role.Write) throw new WebDropException(ErrorCode.BadRequest);
return credentials;
}
private static Role ParseRole(string? value) => value switch
{
"read" => Role.Read,
"write" => Role.Write,
"owner" => Role.Owner,
_ => throw new WebDropException(ErrorCode.BadRequest),
};
/// <summary>终端密钥 S 为 32 字节,服务端只保存并使用 SHA-256(S)。缺省时为空,仅允许不需要席位的操作。</summary>
private static byte[] ParseEndpoint(string? value, bool requireEndpoint)
{
if (string.IsNullOrEmpty(value))
{
if (requireEndpoint) throw new WebDropException(ErrorCode.BadRequest);
return [];
}
if (!Codec.TryDecodeExact(value, 32, out var secret)) throw new WebDropException(ErrorCode.BadRequest);
return TokenHash.Compute(secret);
}
}