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 @@ - - + + + \ No newline at end of file diff --git a/Moonlight/Program.cs b/Moonlight/Program.cs index 2c3db5b0..92c182d3 100644 --- a/Moonlight/Program.cs +++ b/Moonlight/Program.cs @@ -2,7 +2,11 @@ using Moonlight.App.Database; using Moonlight.App.Extensions; using Moonlight.App.Helpers; using Moonlight.App.Helpers.LogMigrator; +using Moonlight.App.Repositories; using Moonlight.App.Services; +using Moonlight.App.Services.Interop; +using Moonlight.App.Services.Users; +using Moonlight.App.Services.Utils; using Serilog; Directory.CreateDirectory(PathBuilder.Dir("storage")); @@ -21,8 +25,26 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddDbContext(); +// Repositories +builder.Services.AddScoped(typeof(Repository<>)); + +// Services / Utils +builder.Services.AddScoped(); + +// Services / Interop +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Services / Users +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Services +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddRazorPages(); builder.Services.AddServerSideBlazor(); diff --git a/Moonlight/Shared/Components/Auth/Login.razor b/Moonlight/Shared/Components/Auth/Login.razor new file mode 100644 index 00000000..508c3657 --- /dev/null +++ b/Moonlight/Shared/Components/Auth/Login.razor @@ -0,0 +1,80 @@ +@page "/login" +@* Virtual route to trick blazor *@ + +@using Moonlight.App.Services +@using Moonlight.App.Models.Forms + +@inject IdentityService IdentityService +@inject CookieService CookieService +@inject NavigationManager Navigation + +
+
+
+

+ Sign In +

+
+ Get unlimited access & earn money +
+
+ + +
+ +
+ +
+ +
+ + + +
+ +
+
Or
+ @* OAuth2 Providers here *@ +
+
+
+
+
+ +@code +{ + private LoginForm Form = new(); + + // 2FA + private bool Require2FA = false; + private string TwoFactorCode = ""; + + private async Task OnValidSubmit() + { + string token; + + try + { + token = await IdentityService.Login(Form.Email, Form.Password, TwoFactorCode); + } + catch (ArgumentNullException) // IdentityService requires two factor code => show field + { + Require2FA = true; + await InvokeAsync(StateHasChanged); + return; + } + + await CookieService.SetValue("token", token); + await IdentityService.Authenticate(token); + + if (Navigation.Uri.EndsWith("/login")) + Navigation.NavigateTo("/"); + } +} \ No newline at end of file diff --git a/Moonlight/Shared/Components/Auth/Register.razor b/Moonlight/Shared/Components/Auth/Register.razor new file mode 100644 index 00000000..9ce32d16 --- /dev/null +++ b/Moonlight/Shared/Components/Auth/Register.razor @@ -0,0 +1,78 @@ +@page "/register" +@* Virtual route to trick blazor *@ + +@using Moonlight.App.Services +@using Moonlight.App.Models.Forms +@using Moonlight.App.Services.Users +@using Moonlight.App.Exceptions + +@inject IdentityService IdentityService +@inject UserService UserService +@inject CookieService CookieService +@inject NavigationManager Navigation + +
+
+
+

+ Sign Up +

+
+ Get unlimited access & earn money +
+
+ + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + + +
+ +
+
Or
+ @* OAuth2 Providers here *@ +
+
+
+
+
+ +@code +{ + private RegisterForm Form = new(); + + private async Task OnValidSubmit() + { + if (Form.Password != Form.RepeatedPassword) + throw new DisplayException("The passwords do not match"); + + var user = await UserService.Auth.Register(Form.Username, Form.Email, Form.Password); + var token = await IdentityService.GenerateToken(user); + + await CookieService.SetValue("token", token); + await IdentityService.Authenticate(token); + + if (Navigation.Uri.EndsWith("/register")) + Navigation.NavigateTo("/"); + } +} \ No newline at end of file diff --git a/Moonlight/Shared/Components/Forms/SmartFileSelect.razor b/Moonlight/Shared/Components/Forms/SmartFileSelect.razor new file mode 100644 index 00000000..e5877914 --- /dev/null +++ b/Moonlight/Shared/Components/Forms/SmartFileSelect.razor @@ -0,0 +1,57 @@ +@using Microsoft.AspNetCore.Components.Forms + +@inject ToastService ToastService + +