Implemented basic user auth, register, login, details and avatar stuff from helio

This commit is contained in:
Marcel Baumgartner 2023-10-15 19:19:47 +02:00
parent 3bb4e7daab
commit 49c893f515
41 changed files with 2079 additions and 212 deletions

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EfCoreCommonOptions">
<option name="migrationsToStartupProjects">
<map>
<entry key="691e5ec2-4b4f-4bd1-9cbc-7d3c6efc12da" value="691e5ec2-4b4f-4bd1-9cbc-7d3c6efc12da" />
</map>
</option>
<option name="startupToMigrationsProjects">
<map>
<entry key="691e5ec2-4b4f-4bd1-9cbc-7d3c6efc12da" value="691e5ec2-4b4f-4bd1-9cbc-7d3c6efc12da" />
</map>
</option>
</component>
</project>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EfCoreDialogsState">
<option name="keyValueStorage">
<map>
<entry key="Common:691e5ec2-4b4f-4bd1-9cbc-7d3c6efc12da:dbContext" value="Moonlight.App.Database.DataContext" />
<entry key="Common:buildConfiguration" value="Debug" />
<entry key="Common:noBuild" value="false" />
<entry key="Common:outputFolder" value="App/Database/Migrations" />
</map>
</option>
</component>
</project>

View file

@ -0,0 +1,67 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Avatar")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Flags")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Permissions")
.HasColumnType("INTEGER");
b.Property<DateTime>("TokenValidTimestamp")
.HasColumnType("TEXT");
b.Property<string>("TotpKey")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Users");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,43 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Moonlight.App.Database.Migrations
{
/// <inheritdoc />
public partial class AddedUser : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Username = table.Column<string>(type: "TEXT", nullable: false),
Email = table.Column<string>(type: "TEXT", nullable: false),
Password = table.Column<string>(type: "TEXT", nullable: false),
Avatar = table.Column<string>(type: "TEXT", nullable: true),
TotpKey = table.Column<string>(type: "TEXT", nullable: true),
Flags = table.Column<string>(type: "TEXT", nullable: false),
Permissions = table.Column<int>(type: "INTEGER", nullable: false),
TokenValidTimestamp = table.Column<DateTime>(type: "TEXT", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Users");
}
}
}

View file

@ -0,0 +1,64 @@
// <auto-generated />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Avatar")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Flags")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Permissions")
.HasColumnType("INTEGER");
b.Property<DateTime>("TokenValidTimestamp")
.HasColumnType("TEXT");
b.Property<string>("TotpKey")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Users");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -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<ActionResult> 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();
}
}
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -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; } = "";
}

View file

@ -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; } = "";
}

View file

@ -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; }
}

View file

@ -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; } = "";
}

View file

@ -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; } = "";
}

View file

@ -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; } = "";
}

View file

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore;
using Moonlight.App.Database;
namespace Moonlight.App.Repositories;
public class Repository<TEntity> where TEntity : class
{
private readonly DataContext DataContext;
private readonly DbSet<TEntity> DbSet;
public Repository(DataContext dbContext)
{
DataContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
DbSet = DataContext.Set<TEntity>();
}
public DbSet<TEntity> 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();
}
}

View file

@ -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<string> 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<Stream> 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>(stream);
}
else
throw new FileNotFoundException();
}
}

View file

@ -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<User> 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<User> 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<string> 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<string> 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();
}
}

View file

@ -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<string> 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<string> GetCookie()
{
return await JsRuntime.InvokeAsync<string>("eval", $"document.cookie");
}
private int ExpireDays
{
set => Expires = DateToUTC(value);
}
private static string DateToUTC(int days) => DateTime.Now.AddDays(days).ToUniversalTime().ToString("R");
}

View file

@ -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);
}
}

View file

@ -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<User> UserRepository;
//private readonly MailService MailService;
private readonly JwtService JwtService;
private readonly ConfigService ConfigService;
public UserAuthService(
Repository<User> userRepository,
//MailService mailService,
JwtService jwtService,
ConfigService configService)
{
UserRepository = userRepository;
//MailService = mailService;
JwtService = jwtService;
ConfigService = configService;
}
public async Task<User> 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
});*/
}
}

View file

@ -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<User> UserRepository;
public UserDetailsService(BucketService bucketService, Repository<User> 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;
}
}

View file

@ -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<User> UserRepository;
private readonly IServiceProvider ServiceProvider;
public UserAuthService Auth => ServiceProvider.GetRequiredService<UserAuthService>();
public UserDetailsService Details => ServiceProvider.GetRequiredService<UserDetailsService>();
public UserService(
Repository<User> 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;
}
}

View file

@ -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<string> Create(Action<Dictionary<string, string>> 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<string, string>();
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<bool> 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<Dictionary<string, string>> Decode(string token)
{
try
{
var json = new JwtBuilder()
.WithSecret(ConfigService.Get().Security.Token)
.WithAlgorithm(new HMACSHA512Algorithm())
.MustVerifySignature()
.Decode(token);
var data = JsonConvert.DeserializeObject<Dictionary<string, string>>(json);
return Task.FromResult(data)!;
}
catch (Exception)
{
return Task.FromResult<Dictionary<string, string>>(null!);
}
}
}

View file

@ -16,20 +16,27 @@
<ItemGroup>
<Folder Include="App\Database\Enums\" />
<Folder Include="App\Database\Migrations\" />
<Folder Include="App\Http\" />
<Folder Include="App\Models\Forms\" />
<Folder Include="App\Repositories\" />
<Folder Include="App\Http\Middleware\" />
<Folder Include="App\Http\Requests\" />
<Folder Include="App\Http\Resources\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Ben.Demystifier" Version="0.4.1" />
<PackageReference Include="JWT" Version="10.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.0" />
<PackageReference Include="MimeTypes" Version="2.4.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Otp.NET" Version="1.3.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="7.0.0" />
<PackageReference Include="QRCoder" Version="1.4.3" />
<PackageReference Include="Serilog" Version="3.1.0-dev-02078" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.0-dev-00923" />
</ItemGroup>

View file

@ -30,8 +30,9 @@
<component type="typeof(BlazorApp)" render-mode="ServerPrerendered"/>
<script src="/_framework/blazor.server.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
<script src="/js/toaster.js"></script>
<script src="/js/moonlight.js"></script>
<script src="/_framework/blazor.server.js"></script>
</body>
</html>

View file

@ -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<DataContext>();
// Repositories
builder.Services.AddScoped(typeof(Repository<>));
// Services / Utils
builder.Services.AddScoped<JwtService>();
// Services / Interop
builder.Services.AddScoped<CookieService>();
builder.Services.AddScoped<ToastService>();
// Services / Users
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<UserAuthService>();
builder.Services.AddScoped<UserDetailsService>();
// Services
builder.Services.AddScoped<IdentityService>();
builder.Services.AddSingleton<ConfigService>();
builder.Services.AddSingleton<SessionService>();
builder.Services.AddSingleton<BucketService>();
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();

View file

@ -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
<div class="w-100">
<div class="card-body">
<div class="text-start mb-8">
<h1 class="text-dark mb-3 fs-3x">
Sign In
</h1>
<div class="text-gray-400 fw-semibold fs-6">
Get unlimited access &amp; earn money
</div>
</div>
<SmartForm Model="Form" OnValidSubmit="OnValidSubmit">
<div class="fv-row mb-8">
<input @bind="Form.Email" type="text" placeholder="Email" class="form-control form-control-solid">
</div>
<div class="fv-row mb-7">
<input @bind="Form.Password" type="password" placeholder="Password" class="form-control form-control-solid">
</div>
<div class="d-flex flex-stack flex-wrap gap-3 fs-base fw-semibold mb-10">
<a href="/reset-password" class="link-primary">
Forgot Password ?
</a>
<a href="/register" class="link-primary">
Need an account ?
</a>
</div>
<div class="d-flex flex-stack">
<button type="submit" class="btn btn-primary me-2 flex-shrink-0">Sign In</button>
<div class="d-flex align-items-center">
<div class="text-gray-400 fw-semibold fs-6 me-3 me-md-6">Or</div>
@* OAuth2 Providers here *@
</div>
</div>
</SmartForm>
</div>
</div>
@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("/");
}
}

View file

@ -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
<div class="w-100">
<div class="card-body">
<div class="text-start mb-8">
<h1 class="text-dark mb-3 fs-3x">
Sign Up
</h1>
<div class="text-gray-400 fw-semibold fs-6">
Get unlimited access &amp; earn money
</div>
</div>
<SmartForm Model="Form" OnValidSubmit="OnValidSubmit">
<div class="fv-row mb-8">
<input @bind="Form.Username" type="text" placeholder="Username" class="form-control form-control-solid">
</div>
<div class="fv-row mb-8">
<input @bind="Form.Email" type="text" placeholder="Email" class="form-control form-control-solid">
</div>
<div class="fv-row mb-7">
<input @bind="Form.Password" type="password" placeholder="Password" class="form-control form-control-solid">
</div>
<div class="fv-row mb-7">
<input @bind="Form.RepeatedPassword" type="password" placeholder="Repeat your password" class="form-control form-control-solid">
</div>
<div class="d-flex flex-stack flex-wrap gap-3 fs-base fw-semibold mb-10">
<div></div>
<a href="/login" class="link-primary">
Already have an account ?
</a>
</div>
<div class="d-flex flex-stack">
<button type="submit" class="btn btn-primary me-2 flex-shrink-0">Sign Up</button>
<div class="d-flex align-items-center">
<div class="text-gray-400 fw-semibold fs-6 me-3 me-md-6">Or</div>
@* OAuth2 Providers here *@
</div>
</div>
</SmartForm>
</div>
</div>
@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("/");
}
}

View file

@ -0,0 +1,57 @@
@using Microsoft.AspNetCore.Components.Forms
@inject ToastService ToastService
<InputFile OnChange="OnFileChanged" type="file" id="fileUpload" hidden=""/>
<label for="fileUpload" class="">
@if (SelectedFile != null)
{
<div class="input-group">
<input type="text" class="form-control disabled" value="@(SelectedFile.Name)" disabled="">
<button class="btn btn-danger" type="button" @onclick="RemoveSelection">
Remove
</button>
</div>
}
else
{
<div class="btn btn-primary me-3 btn-icon">
<i class="bx bx-upload"></i>
</div>
}
</label>
@code
{
[Parameter]
public IBrowserFile? SelectedFile { get; set; }
[Parameter]
public int MaxFileSize { get; set; } = 1024 * 1024 * 5;
private async Task OnFileChanged(InputFileChangeEventArgs arg)
{
if (arg.FileCount > 0)
{
if (arg.File.Size < 1024 * 1024 * 5)
{
SelectedFile = arg.File;
await InvokeAsync(StateHasChanged);
return;
}
await ToastService.Danger("The uploaded file should not be bigger than 5MB");
}
SelectedFile = null;
await InvokeAsync(StateHasChanged);
}
public async Task RemoveSelection()
{
SelectedFile = null;
await InvokeAsync(StateHasChanged);
}
}

View file

@ -0,0 +1,132 @@
@using Microsoft.AspNetCore.Components.Forms
@using Moonlight.App.Exceptions
<div class="form @CssClass">
<EditForm @ref="EditForm" Model="Model" OnValidSubmit="ValidSubmit" OnInvalidSubmit="InvalidSubmit">
<DataAnnotationsValidator></DataAnnotationsValidator>
@if (Working)
{
<div class="d-flex flex-center flex-column">
<span class="fs-1 spinner-border spinner-border-lg align-middle me-2"></span>
<span class="mt-3 fs-5">Proccessing</span>
</div>
}
else
{
if (ErrorMessages.Any())
{
<div class="alert alert-danger bg-danger text-white p-10 mb-3">
@foreach (var msg in ErrorMessages)
{
<TL>@(msg)</TL>
<br/>
}
</div>
}
@(ChildContent)
}
</EditForm>
</div>
@code
{
[Parameter]
public object Model { get; set; }
[Parameter]
public Func<Task>? OnValidSubmit { get; set; }
[Parameter]
public Func<Task>? OnInvalidSubmit { get; set; }
[Parameter]
public Func<Task>? OnSubmit { get; set; }
[Parameter]
public RenderFragment ChildContent { get; set; }
[Parameter]
public string CssClass { get; set; }
private EditForm EditForm;
private List<string> ErrorMessages = new();
private bool Working = false;
protected override void OnAfterRender(bool firstRender)
{
if (firstRender)
{
//EditForm.EditContext!.SetFieldCssClassProvider(new FieldCssHelper());
}
}
private async Task ValidSubmit(EditContext context)
{
ErrorMessages.Clear();
Working = true;
await InvokeAsync(StateHasChanged);
await Task.Run(async () =>
{
await InvokeAsync(async () =>
{
try
{
if (OnValidSubmit != null)
await OnValidSubmit.Invoke();
if (OnSubmit != null)
await OnSubmit.Invoke();
}
catch (Exception e)
{
if (e is DisplayException displayException)
{
ErrorMessages.Add(displayException.Message);
}
else
throw e;
}
});
Working = false;
await InvokeAsync(StateHasChanged);
});
}
private async Task InvalidSubmit(EditContext context)
{
ErrorMessages.Clear();
context.Validate();
foreach (var message in context.GetValidationMessages())
{
ErrorMessages.Add(message);
}
await InvokeAsync(StateHasChanged);
try
{
if (OnInvalidSubmit != null)
await OnInvalidSubmit.Invoke();
if (OnSubmit != null)
await OnSubmit.Invoke();
}
catch (Exception e)
{
if (e is DisplayException displayException)
{
ErrorMessages.Add(displayException.Message);
}
else
throw e;
}
await InvokeAsync(StateHasChanged);
}
}

View file

@ -0,0 +1,22 @@
<div class="card mb-5 mb-xl-10">
<div class="card-body pt-0 pb-0">
<ul class="nav nav-stretch nav-line-tabs nav-line-tabs-2x border-transparent fs-5 fw-bold">
<li class="nav-item mt-2">
<a class="nav-link text-active-primary ms-0 me-10 py-5 @(Index == 0 ? "active" : "")" href="/account">
General
</a>
</li>
<li class="nav-item mt-2">
<a class="nav-link text-active-primary ms-0 me-10 py-5 @(Index == 1 ? "active" : "")" href="/account/security">
Security
</a>
</li>
</ul>
</div>
</div>
@code
{
[Parameter]
public int Index { get; set; }
}

View file

@ -1,213 +1,70 @@
@using Moonlight.Shared.Layouts
<div id="kt_app_header" class="app-header ">
<div class="app-container container-fluid d-flex align-items-stretch flex-stack " id="kt_app_header_container">
<div class="d-flex align-items-center d-block d-lg-none ms-n3" title="Show sidebar menu">
<a href="#" @onclick="Layout.ToggleMobileSidebar" @onclick:preventDefault class="btn btn-icon btn-active-color-primary w-35px h-35px me-2">
<i class="bx bx-sm bx-menu"></i>
</a>
<a href="/metronic8/demo38/../demo38/index.html">
<img alt="Logo" src="/metronic8/demo38/assets/media/logos/demo38-small.svg" class="h-30px">
</a>
</div>
<div class="app-navbar flex-lg-grow-1" id="kt_app_header_navbar">
<div class="app-navbar-item d-flex align-items-stretch flex-lg-grow-1">
</div>
<div class="app-navbar-item ms-1 ms-md-3">
<ConnectionIndicator />
</div>
<div class="app-navbar-item ms-5 ms-md-5">
<div class="cursor-pointer symbol symbol-circle symbol-35px symbol-md-45px" id="dropdownMenuLink" data-bs-toggle="dropdown">
<img src="https://endelon-hosting.de/assets/img/logo.svg" alt="user">
@using Moonlight.App.Services
@inject IdentityService IdentityService
@inject CookieService CookieService
<div class="app-header">
<div class="app-container container-fluid d-flex align-items-stretch flex-stack">
<div class="d-flex align-items-center d-block d-lg-none ms-n3" title="Show sidebar menu">
<a href="#" @onclick="Layout.ToggleMobileSidebar" @onclick:preventDefault class="btn btn-icon btn-active-color-primary w-35px h-35px me-2">
<i class="bx bx-sm bx-menu"></i>
</a>
<a href="/metronic8/demo38/../demo38/index.html">
<img alt="Logo" src="/metronic8/demo38/assets/media/logos/demo38-small.svg" class="h-30px">
</a>
</div>
<div class="dropdown-menu dropdown-menu-end menu menu-sub menu-sub-dropdown menu-column menu-rounded menu-gray-800 menu-state-bg menu-state-color fw-semibold py-4 fs-6 w-275px" aria-labelledby="dropdownMenuLink">
<div class="menu-item px-3">
<div class="menu-content d-flex align-items-center px-3">
<div class="symbol symbol-50px me-5">
<img alt="Logo" src="/metronic8/demo38/assets/media/avatars/300-2.jpg">
</div>
<div class="d-flex flex-column">
<div class="fw-bold d-flex align-items-center fs-5">
Max Smith <span class="badge badge-light-success fw-bold fs-8 px-2 py-1 ms-2">Pro</span>
</div>
<a href="#" class="fw-semibold text-muted text-hover-primary fs-7">
max@kt.com
</a>
</div>
<div class="app-navbar flex-lg-grow-1">
<div class="app-navbar-item d-flex align-items-stretch flex-lg-grow-1">
</div>
<div class="app-navbar-item ms-1 ms-md-3">
<ConnectionIndicator/>
</div>
<div class="app-navbar-item ms-5 ms-md-5">
<div class="cursor-pointer symbol symbol-circle symbol-35px symbol-md-45px" id="dropdownMenuLink" data-bs-toggle="dropdown">
@if (IdentityService.CurrentUser.Avatar == null)
{
<img src="/assets/img/avatar.png" alt="Avatar">
}
else
{
<img src="/api/bucket/avatars/@(IdentityService.CurrentUser.Avatar)" alt="Avatar">
}
</div>
</div>
<div class="separator my-2"></div>
<div class="menu-item px-5">
<a href="/metronic8/demo38/../demo38/account/overview.html" class="menu-link px-5">
My Profile
</a>
</div>
<div class="menu-item px-5">
<a href="/metronic8/demo38/../demo38/apps/projects/list.html" class="menu-link px-5">
<span class="menu-text">My Projects</span>
<span class="menu-badge">
<span class="badge badge-light-danger badge-circle fw-bold fs-7">3</span>
</span>
</a>
</div>
<div class="menu-item px-5" data-kt-menu-trigger="{default: 'click', lg: 'hover'}" data-kt-menu-placement="left-start" data-kt-menu-offset="-15px, 0">
<a href="#" class="menu-link px-5">
<span class="menu-title">My Subscription</span>
<span class="menu-arrow"></span>
</a>
<div class="menu-sub menu-sub-dropdown w-175px py-4">
<div class="dropdown-menu dropdown-menu-end menu menu-sub menu-sub-dropdown menu-column menu-rounded menu-gray-800 menu-state-bg menu-state-color fw-semibold py-4 fs-6 w-275px" aria-labelledby="dropdownMenuLink">
<div class="menu-item px-3">
<a href="/metronic8/demo38/../demo38/account/referrals.html" class="menu-link px-5">
Referrals
</a>
<div class="menu-content d-flex align-items-center px-3">
<div class="symbol symbol-50px me-5">
<img alt="Logo" src="/metronic8/demo38/assets/media/avatars/300-2.jpg">
</div>
<div class="d-flex flex-column">
<div class="fw-bold d-flex align-items-center fs-5">
@(IdentityService.CurrentUser.Username)
</div>
<span class="fw-semibold text-muted fs-7">
@(IdentityService.CurrentUser.Email)
</span>
</div>
</div>
</div>
<div class="menu-item px-3">
<a href="/metronic8/demo38/../demo38/account/billing.html" class="menu-link px-5">
Billing
</a>
</div>
<div class="menu-item px-3">
<a href="/metronic8/demo38/../demo38/account/statements.html" class="menu-link px-5">
Payments
</a>
</div>
<div class="menu-item px-3">
<a href="/metronic8/demo38/../demo38/account/statements.html" class="menu-link d-flex flex-stack px-5">
Statements
<span class="ms-2 lh-0" data-bs-toggle="tooltip" aria-label="View your statements" data-bs-original-title="View your statements" data-kt-initialized="1">
<i class="ki-outline ki-information-5 fs-5"></i>
</span>
<div class="separator my-2"></div>
<div class="menu-item px-5">
<a href="/account" class="menu-link px-5">
Account
</a>
</div>
<div class="separator my-2"></div>
<div class="menu-item px-3">
<div class="menu-content px-3">
<label class="form-check form-switch form-check-custom form-check-solid">
<input class="form-check-input w-30px h-20px" type="checkbox" value="1" checked="checked" name="notifications">
<span class="form-check-label text-muted fs-7">
Notifications
</span>
</label>
</div>
</div>
</div>
</div>
<div class="menu-item px-5">
<a href="/metronic8/demo38/../demo38/account/statements.html" class="menu-link px-5">
My Statements
</a>
</div>
<div class="separator my-2"></div>
<div class="menu-item px-5" data-kt-menu-trigger="{default: 'click', lg: 'hover'}" data-kt-menu-placement="left-start" data-kt-menu-offset="-15px, 0">
<a href="#" class="menu-link px-5">
<span class="menu-title position-relative">
Mode
<span class="ms-5 position-absolute translate-middle-y top-50 end-0">
<i class="ki-outline ki-night-day theme-light-show fs-2"></i> <i class="ki-outline ki-moon theme-dark-show fs-2"></i>
</span>
</span>
</a>
<div class="menu menu-sub menu-sub-dropdown menu-column menu-rounded menu-title-gray-700 menu-icon-gray-500 menu-active-bg menu-state-color fw-semibold py-4 fs-base w-150px" data-kt-menu="true" data-kt-element="theme-mode-menu">
<div class="menu-item px-3 my-0">
<a href="#" class="menu-link px-3 py-2" data-kt-element="mode" data-kt-value="light">
<span class="menu-icon" data-kt-element="icon">
<i class="ki-outline ki-night-day fs-2"></i>
</span>
<span class="menu-title">
Light
</span>
</a>
</div>
<div class="menu-item px-3 my-0">
<a href="#" class="menu-link px-3 py-2 active" data-kt-element="mode" data-kt-value="dark">
<span class="menu-icon" data-kt-element="icon">
<i class="ki-outline ki-moon fs-2"></i>
</span>
<span class="menu-title">
Dark
</span>
</a>
</div>
<div class="menu-item px-3 my-0">
<a href="#" class="menu-link px-3 py-2" data-kt-element="mode" data-kt-value="system">
<span class="menu-icon" data-kt-element="icon">
<i class="ki-outline ki-screen fs-2"></i>
</span>
<span class="menu-title">
System
</span>
<div class="menu-item px-5">
<a href="#" @onclick="Logout" @onclick:preventDefault class="menu-link px-5">
Sign Out
</a>
</div>
</div>
</div>
<div class="menu-item px-5" data-kt-menu-trigger="{default: 'click', lg: 'hover'}" data-kt-menu-placement="left-start" data-kt-menu-offset="-15px, 0">
<a href="#" class="menu-link px-5">
<span class="menu-title position-relative">
Language
<span class="fs-8 rounded bg-light px-3 py-2 position-absolute translate-middle-y top-50 end-0">
English <img class="w-15px h-15px rounded-1 ms-2" src="/metronic8/demo38/assets/media/flags/united-states.svg" alt="">
</span>
</span>
</a>
<div class="menu-sub menu-sub-dropdown w-175px py-4">
<div class="menu-item px-3">
<a href="/metronic8/demo38/../demo38/account/settings.html" class="menu-link d-flex px-5 active">
<span class="symbol symbol-20px me-4">
<img class="rounded-1" src="/metronic8/demo38/assets/media/flags/united-states.svg" alt="">
</span>
English
</a>
</div>
<div class="menu-item px-3">
<a href="/metronic8/demo38/../demo38/account/settings.html" class="menu-link d-flex px-5">
<span class="symbol symbol-20px me-4">
<img class="rounded-1" src="/metronic8/demo38/assets/media/flags/spain.svg" alt="">
</span>
Spanish
</a>
</div>
<div class="menu-item px-3">
<a href="/metronic8/demo38/../demo38/account/settings.html" class="menu-link d-flex px-5">
<span class="symbol symbol-20px me-4">
<img class="rounded-1" src="/metronic8/demo38/assets/media/flags/germany.svg" alt="">
</span>
German
</a>
</div>
<div class="menu-item px-3">
<a href="/metronic8/demo38/../demo38/account/settings.html" class="menu-link d-flex px-5">
<span class="symbol symbol-20px me-4">
<img class="rounded-1" src="/metronic8/demo38/assets/media/flags/japan.svg" alt="">
</span>
Japanese
</a>
</div>
<div class="menu-item px-3">
<a href="/metronic8/demo38/../demo38/account/settings.html" class="menu-link d-flex px-5">
<span class="symbol symbol-20px me-4">
<img class="rounded-1" src="/metronic8/demo38/assets/media/flags/france.svg" alt="">
</span>
French
</a>
</div>
</div>
</div>
<div class="menu-item px-5 my-1">
<a href="/metronic8/demo38/../demo38/account/settings.html" class="menu-link px-5">
Account Settings
</a>
</div>
<div class="menu-item px-5">
<a href="/metronic8/demo38/../demo38/authentication/layouts/corporate/sign-in.html" class="menu-link px-5">
Sign Out
</a>
</div>
</div>
</div>
</div>
</div>
</div>
@ -216,4 +73,10 @@
{
[CascadingParameter]
public DefaultLayout Layout { get; set; }
}
private async Task Logout()
{
await IdentityService.Authenticate("");
await CookieService.SetValue("token", "");
}
}

View file

@ -1,5 +1,6 @@
@using System.Diagnostics
@using Moonlight.App.Exceptions
@inherits ErrorBoundaryBase
@if (Crashed)

View file

@ -1,7 +1,7 @@
<div class="d-flex flex-column flex-root app-root">
<div class="app-page flex-column flex-column-fluid">
<CascadingValue Value="this">
<PageHeader/>
<PageHeader />
<div class="app-wrapper flex-column flex-row-fluid">
<Sidebar/>
<div class="d-flex flex-column flex-column-fluid">
@ -25,9 +25,8 @@
{
[Parameter]
public RenderFragment ChildContent { get; set; }
public bool ShowMobileSidebar { get; set; }
public async Task ToggleMobileSidebar()
{
ShowMobileSidebar = !ShowMobileSidebar;

View file

@ -1,7 +1,132 @@
@inherits LayoutComponentBase
@using Moonlight.App.Services
@using Moonlight.App.Models.Abstractions
@using Moonlight.App.Models.Enums
@using Moonlight.Shared.Components.Auth
<DefaultLayout>
<SoftErrorHandler>
@Body
</SoftErrorHandler>
</DefaultLayout>
@inherits LayoutComponentBase
@implements IDisposable
@inject CookieService CookieService
@inject ConfigService ConfigService
@inject IdentityService IdentityService
@inject SessionService SessionService
@inject NavigationManager Navigation
@{
var url = new Uri(Navigation.Uri);
}
@if (Initialized)
{
if (IdentityService.IsSignedIn)
{
if (!IdentityService.Flags[UserFlag.MailVerified] && ConfigService.Get().Security.EnableEmailVerify)
{
}
else if (IdentityService.Flags[UserFlag.PasswordPending])
{
}
else
{
<DefaultLayout>
<SoftErrorHandler>
@Body
</SoftErrorHandler>
</DefaultLayout>
}
}
else
{
if (url.LocalPath == "/register")
{
<OverlayLayout>
<Register />
</OverlayLayout>
}
else
{
<OverlayLayout>
<Login />
</OverlayLayout>
}
}
}
else
{
<OverlayLayout>
<div class="w-100">
<div class="card-body">
<div class="text-center mb-10">
<h1 class="text-dark mb-3 fs-4">
Connecting to the remote server
</h1>
<div class="text-gray-400 fw-semibold fs-6">
<div class="text-success">
<div class="spinner-border me-2" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
</div>
</div>
</OverlayLayout>
}
@code
{
private bool Initialized = false;
private Session? MySession;
protected override void OnInitialized()
{
IdentityService.OnAuthenticationStateChanged += async (_, _) =>
{
if (MySession != null)
{
MySession.User = IdentityService.CurrentUserNullable;
MySession.UpdatedAt = DateTime.UtcNow;
}
await InvokeAsync(StateHasChanged);
};
Navigation.LocationChanged += (_, _) =>
{
if (MySession != null)
{
MySession.Url = Navigation.Uri;
MySession.UpdatedAt = DateTime.UtcNow;
}
};
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
var token = await CookieService.GetValue("token");
await IdentityService.Authenticate(token);
MySession = new()
{
//Ip = ConnectionService.GetIp(), TODO: Implement
Url = Navigation.Uri,
User = IdentityService.CurrentUserNullable
};
await SessionService.Register(MySession);
Initialized = true;
await InvokeAsync(StateHasChanged);
}
}
public async void Dispose() // This method will be called if the user closes his tab
{
if (MySession != null)
await SessionService.Unregister(MySession);
}
}

View file

@ -0,0 +1,27 @@
<div class="d-flex flex-column flex-root" id="kt_app_root">
<div class="d-flex flex-column flex-lg-row flex-column-fluid">
<a href="/" class="d-block d-lg-none mx-auto py-20">
<img alt="Logo" src="/metronic8/demo38/assets/media/logos/default.svg" class="theme-light-show h-25px">
<img alt="Logo" src="/metronic8/demo38/assets/media/logos/default-dark.svg" class="theme-dark-show h-25px">
</a>
<div class="d-flex flex-column flex-column-fluid flex-center w-lg-50 p-10">
<div class="d-flex justify-content-between flex-column-fluid flex-column w-100 mw-450px">
<div class="d-flex flex-stack py-2">
</div>
<div class="py-20">
@ChildContent
</div>
<div class="m-0">
</div>
</div>
</div>
<div class="d-none d-lg-flex flex-lg-row-fluid w-50 bgi-size-cover bgi-position-y-center bgi-position-x-start bgi-no-repeat" style="background-image: url(/metronic8/demo38/assets/media/auth/bg11.png)">
</div>
</div>
</div>
@code
{
[Parameter]
public RenderFragment ChildContent { get; set; }
}

View file

@ -0,0 +1,135 @@
@page "/account"
@using Moonlight.App.Services
@using Moonlight.App.Services.Users
@using Moonlight.App.Models.Forms
@inject IdentityService IdentityService
@inject UserService UserService
@inject ToastService ToastService
<AccountNavigation Index="0"/>
<div class="card mt-5">
<div class="card-body">
<div class="row justify-content-between align-items-center">
<div class="col">
<div class="row align-items-center">
<div class="col-auto">
<div class="symbol symbol-circle symbol-35px symbol-md-45px">
@if (IdentityService.CurrentUser.Avatar == null)
{
<img src="/assets/img/avatar.png" alt="Avatar">
}
else
{
<img src="/api/bucket/avatars/@(IdentityService.CurrentUser.Avatar)" alt="Avatar">
}
</div>
</div>
<div class="col ms-n2">
<h4 class="mb-1">
Your avatar
</h4>
<small class="text-body-secondary">
PNG or JPG no bigger than 1000px wide and tall.
</small>
</div>
</div>
</div>
<div class="col-auto d-flex align-items-center">
<SmartFileSelect @ref="SmartFileSelect"/>
<WButton OnClick="OnUpload" Text="Upload" CssClasses="ms-2 btn btn-primary" WorkingText="Uploading"/>
</div>
</div>
</div>
</div>
<div class="card mt-5">
<SmartForm Model="Form" OnValidSubmit="OnSubmit">
<div class="card-body">
<div>
<div class="mb-5">
<label class="form-label">Username</label>
<input @bind="Form.Username" class="form-control form-control-solid" type="text" />
</div>
<div class="mb-5">
<label class="form-label">Email</label>
<input @bind="Form.Email" class="form-control form-control-solid" type="text" />
</div>
</div>
</div>
<div class="card-footer">
<div class="text-end">
<button type="submit" class="btn btn-primary">Save changes</button>
</div>
</div>
</SmartForm>
</div>
<div class="card card-body mt-5">
<div class="text-end">
<ConfirmButton OnClick="OnDelete" Text="Delete account" CssClasses="btn btn-danger" />
</div>
</div>
@code
{
private UpdateAccountForm Form = new();
private SmartFileSelect SmartFileSelect;
protected override void OnInitialized()
{
Form.Username = IdentityService.CurrentUser.Username;
Form.Email = IdentityService.CurrentUser.Email;
}
private async Task OnSubmit()
{
await UserService.Update(
IdentityService.CurrentUser,
Form.Username,
Form.Email
);
await ToastService.Success("Successfully saved changes");
}
private async Task OnUpload()
{
// Validation
if (SmartFileSelect.SelectedFile == null)
return;
if (!Formatter.EndsInOneOf(SmartFileSelect.SelectedFile.Name, new[]
{
".jpg",
".png"
}))
{
await ToastService.Danger("You are only allowed to upload JPG and PNG files");
return;
}
// Now, we are actually working
await UserService.Details.UpdateAvatar(
IdentityService.CurrentUser,
SmartFileSelect.SelectedFile.OpenReadStream(SmartFileSelect.MaxFileSize),
SmartFileSelect.SelectedFile.Name
);
// Reset
await SmartFileSelect.RemoveSelection();
await InvokeAsync(StateHasChanged);
await ToastService.Success("Successfully uploaded avatar");
}
private async Task OnDelete()
{
await UserService.Delete(IdentityService.CurrentUser);
await IdentityService.Authenticate();
}
}

View file

@ -0,0 +1,158 @@
@page "/account/security"
@using Moonlight.App.Models.Forms
@using Moonlight.App.Services
@using Moonlight.App.Services.Users
@using OtpNet
@using QRCoder
@using Moonlight.App.Models.Enums
@inject IdentityService IdentityService
@inject UserService UserService
@inject ToastService ToastService
<AccountNavigation Index="1"/>
<div class="row mt-5">
<div class="col-md-6 col-12">
<div class="card">
<SmartForm Model="PasswordForm" OnValidSubmit="OnPasswordSubmit">
<div class="card-body">
<div class="row">
<div class="col-md-6 col-12">
<div>
<label class="form-label">New password</label>
<input @bind="PasswordForm.Password" type="password" class="form-control">
</div>
</div>
<div class="col-md-6 col-12">
<div>
<label class="form-label">New password repeated</label>
<input @bind="PasswordForm.RepeatedPassword" type="password" class="form-control">
</div>
</div>
</div>
</div>
<div class="card-footer">
<div class="text-end">
<button type="submit" class="btn btn-primary">Update password</button>
</div>
</div>
</SmartForm>
</div>
</div>
<div class="col-md-6 col-12">
@if (IdentityService.Flags[UserFlag.TotpEnabled])
{
<div class="card card-body fs-6">
<span class="card-title text-success">Your account is secured with 2fa</span>
<div class="text-center">
<ConfirmButton OnClick="OnDisable2FA" Text="Disable 2fa" CssClasses="btn btn-danger" WorkingText="Disabling"/>
</div>
</div>
}
else if (!IdentityService.Flags[UserFlag.TotpEnabled] && IdentityService.CurrentUser.TotpKey != null)
{
<div class="card">
<div class="card-body fs-6">
<p>
Scan the qr code and enter the code generated by the app you have scanned it in
</p>
@{
QRCodeGenerator qrGenerator = new QRCodeGenerator();
var qrCodeData = qrGenerator.CreateQrCode
(
$"otpauth://totp/{Uri.EscapeDataString(IdentityService.CurrentUser.Email)}?secret={IdentityService.CurrentUser.TotpKey}&issuer={Uri.EscapeDataString("Moonlight")}",
QRCodeGenerator.ECCLevel.Q
);
PngByteQRCode qrCode = new PngByteQRCode(qrCodeData);
byte[] qrCodeAsPngByteArr = qrCode.GetGraphic(5);
var base64 = Convert.ToBase64String(qrCodeAsPngByteArr);
}
<div class="text-center">
<img src="data:image/png;base64,@(base64)" alt="QR Code" class="img-fluid">
</div>
<div class="mt-3 text-center">
<span class="h3">@(IdentityService.CurrentUser.TotpKey)</span>
</div>
</div>
<div class="card-footer">
<SmartForm Model="CodeForm" OnValidSubmit="On2FASubmit">
<div class="input-group">
<input @bind="CodeForm.Code" type="number" class="form-control"/>
<button type="submit" class="btn btn-primary">Enable 2fa</button>
</div>
</SmartForm>
</div>
</div>
}
else
{
<div class="card card-body fs-6">
<span class="card-title">Secure your account using 2fa</span>
<p>
Make sure you have installed one of the following apps on your smartphone and continue
</p>
<a href="https://support.google.com/accounts/answer/1066447?hl=en" target="_blank">Google Authenticator</a>
<br/>
<a href="https://www.microsoft.com/en-us/account/authenticator" target="_blank">Microsoft Authenticator</a>
<br/>
<a href="https://authy.com/download/" target="_blank">Authy</a>
<br/>
<a href="https://support.1password.com/one-time-passwords/" target="_blank">1Password</a>
<br/>
<div class="text-center">
<WButton OnClick="OnSeed2FA" Text="Enable 2fa" CssClasses="btn btn-primary"/>
</div>
</div>
}
</div>
</div>
@code
{
private UpdateAccountPasswordForm PasswordForm = new();
private TwoFactorCodeForm CodeForm = new();
private async Task OnPasswordSubmit()
{
if (PasswordForm.Password != PasswordForm.RepeatedPassword)
throw new DisplayException("The passwords do not match");
await UserService.Auth.ChangePassword(IdentityService.CurrentUser, PasswordForm.Password);
await ToastService.Success("Successfully updated your password");
await IdentityService.Authenticate();
}
private async Task OnDisable2FA()
{
await UserService.Auth.SetTotp(IdentityService.CurrentUser, false);
await IdentityService.Authenticate();
await InvokeAsync(StateHasChanged);
}
private async Task OnSeed2FA()
{
await UserService.Auth.SeedTotp(IdentityService.CurrentUser);
await IdentityService.Authenticate();
await InvokeAsync(StateHasChanged);
}
private async Task On2FASubmit()
{
var totp = new Totp(Base32Encoding.ToBytes(IdentityService.CurrentUser.TotpKey));
var code = totp.ComputeTotp();
if (code != CodeForm.Code)
throw new DisplayException("The 2fa code you entered is invalid");
CodeForm = new();
await UserService.Auth.SetTotp(IdentityService.CurrentUser, true);
await IdentityService.Authenticate();
await InvokeAsync(StateHasChanged);
}
}

View file

@ -4,4 +4,7 @@
@using Moonlight
@using Moonlight.App.Helpers
@using Moonlight.Shared.Components.Partials
@using Moonlight.Shared.Components.Forms
@using Moonlight.Shared.Components.Forms
@using Moonlight.Shared.Components.Navigations
@using Moonlight.App.Services.Interop
@using Moonlight.App.Exceptions

25
Moonlight/wwwroot/js/moonlight.js vendored Normal file
View file

@ -0,0 +1,25 @@
window.moonlight = {
toasts: {
success: function(title, message, timeout)
{
this.show(title, message, timeout, "success");
},
danger: function(title, message, timeout)
{
this.show(title, message, timeout, "danger");
},
warning: function(title, message, timeout)
{
this.show(title, message, timeout, "warning");
},
info: function(title, message, timeout)
{
this.show(title, message, timeout, "info");
},
show: function(title, message, timeout, color)
{
var toast = new ToastHelper(title, message, color, timeout);
toast.show();
}
}
}

97
Moonlight/wwwroot/js/toaster.js vendored Normal file
View file

@ -0,0 +1,97 @@
class ToastHelper {
constructor(title, description, color, timeout) {
var toastElement = buildToast(title, description, color);
var toastWrapper = getOrCreateToastWrapper();
toastWrapper.append(toastElement);
this.bootstrapToast = new bootstrap.Toast(toastElement);
this.show = function () {
this.bootstrapToast.show();
if (timeout && typeof timeout === 'number') {
setTimeout(() => {
this.hide();
toastElement.remove();
}, timeout);
}
}
this.hide = function () {
this.bootstrapToast.hide();
}
this.dispose = function () {
this.bootstrapToast.dispose();
}
}
}
function getOrCreateToastWrapper() {
var toastWrapper = document.querySelector('body > [data-toast-wrapper]');
if (!toastWrapper) {
toastWrapper = document.createElement('div');
toastWrapper.style.zIndex = 11;
toastWrapper.style.position = 'fixed';
toastWrapper.style.bottom = 0;
toastWrapper.style.right = 0;
toastWrapper.style.padding = '1rem';
toastWrapper.setAttribute('data-toast-wrapper', '');
document.body.append(toastWrapper);
}
return toastWrapper;
}
function buildToastHeader(title, color) {
var toastHeader = document.createElement('div');
if(title !== "")
{
toastHeader.setAttribute('class', 'toast-header');
var strong = document.createElement('strong');
strong.setAttribute('class', 'me-auto ' + (color ? 'text-' + color : ''));
strong.textContent = title;
var closeButton = document.createElement('button');
closeButton.setAttribute('type', 'button');
closeButton.setAttribute('class', 'btn-close');
closeButton.setAttribute('data-bs-dismiss', 'toast');
closeButton.setAttribute('data-label', 'Close');
toastHeader.append(strong);
toastHeader.append(closeButton);
}
return toastHeader;
}
function buildToastBody(title, description, color) {
var toastBody = document.createElement('div');
if(title !== "")
toastBody.setAttribute('class', 'toast-body fs-5');
else
toastBody.setAttribute('class', 'toast-body fs-5 fw-bold ' + (color ? 'text-' + color : ''));
toastBody.textContent = description;
return toastBody;
}
function buildToast(title, description, color) {
var toast = document.createElement('div');
toast.setAttribute('class', 'toast my-2');
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'assertive');
toast.setAttribute('aria-atomic', 'true');
var toastHeader = buildToastHeader(title, color);
var toastBody = buildToastBody(title, description, color);
toast.append(toastHeader);
toast.append(toastBody);
return toast;
}