forked from Kieran/void.cat
Accept Nostr auth
This commit is contained in:
parent
5b955142aa
commit
6f28c3f293
@ -7,6 +7,7 @@ using VoidCat.Database;
|
|||||||
using VoidCat.Model;
|
using VoidCat.Model;
|
||||||
using VoidCat.Services.Abstractions;
|
using VoidCat.Services.Abstractions;
|
||||||
using VoidCat.Services.Files;
|
using VoidCat.Services.Files;
|
||||||
|
using VoidCat.Services.Users;
|
||||||
using File = VoidCat.Database.File;
|
using File = VoidCat.Database.File;
|
||||||
|
|
||||||
namespace VoidCat.Controllers
|
namespace VoidCat.Controllers
|
||||||
@ -21,12 +22,13 @@ namespace VoidCat.Controllers
|
|||||||
private readonly FileInfoManager _fileInfo;
|
private readonly FileInfoManager _fileInfo;
|
||||||
private readonly IUserUploadsStore _userUploads;
|
private readonly IUserUploadsStore _userUploads;
|
||||||
private readonly IUserStore _userStore;
|
private readonly IUserStore _userStore;
|
||||||
|
private readonly UserManager _userManager;
|
||||||
private readonly ITimeSeriesStatsReporter _timeSeriesStats;
|
private readonly ITimeSeriesStatsReporter _timeSeriesStats;
|
||||||
private readonly VoidSettings _settings;
|
private readonly VoidSettings _settings;
|
||||||
|
|
||||||
public UploadController(FileStoreFactory storage, IFileMetadataStore metadata, IPaymentStore payment,
|
public UploadController(FileStoreFactory storage, IFileMetadataStore metadata, IPaymentStore payment,
|
||||||
IPaymentFactory paymentFactory, FileInfoManager fileInfo, IUserUploadsStore userUploads,
|
IPaymentFactory paymentFactory, FileInfoManager fileInfo, IUserUploadsStore userUploads,
|
||||||
ITimeSeriesStatsReporter timeSeriesStats, IUserStore userStore, VoidSettings settings)
|
ITimeSeriesStatsReporter timeSeriesStats, IUserStore userStore, VoidSettings settings, UserManager userManager)
|
||||||
{
|
{
|
||||||
_storage = storage;
|
_storage = storage;
|
||||||
_metadata = metadata;
|
_metadata = metadata;
|
||||||
@ -37,6 +39,7 @@ namespace VoidCat.Controllers
|
|||||||
_timeSeriesStats = timeSeriesStats;
|
_timeSeriesStats = timeSeriesStats;
|
||||||
_userStore = userStore;
|
_userStore = userStore;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
|
_userManager = userManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -68,6 +71,13 @@ namespace VoidCat.Controllers
|
|||||||
}
|
}
|
||||||
|
|
||||||
var uid = HttpContext.GetUserId();
|
var uid = HttpContext.GetUserId();
|
||||||
|
var pubkey = HttpContext.GetPubKey();
|
||||||
|
if (uid == default && !string.IsNullOrEmpty(pubkey))
|
||||||
|
{
|
||||||
|
var nostrUser = await _userManager.LoginOrRegister(pubkey);
|
||||||
|
uid = nostrUser.Id;
|
||||||
|
}
|
||||||
|
|
||||||
var mime = Request.Headers.GetHeader("V-Content-Type");
|
var mime = Request.Headers.GetHeader("V-Content-Type");
|
||||||
var filename = Request.Headers.GetHeader("V-Filename");
|
var filename = Request.Headers.GetHeader("V-Filename");
|
||||||
|
|
||||||
|
@ -110,5 +110,10 @@ public enum UserAuthType
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lightning node challenge
|
/// Lightning node challenge
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Lightning = 3
|
Lightning = 3,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nostr login
|
||||||
|
/// </summary>
|
||||||
|
Nostr = 4,
|
||||||
}
|
}
|
||||||
|
@ -29,10 +29,16 @@ public static class Extensions
|
|||||||
|
|
||||||
public static Guid? GetUserId(this HttpContext context)
|
public static Guid? GetUserId(this HttpContext context)
|
||||||
{
|
{
|
||||||
var claimSub = context?.User?.Claims?.FirstOrDefault(a => a.Type == ClaimTypes.NameIdentifier)?.Value;
|
var claimSub = context.User.Claims.FirstOrDefault(a => a.Type == ClaimTypes.NameIdentifier)?.Value;
|
||||||
return Guid.TryParse(claimSub, out var g) ? g : null;
|
return Guid.TryParse(claimSub, out var g) ? g : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string? GetPubKey(this HttpContext context)
|
||||||
|
{
|
||||||
|
var claim = context.User.Claims.FirstOrDefault(a => a.Type == ClaimTypes.Name);
|
||||||
|
return claim?.Value;
|
||||||
|
}
|
||||||
|
|
||||||
public static IEnumerable<string>? GetUserRoles(this HttpContext context)
|
public static IEnumerable<string>? GetUserRoles(this HttpContext context)
|
||||||
{
|
{
|
||||||
return context?.User?.Claims?.Where(a => a.Type == ClaimTypes.Role)
|
return context?.User?.Claims?.Where(a => a.Type == ClaimTypes.Role)
|
||||||
|
@ -9,4 +9,5 @@ public static class Roles
|
|||||||
public static class Policies
|
public static class Policies
|
||||||
{
|
{
|
||||||
public const string RequireAdmin = "RequireAdmin";
|
public const string RequireAdmin = "RequireAdmin";
|
||||||
|
public const string RequireNostr = "RequireNostr";
|
||||||
}
|
}
|
91
VoidCat/Services/NostrAuth.cs
Normal file
91
VoidCat/Services/NostrAuth.cs
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Nostr.Client.Json;
|
||||||
|
using Nostr.Client.Messages;
|
||||||
|
|
||||||
|
namespace VoidCat.Services;
|
||||||
|
|
||||||
|
public static class NostrAuth
|
||||||
|
{
|
||||||
|
public const string Scheme = "Nostr";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NostrAuthOptions : AuthenticationSchemeOptions
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NostrAuthHandler : AuthenticationHandler<NostrAuthOptions>
|
||||||
|
{
|
||||||
|
public NostrAuthHandler(IOptionsMonitor<NostrAuthOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) :
|
||||||
|
base(options, logger, encoder, clock)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
var auth = Request.Headers.Authorization.FirstOrDefault()?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(auth))
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("Missing Authorization header");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!auth.StartsWith(NostrAuth.Scheme))
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("Invalid auth scheme");
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = auth[6..];
|
||||||
|
var bToken = Convert.FromBase64String(token);
|
||||||
|
if (string.IsNullOrEmpty(token) || bToken.Length == 0 || bToken[0] != '{')
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("Invalid token");
|
||||||
|
}
|
||||||
|
|
||||||
|
var ev = JsonConvert.DeserializeObject<NostrEvent>(Encoding.UTF8.GetString(bToken), NostrSerializer.Settings);
|
||||||
|
if (ev == default)
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("Invalid nostr event");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ev.IsSignatureValid())
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("Invalid nostr event, invalid sig");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.Kind != (NostrKind)27_235)
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("Invalid nostr event, wrong kind");
|
||||||
|
}
|
||||||
|
|
||||||
|
var diffTime = Math.Abs((ev.CreatedAt!.Value - DateTime.UtcNow).TotalSeconds);
|
||||||
|
if (diffTime > 60d)
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("Invalid nostr event, timestamp out of range");
|
||||||
|
}
|
||||||
|
|
||||||
|
var urlTag = ev.Tags!.FirstOrDefault(a => a.TagIdentifier == "u");
|
||||||
|
var methodTag = ev.Tags!.FirstOrDefault(a => a.TagIdentifier == "method");
|
||||||
|
if (string.IsNullOrEmpty(urlTag?.AdditionalData[0] as string) ||
|
||||||
|
!new Uri((urlTag.AdditionalData[0] as string)!).AbsolutePath.Equals(Request.Path, StringComparison.InvariantCultureIgnoreCase))
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("Invalid nostr event, url tag invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(methodTag?.AdditionalData[0] as string) ||
|
||||||
|
!((methodTag.AdditionalData[0] as string)?.Equals(Request.Method, StringComparison.InvariantCultureIgnoreCase) ?? false))
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("Invalid nostr event, method tag invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
var principal = new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.Name, ev.Pubkey!)
|
||||||
|
});
|
||||||
|
|
||||||
|
return AuthenticateResult.Success(new(new ClaimsPrincipal(new[] {principal}), Scheme.Name));
|
||||||
|
}
|
||||||
|
}
|
42
VoidCat/Services/Users/NostrProfileService.cs
Normal file
42
VoidCat/Services/Users/NostrProfileService.cs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
using Newtonsoft.Json;
|
||||||
|
using Nostr.Client.Json;
|
||||||
|
using Nostr.Client.Messages;
|
||||||
|
using Nostr.Client.Messages.Metadata;
|
||||||
|
using VoidCat.Model;
|
||||||
|
|
||||||
|
namespace VoidCat.Services.Users;
|
||||||
|
|
||||||
|
public class NostrProfileService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
private readonly VoidSettings _settings;
|
||||||
|
|
||||||
|
public NostrProfileService(HttpClient client, VoidSettings settings)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_settings = settings;
|
||||||
|
_client.Timeout = TimeSpan.FromSeconds(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NostrMetadata?> FetchProfile(string pubkey)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var req = await _client.GetAsync($"https://api.snort.social/api/v1/raw/p/{pubkey}");
|
||||||
|
if (req.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var ev = JsonConvert.DeserializeObject<NostrEvent>(await req.Content.ReadAsStringAsync(), NostrSerializer.Settings);
|
||||||
|
if (ev != default)
|
||||||
|
{
|
||||||
|
return JsonConvert.DeserializeObject<NostrMetadata>(ev.Content!, NostrSerializer.Settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
}
|
@ -10,13 +10,15 @@ public class UserManager
|
|||||||
private readonly IUserStore _store;
|
private readonly IUserStore _store;
|
||||||
private readonly IEmailVerification _emailVerification;
|
private readonly IEmailVerification _emailVerification;
|
||||||
private readonly OAuthFactory _oAuthFactory;
|
private readonly OAuthFactory _oAuthFactory;
|
||||||
|
private readonly NostrProfileService _nostrProfile;
|
||||||
private static bool _checkFirstRegister;
|
private static bool _checkFirstRegister;
|
||||||
|
|
||||||
public UserManager(IUserStore store, IEmailVerification emailVerification, OAuthFactory oAuthFactory)
|
public UserManager(IUserStore store, IEmailVerification emailVerification, OAuthFactory oAuthFactory, NostrProfileService nostrProfile)
|
||||||
{
|
{
|
||||||
_store = store;
|
_store = store;
|
||||||
_emailVerification = emailVerification;
|
_emailVerification = emailVerification;
|
||||||
_oAuthFactory = oAuthFactory;
|
_oAuthFactory = oAuthFactory;
|
||||||
|
_nostrProfile = nostrProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -108,6 +110,42 @@ public class UserManager
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Login or Register with nostr pubkey
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="pubkey">Hex public key</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
/// <exception cref="InvalidOperationException"></exception>
|
||||||
|
public async ValueTask<User> LoginOrRegister(string pubkey)
|
||||||
|
{
|
||||||
|
var uid = await _store.LookupUser(pubkey);
|
||||||
|
if (uid.HasValue)
|
||||||
|
{
|
||||||
|
var existingUser = await _store.Get(uid.Value);
|
||||||
|
if (existingUser?.AuthType == UserAuthType.Nostr)
|
||||||
|
{
|
||||||
|
return existingUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Auth failure, user type does not match!");
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile = await _nostrProfile.FetchProfile(pubkey);
|
||||||
|
var newUser = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
AuthType = UserAuthType.Nostr,
|
||||||
|
Created = DateTime.UtcNow,
|
||||||
|
Avatar = profile?.Picture,
|
||||||
|
DisplayName = profile?.Name ?? "Nostrich",
|
||||||
|
Email = pubkey,
|
||||||
|
Flags = UserFlags.EmailVerified // always mark as email verififed
|
||||||
|
};
|
||||||
|
|
||||||
|
await SetupNewUser(newUser);
|
||||||
|
return newUser;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SetupNewUser(User newUser)
|
private async Task SetupNewUser(User newUser)
|
||||||
{
|
{
|
||||||
// automatically set first user to admin
|
// automatically set first user to admin
|
||||||
@ -127,7 +165,10 @@ public class UserManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _store.Add(newUser);
|
await _store.Add(newUser);
|
||||||
await _emailVerification.SendNewCode(newUser);
|
if (!newUser.Flags.HasFlag(UserFlags.EmailVerified))
|
||||||
|
{
|
||||||
|
await _emailVerification.SendNewCode(newUser);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleLogin(User user)
|
private async Task HandleLogin(User user)
|
||||||
|
@ -10,6 +10,7 @@ public static class UsersStartup
|
|||||||
{
|
{
|
||||||
services.AddTransient<UserManager>();
|
services.AddTransient<UserManager>();
|
||||||
services.AddTransient<OAuthFactory>();
|
services.AddTransient<OAuthFactory>();
|
||||||
|
services.AddTransient<NostrProfileService>();
|
||||||
|
|
||||||
if (settings.HasDiscord())
|
if (settings.HasDiscord())
|
||||||
{
|
{
|
||||||
|
@ -39,6 +39,7 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="NBitcoin" Version="7.0.25" />
|
<PackageReference Include="NBitcoin" Version="7.0.25" />
|
||||||
<PackageReference Include="nClam" Version="7.0.0" />
|
<PackageReference Include="nClam" Version="7.0.0" />
|
||||||
|
<PackageReference Include="Nostr.Client" Version="1.4.3" />
|
||||||
<PackageReference Include="Npgsql" Version="7.0.2" />
|
<PackageReference Include="Npgsql" Version="7.0.2" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.1" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.1" />
|
||||||
<PackageReference Include="prometheus-net.AspNetCore" Version="8.0.0" />
|
<PackageReference Include="prometheus-net.AspNetCore" Version="8.0.0" />
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Authorization.Infrastructure;
|
||||||
using Microsoft.AspNetCore.HttpLogging;
|
using Microsoft.AspNetCore.HttpLogging;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi.Models;
|
||||||
@ -133,7 +136,12 @@ public static class VoidStartup
|
|||||||
|
|
||||||
services.AddHealthChecks();
|
services.AddHealthChecks();
|
||||||
|
|
||||||
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
services.AddTransient<NostrAuthHandler>();
|
||||||
|
services.AddAuthentication(o =>
|
||||||
|
{
|
||||||
|
o.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||||
|
o.AddScheme<NostrAuthHandler>(NostrAuth.Scheme, "Nostr");
|
||||||
|
})
|
||||||
.AddJwtBearer(options =>
|
.AddJwtBearer(options =>
|
||||||
{
|
{
|
||||||
options.TokenValidationParameters = new()
|
options.TokenValidationParameters = new()
|
||||||
@ -147,7 +155,15 @@ public static class VoidStartup
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddAuthorization((opt) => { opt.AddPolicy(Policies.RequireAdmin, (auth) => { auth.RequireRole(Roles.Admin); }); });
|
services.AddAuthorization((opt) =>
|
||||||
|
{
|
||||||
|
opt.AddPolicy(Policies.RequireNostr, new AuthorizationPolicy(new[]
|
||||||
|
{
|
||||||
|
new ClaimsAuthorizationRequirement(ClaimTypes.Name, null)
|
||||||
|
}, new[] {NostrAuth.Scheme}));
|
||||||
|
|
||||||
|
opt.AddPolicy(Policies.RequireAdmin, auth => { auth.RequireRole(Roles.Admin); });
|
||||||
|
});
|
||||||
|
|
||||||
services.AddTransient<RazorPartialToStringRenderer>();
|
services.AddTransient<RazorPartialToStringRenderer>();
|
||||||
services.AddAnalytics(voidSettings);
|
services.AddAnalytics(voidSettings);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@void-cat/api",
|
"name": "@void-cat/api",
|
||||||
"version": "1.0.7",
|
"version": "1.0.8",
|
||||||
"description": "void.cat API package",
|
"description": "void.cat API package",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
@ -16,10 +16,12 @@ import { XHRUploader } from "./xhr-uploader";
|
|||||||
export class VoidApi {
|
export class VoidApi {
|
||||||
readonly #uri: string
|
readonly #uri: string
|
||||||
readonly #auth?: string
|
readonly #auth?: string
|
||||||
|
readonly #scheme?: string
|
||||||
|
|
||||||
constructor(uri: string, auth?: string) {
|
constructor(uri: string, auth?: string, scheme?: string) {
|
||||||
this.#uri = uri;
|
this.#uri = uri;
|
||||||
this.#auth = auth;
|
this.#auth = auth;
|
||||||
|
this.#scheme = scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
async #req<T>(method: string, url: string, body?: object): Promise<T> {
|
async #req<T>(method: string, url: string, body?: object): Promise<T> {
|
||||||
@ -27,7 +29,7 @@ export class VoidApi {
|
|||||||
"Accept": "application/json"
|
"Accept": "application/json"
|
||||||
};
|
};
|
||||||
if (this.#auth) {
|
if (this.#auth) {
|
||||||
headers["Authorization"] = `Bearer ${this.#auth}`;
|
headers["Authorization"] = `${this.#scheme ?? "Bearer"} ${this.#auth}`;
|
||||||
}
|
}
|
||||||
if (body) {
|
if (body) {
|
||||||
headers["Content-Type"] = "application/json";
|
headers["Content-Type"] = "application/json";
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "src",
|
"baseUrl": "src",
|
||||||
"target": "ES2020",
|
"target": "ESNext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "NodeNext",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"module": "CommonJS",
|
"module": "NodeNext",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
@ -14,5 +14,6 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"allowJs": true
|
"allowJs": true
|
||||||
},
|
},
|
||||||
|
"include": ["./src/**/*.ts"],
|
||||||
"files": ["./src/index.ts"]
|
"files": ["./src/index.ts"]
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user