Merge branch 'feature/webapi/refresh-token' into develop

This commit is contained in:
MrFastwind
2025-07-02 17:53:18 +02:00
15 changed files with 1122 additions and 72 deletions

View File

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

View File

@@ -0,0 +1,6 @@
namespace Butter.Dtos;
public class RefreshDto {
public required Guid UserId { get; set; }
public required string RefreshToken { get; set; }
}

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
using Microsoft.Extensions.Options;
namespace Lactose.Configuration;
public class SignKeyProvider(IConfiguration configuration) : IConfigureOptions<SignKeyConfiguration> {
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;
}
}

View File

@@ -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<User> passwordHasher
) : ControllerBase {
//TODO: Reply with Authentication Result
[HttpPost("login")]
public ActionResult<string> Login([FromBody] CredentialsDto userDto) {
public ActionResult<AuthResultDto> 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<LactoseAuthService> Check() {
public ActionResult<LactoseAuthenticatedUser> 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<AuthResultDto> 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;
}
}

View File

@@ -0,0 +1,452 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("AlbumsId")
.HasColumnType("uuid");
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.HasKey("AlbumsId", "AssetsId");
b.HasIndex("AssetsId");
b.ToTable("AlbumAsset");
});
modelBuilder.Entity("AssetTag", b =>
{
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.Property<Guid>("TagsId")
.HasColumnType("uuid");
b.HasKey("AssetsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("AssetTag");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("CreatedAt")
.IsRequired()
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("UserOwnerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("PersonOwnerId");
b.HasIndex("UserOwnerId");
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid");
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<byte[]>("Hash")
.IsRequired()
.HasColumnType("BYTEA");
b.Property<bool>("IsPubliclyShared")
.HasColumnType("boolean");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("OwnerId")
.HasColumnType("uuid");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.HasKey("Id");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.Settings", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("UserRegistrationOpen")
.HasColumnType("boolean");
b.HasKey("Id");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp without time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("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
}
}
}

View File

@@ -0,0 +1,282 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddRefreshToken : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<DateTime>(
name: "UpdatedAt",
table: "Users",
type: "timestamp without time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "LastLogin",
table: "Users",
type: "timestamp without time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "DeletedAt",
table: "Users",
type: "timestamp without time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "CreatedAt",
table: "Users",
type: "timestamp without time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone");
migrationBuilder.AlterColumn<DateTime>(
name: "BannedAt",
table: "Users",
type: "timestamp without time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldNullable: true);
migrationBuilder.AddColumn<string>(
name: "RefreshToken",
table: "Users",
type: "VARCHAR(255)",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<DateTime>(
name: "RefreshTokenExpires",
table: "Users",
type: "timestamp without time zone",
nullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "UpdatedAt",
table: "People",
type: "timestamp without time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "DeletedAt",
table: "People",
type: "timestamp without time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "CreatedAt",
table: "People",
type: "timestamp without time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone");
migrationBuilder.AlterColumn<DateTime>(
name: "UpdatedAt",
table: "Assets",
type: "timestamp without time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldNullable: true);
migrationBuilder.AlterColumn<byte[]>(
name: "Hash",
table: "Assets",
type: "BYTEA",
nullable: false,
oldClrType: typeof(byte[]),
oldType: "BLOB(128)");
migrationBuilder.AlterColumn<DateTime>(
name: "DeletedAt",
table: "Assets",
type: "timestamp without time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "CreatedAt",
table: "Assets",
type: "timestamp without time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone");
migrationBuilder.AlterColumn<DateTime>(
name: "UpdatedAt",
table: "Albums",
type: "timestamp without time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "CreatedAt",
table: "Albums",
type: "timestamp without time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "RefreshToken",
table: "Users");
migrationBuilder.DropColumn(
name: "RefreshTokenExpires",
table: "Users");
migrationBuilder.AlterColumn<DateTime>(
name: "UpdatedAt",
table: "Users",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "LastLogin",
table: "Users",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "DeletedAt",
table: "Users",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "CreatedAt",
table: "Users",
type: "timestamp with time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone");
migrationBuilder.AlterColumn<DateTime>(
name: "BannedAt",
table: "Users",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "UpdatedAt",
table: "People",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "DeletedAt",
table: "People",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "CreatedAt",
table: "People",
type: "timestamp with time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone");
migrationBuilder.AlterColumn<DateTime>(
name: "UpdatedAt",
table: "Assets",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone",
oldNullable: true);
migrationBuilder.AlterColumn<byte[]>(
name: "Hash",
table: "Assets",
type: "BLOB(128)",
nullable: false,
oldClrType: typeof(byte[]),
oldType: "BYTEA");
migrationBuilder.AlterColumn<DateTime>(
name: "DeletedAt",
table: "Assets",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "CreatedAt",
table: "Assets",
type: "timestamp with time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone");
migrationBuilder.AlterColumn<DateTime>(
name: "UpdatedAt",
table: "Albums",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "CreatedAt",
table: "Albums",
type: "timestamp with time zone",
nullable: false,
oldClrType: typeof(DateTime),
oldType: "timestamp without time zone");
}
}
}

View File

@@ -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<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("CreatedAt")
.IsRequired()
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
@@ -69,7 +70,7 @@ namespace Lactose.Migrations
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("UserOwnerId")
.HasColumnType("uuid");
@@ -90,10 +91,10 @@ namespace Lactose.Migrations
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
.HasColumnType("timestamp without time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
@@ -109,7 +110,7 @@ namespace Lactose.Migrations
b.Property<byte[]>("Hash")
.IsRequired()
.HasColumnType("BLOB(128)");
.HasColumnType("BYTEA");
b.Property<bool>("IsPubliclyShared")
.HasColumnType("boolean");
@@ -147,7 +148,7 @@ namespace Lactose.Migrations
.HasColumnType("integer");
b.Property<DateTime?>("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<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
.HasColumnType("timestamp without time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("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<DateTime?>("BannedAt")
.HasColumnType("timestamp with time zone");
.HasColumnType("timestamp without time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
.HasColumnType("timestamp without time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp with time zone");
.HasColumnType("timestamp without time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
.HasColumnType("timestamp without time zone");
b.Property<string>("Username")
.IsRequired()

View File

@@ -63,6 +63,11 @@ public class User {
/// Gets or sets the access level of the user.
/// </summary>
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

View File

@@ -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<RouteOptions>(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<SignKeyConfiguration>().Bind(builder.Configuration.GetSection(SignKeyConfiguration.Position));
if (builder.Configuration.Get<SignKeyConfiguration>()!.Key.Length<32) {
throw new Exception("Key is not 256 bits long");
}
builder.Services.ConfigureOptions<SignKeyProvider>();
string? user = builder.Configuration["DatabaseCredentials:UserID"];
string? password = builder.Configuration["DatabaseCredentials:Password"];
@@ -76,6 +73,7 @@ builder.Services.AddTransient<ITagRepository, TagRepository>();
builder.Services.AddTransient<IAssetRepository, AssetRepository>();
builder.Services.AddTransient<IAlbumRepository, AlbumRepository>();
builder.Services.AddTransient<IMediaRepository, MediaRepository>();
builder.Services.AddTransient<ITokenService, TokenService>();
builder.Services.AddTransient<LactoseAuthService>();
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<IClaimsTransformation, RefreshTokenTransformation>();
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<SignKeyConfiguration>()!.GetSecurityKey(),
IssuerSigningKey = securityKey,
ValidateIssuer = false,
ValidateAudience = false
};
});
WebApplication app = builder.Build();
using (var scope = app.Services.CreateScope()) {

View File

@@ -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<LactoseAuthService> logger,IOptions<SignKeyConfiguration> keyOption) {
public string GenerateToken(User user) {
var handler = new JwtSecurityTokenHandler();
public class LactoseAuthService(
ILogger<LactoseAuthService> 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<LactoseAuthService> logger,IOptions<Sign
(EAccessLevel)Enum.Parse(typeof(EAccessLevel),eAccessLevel)
);
}
public void RevokeAccess(Guid id) {
//TODO: Revoke access to user
public bool ValidateRefreshToken(User user, string tokenRefreshToken) {
if (user.RefreshToken != tokenRefreshToken) return false;
if (user.RefreshTokenExpires < DateTime.Now) return false;
return true;
}
}

View File

@@ -0,0 +1,36 @@
using Lactose.Configuration;
using Lactose.Models;
using Lactose.Repositories;
using Lactose.Utils;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.Collections.Concurrent;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace Lactose.Services;
public interface ITokenService {
string GenerateToken(ClaimsIdentity identity, int expireTime);
string GenerateToken(int expireTime);
}
public class TokenService (
IOptions<SignKeyConfiguration> 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);
}

View File

@@ -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<T>(Func<SecurityTokenDescriptor, T> handler) {
T token = handler(PrepareSecurityTokenDescriptor());
this.claims = null;
return token;
}
}
public class TokenGenerator(SigningCredentials credentials, Func<SecurityTokenDescriptor, string> 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);
}
}

View File

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