Compare commits
3
Commits
8c8ef55b58
...
f6400ce3ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6400ce3ac | ||
|
|
d9462069e2 | ||
|
|
9e715fe3ab |
@@ -4,6 +4,7 @@ public interface IJwtTokenProvider
|
|||||||
{
|
{
|
||||||
string GenerateAccessToken(Guid userId, string username);
|
string GenerateAccessToken(Guid userId, string username);
|
||||||
string GenerateRefreshToken();
|
string GenerateRefreshToken();
|
||||||
|
DateTime GetRefreshTokenExpiry();
|
||||||
string Generate(Guid userId, string username, string displayName, string? avatar);
|
string Generate(Guid userId, string username, string displayName, string? avatar);
|
||||||
string Generate(Domain.UserContract user);
|
string Generate(Domain.UserContract user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,7 @@ public static class AuthErrors
|
|||||||
public static Error IdentityInvalidCredentials => new("Auth.InvalidCredentials", "Invalid credentials");
|
public static Error IdentityInvalidCredentials => new("Auth.InvalidCredentials", "Invalid credentials");
|
||||||
public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled");
|
public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled");
|
||||||
public static Error IdentityUsernameNotUnique => new("Auth.UsernameNotUnique", "Username is already taken");
|
public static Error IdentityUsernameNotUnique => new("Auth.UsernameNotUnique", "Username is already taken");
|
||||||
|
public static Error IdentityRegistrationFailed => new("Auth.RegistrationFailed", "Failed to register user");
|
||||||
|
public static Error RefreshTokenExpired => new("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again.");
|
||||||
public static Error UserNotFound => new("Auth.UserNotFound", "User not found");
|
public static Error UserNotFound => new("Auth.UserNotFound", "User not found");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,4 +19,12 @@ public class UserContract
|
|||||||
public bool IsExternal { get; set; }
|
public bool IsExternal { get; set; }
|
||||||
public string? Domain { get; set; }
|
public string? Domain { get; set; }
|
||||||
public DateTime? LastSeen { get; set; }
|
public DateTime? LastSeen { get; set; }
|
||||||
|
public string? RefreshToken { get; set; }
|
||||||
|
public DateTime? RefreshTokenExpiry { get; set; }
|
||||||
|
|
||||||
|
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
|
||||||
|
{
|
||||||
|
RefreshToken = refreshToken;
|
||||||
|
RefreshTokenExpiry = expiry;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"Secret": "knot_super_secret_key_1234567890_knot",
|
"Secret": "knot_super_secret_key_1234567890_knot",
|
||||||
"Issuer": "Knot",
|
"Issuer": "Knot",
|
||||||
"Audience": "KnotUsers",
|
"Audience": "KnotUsers",
|
||||||
"ExpiryInMinutes": 1440
|
"ExpiryInMinutes": 1440,
|
||||||
|
"RefreshExpiryInDays": 30
|
||||||
},
|
},
|
||||||
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
|
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
|
||||||
}
|
}
|
||||||
@@ -5,5 +5,8 @@ namespace Knot.Modules.Auth.Application.Abstractions;
|
|||||||
public interface IJwtTokenProvider
|
public interface IJwtTokenProvider
|
||||||
{
|
{
|
||||||
string Generate(User user);
|
string Generate(User user);
|
||||||
|
string Generate(Guid userId, string username, string displayName, string? avatar);
|
||||||
|
string GenerateRefreshToken();
|
||||||
|
DateTime GetRefreshTokenExpiry();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.Contracts.Auth.Application.Auth.DTOs;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
|
|
||||||
@@ -9,10 +11,12 @@ public sealed record GetMeQuery(Guid UserId) : IQuery<AuthResponseDto>;
|
|||||||
internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto>
|
internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto>
|
||||||
{
|
{
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly IJwtTokenProvider _tokenProvider;
|
||||||
|
|
||||||
public GetMeQueryHandler(IUserRepository userRepository)
|
public GetMeQueryHandler(IUserRepository userRepository, IJwtTokenProvider tokenProvider)
|
||||||
{
|
{
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
|
_tokenProvider = tokenProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken)
|
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);
|
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
|
var response = new AuthResponseDto
|
||||||
{
|
{
|
||||||
AccessToken = string.Empty,
|
AccessToken = newAccessToken,
|
||||||
RefreshToken = string.Empty,
|
RefreshToken = string.Empty,
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
Username = user.Username,
|
Username = user.Username,
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ using Knot.Shared.Kernel;
|
|||||||
|
|
||||||
namespace Knot.Modules.Auth.Application.Users.Login;
|
namespace Knot.Modules.Auth.Application.Users.Login;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// ������� ��� ����� ������������. ���������� AuthResponseDto.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
|
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
|
||||||
|
|
||||||
public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, 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 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
|
return Result.Success(new AuthResponseDto
|
||||||
{
|
{
|
||||||
AccessToken = token,
|
AccessToken = token,
|
||||||
RefreshToken = string.Empty,
|
RefreshToken = refreshToken,
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
Username = user.Username,
|
Username = user.Username,
|
||||||
DisplayName = user.DisplayName
|
DisplayName = user.DisplayName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using BCrypt.Net;
|
using BCrypt.Net;
|
||||||
using BCrypt.Net;
|
|
||||||
using Knot.Contracts.Auth.Application.Abstractions;
|
using Knot.Contracts.Auth.Application.Abstractions;
|
||||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||||
using Knot.Contracts.Settings.Application.Abstractions;
|
using Knot.Contracts.Settings.Application.Abstractions;
|
||||||
@@ -9,9 +8,6 @@ using Knot.Shared.Kernel;
|
|||||||
|
|
||||||
namespace Knot.Modules.Auth.Application.Users.Register;
|
namespace Knot.Modules.Auth.Application.Users.Register;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// ������� ��� ����������� ������ ������������.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record RegisterUserCommand(
|
public sealed record RegisterUserCommand(
|
||||||
string Username,
|
string Username,
|
||||||
string Password,
|
string Password,
|
||||||
@@ -19,9 +15,6 @@ public sealed record RegisterUserCommand(
|
|||||||
string? Email,
|
string? Email,
|
||||||
string? Bio) : ICommand<AuthResponseDto>;
|
string? Bio) : ICommand<AuthResponseDto>;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// ���������� ������� �����������.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
|
internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
|
||||||
{
|
{
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
@@ -48,16 +41,13 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
|
|||||||
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled);
|
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. �������� ������������ username
|
|
||||||
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
|
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
|
||||||
{
|
{
|
||||||
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique);
|
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. ����������� ������
|
|
||||||
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
|
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
|
||||||
|
|
||||||
// 3. �������� ��������
|
|
||||||
var user = User.Create(
|
var user = User.Create(
|
||||||
request.Username,
|
request.Username,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
@@ -65,21 +55,33 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
|
|||||||
request.Email,
|
request.Email,
|
||||||
request.Bio);
|
request.Bio);
|
||||||
|
|
||||||
// 4. ���������� - ���������� ����� � Domain User
|
var repoImpl = _userRepository as Infrastructure.Persistence.UserRepository;
|
||||||
var repoWithDomainUserAdd = _userRepository as Infrastructure.Persistence.UserRepository;
|
repoImpl?.Add(user);
|
||||||
repoWithDomainUserAdd?.Add(user);
|
|
||||||
|
|
||||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
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();
|
||||||
|
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
|
||||||
|
|
||||||
|
userContract.SetRefreshToken(refreshToken, refreshExpiry);
|
||||||
|
await _userRepository.UpdateAsync(userContract, cancellationToken);
|
||||||
|
|
||||||
return Result.Success(new AuthResponseDto
|
return Result.Success(new AuthResponseDto
|
||||||
{
|
{
|
||||||
AccessToken = token,
|
AccessToken = token,
|
||||||
RefreshToken = string.Empty,
|
RefreshToken = refreshToken,
|
||||||
UserId = user.Id,
|
UserId = userContract.Id,
|
||||||
Username = user.Username,
|
Username = userContract.Username,
|
||||||
DisplayName = user.DisplayName
|
DisplayName = userContract.DisplayName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public sealed class User : AggregateRoot<Guid>
|
|||||||
public bool IsBanned { get; private set; }
|
public bool IsBanned { get; private set; }
|
||||||
public string? PhoneNumber { get; private set; }
|
public string? PhoneNumber { get; private set; }
|
||||||
public string? RefreshToken { get; private set; }
|
public string? RefreshToken { get; private set; }
|
||||||
|
public DateTime? RefreshTokenExpiry { get; private set; }
|
||||||
public DateTime? BannedUntil { get; private set; }
|
public DateTime? BannedUntil { get; private set; }
|
||||||
|
|
||||||
public void Ban() {
|
public void Ban() {
|
||||||
@@ -48,9 +49,10 @@ public sealed class User : AggregateRoot<Guid>
|
|||||||
PhoneNumber = phoneNumber;
|
PhoneNumber = phoneNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetRefreshToken(string? refreshToken)
|
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
|
||||||
{
|
{
|
||||||
RefreshToken = refreshToken;
|
RefreshToken = refreshToken;
|
||||||
|
RefreshTokenExpiry = expiry;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetBannedUntil(DateTime? bannedUntil)
|
public void SetBannedUntil(DateTime? bannedUntil)
|
||||||
@@ -79,6 +81,8 @@ public sealed class User : AggregateRoot<Guid>
|
|||||||
BannedUntil = contract.BannedUntil;
|
BannedUntil = contract.BannedUntil;
|
||||||
SetOnline(contract.IsOnline, contract.LastSeen);
|
SetOnline(contract.IsOnline, contract.LastSeen);
|
||||||
UserDomain = contract.Domain;
|
UserDomain = contract.Domain;
|
||||||
|
RefreshToken = contract.RefreshToken;
|
||||||
|
RefreshTokenExpiry = contract.RefreshTokenExpiry;
|
||||||
}
|
}
|
||||||
|
|
||||||
private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null)
|
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,
|
IsOnline = IsOnline,
|
||||||
IsExternal = IsExternal,
|
IsExternal = IsExternal,
|
||||||
Domain = _domain,
|
Domain = _domain,
|
||||||
LastSeen = LastSeen
|
LastSeen = LastSeen,
|
||||||
|
RefreshToken = RefreshToken,
|
||||||
|
RefreshTokenExpiry = RefreshTokenExpiry
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ internal sealed class JwtTokenProvider : IJwtTokenProvider
|
|||||||
return Convert.ToBase64String(randomBytes);
|
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)
|
public string Generate(Guid userId, string username, string displayName, string? avatar)
|
||||||
{
|
{
|
||||||
var claims = new Claim[]
|
var claims = new Claim[]
|
||||||
|
|||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
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");
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.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 MediatR;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
|
||||||
namespace Knot.Modules.Auth.Presentation.Endpoints;
|
namespace Knot.Modules.Auth.Presentation.Endpoints;
|
||||||
|
|
||||||
@@ -28,6 +29,12 @@ public static class AuthEndpoints
|
|||||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
|
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) =>
|
group.MapGet("me", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new GetMeQuery(userContext.UserId), ct);
|
var result = await sender.Send(new GetMeQuery(userContext.UserId), ct);
|
||||||
|
|||||||
@@ -3,11 +3,74 @@ const API_BASE = '/api';
|
|||||||
|
|
||||||
export class HttpClient {
|
export class HttpClient {
|
||||||
private token: string | null = null;
|
private token: string | null = null;
|
||||||
|
private isRefreshing = false;
|
||||||
|
private refreshSubscribers: Array<(token: string) => void> = [];
|
||||||
|
|
||||||
setToken(token: string | null) {
|
setToken(token: string | null) {
|
||||||
this.token = token;
|
this.token = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private subscribeTokenRefresh(cb: (token: string) => void) {
|
||||||
|
this.refreshSubscribers.push(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onRefreshed(token: string) {
|
||||||
|
this.refreshSubscribers.forEach(cb => cb(token));
|
||||||
|
this.refreshSubscribers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handle401(): Promise<string | null> {
|
||||||
|
if (this.isRefreshing) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
this.subscribeTokenRefresh(token => {
|
||||||
|
resolve(token);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshToken = localStorage.getItem('knot_refresh_token');
|
||||||
|
if (!refreshToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRefreshing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refreshToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
localStorage.removeItem('knot_token');
|
||||||
|
localStorage.removeItem('knot_refresh_token');
|
||||||
|
this.token = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const newToken = data.accessToken;
|
||||||
|
const newRefreshToken = data.refreshToken;
|
||||||
|
|
||||||
|
localStorage.setItem('knot_token', newToken);
|
||||||
|
if (newRefreshToken) {
|
||||||
|
localStorage.setItem('knot_refresh_token', newRefreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.token = newToken;
|
||||||
|
this.onRefreshed(newToken);
|
||||||
|
return newToken;
|
||||||
|
} catch (err) {
|
||||||
|
localStorage.removeItem('knot_token');
|
||||||
|
localStorage.removeItem('knot_refresh_token');
|
||||||
|
this.token = null;
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
this.isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async request<T>(endpoint: string, options: RequestInit & { timeout?: number } = {}): Promise<T> {
|
async request<T>(endpoint: string, options: RequestInit & { timeout?: number } = {}): Promise<T> {
|
||||||
const { timeout = 30_000, ...fetchOptions } = options;
|
const { timeout = 30_000, ...fetchOptions } = options;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -41,6 +104,36 @@ export class HttpClient {
|
|||||||
}
|
}
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
// Handle 401 Unauthorized
|
||||||
|
if (response.status === 401 && endpoint !== '/auth/refresh') {
|
||||||
|
const newToken = await this.handle401();
|
||||||
|
if (newToken) {
|
||||||
|
// Retry the original request with new token
|
||||||
|
const retryHeaders: Record<string, string> = {
|
||||||
|
...computedHeaders,
|
||||||
|
Authorization: `Bearer ${newToken}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryController = new AbortController();
|
||||||
|
const retryTimer = timeout > 0 ? setTimeout(() => retryController.abort(), timeout) : undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
|
...fetchOptions,
|
||||||
|
headers: retryHeaders,
|
||||||
|
signal: retryController.signal,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(retryTimer);
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
|
throw new Error('Время ожидания запроса истекло');
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
clearTimeout(retryTimer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
const errorData = await response.json().catch(() => ({}));
|
||||||
const errorMessage = errorData.error || errorData.message || `Request failed with status ${response.status}`;
|
const errorMessage = errorData.error || errorData.message || `Request failed with status ${response.status}`;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { User } from '../../../core/domain/types';
|
|||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
user: User | null;
|
user: User | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -23,6 +24,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
if (t) AuthApi.setToken(t);
|
if (t) AuthApi.setToken(t);
|
||||||
return t;
|
return t;
|
||||||
})(),
|
})(),
|
||||||
|
refreshToken: (() => {
|
||||||
|
return localStorage.getItem('knot_refresh_token');
|
||||||
|
})(),
|
||||||
user: null,
|
user: null,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -39,11 +43,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
login: async (username, password) => {
|
login: async (username, password) => {
|
||||||
try {
|
try {
|
||||||
set({ error: null, isLoading: true });
|
set({ error: null, isLoading: true });
|
||||||
const { token, user } = await AuthApi.login(username, password);
|
const { token, refreshToken, user } = await AuthApi.login(username, password);
|
||||||
localStorage.setItem('knot_token', token);
|
localStorage.setItem('knot_token', token);
|
||||||
|
if (refreshToken) {
|
||||||
|
localStorage.setItem('knot_refresh_token', refreshToken);
|
||||||
|
}
|
||||||
AuthApi.setToken(token);
|
AuthApi.setToken(token);
|
||||||
connectSocket(token);
|
connectSocket(token);
|
||||||
set({ token, user, isLoading: false });
|
set({ token, refreshToken, user, isLoading: false });
|
||||||
await get().fetchConfig();
|
await get().fetchConfig();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
@@ -55,11 +62,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
register: async (username, displayName, password, bio) => {
|
register: async (username, displayName, password, bio) => {
|
||||||
try {
|
try {
|
||||||
set({ error: null, isLoading: true });
|
set({ error: null, isLoading: true });
|
||||||
const { token, user } = await AuthApi.register(username, displayName, password, bio);
|
const { token, refreshToken, user } = await AuthApi.register(username, displayName, password, bio);
|
||||||
localStorage.setItem('knot_token', token);
|
localStorage.setItem('knot_token', token);
|
||||||
|
if (refreshToken) {
|
||||||
|
localStorage.setItem('knot_refresh_token', refreshToken);
|
||||||
|
}
|
||||||
AuthApi.setToken(token);
|
AuthApi.setToken(token);
|
||||||
connectSocket(token);
|
connectSocket(token);
|
||||||
set({ token, user, isLoading: false });
|
set({ token, refreshToken, user, isLoading: false });
|
||||||
await get().fetchConfig();
|
await get().fetchConfig();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
@@ -70,9 +80,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
localStorage.removeItem('knot_token');
|
localStorage.removeItem('knot_token');
|
||||||
|
localStorage.removeItem('knot_refresh_token');
|
||||||
AuthApi.setToken(null);
|
AuthApi.setToken(null);
|
||||||
disconnectSocket();
|
disconnectSocket();
|
||||||
set({ token: null, user: null });
|
set({ token: null, refreshToken: null, user: null });
|
||||||
},
|
},
|
||||||
|
|
||||||
checkAuth: async () => {
|
checkAuth: async () => {
|
||||||
@@ -122,7 +133,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
errorMsg.includes('Требуется авторизация')
|
errorMsg.includes('Требуется авторизация')
|
||||||
) {
|
) {
|
||||||
localStorage.removeItem('knot_token');
|
localStorage.removeItem('knot_token');
|
||||||
set({ token: null, user: null, isLoading: false });
|
localStorage.removeItem('knot_refresh_token');
|
||||||
|
set({ token: null, refreshToken: null, user: null, isLoading: false });
|
||||||
} else {
|
} else {
|
||||||
// Keep the token but stop loading if we're just offline/network error/500
|
// Keep the token but stop loading if we're just offline/network error/500
|
||||||
set({ isLoading: false });
|
set({ isLoading: false });
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import type { User } from '../../../core/domain/types';
|
|||||||
|
|
||||||
export class AuthApi {
|
export class AuthApi {
|
||||||
static async login(username: string, password: string) {
|
static async login(username: string, password: string) {
|
||||||
const response = await httpClient.request<{ accessToken: string; userId: string; username: string; displayName: string }>('/auth/login', {
|
const response = await httpClient.request<{ accessToken: string; refreshToken: string; userId: string; username: string; displayName: string }>('/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token: response.accessToken,
|
token: response.accessToken,
|
||||||
|
refreshToken: response.refreshToken,
|
||||||
user: {
|
user: {
|
||||||
id: response.userId,
|
id: response.userId,
|
||||||
username: response.username,
|
username: response.username,
|
||||||
@@ -20,13 +21,32 @@ export class AuthApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async register(username: string, displayName: string, password: string, bio?: string) {
|
static async register(username: string, displayName: string, password: string, bio?: string) {
|
||||||
const response = await httpClient.request<{ accessToken: string; userId: string; username: string; displayName: string }>('/auth/register', {
|
const response = await httpClient.request<{ accessToken: string; refreshToken: string; userId: string; username: string; displayName: string }>('/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ username, displayName, password, bio }),
|
body: JSON.stringify({ username, displayName, password, bio }),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token: response.accessToken,
|
token: response.accessToken,
|
||||||
|
refreshToken: response.refreshToken,
|
||||||
|
user: {
|
||||||
|
id: response.userId,
|
||||||
|
username: response.username,
|
||||||
|
displayName: response.displayName,
|
||||||
|
avatar: null
|
||||||
|
} as User
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static async refresh(refreshToken: string) {
|
||||||
|
const response = await httpClient.request<{ accessToken: string; refreshToken: string; userId: string; username: string; displayName: string }>('/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ refreshToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: response.accessToken,
|
||||||
|
refreshToken: response.refreshToken,
|
||||||
user: {
|
user: {
|
||||||
id: response.userId,
|
id: response.userId,
|
||||||
username: response.username,
|
username: response.username,
|
||||||
|
|||||||
Reference in New Issue
Block a user