fronted: login

This commit is contained in:
2025-08-15 20:03:07 +02:00
parent f61b48fa4b
commit 1b2d95344a
184 changed files with 5238 additions and 232 deletions

View File

@@ -14,12 +14,15 @@ public class EventModel
public string? Description { get; set; }
public DateTime? Date { get; set; }
public string? Location { get; set; }
[ForeignKey("TagId")]
public List<TagModel> RelatedTags { get; set; } = [];
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public string? CreatedBy { get; set; }
public string? UpdatedBy { get; set; }
public EventModel() { }
public EventModel(string id)
{
Id = id;

View File

@@ -6,6 +6,8 @@ namespace back.DataModels;
[Table("Galleries")]
public class GalleryModel
{
public GalleryModel() { }
[Key]
public string Id { get; set; }
[MaxLength(100)]
@@ -16,7 +18,8 @@ public class GalleryModel
public DateTime? UpdatedAt { get; set; }
public string? CreatedBy { get; set; }
public string? UpdatedBy { get; set; }
public List<PhotoModel> Photos { get; set; } = new();
[ForeignKey("PhotoId")]
public List<PhotoModel> Photos { get; set; } = [];
public bool? IsPublic { get; set; } = true;
public bool? IsArchived { get; set; } = false;
public bool? IsFavorite { get; set; } = false;

View File

@@ -6,6 +6,8 @@ namespace back.DataModels;
[Table("Permissions")]
public class PermissionModel
{
public PermissionModel() { }
[Key]
public string Id { get; set; }
[Required, MaxLength(100)]

View File

@@ -6,12 +6,15 @@ namespace back.DataModels;
[Table("Persons")]
public class PersonModel
{
public PersonModel() { }
[Key]
public string Id { get; set; }
[Required, MaxLength(100)]
public string? Name { get; set; }
public string? ProfilePicture { get; set; }
public string? Avatar { get; set; }
[ForeignKey("SocialMediaId")]
public SocialMedia? SocialMedia { get; set; }
[MaxLength(250)]
public string? Bio { get; set; } // Optional field for a short biography or description
@@ -33,9 +36,11 @@ public class PersonModel
}
}
[Table("SocialMediaLinks")]
[Table("SocialMedia")]
public class SocialMedia
{
[Key]
public string Id { get; set; } = Guid.NewGuid().ToString();
public string? Facebook { get; set; }
public string? Instagram { get; set; }
public string? Twitter { get; set; }

View File

@@ -3,9 +3,11 @@ using System.ComponentModel.DataAnnotations.Schema;
namespace back.DataModels;
[Table("Photo")]
[Table("Photos")]
public class PhotoModel
{
public PhotoModel() { }
[Key]
public string Id { get; set; }
[Required, MaxLength(100), MinLength(1)]
@@ -20,12 +22,16 @@ public class PhotoModel
public DateTime UpdatedAt { get; set; }
public string CreatedBy { get; set; }
public string UpdatedBy { get; set; }
[ForeignKey("EventId")]
public EventModel? Event { get; set; } = null;
[ForeignKey("TagId")]
public List<TagModel> Tags { get; set; } = [];
[ForeignKey("RankingId")]
public RankingModel Ranking { get; set; } = new RankingModel(0);
public bool IsFavorite { get; set; } = false;
public bool IsPublic { get; set; } = true;
public bool IsArchived { get; set; } = false;
[ForeignKey("PersonId")]
public List<PersonModel>? Persons { get; set; }
public PhotoModel(

View File

@@ -1,7 +1,14 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace back.DataModels;
[Table("Rankings")]
public class RankingModel
{
[Key]
public string Id { get; set; } = Guid.NewGuid().ToString();
private int totalVotes;
private int upVotes;
private int downVotes;
@@ -13,6 +20,13 @@ public class RankingModel
this.downVotes = downVotes;
}
public RankingModel()
{
totalVotes = 0;
upVotes = 0;
downVotes = 0;
}
public void DownVote()
{
downVotes++;

View File

@@ -1,17 +1,22 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace back.DataModels;
[Tags("Roles")]
public class RoleModel
{
public RoleModel() { }
[Key]
public string Id { get; set; }
[Required, MaxLength(100)]
public string Name { get; set; }
[MaxLength(250)]
public string Description { get; set; }
[ForeignKey("PermissionId")]
public List<PermissionModel> Permissions { get; set; }
[ForeignKey("RoleId")]
public RoleModel? BaseRoleModel { get; set; }
public RoleModel(string id, string name, string description, List<PermissionModel>? permissions = null, RoleModel? baseRoleModel = null)

View File

@@ -4,11 +4,18 @@ using System.ComponentModel.DataAnnotations.Schema;
namespace back.DataModels;
[Table("Tags")]
public class TagModel(string id, string? name = null)
public class TagModel
{
public TagModel() { }
public TagModel(string id, string? name = null)
{
Id = id;
Name = name;
}
[Key]
public string Id { get; init; } = id;
public string Id { get; init; }
[Required, MaxLength(25)]
public string? Name { get; init; } = name;
public string? Name { get; init; }
public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
}

View File

@@ -10,6 +10,9 @@ public class UserModel : PersonModel
public string Email { get; set; }
[Required, MinLength(8)]
public string Password { get; set; }
[Required]
public string Salt { get; set; }
[ForeignKey("RoleId")]
public List<RoleModel> Role { get; set; }
public UserModel(string id, string email, string password, string name, List<RoleModel> role, DateTime createdAt, DateTime updatedAt)
@@ -22,17 +25,30 @@ public class UserModel : PersonModel
UpdatedAt = updatedAt;
}
public UserModel() { }
public bool IsAdmin => Role.Exists(r => r.IsAdmin);
public bool IsContentManager => Role.Exists(r => r.IsContentManager);
public bool IsUser => Role.Exists(r => r.IsUser);
public static readonly UserModel DefaultUser = new(
"0",
"default@example.com",
string.Empty,
"Default User",
[RoleModel.UserRole],
DateTime.UtcNow,
DateTime.UtcNow
);
public UserDto ToDto()
{
return new UserDto
{
Id = Id,
Name = Name,
Email = Email,
ProfilePicture = ProfilePicture,
Avatar = Avatar,
SocialMedia = SocialMedia,
Bio = Bio,
CreatedAt = CreatedAt,
UpdatedAt = UpdatedAt
};
}
}
public class UserDto : PersonModel
{
public required string Email { get; set; }
}

View File

@@ -49,7 +49,9 @@ public static partial class DbContextOptionsBuilderExtensions
public static void UseDatabaseConfig(this DbContextOptionsBuilder options, DatabaseConfig config)
{
if(!Enum.TryParse(Enum.GetName(typeof(DatabaseProvider), config.Provider)?.ToLowerInvariant(), out DatabaseProvider provider))
var providerName = Enum.GetNames<DatabaseProvider>()
.FirstOrDefault(name => name.Equals(config.Provider, StringComparison.InvariantCultureIgnoreCase));
if (!Enum.TryParse(providerName, out DatabaseProvider provider))
{
throw new InvalidOperationException($"Unsupported database provider: {config.Provider} -- Supported providers are: {SupportedDbs()}");
}

View File

@@ -1,4 +1,5 @@
using back.persistance.blob;
using back.services.Crypto;
using back.services.ImageResizer;
namespace back.ServicesExtensions;
@@ -15,6 +16,7 @@ public static partial class ServicesExtensions
services.AddSingleton<IBlobStorageService, FileSystemImageStorageService>();
services.AddSingleton<IImageResizer, ImageResizer>();
services.AddSingleton<ICryptoService, CryptoService>();
return services;
}

View File

@@ -6,7 +6,7 @@ namespace back.context;
public class EventContext : DbContext
{
private DbSet<EventModel> Events { get; set; }
public EventContext(DbContextOptions<EventContext> options)
public EventContext(DbContextOptions<EventContext> options) : base(options)
{
Database.EnsureCreated();
}

View File

@@ -6,8 +6,9 @@ namespace back.context;
public class PersonContext : DbContext
{
private DbSet<PersonModel> Persons { get; set; }
public PersonContext(DbContextOptions<PersonContext> options)
public PersonContext(DbContextOptions<PersonContext> options) : base(options)
{
// Ensure database is created
Database.EnsureCreated();
}

View File

@@ -162,36 +162,71 @@ public class PhotoContext : DbContext
public async Task<PhotoModel?> GetById(Guid id)
{
return await Photos.FindAsync(id);
try
{
return await Photos.FindAsync(id);
}
catch
{
return null;
}
}
public async Task<int> GetTotalItems()
{
return await Photos.CountAsync();
try
{
return await Photos.CountAsync();
}
catch
{
return 0;
}
}
public async Task<IEnumerable<PhotoModel>> GetPage(int page = 1, int pageSize = 20)
public async Task<IEnumerable<PhotoModel>?> GetPage(int page = 1, int pageSize = 20)
{
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 20;
return await Photos
try
{
return await Photos
.OrderByDescending(p => p.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}
catch
{
return null;
}
}
public async Task<bool> Exists(PhotoModel? photo)
{
if (photo == null) return false;
if (string.IsNullOrEmpty(photo.Id)) return false;
return await Photos.AnyAsync(p => p.Id == photo.Id);
try
{
if (photo == null) return false;
if (string.IsNullOrEmpty(photo.Id)) return false;
return await Photos.AnyAsync(p => p.Id == photo.Id);
}
catch
{
return false; // Handle exceptions gracefully
}
}
public async Task<bool> Exists(string id)
{
return await Photos.AnyAsync(p => p.Id == id);
try
{
if (string.IsNullOrEmpty(id)) return false;
return await Photos.AnyAsync(p => p.Id == id);
}
catch
{
return false; // Handle exceptions gracefully
}
}
public async Task Delete(PhotoModel photo)

View File

@@ -6,7 +6,7 @@ namespace back.context;
public class TagContext : DbContext
{
private DbSet<TagModel> Tags { get; set; }
public TagContext(DbContextOptions<TagContext> options)
public TagContext(DbContextOptions<TagContext> options) : base(options)
{
Database.EnsureCreated();
}

View File

@@ -1,17 +1,111 @@
using back.DataModels;
using back.services.Crypto;
using Microsoft.EntityFrameworkCore;
using System.Net;
namespace back.context;
public class UserContext : DbContext
{
public DbSet<UserModel> Users { get; set; }
public UserContext(DbContextOptions<UserContext> options) : base(options)
public record HttpErrorMap(HttpStatusCode Code, string Description);
public static class Errors
{
public static readonly HttpErrorMap Unauthorized =
new(HttpStatusCode.Unauthorized, "Invalid user data. Email or password are wrong.");
public static readonly HttpErrorMap BadRequest =
new(HttpStatusCode.BadRequest, "Missing user data.");
}
public DbSet<UserModel> Users { get; set; }
private readonly ICryptoService _cryptoService;
public UserContext(
DbContextOptions<UserContext> options,
ICryptoService cryptoService
) : base(options)
{
_cryptoService = cryptoService ?? throw new ArgumentNullException(nameof(cryptoService));
// Ensure database is created
Database.EnsureCreated();
}
public async Task<UserModel?> Create(string clientId, UserModel user)
{
ArgumentNullException.ThrowIfNull(user);
if (await Exists(user))
{
return await GetById(Guid.Parse(user.Id)) ?? null;
}
if (string.IsNullOrEmpty(user.Id))
{
user.Id = Guid.NewGuid().ToString();
}
if (string.IsNullOrEmpty(user.Salt))
{
user.Salt = _cryptoService.Salt();
}
user.Password = _cryptoService.Decrypt(clientId, user.Password) ?? string.Empty;
user.Password = _cryptoService.Hash(user.Password + user.Salt + _cryptoService.Pepper()) ?? string.Empty;
user.CreatedAt = DateTime.UtcNow;
Users.Add(user);
await SaveChangesAsync();
return user;
}
public async Task<UserModel?> Update(UserModel user)
{
ArgumentNullException.ThrowIfNull(user);
if (!await Exists(user))
{
return null;
}
var existingUser = await GetById(Guid.Parse(user.Id));
if (existingUser == null) return null;
existingUser.Name = user.Name;
existingUser.Email = user.Email;
existingUser.UpdatedAt = DateTime.UtcNow;
Users.Update(existingUser);
await SaveChangesAsync();
return existingUser;
}
public async Task<bool> Delete(Guid id)
{
var user = await GetById(id);
if (user == null) return false;
Users.Remove(user);
await SaveChangesAsync();
return true;
}
public async Task<UserModel?> GetByEmail(string email)
{
if (string.IsNullOrEmpty(email)) return null;
return await Users.FirstOrDefaultAsync(u => u.Email == email);
}
public async Task<string> GetUserSaltByEmail(string email)
{
if (string.IsNullOrEmpty(email)) return string.Empty;
var user = await Users.FirstOrDefaultAsync(u => u.Email == email);
return user?.Salt ?? string.Empty;
}
public async Task<UserModel?> Login(string email, string password, string clientId)
{
if (string.IsNullOrEmpty(email) || string.IsNullOrEmpty(password)) return null;
var pass = _cryptoService.Decrypt(clientId, password) + await GetUserSaltByEmail(email) + _cryptoService.Pepper();
var hashedPassword = _cryptoService.Hash(pass);
var user = await Users
.FirstOrDefaultAsync(u => u.Email == email && u.Password == hashedPassword);
return user;
}
public async Task<UserModel?> GetById(Guid id)
{
return await Users.FindAsync(id);

View File

@@ -0,0 +1,54 @@
using back.context;
using back.DataModels;
using Microsoft.AspNetCore.Mvc;
using System.Net;
namespace back.controllers;
[ApiController, Route("api/[controller]")]
public class UsersController(UserContext userContext) : ControllerBase
{
private readonly UserContext _userContext = userContext;
// GET: api/<UsersController>
//[HttpGet]
//public async Task<ActionResult<IEnumerable<UserModel>>> Get([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
//{
// var users = await _userContext.GetPage(page, pageSize);
// var totalItems = await _userContext.GetTotalItems();
// Response.Headers.Append("X-Total-Count", totalItems.ToString());
// return Ok(users);
//}
//// GET api/<UsersController>/5
//[HttpGet("{id}")]
//public async Task<IActionResult> Get(Guid id)
//{
// var user = await _userContext.GetById(id);
// if (user == null)
// return NotFound();
// return Ok(user);
//}
[HttpPost]
public async Task<IActionResult> Login(
[FromHeader(Name = "X-client-thumbprint")] string clientId,
[FromBody] UserModel user
)
{
if (user == null || string.IsNullOrEmpty(user.Email) || string.IsNullOrEmpty(user.Password))
return BadRequest(UserContext.Errors.BadRequest.Description);
var existingUser = await _userContext.Login(user.Email, user.Password, clientId);
if (existingUser == null)
return Unauthorized(UserContext.Errors.Unauthorized.Description);
return Ok(existingUser.ToDto());
}
//// POST api/<UsersController>
//[HttpPost]
//public async Task<IActionResult> Post([FromBody] UserModel user)
//{
// if (user == null)
// return BadRequest("User cannot be null");
// var createdUser = await _userContext.Create(user);
// return CreatedAtAction(nameof(Get), new { id = createdUser.Id }, createdUser);
//}
}

View File

@@ -0,0 +1,168 @@
using Microsoft.Extensions.Caching.Memory;
using System.Security.Cryptography;
namespace back.services.Crypto;
public class CryptoService(IMemoryCache cache) : ICryptoService
{
private readonly IMemoryCache _cache = cache;
private readonly MemoryCacheEntryOptions _CacheOptions = new()
{
AbsoluteExpiration = DateTimeOffset.Now.AddHours(1),
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1),
SlidingExpiration = TimeSpan.FromMinutes(30),
Priority = CacheItemPriority.High,
PostEvictionCallbacks =
{
new PostEvictionCallbackRegistration
{
EvictionCallback = (key, value, reason, state) =>
{
var clientId = key.ToString()?.Replace("_public","").Replace("_private","");
if(string.IsNullOrEmpty(clientId)) { return; }
// Handle the eviction of the certificate - removing public/private keys from the cache
try{ cache.Remove($"{clientId}_public"); } catch{ }
try{ cache.Remove($"{clientId}_private"); } catch{ }
}
}
}
};
public string? Encrypt(string plainText, string clientId)
{
// get keys from cache
if (!_cache.TryGetValue($"{clientId}_private", out string? privateCert) || string.IsNullOrEmpty(privateCert))
{
throw new InvalidOperationException("Private certificate not found for the client.");
}
if (!_cache.TryGetValue($"{clientId}_public", out string? publicCert) || string.IsNullOrEmpty(publicCert))
{
throw new InvalidOperationException("Public certificate not found for the client.");
}
// import rsa keys and configure RSA for encryption
using var rsa = RSA.Create(2048);
rsa.ImportRSAPublicKey(Convert.FromBase64String(publicCert), out _);
rsa.ImportRSAPrivateKey(Convert.FromBase64String(privateCert), out _);
// Encrypt the plain text using RSA
string? encryptedText = null;
try
{
var plainBytes = System.Text.Encoding.UTF8.GetBytes(plainText);
var encryptedBytes = rsa.Encrypt(plainBytes, RSAEncryptionPadding.OaepSHA256);
encryptedText = Convert.ToBase64String(encryptedBytes);
}
catch (CryptographicException ex)
{
// Handle encryption errors
throw new InvalidOperationException("Encryption failed.", ex);
}
return encryptedText;
}
public string? Decrypt(string encryptedText, string clientId)
{
// get keys from cache
if (!_cache.TryGetValue($"{clientId}_private", out string? privateCert) || string.IsNullOrEmpty(privateCert))
{
throw new InvalidOperationException("Private certificate not found for the client.");
}
if (!_cache.TryGetValue($"{clientId}_public", out string? publicCert) || string.IsNullOrEmpty(publicCert))
{
throw new InvalidOperationException("Private certificate not found for the client.");
}
// import rsa keys and configure RSA for decryption
using var rsa = RSA.Create(2048);
rsa.ImportRSAPublicKey(Convert.FromBase64String(publicCert), out _);
rsa.ImportRSAPrivateKey(Convert.FromBase64String(privateCert), out _);
// Decrypt the encrypted text using RSA
string? plainText = null;
try
{
var encryptedBytes = Convert.FromBase64String(encryptedText);
var decryptedBytes = rsa.Decrypt(encryptedBytes, RSAEncryptionPadding.OaepSHA256);
plainText = System.Text.Encoding.UTF8.GetString(decryptedBytes);
}
catch (CryptographicException ex)
{
// Handle decryption errors
throw new InvalidOperationException("Decryption failed.", ex);
}
return plainText;
}
public string GetPublicCertificate(string clientId)
{
if (_cache.TryGetValue($"{clientId}_public", out string? publicCert) && !string.IsNullOrEmpty(publicCert))
{
return publicCert;
}
(publicCert, _) = GenerateCertificate();
_cache.Set($"{clientId}_public", publicCert, _CacheOptions);
return publicCert;
}
public string GetPrivateCertificate(string clientId)
{
if (_cache.TryGetValue($"{clientId}_private", out string? privateCert) && !string.IsNullOrEmpty(privateCert))
{
return privateCert;
}
(_, privateCert) = GenerateCertificate();
_cache.Set($"{clientId}_private", privateCert, _CacheOptions);
return privateCert;
}
private static (string publicCert, string privateCert) GenerateCertificate()
{
// Generate a new RSA key pair for the client
using var rsa = RSA.Create(2048);
var publicKey = rsa.ExportRSAPublicKey();
var privateKey = rsa.ExportRSAPrivateKey();
// Convert to Base64 strings for storage
var publicCert = Convert.ToBase64String(publicKey);
var privateCert = Convert.ToBase64String(privateKey);
return (publicCert, privateCert);
}
public string? Hash(string plainText)
{
string? hash = null;
if (string.IsNullOrEmpty(plainText))
{
return hash;
}
var plainBytes = System.Text.Encoding.UTF8.GetBytes(plainText);
var hashBytes = SHA256.HashData(plainBytes);
hash = Convert.ToBase64String(hashBytes);
return hash;
}
public bool VerifyHash(string plainText, string hash)
{
var plainTextHash = Hash(plainText);
if (string.IsNullOrEmpty(plainTextHash) || string.IsNullOrEmpty(hash))
{
return false;
}
return plainTextHash.Equals(hash, StringComparison.OrdinalIgnoreCase);
}
public string Pepper()
{
// get pepper from environtment variable
var pepper = Environment.GetEnvironmentVariable("PEPPER");
if (string.IsNullOrEmpty(pepper))
{
return "BactilForteFlash20mg";
}
return pepper;
}
public string Salt()
{
var saltBytes = new byte[32]; // 256 bits
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(saltBytes);
return Convert.ToBase64String(saltBytes);
}
}

View File

@@ -0,0 +1,13 @@
namespace back.services.Crypto;
public interface ICryptoService
{
string? Encrypt(string clientId, string plainText);
string? Decrypt(string clientId, string encryptedText);
string? Hash(string plainText);
bool VerifyHash(string plainText, string hash);
string Salt();
string Pepper();
string GetPublicCertificate(string clientId);
string GetPrivateCertificate(string clientId);
}