Токены, правильная работа
This commit is contained in:
@@ -4,6 +4,7 @@ public interface IJwtTokenProvider
|
||||
{
|
||||
string GenerateAccessToken(Guid userId, string username);
|
||||
string GenerateRefreshToken();
|
||||
DateTime GetRefreshTokenExpiry();
|
||||
string Generate(Guid userId, string username, string displayName, string? avatar);
|
||||
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 IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled");
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -19,4 +19,12 @@ public class UserContract
|
||||
public bool IsExternal { get; set; }
|
||||
public string? Domain { 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",
|
||||
"Issuer": "Knot",
|
||||
"Audience": "KnotUsers",
|
||||
"ExpiryInMinutes": 1440
|
||||
"ExpiryInMinutes": 1440,
|
||||
"RefreshExpiryInDays": 30
|
||||
},
|
||||
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
101
backend/src/Modules/Auth/Migrations/20270419220000_AddRefreshTokenExpiry.Designer.cs
generated
Normal file
101
backend/src/Modules/Auth/Migrations/20270419220000_AddRefreshTokenExpiry.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.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);
|
||||
|
||||
@@ -3,11 +3,74 @@ const API_BASE = '/api';
|
||||
|
||||
export class HttpClient {
|
||||
private token: string | null = null;
|
||||
private isRefreshing = false;
|
||||
private refreshSubscribers: Array<(token: string) => void> = [];
|
||||
|
||||
setToken(token: string | null) {
|
||||
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> {
|
||||
const { timeout = 30_000, ...fetchOptions } = options;
|
||||
const controller = new AbortController();
|
||||
@@ -41,6 +104,36 @@ export class HttpClient {
|
||||
}
|
||||
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) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
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 {
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
@@ -23,6 +24,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
if (t) AuthApi.setToken(t);
|
||||
return t;
|
||||
})(),
|
||||
refreshToken: (() => {
|
||||
return localStorage.getItem('knot_refresh_token');
|
||||
})(),
|
||||
user: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
@@ -33,17 +37,20 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
try {
|
||||
const res = await AuthApi.getConfig();
|
||||
set({ config: res });
|
||||
} catch {}
|
||||
} catch { }
|
||||
},
|
||||
|
||||
login: async (username, password) => {
|
||||
try {
|
||||
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);
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('knot_refresh_token', refreshToken);
|
||||
}
|
||||
AuthApi.setToken(token);
|
||||
connectSocket(token);
|
||||
set({ token, user, isLoading: false });
|
||||
set({ token, refreshToken, user, isLoading: false });
|
||||
await get().fetchConfig();
|
||||
} catch (err: unknown) {
|
||||
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) => {
|
||||
try {
|
||||
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);
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('knot_refresh_token', refreshToken);
|
||||
}
|
||||
AuthApi.setToken(token);
|
||||
connectSocket(token);
|
||||
set({ token, user, isLoading: false });
|
||||
set({ token, refreshToken, user, isLoading: false });
|
||||
await get().fetchConfig();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
@@ -70,9 +80,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('knot_token');
|
||||
localStorage.removeItem('knot_refresh_token');
|
||||
AuthApi.setToken(null);
|
||||
disconnectSocket();
|
||||
set({ token: null, user: null });
|
||||
set({ token: null, refreshToken: null, user: null });
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
@@ -121,11 +132,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
errorMsg.includes('Недействительный токен') ||
|
||||
errorMsg.includes('Требуется авторизация')
|
||||
) {
|
||||
localStorage.removeItem('knot_token');
|
||||
set({ token: null, user: null, isLoading: false });
|
||||
localStorage.removeItem('knot_token');
|
||||
localStorage.removeItem('knot_refresh_token');
|
||||
set({ token: null, refreshToken: null, user: null, isLoading: false });
|
||||
} else {
|
||||
// Keep the token but stop loading if we're just offline/network error/500
|
||||
set({ isLoading: false });
|
||||
// Keep the token but stop loading if we're just offline/network error/500
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@ import type { User } from '../../../core/domain/types';
|
||||
|
||||
export class AuthApi {
|
||||
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',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
return {
|
||||
token: response.accessToken,
|
||||
refreshToken: response.refreshToken,
|
||||
user: {
|
||||
id: response.userId,
|
||||
username: response.username,
|
||||
@@ -20,13 +21,32 @@ export class AuthApi {
|
||||
}
|
||||
|
||||
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',
|
||||
body: JSON.stringify({ username, displayName, password, bio }),
|
||||
});
|
||||
|
||||
return {
|
||||
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: {
|
||||
id: response.userId,
|
||||
username: response.username,
|
||||
|
||||
Reference in New Issue
Block a user