Токены, правильная работа

This commit is contained in:
Халимов Рустам
2026-04-19 22:13:20 +03:00
parent 70acad56fb
commit 9e715fe3ab
18 changed files with 422 additions and 45 deletions

View File

@@ -5,5 +5,8 @@ namespace Knot.Modules.Auth.Application.Abstractions;
public interface IJwtTokenProvider
{
string Generate(User user);
string Generate(Guid userId, string username, string displayName, string? avatar);
string GenerateRefreshToken();
DateTime GetRefreshTokenExpiry();
}

View File

@@ -1,4 +1,6 @@
using Knot.Contracts.Auth.Application.Auth.DTOs;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Shared.Kernel;
@@ -9,10 +11,12 @@ public sealed record GetMeQuery(Guid UserId) : IQuery<AuthResponseDto>;
internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
private readonly IJwtTokenProvider _tokenProvider;
public GetMeQueryHandler(IUserRepository userRepository)
public GetMeQueryHandler(IUserRepository userRepository, IJwtTokenProvider tokenProvider)
{
_userRepository = userRepository;
_tokenProvider = tokenProvider;
}
public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken)
@@ -23,9 +27,18 @@ internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponse
return Result.Failure<AuthResponseDto>(AuthErrors.UserNotFound);
}
// Check if access token needs to be refreshed (less than 1 hour remaining)
string? newAccessToken = null;
// We can't directly check the current token's expiry here, but we can
// always issue a new token if the user is authenticated
// For now, let's issue a new token on every request (simplified approach)
// A better approach would be to parse the incoming token and check expiry
newAccessToken = _tokenProvider.Generate(user);
var response = new AuthResponseDto
{
AccessToken = string.Empty,
AccessToken = newAccessToken,
RefreshToken = string.Empty,
UserId = user.Id,
Username = user.Username,

View File

@@ -5,9 +5,6 @@ using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Application.Users.Login;
/// <summary>
/// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> AuthResponseDto.
/// </summary>
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto>
@@ -31,14 +28,21 @@ public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand,
}
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
string refreshToken = _tokenProvider.GenerateRefreshToken();
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
// Save refresh token to database
user.SetRefreshToken(refreshToken, refreshExpiry);
await _userRepository.UpdateAsync(user, cancellationToken);
return Result.Success(new AuthResponseDto
{
AccessToken = token,
RefreshToken = string.Empty,
RefreshToken = refreshToken,
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName
});
}
}

View File

@@ -0,0 +1,7 @@
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
public record RefreshTokenCommand(string RefreshToken) : ICommand<AuthResponseDto>;

View File

@@ -0,0 +1,65 @@
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Contracts.Auth.Domain;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
internal sealed class RefreshTokenCommandHandler : ICommandHandler<RefreshTokenCommand, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
private readonly IJwtTokenProvider _tokenProvider;
public RefreshTokenCommandHandler(
IUserRepository userRepository,
IJwtTokenProvider tokenProvider)
{
_userRepository = userRepository;
_tokenProvider = tokenProvider;
}
public async Task<Result<AuthResponseDto>> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.RefreshToken))
{
return Result.Failure<AuthResponseDto>(
new Error("Auth.InvalidRefreshToken", "Refresh token is required"));
}
var user = await _userRepository.GetByRefreshTokenAsync(request.RefreshToken, cancellationToken);
if (user == null)
{
return Result.Failure<AuthResponseDto>(
new Error("Auth.InvalidRefreshToken", "Invalid or expired refresh token"));
}
// Check if refresh token has expired
if (user.RefreshTokenExpiry.HasValue && user.RefreshTokenExpiry.Value < DateTime.UtcNow)
{
// Clear expired refresh token
user.SetRefreshToken(null, null);
await _userRepository.UpdateAsync(user, cancellationToken);
return Result.Failure<AuthResponseDto>(
new Error("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again."));
}
var newAccessToken = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
var newRefreshToken = _tokenProvider.GenerateRefreshToken();
var newRefreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
user.SetRefreshToken(newRefreshToken, newRefreshExpiry);
await _userRepository.UpdateAsync(user, cancellationToken);
return Result.Success(new AuthResponseDto
{
AccessToken = newAccessToken,
RefreshToken = newRefreshToken,
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName,
Avatar = user.Avatar
});
}
}

View File

@@ -1,5 +1,4 @@
using BCrypt.Net;
using BCrypt.Net;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Contracts.Settings.Application.Abstractions;
@@ -9,9 +8,6 @@ using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Application.Users.Register;
/// <summary>
/// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>.
/// </summary>
public sealed record RegisterUserCommand(
string Username,
string Password,
@@ -19,9 +15,6 @@ public sealed record RegisterUserCommand(
string? Email,
string? Bio) : ICommand<AuthResponseDto>;
/// <summary>
/// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>.
/// </summary>
internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
@@ -48,16 +41,13 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled);
}
// 1. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> username
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
{
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique);
}
// 2. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
// 3. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
var user = User.Create(
request.Username,
passwordHash,
@@ -65,21 +55,31 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
request.Email,
request.Bio);
// 4. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> - <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20> Domain User
var repoWithDomainUserAdd = _userRepository as Infrastructure.Persistence.UserRepository;
repoWithDomainUserAdd?.Add(user);
var repoImpl = _userRepository as Infrastructure.Persistence.UserRepository;
repoImpl?.Add(user);
await _unitOfWork.SaveChangesAsync(cancellationToken);
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
// Get the saved user as contract
var userContract = await _userRepository.GetByUsernameAsync(request.Username, cancellationToken);
if (userContract == null)
{
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationFailed);
}
string token = _tokenProvider.Generate(userContract.Id, userContract.Username, userContract.DisplayName, userContract.Avatar);
string refreshToken = _tokenProvider.GenerateRefreshToken();
userContract.SetRefreshToken(refreshToken);
await _userRepository.UpdateAsync(userContract, cancellationToken);
return Result.Success(new AuthResponseDto
{
AccessToken = token,
RefreshToken = string.Empty,
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName
RefreshToken = refreshToken,
UserId = userContract.Id,
Username = userContract.Username,
DisplayName = userContract.DisplayName
});
}
}

View File

@@ -26,6 +26,7 @@ public sealed class User : AggregateRoot<Guid>
public bool IsBanned { get; private set; }
public string? PhoneNumber { get; private set; }
public string? RefreshToken { get; private set; }
public DateTime? RefreshTokenExpiry { get; private set; }
public DateTime? BannedUntil { get; private set; }
public void Ban() {
@@ -48,9 +49,10 @@ public sealed class User : AggregateRoot<Guid>
PhoneNumber = phoneNumber;
}
public void SetRefreshToken(string? refreshToken)
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
{
RefreshToken = refreshToken;
RefreshTokenExpiry = expiry;
}
public void SetBannedUntil(DateTime? bannedUntil)
@@ -79,6 +81,8 @@ public sealed class User : AggregateRoot<Guid>
BannedUntil = contract.BannedUntil;
SetOnline(contract.IsOnline, contract.LastSeen);
UserDomain = contract.Domain;
RefreshToken = contract.RefreshToken;
RefreshTokenExpiry = contract.RefreshTokenExpiry;
}
private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null)
@@ -181,7 +185,9 @@ public sealed class User : AggregateRoot<Guid>
IsOnline = IsOnline,
IsExternal = IsExternal,
Domain = _domain,
LastSeen = LastSeen
LastSeen = LastSeen,
RefreshToken = RefreshToken,
RefreshTokenExpiry = RefreshTokenExpiry
};
}
}

View File

@@ -50,6 +50,12 @@ internal sealed class JwtTokenProvider : IJwtTokenProvider
return Convert.ToBase64String(randomBytes);
}
public DateTime GetRefreshTokenExpiry()
{
var expiryInDays = int.Parse(_configuration["Jwt:RefreshExpiryInDays"] ?? "30");
return DateTime.UtcNow.AddDays(expiryInDays);
}
public string Generate(Guid userId, string username, string displayName, string? avatar)
{
var claims = new Claim[]

View File

@@ -0,0 +1,101 @@
using System;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AuthDbContext))]
[Migration("20270419220000_AddRefreshTokenExpiry")]
partial class AddRefreshTokenExpiry
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0-rc.1.25451.105")
.HasAnnotation("Relational:DefaultSchema", "identity");
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime?>("BannedUntil")
.HasColumnType("timestamp with time zone");
b.Property<string>("Bio")
.HasColumnType("text");
b.Property<DateTime>("Birthday")
.HasColumnType("timestamp with time zone")
.IsRequired(false);
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<string>("Domain")
.HasColumnType("text");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("HideStatus")
.HasColumnType("boolean");
b.Property<bool>("HideStoryViews")
.HasColumnType("boolean");
b.Property<bool>("IsBanned")
.HasColumnType("boolean");
b.Property<bool>("IsExternal")
.HasColumnType("boolean");
b.Property<bool>("IsOnline")
.HasColumnType("boolean");
b.Property<DateTime?>("LastSeen")
.HasColumnType("timestamp with time zone");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<string>("RefreshToken")
.HasColumnType("text");
b.Property<DateTime?>("RefreshTokenExpiry")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users", "identity");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
/// <inheritdoc />
public partial class AddRefreshTokenExpiry : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "RefreshTokenExpiry",
schema: "identity",
table: "Users",
type: "timestamp with time zone",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "RefreshTokenExpiry",
schema: "identity",
table: "Users");
}
}
}

View File

@@ -1,12 +1,13 @@
using Knot.Shared.Kernel;
using Knot.Modules.Auth.Application.Users.Login;
using Knot.Modules.Auth.Application.Users.Register;
using Knot.Modules.Auth.Application.Users.GetMe;
using Knot.Modules.Auth.Application.Users.Login;
using Knot.Modules.Auth.Application.Users.RefreshToken;
using Knot.Modules.Auth.Application.Users.Register;
using Knot.Shared.Kernel;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
namespace Knot.Modules.Auth.Presentation.Endpoints;
@@ -28,6 +29,12 @@ public static class AuthEndpoints
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
});
group.MapPost("refresh", async ([FromBody] RefreshTokenCommand command, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
});
group.MapGet("me", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new GetMeQuery(userContext.UserId), ct);