summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorivarlovlie <git@ivarlovlie.no>2022-01-23 11:41:42 +0100
committerivarlovlie <git@ivarlovlie.no>2022-01-23 14:33:05 +0100
commitce86d103039b22695b04714ee85e9ef3e1e032b5 (patch)
tree557455780de06ceb95dd556ca5ffca0208a1f8ba
parent89816382424e59ad953b433fbf82c925741b3136 (diff)
downloadbookmark-thing-ce86d103039b22695b04714ee85e9ef3e1e032b5.tar.xz
bookmark-thing-ce86d103039b22695b04714ee85e9ef3e1e032b5.zip
feat(auth): Implements first draft of basic auth gen/validation
-rw-r--r--src/server/Api/Internal/Account/CreateTokenRequest.cs6
-rw-r--r--src/server/Api/Internal/Account/CreateTokenRoute.cs28
-rw-r--r--src/server/Api/V1/BaseV1Route.cs21
-rw-r--r--src/server/Api/V1/Entries/CreateEntryRoute.cs4
-rw-r--r--src/server/Api/V1/Entries/DeleteEntryRoute.cs4
-rw-r--r--src/server/Api/V1/Entries/GetEntriesRoute.cs4
-rw-r--r--src/server/Api/V1/Entries/UpdateEntryRoute.cs5
-rw-r--r--src/server/Migrations/20220123102257_AccessTokens.Designer.cs141
-rw-r--r--src/server/Migrations/20220123102257_AccessTokens.cs79
-rw-r--r--src/server/Migrations/AppDbContextModelSnapshot.cs64
-rw-r--r--src/server/Models/Database/AccessToken.cs10
-rw-r--r--src/server/Startup.cs4
-rw-r--r--src/server/StaticData/AppJsonSettings.cs2
-rw-r--r--src/server/StaticData/Constants.cs4
-rw-r--r--src/server/Utilities/BasicAuthenticationHandler.cs58
-rw-r--r--src/server/Utilities/SnakeCaseNamingPolicy.cs15
-rw-r--r--src/webapp/src/components/carbon-extras/NativeDateInput.svelte21
-rw-r--r--src/webapp/src/lib/api/account.ts26
-rw-r--r--src/webapp/src/lib/configuration.ts6
-rw-r--r--src/webapp/src/lib/models/IAccessToken.ts9
-rw-r--r--src/webapp/src/lib/models/ICreateTokenRequest.ts7
-rw-r--r--src/webapp/src/routes/app/_header.svelte11
-rw-r--r--src/webapp/src/routes/app/modals/access-tokens-modal.svelte148
-rw-r--r--src/webapp/src/routes/app/modals/new-access-token-modal.svelte131
24 files changed, 769 insertions, 39 deletions
diff --git a/src/server/Api/Internal/Account/CreateTokenRequest.cs b/src/server/Api/Internal/Account/CreateTokenRequest.cs
index 399bdfc..6839092 100644
--- a/src/server/Api/Internal/Account/CreateTokenRequest.cs
+++ b/src/server/Api/Internal/Account/CreateTokenRequest.cs
@@ -2,5 +2,9 @@ namespace IOL.BookmarkThing.Server.Api.Internal.Account;
public class CreateTokenRequest
{
- public string Name { get; set; }
+ public bool AllowRead { get; set; }
+ public bool AllowCreate { get; set; }
+ public bool AllowUpdate { get; set; }
+ public bool AllowDelete { get; set; }
+ public DateTime ExpiryDate { get; set; }
}
diff --git a/src/server/Api/Internal/Account/CreateTokenRoute.cs b/src/server/Api/Internal/Account/CreateTokenRoute.cs
index ea0e01f..0b30cc0 100644
--- a/src/server/Api/Internal/Account/CreateTokenRoute.cs
+++ b/src/server/Api/Internal/Account/CreateTokenRoute.cs
@@ -1,11 +1,17 @@
+using System.Text;
+
namespace IOL.BookmarkThing.Server.Api.Internal.Account;
public class CreateTokenRoute : RouteBaseInternalSync.WithRequest<CreateTokenRequest>.WithActionResult
{
private readonly AppDbContext _context;
+ private readonly IConfiguration _configuration;
+ private readonly ILogger<CreateTokenRoute> _logger;
- public CreateTokenRoute(AppDbContext context) {
+ public CreateTokenRoute(AppDbContext context, IConfiguration configuration, ILogger<CreateTokenRoute> logger) {
_context = context;
+ _configuration = configuration;
+ _logger = logger;
}
[ApiVersionNeutral]
@@ -17,18 +23,24 @@ public class CreateTokenRoute : RouteBaseInternalSync.WithRequest<CreateTokenReq
return NotFound(new ErrorResult("User does not exist"));
}
- if (request.Name.IsNullOrWhiteSpace()) {
- return BadRequest(new ErrorResult("Token name is required"));
+ var token_entropy = _configuration.GetValue<string>("TOKEN_ENTROPY");
+ if (token_entropy.IsNullOrWhiteSpace()) {
+ _logger.LogWarning("No token entropy is available in env:TOKEN_ENTROPY, Basic auth is disabled");
+ return NotFound();
}
- var token = new AccessToken {
+ var access_token = new AccessToken {
Id = Guid.NewGuid(),
- Name = request.Name,
- User = user
+ User = user,
+ ExpiryDate = request.ExpiryDate.ToUniversalTime(),
+ AllowCreate = request.AllowCreate,
+ AllowRead = request.AllowRead,
+ AllowDelete = request.AllowDelete,
+ AllowUpdate = request.AllowUpdate
};
- _context.AccessTokens.Add(token);
+ _context.AccessTokens.Add(access_token);
_context.SaveChanges();
- return Ok(token);
+ return Ok(Convert.ToBase64String(Encoding.UTF8.GetBytes(access_token.Id.ToString().EncryptWithAes(token_entropy))));
}
}
diff --git a/src/server/Api/V1/BaseV1Route.cs b/src/server/Api/V1/BaseV1Route.cs
index ba7d978..b1e2128 100644
--- a/src/server/Api/V1/BaseV1Route.cs
+++ b/src/server/Api/V1/BaseV1Route.cs
@@ -1,3 +1,5 @@
+using System.Net.Http.Headers;
+
namespace IOL.BookmarkThing.Server.Api.V1;
/// <inheritdoc />
@@ -14,4 +16,23 @@ public class BaseV1Route : ControllerBase
Username = User.Identity?.Name,
Id = User.Claims.SingleOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value.ToGuid() ?? default
};
+
+ protected bool IsApiCall() {
+ if (!Request.Headers.ContainsKey("Authorization")) return false;
+ try {
+ var authHeader = AuthenticationHeaderValue.Parse(Request.Headers["Authorization"]);
+ if (authHeader.Parameter == null) return false;
+ } catch (Exception e) {
+ return false;
+ }
+
+ return true;
+ }
+
+ protected bool HasApiPermission(string permission_key) {
+ var permission_claim = User.Claims.SingleOrDefault(c => c.Type == permission_key);
+ return permission_claim is {
+ Value: "True"
+ };
+ }
}
diff --git a/src/server/Api/V1/Entries/CreateEntryRoute.cs b/src/server/Api/V1/Entries/CreateEntryRoute.cs
index ebe49fc..b502e4a 100644
--- a/src/server/Api/V1/Entries/CreateEntryRoute.cs
+++ b/src/server/Api/V1/Entries/CreateEntryRoute.cs
@@ -21,6 +21,10 @@ public class CreateEntryRoute : RouteBaseV1Sync.WithRequest<CreateEntryRequest>.
[ApiVersion(ApiSpecV1.VERSION_STRING)]
[HttpPost("~/v{version:apiVersion}/entries/create")]
public override ActionResult<EntryDto> Handle(CreateEntryRequest entry) {
+ if (IsApiCall() && !HasApiPermission(Constants.TOKEN_ALLOW_CREATE)) {
+ return StatusCode(403, "Your token does not permit access to this resource");
+ }
+
var errors = entry.GetErrors();
if (errors.Count != 0) {
return BadRequest(errors);
diff --git a/src/server/Api/V1/Entries/DeleteEntryRoute.cs b/src/server/Api/V1/Entries/DeleteEntryRoute.cs
index fc79049..c979c1f 100644
--- a/src/server/Api/V1/Entries/DeleteEntryRoute.cs
+++ b/src/server/Api/V1/Entries/DeleteEntryRoute.cs
@@ -18,6 +18,10 @@ public class DeleteEntryRoute : RouteBaseV1Sync.WithRequest<Guid>.WithActionResu
[ApiVersion(ApiSpecV1.VERSION_STRING)]
[HttpDelete("~/v{version:apiVersion}/entries/{entryId:guid}")]
public override ActionResult Handle(Guid entryId) {
+ if (IsApiCall() && !HasApiPermission(Constants.TOKEN_ALLOW_DELETE)) {
+ return StatusCode(403, "Your token does not permit access to this resource");
+ }
+
var entry = _context.Entries.SingleOrDefault(c => c.Id == entryId && c.UserId == LoggedInUser.Id);
if (entry == default) {
return NotFound(new ErrorResult("Entry does not exist"));
diff --git a/src/server/Api/V1/Entries/GetEntriesRoute.cs b/src/server/Api/V1/Entries/GetEntriesRoute.cs
index adadf01..27905a2 100644
--- a/src/server/Api/V1/Entries/GetEntriesRoute.cs
+++ b/src/server/Api/V1/Entries/GetEntriesRoute.cs
@@ -16,6 +16,10 @@ public class GetEntriesRoute : RouteBaseV1Sync.WithoutRequest.WithActionResult<L
[ApiVersion(ApiSpecV1.VERSION_STRING)]
[HttpGet("~/v{version:apiVersion}/entries")]
public override ActionResult<List<EntryDto>> Handle() {
+ if (IsApiCall() && !HasApiPermission(Constants.TOKEN_ALLOW_READ)) {
+ return StatusCode(403, "Your token does not permit access to this resource");
+ }
+
return Ok(_context.Entries.Where(c => c.UserId == LoggedInUser.Id).Select(c => new EntryDto(c)));
}
}
diff --git a/src/server/Api/V1/Entries/UpdateEntryRoute.cs b/src/server/Api/V1/Entries/UpdateEntryRoute.cs
index 96c60fe..919364d 100644
--- a/src/server/Api/V1/Entries/UpdateEntryRoute.cs
+++ b/src/server/Api/V1/Entries/UpdateEntryRoute.cs
@@ -1,4 +1,3 @@
-using System.Security.Cryptography;
using IOL.BookmarkThing.Server.Api.V1.Entries.Dtos;
namespace IOL.BookmarkThing.Server.Api.V1.Entries;
@@ -24,6 +23,10 @@ public class UpdateEntryRoute : RouteBaseV1Sync.WithRequest<UpdateEntryRequest>.
[ApiVersion(ApiSpecV1.VERSION_STRING)]
[HttpPost("~/v{version:apiVersion}/entries/update")]
public override ActionResult<EntryDto> Handle(UpdateEntryRequest entryToUpdate) {
+ if (IsApiCall() && !HasApiPermission(Constants.TOKEN_ALLOW_UPDATE)) {
+ return StatusCode(403, "Your token does not permit access to this resource");
+ }
+
var entry = _context.Entries.SingleOrDefault(c => c.Id == entryToUpdate.Id && c.UserId == LoggedInUser.Id);
if (entry == default) {
return NotFound(new ErrorResult("Entry does not exist"));
diff --git a/src/server/Migrations/20220123102257_AccessTokens.Designer.cs b/src/server/Migrations/20220123102257_AccessTokens.Designer.cs
new file mode 100644
index 0000000..0b8d3c0
--- /dev/null
+++ b/src/server/Migrations/20220123102257_AccessTokens.Designer.cs
@@ -0,0 +1,141 @@
+// <auto-generated />
+using System;
+using IOL.BookmarkThing.Server.Models.Database;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace IOL.BookmarkThing.Server.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20220123102257_AccessTokens")]
+ partial class AccessTokens
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.1")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.BookmarkThing.Server.Models.Database.AccessToken", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<bool>("AllowCreate")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_create");
+
+ b.Property<bool>("AllowDelete")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_delete");
+
+ b.Property<bool>("AllowRead")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_read");
+
+ b.Property<bool>("AllowUpdate")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_update");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<DateTime>("ExpiryDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expiry_date");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_access_tokens");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_access_tokens_user_id");
+
+ b.ToTable("access_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.BookmarkThing.Server.Models.Database.Entry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<string>("Tags")
+ .HasColumnType("text")
+ .HasColumnName("tags");
+
+ b.Property<string>("Url")
+ .HasColumnType("text")
+ .HasColumnName("url");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_entries");
+
+ b.ToTable("entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.BookmarkThing.Server.Models.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.BookmarkThing.Server.Models.Database.AccessToken", b =>
+ {
+ b.HasOne("IOL.BookmarkThing.Server.Models.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_access_tokens_users_user_id");
+
+ b.Navigation("User");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/server/Migrations/20220123102257_AccessTokens.cs b/src/server/Migrations/20220123102257_AccessTokens.cs
new file mode 100644
index 0000000..a29f944
--- /dev/null
+++ b/src/server/Migrations/20220123102257_AccessTokens.cs
@@ -0,0 +1,79 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.BookmarkThing.Server.Migrations
+{
+ public partial class AccessTokens : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "users",
+ type: "timestamp with time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp without time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "entries",
+ type: "timestamp with time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp without time zone");
+
+ migrationBuilder.CreateTable(
+ name: "access_tokens",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ user_id = table.Column<Guid>(type: "uuid", nullable: true),
+ expiry_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
+ allow_read = table.Column<bool>(type: "boolean", nullable: false),
+ allow_create = table.Column<bool>(type: "boolean", nullable: false),
+ allow_update = table.Column<bool>(type: "boolean", nullable: false),
+ allow_delete = table.Column<bool>(type: "boolean", nullable: false),
+ created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_access_tokens", x => x.id);
+ table.ForeignKey(
+ name: "fk_access_tokens_users_user_id",
+ column: x => x.user_id,
+ principalTable: "users",
+ principalColumn: "id");
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_access_tokens_user_id",
+ table: "access_tokens",
+ column: "user_id");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "access_tokens");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "users",
+ type: "timestamp without time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp with time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "entries",
+ type: "timestamp without time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp with time zone");
+ }
+ }
+}
diff --git a/src/server/Migrations/AppDbContextModelSnapshot.cs b/src/server/Migrations/AppDbContextModelSnapshot.cs
index 5476b90..c48f478 100644
--- a/src/server/Migrations/AppDbContextModelSnapshot.cs
+++ b/src/server/Migrations/AppDbContextModelSnapshot.cs
@@ -17,12 +17,56 @@ namespace IOL.BookmarkThing.Server.Migrations
{
#pragma warning disable 612, 618
modelBuilder
- .HasAnnotation("ProductVersion", "5.0.7")
+ .HasAnnotation("ProductVersion", "6.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
- modelBuilder.Entity("IOL.BookmarkThing.Server.Data.Database.Entry", b =>
+ modelBuilder.Entity("IOL.BookmarkThing.Server.Models.Database.AccessToken", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<bool>("AllowCreate")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_create");
+
+ b.Property<bool>("AllowDelete")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_delete");
+
+ b.Property<bool>("AllowRead")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_read");
+
+ b.Property<bool>("AllowUpdate")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_update");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<DateTime>("ExpiryDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expiry_date");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_access_tokens");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_access_tokens_user_id");
+
+ b.ToTable("access_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.BookmarkThing.Server.Models.Database.Entry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -30,7 +74,7 @@ namespace IOL.BookmarkThing.Server.Migrations
.HasColumnName("id");
b.Property<DateTime>("Created")
- .HasColumnType("timestamp without time zone")
+ .HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Description")
@@ -55,7 +99,7 @@ namespace IOL.BookmarkThing.Server.Migrations
b.ToTable("entries", (string)null);
});
- modelBuilder.Entity("IOL.BookmarkThing.Server.Data.Database.User", b =>
+ modelBuilder.Entity("IOL.BookmarkThing.Server.Models.Database.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -63,7 +107,7 @@ namespace IOL.BookmarkThing.Server.Migrations
.HasColumnName("id");
b.Property<DateTime>("Created")
- .HasColumnType("timestamp without time zone")
+ .HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Password")
@@ -79,6 +123,16 @@ namespace IOL.BookmarkThing.Server.Migrations
b.ToTable("users", (string)null);
});
+
+ modelBuilder.Entity("IOL.BookmarkThing.Server.Models.Database.AccessToken", b =>
+ {
+ b.HasOne("IOL.BookmarkThing.Server.Models.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_access_tokens_users_user_id");
+
+ b.Navigation("User");
+ });
#pragma warning restore 612, 618
}
}
diff --git a/src/server/Models/Database/AccessToken.cs b/src/server/Models/Database/AccessToken.cs
index 51ada27..5094c5e 100644
--- a/src/server/Models/Database/AccessToken.cs
+++ b/src/server/Models/Database/AccessToken.cs
@@ -3,9 +3,11 @@ namespace IOL.BookmarkThing.Server.Models.Database;
public class AccessToken : Base
{
public User User { get; set; }
- public string Name { get; set; }
+ public DateTime ExpiryDate { get; set; }
+ public bool AllowRead { get; set; }
+ public bool AllowCreate { get; set; }
+ public bool AllowUpdate { get; set; }
+ public bool AllowDelete { get; set; }
- public string PublicId() {
- return Convert.ToBase64String(Id.ToByteArray());
- }
+ public bool HasExpired => ExpiryDate < DateTime.UtcNow;
}
diff --git a/src/server/Startup.cs b/src/server/Startup.cs
index 8fd6955..f96d23b 100644
--- a/src/server/Startup.cs
+++ b/src/server/Startup.cs
@@ -83,10 +83,10 @@ public class Startup
Name = "Authorization",
Type = SecuritySchemeType.ApiKey,
Scheme = "Basic",
- BearerFormat = "Custom",
+ BearerFormat = "Basic",
In = ParameterLocation.Header,
Description =
- "Enter your token in the text input below.\r\n\r\nExample: \"12345abcdef\"",
+ "Enter your token in the text input below.\r\n\r\nExample: \"Basic 12345abcdef\"",
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement {
diff --git a/src/server/StaticData/AppJsonSettings.cs b/src/server/StaticData/AppJsonSettings.cs
index 47c67a7..bed5ba9 100644
--- a/src/server/StaticData/AppJsonSettings.cs
+++ b/src/server/StaticData/AppJsonSettings.cs
@@ -6,6 +6,6 @@ public static class AppJsonSettings
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowReadingFromString;
- options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
+ options.JsonSerializerOptions.PropertyNamingPolicy = SnakeCaseNamingPolicy.Instance;
};
}
diff --git a/src/server/StaticData/Constants.cs b/src/server/StaticData/Constants.cs
index 818004f..e0a46b1 100644
--- a/src/server/StaticData/Constants.cs
+++ b/src/server/StaticData/Constants.cs
@@ -4,4 +4,8 @@ public static class Constants
{
public const string API_NAME = "Bookmark API";
public const string BASIC_AUTH_SCHEME = "BasicAuthenticationScheme";
+ public const string TOKEN_ALLOW_READ = "TOKEN_ALLOW_READ";
+ public const string TOKEN_ALLOW_CREATE = "TOKEN_ALLOW_CREATE";
+ public const string TOKEN_ALLOW_UPDATE = "TOKEN_ALLOW_UPDATE";
+ public const string TOKEN_ALLOW_DELETE = "TOKEN_ALLOW_DELETE";
}
diff --git a/src/server/Utilities/BasicAuthenticationHandler.cs b/src/server/Utilities/BasicAuthenticationHandler.cs
index 7961b82..c4124e8 100644
--- a/src/server/Utilities/BasicAuthenticationHandler.cs
+++ b/src/server/Utilities/BasicAuthenticationHandler.cs
@@ -8,10 +8,21 @@ namespace IOL.BookmarkThing.Server.Utilities;
public class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private readonly AppDbContext _context;
+ private readonly IConfiguration _configuration;
+ private readonly ILogger<BasicAuthenticationHandler> _logger;
- public BasicAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, AppDbContext context) :
+ public BasicAuthenticationHandler(
+ IOptionsMonitor<AuthenticationSchemeOptions> options,
+ ILoggerFactory logger,
+ UrlEncoder encoder,
+ ISystemClock clock,
+ AppDbContext context,
+ IConfiguration configuration
+ ) :
base(options, logger, encoder, clock) {
_context = context;
+ _configuration = configuration;
+ _logger = logger.CreateLogger<BasicAuthenticationHandler>();
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync() {
@@ -23,26 +34,45 @@ public class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSc
return Task.FromResult(AuthenticateResult.Fail("Missing Authorization Header"));
try {
+ var token_entropy = _configuration.GetValue<string>("TOKEN_ENTROPY");
+ if (token_entropy.IsNullOrWhiteSpace()) {
+ _logger.LogWarning("No token entropy is available in env:TOKEN_ENTROPY, Basic auth is disabled");
+ return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header"));
+ }
+
var authHeader = AuthenticationHeaderValue.Parse(Request.Headers["Authorization"]);
if (authHeader.Parameter == null) return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header"));
var credentialBytes = Convert.FromBase64String(authHeader.Parameter);
- var token_is_guid = Guid.TryParse(Encoding.UTF8.GetString(credentialBytes), out var token_id);
- if (token_is_guid) {
- var token = _context.AccessTokens.Include(c => c.User).SingleOrDefault(c => c.Id == token_id);
- if (token == default) {
- return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header"));
- }
+ var decrypted_string = Encoding.UTF8.GetString(credentialBytes).DecryptWithAes(token_entropy);
+ var token_is_guid = Guid.TryParse(decrypted_string, out var token_id);
- var claims = token.User.DefaultClaims();
- var identity = new ClaimsIdentity(claims, Scheme.Name);
- var principal = new ClaimsPrincipal(identity);
- var ticket = new AuthenticationTicket(principal, Scheme.Name);
+ if (!token_is_guid) {
+ return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header"));
+ }
- return Task.FromResult(AuthenticateResult.Success(ticket));
+ var token = _context.AccessTokens.Include(c => c.User).SingleOrDefault(c => c.Id == token_id);
+ if (token == default) {
+ return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header: Not Found"));
}
- return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header"));
- } catch {
+ if (token.HasExpired) {
+ return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header: Expired"));
+ }
+
+ var permissions = new List<Claim>() {
+ new(Constants.TOKEN_ALLOW_READ, token.AllowRead.ToString()),
+ new(Constants.TOKEN_ALLOW_UPDATE, token.AllowUpdate.ToString()),
+ new(Constants.TOKEN_ALLOW_CREATE, token.AllowCreate.ToString()),
+ new(Constants.TOKEN_ALLOW_DELETE, token.AllowDelete.ToString()),
+ };
+ var claims = token.User.DefaultClaims().Concat(permissions);
+ var identity = new ClaimsIdentity(claims, Constants.BASIC_AUTH_SCHEME);
+ var principal = new ClaimsPrincipal(identity);
+ var ticket = new AuthenticationTicket(principal, Constants.BASIC_AUTH_SCHEME);
+
+ return Task.FromResult(AuthenticateResult.Success(ticket));
+ } catch (Exception e) {
+ _logger.LogError(e, $"An exception occured when challenging {Constants.BASIC_AUTH_SCHEME}");
return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header"));
}
}
diff --git a/src/server/Utilities/SnakeCaseNamingPolicy.cs b/src/server/Utilities/SnakeCaseNamingPolicy.cs
new file mode 100644
index 0000000..9a7f1f3
--- /dev/null
+++ b/src/server/Utilities/SnakeCaseNamingPolicy.cs
@@ -0,0 +1,15 @@
+namespace IOL.BookmarkThing.Server.Utilities;
+
+public class SnakeCaseNamingPolicy : JsonNamingPolicy
+{
+ public static SnakeCaseNamingPolicy Instance { get; } = new SnakeCaseNamingPolicy();
+
+ public override string ConvertName(string name) {
+ // Conversion to other naming convention goes here. Like SnakeCase, KebabCase etc.
+ return ToSnakeCase(name);
+ }
+
+ private static string ToSnakeCase(string str) {
+ return string.Concat(str.Select((x, i) => i > 0 && char.IsUpper(x) ? "_" + x.ToString() : x.ToString())).ToLower();
+ }
+}
diff --git a/src/webapp/src/components/carbon-extras/NativeDateInput.svelte b/src/webapp/src/components/carbon-extras/NativeDateInput.svelte
new file mode 100644
index 0000000..ccdec53
--- /dev/null
+++ b/src/webapp/src/components/carbon-extras/NativeDateInput.svelte
@@ -0,0 +1,21 @@
+<script lang="ts">
+ import {generate_unsafe_id} from "@/lib/helpers";
+
+ export let value;
+ export let minDate;
+ export let label;
+ export let id = generate_unsafe_id(9);
+</script>
+
+<div class="bx--date-picker-container">
+ <label for="{id}"
+ class="bx--label">{label}</label>
+ <div class="bx--date-picker-input__wrapper">
+ <input id="{id}"
+ type="date"
+ class="bx--date-picker__input"
+ placeholder="mm/dd/yyyy"
+ min={minDate}
+ bind:value>
+ </div>
+</div>
diff --git a/src/webapp/src/lib/api/account.ts b/src/webapp/src/lib/api/account.ts
index f1c1708..eaf7592 100644
--- a/src/webapp/src/lib/api/account.ts
+++ b/src/webapp/src/lib/api/account.ts
@@ -1,5 +1,6 @@
import {api_base} from "@/lib/configuration";
import type {ICreateSessionRequest} from "@/lib/models/ICreateSessionRequest";
+import type {ICreateTokenRequest} from "@/lib/models/ICreateTokenRequest";
export async function create_session_async(request: ICreateSessionRequest): Promise<Response> {
return fetch(api_base("account/create-session"), {
@@ -25,3 +26,28 @@ export async function end_session_async(): Promise<Response> {
credentials: "include"
});
}
+
+export async function get_tokens_async(): Promise<Response> {
+ return fetch(api_base("account/tokens"), {
+ method: "get",
+ credentials: "include"
+ });
+}
+
+export async function create_token_async(request: ICreateTokenRequest): Promise<Response> {
+ return fetch(api_base("account/create-token"), {
+ method: "post",
+ credentials: "include",
+ body: JSON.stringify(request),
+ headers: {
+ "Content-Type": "application/json;charset=UTF-8"
+ }
+ });
+}
+
+export async function delete_token_async(token_id: string): Promise<Response> {
+ return fetch(api_base("account/delete-token?id=" + token_id), {
+ method: "delete",
+ credentials: "include"
+ });
+}
diff --git a/src/webapp/src/lib/configuration.ts b/src/webapp/src/lib/configuration.ts
index e7dbcfe..2b9c2af 100644
--- a/src/webapp/src/lib/configuration.ts
+++ b/src/webapp/src/lib/configuration.ts
@@ -1,7 +1,9 @@
-const api_version = "v1/";
+export const api_version = "v1";
+export const api_docs = "http://localhost:5003/swagger/index.html";
+export const api_url = "http://localhost:5003";
export function api_base(path) {
- return is_development() ? "http://localhost:5003/" + api_version + path : "/" + api_version + path;
+ return `${api_url}/${api_version}/${path}`;
}
export function is_development() {
diff --git a/src/webapp/src/lib/models/IAccessToken.ts b/src/webapp/src/lib/models/IAccessToken.ts
new file mode 100644
index 0000000..218f888
--- /dev/null
+++ b/src/webapp/src/lib/models/IAccessToken.ts
@@ -0,0 +1,9 @@
+export interface IAccessToken {
+ id: string,
+ created: Date,
+ expiry_date: Date,
+ allow_read: boolean;
+ allow_create: boolean;
+ allow_update: boolean;
+ allow_delete: boolean;
+}
diff --git a/src/webapp/src/lib/models/ICreateTokenRequest.ts b/src/webapp/src/lib/models/ICreateTokenRequest.ts
new file mode 100644
index 0000000..6e3c7a5
--- /dev/null
+++ b/src/webapp/src/lib/models/ICreateTokenRequest.ts
@@ -0,0 +1,7 @@
+export interface ICreateTokenRequest {
+ allow_read: boolean;
+ allow_create: boolean;
+ allow_update: boolean;
+ allow_delete: boolean;
+ expiry_date: Date;
+}
diff --git a/src/webapp/src/routes/app/_header.svelte b/src/webapp/src/routes/app/_header.svelte
index 4b2ddb7..04eb405 100644
--- a/src/webapp/src/routes/app/_header.svelte
+++ b/src/webapp/src/routes/app/_header.svelte
@@ -12,11 +12,13 @@
import Help16 from "carbon-icons-svelte/lib/Help16";
import {end_session_async} from "@/lib/api/account";
import {clear_entries} from "@/lib/stores/entries";
+ import AccessTokensModal from "./modals/access-tokens-modal.svelte";
let profile_dropdown_is_open = false;
let options_dropdown_is_open = false;
let enable_dark_theme = get(preferences).theme === ApplicationTheme.DARK;
let enable_site_report = get(preferences).enable_site_report;
+ let access_token_modal_is_open = false;
preferences.subscribe(e => {
enable_site_report = e.enable_site_report;
@@ -43,7 +45,11 @@
}
function manage_access_tokens() {
- alert("Not implemented");
+ access_token_modal_is_open = true;
+ }
+
+ function on_access_tokens_modal_close() {
+ access_token_modal_is_open = false;
}
function toggle_site_report() {
@@ -62,6 +68,9 @@
}
</script>
+<AccessTokensModal open={access_token_modal_is_open}
+ on:close={on_access_tokens_modal_close}/>
+
<Header company="IOL"
platformName="Bookmark Thing">
<HeaderUtilities>
diff --git a/src/webapp/src/routes/app/modals/access-tokens-modal.svelte b/src/webapp/src/routes/app/modals/access-tokens-modal.svelte
index e69de29..af4b524 100644
--- a/src/webapp/src/routes/app/modals/access-tokens-modal.svelte
+++ b/src/webapp/src/routes/app/modals/access-tokens-modal.svelte
@@ -0,0 +1,148 @@
+<script lang="ts">
+ import {delete_token_async, get_tokens_async} from "@/lib/api/account";
+ import {Modal, DataTable, ToolbarContent, Toolbar, ToolbarMenu, ToolbarMenuItem, Button, OverflowMenu, OverflowMenuItem, CodeSnippet, Link} from "carbon-components-svelte";
+ import {onMount} from "svelte";
+ import type {IAccessToken} from "@/lib/models/IAccessToken";
+ import {api_docs} from "@/lib/configuration";
+ import NewAccessTokenModal from "./new-access-token-modal.svelte";
+
+ export let open;
+ let loading_tokens_table;
+ let tokens = [] as Array<IAccessToken>;
+ let create_access_token_modal_is_open = false;
+ let copy_access_token_modal_is_open = false;
+ let new_access_token = "";
+ const table_headers = [
+ {key: "created", value: "Created"},
+ {key: "expiry_date", value: "Expires"},
+ {key: "allow_create", value: "Create"},
+ {key: "allow_read", value: "Read"},
+ {key: "allow_update", value: "Update"},
+ {key: "allow_delete", value: "Delete"},
+ {key: "overflow", empty: true},
+ ];
+
+ async function load_tokens() {
+ loading_tokens_table = true;
+ const get_tokens_request = await get_tokens_async();
+ loading_tokens_table = false;
+ if (get_tokens_request.ok) {
+ const response_json = await get_tokens_request.json();
+ if (response_json?.length > 0) {
+ let temparr = [];
+ for (const token of response_json as Array<IAccessToken>) {
+ temparr.push({
+ id: token.id,
+ expiry_date: new Date(token.expiry_date).toLocaleDateString(),
+ created: new Date(token.created).toLocaleDateString(),
+ allow_read: token.allow_read,
+ allow_create: token.allow_create,
+ allow_update: token.allow_update,
+ allow_delete: token.allow_delete,
+ });
+ }
+ tokens = temparr;
+ } else {
+ tokens = [];
+ }
+ } else {
+ tokens = [];
+ }
+ }
+
+ async function delete_token(event) {
+ const row = event.target.closest("tr");
+ const id = row.id.substring(4, row.id.length);
+ if (confirm("Are you sure you want to delete this token?")) {
+ const http_request = await delete_token_async(id);
+ if (http_request.ok) {
+ await load_tokens();
+ }
+ }
+ }
+
+ async function delete_all_tokens() {
+ if (confirm("Are you sure you want to delete all tokens?")) {
+ for (const token of tokens) {
+ await delete_token_async(token.id);
+ }
+ await load_tokens();
+ }
+ }
+
+ async function handle_token_created(e) {
+ const token = e.detail;
+ console.log("Created token", token);
+ create_access_token_modal_is_open = false;
+ new_access_token = token;
+ copy_access_token_modal_is_open = true;
+ await load_tokens();
+ }
+
+ function copy_access_token_modal_closed() {
+ new_access_token = "";
+ copy_access_token_modal_is_open = false;
+ }
+
+ onMount(async () => {
+ await load_tokens();
+ });
+</script>
+
+<Modal size="lg"
+ passiveModal="{true}"
+ {open}
+ preventCloseOnClickOutside
+ modalHeading="Manage access tokens"
+ on:open
+ on:close>
+ <DataTable size="compact"
+ sortable="{true}"
+ headers={table_headers}
+ style="padding-bottom: 50px"
+ rows={tokens}>
+ <Toolbar size="sm">
+ <ToolbarContent>
+ <ToolbarMenu>
+ <ToolbarMenuItem>
+ <Link href="{api_docs}"
+ target="_blank">API documentation
+ </Link>
+ </ToolbarMenuItem>
+ <ToolbarMenuItem danger
+ on:click={delete_all_tokens}>
+ Delete all tokens
+ </ToolbarMenuItem>
+ </ToolbarMenu>
+ <Button on:click={() => create_access_token_modal_is_open = true}>Create token</Button>
+ </ToolbarContent>
+ </Toolbar>
+ <svelte:fragment slot="cell"
+ let:cell>
+ {#if cell.key === "overflow"}
+ <OverflowMenu flipped
+ size="sm">
+ <OverflowMenuItem danger
+ on:click={delete_token}
+ text="Delete"/>
+ </OverflowMenu>
+ {:else}
+ {cell.value}
+ {/if}
+ </svelte:fragment>
+ </DataTable>
+</Modal>
+
+<Modal size="sm"
+ passiveModal="{true}"
+ preventCloseOnClickOutside
+ modalHeading="Your access token"
+ modalDescription="This will only be shown once"
+ on:close={copy_access_token_modal_closed}
+ open={copy_access_token_modal_is_open}>
+ <CodeSnippet code={new_access_token}/>
+ <br>
+</Modal>
+
+<NewAccessTokenModal bind:open={create_access_token_modal_is_open}
+ on:created={handle_token_created}/>
diff --git a/src/webapp/src/routes/app/modals/new-access-token-modal.svelte b/src/webapp/src/routes/app/modals/new-access-token-modal.svelte
new file mode 100644
index 0000000..8999b0b
--- /dev/null
+++ b/src/webapp/src/routes/app/modals/new-access-token-modal.svelte
@@ -0,0 +1,131 @@
+<script lang="ts">
+ import {Checkbox, Form, FormGroup, Modal} from "carbon-components-svelte";
+ import NativeDateInput from "@/components/carbon-extras/NativeDateInput.svelte";
+ import type {ICreateTokenRequest} from "@/lib/models/ICreateTokenRequest";
+ import {create_token_async} from "@/lib/api/account";
+ import {createEventDispatcher} from "svelte";
+
+ export let open = false;
+ const default_expires = (new Date().setTime(new Date().getTime() + 1));
+ const dispatch = createEventDispatcher();
+ const form = {
+ error: "",
+ loading: false,
+ expiry_date: {
+ value: default_expires,
+ error: "",
+ warning: "",
+ reset() {
+ form.expiry_date.value = default_expires;
+ },
+ change() {
+ },
+ validate() {
+ }
+ },
+ allow_create: {
+ value: false,
+ error: "",
+ warning: "",
+ reset() {
+ form.allow_create.value = false;
+ },
+ change() {
+ },
+ validate() {
+ }
+ },
+ allow_read: {
+ value: false,
+ error: "",
+ warning: "",
+ reset() {
+ form.allow_read.value = false;
+ },
+ change() {
+ },
+ validate() {
+ }
+ },
+ allow_update: {
+ value: false,
+ error: "",
+ warning: "",
+ reset() {
+ form.allow_update.value = false;
+ },
+ change() {
+ },
+ validate() {
+ }
+ },
+ allow_delete: {
+ value: false,
+ error: "",
+ warning: "",
+ reset() {
+ form.allow_delete.value = false;
+ },
+ change() {
+ },
+ validate() {
+ }
+ },
+ reset() {
+ form.allow_read.reset();
+ form.allow_delete.reset();
+ form.allow_update.reset();
+ form.allow_create.reset();
+ form.expiry_date.reset();
+ },
+ payload(): ICreateTokenRequest {
+ return {
+ allow_delete: form.allow_delete.value,
+ allow_read: form.allow_read.value,
+ allow_update: form.allow_update.value,
+ allow_create: form.allow_create.value,
+ expiry_date: form.expiry_date.value,
+ } as ICreateTokenRequest;
+ },
+ async submit() {
+ form.loading = true;
+ const http_request = await create_token_async(form.payload());
+ form.loading = false;
+ if (http_request.ok) {
+ dispatch("created", await http_request.text());
+ }
+ }
+ };
+</script>
+
+<Modal modalHeading="Create access token"
+ preventCloseOnClickOutside
+ hasForm
+ primaryButtonText="Submit"
+ secondaryButtonText="Cancel"
+ style="padding-bottom: 50px;padding-right: 30px;"
+ on:click:button--secondary={() => open = false}
+ bind:open={open}
+ on:submit={form.submit}>
+ <Form>
+ <FormGroup>
+ <NativeDateInput label="Expiry date"
+ minDate="{new Date()}"
+ bind:value={form.expiry_date.value}/>
+ </FormGroup>
+ <FormGroup legendText="Permissions">
+ <Checkbox id="permission-create"
+ labelText="Create"
+ bind:checked="{form.allow_create.value}"/>
+ <Checkbox id="permission-read"
+ labelText="Read"
+ bind:checked="{form.allow_read.value}"/>
+ <Checkbox id="permission-update"
+ labelText="Update"
+ bind:checked="{form.allow_update.value}"/>
+ <Checkbox id="permission-delete"
+ labelText="Delete"
+ bind:checked="{form.allow_delete.value}"/>
+ </FormGroup>
+ </Form>
+</Modal>