diff --git a/Butter/Dtos/AuthResultDto.cs b/Butter/Dtos/AuthResultDto.cs new file mode 100644 index 0000000..dc633ea --- /dev/null +++ b/Butter/Dtos/AuthResultDto.cs @@ -0,0 +1,9 @@ +namespace Butter.Dtos; + +public class AuthResultDto { + public Guid? UserId { get; set; } + public string? Token { get; set; } + public string? RefreshToken { get; set; } + public string? ErrorMessage { get; set; } + public bool Success { get; set; } +} diff --git a/Butter/Dtos/RefreshDto.cs b/Butter/Dtos/RefreshDto.cs new file mode 100644 index 0000000..27a26a3 --- /dev/null +++ b/Butter/Dtos/RefreshDto.cs @@ -0,0 +1,6 @@ +namespace Butter.Dtos; + +public class RefreshDto { + public required Guid UserId { get; set; } + public required string RefreshToken { get; set; } +} diff --git a/Lactose/Authorization/RefreshTokenAuthorization.cs b/Lactose/Authorization/RefreshTokenAuthorization.cs new file mode 100644 index 0000000..9071bfa --- /dev/null +++ b/Lactose/Authorization/RefreshTokenAuthorization.cs @@ -0,0 +1,65 @@ +using Lactose.Repositories; +using Lactose.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using System.Security.Claims; + +namespace Lactose.Authorization; + +/* + * Technical note: + * + * The RefreshTokenTransformation is used to add a claim to the user's claims principal that indicates if + * the user's login is still valid. while UnexpiredLoginRequirement is used to check the claim. + * + * More Claim Transformation and Requirements can be added with IClaimsTransformation IAuthorizationRequirement and + * added to Program.cs + */ + +public class UnexpiredLoginRequirement : IAuthorizationRequirement { + + public class RefreshTokenValidityHandler : AuthorizationHandler { + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + UnexpiredLoginRequirement requirement + ) { + Claim? firstOrDefault + = context.User.Claims.FirstOrDefault(claim => claim.Type == RefreshTokenTransformation.ClaimType); + + if (firstOrDefault is not { Value: "true" }) { + context.Fail(); + return Task.CompletedTask; + } + + context.Succeed(requirement); + return Task.CompletedTask; + } + } +} + +public class RefreshTokenTransformation( + IUserRepository userRepository, + LactoseAuthService authService + ) : IClaimsTransformation +{ + public const string ClaimType = "LoginValid"; + public Task TransformAsync(ClaimsPrincipal principal) + { + ClaimsIdentity claimsIdentity = new ClaimsIdentity(); + if (principal.HasClaim(claim => claim.Type == ClaimType)) { + foreach (ClaimsIdentity identity in principal.Identities) { + if (identity.HasClaim(claim => claim.Type == ClaimType)) { + identity.RemoveClaim(identity.FindFirst(ClaimType)!); + } + } + } + var userData = authService.GetUserData(principal); + if (userData == null) return Task.FromResult(principal); + var user = userRepository.Find(userData.Id); + if (user == null) return Task.FromResult(principal); + if (string.IsNullOrEmpty(user.RefreshToken) || user.RefreshTokenExpires == null || user.RefreshTokenExpires < DateTime.Now) return Task.FromResult(principal); + claimsIdentity.AddClaim(new Claim(ClaimType, "true", ClaimValueTypes.Boolean)); + principal.AddIdentity(claimsIdentity); + return Task.FromResult(principal); + } +} diff --git a/Lactose/Configuration/SignKeyConfiguration.cs b/Lactose/Configuration/SignKeyConfiguration.cs index 914e33d..415c29f 100644 --- a/Lactose/Configuration/SignKeyConfiguration.cs +++ b/Lactose/Configuration/SignKeyConfiguration.cs @@ -4,8 +4,8 @@ using System.Text; namespace Lactose.Configuration; public class SignKeyConfiguration { - public const string Position = "SignKey"; - public string Key { get; set; } = "32_CHARACTERS_KEY_IS_REQUIRED_TO_WORK"; + public const string SectionName = "SignKey"; + public string Key { get; set; } = string.Empty; public SecurityKey GetSecurityKey() => new SymmetricSecurityKey(Encoding.UTF8.GetBytes(this.Key)); } diff --git a/Lactose/Configuration/SignKeyProvider.cs b/Lactose/Configuration/SignKeyProvider.cs new file mode 100644 index 0000000..cd6daa0 --- /dev/null +++ b/Lactose/Configuration/SignKeyProvider.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Options; + +namespace Lactose.Configuration; + +public class SignKeyProvider(IConfiguration configuration) : IConfigureOptions { + + const string SectionName = SignKeyConfiguration.SectionName+":Key"; + public void Configure(SignKeyConfiguration options) { + + if (configuration[SectionName]!.Length <= 32 ) { + throw new Exception("SignKey:Key is not 256 bits long"); + } + options.Key = configuration[SectionName] ?? throw new Exception("SignKey:Key is not set"); + } + + public SignKeyConfiguration Get() { + var options = new SignKeyConfiguration(); + this.Configure(options); + return options; + } +} diff --git a/Lactose/Controllers/AuthController.cs b/Lactose/Controllers/AuthController.cs index d265bf3..618be5b 100644 --- a/Lactose/Controllers/AuthController.cs +++ b/Lactose/Controllers/AuthController.cs @@ -4,6 +4,7 @@ using Butter.Types; using Lactose.Models; using Lactose.Repositories; using Lactose.Services; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -17,9 +18,10 @@ public class AuthController( IUserRepository userRepository, IPasswordHasher passwordHasher ) : ControllerBase { - //TODO: Reply with Authentication Result + + [HttpPost("login")] - public ActionResult Login([FromBody] CredentialsDto userDto) { + public ActionResult Login([FromBody] CredentialsDto userDto) { User? user; if (userDto.Identifier.Contains('@')) { @@ -29,17 +31,32 @@ public class AuthController( //search for user in database (by username) user = userRepository.FindByUsername(userDto.Identifier); } + if (user == null) { + return NotFound(new AuthResultDto() { + Success = false, + ErrorMessage = "User or Password was wrong" + } + ); + } + if (user.BannedAt != null) { + return StatusCode(StatusCodes.Status100Continue,new AuthResultDto() { + Success = false, + ErrorMessage = "User is banned" + } + ); + } - if (user == null) { return NotFound("User or Password was wrong"); } - - if (user.BannedAt != null) { return Forbid("User is banned"); } - - if (user.DeletedAt != null) { return Forbid("User is disabled"); } - + if (user.DeletedAt != null) { + return StatusCode(StatusCodes.Status100Continue,new AuthResultDto() { + Success = false, + ErrorMessage = "User is disabled" + } + ); + } PasswordVerificationResult result = passwordHasher.VerifyHashedPassword(user, user.Password, userDto.Password); switch (result) { - case PasswordVerificationResult.Failed: return NotFound("User or Password was wrong"); + case PasswordVerificationResult.Failed: return NotFound(new AuthResultDto() { Success = false, ErrorMessage = "User or Password was wrong"}); case PasswordVerificationResult.SuccessRehashNeeded: user.Password = passwordHasher.HashPassword(user, userDto.Password); userRepository.Save(); @@ -48,8 +65,16 @@ public class AuthController( } user.LastLogin = DateTime.Now; + + var token = authService.GenerateAccessToken(user); + var refreshToken = authService.GenerateRefreshToken(user); userRepository.Save(); - return authService.GenerateToken(user); + return new AuthResultDto() { + UserId = user.Id, + Token = token, + RefreshToken = refreshToken, + Success = true + }; } //TODO: Switch Guid reply with Authentication Result (giving a complete reason in case of failure or giving the authentication token) @@ -82,21 +107,90 @@ public class AuthController( return Ok(user.Id); } + [Authorize] [HttpPost()] - public ActionResult Check() { + public ActionResult Check() { // this.User; //ClaimsPrincipal principal = User; LactoseAuthenticatedUser? identity = authService.GetUserData(User); return Ok(identity); } + [Authorize] [HttpPost("logout")] public ActionResult Logout() { LactoseAuthenticatedUser? identity = authService.GetUserData(User); if (identity == null) { return Unauthorized(); } - - authService.RevokeAccess(identity.Id); + + //Reset token from the database + User? user = userRepository.Find(identity.Id); + if (user == null) { return Unauthorized(); } + user.RefreshToken = string.Empty; + user.RefreshTokenExpires = null; + userRepository.Save(); + return Ok(); } + + + /* + * Refresh access Token using the Refresh Token + */ + + [HttpPost("refresh")] + public ActionResult RefreshToken([FromBody] RefreshDto token) { + User? user = userRepository.Find(token.UserId); + + if (user == null) { + return NotFound( + new AuthResultDto() { + Success = false, + ErrorMessage = "No user found" + } + ); + } + + if (user.BannedAt != null) { + return StatusCode( + StatusCodes.Status100Continue, + new AuthResultDto() { + Success = false, + ErrorMessage = "User is banned" + } + ); + } + + if (user.DeletedAt != null) { + return StatusCode( + StatusCodes.Status100Continue, + new AuthResultDto() { + Success = false, + ErrorMessage = "User is disabled" + } + ); + } + + if (!authService.ValidateRefreshToken(user, token.RefreshToken)) { + return Unauthorized( + new AuthResultDto() { + Success = false, + ErrorMessage = "Invalid refresh token" + } + ); + } + + var authDto = new AuthResultDto() { + Success = true, + Token = authService.GenerateAccessToken(user) + }; + + // TODO: Decide if we want to change or extend refresh token + if (user.RefreshTokenExpires > DateTime.Now - TimeSpan.FromMinutes(LactoseAuthService.BaseExpirationTime)) { + user.RefreshTokenExpires = DateTime.Now.AddMinutes(LactoseAuthService.LongExpirationTime); + userRepository.Save(); + } + + return authDto; + } } diff --git a/Lactose/Migrations/20250604233318_AddRefreshToken.Designer.cs b/Lactose/Migrations/20250604233318_AddRefreshToken.Designer.cs new file mode 100644 index 0000000..5148d9e --- /dev/null +++ b/Lactose/Migrations/20250604233318_AddRefreshToken.Designer.cs @@ -0,0 +1,452 @@ +// +using System; +using Lactose.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Lactose.Migrations +{ + [DbContext(typeof(LactoseDbContext))] + [Migration("20250604233318_AddRefreshToken")] + partial class AddRefreshToken + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.15") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AlbumAsset", b => + { + b.Property("AlbumsId") + .HasColumnType("uuid"); + + b.Property("AssetsId") + .HasColumnType("uuid"); + + b.HasKey("AlbumsId", "AssetsId"); + + b.HasIndex("AssetsId"); + + b.ToTable("AlbumAsset"); + }); + + modelBuilder.Entity("AssetTag", b => + { + b.Property("AssetsId") + .HasColumnType("uuid"); + + b.Property("TagsId") + .HasColumnType("uuid"); + + b.HasKey("AssetsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("AssetTag"); + }); + + modelBuilder.Entity("Lactose.Models.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("timestamp without time zone"); + + b.Property("PersonOwnerId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("UserOwnerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PersonOwnerId"); + + b.HasIndex("UserOwnerId"); + + b.ToTable("Albums"); + }); + + modelBuilder.Entity("Lactose.Models.Asset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("Duration") + .HasColumnType("real"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("FolderId") + .HasColumnType("uuid"); + + b.Property("FrameRate") + .HasColumnType("real"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("BYTEA"); + + b.Property("IsPubliclyShared") + .HasColumnType("boolean"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("OriginalFilename") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("OriginalPath") + .IsRequired() + .HasColumnType("VARCHAR(2048)"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("PreviewPath") + .IsRequired() + .HasColumnType("VARCHAR(2048)"); + + b.Property("ResolutionHeight") + .HasColumnType("integer"); + + b.Property("ResolutionWidth") + .HasColumnType("integer"); + + b.Property("ThumbnailPath") + .IsRequired() + .HasColumnType("VARCHAR(2048)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("OwnerId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Lactose.Models.Face", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("BoundingBoxX1") + .HasColumnType("integer"); + + b.Property("BoundingBoxX2") + .HasColumnType("integer"); + + b.Property("BoundingBoxY1") + .HasColumnType("integer"); + + b.Property("BoundingBoxY2") + .HasColumnType("integer"); + + b.Property("ImageHeight") + .HasColumnType("integer"); + + b.Property("ImageWidth") + .HasColumnType("integer"); + + b.Property("PersonId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("PersonId"); + + b.ToTable("Faces"); + }); + + modelBuilder.Entity("Lactose.Models.Folder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("BasePath") + .IsRequired() + .HasColumnType("VARCHAR(2048)"); + + b.HasKey("Id"); + + b.ToTable("Folders"); + }); + + modelBuilder.Entity("Lactose.Models.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Id"); + + b.ToTable("People"); + }); + + modelBuilder.Entity("Lactose.Models.Settings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("UserRegistrationOpen") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("Lactose.Models.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Lactose.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessLevel") + .HasColumnType("integer"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("BannedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("VARCHAR(128)"); + + b.Property("LastLogin") + .HasColumnType("timestamp without time zone"); + + b.Property("Password") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("RefreshTokenExpires") + .HasColumnType("timestamp without time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("Username") + .IsRequired() + .HasColumnType("VARCHAR(64)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("AlbumAsset", b => + { + b.HasOne("Lactose.Models.Album", null) + .WithMany() + .HasForeignKey("AlbumsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Lactose.Models.Asset", null) + .WithMany() + .HasForeignKey("AssetsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AssetTag", b => + { + b.HasOne("Lactose.Models.Asset", null) + .WithMany() + .HasForeignKey("AssetsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Lactose.Models.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Lactose.Models.Album", b => + { + b.HasOne("Lactose.Models.Person", "PersonOwner") + .WithMany("Albums") + .HasForeignKey("PersonOwnerId"); + + b.HasOne("Lactose.Models.User", "UserOwner") + .WithMany("OwnedAlbums") + .HasForeignKey("UserOwnerId"); + + b.Navigation("PersonOwner"); + + b.Navigation("UserOwner"); + }); + + modelBuilder.Entity("Lactose.Models.Asset", b => + { + b.HasOne("Lactose.Models.Folder", "Folder") + .WithMany("Assets") + .HasForeignKey("FolderId"); + + b.HasOne("Lactose.Models.User", "Owner") + .WithMany("OwnedAssets") + .HasForeignKey("OwnerId"); + + b.Navigation("Folder"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Lactose.Models.Face", b => + { + b.HasOne("Lactose.Models.Asset", "Asset") + .WithMany("Faces") + .HasForeignKey("AssetId"); + + b.HasOne("Lactose.Models.Person", "Person") + .WithMany("Faces") + .HasForeignKey("PersonId"); + + b.Navigation("Asset"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("Lactose.Models.Tag", b => + { + b.HasOne("Lactose.Models.Tag", "Parent") + .WithMany() + .HasForeignKey("ParentId"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Lactose.Models.User", b => + { + b.HasOne("Lactose.Models.Asset", null) + .WithMany("SharedWith") + .HasForeignKey("AssetId"); + }); + + modelBuilder.Entity("Lactose.Models.Asset", b => + { + b.Navigation("Faces"); + + b.Navigation("SharedWith"); + }); + + modelBuilder.Entity("Lactose.Models.Folder", b => + { + b.Navigation("Assets"); + }); + + modelBuilder.Entity("Lactose.Models.Person", b => + { + b.Navigation("Albums"); + + b.Navigation("Faces"); + }); + + modelBuilder.Entity("Lactose.Models.User", b => + { + b.Navigation("OwnedAlbums"); + + b.Navigation("OwnedAssets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Lactose/Migrations/20250604233318_AddRefreshToken.cs b/Lactose/Migrations/20250604233318_AddRefreshToken.cs new file mode 100644 index 0000000..a099050 --- /dev/null +++ b/Lactose/Migrations/20250604233318_AddRefreshToken.cs @@ -0,0 +1,282 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Lactose.Migrations +{ + /// + public partial class AddRefreshToken : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "UpdatedAt", + table: "Users", + type: "timestamp without time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "LastLogin", + table: "Users", + type: "timestamp without time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "DeletedAt", + table: "Users", + type: "timestamp without time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + table: "Users", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn( + name: "BannedAt", + table: "Users", + type: "timestamp without time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AddColumn( + name: "RefreshToken", + table: "Users", + type: "VARCHAR(255)", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "RefreshTokenExpires", + table: "Users", + type: "timestamp without time zone", + nullable: true); + + migrationBuilder.AlterColumn( + name: "UpdatedAt", + table: "People", + type: "timestamp without time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "DeletedAt", + table: "People", + type: "timestamp without time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + table: "People", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn( + name: "UpdatedAt", + table: "Assets", + type: "timestamp without time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Hash", + table: "Assets", + type: "BYTEA", + nullable: false, + oldClrType: typeof(byte[]), + oldType: "BLOB(128)"); + + migrationBuilder.AlterColumn( + name: "DeletedAt", + table: "Assets", + type: "timestamp without time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + table: "Assets", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn( + name: "UpdatedAt", + table: "Albums", + type: "timestamp without time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + table: "Albums", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RefreshToken", + table: "Users"); + + migrationBuilder.DropColumn( + name: "RefreshTokenExpires", + table: "Users"); + + migrationBuilder.AlterColumn( + name: "UpdatedAt", + table: "Users", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "LastLogin", + table: "Users", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "DeletedAt", + table: "Users", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + table: "Users", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "BannedAt", + table: "Users", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "UpdatedAt", + table: "People", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "DeletedAt", + table: "People", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + table: "People", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "UpdatedAt", + table: "Assets", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Hash", + table: "Assets", + type: "BLOB(128)", + nullable: false, + oldClrType: typeof(byte[]), + oldType: "BYTEA"); + + migrationBuilder.AlterColumn( + name: "DeletedAt", + table: "Assets", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + table: "Assets", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "UpdatedAt", + table: "Albums", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + table: "Albums", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + } + } +} diff --git a/Lactose/Migrations/LactoseDbContextModelSnapshot.cs b/Lactose/Migrations/LactoseDbContextModelSnapshot.cs index 0960c0d..be00f42 100644 --- a/Lactose/Migrations/LactoseDbContextModelSnapshot.cs +++ b/Lactose/Migrations/LactoseDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace Lactose.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("ProductVersion", "8.0.15") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -58,8 +58,9 @@ namespace Lactose.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("timestamp without time zone"); b.Property("PersonOwnerId") .HasColumnType("uuid"); @@ -69,7 +70,7 @@ namespace Lactose.Migrations .HasColumnType("VARCHAR(255)"); b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("UserOwnerId") .HasColumnType("uuid"); @@ -90,10 +91,10 @@ namespace Lactose.Migrations .HasColumnType("uuid"); b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("Duration") .HasColumnType("real"); @@ -109,7 +110,7 @@ namespace Lactose.Migrations b.Property("Hash") .IsRequired() - .HasColumnType("BLOB(128)"); + .HasColumnType("BYTEA"); b.Property("IsPubliclyShared") .HasColumnType("boolean"); @@ -147,7 +148,7 @@ namespace Lactose.Migrations .HasColumnType("integer"); b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.HasKey("Id"); @@ -222,17 +223,17 @@ namespace Lactose.Migrations .HasColumnType("uuid"); b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("Name") .IsRequired() .HasColumnType("VARCHAR(255)"); b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.HasKey("Id"); @@ -286,27 +287,34 @@ namespace Lactose.Migrations .HasColumnType("uuid"); b.Property("BannedAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("DeletedAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("Email") .IsRequired() .HasColumnType("VARCHAR(128)"); b.Property("LastLogin") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("Password") .IsRequired() .HasColumnType("VARCHAR(255)"); + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("VARCHAR(255)"); + + b.Property("RefreshTokenExpires") + .HasColumnType("timestamp without time zone"); + b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("Username") .IsRequired() diff --git a/Lactose/Models/User.cs b/Lactose/Models/User.cs index f4b5f57..0c4ee32 100644 --- a/Lactose/Models/User.cs +++ b/Lactose/Models/User.cs @@ -63,6 +63,11 @@ public class User { /// Gets or sets the access level of the user. /// public EAccessLevel AccessLevel { get; set; } + + [Column(TypeName = "VARCHAR(255)")][JsonIgnore] + public string RefreshToken { get; set; } = string.Empty; + + public DateTime? RefreshTokenExpires { get; set; } #region Navigation Properties diff --git a/Lactose/Program.cs b/Lactose/Program.cs index d8b0d11..7e5b7d9 100644 --- a/Lactose/Program.cs +++ b/Lactose/Program.cs @@ -1,9 +1,12 @@ +using Lactose.Authorization; using Lactose.Configuration; using Lactose.Context; using Lactose.Models; using Lactose.Repositories; using Lactose.Services; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; @@ -17,13 +20,7 @@ builder.Configuration.AddJsonFile("appsettings.json", true, true) builder.Services.Configure(options => options.LowercaseUrls = true); -//TODO: Security Issue, Switch to a more secrete way to pass the key -//TODO: Switch to use a binder to Fix configuration not being loaded with GetSection -builder.Services.AddOptions().Bind(builder.Configuration.GetSection(SignKeyConfiguration.Position)); -if (builder.Configuration.Get()!.Key.Length<32) { - throw new Exception("Key is not 256 bits long"); -} - +builder.Services.ConfigureOptions(); string? user = builder.Configuration["DatabaseCredentials:UserID"]; string? password = builder.Configuration["DatabaseCredentials:Password"]; @@ -76,6 +73,7 @@ builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); +builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddControllers(); @@ -106,7 +104,7 @@ builder.Services.AddSwaggerGen( }, }, - new string [] {} + [] } }); @@ -115,7 +113,24 @@ builder.Services.AddSwaggerGen( options.UseInlineDefinitionsForEnums(); } ); +SecurityKey securityKey = new SignKeyProvider(builder.Configuration).Get().GetSecurityKey(); +builder.Services.AddTransient(); + +var policy = new AuthorizationPolicyBuilder() + .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser() + .RequireClaim(RefreshTokenTransformation.ClaimType) + .Build(); + +builder.Services.AddAuthorization(options => +{ + AuthorizationPolicy defaultPolicy = + new AuthorizationPolicyBuilder(JwtBearerDefaults.AuthenticationScheme) + .RequireAuthenticatedUser() + .RequireClaim(RefreshTokenTransformation.ClaimType,"true") + .Build(); +}); builder.Services .AddAuthentication(x => @@ -129,14 +144,13 @@ builder.Services x.SaveToken = true; x.TokenValidationParameters = new TokenValidationParameters { - //TODO Load from configuration or file - - IssuerSigningKey = builder.Configuration.Get()!.GetSecurityKey(), + IssuerSigningKey = securityKey, ValidateIssuer = false, ValidateAudience = false }; }); + WebApplication app = builder.Build(); using (var scope = app.Services.CreateScope()) { diff --git a/Lactose/Services/LactoseAuthService.cs b/Lactose/Services/LactoseAuthService.cs index 6cb2039..bfca200 100644 --- a/Lactose/Services/LactoseAuthService.cs +++ b/Lactose/Services/LactoseAuthService.cs @@ -2,36 +2,24 @@ using Butter.Types; using Lactose.Configuration; using Lactose.Models; using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; -using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; namespace Lactose.Services; -public class LactoseAuthService(ILogger logger,IOptions keyOption) { - - public string GenerateToken(User user) { - var handler = new JwtSecurityTokenHandler(); +public class LactoseAuthService( + ILogger logger, + ITokenService tokenService) +{ + public const int BaseExpirationTime = 10; + public const int LongExpirationTime = 60; - SecurityKey key = keyOption.Value.GetSecurityKey(); - - if (key.KeySize < 256) { - logger.LogError("Signing key is not 256 bits long"); - throw new Exception("Key is not 256 bits long"); - } + public string GenerateAccessToken(User user) => tokenService.GenerateToken(GenerateClaims(user), BaseExpirationTime); - var credentials = new SigningCredentials( - keyOption.Value.GetSecurityKey(), - SecurityAlgorithms.HmacSha256Signature); - - var tokenDescriptor = new SecurityTokenDescriptor { - Subject = GenerateClaims(user), - Expires = DateTime.UtcNow.AddMinutes(30), - SigningCredentials = credentials, - }; - - var token = handler.CreateToken(tokenDescriptor); - return handler.WriteToken(token); + public string GenerateRefreshToken(User user) { + var token =tokenService.GenerateToken(LongExpirationTime); + user.RefreshToken = token; + user.RefreshTokenExpires = DateTime.Now.AddMinutes(LongExpirationTime); + return token; } static ClaimsIdentity GenerateClaims(User user) { @@ -60,9 +48,11 @@ public class LactoseAuthService(ILogger logger,IOptions signKey + ) : ITokenService { + + public const int BaseExpirationTime = 30; + + readonly TokenGenerator tokenGenerator = new TokenGenerator( + new SigningCredentials(signKey.Value.GetSecurityKey(), SecurityAlgorithms.HmacSha256Signature), + descriptor => { + var handler = new JwtSecurityTokenHandler(); + var token = handler.CreateToken(descriptor); + return handler.WriteToken(token); + } + ); + + public string GenerateToken(ClaimsIdentity identity, int expireTime = BaseExpirationTime) => tokenGenerator.Generate(identity, expireTime); + + public string GenerateToken(int expireTime = BaseExpirationTime) => tokenGenerator.Generate(expireTime); +} diff --git a/Lactose/Utils/TokenGeneratorProvvider.cs b/Lactose/Utils/TokenGeneratorProvvider.cs new file mode 100644 index 0000000..9efa180 --- /dev/null +++ b/Lactose/Utils/TokenGeneratorProvvider.cs @@ -0,0 +1,66 @@ +using Microsoft.IdentityModel.Tokens; +using System.Security.Claims; + +namespace Lactose.Utils; + +public class TokenBuilder { + + SigningCredentials? credentials; + int expireTime; + ClaimsIdentity? claims; + + public TokenBuilder(SigningCredentials? signingCredentials = null, int expireTime = 10) { + this.credentials = signingCredentials; + this.expireTime = expireTime; + } + + public TokenBuilder ExpireTime(int minutes) { + this.expireTime = expireTime; + return this; + } + + public TokenBuilder SigningCredentials(SigningCredentials signingCredentials) { + this.credentials = credentials; + return this; + } + + public TokenBuilder SetClaims(ClaimsIdentity identity) { + this.claims = identity; + return this; + } + + private SecurityTokenDescriptor PrepareSecurityTokenDescriptor() { + var descriptor = new SecurityTokenDescriptor { + SigningCredentials = credentials, + Expires = DateTime.UtcNow + }; + if (expireTime>0) descriptor.Expires = DateTime.UtcNow.AddMinutes(expireTime); + if (claims != null) descriptor.Subject = claims; + return descriptor; + } + + public SecurityToken GenerateToken(SecurityTokenHandler handler) => GenerateToken(handler.CreateToken); + + + public T GenerateToken(Func handler) { + T token = handler(PrepareSecurityTokenDescriptor()); + this.claims = null; + return token; + } +} + + +public class TokenGenerator(SigningCredentials credentials, Func handler) { + readonly TokenBuilder builder = new TokenBuilder(credentials); + + public string Generate(ClaimsIdentity claims, int expireTime) { + builder.ExpireTime(expireTime); + builder.SetClaims(claims); + return builder.GenerateToken(handler); + } + public string Generate(int expireTime) { + builder.ExpireTime(expireTime); + return builder.GenerateToken(handler); + } + +} diff --git a/Lactose/WepApiTest.http b/Lactose/WepApiTest.http index 42b71a5..695c422 100644 --- a/Lactose/WepApiTest.http +++ b/Lactose/WepApiTest.http @@ -44,9 +44,10 @@ Content-Type: application/json } > {% - client.global.set("auth_token", response.body); + client.global.set("auth_token", jsonPath(response.body, "$.token")); client.test("Login rng user", function () { client.assert(response.status === 200) + //client.assert(jsonPath(response.body, "$.success") != true,jsonPath(response.body, "$.errorMessage")??"null") }); %} @@ -59,7 +60,7 @@ Content-Type: application/json > {% client.test("API Authenticated Empty", function () { - client.assert(response.status === 204) + client.assert(response.status === 401) }); %} @@ -95,9 +96,10 @@ Content-Type: application/json } > {% - client.global.set("admin_token", response.body); + client.global.set("admin_token", jsonPath(response.body, "$.token")); client.test("Login as admin", function () { client.assert(response.status === 200) + //client.assert(jsonPath(response.body, "$.success") != true,jsonPath(response.body, "$.errorMessage")??"null") }); %}