From 2ce75d2cd9aa3d5cc2a501189fa2ca1fa2f2b79b Mon Sep 17 00:00:00 2001 From: Benjamin Sutas Date: Fri, 13 Sep 2024 16:38:25 +1000 Subject: [PATCH] initial progress of user accounts --- DatabaseExporter/Program.cs | 30 +- DatabaseSeeder/Program.cs | 38 +- Definitions/DTO/DtoCreateUser.cs | 26 ++ Definitions/Database/LocoDb.cs | 17 +- Definitions/Database/TblRole.cs | 14 + Definitions/Database/TblUser.cs | 24 ++ .../20240913053200_AddUsersRoles.Designer.cs | 381 ++++++++++++++++++ .../20240913053200_AddUsersRoles.cs | 122 ++++++ Definitions/Migrations/LocoDbModelSnapshot.cs | 103 +++++ Definitions/SourceData/LicenceJsonRecord.cs | 2 + Definitions/SourceData/UserJsonRecord.cs | 4 + Definitions/Web/Routes.cs | 10 + ObjectService/ObjectService.csproj | 3 +- ObjectService/Passwords.cs | 57 +++ ObjectService/Program.cs | 26 +- ObjectService/Server.cs | 127 ++++-- ObjectService/appsettings.json | 13 +- 17 files changed, 950 insertions(+), 47 deletions(-) create mode 100644 Definitions/DTO/DtoCreateUser.cs create mode 100644 Definitions/Database/TblRole.cs create mode 100644 Definitions/Database/TblUser.cs create mode 100644 Definitions/Migrations/20240913053200_AddUsersRoles.Designer.cs create mode 100644 Definitions/Migrations/20240913053200_AddUsersRoles.cs create mode 100644 Definitions/SourceData/UserJsonRecord.cs create mode 100644 ObjectService/Passwords.cs diff --git a/DatabaseExporter/Program.cs b/DatabaseExporter/Program.cs index 2f88d442..ecfea013 100644 --- a/DatabaseExporter/Program.cs +++ b/DatabaseExporter/Program.cs @@ -17,11 +17,28 @@ var tags = JsonSerializer.Serialize>(db.Tags.Select(t => t.Name).ToList().Order(), jsonOptions); var licences = JsonSerializer.Serialize>(db.Licences.Select(l => new LicenceJsonRecord(l.Name, l.Text)).ToList().OrderBy(l => l.Name), jsonOptions); var modpacks = JsonSerializer.Serialize>(db.Modpacks.Select(m => new ModpackJsonRecord(m.Name, m.Author)).ToList().OrderBy(m => m.Name), jsonOptions); +var roles = JsonSerializer.Serialize>(db.Roles.Select(r => r.Name).ToList().Order(), jsonOptions); -var objs = new List(); +var usrs = new List(); +foreach (var u in db.Users + .Include(x => x.Author) + .ToList() + .OrderBy(x => x.Name)) +{ + var usr = new UserJsonRecord( + u.Name, + u.DisplayName, + u.Author?.Name, + u.Roles.Select(r => r.Name).Order().ToList(), + u.PasswordHashed, + u.PasswordSalt); + usrs.Add(usr); +} +var users = JsonSerializer.Serialize>(usrs, jsonOptions); +var objs = new List(); foreach (var o in db.Objects - .Include(l => l.Licence) + .Include(x => x.Licence) .Select(x => new ExpandedTblLocoObject(x, x.Authors, x.Tags, x.Modpacks)) .ToList() .OrderBy(x => x.Object.Name)) @@ -30,13 +47,12 @@ o.Object.OriginalName, o.Object.OriginalChecksum, o.Object.Description, - o.Authors.Select(a => a.Name).ToList(), - o.Tags.Select(t => t.Name).ToList(), - o.Modpacks.Select(m => m.Name).ToList(), + o.Authors.Select(a => a.Name).Order().ToList(), + o.Tags.Select(t => t.Name).Order().ToList(), + o.Modpacks.Select(m => m.Name).Order().ToList(), o.Object.Licence?.Name); objs.Add(obj); } - var objects = JsonSerializer.Serialize>(objs, jsonOptions); Console.WriteLine("writing"); @@ -46,5 +62,7 @@ File.WriteAllText("Q:\\Games\\Locomotion\\Database\\licences.json", licences); File.WriteAllText("Q:\\Games\\Locomotion\\Database\\modpacks.json", modpacks); File.WriteAllText("Q:\\Games\\Locomotion\\Database\\objects.json", objects); +File.WriteAllText("Q:\\Games\\Locomotion\\Database\\users.json", users); +File.WriteAllText("Q:\\Games\\Locomotion\\Database\\roles.json", roles); Console.WriteLine("done"); diff --git a/DatabaseSeeder/Program.cs b/DatabaseSeeder/Program.cs index 2e4b83b7..4c968489 100644 --- a/DatabaseSeeder/Program.cs +++ b/DatabaseSeeder/Program.cs @@ -39,6 +39,8 @@ static void SeedDb(LocoDb db, bool deleteExisting) if (deleteExisting) { Console.WriteLine("Clearing database"); + _ = db.Users.ExecuteDelete(); + _ = db.Roles.ExecuteDelete(); _ = db.Objects.ExecuteDelete(); _ = db.Tags.ExecuteDelete(); _ = db.Modpacks.ExecuteDelete(); @@ -56,14 +58,48 @@ static void SeedDb(LocoDb db, bool deleteExisting) // ... + if (!db.Roles.Any()) + { + Console.WriteLine("Seeding Roles"); + + var roles = JsonSerializer.Deserialize>(File.ReadAllText("Q:\\Games\\Locomotion\\Server\\roles.json"), jsonOptions); + if (roles != null) + { + db.AddRange(roles.Select(x => new TblRole() { Name = x })); + _ = db.SaveChanges(); + } + } + + // ... + + Console.WriteLine("Seeding Users"); + + var users = JsonSerializer.Deserialize>(File.ReadAllText("Q:\\Games\\Locomotion\\Server\\users.json"), jsonOptions); + if (users != null) + { + db.AddRange(users.Select(x => new TblUser() + { + Name = x.Name, + DisplayName = x.DisplayName, + Author = db.Authors.SingleOrDefault(a => a.Name == x.Author), + Roles = [.. db.Roles.Where(r => x.Roles.Contains(r.Name))], + PasswordHashed = x.PasswordHashed, + PasswordSalt = x.PasswordSalt + })); + _ = db.SaveChanges(); + } + + // ... + if (!db.Authors.Any()) { + Console.WriteLine("Seeding Authors"); var authors = JsonSerializer.Deserialize>(File.ReadAllText("Q:\\Games\\Locomotion\\Server\\authors.json"), jsonOptions); if (authors != null) { - db.AddRange(authors.Select(x => new TblAuthor() { Name = x })); + db.AddRange(authors.Select(x => new TblUser() { Name = x })); _ = db.SaveChanges(); } } diff --git a/Definitions/DTO/DtoCreateUser.cs b/Definitions/DTO/DtoCreateUser.cs new file mode 100644 index 00000000..1a554fa1 --- /dev/null +++ b/Definitions/DTO/DtoCreateUser.cs @@ -0,0 +1,26 @@ +namespace OpenLoco.Definitions.DTO +{ + public enum DataOperation + { + Create, + Read, + Update, + Delete, + } + + public record DtoCreateUser( + string Name, + string DisplayName, + string? Author, + string Password); + + public record DtoChangeUserRole( + string UserName, + string RoleName, + DataOperation Op); + + public record DtoChangeUserAuthor( + string UserName, + string AuthorName, + DataOperation Op); +} diff --git a/Definitions/Database/LocoDb.cs b/Definitions/Database/LocoDb.cs index 30c70a22..807e625e 100644 --- a/Definitions/Database/LocoDb.cs +++ b/Definitions/Database/LocoDb.cs @@ -5,11 +5,13 @@ namespace OpenLoco.Definitions.Database { public class LocoDb : DbContext { + public DbSet Objects => Set(); public DbSet Authors => Set(); public DbSet Tags => Set(); public DbSet Modpacks => Set(); - public DbSet Objects => Set(); public DbSet Licences => Set(); + public DbSet Users => Set(); + public DbSet Roles => Set(); public LocoDb() { } @@ -25,9 +27,16 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) } } - protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.Entity() - .Property(b => b.UploadDate) - .HasDefaultValueSql("datetime(datetime('now', 'localtime'), 'utc')"); // this is necessary, it seems like a bug in sqlite + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .Property(x => x.UploadDate) + .HasDefaultValueSql("datetime(datetime('now', 'localtime'), 'utc')"); // this is necessary, it seems like a bug in sqlite + + modelBuilder.Entity() + .Property(x => x.CreatedDate) + .HasDefaultValueSql("datetime(datetime('now', 'localtime'), 'utc')"); // this is necessary, it seems like a bug in sqlite + } public bool DoesObjectExist(S5Header s5Header, out TblLocoObject? existingObject) => DoesObjectExist(s5Header.Name, s5Header.Checksum, out existingObject); diff --git a/Definitions/Database/TblRole.cs b/Definitions/Database/TblRole.cs new file mode 100644 index 00000000..b2539986 --- /dev/null +++ b/Definitions/Database/TblRole.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; + +namespace OpenLoco.Definitions.Database +{ + [Index(nameof(Name), IsUnique = true)] + public class TblRole + { + public int Id { get; set; } + + public string Name { get; set; } + + public ICollection Users { get; set; } + } +} diff --git a/Definitions/Database/TblUser.cs b/Definitions/Database/TblUser.cs new file mode 100644 index 00000000..a88241f5 --- /dev/null +++ b/Definitions/Database/TblUser.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; + +namespace OpenLoco.Definitions.Database +{ + [Index(nameof(Name), IsUnique = true)] + [Index(nameof(DisplayName), IsUnique = true)] + [Index(nameof(PasswordSalt), IsUnique = true)] + public class TblUser + { + public int Id { get; set; } + public string Name { get; set; } + + public string DisplayName { get; set; } + + public TblAuthor? Author { get; set; } + + public ICollection Roles { get; set; } + + public string PasswordHashed { get; set; } + public string PasswordSalt { get; set; } + + public DateTimeOffset CreatedDate { get; set; } + } +} diff --git a/Definitions/Migrations/20240913053200_AddUsersRoles.Designer.cs b/Definitions/Migrations/20240913053200_AddUsersRoles.Designer.cs new file mode 100644 index 00000000..d3393d59 --- /dev/null +++ b/Definitions/Migrations/20240913053200_AddUsersRoles.Designer.cs @@ -0,0 +1,381 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using OpenLoco.Definitions.Database; + +#nullable disable + +namespace Definitions.Migrations +{ + [DbContext(typeof(LocoDb))] + [Migration("20240913053200_AddUsersRoles")] + partial class AddUsersRoles + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("OpenLoco.Definitions.Database.TblAuthor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("OpenLoco.Definitions.Database.TblLicence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Licences"); + }); + + modelBuilder.Entity("OpenLoco.Definitions.Database.TblLocoObject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Availability") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsVanilla") + .HasColumnType("INTEGER"); + + b.Property("LastEditDate") + .HasColumnType("TEXT"); + + b.Property("LicenceId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ObjectType") + .HasColumnType("INTEGER"); + + b.Property("OriginalChecksum") + .HasColumnType("INTEGER"); + + b.Property("OriginalName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PathOnDisk") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UploadDate") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("TEXT") + .HasDefaultValueSql("datetime(datetime('now', 'localtime'), 'utc')"); + + b.Property("VehicleType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LicenceId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("PathOnDisk") + .IsUnique(); + + b.HasIndex("OriginalName", "OriginalChecksum") + .IsUnique() + .IsDescending(true, false); + + b.ToTable("Objects"); + }); + + modelBuilder.Entity("OpenLoco.Definitions.Database.TblModpack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Modpacks"); + }); + + modelBuilder.Entity("OpenLoco.Definitions.Database.TblRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Roles"); + }); + + modelBuilder.Entity("OpenLoco.Definitions.Database.TblTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("OpenLoco.Definitions.Database.TblUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorId") + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("datetime(datetime('now', 'localtime'), 'utc')"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordHashed") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordSalt") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("DisplayName") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("PasswordSalt") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("TblAuthorTblLocoObject", b => + { + b.Property("AuthorsId") + .HasColumnType("INTEGER"); + + b.Property("ObjectsId") + .HasColumnType("INTEGER"); + + b.HasKey("AuthorsId", "ObjectsId"); + + b.HasIndex("ObjectsId"); + + b.ToTable("TblAuthorTblLocoObject"); + }); + + modelBuilder.Entity("TblLocoObjectTblModpack", b => + { + b.Property("ModpacksId") + .HasColumnType("INTEGER"); + + b.Property("ObjectsId") + .HasColumnType("INTEGER"); + + b.HasKey("ModpacksId", "ObjectsId"); + + b.HasIndex("ObjectsId"); + + b.ToTable("TblLocoObjectTblModpack"); + }); + + modelBuilder.Entity("TblLocoObjectTblTag", b => + { + b.Property("ObjectsId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ObjectsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("TblLocoObjectTblTag"); + }); + + modelBuilder.Entity("TblRoleTblUser", b => + { + b.Property("RolesId") + .HasColumnType("INTEGER"); + + b.Property("UsersId") + .HasColumnType("INTEGER"); + + b.HasKey("RolesId", "UsersId"); + + b.HasIndex("UsersId"); + + b.ToTable("TblRoleTblUser"); + }); + + modelBuilder.Entity("OpenLoco.Definitions.Database.TblLocoObject", b => + { + b.HasOne("OpenLoco.Definitions.Database.TblLicence", "Licence") + .WithMany() + .HasForeignKey("LicenceId"); + + b.Navigation("Licence"); + }); + + modelBuilder.Entity("OpenLoco.Definitions.Database.TblModpack", b => + { + b.HasOne("OpenLoco.Definitions.Database.TblAuthor", "Author") + .WithMany() + .HasForeignKey("AuthorId"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("OpenLoco.Definitions.Database.TblUser", b => + { + b.HasOne("OpenLoco.Definitions.Database.TblAuthor", "Author") + .WithMany() + .HasForeignKey("AuthorId"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("TblAuthorTblLocoObject", b => + { + b.HasOne("OpenLoco.Definitions.Database.TblAuthor", null) + .WithMany() + .HasForeignKey("AuthorsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OpenLoco.Definitions.Database.TblLocoObject", null) + .WithMany() + .HasForeignKey("ObjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TblLocoObjectTblModpack", b => + { + b.HasOne("OpenLoco.Definitions.Database.TblModpack", null) + .WithMany() + .HasForeignKey("ModpacksId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OpenLoco.Definitions.Database.TblLocoObject", null) + .WithMany() + .HasForeignKey("ObjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TblLocoObjectTblTag", b => + { + b.HasOne("OpenLoco.Definitions.Database.TblLocoObject", null) + .WithMany() + .HasForeignKey("ObjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OpenLoco.Definitions.Database.TblTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TblRoleTblUser", b => + { + b.HasOne("OpenLoco.Definitions.Database.TblRole", null) + .WithMany() + .HasForeignKey("RolesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OpenLoco.Definitions.Database.TblUser", null) + .WithMany() + .HasForeignKey("UsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Definitions/Migrations/20240913053200_AddUsersRoles.cs b/Definitions/Migrations/20240913053200_AddUsersRoles.cs new file mode 100644 index 00000000..c658a058 --- /dev/null +++ b/Definitions/Migrations/20240913053200_AddUsersRoles.cs @@ -0,0 +1,122 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Definitions.Migrations +{ + /// + public partial class AddUsersRoles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Roles", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Roles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: false), + DisplayName = table.Column(type: "TEXT", nullable: false), + AuthorId = table.Column(type: "INTEGER", nullable: true), + PasswordHashed = table.Column(type: "TEXT", nullable: false), + PasswordSalt = table.Column(type: "TEXT", nullable: false), + CreatedDate = table.Column(type: "TEXT", nullable: false, defaultValueSql: "datetime(datetime('now', 'localtime'), 'utc')") + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + table.ForeignKey( + name: "FK_Users_Authors_AuthorId", + column: x => x.AuthorId, + principalTable: "Authors", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "TblRoleTblUser", + columns: table => new + { + RolesId = table.Column(type: "INTEGER", nullable: false), + UsersId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TblRoleTblUser", x => new { x.RolesId, x.UsersId }); + table.ForeignKey( + name: "FK_TblRoleTblUser_Roles_RolesId", + column: x => x.RolesId, + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TblRoleTblUser_Users_UsersId", + column: x => x.UsersId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Roles_Name", + table: "Roles", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TblRoleTblUser_UsersId", + table: "TblRoleTblUser", + column: "UsersId"); + + migrationBuilder.CreateIndex( + name: "IX_Users_AuthorId", + table: "Users", + column: "AuthorId"); + + migrationBuilder.CreateIndex( + name: "IX_Users_DisplayName", + table: "Users", + column: "DisplayName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_Name", + table: "Users", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_PasswordSalt", + table: "Users", + column: "PasswordSalt", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TblRoleTblUser"); + + migrationBuilder.DropTable( + name: "Roles"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/Definitions/Migrations/LocoDbModelSnapshot.cs b/Definitions/Migrations/LocoDbModelSnapshot.cs index 22362c6f..8ac71b61 100644 --- a/Definitions/Migrations/LocoDbModelSnapshot.cs +++ b/Definitions/Migrations/LocoDbModelSnapshot.cs @@ -144,6 +144,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Modpacks"); }); + modelBuilder.Entity("OpenLoco.Definitions.Database.TblRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Roles"); + }); + modelBuilder.Entity("OpenLoco.Definitions.Database.TblTag", b => { b.Property("Id") @@ -162,6 +180,52 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Tags"); }); + modelBuilder.Entity("OpenLoco.Definitions.Database.TblUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorId") + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("datetime(datetime('now', 'localtime'), 'utc')"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordHashed") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordSalt") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("DisplayName") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("PasswordSalt") + .IsUnique(); + + b.ToTable("Users"); + }); + modelBuilder.Entity("TblAuthorTblLocoObject", b => { b.Property("AuthorsId") @@ -207,6 +271,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("TblLocoObjectTblTag"); }); + modelBuilder.Entity("TblRoleTblUser", b => + { + b.Property("RolesId") + .HasColumnType("INTEGER"); + + b.Property("UsersId") + .HasColumnType("INTEGER"); + + b.HasKey("RolesId", "UsersId"); + + b.HasIndex("UsersId"); + + b.ToTable("TblRoleTblUser"); + }); + modelBuilder.Entity("OpenLoco.Definitions.Database.TblLocoObject", b => { b.HasOne("OpenLoco.Definitions.Database.TblLicence", "Licence") @@ -225,6 +304,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Author"); }); + modelBuilder.Entity("OpenLoco.Definitions.Database.TblUser", b => + { + b.HasOne("OpenLoco.Definitions.Database.TblAuthor", "Author") + .WithMany() + .HasForeignKey("AuthorId"); + + b.Navigation("Author"); + }); + modelBuilder.Entity("TblAuthorTblLocoObject", b => { b.HasOne("OpenLoco.Definitions.Database.TblAuthor", null) @@ -269,6 +357,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); + + modelBuilder.Entity("TblRoleTblUser", b => + { + b.HasOne("OpenLoco.Definitions.Database.TblRole", null) + .WithMany() + .HasForeignKey("RolesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OpenLoco.Definitions.Database.TblUser", null) + .WithMany() + .HasForeignKey("UsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); #pragma warning restore 612, 618 } } diff --git a/Definitions/SourceData/LicenceJsonRecord.cs b/Definitions/SourceData/LicenceJsonRecord.cs index ca04b4a9..05b9013a 100644 --- a/Definitions/SourceData/LicenceJsonRecord.cs +++ b/Definitions/SourceData/LicenceJsonRecord.cs @@ -1,3 +1,5 @@ +using System.Net; + namespace OpenLoco.Definitions.SourceData { public record LicenceJsonRecord(string Name, string Text); diff --git a/Definitions/SourceData/UserJsonRecord.cs b/Definitions/SourceData/UserJsonRecord.cs new file mode 100644 index 00000000..4034b091 --- /dev/null +++ b/Definitions/SourceData/UserJsonRecord.cs @@ -0,0 +1,4 @@ +namespace OpenLoco.Definitions.SourceData +{ + public record UserJsonRecord(string Name, string DisplayName, string? Author, List Roles, string PasswordHashed, string PasswordSalt); +} diff --git a/Definitions/Web/Routes.cs b/Definitions/Web/Routes.cs index a4523fb8..f04630bf 100644 --- a/Definitions/Web/Routes.cs +++ b/Definitions/Web/Routes.cs @@ -12,9 +12,19 @@ public static class Routes // POST public const string UploadDat = "/objects/uploaddat"; public const string UploadObject = "/objects/uploadobject"; + public const string CreateUser = "/users/create"; + // PATCH public const string UpdateDat = "/objects/updatedat"; public const string UpdateObject = "/objects/updateobject"; + + public const string AddRole = "/users/{userid}/roles/add/{tagid}"; + public const string RemoveRole = "/users/{userid}/roles/remove/{tagid}"; + + public const string AddAuthor = "/users/{userid}/author/add/{authorid}"; + public const string RemoveAuthor = "/users/{userid}/author/remove/{authorid}"; + + public const string AddModpack } } diff --git a/ObjectService/ObjectService.csproj b/ObjectService/ObjectService.csproj index d95d20a3..89e0a59e 100644 --- a/ObjectService/ObjectService.csproj +++ b/ObjectService/ObjectService.csproj @@ -8,9 +8,10 @@ + - + diff --git a/ObjectService/Passwords.cs b/ObjectService/Passwords.cs new file mode 100644 index 00000000..e94f9511 --- /dev/null +++ b/ObjectService/Passwords.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Cryptography.KeyDerivation; +using System.Security.Cryptography; + +namespace OpenLoco.ObjectService +{ + public static class Passwords + { + static byte[] GenerateSalt(int sizeInBytes) + { + using (var rng = RandomNumberGenerator.Create()) + { + var salt = new byte[sizeInBytes]; + rng.GetBytes(salt); + return salt; + } + } + + public static (string Hash, string Salt) HashPassword(string password) + { + // Generate a random salt + var salt = GenerateSalt(64); + + // Derive a 256-bit subkey (use HMACSHA256 with 100,000 iterations) + var hashed = Convert.ToBase64String(KeyDerivation.Pbkdf2( + password: password, + salt: salt, + prf: KeyDerivationPrf.HMACSHA256, + iterationCount: 100000, + numBytesRequested: 256 / 8)); + + return (hashed, Convert.ToBase64String(salt)); + } + + public static bool VerifyPassword(string password, string hashedPassword, string salt) + { + // Re-hash the provided password with the stored salt + var newHash = HashPassword(password, Convert.FromBase64String(salt)).Hash; + + // Compare the re-hashed password with the stored hash + return newHash == hashedPassword; + } + + private static (string Hash, string Salt) HashPassword(string password, byte[] salt) + { + // Similar to the public HashPassword method, but takes a byte[] salt + var hashed = Convert.ToBase64String(KeyDerivation.Pbkdf2( + password: password, + salt: salt, + prf: KeyDerivationPrf.HMACSHA256, + iterationCount: 100000, + numBytesRequested: 256 / 8)); + + return (hashed, Convert.ToBase64String(salt)); + + } + } +} diff --git a/ObjectService/Program.cs b/ObjectService/Program.cs index 97906a8f..70f8ffb0 100644 --- a/ObjectService/Program.cs +++ b/ObjectService/Program.cs @@ -53,6 +53,15 @@ }; })); +builder.Services.AddAuthentication().AddJwtBearer("Bearer"); +builder.Services.AddAuthorization(); +builder.Services.AddAuthorizationBuilder() + .AddPolicy("editObject", policy => policy.RequireRole("editor")) + .AddPolicy("editAuthor", policy => policy.RequireRole("editor")) + .AddPolicy("editTags", policy => policy.RequireRole("editor")) + .AddPolicy("editLicence", policy => policy.RequireRole("editor")) + .AddPolicy("editModpack", policy => policy.RequireRole("editor")); + builder.Services.AddSingleton(); var serviceSettings = builder.Services.Configure(builder.Configuration.GetSection("ObjectService")); @@ -87,12 +96,25 @@ // PATCH _ = app.MapPatch(Routes.UpdateDat, () => Results.Problem(statusCode: StatusCodes.Status501NotImplemented)) - .RequireRateLimiting(tokenPolicy); + .RequireRateLimiting(tokenPolicy) + .RequireAuthorization("editObject"); _ = app.MapPatch(Routes.UpdateObject, () => Results.Problem(statusCode: StatusCodes.Status501NotImplemented)) - .RequireRateLimiting(tokenPolicy); + .RequireRateLimiting(tokenPolicy) + .RequireAuthorization("editObject"); + +_ = app.MapPatch(Routes.AddRole, () => Results.Problem(statusCode: StatusCodes.Status501NotImplemented)) + .RequireRateLimiting(tokenPolicy) + .RequireAuthorization("editObject"); + +_ = app.MapPatch(Routes.RemoveRole, () => Results.Problem(statusCode: StatusCodes.Status501NotImplemented)) + .RequireRateLimiting(tokenPolicy) + .RequireAuthorization("editObject"); // POST +_ = app.MapPost(Routes.CreateUser, server.CreateUser) + .RequireRateLimiting(tokenPolicy); + _ = app.MapPost(Routes.UploadDat, server.UploadDat) .RequireRateLimiting(tokenPolicy); diff --git a/ObjectService/Server.cs b/ObjectService/Server.cs index 8941241d..fe94c3ea 100644 --- a/ObjectService/Server.cs +++ b/ObjectService/Server.cs @@ -185,37 +185,100 @@ public async Task UploadDat(DtoUploadDat request, LocoDb db) return Results.Accepted($"Object already exists in the database. OriginalName={s5Header.Name} OriginalChecksum={s5Header.Checksum} UploadDate={existingObject!.UploadDate}"); } - const string UploadFolder = "UploadedObjects"; - var uuid = Guid.NewGuid(); - var saveFileName = Path.Combine(settings.ObjectRootFolder, UploadFolder, $"{uuid}.dat"); - File.WriteAllBytes(saveFileName, datFileBytes); - - Console.WriteLine($"File accepted OriginalName={s5Header.Name} OriginalChecksum={s5Header.Checksum} PathOnDisk={saveFileName}"); - - var locoTbl = new TblLocoObject() - { - Name = $"{s5Header.Name}_{s5Header.Checksum}", // same as DB seeder name - PathOnDisk = Path.Combine(UploadFolder, $"{uuid}.dat"), - OriginalName = s5Header.Name, - OriginalChecksum = s5Header.Checksum, - IsVanilla = false, // not possible to upload vanilla objects - ObjectType = s5Header.ObjectType, - VehicleType = s5Header.ObjectType == ObjectType.Vehicle ? (locoObject.Object as VehicleObject)!.Type : null, - Description = "", - Authors = [], - CreationDate = creationTime, - LastEditDate = null, - UploadDate = DateTimeOffset.UtcNow, - Tags = [], - Modpacks = [], - Availability = ObjectAvailability.NewGames, - Licence = null, - }; - - _ = db.Objects.Add(locoTbl); - _ = await db.SaveChangesAsync(); - - return Results.Created($"Successfully added {locoTbl.Name} with unique id {locoTbl.Id}", locoTbl.Id); + try + { + const string UploadFolder = "UploadedObjects"; + var uuid = Guid.NewGuid(); + var saveFileName = Path.Combine(settings.ObjectRootFolder, UploadFolder, $"{uuid}.dat"); + File.WriteAllBytes(saveFileName, datFileBytes); + + Console.WriteLine($"File accepted OriginalName={s5Header.Name} OriginalChecksum={s5Header.Checksum} PathOnDisk={saveFileName}"); + + var locoTbl = new TblLocoObject() + { + Name = $"{s5Header.Name}_{s5Header.Checksum}", // same as DB seeder name + PathOnDisk = Path.Combine(UploadFolder, $"{uuid}.dat"), + OriginalName = s5Header.Name, + OriginalChecksum = s5Header.Checksum, + IsVanilla = false, // not possible to upload vanilla objects + ObjectType = s5Header.ObjectType, + VehicleType = s5Header.ObjectType == ObjectType.Vehicle ? (locoObject.Object as VehicleObject)!.Type : null, + Description = "", + Authors = [], + CreationDate = creationTime, + LastEditDate = null, + UploadDate = DateTimeOffset.UtcNow, + Tags = [], + Modpacks = [], + Availability = ObjectAvailability.NewGames, + Licence = null, + }; + + _ = db.Objects.Add(locoTbl); + _ = await db.SaveChangesAsync(); + + return Results.Created($"Successfully added object {locoTbl.Name} with unique id {locoTbl.Id}", locoTbl.Id); + } + catch (Exception ex) + { + return Results.Problem(ex.Message); + } } + + public async Task CreateUser(DtoCreateUser request, LocoDb db) + { + if (string.IsNullOrEmpty(request.Name)) + { + return Results.BadRequest("Cannot accept empty name"); + } + if (string.IsNullOrEmpty(request.DisplayName)) + { + return Results.BadRequest("Cannot accept empty display name"); + } + if (string.IsNullOrEmpty(request.Password)) + { + return Results.BadRequest("Cannot accept empty password"); + } + if (request.Password.Length < 8) + { + return Results.BadRequest("Password must be 8 or more characters"); + } + + if (db.Users.FirstOrDefault(x => x.Name == request.Name) != null) + { + return Results.Conflict($"User {request.Name} already exists in database."); + } + + if (!string.IsNullOrEmpty(request.Author) && db.Authors.FirstOrDefault(x => x.Name == request.Author) == null) + { + return Results.Conflict($"Author {request.Author} does not exist in database."); + } + + (var salt, var hashed) = Passwords.HashPassword(request.Password); + + try + { + var user = new TblUser() + { + Name = request.Name, + DisplayName = request.DisplayName, + Author = db.Authors.FirstOrDefault(x => x.Name == request.Name), + PasswordHashed = hashed, + PasswordSalt = salt + }; + + _ = db.Users.Add(user); + _ = await db.SaveChangesAsync(); + + return Results.Created($"Successfully added user {user.Name}", user.Name); + } + catch (Exception ex) + { + return Results.Problem(ex.Message); + } + } + + public async Task AddRole(DtoAddRole request, LocoDb db) => Results.Ok(); + + public async Task RemoveRole(DtoRemoveRole request, LocoDb db) => Results.Ok(); } -} diff --git a/ObjectService/appsettings.json b/ObjectService/appsettings.json index 32b642fe..49955e7e 100644 --- a/ObjectService/appsettings.json +++ b/ObjectService/appsettings.json @@ -19,7 +19,18 @@ "AllowedHosts": "*", "ConnectionStrings": { "SQLiteConnection": "Data Source=Q:\\Games\\Locomotion\\Server\\loco-dev.db" - + }, + "Authentication": { + "DefaultScheme": "Bearer", + "Schemes": { + "Bearer": { + "ValidAudiences": [ + "http://localhost:7229", + "https://localhost:7230" + ], + "ValidIssuer": "dotnet-user-jwts" + } + } }, "ObjectService": { "ObjectRootFolder": "Q:\\Games\\Locomotion\\Server\\Objects"