diff --git a/Moonlight.ApiServer/Attributes/RequirePermissionAttribute.cs b/Moonlight.ApiServer/Attributes/RequirePermissionAttribute.cs new file mode 100644 index 00000000..3b128a27 --- /dev/null +++ b/Moonlight.ApiServer/Attributes/RequirePermissionAttribute.cs @@ -0,0 +1,11 @@ +namespace Moonlight.ApiServer.Attributes; + +public class RequirePermissionAttribute : Attribute +{ + public string Permission { get; set; } + + public RequirePermissionAttribute(string permission) + { + Permission = permission; + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Configuration/AppConfiguration.cs b/Moonlight.ApiServer/Configuration/AppConfiguration.cs index 48da7782..04399c8f 100644 --- a/Moonlight.ApiServer/Configuration/AppConfiguration.cs +++ b/Moonlight.ApiServer/Configuration/AppConfiguration.cs @@ -3,6 +3,8 @@ public class AppConfiguration { public DatabaseConfig Database { get; set; } = new(); + public AuthenticationConfig Authentication { get; set; } = new(); + public DevelopmentConfig Development { get; set; } = new(); public class DatabaseConfig { @@ -14,4 +16,19 @@ public class AppConfiguration public string Database { get; set; } = "db_name"; } + + public class AuthenticationConfig + { + public string Secret { get; set; } = Guid + .NewGuid() + .ToString() + .Replace("-", ""); + + public int TokenDuration { get; set; } = 10; + } + + public class DevelopmentConfig + { + public bool EnableApiDocs { get; set; } = false; + } } \ No newline at end of file diff --git a/Moonlight.ApiServer/Database/CoreDataContext.cs b/Moonlight.ApiServer/Database/CoreDataContext.cs index df87d577..51fa8b70 100644 --- a/Moonlight.ApiServer/Database/CoreDataContext.cs +++ b/Moonlight.ApiServer/Database/CoreDataContext.cs @@ -1,8 +1,12 @@ -using Moonlight.ApiServer.Helpers; +using Microsoft.EntityFrameworkCore; +using Moonlight.ApiServer.Database.Entities; +using Moonlight.ApiServer.Helpers; namespace Moonlight.ApiServer.Database; public class CoreDataContext : DatabaseContext { public override string Prefix { get; } = "Core"; + + public DbSet Users { get; set; } } \ No newline at end of file diff --git a/Moonlight.ApiServer/Database/Entities/User.cs b/Moonlight.ApiServer/Database/Entities/User.cs new file mode 100644 index 00000000..b4e6ac5b --- /dev/null +++ b/Moonlight.ApiServer/Database/Entities/User.cs @@ -0,0 +1,13 @@ +namespace Moonlight.ApiServer.Database.Entities; + +public class User +{ + public int Id { get; set; } + + public string Username { get; set; } + public string Email { get; set; } + public string Password { get; set; } + + public DateTime TokenValidTimestamp { get; set; } = DateTime.UtcNow; + public string PermissionsJson { get; set; } = "[]"; +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Database/Migrations/20241001090841_AddedBasicUserModel.Designer.cs b/Moonlight.ApiServer/Database/Migrations/20241001090841_AddedBasicUserModel.Designer.cs new file mode 100644 index 00000000..958c8d42 --- /dev/null +++ b/Moonlight.ApiServer/Database/Migrations/20241001090841_AddedBasicUserModel.Designer.cs @@ -0,0 +1,63 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Moonlight.ApiServer.Database; + +#nullable disable + +namespace Moonlight.ApiServer.Database.Migrations +{ + [DbContext(typeof(CoreDataContext))] + [Migration("20241001090841_AddedBasicUserModel")] + partial class AddedBasicUserModel + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Core") + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Password") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("TokenValidTimestamp") + .HasColumnType("datetime(6)"); + + b.Property("Username") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Users", "Core"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Moonlight.ApiServer/Database/Migrations/20241001090841_AddedBasicUserModel.cs b/Moonlight.ApiServer/Database/Migrations/20241001090841_AddedBasicUserModel.cs new file mode 100644 index 00000000..1c7b6edb --- /dev/null +++ b/Moonlight.ApiServer/Database/Migrations/20241001090841_AddedBasicUserModel.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Moonlight.ApiServer.Database.Migrations +{ + /// + public partial class AddedBasicUserModel : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "Core"); + + migrationBuilder.AlterDatabase() + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "Users", + schema: "Core", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + Username = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Email = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Password = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + TokenValidTimestamp = table.Column(type: "datetime(6)", nullable: false), + PermissionsJson = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Users", + schema: "Core"); + } + } +} diff --git a/Moonlight.ApiServer/Database/Migrations/CoreDataContextModelSnapshot.cs b/Moonlight.ApiServer/Database/Migrations/CoreDataContextModelSnapshot.cs new file mode 100644 index 00000000..7c3a82da --- /dev/null +++ b/Moonlight.ApiServer/Database/Migrations/CoreDataContextModelSnapshot.cs @@ -0,0 +1,60 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Moonlight.ApiServer.Database; + +#nullable disable + +namespace Moonlight.ApiServer.Database.Migrations +{ + [DbContext(typeof(CoreDataContext))] + partial class CoreDataContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Core") + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Password") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PermissionsJson") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("TokenValidTimestamp") + .HasColumnType("datetime(6)"); + + b.Property("Username") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Users", "Core"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Moonlight.ApiServer/Helpers/Authentication/PermClaimsPrinciple.cs b/Moonlight.ApiServer/Helpers/Authentication/PermClaimsPrinciple.cs new file mode 100644 index 00000000..9c08253e --- /dev/null +++ b/Moonlight.ApiServer/Helpers/Authentication/PermClaimsPrinciple.cs @@ -0,0 +1,52 @@ +using System.Security.Claims; +using Moonlight.ApiServer.Database.Entities; + +namespace Moonlight.ApiServer.Helpers.Authentication; + +public class PermClaimsPrinciple : ClaimsPrincipal +{ + public string[] Permissions { get; private set; } + public User? CurrentModel { get; private set; } + + public PermClaimsPrinciple(string[] permissions, User? currentModel) + { + Permissions = permissions; + CurrentModel = currentModel; + } + + public bool HasPermission(string requiredPermission) + { + // Check for wildcard permission + if (Permissions.Contains("*")) + return true; + + var requiredSegments = requiredPermission.Split('.'); + + // Check if the user has the exact permission or a wildcard match + foreach (var permission in Permissions) + { + var permissionSegments = permission.Split('.'); + + // Iterate over the segments of the required permission + for (var i = 0; i < requiredSegments.Length; i++) + { + // If the current segment matches or is a wildcard, continue to the next segment + if (i < permissionSegments.Length && requiredSegments[i] == permissionSegments[i] || + permissionSegments[i] == "*") + { + // If we've reached the end of the permissionSegments array, it means we've found a match + if (i == permissionSegments.Length - 1) + return true; // Found an exact match or a wildcard match + } + else + { + // If we reach here, it means the segments don't match and we break out of the loop + break; + } + } + } + + // No matching permission found + return false; + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Helpers/Authentication/SyncedClaimsPrinciple.cs b/Moonlight.ApiServer/Helpers/Authentication/SyncedClaimsPrinciple.cs deleted file mode 100644 index 7d309220..00000000 --- a/Moonlight.ApiServer/Helpers/Authentication/SyncedClaimsPrinciple.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Security.Claims; - -namespace Moonlight.ApiServer.Helpers.Authentication; - -public class SyncedClaimsPrinciple : ClaimsPrincipal -{ - -} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/Auth/AuthController.cs b/Moonlight.ApiServer/Http/Controllers/Auth/AuthController.cs new file mode 100644 index 00000000..685942f7 --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/Auth/AuthController.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Mvc; +using Moonlight.ApiServer.Services; +using Moonlight.Shared.Http.Requests.Auth; +using Moonlight.Shared.Http.Responses.Auth; + +namespace Moonlight.ApiServer.Http.Controllers.Auth; + +[ApiController] +[Route("api/auth")] +public class AuthController : Controller +{ + private readonly AuthService AuthService; + + public AuthController(AuthService authService) + { + AuthService = authService; + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginRequest request) + { + var user = await AuthService.Login(request.Email, request.Password); + + return new LoginResponse() + { + Token = await AuthService.GenerateToken(user) + }; + } + + [HttpPost("register")] + public async Task Register([FromBody] RegisterRequest request) + { + var user = await AuthService.Register( + request.Username, + request.Email, + request.Password + ); + + return new RegisterResponse() + { + Token = await AuthService.GenerateToken(user) + }; + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Controllers/Swagger/SwaggerController.cs b/Moonlight.ApiServer/Http/Controllers/Swagger/SwaggerController.cs new file mode 100644 index 00000000..c7aededc --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/Swagger/SwaggerController.cs @@ -0,0 +1,54 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using MoonCore.Services; +using Moonlight.ApiServer.Configuration; +using Moonlight.ApiServer.Models; + +namespace Moonlight.ApiServer.Http.Controllers.Swagger; + +[ApiController] +[Route("api/swagger")] +public class SwaggerController : Controller +{ + private readonly ConfigService ConfigService; + + public SwaggerController(ConfigService configService) + { + ConfigService = configService; + } + + [HttpGet] + public async Task Get() + { + if (!ConfigService.Get().Development.EnableApiDocs) + return BadRequest("Api docs are disabled"); + + var options = new ApiDocsOptions(); + var optionsJson = JsonSerializer.Serialize(options); + + //TODO: Replace the css link with a better one + + var html = "\n" + + "\n" + + "\n" + + "Moonlight Api Reference\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + ""; + + return Content(html, "text/html"); + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Http/Middleware/AuthenticationMiddleware.cs b/Moonlight.ApiServer/Http/Middleware/AuthenticationMiddleware.cs index 9a140328..25d15f9b 100644 --- a/Moonlight.ApiServer/Http/Middleware/AuthenticationMiddleware.cs +++ b/Moonlight.ApiServer/Http/Middleware/AuthenticationMiddleware.cs @@ -1,15 +1,107 @@ -namespace Moonlight.ApiServer.Http.Middleware; +using System.Text.Json; +using MoonCore.Extended.Abstractions; +using MoonCore.Extended.Helpers; +using MoonCore.Services; +using Moonlight.ApiServer.Configuration; +using Moonlight.ApiServer.Database.Entities; +using Moonlight.ApiServer.Helpers.Authentication; + +namespace Moonlight.ApiServer.Http.Middleware; public class AuthenticationMiddleware { private readonly RequestDelegate Next; + private readonly ILogger Logger; - public AuthenticationMiddleware(RequestDelegate next) + public AuthenticationMiddleware(RequestDelegate next, ILogger logger) { Next = next; + Logger = logger; } public async Task InvokeAsync(HttpContext context) + { + await Authenticate(context); + await Next(context); + } + + private async Task Authenticate(HttpContext context) + { + var request = context.Request; + string? token = null; + + // Cookie for Moonlight.Client + if (request.Cookies.ContainsKey("token") && !string.IsNullOrEmpty(request.Cookies["token"])) + token = request.Cookies["token"]; + + // Header for api clients + if (request.Headers.ContainsKey("Authorization") && !string.IsNullOrEmpty(request.Cookies["Authorization"])) + { + var headerValue = request.Cookies["Authorization"] ?? ""; + + if (headerValue.StartsWith("Bearer")) + { + var headerParts = headerValue.Split(" "); + + if (headerParts.Length > 1 && !string.IsNullOrEmpty(headerParts[1])) + token = headerParts[1]; + } + } + + if(token == null) + return; + + // Validate token + if (token.Length > 300) + { + Logger.LogWarning("Received token bigger than 300 characters, Length: {length}", token.Length); + return; + } + + // Decide which authentication method we need for the token + if (token.Count(x => x == '.') == 2) // JWT only has two dots + await AuthenticateUser(context, token); + else + await AuthenticateApiKey(context, token); + } + + private async Task AuthenticateUser(HttpContext context, string jwt) + { + var jwtHelper = context.RequestServices.GetRequiredService(); + var configService = context.RequestServices.GetRequiredService>(); + var secret = configService.Get().Authentication.Secret; + + if(!await jwtHelper.Validate(secret, jwt, "login")) + return; + + var data = await jwtHelper.Decode(secret, jwt); + + if(!data.TryGetValue("iat", out var issuedAtString) || !data.TryGetValue("userId", out var userIdString)) + return; + + var userId = int.Parse(userIdString); + var issuedAt = DateTimeOffset.FromUnixTimeSeconds(long.Parse(issuedAtString)).DateTime; + + var userRepo = context.RequestServices.GetRequiredService>(); + var user = userRepo.Get().FirstOrDefault(x => x.Id == userId); + + if(user == null) + return; + + // Check if token is in the past + if(user.TokenValidTimestamp > issuedAt) + return; + + // Load permissions, handle empty values + var permissions = JsonSerializer.Deserialize( + string.IsNullOrEmpty(user.PermissionsJson) ? "[]" : user.PermissionsJson + ) ?? []; + + // Save permission state + context.User = new PermClaimsPrinciple(permissions, user); + } + + private async Task AuthenticateApiKey(HttpContext context, string apiKey) { } diff --git a/Moonlight.ApiServer/Http/Middleware/AuthorisationMiddleware.cs b/Moonlight.ApiServer/Http/Middleware/AuthorisationMiddleware.cs new file mode 100644 index 00000000..7218bee5 --- /dev/null +++ b/Moonlight.ApiServer/Http/Middleware/AuthorisationMiddleware.cs @@ -0,0 +1,76 @@ +using Microsoft.AspNetCore.Mvc.Controllers; +using Moonlight.ApiServer.Attributes; + +namespace Moonlight.ApiServer.Http.Middleware; + +public class AuthorisationMiddleware +{ + private readonly RequestDelegate Next; + private readonly ILogger Logger; + + public AuthorisationMiddleware(RequestDelegate next, ILogger logger) + { + Next = next; + Logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + await Next(context); + } + + private async Task Authorize(HttpContext context) + { + + } + + private string[] ResolveRequiredPermissions(HttpContext context) + { + // Basic handling + var endpoint = context.GetEndpoint(); + + if (endpoint == null) + return []; + + var metadata = endpoint + .Metadata + .GetMetadata(); + + if (metadata == null) + return []; + + // Retrieve attribute infos + var controllerAttrInfo = metadata + .ControllerTypeInfo + .CustomAttributes + .FirstOrDefault(x => x.AttributeType == typeof(RequirePermissionAttribute)); + + var methodAttrInfo = metadata + .MethodInfo + .CustomAttributes + .FirstOrDefault(x => x.AttributeType == typeof(RequirePermissionAttribute)); + + // Retrieve permissions from attribute infos + var controllerPermission = controllerAttrInfo != null + ? controllerAttrInfo.ConstructorArguments.First().Value as string + : null; + + var methodPermission = methodAttrInfo != null + ? methodAttrInfo.ConstructorArguments.First().Value as string + : null; + + // If both have a permission flag, return both + if (controllerPermission != null && methodPermission != null) + return [controllerPermission, methodPermission]; + + // If either of them have a permission set, return it + if (controllerPermission != null) + return [controllerPermission]; + + if (methodPermission != null) + return [methodPermission]; + + // If both have no permission set, allow everyone to access it + return []; + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Models/ApiDocsOptions.cs b/Moonlight.ApiServer/Models/ApiDocsOptions.cs new file mode 100644 index 00000000..60e601a1 --- /dev/null +++ b/Moonlight.ApiServer/Models/ApiDocsOptions.cs @@ -0,0 +1,43 @@ +namespace Moonlight.ApiServer.Models; + +// From https://github.com/scalar/scalar/blob/main/packages/scalar.aspnetcore/ScalarOptions.cs + +public class ApiDocsOptions +{ + public string Theme { get; set; } = "purple"; + + public bool? DarkMode { get; set; } + public bool? HideDownloadButton { get; set; } + public bool? ShowSideBar { get; set; } + + public bool? WithDefaultFonts { get; set; } + + public string? Layout { get; set; } + + public string? CustomCss { get; set; } + + public string? SearchHotkey { get; set; } + + public Dictionary? Metadata { get; set; } + + public ScalarAuthenticationOptions? Authentication { get; set; } +} + +public class ScalarAuthenticationOptions +{ + public string? PreferredSecurityScheme { get; set; } + + public ScalarAuthenticationApiKey? ApiKey { get; set; } +} + +public class ScalarAuthenticationoAuth2 +{ + public string? ClientId { get; set; } + + public List? Scopes { get; set; } +} + +public class ScalarAuthenticationApiKey +{ + public string? Token { get; set; } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Moonlight.ApiServer.csproj b/Moonlight.ApiServer/Moonlight.ApiServer.csproj index 2e783d1e..c3acad43 100644 --- a/Moonlight.ApiServer/Moonlight.ApiServer.csproj +++ b/Moonlight.ApiServer/Moonlight.ApiServer.csproj @@ -12,7 +12,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + @@ -25,11 +25,9 @@ - - - - + + diff --git a/Moonlight.ApiServer/Program.cs b/Moonlight.ApiServer/Program.cs index e2b47f0d..70be7ed4 100644 --- a/Moonlight.ApiServer/Program.cs +++ b/Moonlight.ApiServer/Program.cs @@ -1,3 +1,5 @@ +using Microsoft.OpenApi.Models; +using MoonCore.Extended.Abstractions; using MoonCore.Extended.Helpers; using MoonCore.Extensions; using MoonCore.Helpers; @@ -5,6 +7,7 @@ using MoonCore.Services; using Moonlight.ApiServer.Configuration; using Moonlight.ApiServer.Database; using Moonlight.ApiServer.Helpers; +using Moonlight.ApiServer.Http.Middleware; // Prepare file system Directory.CreateDirectory(PathBuilder.Dir("storage")); @@ -73,18 +76,32 @@ builder.Services.AddSwaggerGen(); builder.Services.AddSingleton(configService); +builder.Services.AddSingleton(); +builder.Services.AutoAddServices(); + // Database var databaseHelper = new DatabaseHelper( loggerFactory.CreateLogger() ); builder.Services.AddSingleton(databaseHelper); +builder.Services.AddScoped(typeof(DatabaseRepository<>)); builder.Services.AddDbContext(); databaseHelper.AddDbContext(); databaseHelper.GenerateMappings(); +// API Docs +if (configService.Get().Development.EnableApiDocs) +{ + // Configure swagger api specification generator and set the document title for the api docs to use + builder.Services.AddSwaggerGen(options => options.SwaggerDoc("main", new OpenApiInfo() + { + Title = "Moonlight API" + })); +} + var app = builder.Build(); using (var scope = app.Services.CreateScope()) @@ -92,19 +109,22 @@ using (var scope = app.Services.CreateScope()) await databaseHelper.EnsureMigrated(scope.ServiceProvider); } -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); +if(app.Environment.IsDevelopment()) app.UseWebAssemblyDebugging(); -} app.UseBlazorFrameworkFiles(); app.UseStaticFiles(); app.UseRouting(); + +app.UseMiddleware(); + app.MapControllers(); app.MapFallbackToFile("index.html"); +// API Docs +if (configService.Get().Development.EnableApiDocs) + app.MapSwagger("/api/swagger/{documentName}"); + app.Run(); \ No newline at end of file diff --git a/Moonlight.ApiServer/Services/AuthService.cs b/Moonlight.ApiServer/Services/AuthService.cs new file mode 100644 index 00000000..a803296e --- /dev/null +++ b/Moonlight.ApiServer/Services/AuthService.cs @@ -0,0 +1,83 @@ +using MoonCore.Attributes; +using MoonCore.Exceptions; +using MoonCore.Extended.Abstractions; +using MoonCore.Extended.Helpers; +using MoonCore.Services; +using Moonlight.ApiServer.Configuration; +using Moonlight.ApiServer.Database.Entities; + +namespace Moonlight.ApiServer.Services; + +[Scoped] +public class AuthService +{ + private readonly DatabaseRepository UserRepository; + private readonly ConfigService ConfigService; + private readonly JwtHelper JwtHelper; + + public AuthService( + DatabaseRepository userRepository, + ConfigService configService, + JwtHelper jwtHelper) + { + UserRepository = userRepository; + ConfigService = configService; + JwtHelper = jwtHelper; + } + + public Task Register(string username, string email, string password) + { + // Reformat values + username = username.ToLower().Trim(); + email = email.ToLower().Trim(); + + // Check for users with the same values + if (UserRepository.Get().Any(x => x.Username == username)) + throw new HttpApiException("A user with that username already exists", 400); + + if (UserRepository.Get().Any(x => x.Email == email)) + throw new HttpApiException("A user with that email address already exists", 400); + + // Build model and add it to the database + var user = new User() + { + Username = username, + Email = email, + Password = HashHelper.Hash(password), + PermissionsJson = "[]", + TokenValidTimestamp = DateTime.UtcNow + }; + + UserRepository.Add(user); + + return Task.FromResult(user); + } + + public Task Login(string email, string password) + { + // Reformat values + email = email.ToLower().Trim(); + + var user = UserRepository + .Get() + .FirstOrDefault(x => x.Email == email); + + if (user == null) + throw new HttpApiException("Invalid email or password", 400); + + if(!HashHelper.Verify(password, user.Password)) + throw new HttpApiException("Invalid email or password", 400); + + return Task.FromResult(user); + } + + public async Task GenerateToken(User user) + { + var authConfig = ConfigService.Get().Authentication; + + return await JwtHelper.Create(authConfig.Secret, data => + { + data.Add("userId", user.Id.ToString()); + }, "login", TimeSpan.FromDays(authConfig.TokenDuration)); + } +} \ No newline at end of file diff --git a/Moonlight.Client/Moonlight.Client.csproj b/Moonlight.Client/Moonlight.Client.csproj index 355e8b7d..6c6bb562 100644 --- a/Moonlight.Client/Moonlight.Client.csproj +++ b/Moonlight.Client/Moonlight.Client.csproj @@ -10,7 +10,7 @@ - + diff --git a/Moonlight.Shared/Http/Requests/Auth/LoginRequest.cs b/Moonlight.Shared/Http/Requests/Auth/LoginRequest.cs new file mode 100644 index 00000000..923e909b --- /dev/null +++ b/Moonlight.Shared/Http/Requests/Auth/LoginRequest.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Moonlight.Shared.Http.Requests.Auth; + +public class LoginRequest +{ + [Required(ErrorMessage = "You need to provide an email address")] + [EmailAddress(ErrorMessage = "You need to provide 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.Shared/Http/Requests/Auth/RegisterRequest.cs b/Moonlight.Shared/Http/Requests/Auth/RegisterRequest.cs new file mode 100644 index 00000000..d84c1bea --- /dev/null +++ b/Moonlight.Shared/Http/Requests/Auth/RegisterRequest.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace Moonlight.Shared.Http.Requests.Auth; + +public class RegisterRequest +{ + [Required(ErrorMessage = "You need to provide an email address")] + [EmailAddress(ErrorMessage = "You need to provide a valid email address")] + public string Email { get; set; } + + [Required(ErrorMessage = "You need to provide a username")] + [RegularExpression("^[a-z][a-z0-9]*$", ErrorMessage = "Usernames can only contain lowercase characters and numbers and should not start with a number")] + public string Username { get; set; } + + [Required(ErrorMessage = "You need to provide a password")] + [MinLength(8, ErrorMessage = "Your password needs to be at least 8 characters long")] + [MaxLength(256, ErrorMessage = "Your password should not exceed the length of 256 characters")] + public string Password { get; set; } +} \ No newline at end of file diff --git a/Moonlight.Shared/Http/Responses/Auth/LoginResponse.cs b/Moonlight.Shared/Http/Responses/Auth/LoginResponse.cs new file mode 100644 index 00000000..1bf4a6f8 --- /dev/null +++ b/Moonlight.Shared/Http/Responses/Auth/LoginResponse.cs @@ -0,0 +1,6 @@ +namespace Moonlight.Shared.Http.Responses.Auth; + +public class LoginResponse +{ + public string Token { get; set; } +} \ No newline at end of file diff --git a/Moonlight.Shared/Http/Responses/Auth/RegisterResponse.cs b/Moonlight.Shared/Http/Responses/Auth/RegisterResponse.cs new file mode 100644 index 00000000..5776b73f --- /dev/null +++ b/Moonlight.Shared/Http/Responses/Auth/RegisterResponse.cs @@ -0,0 +1,6 @@ +namespace Moonlight.Shared.Http.Responses.Auth; + +public class RegisterResponse +{ + public string Token { get; set; } +} \ No newline at end of file diff --git a/Moonlight.Shared/Moonlight.Shared.csproj b/Moonlight.Shared/Moonlight.Shared.csproj index b966d65f..3a635329 100644 --- a/Moonlight.Shared/Moonlight.Shared.csproj +++ b/Moonlight.Shared/Moonlight.Shared.csproj @@ -6,9 +6,4 @@ enable - - - - -