diff --git a/.idea/.idea.Moonlight/.idea/efCoreCommonOptions.xml b/.idea/.idea.Moonlight/.idea/efCoreCommonOptions.xml
new file mode 100644
index 00000000..9e91701b
--- /dev/null
+++ b/.idea/.idea.Moonlight/.idea/efCoreCommonOptions.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/.idea.Moonlight/.idea/efCoreDialogsState.xml b/.idea/.idea.Moonlight/.idea/efCoreDialogsState.xml
new file mode 100644
index 00000000..8f682f3b
--- /dev/null
+++ b/.idea/.idea.Moonlight/.idea/efCoreDialogsState.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Moonlight/App/Database/Migrations/20231013200303_AddedUser.Designer.cs b/Moonlight/App/Database/Migrations/20231013200303_AddedUser.Designer.cs
new file mode 100644
index 00000000..00811bcd
--- /dev/null
+++ b/Moonlight/App/Database/Migrations/20231013200303_AddedUser.Designer.cs
@@ -0,0 +1,67 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Moonlight.App.Database;
+
+#nullable disable
+
+namespace Moonlight.App.Database.Migrations
+{
+ [DbContext(typeof(DataContext))]
+ [Migration("20231013200303_AddedUser")]
+ partial class AddedUser
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "7.0.2");
+
+ modelBuilder.Entity("Moonlight.App.Database.Entities.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Avatar")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Flags")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Password")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Permissions")
+ .HasColumnType("INTEGER");
+
+ b.Property("TokenValidTimestamp")
+ .HasColumnType("TEXT");
+
+ b.Property("TotpKey")
+ .HasColumnType("TEXT");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("Users");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Moonlight/App/Database/Migrations/20231013200303_AddedUser.cs b/Moonlight/App/Database/Migrations/20231013200303_AddedUser.cs
new file mode 100644
index 00000000..ef03bad6
--- /dev/null
+++ b/Moonlight/App/Database/Migrations/20231013200303_AddedUser.cs
@@ -0,0 +1,43 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Moonlight.App.Database.Migrations
+{
+ ///
+ public partial class AddedUser : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Users",
+ columns: table => new
+ {
+ Id = table.Column(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ Username = table.Column(type: "TEXT", nullable: false),
+ Email = table.Column(type: "TEXT", nullable: false),
+ Password = table.Column(type: "TEXT", nullable: false),
+ Avatar = table.Column(type: "TEXT", nullable: true),
+ TotpKey = table.Column(type: "TEXT", nullable: true),
+ Flags = table.Column(type: "TEXT", nullable: false),
+ Permissions = table.Column(type: "INTEGER", nullable: false),
+ TokenValidTimestamp = table.Column(type: "TEXT", nullable: false),
+ CreatedAt = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Users", x => x.Id);
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Users");
+ }
+ }
+}
diff --git a/Moonlight/App/Database/Migrations/DataContextModelSnapshot.cs b/Moonlight/App/Database/Migrations/DataContextModelSnapshot.cs
new file mode 100644
index 00000000..d6b1eedb
--- /dev/null
+++ b/Moonlight/App/Database/Migrations/DataContextModelSnapshot.cs
@@ -0,0 +1,64 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Moonlight.App.Database;
+
+#nullable disable
+
+namespace Moonlight.App.Database.Migrations
+{
+ [DbContext(typeof(DataContext))]
+ partial class DataContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "7.0.2");
+
+ modelBuilder.Entity("Moonlight.App.Database.Entities.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Avatar")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Flags")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Password")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Permissions")
+ .HasColumnType("INTEGER");
+
+ b.Property("TokenValidTimestamp")
+ .HasColumnType("TEXT");
+
+ b.Property("TotpKey")
+ .HasColumnType("TEXT");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("Users");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Moonlight/App/Http/Controllers/Api/BucketController.cs b/Moonlight/App/Http/Controllers/Api/BucketController.cs
new file mode 100644
index 00000000..ea38470e
--- /dev/null
+++ b/Moonlight/App/Http/Controllers/Api/BucketController.cs
@@ -0,0 +1,37 @@
+using Microsoft.AspNetCore.Mvc;
+using Moonlight.App.Helpers;
+using Moonlight.App.Services;
+
+namespace Moonlight.App.Http.Controllers.Api;
+
+[ApiController]
+[Route("api/bucket")]
+public class BucketController : Controller
+{
+ private readonly BucketService BucketService;
+
+ public BucketController(BucketService bucketService)
+ {
+ BucketService = bucketService;
+ }
+
+ [HttpGet("{bucket}/{file}")]
+ public async Task Get([FromRoute] string bucket, [FromRoute] string file) // TODO: Implement auth
+ {
+ if (bucket.Contains("..") || file.Contains(".."))
+ {
+ Logger.Warn($"Detected path transversal attack ({Request.HttpContext.Connection.RemoteIpAddress}).", "security");
+ return NotFound();
+ }
+
+ try
+ {
+ var stream = await BucketService.Pull(bucket, file);
+ return File(stream, MimeTypes.GetMimeType(file));
+ }
+ catch (FileNotFoundException)
+ {
+ return NotFound();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Moonlight/App/Models/Forms/LoginForm.cs b/Moonlight/App/Models/Forms/LoginForm.cs
new file mode 100644
index 00000000..c9787a49
--- /dev/null
+++ b/Moonlight/App/Models/Forms/LoginForm.cs
@@ -0,0 +1,13 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Moonlight.App.Models.Forms;
+
+public class LoginForm
+{
+ [Required(ErrorMessage = "You need to provide an email address")]
+ [EmailAddress(ErrorMessage = "You need to enter a valid email address")]
+ public string Email { get; set; }
+
+ [Required(ErrorMessage = "You need to provide a password")]
+ public string Password { get; set; }
+}
\ No newline at end of file
diff --git a/Moonlight/App/Models/Forms/RegisterForm.cs b/Moonlight/App/Models/Forms/RegisterForm.cs
new file mode 100644
index 00000000..b870fc9e
--- /dev/null
+++ b/Moonlight/App/Models/Forms/RegisterForm.cs
@@ -0,0 +1,26 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Moonlight.App.Models.Forms;
+
+public class RegisterForm
+{
+ [Required(ErrorMessage = "You need to provide an username")]
+ [MinLength(7, ErrorMessage = "The username is too short")]
+ [MaxLength(20, ErrorMessage = "The username cannot be longer than 20 characters")]
+ [RegularExpression("^[a-z][a-z0-9]*$", ErrorMessage = "Usernames can only contain lowercase characters and numbers")]
+ public string Username { get; set; }
+
+ [Required(ErrorMessage = "You need to provide an email address")]
+ [EmailAddress(ErrorMessage = "You need to enter a valid email address")]
+ public string Email { get; set; }
+
+ [Required(ErrorMessage = "You need to provide a password")]
+ [MinLength(8, ErrorMessage = "The password must be at least 8 characters long")]
+ [MaxLength(256, ErrorMessage = "The password must not be longer than 256 characters")]
+ public string Password { get; set; }
+
+ [Required(ErrorMessage = "You need to provide a password")]
+ [MinLength(8, ErrorMessage = "The password must be at least 8 characters long")]
+ [MaxLength(256, ErrorMessage = "The password must not be longer than 256 characters")]
+ public string RepeatedPassword { get; set; }
+}
\ No newline at end of file
diff --git a/Moonlight/App/Models/Forms/ResetPasswordForm.cs b/Moonlight/App/Models/Forms/ResetPasswordForm.cs
new file mode 100644
index 00000000..1519d305
--- /dev/null
+++ b/Moonlight/App/Models/Forms/ResetPasswordForm.cs
@@ -0,0 +1,10 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Moonlight.App.Models.Forms;
+
+public class ResetPasswordForm
+{
+ [Required(ErrorMessage = "You need to specify an email address")]
+ [EmailAddress]
+ public string Email { get; set; } = "";
+}
\ No newline at end of file
diff --git a/Moonlight/App/Models/Forms/TwoFactorCodeForm.cs b/Moonlight/App/Models/Forms/TwoFactorCodeForm.cs
new file mode 100644
index 00000000..36e0c052
--- /dev/null
+++ b/Moonlight/App/Models/Forms/TwoFactorCodeForm.cs
@@ -0,0 +1,9 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Moonlight.App.Models.Forms;
+
+public class TwoFactorCodeForm
+{
+ [Required(ErrorMessage = "You need to enter a two factor code")]
+ public string Code { get; set; } = "";
+}
\ No newline at end of file
diff --git a/Moonlight/App/Models/Forms/UpdateAccountForm.cs b/Moonlight/App/Models/Forms/UpdateAccountForm.cs
new file mode 100644
index 00000000..77259944
--- /dev/null
+++ b/Moonlight/App/Models/Forms/UpdateAccountForm.cs
@@ -0,0 +1,16 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Moonlight.App.Models.Forms;
+
+public class UpdateAccountForm
+{
+ [Required(ErrorMessage = "You need to provide an username")]
+ [MinLength(7, ErrorMessage = "The username is too short")]
+ [MaxLength(20, ErrorMessage = "The username cannot be longer than 20 characters")]
+ [RegularExpression("^[a-z][a-z0-9]*$", ErrorMessage = "Usernames can only contain lowercase characters and numbers")]
+ public string Username { get; set; }
+
+ [Required(ErrorMessage = "You need to provide an email address")]
+ [EmailAddress(ErrorMessage = "You need to enter a valid email address")]
+ public string Email { get; set; }
+}
\ No newline at end of file
diff --git a/Moonlight/App/Models/Forms/UpdateAccountPasswordForm.cs b/Moonlight/App/Models/Forms/UpdateAccountPasswordForm.cs
new file mode 100644
index 00000000..49f3e1be
--- /dev/null
+++ b/Moonlight/App/Models/Forms/UpdateAccountPasswordForm.cs
@@ -0,0 +1,16 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Moonlight.App.Models.Forms;
+
+public class UpdateAccountPasswordForm
+{
+ [Required(ErrorMessage = "You need to specify a password")]
+ [MinLength(8, ErrorMessage = "The password must be at least 8 characters long")]
+ [MaxLength(256, ErrorMessage = "The password must not be longer than 256 characters")]
+ public string Password { get; set; } = "";
+
+ [Required(ErrorMessage = "You need to repeat your new password")]
+ [MinLength(8, ErrorMessage = "The password must be at least 8 characters long")]
+ [MaxLength(256, ErrorMessage = "The password must not be longer than 256 characters")]
+ public string RepeatedPassword { get; set; } = "";
+}
\ No newline at end of file
diff --git a/Moonlight/App/Models/Forms/UpdateUserForm.cs b/Moonlight/App/Models/Forms/UpdateUserForm.cs
new file mode 100644
index 00000000..90754264
--- /dev/null
+++ b/Moonlight/App/Models/Forms/UpdateUserForm.cs
@@ -0,0 +1,16 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Moonlight.App.Models.Forms;
+
+public class UpdateUserForm
+{
+ [Required(ErrorMessage = "You need to enter a username")]
+ [MinLength(7, ErrorMessage = "The username is too short")]
+ [MaxLength(20, ErrorMessage = "The username cannot be longer than 20 characters")]
+ [RegularExpression("^[a-z][a-z0-9]*$", ErrorMessage = "Usernames can only contain lowercase characters and numbers")]
+ public string Username { get; set; } = "";
+
+ [Required(ErrorMessage = "You need to enter a email address")]
+ [EmailAddress(ErrorMessage = "You need to enter a valid email address")]
+ public string Email { get; set; } = "";
+}
\ No newline at end of file
diff --git a/Moonlight/App/Models/Forms/UpdateUserPasswordForm.cs b/Moonlight/App/Models/Forms/UpdateUserPasswordForm.cs
new file mode 100644
index 00000000..8b6ae114
--- /dev/null
+++ b/Moonlight/App/Models/Forms/UpdateUserPasswordForm.cs
@@ -0,0 +1,11 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Moonlight.App.Models.Forms;
+
+public class UpdateUserPasswordForm
+{
+ [Required(ErrorMessage = "You need to specify a password")]
+ [MinLength(8, ErrorMessage = "The password must be at least 8 characters long")]
+ [MaxLength(256, ErrorMessage = "The password must not be longer than 256 characters")]
+ public string Password { get; set; } = "";
+}
\ No newline at end of file
diff --git a/Moonlight/App/Repositories/Repository.cs b/Moonlight/App/Repositories/Repository.cs
new file mode 100644
index 00000000..1c46f6ab
--- /dev/null
+++ b/Moonlight/App/Repositories/Repository.cs
@@ -0,0 +1,40 @@
+using Microsoft.EntityFrameworkCore;
+using Moonlight.App.Database;
+
+namespace Moonlight.App.Repositories;
+
+public class Repository where TEntity : class
+{
+ private readonly DataContext DataContext;
+ private readonly DbSet DbSet;
+
+ public Repository(DataContext dbContext)
+ {
+ DataContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
+ DbSet = DataContext.Set();
+ }
+
+ public DbSet Get()
+ {
+ return DbSet;
+ }
+
+ public TEntity Add(TEntity entity)
+ {
+ var x = DbSet.Add(entity);
+ DataContext.SaveChanges();
+ return x.Entity;
+ }
+
+ public void Update(TEntity entity)
+ {
+ DbSet.Update(entity);
+ DataContext.SaveChanges();
+ }
+
+ public void Delete(TEntity entity)
+ {
+ DbSet.Remove(entity);
+ DataContext.SaveChanges();
+ }
+}
\ No newline at end of file
diff --git a/Moonlight/App/Services/BucketService.cs b/Moonlight/App/Services/BucketService.cs
new file mode 100644
index 00000000..658ffa62
--- /dev/null
+++ b/Moonlight/App/Services/BucketService.cs
@@ -0,0 +1,66 @@
+using Moonlight.App.Helpers;
+
+namespace Moonlight.App.Services;
+
+public class BucketService
+{
+ private readonly string BasePath;
+ public string[] Buckets => GetBuckets();
+
+
+ public BucketService()
+ {
+ // This is used to create the buckets folder in the persistent storage of helio
+ BasePath = PathBuilder.Dir("storage", "buckets");
+ Directory.CreateDirectory(BasePath);
+ }
+
+ public string[] GetBuckets()
+ {
+ return Directory
+ .GetDirectories(BasePath)
+ .Select(x =>
+ x.Replace(BasePath, "").TrimEnd('/')
+ )
+ .ToArray();
+ }
+
+ public Task EnsureBucket(string name) // To ensure a specific bucket has been created, call this function
+ {
+ Directory.CreateDirectory(PathBuilder.Dir(BasePath, name));
+ return Task.CompletedTask;
+ }
+
+ public async Task Store(string bucket, Stream dataStream, string fileName)
+ {
+ await EnsureBucket(bucket); // Ensure the bucket actually exists
+
+ // Create a safe to file name to store the file
+ var extension = Path.GetExtension(fileName);
+ var finalFileName = Path.GetRandomFileName() + extension;
+ var finalFilePath = PathBuilder.File(BasePath, bucket, finalFileName);
+
+ // Copy the file from the remote stream to the bucket
+ var fs = File.Create(finalFilePath);
+ await dataStream.CopyToAsync(fs);
+ await fs.FlushAsync();
+ fs.Close();
+
+ // Return the generated file name to save it in the db or smth
+ return finalFileName;
+ }
+
+ public Task Pull(string bucket, string file)
+ {
+ var filePath = PathBuilder.File(BasePath, bucket, file);
+
+ if (File.Exists(filePath))
+ {
+ var stream = File.Open(filePath, FileMode.Open);
+
+ return Task.FromResult(stream);
+ }
+ else
+ throw new FileNotFoundException();
+ }
+}
\ No newline at end of file
diff --git a/Moonlight/App/Services/IdentityService.cs b/Moonlight/App/Services/IdentityService.cs
new file mode 100644
index 00000000..a3339193
--- /dev/null
+++ b/Moonlight/App/Services/IdentityService.cs
@@ -0,0 +1,170 @@
+using Moonlight.App.Database.Entities;
+using Moonlight.App.Exceptions;
+using Moonlight.App.Helpers;
+using Moonlight.App.Models.Abstractions;
+using Moonlight.App.Models.Enums;
+using Moonlight.App.Repositories;
+using Moonlight.App.Services.Utils;
+using OtpNet;
+
+namespace Moonlight.App.Services;
+
+// This service allows you to reauthenticate, login and force login
+// It does also contain the permission system accessor for the current user
+public class IdentityService
+{
+ private readonly Repository UserRepository;
+ private readonly JwtService JwtService;
+
+ private string Token;
+
+ public User? CurrentUserNullable { get; private set; }
+ public User CurrentUser => CurrentUserNullable!;
+ public bool IsSignedIn => CurrentUserNullable != null;
+ public FlagStorage Flags { get; private set; } = new("");
+ public PermissionStorage Permissions { get; private set; } = new(-1);
+ public EventHandler OnAuthenticationStateChanged { get; set; }
+
+ public IdentityService(Repository userRepository,
+ JwtService jwtService)
+ {
+ UserRepository = userRepository;
+ JwtService = jwtService;
+ }
+
+ // Authentication
+
+ public async Task Authenticate() // Reauthenticate
+ {
+ // Save the last id (or -1 if not set) so we can track a change
+ var lastUserId = CurrentUserNullable == null ? -1 : CurrentUserNullable.Id;
+
+ // Reset
+ CurrentUserNullable = null;
+
+ await ValidateToken();
+
+ // Get current user id to compare against the last one
+ var currentUserId = CurrentUserNullable == null ? -1 : CurrentUserNullable.Id;
+
+ if (lastUserId != currentUserId) // State changed, lets notify all event listeners
+ OnAuthenticationStateChanged?.Invoke(this, null!);
+ }
+
+ private async Task ValidateToken() // Read and validate token
+ {
+ if (string.IsNullOrEmpty(Token))
+ return;
+
+ if (!await JwtService.Validate(Token))
+ return;
+
+ var data = await JwtService.Decode(Token);
+
+ if (!data.ContainsKey("userId"))
+ return;
+
+ var userId = int.Parse(data["userId"]);
+
+ var user = UserRepository
+ .Get()
+ .FirstOrDefault(x => x.Id == userId);
+
+ if (user == null)
+ return;
+
+ if (!data.ContainsKey("issuedAt"))
+ return;
+
+ var issuedAt = long.Parse(data["issuedAt"]);
+ var issuedAtDateTime = DateTimeOffset.FromUnixTimeSeconds(issuedAt).DateTime;
+
+ // If the valid time is newer then when the token was issued, the token is not longer valid
+ if (user.TokenValidTimestamp > issuedAtDateTime)
+ return;
+
+ CurrentUserNullable = user;
+
+ if (CurrentUserNullable == null) // If the current user is null, stop loading additional data
+ return;
+
+ Flags = new(CurrentUser.Flags);
+ Permissions = new(CurrentUser.Permissions);
+ }
+
+ public async Task Login(string email, string password, string? code = null)
+ {
+ var user = UserRepository
+ .Get()
+ .FirstOrDefault(x => x.Email == email);
+
+ if (user == null)
+ throw new DisplayException("A user with these credential combination was not found");
+
+ if (!HashHelper.Verify(password, user.Password))
+ throw new DisplayException("A user with these credential combination was not found");
+
+ var flags = new FlagStorage(user.Flags); // Construct FlagStorage to check for 2fa
+
+ if (!flags[UserFlag.TotpEnabled]) // No 2fa found on this user so were done here
+ return await GenerateToken(user);
+
+ // If we reach this point, 2fa is enabled so we need to continue validating
+
+ if (string.IsNullOrEmpty(code)) // This will show an additional 2fa login field
+ throw new ArgumentNullException(nameof(code), "2FA code missing");
+
+ if (user.TotpKey == null) // Hopefully we will never fulfill this check ;)
+ throw new DisplayException("2FA key is missing. Please contact the support to fix your account");
+
+ // Calculate server side code
+ var totp = new Totp(Base32Encoding.ToBytes(user.TotpKey));
+ var codeServerSide = totp.ComputeTotp();
+
+ if (codeServerSide == code)
+ return await GenerateToken(user);
+
+ throw new DisplayException("Invalid 2fa code entered");
+ }
+
+ public async Task GenerateToken(User user)
+ {
+ var token = await JwtService.Create(data =>
+ {
+ data.Add("userId", user.Id.ToString());
+ data.Add("issuedAt", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString());
+ }, TimeSpan.FromDays(10));
+
+ return token;
+ }
+
+ public Task SaveFlags()
+ {
+ // Prevent saving flags for an empty user
+ if (!IsSignedIn)
+ return Task.CompletedTask;
+
+ // Save the new flag string
+ CurrentUser.Flags = Flags.RawFlagString;
+ UserRepository.Update(CurrentUser);
+
+ return Task.CompletedTask;
+ }
+
+ // Helpers and overloads
+ public async Task
+ Authenticate(HttpRequest request) // Overload for api controllers to authenticate a user like the normal panel
+ {
+ if (request.Cookies.ContainsKey("token"))
+ {
+ var token = request.Cookies["token"];
+ await Authenticate(token!);
+ }
+ }
+
+ public async Task Authenticate(string token) // Overload to set token and reauth
+ {
+ Token = token;
+ await Authenticate();
+ }
+}
\ No newline at end of file
diff --git a/Moonlight/App/Services/Interop/CookieService.cs b/Moonlight/App/Services/Interop/CookieService.cs
new file mode 100644
index 00000000..d8bf3a38
--- /dev/null
+++ b/Moonlight/App/Services/Interop/CookieService.cs
@@ -0,0 +1,61 @@
+using Microsoft.JSInterop;
+
+namespace Moonlight.App.Services.Interop;
+
+public class CookieService
+{
+ private readonly IJSRuntime JsRuntime;
+ private string Expires = "";
+
+ public CookieService(IJSRuntime jsRuntime)
+ {
+ JsRuntime = jsRuntime;
+ ExpireDays = 300;
+ }
+
+ public async Task SetValue(string key, string value, int? days = null)
+ {
+ var curExp = (days != null) ? (days > 0 ? DateToUTC(days.Value) : "") : Expires;
+ await SetCookie($"{key}={value}; expires={curExp}; path=/");
+ }
+
+ public async Task GetValue(string key, string def = "")
+ {
+ var cookieString = await GetCookie();
+
+ var cookieParts = cookieString.Split(";");
+
+ foreach (var cookiePart in cookieParts)
+ {
+ if(string.IsNullOrEmpty(cookiePart))
+ continue;
+
+ var cookieKeyValue = cookiePart.Split("=");
+
+ if (cookieKeyValue.Length == 2)
+ {
+ if (cookieKeyValue[0] == key)
+ return cookieKeyValue[1];
+ }
+ }
+
+ return def;
+ }
+
+ private async Task SetCookie(string value)
+ {
+ await JsRuntime.InvokeVoidAsync("eval", $"document.cookie = \"{value}\"");
+ }
+
+ private async Task GetCookie()
+ {
+ return await JsRuntime.InvokeAsync("eval", $"document.cookie");
+ }
+
+ private int ExpireDays
+ {
+ set => Expires = DateToUTC(value);
+ }
+
+ private static string DateToUTC(int days) => DateTime.Now.AddDays(days).ToUniversalTime().ToString("R");
+}
\ No newline at end of file
diff --git a/Moonlight/App/Services/Interop/ToastService.cs b/Moonlight/App/Services/Interop/ToastService.cs
new file mode 100644
index 00000000..1128cc9d
--- /dev/null
+++ b/Moonlight/App/Services/Interop/ToastService.cs
@@ -0,0 +1,55 @@
+using Microsoft.JSInterop;
+
+namespace Moonlight.App.Services.Interop;
+
+public class ToastService
+{
+ private readonly IJSRuntime JsRuntime;
+
+ public ToastService(IJSRuntime jsRuntime)
+ {
+ JsRuntime = jsRuntime;
+ }
+
+ public async Task Success(string title, string message, int timeout = 5000)
+ {
+ await JsRuntime.InvokeVoidAsync("moonlight.toasts.success", title, message, timeout);
+ }
+
+ public async Task Info(string title, string message, int timeout = 5000)
+ {
+ await JsRuntime.InvokeVoidAsync("moonlight.toasts.info", title, message, timeout);
+ }
+
+ public async Task Danger(string title, string message, int timeout = 5000)
+ {
+ await JsRuntime.InvokeVoidAsync("moonlight.toasts.danger", title, message, timeout);
+ }
+
+ public async Task Warning(string title, string message, int timeout = 5000)
+ {
+ await JsRuntime.InvokeVoidAsync("moonlight.toasts.warning", title, message, timeout);
+ }
+
+ // Overloads
+
+ public async Task Success(string message, int timeout = 5000)
+ {
+ await Success("", message, timeout);
+ }
+
+ public async Task Info(string message, int timeout = 5000)
+ {
+ await Info("", message, timeout);
+ }
+
+ public async Task Danger(string message, int timeout = 5000)
+ {
+ await Danger("", message, timeout);
+ }
+
+ public async Task Warning(string message, int timeout = 5000)
+ {
+ await Warning("", message, timeout);
+ }
+}
\ No newline at end of file
diff --git a/Moonlight/App/Services/Users/UserAuthService.cs b/Moonlight/App/Services/Users/UserAuthService.cs
new file mode 100644
index 00000000..63071279
--- /dev/null
+++ b/Moonlight/App/Services/Users/UserAuthService.cs
@@ -0,0 +1,132 @@
+using Moonlight.App.Database.Entities;
+using Moonlight.App.Exceptions;
+using Moonlight.App.Helpers;
+using Moonlight.App.Models.Abstractions;
+using Moonlight.App.Models.Enums;
+using Moonlight.App.Repositories;
+using Moonlight.App.Services.Utils;
+using OtpNet;
+
+namespace Moonlight.App.Services.Users;
+
+public class UserAuthService
+{
+ private readonly Repository UserRepository;
+ //private readonly MailService MailService;
+ private readonly JwtService JwtService;
+ private readonly ConfigService ConfigService;
+
+ public UserAuthService(
+ Repository userRepository,
+ //MailService mailService,
+ JwtService jwtService,
+ ConfigService configService)
+ {
+ UserRepository = userRepository;
+ //MailService = mailService;
+ JwtService = jwtService;
+ ConfigService = configService;
+ }
+
+ public async Task Register(string username, string email, string password)
+ {
+ // Event though we have form validation i want to
+ // ensure that at least these basic formatting things are done
+ email = email.ToLower().Trim();
+ username = username.ToLower().Trim();
+
+ // Prevent duplication or username and/or email
+ if (UserRepository.Get().Any(x => x.Email == email))
+ throw new DisplayException("A user with that email does already exist");
+
+ if (UserRepository.Get().Any(x => x.Username == username))
+ throw new DisplayException("A user with that username does already exist");
+
+ var user = new User()
+ {
+ Username = username,
+ Email = email,
+ Password = HashHelper.HashToString(password)
+ };
+
+ var result = UserRepository.Add(user);
+/*
+ await MailService.Send(
+ result,
+ "Welcome {{User.Username}}",
+ "register",
+ result
+ );*/
+
+ return result;
+ }
+
+ public Task ChangePassword(User user, string newPassword)
+ {
+ user.Password = HashHelper.HashToString(newPassword);
+ user.TokenValidTimestamp = DateTime.UtcNow;
+ UserRepository.Update(user);
+
+ return Task.CompletedTask;
+ }
+
+ public Task SeedTotp(User user)
+ {
+ var key = Base32Encoding.ToString(KeyGeneration.GenerateRandomKey(20));
+
+ user.TotpKey = key;
+ UserRepository.Update(user);
+
+ return Task.CompletedTask;
+ }
+
+ public Task SetTotp(User user, bool state)
+ {
+ // Access to flags without identity service
+ var flags = new FlagStorage(user.Flags);
+ flags[UserFlag.TotpEnabled] = state;
+ user.Flags = flags.RawFlagString;
+
+ if (!state)
+ user.TotpKey = null;
+
+ UserRepository.Update(user);
+
+ return Task.CompletedTask;
+ }
+
+ // Mails
+
+ public async Task SendVerification(User user)
+ {
+ var jwt = await JwtService.Create(data =>
+ {
+ data.Add("mailToVerify", user.Email);
+ }, TimeSpan.FromMinutes(10));
+/*
+ await MailService.Send(user, "Verify your account", "verifyMail", user, new MailVerify()
+ {
+ Url = ConfigService.Get().AppUrl + "/api/verify?token=" + jwt
+ });*/
+ }
+
+ public async Task SendResetPassword(string email)
+ {
+ var user = UserRepository
+ .Get()
+ .FirstOrDefault(x => x.Email == email);
+
+ if (user == null)
+ throw new DisplayException("An account with that email was not found");
+
+ var jwt = await JwtService.Create(data =>
+ {
+ data.Add("accountToReset", user.Id.ToString());
+ });
+ /*
+ await MailService.Send(user, "Password reset for your account", "passwordReset", user, new ResetPassword()
+ {
+ Url = ConfigService.Get().AppUrl + "/api/reset?token=" + jwt
+ });*/
+ }
+}
\ No newline at end of file
diff --git a/Moonlight/App/Services/Users/UserDetailsService.cs b/Moonlight/App/Services/Users/UserDetailsService.cs
new file mode 100644
index 00000000..6fdf51f5
--- /dev/null
+++ b/Moonlight/App/Services/Users/UserDetailsService.cs
@@ -0,0 +1,32 @@
+using Moonlight.App.Database.Entities;
+using Moonlight.App.Repositories;
+
+namespace Moonlight.App.Services.Users;
+
+public class UserDetailsService
+{
+ private readonly BucketService BucketService;
+ private readonly Repository UserRepository;
+
+ public UserDetailsService(BucketService bucketService, Repository userRepository)
+ {
+ BucketService = bucketService;
+ UserRepository = userRepository;
+ }
+
+ public async Task UpdateAvatar(User user, Stream stream, string fileName)
+ {
+ var file = await BucketService.Store("avatars", stream, fileName);
+
+ user.Avatar = file;
+ UserRepository.Update(user);
+ }
+
+ public Task UpdateAvatar(User user) // Overload to reset avatar
+ {
+ user.Avatar = null;
+ UserRepository.Update(user);
+
+ return Task.CompletedTask;
+ }
+}
\ No newline at end of file
diff --git a/Moonlight/App/Services/Users/UserService.cs b/Moonlight/App/Services/Users/UserService.cs
new file mode 100644
index 00000000..21c907fe
--- /dev/null
+++ b/Moonlight/App/Services/Users/UserService.cs
@@ -0,0 +1,50 @@
+using Moonlight.App.Database.Entities;
+using Moonlight.App.Exceptions;
+using Moonlight.App.Repositories;
+
+namespace Moonlight.App.Services.Users;
+
+public class UserService
+{
+ private readonly Repository UserRepository;
+ private readonly IServiceProvider ServiceProvider;
+
+ public UserAuthService Auth => ServiceProvider.GetRequiredService();
+ public UserDetailsService Details => ServiceProvider.GetRequiredService();
+
+ public UserService(
+ Repository userRepository,
+ IServiceProvider serviceProvider)
+ {
+ UserRepository = userRepository;
+ ServiceProvider = serviceProvider;
+ }
+
+ public Task Update(User user, string username, string email)
+ {
+ // Event though we have form validation i want to
+ // ensure that at least these basic formatting things are done
+ email = email.ToLower().Trim();
+ username = username.ToLower().Trim();
+
+ // Prevent duplication or username and/or email
+ if (UserRepository.Get().Any(x => x.Email == email && x.Id != user.Id))
+ throw new DisplayException("A user with that email does already exist");
+
+ if (UserRepository.Get().Any(x => x.Username == username && x.Id != user.Id))
+ throw new DisplayException("A user with that username does already exist");
+
+ user.Username = username;
+ user.Email = email;
+
+ UserRepository.Update(user);
+
+ return Task.CompletedTask;
+ }
+
+ public Task Delete(User user)
+ {
+ UserRepository.Delete(user);
+ return Task.CompletedTask;
+ }
+}
\ No newline at end of file
diff --git a/Moonlight/App/Services/Utils/JwtService.cs b/Moonlight/App/Services/Utils/JwtService.cs
new file mode 100644
index 00000000..41125119
--- /dev/null
+++ b/Moonlight/App/Services/Utils/JwtService.cs
@@ -0,0 +1,73 @@
+using JWT.Algorithms;
+using JWT.Builder;
+using Newtonsoft.Json;
+
+namespace Moonlight.App.Services.Utils;
+
+public class JwtService
+{
+ private readonly ConfigService ConfigService;
+ private readonly TimeSpan DefaultDuration = TimeSpan.FromDays(365 * 10);
+
+ public JwtService(ConfigService configService)
+ {
+ ConfigService = configService;
+ }
+
+ public Task Create(Action> data, TimeSpan? validDuration = null)
+ {
+ var builder = new JwtBuilder()
+ .WithSecret(ConfigService.Get().Security.Token)
+ .IssuedAt(DateTime.UtcNow)
+ .ExpirationTime(DateTime.UtcNow.Add(validDuration ?? DefaultDuration))
+ .WithAlgorithm(new HMACSHA512Algorithm());
+
+ var dataDic = new Dictionary();
+ data.Invoke(dataDic);
+
+ foreach (var entry in dataDic)
+ builder = builder.AddClaim(entry.Key, entry.Value);
+
+ var jwt = builder.Encode();
+
+ return Task.FromResult(jwt);
+ }
+
+ public Task Validate(string token)
+ {
+ try
+ {
+ _ = new JwtBuilder()
+ .WithSecret(ConfigService.Get().Security.Token)
+ .WithAlgorithm(new HMACSHA512Algorithm())
+ .MustVerifySignature()
+ .Decode(token);
+
+ return Task.FromResult(true);
+ }
+ catch (Exception e)
+ {
+ return Task.FromResult(false);
+ }
+ }
+
+ public Task> Decode(string token)
+ {
+ try
+ {
+ var json = new JwtBuilder()
+ .WithSecret(ConfigService.Get().Security.Token)
+ .WithAlgorithm(new HMACSHA512Algorithm())
+ .MustVerifySignature()
+ .Decode(token);
+
+ var data = JsonConvert.DeserializeObject>(json);
+
+ return Task.FromResult(data)!;
+ }
+ catch (Exception)
+ {
+ return Task.FromResult>(null!);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Moonlight/Moonlight.csproj b/Moonlight/Moonlight.csproj
index 384b9e46..53c2c6eb 100644
--- a/Moonlight/Moonlight.csproj
+++ b/Moonlight/Moonlight.csproj
@@ -16,20 +16,27 @@
-
-
-
+
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
diff --git a/Moonlight/Pages/_Host.cshtml b/Moonlight/Pages/_Host.cshtml
index 3ed58712..3d90fdb4 100644
--- a/Moonlight/Pages/_Host.cshtml
+++ b/Moonlight/Pages/_Host.cshtml
@@ -30,8 +30,9 @@
-
-
+
+
+