Compare commits

..

47 Commits

Author SHA1 Message Date
21c7bcca5a bump 2020-03-06 15:16:53 +09:00
1df0fe9deb Merge pull request #1369 from pavlenex/readme-macos-dev-video
Readme Update .Net Core to 3.1, Add MacOS video
2020-03-04 16:46:07 +09:00
7038c28429 Merge pull request #1370 from bolatovumar/prettify-receive-tab
Prettify wallet receive tab screen
2020-03-04 16:45:34 +09:00
d9bdb46033 Prettify wallet receive tab screen 2020-03-03 21:15:27 -08:00
e0aad34105 Update .Net Core to 3.1, Add MacOS video 2020-03-03 22:44:58 +01:00
a88f46e1ab Merge pull request #1365 from bolatovumar/fix-1332
Specify QR code error correction level explicitly
2020-03-03 18:03:40 +09:00
ba480e40e6 Merge pull request #1362 from pavlenex/readme-deployment
add enviroment configuration to issue template
2020-03-02 18:17:36 +09:00
ef52d6b4c7 Merge pull request #1352 from Kukks/changelly-fiat
remove changelly fiat option
2020-03-02 18:10:50 +09:00
99f47e2848 Merge pull request #1360 from Kukks/pay-button-modal
Modal option for Pay Button
2020-03-02 18:10:14 +09:00
8046872315 Add private info note, change command 2020-03-02 10:07:50 +01:00
b282a70534 Merge pull request #1351 from Kukks/api/api-keys-get
GreenField API #1: Get current API Key info
2020-03-02 18:06:34 +09:00
991daefd85 Merge pull request #1359 from Kukks/cf-fixes
Use proper divisibility for payments in crowdfund and do not show too…
2020-03-02 18:04:14 +09:00
2a0353b6ff Merge pull request #1367 from btcpayserver/fix/flaky-tests
Fixing Selenium tests failing because of dynamic hidden elements
2020-03-02 17:58:06 +09:00
304caaaf1d Fixing Selenium tests failing because of dynamic hidden elements 2020-03-01 22:38:40 -06:00
4f5f52b937 Merge pull request #1366 from btcpayserver/pr/fix-931
Fixing modal Open Wallet click on iOS Safari by targeting iframe parent
2020-03-01 22:05:20 -06:00
0b4760bc29 Merge pull request #1361 from btcpayserver/pr/fix-1316
Showing the next available address in the invoices list
2020-03-01 21:49:14 -06:00
7f6d27cc5b Fixing modal Open Wallet click on iOS Safari by targeting iframe parent 2020-03-01 20:51:34 -06:00
f8520201ce Using tuple for payments partial model 2020-03-01 19:46:05 -06:00
efda8ff5bd Specify QR code error correction level explicitly
fix #1332
2020-03-01 14:16:24 -08:00
27f964e2a1 add enviroment configuration 2020-02-29 10:46:07 +01:00
56380a5fb3 Formatting code 2020-02-28 23:15:14 -06:00
a303e793b4 Fixing CanCreateApiKeys test admin user check 2020-02-28 23:15:06 -06:00
2934c27ee5 Commenting decision to leave partial 2020-02-28 17:04:56 -06:00
44d4673981 Removing reference to InvoicePaymentsPartial.cshtml from Invoice.cshtml 2020-02-28 16:58:09 -06:00
fca6b39681 Revert "Remove the next address to pay to from Invoice details page (Fix #1056) (#1283)"
This reverts commit 6848482999910d4480773a24c6bab407e0565023.
2020-02-28 16:30:57 -06:00
c3bfce7656 Modal option for Pay Button
closes #796
2020-02-28 16:01:44 +01:00
c607696230 Use proper divisibility for payments in crowdfund and do not show tooltip if identical data
fixes #1037 and fixes #1003
2020-02-28 12:51:15 +01:00
9eac33793a GreenField API #1: Get current API Key info 2020-02-26 16:20:32 +01:00
18aaa1a0c4 Merge pull request #1341 from btcpayserver/swagger
Add Swagger and Redoc
2020-02-26 19:02:35 +09:00
e7eea1036b make api key delete use confirm page 2020-02-26 10:26:38 +01:00
48c21baee5 add migration attributes and remove designer 2020-02-26 09:53:58 +01:00
95b9884af7 Revert "consolidate migrations"
This reverts commit 501c3241b543c5941ec9e5d7ecc14cf1edf9661f.
2020-02-26 09:41:32 +01:00
d9ea9fbffd Fix colspan 2020-02-26 17:34:32 +09:00
0c7f35b000 fix swagger gen 2020-02-26 09:17:50 +01:00
78f73132ed Delete docs folder (#1354) 2020-02-26 14:00:46 +09:00
5a93857b4a Simplifying delegate invoke
Ref: 0074790684 (r37477529)
2020-02-25 16:08:57 -06:00
b71fd1653e remove changelly fiat option
closes #728
2020-02-25 16:44:19 +01:00
ec80787120 fix 2020-02-25 15:33:04 +01:00
501c3241b5 consolidate migrations 2020-02-25 15:00:47 +01:00
0a8b303c11 add label for api keys, make api keys without -, fix null exception on authorize 2020-02-25 14:43:53 +01:00
fec5637040 Replace Datetime.UTCNow by entity.InvoiceTime 2020-02-25 17:22:39 +09:00
5cbe61e2e0 Allow user to set the expirationTime of invoice via the API (Fix #1336) 2020-02-25 17:21:08 +09:00
023e64704d Add Swagger and Redoc
Blocked by #1262
2020-02-24 19:04:04 +01:00
276a9a95f9 Remove OpenIddict (#1244) 2020-02-25 00:40:04 +09:00
d16a4334cb Fix error 500 on services page 2020-02-25 00:10:07 +09:00
fa51180dfa Api keys with openiddict (#1262)
* Remove OpenIddict

* Add API Key system

* Revert removing OpenIddict

* fix rebase

* fix tests

* pr changes

* fix tests

* fix apikey test

* pr change

* fix db

* add migration attrs

* fix migration error

* PR Changes

* Fix sqlite migration

* change api key to use Authorization Header

* add supportAddForeignKey

* use tempdata status message

* fix add api key css

* remove redirect url + app identifier feature :(
2020-02-24 22:36:15 +09:00
a3e7729c52 Remove warnings 2020-02-24 22:12:50 +09:00
99 changed files with 2140 additions and 1837 deletions

View File

@ -14,6 +14,9 @@ A clear and concise description of what the bug is.
**Logs (if applicable)**
Basic logs can be found in Server Settings > Logs.
**Setup Parameters**
If you're reporting a deployment issue run `. btcpay-setup.sh -i` and paste your the paremeters by obscuring private information.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'

View File

@ -7,7 +7,6 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.1.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="3.1.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="3.1.0" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="3.0.0-alpha1.20058.15" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="3.1.1" />
</ItemGroup>
</Project>

View File

@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Threading.Tasks;
@ -11,15 +13,32 @@ namespace BTCPayServer.Data
[MaxLength(50)]
public string Id
{
get; set;
get;
set;
}
[MaxLength(50)]
public string StoreId
{
get; set;
}
[MaxLength(50)] public string StoreId { get; set; }
[MaxLength(50)] public string UserId { get; set; }
public APIKeyType Type { get; set; } = APIKeyType.Legacy;
public string Permissions { get; set; }
public StoreData StoreData { get; set; }
public ApplicationUser User { get; set; }
public string Label { get; set; }
public string[] GetPermissions() { return Permissions?.Split(';') ?? new string[0]; }
public void SetPermissions(IEnumerable<string> permissions)
{
Permissions = string.Join(';',
permissions?.Select(s => s.Replace(";", string.Empty)) ?? new string[0]);
}
}
public enum APIKeyType
{
Legacy,
Permanent
}
}

View File

@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Infrastructure;
using OpenIddict.EntityFrameworkCore.Models;
namespace BTCPayServer.Data
{
@ -160,6 +159,12 @@ namespace BTCPayServer.Data
.HasOne(o => o.StoreData)
.WithMany(i => i.APIKeys)
.HasForeignKey(i => i.StoreId).OnDelete(DeleteBehavior.Cascade);
builder.Entity<APIKeyData>()
.HasOne(o => o.User)
.WithMany(i => i.APIKeys)
.HasForeignKey(i => i.UserId).OnDelete(DeleteBehavior.Cascade);
builder.Entity<APIKeyData>()
.HasIndex(o => o.StoreId);
@ -255,28 +260,6 @@ namespace BTCPayServer.Data
.HasOne(o => o.WalletData)
.WithMany(w => w.WalletTransactions).OnDelete(DeleteBehavior.Cascade);
builder.UseOpenIddict<BTCPayOpenIdClient, BTCPayOpenIdAuthorization, OpenIddictScope<string>, BTCPayOpenIdToken, string>();
if (Database.IsSqlite() && !_designTime)
{
// SQLite does not have proper support for DateTimeOffset via Entity Framework Core, see the limitations
// here: https://docs.microsoft.com/en-us/ef/core/providers/sqlite/limitations#query-limitations
// To work around this, when the Sqlite database provider is used, all model properties of type DateTimeOffset
// use the DateTimeOffsetToBinaryConverter
// Based on: https://github.com/aspnet/EntityFrameworkCore/issues/10784#issuecomment-415769754
// This only supports millisecond precision, but should be sufficient for most use cases.
foreach (var entityType in builder.Model.GetEntityTypes())
{
var properties = entityType.ClrType.GetProperties().Where(p => p.PropertyType == typeof(DateTimeOffset));
foreach (var property in properties)
{
builder
.Entity(entityType.Name)
.Property(property.Name)
.HasConversion(new Microsoft.EntityFrameworkCore.Storage.ValueConversion.DateTimeOffsetToBinaryConverter());
}
}
}
}
}

View File

@ -20,9 +20,7 @@ namespace BTCPayServer.Data
{
get; set;
}
public List<BTCPayOpenIdClient> OpenIdClients { get; set; }
public List<StoredFile> StoredFiles
{
get;
@ -30,5 +28,6 @@ namespace BTCPayServer.Data
}
public List<U2FDevice> U2FDevices { get; set; }
public List<APIKeyData> APIKeys { get; set; }
}
}

View File

@ -1,6 +0,0 @@
using OpenIddict.EntityFrameworkCore.Models;
namespace BTCPayServer.Data
{
public class BTCPayOpenIdAuthorization : OpenIddictAuthorization<string, BTCPayOpenIdClient, BTCPayOpenIdToken> { }
}

View File

@ -1,10 +0,0 @@
using OpenIddict.EntityFrameworkCore.Models;
namespace BTCPayServer.Data
{
public class BTCPayOpenIdClient: OpenIddictApplication<string, BTCPayOpenIdAuthorization, BTCPayOpenIdToken>
{
public string ApplicationUserId { get; set; }
public ApplicationUser ApplicationUser { get; set; }
}
}

View File

@ -1,6 +0,0 @@
using OpenIddict.EntityFrameworkCore.Models;
namespace BTCPayServer.Data
{
public class BTCPayOpenIdToken : OpenIddictToken<string, BTCPayOpenIdClient, BTCPayOpenIdAuthorization> { }
}

View File

@ -0,0 +1,74 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20200119130108_ExtendApiKeys")]
public partial class ExtendApiKeys : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Permissions",
table: "ApiKeys",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Type",
table: "ApiKeys",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "UserId",
table: "ApiKeys",
maxLength: 50,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_ApiKeys_UserId",
table: "ApiKeys",
column: "UserId");
if (this.SupportAddForeignKey(migrationBuilder.ActiveProvider))
{
migrationBuilder.AddForeignKey(
name: "FK_ApiKeys_AspNetUsers_UserId",
table: "ApiKeys",
column: "UserId",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
protected override void Down(MigrationBuilder migrationBuilder)
{
if (this.SupportDropForeignKey(migrationBuilder.ActiveProvider))
{
migrationBuilder.DropForeignKey(
name: "FK_ApiKeys_AspNetUsers_UserId",
table: "ApiKeys");
}
migrationBuilder.DropIndex(
name: "IX_ApiKeys_UserId",
table: "ApiKeys");
if (this.SupportDropColumn(migrationBuilder.ActiveProvider))
{
migrationBuilder.DropColumn(
name: "Permissions",
table: "ApiKeys");
migrationBuilder.DropColumn(
name: "Type",
table: "ApiKeys");
migrationBuilder.DropColumn(
name: "UserId",
table: "ApiKeys");
}
}
}
}

View File

@ -0,0 +1,173 @@
using System;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20200224134444_Remove_OpenIddict")]
public partial class Remove_OpenIddict : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "OpenIddictScopes");
migrationBuilder.DropTable(
name: "OpenIddictTokens");
migrationBuilder.DropTable(
name: "OpenIddictAuthorizations");
migrationBuilder.DropTable(
name: "OpenIddictApplications");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
int? maxLength = this.IsMySql(migrationBuilder.ActiveProvider) ? (int?)255 : null;
migrationBuilder.CreateTable(
name: "OpenIddictApplications",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false, maxLength: maxLength),
ApplicationUserId = table.Column<string>(type: "TEXT", nullable: true, maxLength: maxLength),
ClientId = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
ClientSecret = table.Column<string>(type: "TEXT", nullable: true),
ConcurrencyToken = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true),
ConsentType = table.Column<string>(type: "TEXT", nullable: true),
DisplayName = table.Column<string>(type: "TEXT", nullable: true),
Permissions = table.Column<string>(type: "TEXT", nullable: true),
PostLogoutRedirectUris = table.Column<string>(type: "TEXT", nullable: true),
Properties = table.Column<string>(type: "TEXT", nullable: true),
RedirectUris = table.Column<string>(type: "TEXT", nullable: true),
Requirements = table.Column<string>(type: "TEXT", nullable: true),
Type = table.Column<string>(type: "TEXT", maxLength: 25, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OpenIddictApplications", x => x.Id);
table.ForeignKey(
name: "FK_OpenIddictApplications_AspNetUsers_ApplicationUserId",
column: x => x.ApplicationUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "OpenIddictScopes",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false, maxLength: maxLength),
ConcurrencyToken = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true),
Description = table.Column<string>(type: "TEXT", nullable: true),
DisplayName = table.Column<string>(type: "TEXT", nullable: true),
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Properties = table.Column<string>(type: "TEXT", nullable: true),
Resources = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OpenIddictScopes", x => x.Id);
});
migrationBuilder.CreateTable(
name: "OpenIddictAuthorizations",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false, maxLength: maxLength),
ApplicationId = table.Column<string>(type: "TEXT", nullable: true, maxLength: maxLength),
ConcurrencyToken = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true),
Properties = table.Column<string>(type: "TEXT", nullable: true),
Scopes = table.Column<string>(type: "TEXT", nullable: true),
Status = table.Column<string>(type: "TEXT", maxLength: 25, nullable: false),
Subject = table.Column<string>(type: "TEXT", maxLength: 450, nullable: true),
Type = table.Column<string>(type: "TEXT", maxLength: 25, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OpenIddictAuthorizations", x => x.Id);
table.ForeignKey(
name: "FK_OpenIddictAuthorizations_OpenIddictApplications_ApplicationId",
column: x => x.ApplicationId,
principalTable: "OpenIddictApplications",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "OpenIddictTokens",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false, maxLength: maxLength),
ApplicationId = table.Column<string>(type: "TEXT", nullable: true, maxLength: maxLength),
AuthorizationId = table.Column<string>(type: "TEXT", nullable: true, maxLength: maxLength),
ConcurrencyToken = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true),
CreationDate = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
ExpirationDate = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
Payload = table.Column<string>(type: "TEXT", nullable: true),
Properties = table.Column<string>(type: "TEXT", nullable: true),
ReferenceId = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
Status = table.Column<string>(type: "TEXT", maxLength: 25, nullable: false),
Subject = table.Column<string>(type: "TEXT", maxLength: 450, nullable: true),
Type = table.Column<string>(type: "TEXT", maxLength: 25, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OpenIddictTokens", x => x.Id);
table.ForeignKey(
name: "FK_OpenIddictTokens_OpenIddictApplications_ApplicationId",
column: x => x.ApplicationId,
principalTable: "OpenIddictApplications",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_OpenIddictTokens_OpenIddictAuthorizations_AuthorizationId",
column: x => x.AuthorizationId,
principalTable: "OpenIddictAuthorizations",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_OpenIddictApplications_ApplicationUserId",
table: "OpenIddictApplications",
column: "ApplicationUserId");
migrationBuilder.CreateIndex(
name: "IX_OpenIddictApplications_ClientId",
table: "OpenIddictApplications",
column: "ClientId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OpenIddictAuthorizations_ApplicationId_Status_Subject_Type",
table: "OpenIddictAuthorizations",
columns: new[] { "ApplicationId", "Status", "Subject", "Type" });
migrationBuilder.CreateIndex(
name: "IX_OpenIddictScopes_Name",
table: "OpenIddictScopes",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OpenIddictTokens_AuthorizationId",
table: "OpenIddictTokens",
column: "AuthorizationId");
migrationBuilder.CreateIndex(
name: "IX_OpenIddictTokens_ReferenceId",
table: "OpenIddictTokens",
column: "ReferenceId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OpenIddictTokens_ApplicationId_Status_Subject_Type",
table: "OpenIddictTokens",
columns: new[] { "ApplicationId", "Status", "Subject", "Type" });
}
}
}

View File

@ -0,0 +1,27 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20200225133433_AddApiKeyLabel")]
public partial class AddApiKeyLabel : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Label",
table: "ApiKeys",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Label",
table: "ApiKeys");
}
}
}

View File

@ -22,14 +22,29 @@ namespace BTCPayServer.Migrations
.HasColumnType("TEXT")
.HasMaxLength(50);
b.Property<string>("Label")
.HasColumnType("TEXT");
b.Property<string>("Permissions")
.HasColumnType("TEXT");
b.Property<string>("StoreId")
.HasColumnType("TEXT")
.HasMaxLength(50);
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<string>("UserId")
.HasColumnType("TEXT")
.HasMaxLength(50);
b.HasKey("Id");
b.HasIndex("StoreId");
b.HasIndex("UserId");
b.ToTable("ApiKeys");
});
@ -148,164 +163,6 @@ namespace BTCPayServer.Migrations
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("BTCPayServer.Data.BTCPayOpenIdAuthorization", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ApplicationId")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasColumnType("TEXT")
.HasMaxLength(50);
b.Property<string>("Properties")
.HasColumnType("TEXT");
b.Property<string>("Scopes")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasMaxLength(25);
b.Property<string>("Subject")
.HasColumnType("TEXT")
.HasMaxLength(450);
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasMaxLength(25);
b.HasKey("Id");
b.HasIndex("ApplicationId", "Status", "Subject", "Type");
b.ToTable("OpenIddictAuthorizations");
});
modelBuilder.Entity("BTCPayServer.Data.BTCPayOpenIdClient", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ApplicationUserId")
.HasColumnType("TEXT");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("TEXT")
.HasMaxLength(100);
b.Property<string>("ClientSecret")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasColumnType("TEXT")
.HasMaxLength(50);
b.Property<string>("ConsentType")
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.HasColumnType("TEXT");
b.Property<string>("Permissions")
.HasColumnType("TEXT");
b.Property<string>("PostLogoutRedirectUris")
.HasColumnType("TEXT");
b.Property<string>("Properties")
.HasColumnType("TEXT");
b.Property<string>("RedirectUris")
.HasColumnType("TEXT");
b.Property<string>("Requirements")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasMaxLength(25);
b.HasKey("Id");
b.HasIndex("ApplicationUserId");
b.HasIndex("ClientId")
.IsUnique();
b.ToTable("OpenIddictApplications");
});
modelBuilder.Entity("BTCPayServer.Data.BTCPayOpenIdToken", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ApplicationId")
.HasColumnType("TEXT");
b.Property<string>("AuthorizationId")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasColumnType("TEXT")
.HasMaxLength(50);
b.Property<DateTimeOffset?>("CreationDate")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("ExpirationDate")
.HasColumnType("TEXT");
b.Property<string>("Payload")
.HasColumnType("TEXT");
b.Property<string>("Properties")
.HasColumnType("TEXT");
b.Property<string>("ReferenceId")
.HasColumnType("TEXT")
.HasMaxLength(100);
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasMaxLength(25);
b.Property<string>("Subject")
.HasColumnType("TEXT")
.HasMaxLength(450);
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasMaxLength(25);
b.HasKey("Id");
b.HasIndex("AuthorizationId");
b.HasIndex("ReferenceId")
.IsUnique();
b.HasIndex("ApplicationId", "Status", "Subject", "Type");
b.ToTable("OpenIddictTokens");
});
modelBuilder.Entity("BTCPayServer.Data.HistoricalAddressInvoiceData", b =>
{
b.Property<string>("InvoiceDataId")
@ -800,48 +657,17 @@ namespace BTCPayServer.Migrations
b.ToTable("AspNetUserTokens");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictScope<string>", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasColumnType("TEXT")
.HasMaxLength(50);
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasMaxLength(200);
b.Property<string>("Properties")
.HasColumnType("TEXT");
b.Property<string>("Resources")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("OpenIddictScopes");
});
modelBuilder.Entity("BTCPayServer.Data.APIKeyData", b =>
{
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
.WithMany("APIKeys")
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("BTCPayServer.Data.ApplicationUser", "User")
.WithMany("APIKeys")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("BTCPayServer.Data.AddressInvoiceData", b =>
@ -860,31 +686,6 @@ namespace BTCPayServer.Migrations
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("BTCPayServer.Data.BTCPayOpenIdAuthorization", b =>
{
b.HasOne("BTCPayServer.Data.BTCPayOpenIdClient", "Application")
.WithMany("Authorizations")
.HasForeignKey("ApplicationId");
});
modelBuilder.Entity("BTCPayServer.Data.BTCPayOpenIdClient", b =>
{
b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser")
.WithMany("OpenIdClients")
.HasForeignKey("ApplicationUserId");
});
modelBuilder.Entity("BTCPayServer.Data.BTCPayOpenIdToken", b =>
{
b.HasOne("BTCPayServer.Data.BTCPayOpenIdClient", "Application")
.WithMany("Tokens")
.HasForeignKey("ApplicationId");
b.HasOne("BTCPayServer.Data.BTCPayOpenIdAuthorization", "Authorization")
.WithMany("Tokens")
.HasForeignKey("AuthorizationId");
});
modelBuilder.Entity("BTCPayServer.Data.HistoricalAddressInvoiceData", b =>
{
b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData")

View File

@ -11,7 +11,10 @@ namespace BTCPayServer.Migrations
{
return activeProvider != "Microsoft.EntityFrameworkCore.Sqlite";
}
public static bool SupportAddForeignKey(this Microsoft.EntityFrameworkCore.Migrations.Migration migration, string activeProvider)
{
return activeProvider != "Microsoft.EntityFrameworkCore.Sqlite";
}
public static bool SupportDropForeignKey(this Microsoft.EntityFrameworkCore.Migrations.Migration migration, string activeProvider)
{
return activeProvider != "Microsoft.EntityFrameworkCore.Sqlite";

View File

@ -196,14 +196,8 @@ namespace BTCPayServer.Services.Rates
throw new APIException(text);
}
api.ProcessResponse(new InternalHttpWebResponse(webHttpResponse));
// local reference to handle delegate becoming null, extended discussion here:
// https://github.com/btcpayserver/btcpayserver/commit/00747906849f093712c3907c99404c55b3defa66#r37022103
var requestStateChanged = RequestStateChanged;
if (requestStateChanged != null)
{
requestStateChanged(this, RequestMakerState.Finished, text);
return text;
}
RequestStateChanged?.Invoke(this, RequestMakerState.Finished, text);
return text;
}
catch (Exception arg)

View File

@ -0,0 +1,300 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Security.APIKeys;
using BTCPayServer.Tests.Logging;
using BTCPayServer.Views.Manage;
using ExchangeSharp;
using Newtonsoft.Json;
using OpenQA.Selenium;
using Xunit;
using Xunit.Abstractions;
namespace BTCPayServer.Tests
{
public class ApiKeysTests
{
public const int TestTimeout = TestUtils.TestTimeout;
public const string TestApiPath = "api/test/apikey";
public ApiKeysTests(ITestOutputHelper helper)
{
Logs.Tester = new XUnitLog(helper) {Name = "Tests"};
Logs.LogProvider = new XUnitLogProvider(helper);
}
[Fact(Timeout = TestTimeout)]
[Trait("Selenium", "Selenium")]
public async Task CanCreateApiKeys()
{
//there are 2 ways to create api keys:
//as a user through your profile
//as an external application requesting an api key from a user
using (var s = SeleniumTester.Create())
{
await s.StartAsync();
var tester = s.Server;
var user = tester.NewAccount();
user.GrantAccess();
await user.CreateStoreAsync();
s.GoToLogin();
s.Login(user.RegisterDetails.Email, user.RegisterDetails.Password);
s.GoToProfile(ManageNavPages.APIKeys);
s.Driver.FindElement(By.Id("AddApiKey")).Click();
if (!user.IsAdmin)
{
//not an admin, so this permission should not show
Assert.DoesNotContain("ServerManagementPermission", s.Driver.PageSource);
await user.MakeAdmin();
s.Logout();
s.GoToLogin();
s.Login(user.RegisterDetails.Email, user.RegisterDetails.Password);
s.GoToProfile(ManageNavPages.APIKeys);
s.Driver.FindElement(By.Id("AddApiKey")).Click();
}
//server management should show now
s.SetCheckbox(s, "ServerManagementPermission", true);
s.SetCheckbox(s, "StoreManagementPermission", true);
s.Driver.FindElement(By.Id("Generate")).Click();
var superApiKey = s.AssertHappyMessage().FindElement(By.TagName("code")).Text;
//this api key has access to everything
await TestApiAgainstAccessToken(superApiKey, tester, user, APIKeyConstants.Permissions.ServerManagement,
APIKeyConstants.Permissions.StoreManagement);
s.Driver.FindElement(By.Id("AddApiKey")).Click();
s.SetCheckbox(s, "ServerManagementPermission", true);
s.Driver.FindElement(By.Id("Generate")).Click();
var serverOnlyApiKey = s.AssertHappyMessage().FindElement(By.TagName("code")).Text;
await TestApiAgainstAccessToken(serverOnlyApiKey, tester, user,
APIKeyConstants.Permissions.ServerManagement);
s.Driver.FindElement(By.Id("AddApiKey")).Click();
s.SetCheckbox(s, "StoreManagementPermission", true);
s.Driver.FindElement(By.Id("Generate")).Click();
var allStoreOnlyApiKey = s.AssertHappyMessage().FindElement(By.TagName("code")).Text;
await TestApiAgainstAccessToken(allStoreOnlyApiKey, tester, user,
APIKeyConstants.Permissions.StoreManagement);
s.Driver.FindElement(By.Id("AddApiKey")).Click();
s.Driver.FindElement(By.CssSelector("button[value=change-store-mode]")).Click();
//there should be a store already by default in the dropdown
var dropdown = s.Driver.FindElement(By.Name("SpecificStores[0]"));
var option = dropdown.FindElement(By.TagName("option"));
var storeId = option.GetAttribute("value");
option.Click();
s.Driver.FindElement(By.Id("Generate")).Click();
var selectiveStoreApiKey = s.AssertHappyMessage().FindElement(By.TagName("code")).Text;
await TestApiAgainstAccessToken(selectiveStoreApiKey, tester, user,
APIKeyConstants.Permissions.GetStorePermission(storeId));
s.Driver.FindElement(By.Id("AddApiKey")).Click();
s.Driver.FindElement(By.Id("Generate")).Click();
var noPermissionsApiKey = s.AssertHappyMessage().FindElement(By.TagName("code")).Text;
await TestApiAgainstAccessToken(noPermissionsApiKey, tester, user);
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
{
await TestApiAgainstAccessToken<bool>("incorrect key", $"{TestApiPath}/me/id",
tester.PayTester.HttpClient);
});
//let's test the authorized screen now
//options for authorize are:
//applicationName
//redirect
//permissions
//strict
//selectiveStores
UriBuilder authorize = new UriBuilder(tester.PayTester.ServerUri);
authorize.Path = "api-keys/authorize";
authorize.AppendPayloadToQuery(new Dictionary<string, object>()
{
{"redirect", "https://local.local/callback"},
{"applicationName", "kukksappname"},
{"strict", true},
{"selectiveStores", false},
{
"permissions",
new[]
{
APIKeyConstants.Permissions.StoreManagement,
APIKeyConstants.Permissions.ServerManagement
}
},
});
var authUrl = authorize.ToString();
var perms = new[]
{
APIKeyConstants.Permissions.StoreManagement, APIKeyConstants.Permissions.ServerManagement
};
authUrl = authUrl.Replace("permissions=System.String%5B%5D",
string.Join("&", perms.Select(s1 => $"permissions={s1}")));
s.Driver.Navigate().GoToUrl(authUrl);
s.Driver.PageSource.Contains("kukksappname");
Assert.NotNull(s.Driver.FindElement(By.Id("StoreManagementPermission")).GetAttribute("readonly"));
Assert.True(s.Driver.FindElement(By.Id("StoreManagementPermission")).Selected);
Assert.NotNull(s.Driver.FindElement(By.Id("ServerManagementPermission")).GetAttribute("readonly"));
Assert.True(s.Driver.FindElement(By.Id("ServerManagementPermission")).Selected);
Assert.DoesNotContain("change-store-mode", s.Driver.PageSource);
s.Driver.FindElement(By.Id("consent-yes")).Click();
var url = s.Driver.Url;
IEnumerable<KeyValuePair<string, string>> results = url.Split("?").Last().Split("&")
.Select(s1 => new KeyValuePair<string, string>(s1.Split("=")[0], s1.Split("=")[1]));
var apiKeyRepo = s.Server.PayTester.GetService<APIKeyRepository>();
await TestApiAgainstAccessToken(results.Single(pair => pair.Key == "key").Value, tester, user,
(await apiKeyRepo.GetKey(results.Single(pair => pair.Key == "key").Value)).GetPermissions());
authorize = new UriBuilder(tester.PayTester.ServerUri);
authorize.Path = "api-keys/authorize";
authorize.AppendPayloadToQuery(new Dictionary<string, object>()
{
{"strict", false},
{"selectiveStores", true},
{
"permissions",
new[]
{
APIKeyConstants.Permissions.StoreManagement,
APIKeyConstants.Permissions.ServerManagement
}
}
});
authUrl = authorize.ToString();
perms = new[]
{
APIKeyConstants.Permissions.StoreManagement, APIKeyConstants.Permissions.ServerManagement
};
authUrl = authUrl.Replace("permissions=System.String%5B%5D",
string.Join("&", perms.Select(s1 => $"permissions={s1}")));
s.Driver.Navigate().GoToUrl(authUrl);
Assert.DoesNotContain("kukksappname", s.Driver.PageSource);
Assert.Null(s.Driver.FindElement(By.Id("StoreManagementPermission")).GetAttribute("readonly"));
Assert.True(s.Driver.FindElement(By.Id("StoreManagementPermission")).Selected);
Assert.Null(s.Driver.FindElement(By.Id("ServerManagementPermission")).GetAttribute("readonly"));
Assert.True(s.Driver.FindElement(By.Id("ServerManagementPermission")).Selected);
s.SetCheckbox(s, "ServerManagementPermission", false);
Assert.Contains("change-store-mode", s.Driver.PageSource);
s.Driver.FindElement(By.Id("consent-yes")).Click();
url = s.Driver.Url;
results = url.Split("?").Last().Split("&")
.Select(s1 => new KeyValuePair<string, string>(s1.Split("=")[0], s1.Split("=")[1]));
await TestApiAgainstAccessToken(results.Single(pair => pair.Key == "key").Value, tester, user,
(await apiKeyRepo.GetKey(results.Single(pair => pair.Key == "key").Value)).GetPermissions());
}
}
async Task TestApiAgainstAccessToken(string accessToken, ServerTester tester, TestAccount testAccount,
params string[] permissions)
{
var resultUser =
await TestApiAgainstAccessToken<string>(accessToken, $"{TestApiPath}/me/id",
tester.PayTester.HttpClient);
Assert.Equal(testAccount.UserId, resultUser);
//create a second user to see if any of its data gets messed upin our results.
var secondUser = tester.NewAccount();
secondUser.GrantAccess();
var selectiveStorePermissions = APIKeyConstants.Permissions.ExtractStorePermissionsIds(permissions);
if (permissions.Contains(APIKeyConstants.Permissions.StoreManagement) || selectiveStorePermissions.Any())
{
var resultStores =
await TestApiAgainstAccessToken<StoreData[]>(accessToken, $"{TestApiPath}/me/stores",
tester.PayTester.HttpClient);
foreach (string selectiveStorePermission in selectiveStorePermissions)
{
Assert.True(await TestApiAgainstAccessToken<bool>(accessToken,
$"{TestApiPath}/me/stores/{selectiveStorePermission}/can-edit",
tester.PayTester.HttpClient));
Assert.Contains(resultStores,
data => data.Id.Equals(selectiveStorePermission, StringComparison.InvariantCultureIgnoreCase));
}
if (permissions.Contains(APIKeyConstants.Permissions.StoreManagement))
{
Assert.True(await TestApiAgainstAccessToken<bool>(accessToken,
$"{TestApiPath}/me/stores/actions",
tester.PayTester.HttpClient));
Assert.True(await TestApiAgainstAccessToken<bool>(accessToken,
$"{TestApiPath}/me/stores/{testAccount.StoreId}/can-edit",
tester.PayTester.HttpClient));
Assert.Contains(resultStores,
data => data.Id.Equals(testAccount.StoreId, StringComparison.InvariantCultureIgnoreCase));
}
else
{
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
{
await TestApiAgainstAccessToken<bool>(accessToken,
$"{TestApiPath}/me/stores/actions",
tester.PayTester.HttpClient);
});
}
Assert.DoesNotContain(resultStores,
data => data.Id.Equals(secondUser.StoreId, StringComparison.InvariantCultureIgnoreCase));
}
else
{
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
{
await TestApiAgainstAccessToken<bool>(accessToken,
$"{TestApiPath}/me/stores/{testAccount.StoreId}/can-edit",
tester.PayTester.HttpClient);
});
}
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
{
await TestApiAgainstAccessToken<bool>(accessToken, $"{TestApiPath}/me/stores/{secondUser.StoreId}/can-edit",
tester.PayTester.HttpClient);
});
if (permissions.Contains(APIKeyConstants.Permissions.ServerManagement))
{
Assert.True(await TestApiAgainstAccessToken<bool>(accessToken,
$"{TestApiPath}/me/is-admin",
tester.PayTester.HttpClient));
}
}
public async Task<T> TestApiAgainstAccessToken<T>(string apikey, string url, HttpClient client)
{
var httpRequest = new HttpRequestMessage(HttpMethod.Get,
new Uri(client.BaseAddress, url));
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("token", apikey);
var result = await client.SendAsync(httpRequest);
result.EnsureSuccessStatusCode();
var rawJson = await result.Content.ReadAsStringAsync();
if (typeof(T).IsPrimitive || typeof(T) == typeof(string))
{
return (T)Convert.ChangeType(rawJson, typeof(T));
}
return JsonConvert.DeserializeObject<T>(rawJson);
}
}
}

View File

@ -1,427 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using System.Security.Claims;
using BTCPayServer.Tests.Logging;
using Xunit;
using Xunit.Abstractions;
using System.Net.Http;
using System.Net.Http.Headers;
using BTCPayServer.Data;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OpenIddict.Abstractions;
using OpenQA.Selenium;
using Microsoft.AspNetCore.Identity;
namespace BTCPayServer.Tests
{
public class AuthenticationTests
{
public const int TestTimeout = TestUtils.TestTimeout;
public AuthenticationTests(ITestOutputHelper helper)
{
Logs.Tester = new XUnitLog(helper) {Name = "Tests"};
Logs.LogProvider = new XUnitLogProvider(helper);
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task GetRedirectedToLoginPathOnChallenge()
{
using (var tester = ServerTester.Create())
{
await tester.StartAsync();
var client = tester.PayTester.HttpClient;
//Wallets endpoint is protected
var response = await client.GetAsync("wallets");
var urlPath = response.RequestMessage.RequestUri.ToString()
.Replace(tester.PayTester.ServerUri.ToString(), "");
//Cookie Challenge redirects you to login page
Assert.StartsWith("Account/Login", urlPath, StringComparison.InvariantCultureIgnoreCase);
var queryString = response.RequestMessage.RequestUri.ParseQueryString();
Assert.NotNull(queryString["ReturnUrl"]);
Assert.Equal("/wallets", queryString["ReturnUrl"]);
}
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanGetOpenIdConfiguration()
{
using (var tester = ServerTester.Create())
{
await tester.StartAsync();
using (var response =
await tester.PayTester.HttpClient.GetAsync("/.well-known/openid-configuration"))
{
using (var streamToReadFrom = new StreamReader(await response.Content.ReadAsStreamAsync()))
{
var json = await streamToReadFrom.ReadToEndAsync();
Assert.NotNull(json);
JObject.Parse(json); // Should do more tests but good enough
}
}
}
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanUseNonInteractiveFlows()
{
using (var tester = ServerTester.Create())
{
await tester.StartAsync();
var user = tester.NewAccount();
user.GrantAccess();
await user.MakeAdmin();
var token = await RegisterPasswordClientAndGetAccessToken(user, null, tester);
await TestApiAgainstAccessToken(token, tester, user);
token = await RegisterPasswordClientAndGetAccessToken(user, "secret", tester);
await TestApiAgainstAccessToken(token, tester, user);
token = await RegisterClientCredentialsFlowAndGetAccessToken(user, "secret", tester);
await TestApiAgainstAccessToken(token, tester, user);
}
}
[Fact(Timeout = TestTimeout)]
[Trait("Selenium", "Selenium")]
public async Task CanUseImplicitFlow()
{
using (var s = SeleniumTester.Create())
{
await s.StartAsync();
var tester = s.Server;
var user = tester.NewAccount();
user.GrantAccess();
await user.MakeAdmin();
var id = Guid.NewGuid().ToString();
var redirecturi = new Uri("http://127.0.0.1/oidc-callback");
var openIdClient = await user.RegisterOpenIdClient(
new OpenIddictApplicationDescriptor()
{
ClientId = id,
DisplayName = id,
Permissions = {OpenIddictConstants.Permissions.GrantTypes.Implicit},
RedirectUris = {redirecturi},
});
var implicitAuthorizeUrl = new Uri(tester.PayTester.ServerUri,
$"connect/authorize?response_type=token&client_id={id}&redirect_uri={redirecturi.AbsoluteUri}&scope=openid server_management store_management&nonce={Guid.NewGuid().ToString()}");
s.Driver.Navigate().GoToUrl(implicitAuthorizeUrl);
s.Login(user.RegisterDetails.Email, user.RegisterDetails.Password);
s.Driver.FindElement(By.Id("consent-yes")).Click();
var url = s.Driver.Url;
var results = url.Split("#").Last().Split("&")
.ToDictionary(s1 => s1.Split("=")[0], s1 => s1.Split("=")[1]);
await TestApiAgainstAccessToken(results["access_token"], tester, user);
//in Implicit mode, you renew your token by hitting the same endpoint but adding prompt=none. If you are still logged in on the site, you will receive a fresh token.
var implicitAuthorizeUrlSilentModel = new Uri($"{implicitAuthorizeUrl.OriginalString}&prompt=none");
s.Driver.Navigate().GoToUrl(implicitAuthorizeUrlSilentModel);
url = s.Driver.Url;
results = url.Split("#").Last().Split("&").ToDictionary(s1 => s1.Split("=")[0], s1 => s1.Split("=")[1]);
await TestApiAgainstAccessToken(results["access_token"], tester, user);
var stores = await TestApiAgainstAccessToken<StoreData[]>(results["access_token"],
$"api/test/me/stores",
tester.PayTester.HttpClient);
Assert.NotEmpty(stores);
Assert.True(await TestApiAgainstAccessToken<bool>(results["access_token"],
$"api/test/me/stores/{stores[0].Id}/can-edit",
tester.PayTester.HttpClient));
//we dont ask for consent after acquiring it the first time for the same scopes.
LogoutFlow(tester, id, s);
s.Driver.Navigate().GoToUrl(implicitAuthorizeUrl);
s.Login(user.RegisterDetails.Email, user.RegisterDetails.Password);
s.Driver.AssertElementNotFound(By.Id("consent-yes"));
// Let's asks without scopes
LogoutFlow(tester, id, s);
id = Guid.NewGuid().ToString();
openIdClient = await user.RegisterOpenIdClient(
new OpenIddictApplicationDescriptor()
{
ClientId = id,
DisplayName = id,
Permissions = { OpenIddictConstants.Permissions.GrantTypes.Implicit },
RedirectUris = { redirecturi },
});
implicitAuthorizeUrl = new Uri(tester.PayTester.ServerUri,
$"connect/authorize?response_type=token&client_id={id}&redirect_uri={redirecturi.AbsoluteUri}&scope=openid&nonce={Guid.NewGuid().ToString()}");
s.Driver.Navigate().GoToUrl(implicitAuthorizeUrl);
s.Login(user.RegisterDetails.Email, user.RegisterDetails.Password);
s.Driver.FindElement(By.Id("consent-yes")).Click();
results = s.Driver.Url.Split("#").Last().Split("&")
.ToDictionary(s1 => s1.Split("=")[0], s1 => s1.Split("=")[1]);
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
{
await TestApiAgainstAccessToken<StoreData[]>(results["access_token"],
$"api/test/me/stores",
tester.PayTester.HttpClient);
});
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
{
await TestApiAgainstAccessToken<bool>(results["access_token"],
$"api/test/me/stores/{stores[0].Id}/can-edit",
tester.PayTester.HttpClient);
});
}
}
void LogoutFlow(ServerTester tester, string clientId, SeleniumTester seleniumTester)
{
var logoutUrl = new Uri(tester.PayTester.ServerUri,
$"connect/logout?response_type=token&client_id={clientId}");
seleniumTester.Driver.Navigate().GoToUrl(logoutUrl);
seleniumTester.GoToHome();
Assert.Throws<NoSuchElementException>(() => seleniumTester.Driver.FindElement(By.Id("Logout")));
}
[Fact(Timeout = TestTimeout)]
[Trait("Selenium", "Selenium")]
public async Task CanUseCodeFlow()
{
using (var s = SeleniumTester.Create())
{
await s.StartAsync();
var tester = s.Server;
var user = tester.NewAccount();
user.GrantAccess();
await user.MakeAdmin();
var id = Guid.NewGuid().ToString();
var redirecturi = new Uri("http://127.0.0.1/oidc-callback");
var secret = "secret";
var openIdClient = await user.RegisterOpenIdClient(
new OpenIddictApplicationDescriptor()
{
ClientId = id,
DisplayName = id,
Permissions =
{
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
OpenIddictConstants.Permissions.GrantTypes.RefreshToken
},
RedirectUris = {redirecturi}
}, secret);
var authorizeUrl = new Uri(tester.PayTester.ServerUri,
$"connect/authorize?response_type=code&client_id={id}&redirect_uri={redirecturi.AbsoluteUri}&scope=openid offline_access server_management store_management&state={Guid.NewGuid().ToString()}");
s.Driver.Navigate().GoToUrl(authorizeUrl);
s.Login(user.RegisterDetails.Email, user.RegisterDetails.Password);
s.Driver.FindElement(By.Id("consent-yes")).Click();
var url = s.Driver.Url;
var results = url.Split("?").Last().Split("&")
.ToDictionary(s1 => s1.Split("=")[0], s1 => s1.Split("=")[1]);
var httpClient = tester.PayTester.HttpClient;
var httpRequest = new HttpRequestMessage(HttpMethod.Post,
new Uri(tester.PayTester.ServerUri, "/connect/token"))
{
Content = new FormUrlEncodedContent(new List<KeyValuePair<string, string>>()
{
new KeyValuePair<string, string>("grant_type",
OpenIddictConstants.GrantTypes.AuthorizationCode),
new KeyValuePair<string, string>("client_id", openIdClient.ClientId),
new KeyValuePair<string, string>("client_secret", secret),
new KeyValuePair<string, string>("code", results["code"]),
new KeyValuePair<string, string>("redirect_uri", redirecturi.AbsoluteUri)
})
};
var response = await httpClient.SendAsync(httpRequest);
Assert.True(response.IsSuccessStatusCode);
string content = await response.Content.ReadAsStringAsync();
var result = System.Text.Json.JsonSerializer.Deserialize<OpenIddictResponse>(content);
await TestApiAgainstAccessToken(result.AccessToken, tester, user);
var refreshedAccessToken = await RefreshAnAccessToken(result.RefreshToken, httpClient, id, secret);
await TestApiAgainstAccessToken(refreshedAccessToken, tester, user);
LogoutFlow(tester, id, s);
s.Driver.Navigate().GoToUrl(authorizeUrl);
s.Login(user.RegisterDetails.Email, user.RegisterDetails.Password);
Assert.Throws<NoSuchElementException>(() => s.Driver.FindElement(By.Id("consent-yes")));
results = url.Split("?").Last().Split("&")
.ToDictionary(s1 => s1.Split("=")[0], s1 => s1.Split("=")[1]);
Assert.True(results.ContainsKey("code"));
}
}
private static async Task<string> RefreshAnAccessToken(string refreshToken, HttpClient client, string clientId,
string clientSecret = null)
{
var httpRequest = new HttpRequestMessage(HttpMethod.Post,
new Uri(client.BaseAddress, "/connect/token"))
{
Content = new FormUrlEncodedContent(new List<KeyValuePair<string, string>>()
{
new KeyValuePair<string, string>("grant_type",
OpenIddictConstants.GrantTypes.RefreshToken),
new KeyValuePair<string, string>("client_id", clientId),
new KeyValuePair<string, string>("client_secret", clientSecret),
new KeyValuePair<string, string>("refresh_token", refreshToken)
})
};
var response = await client.SendAsync(httpRequest);
Assert.True(response.IsSuccessStatusCode);
string content = await response.Content.ReadAsStringAsync();
var result = System.Text.Json.JsonSerializer.Deserialize<OpenIddictResponse>(content);
Assert.NotEmpty(result.AccessToken);
Assert.Null(result.Error);
return result.AccessToken;
}
private static async Task<string> RegisterClientCredentialsFlowAndGetAccessToken(TestAccount user,
string secret,
ServerTester tester)
{
var id = Guid.NewGuid().ToString();
var openIdClient = await user.RegisterOpenIdClient(
new OpenIddictApplicationDescriptor()
{
ClientId = id,
DisplayName = id,
Permissions = {OpenIddictConstants.Permissions.GrantTypes.ClientCredentials}
}, secret);
var httpClient = tester.PayTester.HttpClient;
var httpRequest = new HttpRequestMessage(HttpMethod.Post,
new Uri(tester.PayTester.ServerUri, "/connect/token"))
{
Content = new FormUrlEncodedContent(new List<KeyValuePair<string, string>>()
{
new KeyValuePair<string, string>("grant_type",
OpenIddictConstants.GrantTypes.ClientCredentials),
new KeyValuePair<string, string>("client_id", openIdClient.ClientId),
new KeyValuePair<string, string>("client_secret", secret),
new KeyValuePair<string, string>("scope", "server_management store_management")
})
};
var response = await httpClient.SendAsync(httpRequest);
Assert.True(response.IsSuccessStatusCode);
string content = await response.Content.ReadAsStringAsync();
var result = System.Text.Json.JsonSerializer.Deserialize<OpenIddictResponse>(content);
Assert.NotEmpty(result.AccessToken);
Assert.Null(result.Error);
return result.AccessToken;
}
private static async Task<string> RegisterPasswordClientAndGetAccessToken(TestAccount user, string secret,
ServerTester tester)
{
var id = Guid.NewGuid().ToString();
var openIdClient = await user.RegisterOpenIdClient(
new OpenIddictApplicationDescriptor()
{
ClientId = id,
DisplayName = id,
Permissions = {OpenIddictConstants.Permissions.GrantTypes.Password}
}, secret);
var httpClient = tester.PayTester.HttpClient;
var httpRequest = new HttpRequestMessage(HttpMethod.Post,
new Uri(tester.PayTester.ServerUri, "/connect/token"))
{
Content = new FormUrlEncodedContent(new List<KeyValuePair<string, string>>()
{
new KeyValuePair<string, string>("grant_type", OpenIddictConstants.GrantTypes.Password),
new KeyValuePair<string, string>("username", user.RegisterDetails.Email),
new KeyValuePair<string, string>("password", user.RegisterDetails.Password),
new KeyValuePair<string, string>("client_id", openIdClient.ClientId),
new KeyValuePair<string, string>("client_secret", secret),
new KeyValuePair<string, string>("scope", "server_management store_management")
})
};
var response = await httpClient.SendAsync(httpRequest);
Assert.True(response.IsSuccessStatusCode);
string content = await response.Content.ReadAsStringAsync();
var result = System.Text.Json.JsonSerializer.Deserialize<OpenIddictResponse>(content);
Assert.NotEmpty(result.AccessToken);
Assert.Null(result.Error);
return result.AccessToken;
}
async Task TestApiAgainstAccessToken(string accessToken, ServerTester tester, TestAccount testAccount)
{
var resultUser =
await TestApiAgainstAccessToken<string>(accessToken, "api/test/me/id",
tester.PayTester.HttpClient);
Assert.Equal(testAccount.UserId, resultUser);
var secondUser = tester.NewAccount();
secondUser.GrantAccess();
var resultStores =
await TestApiAgainstAccessToken<StoreData[]>(accessToken, "api/test/me/stores",
tester.PayTester.HttpClient);
Assert.Contains(resultStores,
data => data.Id.Equals(testAccount.StoreId, StringComparison.InvariantCultureIgnoreCase));
Assert.DoesNotContain(resultStores,
data => data.Id.Equals(secondUser.StoreId, StringComparison.InvariantCultureIgnoreCase));
Assert.True(await TestApiAgainstAccessToken<bool>(accessToken,
$"api/test/me/stores/{testAccount.StoreId}/can-edit",
tester.PayTester.HttpClient));
Assert.True(await TestApiAgainstAccessToken<bool>(accessToken,
$"api/test/me/is-admin",
tester.PayTester.HttpClient));
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
{
await TestApiAgainstAccessToken<bool>(accessToken, $"api/test/me/stores/{secondUser.StoreId}/can-edit",
tester.PayTester.HttpClient);
});
}
public async Task<T> TestApiAgainstAccessToken<T>(string accessToken, string url, HttpClient client)
{
var httpRequest = new HttpRequestMessage(HttpMethod.Get,
new Uri(client.BaseAddress, url));
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var result = await client.SendAsync(httpRequest);
result.EnsureSuccessStatusCode();
var rawJson = await result.Content.ReadAsStringAsync();
if (typeof(T).IsPrimitive || typeof(T) == typeof(string))
{
return (T)Convert.ChangeType(rawJson, typeof(T));
}
return JsonConvert.DeserializeObject<T>(rawJson);
}
}
}

View File

@ -32,7 +32,6 @@ using System.Security.Claims;
using System.Security.Principal;
using System.Text;
using System.Threading;
using OpenIddict.Abstractions;
using Xunit;
using BTCPayServer.Services;
using System.Net.Http;
@ -298,7 +297,7 @@ namespace BTCPayServer.Tests
if (userId != null)
{
List<Claim> claims = new List<Claim>();
claims.Add(new Claim(OpenIddictConstants.Claims.Subject, userId));
claims.Add(new Claim(ClaimTypes.NameIdentifier, userId));
if (isAdmin)
claims.Add(new Claim(ClaimTypes.Role, Roles.ServerAdmin));
context.User = new ClaimsPrincipal(new ClaimsIdentity(claims.ToArray(), AuthenticationSchemes.Cookie));

View File

@ -71,7 +71,7 @@ namespace BTCPayServer.Tests
}
catch { }
s.Driver.AssertElementNotFound(By.Id("emailAddressFormInput"));
s.Driver.AssertElementNotFound(By.Id("emailAddressFormInput"));
s.Driver.Navigate().Refresh();
s.Driver.AssertElementNotFound(By.Id("emailAddressFormInput"));
}
@ -92,7 +92,7 @@ namespace BTCPayServer.Tests
s.GoToInvoiceCheckout(invoiceId);
Assert.True(s.Driver.FindElement(By.Id("DefaultLang")).FindElements(By.TagName("option")).Count > 1);
var payWithTextEnglish = s.Driver.FindElement(By.Id("pay-with-text")).Text;
var prettyDropdown = s.Driver.FindElement(By.Id("prettydropdown-DefaultLang"));
prettyDropdown.Click();
await Task.Delay(200);
@ -100,13 +100,13 @@ namespace BTCPayServer.Tests
await Task.Delay(1000);
Assert.NotEqual(payWithTextEnglish, s.Driver.FindElement(By.Id("pay-with-text")).Text);
s.Driver.Navigate().GoToUrl(s.Driver.Url + "?lang=da-DK");
Assert.NotEqual(payWithTextEnglish, s.Driver.FindElement(By.Id("pay-with-text")).Text);
s.Driver.Quit();
}
}
[Fact(Timeout = TestTimeout)]
[Trait("Altcoins", "Altcoins")]
[Trait("Lightning", "Lightning")]
@ -121,7 +121,7 @@ namespace BTCPayServer.Tests
s.RegisterNewUser();
var store = s.CreateNewStore();
s.AddDerivationScheme("BTC");
//check that there is no dropdown since only one payment method is set
var invoiceId = s.CreateInvoice(store.storeName, 10, "USD", "a@g.com");
s.GoToInvoiceCheckout(invoiceId);
@ -129,33 +129,31 @@ namespace BTCPayServer.Tests
s.GoToHome();
s.GoToStore(store.storeId);
s.AddDerivationScheme("LTC");
s.AddLightningNode("BTC",LightningConnectionType.CLightning);
s.AddLightningNode("BTC", LightningConnectionType.CLightning);
//there should be three now
invoiceId = s.CreateInvoice(store.storeName, 10, "USD", "a@g.com");
s.GoToInvoiceCheckout(invoiceId);
var currencyDropdownButton = s.Driver.FindElement(By.ClassName("payment__currencies"));
var currencyDropdownButton = s.Driver.WaitForElement(By.ClassName("payment__currencies"));
Assert.Contains("BTC", currencyDropdownButton.Text);
currencyDropdownButton.Click();
var elements = s.Driver.FindElement(By.ClassName("vex-content")).FindElements(By.ClassName("vexmenuitem"));
Assert.Equal(3, elements.Count);
elements.Single(element => element.Text.Contains("LTC")).Click();
Thread.Sleep(1000);
currencyDropdownButton = s.Driver.FindElement(By.ClassName("payment__currencies"));
currencyDropdownButton = s.Driver.WaitForElement(By.ClassName("payment__currencies"));
Assert.Contains("LTC", currencyDropdownButton.Text);
currencyDropdownButton.Click();
elements = s.Driver.FindElement(By.ClassName("vex-content")).FindElements(By.ClassName("vexmenuitem"));
elements.Single(element => element.Text.Contains("Lightning")).Click();
Thread.Sleep(1000);
currencyDropdownButton = s.Driver.FindElement(By.ClassName("payment__currencies"));
currencyDropdownButton = s.Driver.WaitForElement(By.ClassName("payment__currencies"));
Assert.Contains("Lightning", currencyDropdownButton.Text);
s.Driver.Quit();
}
}
[Fact(Timeout = TestTimeout)]
[Trait("Lightning", "Lightning")]
public async Task CanUseLightningSatsFeature()
@ -171,12 +169,12 @@ namespace BTCPayServer.Tests
s.GoToStore(store.storeId, StoreNavPages.Checkout);
s.SetCheckbox(s, "LightningAmountInSatoshi", true);
var command = s.Driver.FindElement(By.Name("command"));
command.ForceClick();
var invoiceId = s.CreateInvoice(store.storeName, 10, "USD", "a@g.com");
s.GoToInvoiceCheckout(invoiceId);
Assert.Contains("Sats", s.Driver.FindElement(By.ClassName("payment__currencies_noborder")).Text);
}
}
@ -218,4 +216,30 @@ namespace BTCPayServer.Tests
}
}
}
public static class SeleniumExtensions
{
/// <summary>
/// Utility method to wait until timeout for element to be present (optionally displayed)
/// </summary>
/// <param name="context">Wait context</param>
/// <param name="by">How we search for element</param>
/// <param name="displayed">Flag to wait for element to be displayed or just present</param>
/// <param name="timeout">How long to wait for element to be present/displayed</param>
/// <returns>Element we were waiting for</returns>
public static IWebElement WaitForElement(this IWebDriver context, By by, bool displayed = true, uint timeout = 3)
{
var wait = new DefaultWait<IWebDriver>(context);
wait.Timeout = TimeSpan.FromSeconds(timeout);
wait.IgnoreExceptionTypes(typeof(NoSuchElementException));
return wait.Until(ctx =>
{
var elem = ctx.FindElement(by);
if (displayed && !elem.Displayed)
return null;
return elem;
});
}
}
}

View File

@ -0,0 +1,71 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using BTCPayServer.Controllers;
using BTCPayServer.Controllers.RestApi.ApiKeys;
using BTCPayServer.Data;
using BTCPayServer.Security.APIKeys;
using BTCPayServer.Tests.Logging;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
using Xunit;
using Xunit.Abstractions;
namespace BTCPayServer.Tests
{
public class GreenfieldAPITests
{
public const int TestTimeout = TestUtils.TestTimeout;
public const string TestApiPath = "api/test/apikey";
public GreenfieldAPITests(ITestOutputHelper helper)
{
Logs.Tester = new XUnitLog(helper) {Name = "Tests"};
Logs.LogProvider = new XUnitLogProvider(helper);
}
[Fact]
[Trait("Integration", "Integration")]
public async Task ApiKeysControllerTests()
{
using (var tester = ServerTester.Create())
{
await tester.StartAsync();
var user = tester.NewAccount();
user.GrantAccess();
await user.MakeAdmin();
string apiKey = await GenerateAPIKey(tester, user);
//Get current api key
var request = new HttpRequestMessage(HttpMethod.Get, "api/v1/api-keys/current");
request.Headers.Authorization = new AuthenticationHeaderValue("token", apiKey);
var result = await tester.PayTester.HttpClient.SendAsync(request);
Assert.True(result.IsSuccessStatusCode);
var apiKeyData = JObject.Parse(await result.Content.ReadAsStringAsync()).ToObject<ApiKeyData>();
Assert.NotNull(apiKeyData);
Assert.Equal(apiKey, apiKeyData.ApiKey);
Assert.Equal(user.UserId, apiKeyData.UserId);
Assert.Equal(2, apiKeyData.Permissions.Length);
}
}
private static async Task<string> GenerateAPIKey(ServerTester tester, TestAccount user)
{
var manageController = tester.PayTester.GetController<ManageController>(user.UserId, user.StoreId, user.IsAdmin);
var x = Assert.IsType<RedirectToActionResult>(await manageController.AddApiKey(
new ManageController.AddApiKeyViewModel()
{
ServerManagementPermission = true,
StoreManagementPermission = true,
StoreMode = ManageController.AddApiKeyViewModel.ApiKeyStoreMode.AllStores
}));
var statusMessage = manageController.TempData.GetStatusMessageModel();
Assert.NotNull(statusMessage);
var apiKey = statusMessage.Html.Substring(statusMessage.Html.IndexOf("<code>") + 6);
apiKey = apiKey.Substring(0, apiKey.IndexOf("</code>") );
return apiKey;
}
}
}

View File

@ -19,6 +19,7 @@ using System.Threading.Tasks;
using BTCPayServer.Lightning;
using BTCPayServer.Lightning.CLightning;
using BTCPayServer.Models;
using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Stores;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@ -71,19 +72,20 @@ namespace BTCPayServer.Tests
Driver.AssertNoError();
}
internal void AssertHappyMessage(StatusMessageModel.StatusSeverity severity = StatusMessageModel.StatusSeverity.Success)
internal IWebElement AssertHappyMessage(StatusMessageModel.StatusSeverity severity = StatusMessageModel.StatusSeverity.Success)
{
using var cts = new CancellationTokenSource(20_000);
while (!cts.IsCancellationRequested)
{
var success = Driver.FindElements(By.ClassName($"alert-{StatusMessageModel.ToString(severity)}")).Any(el => el.Displayed);
if (success)
return;
var result = Driver.FindElements(By.ClassName($"alert-{StatusMessageModel.ToString(severity)}")).Where(el => el.Displayed);
if (result.Any())
return result.First();
Thread.Sleep(100);
}
Logs.Tester.LogInformation(this.Driver.PageSource);
Assert.True(false, $"Should have shown {severity} message");
}
return null;
}
public static readonly TimeSpan ImplicitWait = TimeSpan.FromSeconds(10);
public string Link(string relativeLink)
@ -271,6 +273,20 @@ namespace BTCPayServer.Tests
{
Driver.FindElement(By.Id("Invoices")).Click();
}
public void GoToProfile(ManageNavPages navPages = ManageNavPages.Index)
{
Driver.FindElement(By.Id("MySettings")).Click();
if (navPages != ManageNavPages.Index)
{
Driver.FindElement(By.Id(navPages.ToString())).Click();
}
}
public void GoToLogin()
{
Driver.Navigate().GoToUrl(new Uri(Server.PayTester.ServerUri, "Account/Login"));
}
public void GoToCreateInvoicePage()
{

View File

@ -53,6 +53,9 @@ namespace BTCPayServer.Tests
{
NBXplorerUri = ExplorerClient.Address,
TestDatabase = Enum.Parse<TestDatabases>(GetEnvironment("TESTS_DB", TestDatabases.Postgres.ToString()), true),
// TODO: The fact that we use same conn string as development database can cause huge problems with tests
// since in dev we already can have some users / stores registered, while on CI database is being initalized
// for the first time and first registered user gets admin status by default
Postgres = GetEnvironment("TESTS_POSTGRES", "User ID=postgres;Host=127.0.0.1;Port=39372;Database=btcpayserver"),
MySQL = GetEnvironment("TESTS_MYSQL", "User ID=root;Host=127.0.0.1;Port=33036;Database=btcpayserver")
};

View File

@ -18,8 +18,6 @@ using BTCPayServer.Tests.Logging;
using BTCPayServer.Lightning;
using BTCPayServer.Lightning.CLightning;
using BTCPayServer.Data;
using OpenIddict.Abstractions;
using OpenIddict.Core;
using Microsoft.AspNetCore.Identity;
using NBXplorer.Models;
@ -148,6 +146,7 @@ namespace BTCPayServer.Tests
};
await account.Register(RegisterDetails);
UserId = account.RegisteredUserId;
IsAdmin = account.RegisteredAdmin;
}
public RegisterViewModel RegisterDetails{ get; set; }
@ -194,14 +193,5 @@ namespace BTCPayServer.Tests
if (storeController.ModelState.ErrorCount != 0)
Assert.False(true, storeController.ModelState.FirstOrDefault().Value.Errors[0].ErrorMessage);
}
public async Task<BTCPayOpenIdClient> RegisterOpenIdClient(OpenIddictApplicationDescriptor descriptor, string secret = null)
{
var openIddictApplicationManager = parent.PayTester.GetService<OpenIddictApplicationManager<BTCPayOpenIdClient>>();
var client = new BTCPayOpenIdClient { Id = Guid.NewGuid().ToString(), ApplicationUserId = UserId};
await openIddictApplicationManager.PopulateAsync(client, descriptor);
await openIddictApplicationManager.CreateAsync(client, secret);
return client;
}
}
}

View File

@ -48,6 +48,7 @@
<PackageReference Include="NicolasDorier.CommandLine.Configuration" Version="1.0.0.3" />
<PackageReference Include="NicolasDorier.RateLimits" Version="1.1.0" />
<PackageReference Include="NicolasDorier.StandardConfiguration" Version="1.0.0.18" />
<PackageReference Include="NSwag.AspNetCore" Version="13.2.2" />
<PackageReference Include="Serilog" Version="2.9.0" />
<PackageReference Include="Serilog.AspNetCore" Version="3.2.0" />
<PackageReference Include="Serilog.Sinks.File" Version="4.1.0" />
@ -66,9 +67,6 @@
<PackageReference Include="TwentyTwenty.Storage.Local" Version="2.12.1" />
<PackageReference Include="U2F.Core" Version="1.0.4" />
<PackageReference Include="YamlDotNet" Version="8.0.0" />
<PackageReference Include="OpenIddict" Version="3.0.0-alpha1.20058.15" />
<PackageReference Include="OpenIddict.Server.AspNetCore" Version="3.0.0-alpha1.20058.15"></PackageReference>
<PackageReference Include="OpenIddict.Validation.AspNetCore" Version="3.0.0-alpha1.20058.15"></PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="3.1.1" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.1.1" />
</ItemGroup>
@ -220,4 +218,8 @@
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
</ItemGroup>
<ItemGroup>
<_ContentIncludedByDefault Remove="Views\Authorization\Authorize.cshtml" />
</ItemGroup>
</Project>

View File

@ -39,7 +39,7 @@ namespace BTCPayServer.Controllers
SettingsRepository _SettingsRepository;
Configuration.BTCPayServerOptions _Options;
private readonly BTCPayServerEnvironment _btcPayServerEnvironment;
public U2FService _u2FService;
public U2FService _u2FService;
ILogger _logger;
public AccountController(
@ -75,7 +75,7 @@ namespace BTCPayServer.Controllers
[AllowAnonymous]
public async Task<IActionResult> Login(string returnUrl = null)
{
if (User.Identity.IsAuthenticated && string.IsNullOrEmpty(returnUrl))
return RedirectToLocal();
// Clear the existing external cookie to ensure a clean login process
@ -85,7 +85,7 @@ namespace BTCPayServer.Controllers
{
SetInsecureFlags();
}
ViewData["ReturnUrl"] = returnUrl;
return View();
}
@ -126,7 +126,7 @@ namespace BTCPayServer.Controllers
if (await _userManager.CheckPasswordAsync(user, model.Password))
{
LoginWith2faViewModel twoFModel = null;
if (user.TwoFactorEnabled)
{
// we need to do an actual sign in attempt so that 2fa can function in next step
@ -145,14 +145,14 @@ namespace BTCPayServer.Controllers
}
else
{
var incrementAccessFailedResult = await _userManager.AccessFailedAsync(user);
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return View(model);
var incrementAccessFailedResult = await _userManager.AccessFailedAsync(user);
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return View(model);
}
}
var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: true);
if (result.Succeeded)
{
@ -215,7 +215,7 @@ namespace BTCPayServer.Controllers
{
return RedirectToAction("Login");
}
ViewData["ReturnUrl"] = returnUrl;
var user = await _userManager.FindByIdAsync(viewModel.UserId);
@ -276,7 +276,7 @@ namespace BTCPayServer.Controllers
return View("SecondaryLogin", new SecondaryLoginViewModel()
{
LoginWith2FaViewModel = new LoginWith2faViewModel { RememberMe = rememberMe },
LoginWithU2FViewModel = (await _u2FService.HasDevices(user.Id))? await BuildU2FViewModel(rememberMe, user): null
LoginWithU2FViewModel = (await _u2FService.HasDevices(user.Id)) ? await BuildU2FViewModel(rememberMe, user) : null
});
}
@ -322,7 +322,7 @@ namespace BTCPayServer.Controllers
return View("SecondaryLogin", new SecondaryLoginViewModel()
{
LoginWith2FaViewModel = model,
LoginWithU2FViewModel = (await _u2FService.HasDevices(user.Id))? await BuildU2FViewModel(rememberMe, user): null
LoginWithU2FViewModel = (await _u2FService.HasDevices(user.Id)) ? await BuildU2FViewModel(rememberMe, user) : null
});
}
}
@ -447,12 +447,13 @@ namespace BTCPayServer.Controllers
var settings = await _SettingsRepository.GetSettingAsync<ThemeSettings>();
settings.FirstRun = false;
await _SettingsRepository.UpdateSetting<ThemeSettings>(settings);
if(_Options.DisableRegistration)
if (_Options.DisableRegistration)
{
// Once the admin user has been created lock subsequent user registrations (needs to be disabled for unit tests that require multiple users).
policies.LockSubscription = true;
await _SettingsRepository.UpdateSetting(policies);
}
RegisteredAdmin = true;
}
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
@ -462,7 +463,7 @@ namespace BTCPayServer.Controllers
_EmailSenderFactory.GetEmailSender().SendEmailConfirmation(model.Email, callbackUrl);
if (!policies.RequiresConfirmedEmail)
{
if(logon)
if (logon)
await _signInManager.SignInAsync(user, isPersistent: false);
return RedirectToLocal(returnUrl);
}
@ -479,13 +480,9 @@ namespace BTCPayServer.Controllers
return View(model);
}
/// <summary>
/// Test property
/// </summary>
public string RegisteredUserId
{
get; set;
}
// Properties used by tests
public string RegisteredUserId { get; set; }
public bool RegisteredAdmin { get; set; }
[HttpGet]
public async Task<IActionResult> Logout()
@ -539,7 +536,7 @@ namespace BTCPayServer.Controllers
var callbackUrl = Url.ResetPasswordCallbackLink(user.Id, code, Request.Scheme);
_EmailSenderFactory.GetEmailSender().SendEmail(model.Email, "Reset Password",
$"Please reset your password by clicking here: <a href='{callbackUrl}'>link</a>");
return RedirectToAction(nameof(ForgotPasswordConfirmation));
}
@ -625,8 +622,8 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(HomeController.Index), "Home");
}
}
private bool CanLoginOrRegister()
{
return _btcPayServerEnvironment.IsDevelopping || _btcPayServerEnvironment.IsSecure;
@ -639,7 +636,7 @@ namespace BTCPayServer.Controllers
Severity = StatusMessageModel.StatusSeverity.Error,
Message = "You cannot login over an insecure connection. Please use HTTPS or Tor."
});
ViewData["disabled"] = true;
}

View File

@ -1,136 +0,0 @@
/*
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
* See https://github.com/openiddict/openiddict-core for more information concerning
* the license and the contributors participating to this project.
*/
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Security.OpenId;
using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.Models.Authorization;
using BTCPayServer.Security;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore;
using OpenIddict.Abstractions;
using OpenIddict.Core;
using OpenIddict.Server;
using System.Security.Claims;
using OpenIddict.Server.AspNetCore;
namespace BTCPayServer.Controllers
{
public class AuthorizationController : Controller
{
private readonly OpenIddictApplicationManager<BTCPayOpenIdClient> _applicationManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly OpenIddictAuthorizationManager<BTCPayOpenIdAuthorization> _authorizationManager;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IOptions<IdentityOptions> _IdentityOptions;
public AuthorizationController(
OpenIddictApplicationManager<BTCPayOpenIdClient> applicationManager,
SignInManager<ApplicationUser> signInManager,
OpenIddictAuthorizationManager<BTCPayOpenIdAuthorization> authorizationManager,
UserManager<ApplicationUser> userManager,
IOptions<IdentityOptions> identityOptions)
{
_applicationManager = applicationManager;
_signInManager = signInManager;
_authorizationManager = authorizationManager;
_userManager = userManager;
_IdentityOptions = identityOptions;
}
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[HttpGet("/connect/authorize")]
public async Task<IActionResult> Authorize()
{
var request = HttpContext.GetOpenIddictServerRequest();
// Retrieve the application details from the database.
var application = await _applicationManager.FindByClientIdAsync(request.ClientId);
if (application == null)
{
return View("Error",
new ErrorViewModel
{
Error = OpenIddictConstants.Errors.InvalidClient,
ErrorDescription =
"Details concerning the calling client application cannot be found in the database"
});
}
var userId = _userManager.GetUserId(User);
if (!string.IsNullOrEmpty(
await OpenIdExtensions.IsUserAuthorized(_authorizationManager, request, userId, application.Id)))
{
return await Authorize("YES", false);
}
// Flow the request_id to allow OpenIddict to restore
// the original authorization request from the cache.
return View(new AuthorizeViewModel
{
ApplicationName = await _applicationManager.GetDisplayNameAsync(application),
RequestId = request.RequestId,
Scope = request.GetScopes()
});
}
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[HttpPost("/connect/authorize")]
public async Task<IActionResult> Authorize(string consent, bool createAuthorization = true)
{
var request = HttpContext.GetOpenIddictServerRequest();
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return View("Error",
new ErrorViewModel
{
Error = OpenIddictConstants.Errors.ServerError,
ErrorDescription = "The specified user could not be found"
});
}
string type = null;
switch (consent.ToUpperInvariant())
{
case "YESTEMPORARY":
type = OpenIddictConstants.AuthorizationTypes.AdHoc;
break;
case "YES":
type = OpenIddictConstants.AuthorizationTypes.Permanent;
break;
case "NO":
default:
// Notify OpenIddict that the authorization grant has been denied by the resource owner
// to redirect the user agent to the client application using the appropriate response_mode.
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var principal = await _signInManager.CreateUserPrincipalAsync(user);
principal = await _signInManager.CreateUserPrincipalAsync(user);
principal.SetScopes(request.GetScopes().Restrict(principal));
principal.SetDestinations(_IdentityOptions.Value);
if (createAuthorization)
{
var application = await _applicationManager.FindByClientIdAsync(request.ClientId);
var authorization = await _authorizationManager.CreateAsync(User, user.Id, application.Id,
type, principal.GetScopes());
principal.SetInternalAuthorizationId(authorization.Id);
}
// Returning a SignInResult will ask OpenIddict to issue the appropriate access/identity tokens.
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
}
}

View File

@ -103,6 +103,7 @@ namespace BTCPayServer.Controllers
cryptoPayment.Paid = _CurrencyNameTable.DisplayFormatCurrency(accounting.CryptoPaid.ToDecimal(MoneyUnit.BTC), paymentMethodId.CryptoCode);
cryptoPayment.Overpaid = _CurrencyNameTable.DisplayFormatCurrency(accounting.OverpaidHelper.ToDecimal(MoneyUnit.BTC), paymentMethodId.CryptoCode);
var paymentMethodDetails = data.GetPaymentMethodDetails();
cryptoPayment.Address = paymentMethodDetails.GetPaymentDestination();
cryptoPayment.Rate = ExchangeRate(data);
model.CryptoPayments.Add(cryptoPayment);
}

View File

@ -74,7 +74,11 @@ namespace BTCPayServer.Controllers
var getAppsTaggingStore = _InvoiceRepository.GetAppsTaggingStore(store.Id);
var storeBlob = store.GetStoreBlob();
EmailAddressAttribute emailValidator = new EmailAddressAttribute();
entity.ExpirationTime = entity.InvoiceTime.AddMinutes(storeBlob.InvoiceExpiration);
entity.ExpirationTime = invoice.ExpirationTime is DateTimeOffset v ? v : entity.InvoiceTime.AddMinutes(storeBlob.InvoiceExpiration);
if (entity.ExpirationTime - TimeSpan.FromSeconds(30.0) < entity.InvoiceTime)
{
throw new BitpayHttpException(400, "The expirationTime is set too soon");
}
entity.MonitoringExpiration = entity.ExpirationTime + TimeSpan.FromMinutes(storeBlob.MonitoringExpiration);
entity.OrderId = invoice.OrderId;
entity.ServerUrl = serverUrl;

View File

@ -0,0 +1,332 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Hosting.OpenApi;
using BTCPayServer.Models;
using BTCPayServer.Security;
using BTCPayServer.Security.APIKeys;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NSwag.Annotations;
namespace BTCPayServer.Controllers
{
public partial class ManageController
{
[HttpGet]
public async Task<IActionResult> APIKeys()
{
return View(new ApiKeysViewModel()
{
ApiKeyDatas = await _apiKeyRepository.GetKeys(new APIKeyRepository.APIKeyQuery()
{
UserId = new[] {_userManager.GetUserId(User)}
})
});
}
[HttpGet("api-keys/{id}/delete")]
public async Task<IActionResult> RemoveAPIKey(string id)
{
var key = await _apiKeyRepository.GetKey(id);
if (key == null || key.UserId != _userManager.GetUserId(User))
{
return NotFound();
}
return View("Confirm", new ConfirmModel()
{
Title = "Delete API Key "+ ( string.IsNullOrEmpty(key.Label)? string.Empty: key.Label) + "("+key.Id+")",
Description = "Any application using this api key will immediately lose access",
Action = "Delete",
ActionUrl = Request.GetCurrentUrl().Replace("RemoveAPIKey", "RemoveAPIKeyPost")
});
}
[HttpPost("api-keys/{id}/delete")]
public async Task<IActionResult> RemoveAPIKeyPost(string id)
{
var key = await _apiKeyRepository.GetKey(id);
if (key == null || key.UserId != _userManager.GetUserId(User))
{
return NotFound();
}
await _apiKeyRepository.Remove(id, _userManager.GetUserId(User));
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = "API Key removed"
});
return RedirectToAction("APIKeys");
}
[HttpGet]
public async Task<IActionResult> AddApiKey()
{
if (!_btcPayServerEnvironment.IsSecure)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = "Cannot generate api keys while not on https or tor"
});
return RedirectToAction("APIKeys");
}
return View("AddApiKey", await SetViewModelValues(new AddApiKeyViewModel()));
}
/// <param name="permissions">The permissions to request. Current permissions available: ServerManagement, StoreManagement</param>
/// <param name="applicationName">The name of your application</param>
/// <param name="strict">If permissions are specified, and strict is set to false, it will allow the user to reject some of permissions the application is requesting.</param>
/// <param name="selectiveStores">If the application is requesting the CanModifyStoreSettings permission and selectiveStores is set to true, this allows the user to only grant permissions to selected stores under the user's control.</param>
[HttpGet("~/api-keys/authorize")]
[OpenApiTags("Authorization")]
[OpenApiOperation("Authorize User",
"Redirect the browser to this endpoint to request the user to generate an api-key with specific permissions")]
[IncludeInOpenApiDocs]
public async Task<IActionResult> AuthorizeAPIKey(string[] permissions, string applicationName = null,
bool strict = true, bool selectiveStores = false)
{
if (!_btcPayServerEnvironment.IsSecure)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = "Cannot generate api keys while not on https or tor"
});
return RedirectToAction("APIKeys");
}
permissions ??= Array.Empty<string>();
var vm = await SetViewModelValues(new AuthorizeApiKeysViewModel()
{
Label = applicationName,
ServerManagementPermission = permissions.Contains(APIKeyConstants.Permissions.ServerManagement),
StoreManagementPermission = permissions.Contains(APIKeyConstants.Permissions.StoreManagement),
PermissionsFormatted = permissions,
ApplicationName = applicationName,
SelectiveStores = selectiveStores,
Strict = strict,
});
vm.ServerManagementPermission = vm.ServerManagementPermission && vm.IsServerAdmin;
return View(vm);
}
[HttpPost("~/api-keys/authorize")]
public async Task<IActionResult> AuthorizeAPIKey([FromForm] AuthorizeApiKeysViewModel viewModel)
{
await SetViewModelValues(viewModel);
var ar = HandleCommands(viewModel);
if (ar != null)
{
return ar;
}
if (viewModel.PermissionsFormatted.Contains(APIKeyConstants.Permissions.ServerManagement))
{
if (!viewModel.IsServerAdmin && viewModel.ServerManagementPermission)
{
viewModel.ServerManagementPermission = false;
}
if (!viewModel.ServerManagementPermission && viewModel.Strict)
{
ModelState.AddModelError(nameof(viewModel.ServerManagementPermission),
"This permission is required for this application.");
}
}
if (viewModel.PermissionsFormatted.Contains(APIKeyConstants.Permissions.StoreManagement))
{
if (!viewModel.SelectiveStores &&
viewModel.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific)
{
viewModel.StoreMode = AddApiKeyViewModel.ApiKeyStoreMode.AllStores;
ModelState.AddModelError(nameof(viewModel.StoreManagementPermission),
"This application does not allow selective store permissions.");
}
if (!viewModel.StoreManagementPermission && !viewModel.SpecificStores.Any() && viewModel.Strict)
{
ModelState.AddModelError(nameof(viewModel.StoreManagementPermission),
"This permission is required for this application.");
}
}
if (!ModelState.IsValid)
{
return View(viewModel);
}
switch (viewModel.Command.ToLowerInvariant())
{
case "no":
return RedirectToAction("APIKeys");
case "yes":
var key = await CreateKey(viewModel);
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success,
Html = $"API key generated! <code>{key.Id}</code>"
});
return RedirectToAction("APIKeys", new { key = key.Id});
default: return View(viewModel);
}
}
[HttpPost]
public async Task<IActionResult> AddApiKey(AddApiKeyViewModel viewModel)
{
await SetViewModelValues(viewModel);
var ar = HandleCommands(viewModel);
if (ar != null)
{
return ar;
}
if (!ModelState.IsValid)
{
return View(viewModel);
}
var key = await CreateKey(viewModel);
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success,
Html = $"API key generated! <code>{key.Id}</code>"
});
return RedirectToAction("APIKeys");
}
private IActionResult HandleCommands(AddApiKeyViewModel viewModel)
{
switch (viewModel.Command)
{
case "change-store-mode":
viewModel.StoreMode = viewModel.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific
? AddApiKeyViewModel.ApiKeyStoreMode.AllStores
: AddApiKeyViewModel.ApiKeyStoreMode.Specific;
if (viewModel.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific &&
!viewModel.SpecificStores.Any() && viewModel.Stores.Any())
{
viewModel.SpecificStores.Add(null);
}
return View(viewModel);
case "add-store":
viewModel.SpecificStores.Add(null);
return View(viewModel);
case string x when x.StartsWith("remove-store", StringComparison.InvariantCultureIgnoreCase):
{
ModelState.Clear();
var index = int.Parse(
viewModel.Command.Substring(
viewModel.Command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase) + 1),
CultureInfo.InvariantCulture);
viewModel.SpecificStores.RemoveAt(index);
return View(viewModel);
}
}
return null;
}
private async Task<APIKeyData> CreateKey(AddApiKeyViewModel viewModel)
{
var key = new APIKeyData()
{
Id = Guid.NewGuid().ToString().Replace("-", string.Empty),
Type = APIKeyType.Permanent,
UserId = _userManager.GetUserId(User),
Label = viewModel.Label
};
key.SetPermissions(GetPermissionsFromViewModel(viewModel));
await _apiKeyRepository.CreateKey(key);
return key;
}
private IEnumerable<string> GetPermissionsFromViewModel(AddApiKeyViewModel viewModel)
{
var permissions = new List<string>();
if (viewModel.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific)
{
permissions.AddRange(viewModel.SpecificStores.Select(APIKeyConstants.Permissions.GetStorePermission));
}
else if (viewModel.StoreManagementPermission)
{
permissions.Add(APIKeyConstants.Permissions.StoreManagement);
}
if (viewModel.IsServerAdmin && viewModel.ServerManagementPermission)
{
permissions.Add(APIKeyConstants.Permissions.ServerManagement);
}
return permissions;
}
private async Task<T> SetViewModelValues<T>(T viewModel) where T : AddApiKeyViewModel
{
viewModel.Stores = await _StoreRepository.GetStoresByUserId(_userManager.GetUserId(User));
viewModel.IsServerAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings.Key)).Succeeded;
return viewModel;
}
public class AddApiKeyViewModel
{
public string Label { get; set; }
public StoreData[] Stores { get; set; }
public ApiKeyStoreMode StoreMode { get; set; }
public List<string> SpecificStores { get; set; } = new List<string>();
public bool IsServerAdmin { get; set; }
public bool ServerManagementPermission { get; set; }
public bool StoreManagementPermission { get; set; }
public string Command { get; set; }
public enum ApiKeyStoreMode
{
AllStores,
Specific
}
}
public class AuthorizeApiKeysViewModel : AddApiKeyViewModel
{
public string ApplicationName { get; set; }
public bool Strict { get; set; }
public bool SelectiveStores { get; set; }
public string Permissions { get; set; }
public string[] PermissionsFormatted
{
get
{
return Permissions?.Split(";", StringSplitOptions.RemoveEmptyEntries)?? Array.Empty<string>();
}
set
{
Permissions = string.Join(';', value ?? Array.Empty<string>());
}
}
}
public class ApiKeysViewModel
{
public List<APIKeyData> ApiKeyDatas { get; set; }
}
}
}

View File

@ -19,6 +19,8 @@ using System.Globalization;
using BTCPayServer.Security;
using BTCPayServer.U2F;
using BTCPayServer.Data;
using BTCPayServer.Security.APIKeys;
namespace BTCPayServer.Controllers
{
@ -34,6 +36,8 @@ namespace BTCPayServer.Controllers
IWebHostEnvironment _Env;
public U2FService _u2FService;
private readonly BTCPayServerEnvironment _btcPayServerEnvironment;
private readonly APIKeyRepository _apiKeyRepository;
private readonly IAuthorizationService _authorizationService;
StoreRepository _StoreRepository;
@ -48,7 +52,10 @@ namespace BTCPayServer.Controllers
StoreRepository storeRepository,
IWebHostEnvironment env,
U2FService u2FService,
BTCPayServerEnvironment btcPayServerEnvironment)
BTCPayServerEnvironment btcPayServerEnvironment,
APIKeyRepository apiKeyRepository,
IAuthorizationService authorizationService
)
{
_userManager = userManager;
_signInManager = signInManager;
@ -58,6 +65,8 @@ namespace BTCPayServer.Controllers
_Env = env;
_u2FService = u2FService;
_btcPayServerEnvironment = btcPayServerEnvironment;
_apiKeyRepository = apiKeyRepository;
_authorizationService = authorizationService;
_StoreRepository = storeRepository;
}

View File

@ -37,7 +37,6 @@ namespace BTCPayServer.Controllers
[HttpPost]
[Route("api/v1/invoices")]
[MediaTypeAcceptConstraintAttribute("text/html")]
[IgnoreAntiforgeryToken]
[EnableCors(CorsPolicies.All)]
public async Task<IActionResult> PayButtonHandle([FromForm]PayButtonViewModel model, CancellationToken cancellationToken)
@ -78,6 +77,15 @@ namespace BTCPayServer.Controllers
ModelState.AddModelError("Store", e.Message);
return View();
}
if (model.JsonResponse)
{
return Json(new
{
InvoiceId = invoice.Data.Id,
InvoiceUrl = invoice.Data.Url
});
}
if (string.IsNullOrEmpty(model.CheckoutQueryString))
{

View File

@ -0,0 +1,23 @@
using BTCPayServer.Data;
namespace BTCPayServer.Controllers.RestApi.ApiKeys
{
public class ApiKeyData
{
public string ApiKey { get; set; }
public string Label { get; set; }
public string UserId { get; set; }
public string[] Permissions { get; set; }
public static ApiKeyData FromModel(APIKeyData data)
{
return new ApiKeyData()
{
Permissions = data.GetPermissions(),
ApiKey = data.Id,
UserId = data.UserId,
Label = data.Label
};
}
}
}

View File

@ -0,0 +1,37 @@
using System.Threading.Tasks;
using BTCPayServer.Hosting.OpenApi;
using BTCPayServer.Security;
using BTCPayServer.Security.APIKeys;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using NSwag.Annotations;
namespace BTCPayServer.Controllers.RestApi.ApiKeys
{
[ApiController]
[IncludeInOpenApiDocs]
[OpenApiTags("API Keys")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
public class ApiKeysController : ControllerBase
{
private readonly APIKeyRepository _apiKeyRepository;
public ApiKeysController(APIKeyRepository apiKeyRepository)
{
_apiKeyRepository = apiKeyRepository;
}
[OpenApiOperation("Get current API Key information", "View information about the current API key")]
[SwaggerResponse(StatusCodes.Status200OK, typeof(ApiKeyData),
Description = "Information about the current api key")]
[HttpGet("~/api/v1/api-keys/current")]
[HttpGet("~/api/v1/users/me/api-keys/current")]
public async Task<ActionResult<ApiKeyData>> GetKey()
{
ControllerContext.HttpContext.GetAPIKey(out var apiKey);
var data = await _apiKeyRepository.GetKey(apiKey);
return Ok(ApiKeyData.FromModel(data));
}
}
}

View File

@ -1,27 +1,26 @@
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.Security;
using BTCPayServer.Security.APIKeys;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Validation.AspNetCore;
namespace BTCPayServer.Controllers.RestApi
{
/// <summary>
/// this controller serves as a testing endpoint for our OpenId unit tests
/// this controller serves as a testing endpoint for our api key unit tests
/// </summary>
[Route("api/[controller]")]
[Route("api/test/apikey")]
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.OpenId)]
public class TestController : ControllerBase
[Authorize(AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
public class TestApiKeyController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly StoreRepository _storeRepository;
public TestController(UserManager<ApplicationUser> userManager, StoreRepository storeRepository)
public TestApiKeyController(UserManager<ApplicationUser> userManager, StoreRepository storeRepository)
{
_userManager = userManager;
_storeRepository = storeRepository;
@ -40,24 +39,32 @@ namespace BTCPayServer.Controllers.RestApi
}
[HttpGet("me/is-admin")]
[Authorize(Policy = Policies.CanModifyServerSettings.Key, AuthenticationSchemes = AuthenticationSchemes.OpenId)]
[Authorize(Policy = Policies.CanModifyServerSettings.Key, AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
public bool AmIAnAdmin()
{
return true;
}
[HttpGet("me/stores")]
[Authorize(Policy = Policies.CanModifyStoreSettings.Key,
AuthenticationSchemes = AuthenticationSchemes.OpenId)]
[Authorize(Policy = Policies.CanListStoreSettings.Key,
AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
public async Task<StoreData[]> GetCurrentUserStores()
{
return await _storeRepository.GetStoresByUserId(_userManager.GetUserId(User));
return await User.GetStores(_userManager, _storeRepository);
}
[HttpGet("me/stores/actions")]
[Authorize(Policy = Policies.CanModifyStoreSettings.Key,
AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
public bool CanDoNonImplicitStoreActions()
{
return true;
}
[HttpGet("me/stores/{storeId}/can-edit")]
[Authorize(Policy = Policies.CanModifyStoreSettings.Key,
AuthenticationSchemes = AuthenticationSchemes.OpenId)]
AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
public bool CanEdit(string storeId)
{
return true;

View File

@ -141,8 +141,9 @@ namespace BTCPayServer.Controllers
}
[HttpPost]
[Route("{storeId}/derivations/{cryptoCode}")]
public async Task<IActionResult> AddDerivationScheme(string storeId, DerivationSchemeViewModel vm,
[Route("{storeId}/derivations/{cryptoCode}")]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> AddDerivationScheme(string storeId, [FromForm] DerivationSchemeViewModel vm,
string cryptoCode)
{
vm.CryptoCode = cryptoCode;

View File

@ -33,8 +33,6 @@ namespace BTCPayServer.Controllers
vm.ChangellyMerchantId = existing.ChangellyMerchantId;
vm.Enabled = existing.Enabled;
vm.AmountMarkupPercentage = existing.AmountMarkupPercentage;
vm.ShowFiat = existing.ShowFiat;
}
[HttpPost]
@ -60,8 +58,7 @@ namespace BTCPayServer.Controllers
ApiUrl = vm.ApiUrl,
ChangellyMerchantId = vm.ChangellyMerchantId,
Enabled = vm.Enabled,
AmountMarkupPercentage = vm.AmountMarkupPercentage,
ShowFiat = vm.ShowFiat
AmountMarkupPercentage = vm.AmountMarkupPercentage
};
switch (command)

View File

@ -117,7 +117,7 @@ namespace BTCPayServer.Controllers
.GetMetadataAsync<string>(derivationScheme.AccountDerivation,
WellknownMetadataKeys.MasterHDKey);
return await SignWithSeed(walletId,
return SignWithSeed(walletId,
new SignWithSeedViewModel() {SeedOrKey = extKey, PSBT = psbt.ToBase64()});
}

View File

@ -573,7 +573,7 @@ namespace BTCPayServer.Controllers
var extKey = await ExplorerClientProvider.GetExplorerClient(network)
.GetMetadataAsync<string>(derivationScheme.AccountDerivation, WellknownMetadataKeys.MasterHDKey, cancellation);
return await SignWithSeed(walletId, new SignWithSeedViewModel()
return SignWithSeed(walletId, new SignWithSeedViewModel()
{
SeedOrKey = extKey,
PSBT = psbt.PSBT.ToBase64()
@ -645,10 +645,9 @@ namespace BTCPayServer.Controllers
[HttpPost]
[Route("{walletId}/vault")]
public async Task<IActionResult> SubmitVault([ModelBinder(typeof(WalletIdModelBinder))]
public IActionResult SubmitVault([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletSendVaultModel model)
{
return RedirectToWalletPSBTReady(model.PSBT);
}
private IActionResult RedirectToWalletPSBTReady(string psbt, string signingKey= null, string signingKeyPath = null)
@ -718,7 +717,7 @@ namespace BTCPayServer.Controllers
[HttpPost]
[Route("{walletId}/ledger")]
public async Task<IActionResult> SubmitLedger([ModelBinder(typeof(WalletIdModelBinder))]
public IActionResult SubmitLedger([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletSendLedgerModel model)
{
return RedirectToWalletPSBTReady(model.PSBT);
@ -735,7 +734,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{walletId}/psbt/seed")]
public async Task<IActionResult> SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))]
public IActionResult SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, SignWithSeedViewModel viewModel)
{
if (!ModelState.IsValid)

View File

@ -268,7 +268,9 @@ namespace BTCPayServer
public static bool IsOnion(this Uri uri)
{
return uri?.DnsSafeHost?.EndsWith(".onion", StringComparison.OrdinalIgnoreCase) is true;
if (uri == null || !uri.IsAbsoluteUri)
return false;
return uri.DnsSafeHost.EndsWith(".onion", StringComparison.OrdinalIgnoreCase);
}

View File

@ -1,37 +0,0 @@
using System.IO;
using System.Security.Cryptography;
using BTCPayServer.Configuration;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using NETCore.Encrypt.Extensions.Internal;
namespace BTCPayServer
{
public static class OpenIddictExtensions
{
public static SecurityKey GetSigningKey(IConfiguration configuration, string fileName)
{
var file = Path.Combine(configuration.GetDataDir(), fileName);
using var rsa = new RSACryptoServiceProvider(2048);
if (File.Exists(file))
{
rsa.FromXmlString2(File.ReadAllText(file));
}
else
{
var contents = rsa.ToXmlString2(true);
File.WriteAllText(file, contents);
}
return new RsaSecurityKey(rsa.ExportParameters(true));;
}
public static OpenIddictServerBuilder ConfigureSigningKey(this OpenIddictServerBuilder builder,
IConfiguration configuration)
{
return builder
.AddSigningKey(GetSigningKey(configuration, "signing.rsaparams"))
.AddEncryptionKey(GetSigningKey(configuration, "encrypting.rsaparams"));
}
}
}

View File

@ -1,97 +0,0 @@
using System;
using System.Globalization;
using System.Security.Cryptography;
using System.Xml;
namespace NETCore.Encrypt.Extensions.Internal
{
/// <summary>
/// .net core's implementatiosn are still marked as unsupported because of stupid decisions( https://github.com/dotnet/corefx/issues/23686)
/// </summary>
internal static class RsaKeyExtensions
{
#region XML
public static void FromXmlString2(this RSA rsa, string xmlString)
{
RSAParameters parameters = new RSAParameters();
XmlDocument xmlDoc = new XmlDocument();
xmlDoc.LoadXml(xmlString);
if (xmlDoc.DocumentElement.Name.Equals("RSAKeyValue", StringComparison.InvariantCulture))
{
foreach (XmlNode node in xmlDoc.DocumentElement.ChildNodes)
{
switch (node.Name)
{
case "Modulus":
parameters.Modulus = (string.IsNullOrEmpty(node.InnerText)
? null
: Convert.FromBase64String(node.InnerText));
break;
case "Exponent":
parameters.Exponent = (string.IsNullOrEmpty(node.InnerText)
? null
: Convert.FromBase64String(node.InnerText));
break;
case "P":
parameters.P = (string.IsNullOrEmpty(node.InnerText)
? null
: Convert.FromBase64String(node.InnerText));
break;
case "Q":
parameters.Q = (string.IsNullOrEmpty(node.InnerText)
? null
: Convert.FromBase64String(node.InnerText));
break;
case "DP":
parameters.DP = (string.IsNullOrEmpty(node.InnerText)
? null
: Convert.FromBase64String(node.InnerText));
break;
case "DQ":
parameters.DQ = (string.IsNullOrEmpty(node.InnerText)
? null
: Convert.FromBase64String(node.InnerText));
break;
case "InverseQ":
parameters.InverseQ = (string.IsNullOrEmpty(node.InnerText)
? null
: Convert.FromBase64String(node.InnerText));
break;
case "D":
parameters.D = (string.IsNullOrEmpty(node.InnerText)
? null
: Convert.FromBase64String(node.InnerText));
break;
}
}
}
else
{
throw new Exception("Invalid XML RSA key.");
}
rsa.ImportParameters(parameters);
}
public static string ToXmlString2(this RSA rsa, bool includePrivateParameters)
{
RSAParameters parameters = rsa.ExportParameters(includePrivateParameters);
return string.Format(CultureInfo.InvariantCulture,
"<RSAKeyValue><Modulus>{0}</Modulus><Exponent>{1}</Exponent><P>{2}</P><Q>{3}</Q><DP>{4}</DP><DQ>{5}</DQ><InverseQ>{6}</InverseQ><D>{7}</D></RSAKeyValue>",
parameters.Modulus != null ? Convert.ToBase64String(parameters.Modulus) : null,
parameters.Exponent != null ? Convert.ToBase64String(parameters.Exponent) : null,
parameters.P != null ? Convert.ToBase64String(parameters.P) : null,
parameters.Q != null ? Convert.ToBase64String(parameters.Q) : null,
parameters.DP != null ? Convert.ToBase64String(parameters.DP) : null,
parameters.DQ != null ? Convert.ToBase64String(parameters.DQ) : null,
parameters.InverseQ != null ? Convert.ToBase64String(parameters.InverseQ) : null,
parameters.D != null ? Convert.ToBase64String(parameters.D) : null);
}
#endregion
}
}

View File

@ -26,12 +26,14 @@ using System.Threading;
using BTCPayServer.Services.Wallets;
using BTCPayServer.Logging;
using BTCPayServer.HostedServices;
using BTCPayServer.Hosting.OpenApi;
using BTCPayServer.PaymentRequest;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Changelly;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Security;
using BTCPayServer.Security.APIKeys;
using BTCPayServer.Services.PaymentRequests;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using NBXplorer.DerivationStrategy;
@ -40,17 +42,6 @@ using Npgsql;
using BTCPayServer.Services.Apps;
using BTCPayServer.U2F;
using BundlerMinifier.TagHelpers;
using OpenIddict.EntityFrameworkCore.Models;
using System.Collections.Generic;
using System.Diagnostics;
using System.Security.Claims;
using System.Threading.Tasks;
using BTCPayServer.Models;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using BTCPayServer.Security.Bitpay;
using Serilog;
@ -66,7 +57,6 @@ namespace BTCPayServer.Hosting
{
var factory = provider.GetRequiredService<ApplicationDbContextFactory>();
factory.ConfigureBuilder(o);
o.UseOpenIddict<BTCPayOpenIdClient, BTCPayOpenIdAuthorization, OpenIddictScope<string>, BTCPayOpenIdToken, string>();
});
services.AddHttpClient();
services.AddHttpClient(nameof(ExplorerClientProvider), httpClient =>
@ -220,7 +210,6 @@ namespace BTCPayServer.Hosting
services.AddSingleton<IHostedService, WalletReceiveCacheUpdater>();
services.AddSingleton<IBackgroundJobClient, BackgroundJobClient>();
services.AddScoped<IAuthorizationHandler, CookieAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, OpenIdAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, BitpayAuthorizationHandler>();
services.TryAddSingleton<ExplorerClientProvider>();
@ -241,11 +230,11 @@ namespace BTCPayServer.Hosting
services.AddTransient<PaymentRequestController>();
// Add application services.
services.AddSingleton<EmailSenderFactory>();
// bundling
services.AddBtcPayServerAuthenticationSchemes(configuration);
services.AddAPIKeyAuthentication();
services.AddBtcPayServerAuthenticationSchemes();
services.AddAuthorization(o => o.AddBTCPayPolicies());
// bundling
services.AddSingleton<IBundleProvider, ResourceBundleProvider>();
services.AddTransient<BundleOptions>(provider =>
{
@ -274,7 +263,7 @@ namespace BTCPayServer.Hosting
}
return rateLimits;
});
services.AddBTCPayOpenApi();
services.AddLogging(logBuilder =>
{
@ -292,17 +281,18 @@ namespace BTCPayServer.Hosting
return services;
}
private const long MAX_DEBUG_LOG_FILE_SIZE = 2000000; // If debug log is in use roll it every N MB.
private static void AddBtcPayServerAuthenticationSchemes(this IServiceCollection services,
IConfiguration configuration)
private static void AddBtcPayServerAuthenticationSchemes(this IServiceCollection services)
{
services.AddAuthentication()
.AddCookie()
.AddBitpayAuthentication();
.AddBitpayAuthentication()
.AddAPIKeyAuthentication();
}
public static IApplicationBuilder UsePayServer(this IApplicationBuilder app)
{
app.UseMiddleware<BTCPayMiddleware>();
app.UseBTCPayOpenApi();
return app;
}
public static IApplicationBuilder UseHeadersOverride(this IApplicationBuilder app)

View File

@ -0,0 +1,9 @@
using System;
namespace BTCPayServer.Hosting.OpenApi
{
public class IncludeInOpenApiDocs : Attribute
{
}
}

View File

@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Payments;
using BTCPayServer.Security;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using NJsonSchema;
using NJsonSchema.Generation.TypeMappers;
using NSwag;
using NSwag.Generation.Processors.Security;
namespace BTCPayServer.Hosting.OpenApi
{
public static class OpenApiExtensions
{
public static IServiceCollection AddBTCPayOpenApi(this IServiceCollection serviceCollection)
{
return serviceCollection.AddOpenApiDocument(config =>
{
config.PostProcess = document =>
{
document.Info.Version = "v1";
document.Info.Title = "BTCPay Greenfield API";
document.Info.Description = "A full API to use your BTCPay Server";
document.Info.TermsOfService = null;
document.Info.Contact = new NSwag.OpenApiContact
{
Name = "BTCPay Server", Email = string.Empty, Url = "https://btcpayserver.org"
};
};
config.AddOperationFilter(context =>
{
var methodInfo = context.MethodInfo;
if (methodInfo != null)
{
return methodInfo.CustomAttributes.Any(data =>
data.AttributeType == typeof(IncludeInOpenApiDocs)) ||
methodInfo.DeclaringType.CustomAttributes.Any(data =>
data.AttributeType == typeof(IncludeInOpenApiDocs));
}
return false;
});
config.AddSecurity("APIKey", Enumerable.Empty<string>(),
new OpenApiSecurityScheme
{
Type = OpenApiSecuritySchemeType.ApiKey,
Name = "Authorization",
In = OpenApiSecurityApiKeyLocation.Header,
Description =
"BTCPay Server supports authenticating and authorizing users through an API Key that is generated by them. Send the API Key as a header value to Authorization with the format: token {token}. For a smoother experience, you can generate a url that redirects users to an API key creation screen."
});
config.OperationProcessors.Add(
new BTCPayPolicyOperationProcessor("APIKey", AuthenticationSchemes.ApiKey));
config.TypeMappers.Add(
new PrimitiveTypeMapper(typeof(PaymentType), s => s.Type = JsonObjectType.String));
config.TypeMappers.Add(new PrimitiveTypeMapper(typeof(PaymentMethodId),
s => s.Type = JsonObjectType.String));
});
}
public static IApplicationBuilder UseBTCPayOpenApi(this IApplicationBuilder builder)
{
return builder.UseOpenApi()
.UseReDoc(settings => settings.Path = "/docs");
}
class BTCPayPolicyOperationProcessor : AspNetCoreOperationSecurityScopeProcessor
{
private readonly string _authScheme;
public BTCPayPolicyOperationProcessor(string x, string authScheme) : base(x)
{
_authScheme = authScheme;
}
protected override IEnumerable<string> GetScopes(IEnumerable<AuthorizeAttribute> authorizeAttributes)
{
var result = authorizeAttributes
.Where(attribute => attribute?.AuthenticationSchemes != null && attribute.Policy != null &&
attribute.AuthenticationSchemes.Equals(_authScheme,
StringComparison.InvariantCultureIgnoreCase))
.Select(attribute => attribute.Policy);
return result;
}
}
}
}

View File

@ -1,8 +1,7 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Hosting;
using OpenIddict.Validation.AspNetCore;
using OpenIddict.Abstractions;
using Microsoft.AspNetCore.Builder;
using System;
using Microsoft.Extensions.DependencyInjection;
@ -19,14 +18,10 @@ using System.IO;
using Microsoft.Extensions.DependencyInjection.Extensions;
using BTCPayServer.Security;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using OpenIddict.EntityFrameworkCore.Models;
using System.Net;
using BTCPayServer.Security.OpenId;
using BTCPayServer.PaymentRequest;
using BTCPayServer.Services.Apps;
using BTCPayServer.Storage;
using Microsoft.Extensions.Options;
using OpenIddict.Core;
namespace BTCPayServer.Hosting
{
@ -57,8 +52,6 @@ namespace BTCPayServer.Hosting
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
ConfigureOpenIddict(services);
services.AddBTCPayServer(Configuration);
services.AddProviderStorage();
services.AddSession();
@ -95,12 +88,6 @@ namespace BTCPayServer.Hosting
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.AllowedForNewUsers = true;
options.Password.RequireUppercase = false;
// Configure Identity to use the same JWT claims as OpenIddict instead
// of the legacy WS-Federation claims it uses by default (ClaimTypes),
// which saves you from doing the mapping in your authorization controller.
options.ClaimsIdentity.UserNameClaimType = OpenIddictConstants.Claims.Name;
options.ClaimsIdentity.UserIdClaimType = OpenIddictConstants.Claims.Subject;
options.ClaimsIdentity.RoleClaimType = OpenIddictConstants.Claims.Role;
});
// If the HTTPS certificate path is not set this logic will NOT be used and the default Kestrel binding logic will be.
string httpsCertificateFilePath = Configuration.GetOrDefault<string>("HttpsCertificateFilePath", null);
@ -143,67 +130,6 @@ namespace BTCPayServer.Hosting
});
}
}
private DirectoryInfo GetDataDir()
{
return new DirectoryInfo(Configuration.GetDataDir(DefaultConfiguration.GetNetworkType(Configuration)));
}
private void ConfigureOpenIddict(IServiceCollection services)
{
// Register the OpenIddict services.
services.AddOpenIddict()
.AddCore(options =>
{
// Configure OpenIddict to use the Entity Framework Core stores and entities.
options.UseEntityFrameworkCore()
.UseDbContext<ApplicationDbContext>()
.ReplaceDefaultEntities<BTCPayOpenIdClient, BTCPayOpenIdAuthorization, OpenIddictScope<string>,
BTCPayOpenIdToken, string>();
})
.AddServer(options =>
{
options.UseAspNetCore()
.EnableStatusCodePagesIntegration()
.EnableAuthorizationEndpointPassthrough()
.EnableLogoutEndpointPassthrough()
.EnableAuthorizationEndpointCaching()
.DisableTransportSecurityRequirement();
// Enable the token endpoint (required to use the password flow).
options.SetTokenEndpointUris("/connect/token");
options.SetAuthorizationEndpointUris("/connect/authorize");
options.SetLogoutEndpointUris("/connect/logout");
//we do not care about these granular controls for now
options.IgnoreScopePermissions();
options.IgnoreEndpointPermissions();
// Allow client applications various flows
options.AllowImplicitFlow();
options.AllowClientCredentialsFlow();
options.AllowRefreshTokenFlow();
options.AllowPasswordFlow();
options.AllowAuthorizationCodeFlow();
options.UseRollingTokens();
options.RegisterScopes(
OpenIddictConstants.Scopes.OpenId,
BTCPayScopes.StoreManagement,
BTCPayScopes.ServerManagement
);
options.AddEventHandler(PasswordGrantTypeEventHandler.Descriptor);
options.AddEventHandler(OpenIdGrantHandlerCheckCanSignIn.Descriptor);
options.AddEventHandler(ClientCredentialsGrantTypeEventHandler.Descriptor);
options.AddEventHandler(LogoutEventHandler.Descriptor);
options.ConfigureSigningKey(Configuration);
})
.AddValidation(options =>
{
options.UseLocalServer();
options.UseAspNetCore();
});
}
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env,
@ -224,6 +150,10 @@ namespace BTCPayServer.Hosting
});
}
}
private DirectoryInfo GetDataDir()
{
return new DirectoryInfo(Configuration.GetDataDir(DefaultConfiguration.GetNetworkType(Configuration)));
}
private static void ConfigureCore(IApplicationBuilder app, IWebHostEnvironment env, IServiceProvider prov, ILoggerFactory loggerFactory, BTCPayServerOptions options)
{

View File

@ -75,6 +75,7 @@ namespace BTCPayServer.Models.AppViewModels
public bool DisplayPerksRanking { get; set; }
public bool Enabled { get; set; }
public string ResetEvery { get; set; }
public Dictionary<string, CurrencyData> CurrencyDataPayments { get; set; }
}
public class ContributeToCrowdfund

View File

@ -29,5 +29,6 @@ namespace BTCPayServer.Models
get; set;
}
public string ButtonClass { get; set; } = "btn-danger";
public string ActionUrl { get; set; }
}
}

View File

@ -38,6 +38,7 @@ namespace BTCPayServer.Models.InvoicingModels
public string PaymentMethod { get; set; }
public string Due { get; set; }
public string Paid { get; set; }
public string Address { get; internal set; }
public string Rate { get; internal set; }
public string PaymentUrl { get; internal set; }
public string Overpaid { get; set; }

View File

@ -41,5 +41,7 @@ namespace BTCPayServer.Models.StoreViewModels
public List<string> CurrencyDropdown { get; set; }
public string PayButtonImageUrl { get; set; }
public string PayButtonText { get; set; }
public bool UseModal { get; set; }
public bool JsonResponse { get; set; }
}
}

View File

@ -17,9 +17,6 @@ namespace BTCPayServer.Models.StoreViewModels
[Display(Name = "Optional, Changelly Merchant Id")]
public string ChangellyMerchantId { get; set; }
[Display(Name = "Show Fiat Currencies as option in conversion")]
public bool ShowFiat { get; set; } = true;
[Required]
[Range(0, 100)]
[Display(Name =

View File

@ -16,13 +16,11 @@ namespace BTCPayServer.Payments.Changelly
public class Changelly
{
private readonly string _apisecret;
private readonly bool _showFiat;
private readonly HttpClient _httpClient;
public Changelly(HttpClient httpClient, string apiKey, string apiSecret, string apiUrl, bool showFiat = true)
public Changelly(HttpClient httpClient, string apiKey, string apiSecret, string apiUrl)
{
_apisecret = apiSecret;
_showFiat = showFiat;
_httpClient = httpClient;
_httpClient.BaseAddress = new Uri(apiUrl);
_httpClient.DefaultRequestHeaders.Add("api-key", apiKey);
@ -62,36 +60,16 @@ namespace BTCPayServer.Payments.Changelly
public virtual async Task<IEnumerable<CurrencyFull>> GetCurrenciesFull()
{
const string message = @"{
const string message = @"{
""jsonrpc"": ""2.0"",
""id"": 1,
""method"": ""getCurrenciesFull"",
""params"": []
}";
var result = await PostToApi<IEnumerable<CurrencyFull>>(message);
var appendedResult = _showFiat
? result.Result.Concat(new[]
{
new CurrencyFull()
{
Enable = true,
Name = "EUR",
FullName = "Euro",
PayInConfirmations = 0,
ImageLink = "https://changelly.com/api/coins/eur.png"
},
new CurrencyFull()
{
Enable = true,
Name = "USD",
FullName = "US Dollar",
PayInConfirmations = 0,
ImageLink = "https://changelly.com/api/coins/usd.png"
}
})
: result.Result;
return appendedResult;
var result = await PostToApi<IEnumerable<CurrencyFull>>(message);
return result.Result;
}
public virtual async Task<decimal> GetExchangeAmount(string fromCurrency,

View File

@ -61,8 +61,9 @@ namespace BTCPayServer.Payments.Changelly
throw new ChangellyException("Changelly not enabled for this store");
}
var changelly = new Changelly(_httpClientFactory.CreateClient("Changelly"), changellySettings.ApiKey, changellySettings.ApiSecret,
changellySettings.ApiUrl, changellySettings.ShowFiat);
var changelly = new Changelly(_httpClientFactory.CreateClient("Changelly"), changellySettings.ApiKey,
changellySettings.ApiSecret,
changellySettings.ApiUrl);
_clientCache.AddOrReplace(storeId, changelly);
return changelly;
}

View File

@ -8,7 +8,6 @@ namespace BTCPayServer.Payments.Changelly
public bool Enabled { get; set; }
public string ChangellyMerchantId { get; set; }
public decimal AmountMarkupPercentage { get; set; }
public bool ShowFiat { get; set; }
public bool IsConfigured()
{

View File

@ -53,7 +53,6 @@ namespace BTCPayServer
l.AddFilter("Microsoft", LogLevel.Error);
l.AddFilter("System.Net.Http.HttpClient", LogLevel.Critical);
l.AddFilter("Microsoft.AspNetCore.Antiforgery.Internal", LogLevel.Critical);
l.AddFilter("OpenIddict.Server.OpenIddictServerProvider", LogLevel.Error);
l.AddProvider(new CustomConsoleLogProvider(processor));
})
.UseStartup<Startup>()

View File

@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Security.Bitpay;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace BTCPayServer.Security.APIKeys
{
public class APIKeyAuthenticationHandler : AuthenticationHandler<APIKeyAuthenticationOptions>
{
private readonly APIKeyRepository _apiKeyRepository;
private readonly IOptionsMonitor<IdentityOptions> _identityOptions;
public APIKeyAuthenticationHandler(
APIKeyRepository apiKeyRepository,
IOptionsMonitor<IdentityOptions> identityOptions,
IOptionsMonitor<APIKeyAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock) : base(options, logger, encoder, clock)
{
_apiKeyRepository = apiKeyRepository;
_identityOptions = identityOptions;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Context.Request.HttpContext.GetAPIKey(out var apiKey) || string.IsNullOrEmpty(apiKey))
return AuthenticateResult.NoResult();
var key = await _apiKeyRepository.GetKey(apiKey);
if (key == null)
{
return AuthenticateResult.Fail("ApiKey authentication failed");
}
List<Claim> claims = new List<Claim>();
claims.Add(new Claim(_identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, key.UserId));
claims.AddRange(key.GetPermissions()
.Select(permission => new Claim(APIKeyConstants.ClaimTypes.Permissions, permission)));
return AuthenticateResult.Success(new AuthenticationTicket(
new ClaimsPrincipal(new ClaimsIdentity(claims, APIKeyConstants.AuthenticationType)), APIKeyConstants.AuthenticationType));
}
}
}

View File

@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Authentication;
namespace BTCPayServer.Security.Bitpay
{
public class APIKeyAuthenticationOptions : AuthenticationSchemeOptions
{
}
}

View File

@ -1,50 +1,50 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using BTCPayServer.Data;
using BTCPayServer.Services.Stores;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Primitives;
using static BTCPayServer.Security.OpenId.RestAPIPolicies;
using OpenIddict.Abstractions;
using BTCPayServer.Security.OpenId;
namespace BTCPayServer.Security
namespace BTCPayServer.Security.APIKeys
{
public class OpenIdAuthorizationHandler : AuthorizationHandler<PolicyRequirement>
public class APIKeyAuthorizationHandler : AuthorizationHandler<PolicyRequirement>
{
private readonly HttpContext _HttpContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly StoreRepository _storeRepository;
public OpenIdAuthorizationHandler(IHttpContextAccessor httpContextAccessor,
UserManager<ApplicationUser> userManager,
StoreRepository storeRepository)
public APIKeyAuthorizationHandler(IHttpContextAccessor httpContextAccessor,
UserManager<ApplicationUser> userManager,
StoreRepository storeRepository)
{
_HttpContext = httpContextAccessor.HttpContext;
_userManager = userManager;
_storeRepository = storeRepository;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, PolicyRequirement requirement)
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
PolicyRequirement requirement)
{
if (context.User.Identity.AuthenticationType != "AuthenticationTypes.Federation")
if (context.User.Identity.AuthenticationType != APIKeyConstants.AuthenticationType)
return;
bool success = false;
switch (requirement.Policy)
{
case Policies.CanListStoreSettings.Key:
var selectiveStorePermissions =
APIKeyConstants.Permissions.ExtractStorePermissionsIds(context.GetPermissions());
success = context.HasPermissions(APIKeyConstants.Permissions.StoreManagement) ||
selectiveStorePermissions.Any();
break;
case Policies.CanModifyStoreSettings.Key:
if (!context.HasScopes(BTCPayScopes.StoreManagement))
break;
// TODO: It should be possible to grant permission to a specific store
// we can do this by adding saving a claim with the specific store id
// to the access_token
string storeId = _HttpContext.GetImplicitStoreId();
if (!context.HasPermissions(APIKeyConstants.Permissions.StoreManagement) &&
!context.HasPermissions(APIKeyConstants.Permissions.GetStorePermission(storeId)))
break;
if (storeId == null)
{
success = true;
@ -60,9 +60,10 @@ namespace BTCPayServer.Security
success = true;
_HttpContext.SetStoreData(store);
}
break;
case Policies.CanModifyServerSettings.Key:
if (!context.HasScopes(BTCPayScopes.ServerManagement))
if (!context.HasPermissions(APIKeyConstants.Permissions.ServerManagement))
break;
// For this authorization, we stil check in database because it is super sensitive.
var user = await _userManager.GetUserAsync(context.User);

View File

@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace BTCPayServer.Security.APIKeys
{
public static class APIKeyConstants
{
public const string AuthenticationType = "APIKey";
public static class ClaimTypes
{
public const string Permissions = nameof(APIKeys) + "." + nameof(Permissions);
}
public static class Permissions
{
public const string ServerManagement = nameof(ServerManagement);
public const string StoreManagement = nameof(StoreManagement);
public static readonly Dictionary<string, (string Title, string Description)> PermissionDescriptions = new Dictionary<string, (string Title, string Description)>()
{
{StoreManagement, ("Manage your stores", "The app will be able to create, modify and delete all your stores.")},
{$"{nameof(StoreManagement)}:", ("Manage selected stores", "The app will be able to modify and delete selected stores.")},
{ServerManagement, ("Manage your server", "The app will have total control on your server")},
};
public static string GetStorePermission(string storeId) => $"{nameof(StoreManagement)}:{storeId}";
public static IEnumerable<string> ExtractStorePermissionsIds(IEnumerable<string> permissions) => permissions
.Where(s => s.StartsWith($"{nameof(StoreManagement)}:", StringComparison.InvariantCulture))
.Select(s => s.Split(":")[1]);
}
}
}

View File

@ -0,0 +1,75 @@
using System;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Security.Bitpay;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Primitives;
namespace BTCPayServer.Security.APIKeys
{
public static class APIKeyExtensions
{
public static bool GetAPIKey(this HttpContext httpContext, out StringValues apiKey)
{
if (httpContext.Request.Headers.TryGetValue("Authorization", out var value) &&
value.ToString().StartsWith("token ", StringComparison.InvariantCultureIgnoreCase))
{
apiKey = value.ToString().Substring("token ".Length);
return true;
}
return false;
}
public static Task<StoreData[]> GetStores(this ClaimsPrincipal claimsPrincipal,
UserManager<ApplicationUser> userManager, StoreRepository storeRepository)
{
var permissions =
claimsPrincipal.Claims.Where(claim => claim.Type == APIKeyConstants.ClaimTypes.Permissions)
.Select(claim => claim.Value).ToList();
if (permissions.Contains(APIKeyConstants.Permissions.StoreManagement))
{
return storeRepository.GetStoresByUserId(userManager.GetUserId(claimsPrincipal));
}
var storeIds = APIKeyConstants.Permissions.ExtractStorePermissionsIds(permissions);
return storeRepository.GetStoresByUserId(userManager.GetUserId(claimsPrincipal), storeIds);
}
public static AuthenticationBuilder AddAPIKeyAuthentication(this AuthenticationBuilder builder)
{
builder.AddScheme<APIKeyAuthenticationOptions, APIKeyAuthenticationHandler>(AuthenticationSchemes.ApiKey,
o => { });
return builder;
}
public static IServiceCollection AddAPIKeyAuthentication(this IServiceCollection serviceCollection)
{
serviceCollection.AddSingleton<APIKeyRepository>();
serviceCollection.AddScoped<IAuthorizationHandler, APIKeyAuthorizationHandler>();
return serviceCollection;
}
public static string[] GetPermissions(this AuthorizationHandlerContext context)
{
return context.User.Claims.Where(c =>
c.Type.Equals(APIKeyConstants.ClaimTypes.Permissions, StringComparison.InvariantCultureIgnoreCase))
.Select(claim => claim.Value).ToArray();
}
public static bool HasPermissions(this AuthorizationHandlerContext context, params string[] scopes)
{
return scopes.All(s => context.User.HasClaim(c =>
c.Type.Equals(APIKeyConstants.ClaimTypes.Permissions, StringComparison.InvariantCultureIgnoreCase) &&
c.Value.Split(' ').Contains(s)));
}
}
}

View File

@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Security.APIKeys
{
public class APIKeyRepository
{
private readonly ApplicationDbContextFactory _applicationDbContextFactory;
public APIKeyRepository(ApplicationDbContextFactory applicationDbContextFactory)
{
_applicationDbContextFactory = applicationDbContextFactory;
}
public async Task<APIKeyData> GetKey(string apiKey)
{
using (var context = _applicationDbContextFactory.CreateContext())
{
return await EntityFrameworkQueryableExtensions.SingleOrDefaultAsync(context.ApiKeys,
data => data.Id == apiKey && data.Type != APIKeyType.Legacy);
}
}
public async Task<List<APIKeyData>> GetKeys(APIKeyQuery query)
{
using (var context = _applicationDbContextFactory.CreateContext())
{
var queryable = context.ApiKeys.AsQueryable();
if (query?.UserId != null && query.UserId.Any())
{
queryable = queryable.Where(data => query.UserId.Contains(data.UserId));
}
return await queryable.ToListAsync();
}
}
public async Task CreateKey(APIKeyData key)
{
if (key.Type == APIKeyType.Legacy || !string.IsNullOrEmpty(key.StoreId) || string.IsNullOrEmpty(key.UserId))
{
throw new InvalidOperationException("cannot save a bitpay legacy api key with this repository");
}
using (var context = _applicationDbContextFactory.CreateContext())
{
await context.ApiKeys.AddAsync(key);
await context.SaveChangesAsync();
}
}
public async Task Remove(string id, string getUserId)
{
using (var context = _applicationDbContextFactory.CreateContext())
{
var key = await EntityFrameworkQueryableExtensions.SingleOrDefaultAsync(context.ApiKeys,
data => data.Id == id && data.UserId == getUserId);
context.ApiKeys.Remove(key);
await context.SaveChangesAsync();
}
}
public class APIKeyQuery
{
public string[] UserId { get; set; }
}
}
}

View File

@ -1,16 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using OpenIddict.Validation;
using OpenIddict.Validation.AspNetCore;
namespace BTCPayServer.Security
namespace BTCPayServer.Security
{
public class AuthenticationSchemes
{
public const string Cookie = "Identity.Application";
public const string Bitpay = "Bitpay";
public const string OpenId = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
public const string ApiKey = "GreenfieldApiKey";
}
}

View File

@ -66,10 +66,10 @@ namespace BTCPayServer.Security.Bitpay
using (var ctx = _Factory.CreateContext())
{
var existing = await ctx.ApiKeys.Where(o => o.StoreId == storeId).FirstOrDefaultAsync();
if (existing != null)
var existing = await ctx.ApiKeys.Where(o => o.StoreId == storeId && o.Type == APIKeyType.Legacy).ToListAsync();
if (existing.Any())
{
ctx.ApiKeys.Remove(existing);
ctx.ApiKeys.RemoveRange(existing);
}
ctx.ApiKeys.Add(new APIKeyData() { Id = new string(generated), StoreId = storeId });
await ctx.SaveChangesAsync().ConfigureAwait(false);
@ -95,7 +95,7 @@ namespace BTCPayServer.Security.Bitpay
{
using (var ctx = _Factory.CreateContext())
{
return await ctx.ApiKeys.Where(o => o.StoreId == storeId).Select(c => c.Id).ToArrayAsync();
return await ctx.ApiKeys.Where(o => o.StoreId == storeId && o.Type== APIKeyType.Legacy).Select(c => c.Id).ToArrayAsync();
}
}

View File

@ -1,19 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using OpenIddict.Abstractions;
namespace BTCPayServer.Security.OpenId
{
public static class BTCPayScopes
{
public const string StoreManagement = "store_management";
public const string ServerManagement = "server_management";
}
public static class RestAPIPolicies
{
}
}

View File

@ -1,43 +0,0 @@
using System.Threading.Tasks;
using OpenIdConnectRequest = OpenIddict.Abstractions.OpenIddictRequest;
using BTCPayServer.Data;
using BTCPayServer.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using OpenIddict.Core;
using OpenIddict.Server;
using System.Security.Claims;
namespace BTCPayServer.Security.OpenId
{
public abstract class BaseOpenIdGrantHandler<T> :
IOpenIddictServerHandler<T>
where T : OpenIddictServerEvents.BaseContext
{
private readonly OpenIddictApplicationManager<BTCPayOpenIdClient> _applicationManager;
private readonly OpenIddictAuthorizationManager<BTCPayOpenIdAuthorization> _authorizationManager;
protected readonly SignInManager<ApplicationUser> _signInManager;
protected readonly IOptions<IdentityOptions> _identityOptions;
protected BaseOpenIdGrantHandler(
OpenIddictApplicationManager<BTCPayOpenIdClient> applicationManager,
OpenIddictAuthorizationManager<BTCPayOpenIdAuthorization> authorizationManager,
SignInManager<ApplicationUser> signInManager,
IOptions<IdentityOptions> identityOptions)
{
_applicationManager = applicationManager;
_authorizationManager = authorizationManager;
_signInManager = signInManager;
_identityOptions = identityOptions;
}
protected Task<ClaimsPrincipal> CreateClaimsPrincipalAsync(OpenIdConnectRequest request, ApplicationUser user)
{
return OpenIdExtensions.CreateClaimsPrincipalAsync(_applicationManager, _authorizationManager,
_identityOptions.Value, _signInManager, request, user);
}
public abstract ValueTask HandleAsync(T notification);
}
}

View File

@ -1,61 +0,0 @@
using System.Security.Claims;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using OpenIddict.Core;
using OpenIddict.EntityFrameworkCore.Models;
using OpenIddict.Server;
namespace BTCPayServer.Security.OpenId
{
public class ClientCredentialsGrantTypeEventHandler :
BaseOpenIdGrantHandler<OpenIddictServerEvents.HandleTokenRequestContext>
{
public static OpenIddictServerHandlerDescriptor Descriptor { get; } =
OpenIddictServerHandlerDescriptor.CreateBuilder<OpenIddictServerEvents.HandleTokenRequestContext>()
.UseScopedHandler<ClientCredentialsGrantTypeEventHandler>()
.Build();
private readonly OpenIddictApplicationManager<BTCPayOpenIdClient> _applicationManager;
private readonly UserManager<ApplicationUser> _userManager;
public ClientCredentialsGrantTypeEventHandler(
OpenIddictApplicationManager<BTCPayOpenIdClient> applicationManager,
OpenIddictAuthorizationManager<BTCPayOpenIdAuthorization> authorizationManager,
SignInManager<ApplicationUser> signInManager,
IOptions<IdentityOptions> identityOptions,
UserManager<ApplicationUser> userManager) : base(applicationManager, authorizationManager, signInManager,
identityOptions)
{
_applicationManager = applicationManager;
_userManager = userManager;
}
public override async ValueTask HandleAsync(
OpenIddictServerEvents.HandleTokenRequestContext notification)
{
var request = notification.Request;
var context = notification;
if (!request.IsClientCredentialsGrantType())
{
return;
}
var application = await _applicationManager.FindByClientIdAsync(request.ClientId);
if (application == null)
{
context.Reject(
error: OpenIddictConstants.Errors.InvalidClient,
description: "The client application was not found in the database.");
return;
}
var user = await _userManager.FindByIdAsync(application.ApplicationUserId);
context.Principal = await CreateClaimsPrincipalAsync(request, user);
}
}
}

View File

@ -1,34 +0,0 @@
using System;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using OpenIddict.Core;
using OpenIddict.Server;
using Microsoft.AspNetCore;
using OpenIddict.Server.AspNetCore;
namespace BTCPayServer.Security.OpenId
{
public class LogoutEventHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleLogoutRequestContext>
{
protected readonly SignInManager<ApplicationUser> _signInManager;
public static OpenIddictServerHandlerDescriptor Descriptor { get; } =
OpenIddictServerHandlerDescriptor.CreateBuilder<OpenIddictServerEvents.HandleLogoutRequestContext>()
.UseScopedHandler<LogoutEventHandler>()
.Build();
public LogoutEventHandler(SignInManager<ApplicationUser> signInManager)
{
_signInManager = signInManager;
}
public async ValueTask HandleAsync(
OpenIddictServerEvents.HandleLogoutRequestContext notification)
{
await _signInManager.SignOutAsync();
}
}
}

View File

@ -1,106 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using OpenIddict.Abstractions;
using OpenIddict.Core;
using OpenIddict.Server;
namespace BTCPayServer.Security.OpenId
{
public static class OpenIdExtensions
{
public static ImmutableHashSet<string> Restrict(this ImmutableArray<string> scopes, ClaimsPrincipal claimsPrincipal)
{
HashSet<string> restricted = new HashSet<string>();
foreach (var scope in scopes)
{
if (scope == BTCPayScopes.ServerManagement && !claimsPrincipal.IsInRole(Roles.ServerAdmin))
continue;
restricted.Add(scope);
}
return restricted.ToImmutableHashSet();
}
public static async Task<ClaimsPrincipal> CreateClaimsPrincipalAsync(OpenIddictApplicationManager<BTCPayOpenIdClient> applicationManager,
OpenIddictAuthorizationManager<BTCPayOpenIdAuthorization> authorizationManager,
IdentityOptions identityOptions,
SignInManager<ApplicationUser> signInManager,
OpenIddictRequest request,
ApplicationUser user)
{
var principal = await signInManager.CreateUserPrincipalAsync(user);
if (!request.IsAuthorizationCodeGrantType() && !request.IsRefreshTokenGrantType())
{
principal.SetScopes(request.GetScopes().Restrict(principal));
}
else if (request.IsAuthorizationCodeGrantType() &&
string.IsNullOrEmpty(principal.GetInternalAuthorizationId()))
{
var app = await applicationManager.FindByClientIdAsync(request.ClientId);
var authorizationId = await IsUserAuthorized(authorizationManager, request, user.Id, app.Id);
if (!string.IsNullOrEmpty(authorizationId))
{
principal.SetInternalAuthorizationId(authorizationId);
}
}
principal.SetDestinations(identityOptions);
return principal;
}
public static void SetDestinations(this ClaimsPrincipal principal, IdentityOptions identityOptions)
{
foreach (var claim in principal.Claims)
{
claim.SetDestinations(GetDestinations(identityOptions, claim, principal));
}
}
private static IEnumerable<string> GetDestinations(IdentityOptions identityOptions, Claim claim,
ClaimsPrincipal principal)
{
switch (claim.Type)
{
case OpenIddictConstants.Claims.Name:
case OpenIddictConstants.Claims.Email:
yield return OpenIddictConstants.Destinations.AccessToken;
yield break;
}
}
public static async Task<string> IsUserAuthorized(
OpenIddictAuthorizationManager<BTCPayOpenIdAuthorization> authorizationManager,
OpenIddictRequest request, string userId, string applicationId)
{
var authorizations = await authorizationManager.ListAsync(queryable =>
queryable.Where(authorization =>
authorization.Subject == userId &&
authorization.Application.Id == applicationId &&
authorization.Status == OpenIddictConstants.Statuses.Valid)).ToArrayAsync();
if (authorizations.Length > 0)
{
var scopeTasks = authorizations.Select(authorization =>
(authorizationManager.GetScopesAsync(authorization).AsTask(), authorization.Id));
await Task.WhenAll(scopeTasks.Select((tuple) => tuple.Item1));
var authorizationsWithSufficientScopes = scopeTasks
.Select((tuple) => (Id: tuple.Id, Scopes: tuple.Item1.Result))
.Where((tuple) => !request.GetScopes().Except(tuple.Scopes).Any());
if (authorizationsWithSufficientScopes.Any())
{
return authorizationsWithSufficientScopes.First().Id;
}
}
return null;
}
}
}

View File

@ -1,70 +0,0 @@
using System.Threading.Tasks;
using OpenIddict.Abstractions;
using BTCPayServer.Data;
using BTCPayServer.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using OpenIddict.Core;
using OpenIddict.Server;
using Microsoft.AspNetCore;
using OpenIddict.Server.AspNetCore;
namespace BTCPayServer.Security.OpenId
{
public class OpenIdGrantHandlerCheckCanSignIn :
BaseOpenIdGrantHandler<OpenIddictServerEvents.HandleTokenRequestContext>
{
public static OpenIddictServerHandlerDescriptor Descriptor { get; } =
OpenIddictServerHandlerDescriptor.CreateBuilder<OpenIddictServerEvents.HandleTokenRequestContext>()
.UseScopedHandler<OpenIdGrantHandlerCheckCanSignIn>()
.Build();
private readonly UserManager<ApplicationUser> _userManager;
public OpenIdGrantHandlerCheckCanSignIn(
OpenIddictApplicationManager<BTCPayOpenIdClient> applicationManager,
OpenIddictAuthorizationManager<BTCPayOpenIdAuthorization> authorizationManager,
SignInManager<ApplicationUser> signInManager,
IOptions<IdentityOptions> identityOptions, UserManager<ApplicationUser> userManager) : base(
applicationManager, authorizationManager, signInManager,
identityOptions)
{
_userManager = userManager;
}
public override async ValueTask HandleAsync(
OpenIddictServerEvents.HandleTokenRequestContext notification)
{
var request = notification.Request;
if (!request.IsRefreshTokenGrantType() && !request.IsAuthorizationCodeGrantType())
{
// Allow other handlers to process the event.
return;
}
var httpContext = notification.Transaction.GetHttpRequest().HttpContext;
var authenticateResult = (await httpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme));
var user = await _userManager.GetUserAsync(authenticateResult.Principal);
if (user == null)
{
notification.Reject(
error: OpenIddictConstants.Errors.InvalidGrant,
description: "The token is no longer valid.");
return;
}
// Ensure the user is still allowed to sign in.
if (!await _signInManager.CanSignInAsync(user))
{
notification.Reject(
error: OpenIddictConstants.Errors.InvalidGrant,
description: "The user is no longer allowed to sign in.");
return;
}
notification.Principal = await this.CreateClaimsPrincipalAsync(request, user);
}
}
}

View File

@ -1,65 +0,0 @@
using System;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.U2F;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using OpenIddict.Core;
using OpenIddict.Server;
using Microsoft.AspNetCore;
namespace BTCPayServer.Security.OpenId
{
public class PasswordGrantTypeEventHandler : BaseOpenIdGrantHandler<OpenIddictServerEvents.HandleTokenRequestContext>
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly NicolasDorier.RateLimits.RateLimitService _rateLimitService;
private readonly U2FService _u2FService;
public PasswordGrantTypeEventHandler(
OpenIddictApplicationManager<BTCPayOpenIdClient> applicationManager,
OpenIddictAuthorizationManager<BTCPayOpenIdAuthorization> authorizationManager,
SignInManager<ApplicationUser> signInManager,
UserManager<ApplicationUser> userManager,
NicolasDorier.RateLimits.RateLimitService rateLimitService,
IOptions<IdentityOptions> identityOptions, U2FService u2FService) : base(applicationManager,
authorizationManager, signInManager, identityOptions)
{
_userManager = userManager;
_rateLimitService = rateLimitService;
_u2FService = u2FService;
}
public static OpenIddictServerHandlerDescriptor Descriptor { get; } =
OpenIddictServerHandlerDescriptor.CreateBuilder<OpenIddictServerEvents.HandleTokenRequestContext>()
.UseScopedHandler<PasswordGrantTypeEventHandler>()
.Build();
public override async ValueTask HandleAsync(
OpenIddictServerEvents.HandleTokenRequestContext notification)
{
var request = notification.Request;
if (!request.IsPasswordGrantType())
{
return;
}
var httpContext = notification.Transaction.GetHttpRequest().HttpContext;
await _rateLimitService.Throttle(ZoneLimits.Login, httpContext.Connection.RemoteIpAddress.ToString(), httpContext.RequestAborted);
var user = await _userManager.FindByNameAsync(request.Username);
if (user == null || await _u2FService.HasDevices(user.Id) ||
!(await _signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true))
.Succeeded)
{
notification.Reject(
error: OpenIddictConstants.Errors.InvalidGrant,
description: "The specified credentials are invalid.");
return;
}
notification.Principal = await CreateClaimsPrincipalAsync(request, user);
}
}
}

View File

@ -11,6 +11,7 @@ namespace BTCPayServer.Security
public static AuthorizationOptions AddBTCPayPolicies(this AuthorizationOptions options)
{
options.AddPolicy(CanModifyStoreSettings.Key);
options.AddPolicy(CanListStoreSettings.Key);
options.AddPolicy(CanCreateInvoice.Key);
options.AddPolicy(CanGetRates.Key);
options.AddPolicy(CanModifyServerSettings.Key);
@ -30,6 +31,10 @@ namespace BTCPayServer.Security
{
public const string Key = "btcpay.store.canmodifystoresettings";
}
public class CanListStoreSettings
{
public const string Key = "btcpay.store.canliststoresettings";
}
public class CanCreateInvoice
{
public const string Key = "btcpay.store.cancreateinvoice";

View File

@ -1,14 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Routing;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using OpenIddict.Abstractions;
using BTCPayServer.Data;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Identity;
namespace BTCPayServer.Security
{
@ -16,7 +10,7 @@ namespace BTCPayServer.Security
{
public static bool HasScopes(this AuthorizationHandlerContext context, params string[] scopes)
{
return scopes.All(s => context.User.HasClaim(c => c.Type == OpenIddictConstants.Claims.Scope && c.Value.Split(' ').Contains(s)));
return scopes.All(s => context.User.HasClaim(c => c.Type.Equals("scope", StringComparison.InvariantCultureIgnoreCase) && c.Value.Split(' ').Contains(s)));
}
public static string GetImplicitStoreId(this HttpContext httpContext)
{

View File

@ -153,6 +153,10 @@ namespace BTCPayServer.Services.Apps
Sounds = settings.Sounds,
AnimationColors = settings.AnimationColors,
CurrencyData = _Currencies.GetCurrencyData(settings.TargetCurrency, true),
CurrencyDataPayments = currentPayments.Select(pair => pair.Key)
.Concat(pendingPayments.Select(pair => pair.Key)).Distinct()
.Select(id => _Currencies.GetCurrencyData(id.CryptoCode, true))
.ToDictionary(data => data.Code, data => data),
Info = new ViewCrowdfundViewModel.CrowdfundInfo()
{
TotalContributors = paidInvoices.Length,

View File

@ -78,12 +78,12 @@ namespace BTCPayServer.Services.Stores
}
}
public async Task<StoreData[]> GetStoresByUserId(string userId)
public async Task<StoreData[]> GetStoresByUserId(string userId, IEnumerable<string> storeIds = null)
{
using (var ctx = _ContextFactory.CreateContext())
{
return (await ctx.UserStore
.Where(u => u.ApplicationUserId == userId)
.Where(u => u.ApplicationUserId == userId && (storeIds == null || storeIds.Contains(u.StoreDataId)))
.Select(u => new { u.StoreData, u.Role })
.ToArrayAsync())
.Select(u =>

View File

@ -128,7 +128,7 @@
<b-tooltip target="crowdfund-body-raised-amount" v-if="paymentStats && paymentStats.length > 0">
<ul class="p-0 text-uppercase">
<li v-for="stat of paymentStats" class="list-unstyled">
{{stat.label}} <span v-if="stat.lightning" class="fa fa-bolt"></span> {{stat.value.toFixed(srvModel.currencyData.divisibility)}}
{{stat.label}} <span v-if="stat.lightning" class="fa fa-bolt"></span> {{stat.value}}
</li>
</ul>
</b-tooltip>

View File

@ -1,57 +0,0 @@
@using BTCPayServer.Security.OpenId
@using OpenIddict.Abstractions
@model BTCPayServer.Models.Authorization.AuthorizeViewModel
@{
var scopeMappings = new Dictionary<string, (string Title, string Description)>()
{
{BTCPayScopes.StoreManagement, ("Manage your stores", "The app will be able to create, modify and delete all your stores.")},
{BTCPayScopes.ServerManagement, ("Manage your server", "The app will have total control on your server")},
};
}
<form method="post">
<input type="hidden" name="request_id" value="@Model.RequestId"/>
<section>
<div class="card container">
<div class="row">
<div class="col-lg-12 section-heading">
<h2>Authorization Request</h2>
<hr class="primary">
<p>@Model.ApplicationName is requesting access to your account.</p>
</div>
</div>
<div class="row">
<div class="col-lg-12">
<div class="list-group list-group-flush">
@foreach (var scope in Model.Scope)
{
@if (scopeMappings.TryGetValue(scope, out var text))
{
<li class="list-group-item">
<h5 class="mb-1">@text.Title</h5>
<p class="mb-1">@text.Description.</p>
</li>
}
}
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-lg-12 text-center">
<div class="btn-group">
<button class="btn btn-primary" name="consent" id="consent-yes" type="submit" value="Yes">Authorize app</button>
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="sr-only">Toggle Dropdown</span>
</button>
<div class="dropdown-menu">
<button class="dropdown-item" name="consent" id="consent-yes-temporary" type="submit" value="YesTemporary">Authorize app until session ends</button>
</div>
</div>
<button class="btn btn-secondary" id="consent-no" name="consent" type="submit" value="No">Cancel</button>
</div>
</div>
</div>
</section>
</form>

View File

@ -55,7 +55,8 @@
text: @Safe.Json(Model.BitcoinUri),
width: 200,
height: 200,
useSVG: true
useSVG: true,
correctLevel : QRCode.CorrectLevel.M
});
$("#qrCode > img").css({ "margin": "auto" });
</script>

View File

@ -183,7 +183,8 @@
</div>
}
<partial name="InvoicePaymentsPartial" model="Model" />
<partial name="ListInvoicesPaymentsPartial" model="(Model, false)" />
<div class="row">
<div class="col-md-12">

View File

@ -274,7 +274,8 @@
<tr id="invoice_@invoice.InvoiceId" style="display:none;">
<td colspan="99">
<div style="margin-left: 15px; margin-bottom: 0;">
<partial name="InvoicePaymentsPartial" model="invoice.Details" />
@* Leaving this as partial because it abstracts complexity of Invoice Payments *@
<partial name="ListInvoicesPaymentsPartial" model="(invoice.Details, true)" />
</div>
</td>
</tr>

View File

@ -1,31 +1,42 @@
@model InvoiceDetailsModel
@model (InvoiceDetailsModel Invoice, bool ShowAddress)
@{ var invoice = Model.Invoice; }
@inject PaymentMethodHandlerDictionary PaymentMethodHandlerDictionary
<div class="row">
<div class="col-md-12 invoice-payments">
<h3>Current status</h3>
<h3>Paid summary</h3>
<table class="table table-sm table-responsive-md">
<thead class="thead-inverse">
<tr>
<th>Payment method</th>
@if (Model.ShowAddress)
{
<th>Address</th>
}
<th class="text-right">Rate</th>
<th class="text-right">Paid</th>
<th class="text-right">Due</th>
@if (Model.StatusException == InvoiceExceptionStatus.PaidOver)
@if (invoice.StatusException == InvoiceExceptionStatus.PaidOver)
{
<th class="text-right">Overpaid</th>
}
</tr>
</thead>
<tbody>
@foreach (var payment in Model.CryptoPayments)
@foreach (var payment in invoice.CryptoPayments)
{
<tr>
<td>@payment.PaymentMethod</td>
@if (Model.ShowAddress)
{
<td title="@payment.Address">
<span class="text-truncate d-block" style="max-width: 400px">@payment.Address</span>
</td>
}
<td class="text-right">@payment.Rate</td>
<td class="text-right">@payment.Paid</td>
<td class="text-right">@payment.Due</td>
@if (Model.StatusException == InvoiceExceptionStatus.PaidOver)
@if (invoice.StatusException == InvoiceExceptionStatus.PaidOver)
{
<td class="text-right">@payment.Overpaid</td>
}
@ -36,8 +47,7 @@
</div>
</div>
@{
var grouped = Model.Payments.GroupBy(payment => payment.GetPaymentMethodId().PaymentType);
var grouped = invoice.Payments.GroupBy(payment => payment.GetPaymentMethodId().PaymentType);
}
@foreach (var paymentGroup in grouped)
{

View File

@ -0,0 +1,52 @@
@model BTCPayServer.Controllers.ManageController.ApiKeysViewModel
@{
ViewData.SetActivePageAndTitle(ManageNavPages.APIKeys, "Manage your API Keys");
}
<partial name="_StatusMessage"/>
<h4>API Keys</h4>
<table class="table table-lg">
<thead>
<tr>
<th>Label</th>
<th>Key</th>
<th>Permissions</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var keyData in Model.ApiKeyDatas)
{
<tr>
<td>@keyData.Label</td>
<td>@keyData.Id</td>
<td>
@if (string.IsNullOrEmpty(keyData.Permissions))
{
<span>No permissions</span>
}
else
{
<span>@string.Join(", ", keyData.GetPermissions())</span>
}
</td>
<td class="text-right">
<a asp-action="RemoveAPIKey" asp-route-id="@keyData.Id" asp-controller="Manage">Remove</a>
</td>
</tr>
}
@if (!Model.ApiKeyDatas.Any())
{
<tr>
<td colspan="4" class="text-center h5 py-2">
No API keys
</td>
</tr>
}
<tr class="bg-gray">
<td colspan="4">
<a class="btn btn-primary" asp-action="AddApiKey" id="AddApiKey">Generate new key</a>
</td>
</tr>
</tbody>
</table>

View File

@ -0,0 +1,128 @@
@using BTCPayServer.Controllers
@using BTCPayServer.Security.APIKeys
@model BTCPayServer.Controllers.ManageController.AddApiKeyViewModel
@{
ViewData.SetActivePageAndTitle(ManageNavPages.APIKeys, "Add API Key");
string GetDescription(string permission)
{
return APIKeyConstants.Permissions.PermissionDescriptions[permission].Description;
}
string GetTitle(string permission)
{
return APIKeyConstants.Permissions.PermissionDescriptions[permission].Description;
}
}
<h4>@ViewData["Title"]</h4>
<partial name="_StatusMessage"/>
<p >
Generate a new api key to use BTCPay through its API.
</p>
<div class="row">
<div class="col-md-12">
<form method="post" asp-action="AddApiKey" class="list-group">
<input type="hidden" asp-for="StoreMode" value="@Model.StoreMode"/>
<div asp-validation-summary="All" class="text-danger"></div>
<div class="list-group-item ">
<div class="form-group">
<label asp-for="Label"></label>
<input asp-for="Label" class="form-control"/>
<span asp-validation-for="Label" class="text-danger"></span>
</div>
</div>
@if (Model.IsServerAdmin)
{
<div class="list-group-item form-group">
<input asp-for="ServerManagementPermission" class="form-check-inline"/>
<label asp-for="ServerManagementPermission" class="h5">@GetTitle(APIKeyConstants.Permissions.ServerManagement)</label>
<span asp-validation-for="ServerManagementPermission" class="text-danger"></span>
<p>@GetDescription(APIKeyConstants.Permissions.ServerManagement).</p>
</div>
}
@if (Model.StoreMode == ManageController.AddApiKeyViewModel.ApiKeyStoreMode.AllStores)
{
<div class="list-group-item form-group">
@Html.CheckBoxFor(model => model.StoreManagementPermission, new Dictionary<string, string>() {{"class", "form-check-inline"}})
<label asp-for="StoreManagementPermission" class="h5">@GetTitle(APIKeyConstants.Permissions.StoreManagement)</label>
<span asp-validation-for="StoreManagementPermission" class="text-danger"></span>
<p class="mb-0">@GetDescription(APIKeyConstants.Permissions.StoreManagement).</p>
<button type="submit" class="btn btn-link" name="command" value="change-store-mode">Give permission to specific stores instead</button>
</div>
}
else if (Model.StoreMode == ManageController.AddApiKeyViewModel.ApiKeyStoreMode.Specific)
{
<div class="list-group-item p-0 border-0 mb-2">
<li class="list-group-item ">
<h5 class="mb-1">@GetTitle(APIKeyConstants.Permissions.StoreManagement + ":")</h5>
<p class="mb-0">@GetDescription(APIKeyConstants.Permissions.StoreManagement + ":").</p>
<button type="submit" class="btn btn-link" name="command" value="change-store-mode">Give permission to all stores instead</button>
</li>
@if (!Model.Stores.Any())
{
<li class="list-group-item alert-warning">
You currently have no stores configured.
</li>
}
@for (var index = 0; index < Model.SpecificStores.Count; index++)
{
<div class="list-group-item transaction-output-form p-0 pl-lg-2">
<div class="row">
<div class="col-sm-12 col-md-12 col-lg-10 py-2 ">
<div class="form-group my-0">
@if (Model.SpecificStores[index] == null)
{
<select asp-for="SpecificStores[index]" class="form-control" asp-items="@(new SelectList(Model.Stores.Where(data => !Model.SpecificStores.Contains(data.Id)), nameof(StoreData.Id), nameof(StoreData.StoreName)))"></select>
}
else
{
var store = Model.Stores.SingleOrDefault(data => data.Id == Model.SpecificStores[index]);
<select asp-for="SpecificStores[index]" class="form-control" asp-items="@(new SelectList(new[] {store}, nameof(StoreData.Id), nameof(StoreData.StoreName), store.Id))"></select>
}
<span asp-validation-for="SpecificStores[index]" class="text-danger"></span>
</div>
</div>
<div class="col-sm-12 col-md-12 col-lg-2 pull-right">
<button type="submit" title="Remove Store Permission" name="command" value="@($"remove-store:{index}")"
class="d-block d-lg-none d-xl-none btn btn-danger mb-2 ml-2">
Remove
</button>
<button type="submit" title="Remove Store Permission" name="command" value="@($"remove-store:{index}")"
class="d-none d-lg-block remove-btn text-decoration-none h-100 align-middle btn text-danger btn-link fa fa-times rounded-0 pull-right">
</button>
</div>
</div>
</div>
}
@if (Model.SpecificStores.Count < Model.Stores.Length)
{
<div class="list-group-item">
<button type="submit" name="command" value="add-store" class="ml-1 btn btn-secondary">Add another store </button>
</div>
}
</div>
}
<button type="submit" class="btn btn-primary" id="Generate">Generate API Key</button>
</form>
</div>
</div>
@section Scripts {
@await Html.PartialAsync("_ValidationScriptsPartial")
<style>
.remove-btn{
font-size: 1.5rem;
border-radius: 0;
}
.remove-btn:hover{
background-color: #CCCCCC;
}
</style>
}

View File

@ -0,0 +1,150 @@
@using BTCPayServer.Controllers
@using BTCPayServer.Security.APIKeys
@model BTCPayServer.Controllers.ManageController.AuthorizeApiKeysViewModel
@{
Layout = "_Layout";
ViewData["Title"] = $"Authorize {(Model.ApplicationName ?? "Application")}";
string GetDescription(string permission)
{
return APIKeyConstants.Permissions.PermissionDescriptions[permission].Description;
}
string GetTitle(string permission)
{
return APIKeyConstants.Permissions.PermissionDescriptions[permission].Description;
}
}
<partial name="_StatusMessage"/>
<form method="post" asp-action="AuthorizeAPIKey">
<input type="hidden" asp-for="Permissions" value="@Model.Permissions"/>
<input type="hidden" asp-for="Strict" value="@Model.Strict"/>
<input type="hidden" asp-for="ApplicationName" value="@Model.ApplicationName"/>
<input type="hidden" asp-for="SelectiveStores" value="@Model.SelectiveStores"/>
<section>
<div class="card container">
<div class="row">
<div class="col-lg-12 section-heading">
<h2>Authorization Request</h2>
<hr class="primary">
<p class="mb-1">@(Model.ApplicationName ?? "An application") is requesting access to your account.</p>
</div>
</div>
<div class="row">
<div class="col-lg-12 list-group px-2">
<div asp-validation-summary="All" class="text-danger"></div>
<div class="list-group-item ">
<div class="form-group">
<label asp-for="Label"></label>
<input asp-for="Label" class="form-control"/>
<span asp-validation-for="Label" class="text-danger"></span>
</div>
</div>
@if (!Model.PermissionsFormatted.Any())
{
<div class="list-group-item form-group">
<p >There are no associated permissions to the API key being requested here. The application cannot do anything with your BTCPay account other than validating your account exists.</p>
</div>
}
@if (Model.PermissionsFormatted.Contains(APIKeyConstants.Permissions.ServerManagement) && (Model.IsServerAdmin || Model.Strict))
{
<div class="list-group-item form-group">
<input asp-for="ServerManagementPermission" class="form-check-inline" readonly="@(Model.Strict || !Model.IsServerAdmin)"/>
<label asp-for="ServerManagementPermission" class="h5">@GetTitle(APIKeyConstants.Permissions.ServerManagement)</label>
@if (!Model.IsServerAdmin)
{
<span class="text-danger">
The server management permission is being requested but your account is not an administrator
</span>
}
<span asp-validation-for="ServerManagementPermission" class="text-danger"></span>
<p>@GetDescription(APIKeyConstants.Permissions.ServerManagement).</p>
</div>
}
@if (Model.PermissionsFormatted.Contains(APIKeyConstants.Permissions.StoreManagement))
{
@if (Model.StoreMode == ManageController.AddApiKeyViewModel.ApiKeyStoreMode.AllStores)
{
<div class="list-group-item form-group">
<input type="checkbox" asp-for="StoreManagementPermission" class="form-check-inline" readonly="@Model.Strict"/>
<label asp-for="StoreManagementPermission" class="h5">@GetTitle(APIKeyConstants.Permissions.StoreManagement)</label>
<span asp-validation-for="StoreManagementPermission" class="text-danger"></span>
<p class="mb-0">@GetDescription(APIKeyConstants.Permissions.StoreManagement).</p>
@if (Model.SelectiveStores)
{
<button type="submit" class="btn btn-link" name="command" value="change-store-mode">Give permission to specific stores instead</button>
}
</div>
}
else if (Model.StoreMode == ManageController.AddApiKeyViewModel.ApiKeyStoreMode.Specific)
{
<div class="list-group-item p-0 border-0 mb-2">
<li class="list-group-item">
<h5 class="mb-1">@GetTitle(APIKeyConstants.Permissions.StoreManagement + ":")</h5>
<p class="mb-0">@GetDescription(APIKeyConstants.Permissions.StoreManagement + ":").</p>
<button type="submit" class="btn btn-link" name="command" value="change-store-mode">Give permission to all stores instead</button>
</li>
@if (!Model.Stores.Any())
{
<li class="list-group-item alert-warning">
You currently have no stores configured.
</li>
}
@for (var index = 0; index < Model.SpecificStores.Count; index++)
{
<div class="list-group-item transaction-output-form p-0 pl-lg-2">
<div class="row">
<div class="col-sm-12 col-md-12 col-lg-10 py-2 ">
<div class="form-group my-0">
@if (Model.SpecificStores[index] == null)
{
<select asp-for="SpecificStores[index]" class="form-control" asp-items="@(new SelectList(Model.Stores.Where(data => !Model.SpecificStores.Contains(data.Id)), nameof(StoreData.Id), nameof(StoreData.StoreName)))"></select>
}
else
{
var store = Model.Stores.SingleOrDefault(data => data.Id == Model.SpecificStores[index]);
<select asp-for="SpecificStores[index]" class="form-control" asp-items="@(new SelectList(new[] {store}, nameof(StoreData.Id), nameof(StoreData.StoreName), store.Id))"></select>
}
<span asp-validation-for="SpecificStores[index]" class="text-danger"></span>
</div>
</div>
<div class="col-sm-12 col-md-12 col-lg-2 pull-right">
<button type="submit" title="Remove Store Permission" name="command" value="@($"remove-store:{index}")"
class="d-block d-lg-none d-xl-none btn btn-danger mb-2 ml-2">
Remove
</button>
<button type="submit" title="Remove Store Permission" name="command" value="@($"remove-store:{index}")"
class="d-none d-lg-block remove-btn text-decoration-none h-100 align-middle btn text-danger btn-link fa fa-times rounded-0 pull-right">
</button>
</div>
</div>
</div>
}
@if (Model.SpecificStores.Count < Model.Stores.Length)
{
<div class="list-group-item">
<button type="submit" name="command" value="add-store" class="ml-1 btn btn-secondary">Add another store </button>
</div>
}
</div>
}
}
</div>
</div>
<div class="row my-2">
<div class="col-lg-12 text-center">
<div class="btn-group">
<button class="btn btn-primary" name="command" id="consent-yes" type="submit" value="Yes">Authorize app</button>
</div>
<button class="btn btn-secondary" id="consent-no" name="command" type="submit" value="No">Cancel</button>
</div>
</div>
</div>
</section>
</form>

View File

@ -56,7 +56,8 @@
text: @Safe.Json(Model.AuthenticatorUri),
width: 200,
height: 200,
useSVG: true
useSVG: true,
correctLevel : QRCode.CorrectLevel.M
});
$("#qrCode > img").css({ "margin": "auto" });
</script>

View File

@ -7,6 +7,6 @@ namespace BTCPayServer.Views.Manage
{
public enum ManageNavPages
{
Index, ChangePassword, TwoFactorAuthentication, U2F
Index, ChangePassword, TwoFactorAuthentication, U2F, APIKeys
}
}

View File

@ -1,9 +1,10 @@
@inject SignInManager<ApplicationUser> SignInManager
<div class="nav flex-column nav-pills">
<a class="nav-link @ViewData.IsActivePage(ManageNavPages.Index)" asp-action="Index">Profile</a>
<a class="nav-link @ViewData.IsActivePage(ManageNavPages.ChangePassword)" asp-action="ChangePassword" id="ChangePassword">Password</a>
<a class="nav-link @ViewData.IsActivePage(ManageNavPages.TwoFactorAuthentication)" asp-action="TwoFactorAuthentication">Two-factor authentication</a>
<a class="nav-link @ViewData.IsActivePage(ManageNavPages.U2F)" asp-action="U2FAuthentication">U2F Authentication</a>
<a id="@ManageNavPages.Index.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.Index)" asp-action="Index">Profile</a>
<a id="@ManageNavPages.ChangePassword.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.ChangePassword)" asp-action="ChangePassword">Password</a>
<a id="@ManageNavPages.TwoFactorAuthentication.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.TwoFactorAuthentication)" asp-action="TwoFactorAuthentication">Two-factor authentication</a>
<a id="@ManageNavPages.U2F.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.U2F)" asp-action="U2FAuthentication">U2F Authentication</a>
<a id="@ManageNavPages.APIKeys.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.APIKeys)" asp-action="APIKeys">API Keys</a>
</div>

View File

@ -158,7 +158,8 @@
text: @Safe.Json(Model.QRCode),
width: 200,
height: 200,
useSVG: true
useSVG: true,
correctLevel : QRCode.CorrectLevel.M
});
</script>
}

View File

@ -73,7 +73,8 @@
text: @Safe.Json(Model.ServiceLink),
width: 200,
height: 200,
useSVG: true
useSVG: true,
correctLevel : QRCode.CorrectLevel.M
});
</script>
}

View File

@ -187,7 +187,8 @@
text: @Safe.Json(Model.QRCode),
width: 200,
height: 200,
useSVG: true
useSVG: true,
correctLevel : QRCode.CorrectLevel.M
});
</script>
}

View File

@ -101,7 +101,8 @@
text: @Safe.Json(Model.ServiceLink),
width: 200,
height: 200,
useSVG: true
useSVG: true,
correctLevel : QRCode.CorrectLevel.M
});
</script>
}

View File

@ -98,7 +98,8 @@
text: @Safe.Json(Model.ServiceLink),
width: 200,
height: 200,
useSVG: true
useSVG: true,
correctLevel : QRCode.CorrectLevel.M
});
</script>
}

View File

@ -27,7 +27,7 @@
</div>
</div>
<div class="payment__details__instruction__open-wallet" v-if="srvModel.invoiceBitcoinUrl">
<a class="payment__details__instruction__open-wallet__btn action-button" v-bind:href="srvModel.invoiceBitcoinUrl">
<a class="payment__details__instruction__open-wallet__btn action-button" target="_top" v-bind:href="srvModel.invoiceBitcoinUrl">
<span>{{$t("Open in wallet")}}</span>
</a>
</div>

View File

@ -26,7 +26,7 @@
{
<div class="row">
<div class="col-lg-12 text-center">
<form method="post">
<form method="post" action="@Model.ActionUrl">
<button id="continue" type="submit" class="btn @Model.ButtonClass w-25">@Model.Action</button>
<button type="submit" class="btn btn-secondary w-25" onclick="history.back(); return false;">Go back</button>
</form>

View File

@ -47,6 +47,11 @@
<br />
<div class="row">
<div class="col-lg-7">
<div class="form-group">
<label>
<input type="checkbox" v-model="srvModel.useModal" v-on:change="inputChanges" class="form-check-inline"/>Use Modal
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" v-model="buttonInlineTextMode" v-on:change="inputChanges" class="form-check-inline"/> Customize text in button

View File

@ -38,11 +38,6 @@
<input asp-for="AmountMarkupPercentage" class="form-control"/>
<span asp-validation-for="AmountMarkupPercentage" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="ShowFiat"></label>
<input asp-for="ShowFiat" class="form-check"/>
<span asp-validation-for="ShowFiat" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Enabled"></label>
<input asp-for="Enabled" type="checkbox" class="form-check"/>

View File

@ -14,21 +14,39 @@
</div>
}
<div class="row no-gutters">
<div class="col-lg-6 mx-auto my-auto ">
<div class="col-lg-7 mx-auto my-auto ">
<form method="post" asp-action="WalletReceive" class="card text-center">
@if (string.IsNullOrEmpty(Model.Address))
{
<div class="card-body">
@if (string.IsNullOrEmpty(Model.Address))
{
<h2 class="card-title">Receive @Model.CryptoCode</h2>
<button class="btn btn-lg btn-primary m-2" type="submit" name="command" value="generate-new-address">Generate @Model.CryptoCode address</button>
}
else
{
<h2 class="card-title">Next available @Model.CryptoCode address</h2>
<noscript>
<div class="card-body m-sm-0 p-sm-0">
<div class="input-group">
<input type="text" class="form-control " readonly="readonly" asp-for="Address" id="address" />
<h3 class="card-title mb-3">Receive @Model.CryptoCode</h3>
<button class="btn btn-lg btn-primary" type="submit" name="command" value="generate-new-address">Generate @Model.CryptoCode address</button>
}
else
{
<h3 class="card-title mb-4">Next available @Model.CryptoCode address</h3>
<noscript>
<div class="card-body m-sm-0 p-sm-0">
<div class="input-group">
<input type="text" class="form-control " readonly="readonly" asp-for="Address" id="address" />
<div class="input-group-append">
<span class="input-group-text fa fa-copy"> </span>
</div>
</div>
<button type="submit" name="command" value="unreserve-current-address" class="btn btn-link">Unreserve this address</button>
</div>
</noscript>
<div class="only-for-js card-body m-sm-0 p-sm-0" id="app">
<div class="qr-container mb-4">
<img v-bind:src="srvModel.cryptoImage" class="qr-icon" />
<qrcode v-bind:value="srvModel.address" :options="{ width: 256, margin: 0, color: {dark:'#000', light:'#fff'} }" tag="svg">
</qrcode>
</div>
<div class="input-group copy" data-clipboard-target="#vue-address">
<input type="text" class=" form-control " readonly="readonly" :value="srvModel.address" id="vue-address" />
<div class="input-group-append">
<span class="input-group-text fa fa-copy"> </span>
</div>
@ -37,25 +55,8 @@
<button type="submit" name="command" value="unreserve-current-address" class="btn btn-link">Unreserve this address</button>
</div>
</noscript>
<div class="only-for-js card-body m-sm-0 p-sm-0" id="app">
<div class="qr-container mb-2">
<img v-bind:src="srvModel.cryptoImage" class="qr-icon" />
<qrcode v-bind:value="srvModel.address" :options="{ width: 256, margin: 0, color: {dark:'#000', light:'#fff'} }" tag="svg">
</qrcode>
</div>
<div class="input-group copy" data-clipboard-target="#vue-address">
<input type="text" class=" form-control " readonly="readonly" :value="srvModel.address" id="vue-address" />
<div class="input-group-append">
<span class="input-group-text fa fa-copy"> </span>
</div>
</div>
<button type="submit" name="command" value="unreserve-current-address" class="btn btn-link">Unreserve this address</button>
</div>
}
}
</div>
</form>
</div>
</div>

View File

@ -109,11 +109,8 @@ addLoadEvent(function (ev) {
return this.srvModel.targetCurrency.toUpperCase();
},
paymentStats: function(){
var result= [];
var result= [];
var combinedStats = {};
var keys = Object.keys(this.srvModel.info.paymentStats);
for (var i = 0; i < keys.length; i++) {
@ -135,20 +132,24 @@ addLoadEvent(function (ev) {
}
keys = Object.keys(combinedStats);
for (var i = 0; i < keys.length; i++) {
var newItem = {key:keys[i], value: combinedStats[keys[i]], label: keys[i].replace("_","")};
result.push(newItem);
}
for (var i = 0; i < result.length; i++) {
var current = result[i];
if(current.label.endsWith("LightningLike")){
current.label = current.label.substr(0,current.label.indexOf("LightningLike"));
current.lightning = true;
if(!combinedStats[keys[i]]){
continue;
}
var paymentMethodId = keys[i].split("_");
var value = combinedStats[keys[i]].toFixed(this.srvModel.currencyDataPayments[paymentMethodId[0]].divisibility);
var newItem = {key:keys[i], value: value, label: paymentMethodId[0]};
if(paymentMethodId.length > 1 && paymentMethodId[1].endsWith("LightningLike")){
newItem.lightning = true;
}
result.push(newItem);
}
if(result.length === 1 && result[0].label === srvModel.targetCurrency){
return [];
}
return result;
},
perks: function(){
@ -255,7 +256,7 @@ addLoadEvent(function (ev) {
} );
});
eventAggregator.$on("payment-received", function (amount, cryptoCode, type) {
var onChain = type.toLowerCase() === "btclike";
var onChain = type.toLowerCase() !== "lightninglike";
if(self.sound) {
playRandomSound();
}

View File

@ -41,6 +41,34 @@ function getStyles (styles) {
return document.getElementById(styles).innerHTML.trim().replace(/\s{2}/g, '') + '\n'
}
function getScripts(srvModel) {
return ""+
"<script>" +
"if(!window.btcpay){ " +
" var head = document.getElementsByTagName('head')[0];" +
" var script = document.createElement('script');" +
" script.src='"+esc(srvModel.urlRoot)+"modal/btcpay.js';" +
" script.type = 'text/javascript';" +
" head.append(script);" +
"}" +
"function onBTCPayFormSubmit(event){" +
" var xhttp = new XMLHttpRequest();" +
" xhttp.onreadystatechange = function() {" +
" if (this.readyState == 4 && this.status == 200) {" +
" if(this.status == 200 && this.responseText){" +
" var response = JSON.parse(this.responseText);" +
" window.btcpay.showInvoice(response.invoiceId);" +
" }" +
" }" +
" };" +
" xhttp.open(\"POST\", event.target.getAttribute('action'), true);" +
" xhttp.send(new FormData( event.target ));" +
"}" +
"</script>";
}
function inputChanges(event, buttonSize) {
if (buttonSize !== null && buttonSize !== undefined) {
srvModel.buttonSize = buttonSize;
@ -64,14 +92,16 @@ function inputChanges(event, buttonSize) {
width = "209px";
height = "57px";
}
var html =
//Scripts
(srvModel.useModal? getScripts(srvModel) :"") +
// Styles
getStyles('template-paybutton-styles') + (isSlider ? getStyles('template-slider-styles') : '') +
// Form
'<form method="POST" action="' + esc(srvModel.urlRoot) + 'api/v1/invoices" class="btcpay-form btcpay-form--' + (srvModel.fitButtonInline ? 'inline' : 'block') +'">\n' +
'<form method="POST" '+ ( srvModel.useModal? ' onsubmit="onBTCPayFormSubmit(event);return false" ' : '' )+' action="' + esc(srvModel.urlRoot) + 'api/v1/invoices" class="btcpay-form btcpay-form--' + (srvModel.fitButtonInline ? 'inline' : 'block') +'">\n' +
addInput("storeId", srvModel.storeId);
if (srvModel.useModal) html += addInput("jsonResponse", true);
if (srvModel.checkoutDesc) html += addInput("checkoutDesc", srvModel.checkoutDesc);
if (srvModel.orderId) html += addInput("orderId", srvModel.orderId);

View File

@ -1,5 +1,5 @@
<Project>
<PropertyGroup>
<Version>1.0.3.160</Version>
<Version>1.0.3.162</Version>
</PropertyGroup>
</Project>

View File

@ -1,51 +0,0 @@
# Developing and extending themes
The BTCPay Server user interface is built on a customized version of Bootstrap that supports [CSS custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/--*).
This allows us to change theme related settings like fonts and colors without affecting the [`bootstrap.css`](#Notes-on-bootstrapcss).
Also we can provide just the relevant customized parts instead of shipping a whole `bootstrap.css` file for each theme.
Take a look at the [predefined themes](../BTCPayServer/wwwroot/main/themes) to get an overview of this approach.
## Modifying existing themes
The custom property definitions in the `:root` selector are divided into several sections, that can be seen as a cascade:
- The first section contains general definitions (i.e. for custom brand and neutral colors).
- The second section defines variables for specific purposes.
Here you can map the general definitions or create additional ones.
- The third section contains definitions for specific parts of the page, sections or components.
Here you should try to reuse definitions from above as much as possible to provide a consistent look and feel.
The variables defined in a theme file get used in the [`site.css`](../BTCPayServer/wwwroot/main/site.css) and [`creative.css`](../BTCPayServer/wwwroot/main/bootstrap4-creativestart/creative.css) files.
### Overriding Bootstrap selectors
In addition to the variables you can also provide styles by directly adding CSS selectors to this file.
This can be seen as a last resort in case there is no variable for something you want to change or some minor tweaking.
### Adding theme variables
In general it is a good idea to introduce specific variables for special purposes (like setting the link colors of a specific section).
This allows us to address individual portions of the styles without affecting other parts which might be tight to a general variable.
For cases in which you want to introduce new variables that are used across all themes, add them to the `site.css` file.
This file contains our modifications of the Bootstrap styles.
Refrain from modifying `bootstrap.css` directly  see the [additional notes](#Notes-on-bootstrapcss) for the reasoning behind this.
## Adding a new theme
You should copy one of our predefined themes and change the variables to fit your needs.
To test and play around with the adjustments, you can also use the developer tools of the browser:
Inspect the `<html>` element and modify the variables in the `:root` section of the styles inspector.
## Notes on bootstrap.css
The `bootstrap.css` file itself is generated based on what the original vendor `bootstrap.css` provides.
Right now [Bootstrap](https://getbootstrap.com/docs/4.3/getting-started/theming/) does not use custom properties, but in the future it is likely that they might switch to this approach as well.
Until then we created a build script [in this repo](https://github.com/dennisreimann/btcpayserver-ui-prototype) which generates the `bootstrap.css` file we are using here.
The general approach should be to not modify the `bootstrap.css`, so that we can keep it easily updatable.
The initial modifications of this file were made in order to allow for this themeing approach.
Because bootstrap has colors spread all over the place we'd otherwise have to override mostly everything, that's why these general modifications are in the main `bootstrap.css` file.

View File

@ -81,16 +81,17 @@ Contributors looking to do something a bit more challenging, before opening a pu
- [Setting up development environment on Windows](https://www.youtube.com/watch?v=ZePbMPSIvHM)
- [Setting up development environment Linux (Ubuntu)](https://www.youtube.com/watch?v=j486T_Rk-yw&t)
- [Setting up development environment MacOS](https://www.youtube.com/watch?v=GWR_CcMsEV0)
You also have an awesome video of our contributors which explains how to get started.[![Rockstar Dev and Britt Kelly - Btc Pay Server Code Along](https://img.youtube.com/vi/ZePbMPSIvHM/sddefault.jpg)](https://www.youtube.com/embed/VNMnd-dX9Q8)
Here is some info about [how to extend the themes](Docs/Themes.md)
Here is some info about [how to extend the themes](https://github.com/btcpayserver/btcpayserver-doc/blob/master/Theme.md)
## How to build
While the documentation advises to use docker-compose, you may want to build BTCPay yourself.
While the documentation advises to use docker-compose, you may want to build BTCPay Server yourself.
First install .NET Core SDK v2.1.9 as specified by [Microsoft website](https://www.microsoft.com/net/download/dotnet-core/2.1).
First install .NET Core SDK v3.1 as specified by [Microsoft website](https://dotnet.microsoft.com/download/dotnet-core/3.1).
On Powershell:
```
@ -118,7 +119,7 @@ On linux:
## How to debug
If you want to debug, use Visual Studio Code or Visual Studio 2017.
If you want to debug, use Visual Studio Code or Visual Studio 2019.
You need to run the development time docker-compose as described [in the test guide](BTCPayServer.Tests/README.md).