From 9e715fe3abc6dbe5fed59e5da36900b207b977bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Sun, 19 Apr 2026 22:13:20 +0300 Subject: [PATCH 1/2] =?UTF-8?q?=D0=A2=D0=BE=D0=BA=D0=B5=D0=BD=D1=8B,=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Abstractions/IJwtTokenProvider.cs | 1 + .../Auth/Application/Auth/DTOs/AuthErrors.cs | 2 + .../src/Contracts/Auth/Domain/UserContract.cs | 8 ++ backend/src/Host/appsettings.json | 5 +- .../Abstractions/IJwtTokenProvider.cs | 3 + .../Auth/Application/Users/GetMe/GetMe.cs | 19 +++- .../Users/Login/LoginUserCommandHandler.cs | 12 ++- .../Users/RefreshToken/RefreshTokenCommand.cs | 7 ++ .../RefreshTokenCommandHandler.cs | 65 +++++++++++ .../Register/RegisterUserCommandHandler.cs | 36 +++---- backend/src/Modules/Auth/Domain/User.cs | 10 +- .../Authentication/JwtTokenProvider.cs | 6 ++ ...19220000_AddRefreshTokenExpiry.Designer.cs | 101 ++++++++++++++++++ .../20270419220000_AddRefreshTokenExpiry.cs | 28 +++++ .../Presentation/Endpoints/AuthEndpoints.cs | 15 ++- .../src/core/infrastructure/httpClient.ts | 93 ++++++++++++++++ .../src/modules/auth/application/authStore.ts | 32 ++++-- .../modules/auth/infrastructure/authApi.ts | 24 ++++- 18 files changed, 422 insertions(+), 45 deletions(-) create mode 100644 backend/src/Modules/Auth/Application/Users/RefreshToken/RefreshTokenCommand.cs create mode 100644 backend/src/Modules/Auth/Application/Users/RefreshToken/RefreshTokenCommandHandler.cs create mode 100644 backend/src/Modules/Auth/Migrations/20270419220000_AddRefreshTokenExpiry.Designer.cs create mode 100644 backend/src/Modules/Auth/Migrations/20270419220000_AddRefreshTokenExpiry.cs diff --git a/backend/src/Contracts/Auth/Application/Abstractions/IJwtTokenProvider.cs b/backend/src/Contracts/Auth/Application/Abstractions/IJwtTokenProvider.cs index bf2f35e..73ec3de 100644 --- a/backend/src/Contracts/Auth/Application/Abstractions/IJwtTokenProvider.cs +++ b/backend/src/Contracts/Auth/Application/Abstractions/IJwtTokenProvider.cs @@ -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); } diff --git a/backend/src/Contracts/Auth/Application/Auth/DTOs/AuthErrors.cs b/backend/src/Contracts/Auth/Application/Auth/DTOs/AuthErrors.cs index 8beb50e..95ae394 100644 --- a/backend/src/Contracts/Auth/Application/Auth/DTOs/AuthErrors.cs +++ b/backend/src/Contracts/Auth/Application/Auth/DTOs/AuthErrors.cs @@ -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"); } diff --git a/backend/src/Contracts/Auth/Domain/UserContract.cs b/backend/src/Contracts/Auth/Domain/UserContract.cs index 657b1a3..397a5d2 100644 --- a/backend/src/Contracts/Auth/Domain/UserContract.cs +++ b/backend/src/Contracts/Auth/Domain/UserContract.cs @@ -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; + } } diff --git a/backend/src/Host/appsettings.json b/backend/src/Host/appsettings.json index 9f436c0..566da9c 100644 --- a/backend/src/Host/appsettings.json +++ b/backend/src/Host/appsettings.json @@ -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" -} +} \ No newline at end of file diff --git a/backend/src/Modules/Auth/Application/Abstractions/IJwtTokenProvider.cs b/backend/src/Modules/Auth/Application/Abstractions/IJwtTokenProvider.cs index 4cf048e..51a01ee 100644 --- a/backend/src/Modules/Auth/Application/Abstractions/IJwtTokenProvider.cs +++ b/backend/src/Modules/Auth/Application/Abstractions/IJwtTokenProvider.cs @@ -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(); } diff --git a/backend/src/Modules/Auth/Application/Users/GetMe/GetMe.cs b/backend/src/Modules/Auth/Application/Users/GetMe/GetMe.cs index 57f6dd5..c69a47e 100644 --- a/backend/src/Modules/Auth/Application/Users/GetMe/GetMe.cs +++ b/backend/src/Modules/Auth/Application/Users/GetMe/GetMe.cs @@ -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; internal sealed class GetMeQueryHandler : IQueryHandler { 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> Handle(GetMeQuery request, CancellationToken cancellationToken) @@ -23,9 +27,18 @@ internal sealed class GetMeQueryHandler : IQueryHandler(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, diff --git a/backend/src/Modules/Auth/Application/Users/Login/LoginUserCommandHandler.cs b/backend/src/Modules/Auth/Application/Users/Login/LoginUserCommandHandler.cs index fdf0bbb..963720f 100644 --- a/backend/src/Modules/Auth/Application/Users/Login/LoginUserCommandHandler.cs +++ b/backend/src/Modules/Auth/Application/Users/Login/LoginUserCommandHandler.cs @@ -5,9 +5,6 @@ using Knot.Shared.Kernel; namespace Knot.Modules.Auth.Application.Users.Login; -/// -/// ������� ��� ����� ������������. ���������� AuthResponseDto. -/// public sealed record LoginUserCommand(string Username, string Password) : ICommand; public sealed class LoginUserCommandHandler : ICommandHandler @@ -31,14 +28,21 @@ public sealed class LoginUserCommandHandler : ICommandHandler; \ No newline at end of file diff --git a/backend/src/Modules/Auth/Application/Users/RefreshToken/RefreshTokenCommandHandler.cs b/backend/src/Modules/Auth/Application/Users/RefreshToken/RefreshTokenCommandHandler.cs new file mode 100644 index 0000000..5a834a7 --- /dev/null +++ b/backend/src/Modules/Auth/Application/Users/RefreshToken/RefreshTokenCommandHandler.cs @@ -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 +{ + private readonly IUserRepository _userRepository; + private readonly IJwtTokenProvider _tokenProvider; + + public RefreshTokenCommandHandler( + IUserRepository userRepository, + IJwtTokenProvider tokenProvider) + { + _userRepository = userRepository; + _tokenProvider = tokenProvider; + } + + public async Task> Handle(RefreshTokenCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.RefreshToken)) + { + return Result.Failure( + new Error("Auth.InvalidRefreshToken", "Refresh token is required")); + } + + var user = await _userRepository.GetByRefreshTokenAsync(request.RefreshToken, cancellationToken); + if (user == null) + { + return Result.Failure( + 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( + 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 + }); + } +} diff --git a/backend/src/Modules/Auth/Application/Users/Register/RegisterUserCommandHandler.cs b/backend/src/Modules/Auth/Application/Users/Register/RegisterUserCommandHandler.cs index e170a09..57d4b8a 100644 --- a/backend/src/Modules/Auth/Application/Users/Register/RegisterUserCommandHandler.cs +++ b/backend/src/Modules/Auth/Application/Users/Register/RegisterUserCommandHandler.cs @@ -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; -/// -/// ������� ��� ����������� ������ ������������. -/// public sealed record RegisterUserCommand( string Username, string Password, @@ -19,9 +15,6 @@ public sealed record RegisterUserCommand( string? Email, string? Bio) : ICommand; -/// -/// ���������� ������� �����������. -/// internal sealed class RegisterUserCommandHandler : ICommandHandler { private readonly IUserRepository _userRepository; @@ -48,16 +41,13 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler(AuthErrors.IdentityRegistrationDisabled); } - // 1. �������� ������������ username if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken)) { return Result.Failure(AuthErrors.IdentityUsernameNotUnique); } - // 2. ����������� ������ string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password); - // 3. �������� �������� var user = User.Create( request.Username, passwordHash, @@ -65,21 +55,31 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler(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 }); } } diff --git a/backend/src/Modules/Auth/Domain/User.cs b/backend/src/Modules/Auth/Domain/User.cs index 3059c0b..742df7d 100644 --- a/backend/src/Modules/Auth/Domain/User.cs +++ b/backend/src/Modules/Auth/Domain/User.cs @@ -26,6 +26,7 @@ public sealed class User : AggregateRoot 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 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 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 IsOnline = IsOnline, IsExternal = IsExternal, Domain = _domain, - LastSeen = LastSeen + LastSeen = LastSeen, + RefreshToken = RefreshToken, + RefreshTokenExpiry = RefreshTokenExpiry }; } } diff --git a/backend/src/Modules/Auth/Infrastructure/Authentication/JwtTokenProvider.cs b/backend/src/Modules/Auth/Infrastructure/Authentication/JwtTokenProvider.cs index 9eb35ed..4a3e8c7 100644 --- a/backend/src/Modules/Auth/Infrastructure/Authentication/JwtTokenProvider.cs +++ b/backend/src/Modules/Auth/Infrastructure/Authentication/JwtTokenProvider.cs @@ -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[] diff --git a/backend/src/Modules/Auth/Migrations/20270419220000_AddRefreshTokenExpiry.Designer.cs b/backend/src/Modules/Auth/Migrations/20270419220000_AddRefreshTokenExpiry.Designer.cs new file mode 100644 index 0000000..e28672c --- /dev/null +++ b/backend/src/Modules/Auth/Migrations/20270419220000_AddRefreshTokenExpiry.Designer.cs @@ -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 +{ + /// + [DbContext(typeof(AuthDbContext))] + [Migration("20270419220000_AddRefreshTokenExpiry")] + partial class AddRefreshTokenExpiry + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Avatar") + .HasColumnType("text"); + + b.Property("BannedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Bio") + .HasColumnType("text"); + + b.Property("Birthday") + .HasColumnType("timestamp with time zone") + .IsRequired(false); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("Domain") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("HideStatus") + .HasColumnType("boolean"); + + b.Property("HideStoryViews") + .HasColumnType("boolean"); + + b.Property("IsBanned") + .HasColumnType("boolean"); + + b.Property("IsExternal") + .HasColumnType("boolean"); + + b.Property("IsOnline") + .HasColumnType("boolean"); + + b.Property("LastSeen") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("RefreshToken") + .HasColumnType("text"); + + b.Property("RefreshTokenExpiry") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", "identity"); + }); +#pragma warning restore 612, 618 + } + } +} \ No newline at end of file diff --git a/backend/src/Modules/Auth/Migrations/20270419220000_AddRefreshTokenExpiry.cs b/backend/src/Modules/Auth/Migrations/20270419220000_AddRefreshTokenExpiry.cs new file mode 100644 index 0000000..3791f54 --- /dev/null +++ b/backend/src/Modules/Auth/Migrations/20270419220000_AddRefreshTokenExpiry.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Knot.Modules.Auth.Migrations +{ + /// + public partial class AddRefreshTokenExpiry : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + 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"); + } + } +} \ No newline at end of file diff --git a/backend/src/Modules/Auth/Presentation/Endpoints/AuthEndpoints.cs b/backend/src/Modules/Auth/Presentation/Endpoints/AuthEndpoints.cs index 18d349c..620c1e6 100644 --- a/backend/src/Modules/Auth/Presentation/Endpoints/AuthEndpoints.cs +++ b/backend/src/Modules/Auth/Presentation/Endpoints/AuthEndpoints.cs @@ -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); diff --git a/client-web/src/core/infrastructure/httpClient.ts b/client-web/src/core/infrastructure/httpClient.ts index 1cdefd9..88e89ec 100644 --- a/client-web/src/core/infrastructure/httpClient.ts +++ b/client-web/src/core/infrastructure/httpClient.ts @@ -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 { + 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(endpoint: string, options: RequestInit & { timeout?: number } = {}): Promise { 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 = { + ...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}`; diff --git a/client-web/src/modules/auth/application/authStore.ts b/client-web/src/modules/auth/application/authStore.ts index 6889797..e947b1b 100644 --- a/client-web/src/modules/auth/application/authStore.ts +++ b/client-web/src/modules/auth/application/authStore.ts @@ -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((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((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((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((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((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 }); } }, diff --git a/client-web/src/modules/auth/infrastructure/authApi.ts b/client-web/src/modules/auth/infrastructure/authApi.ts index babc2e9..31a6c2f 100644 --- a/client-web/src/modules/auth/infrastructure/authApi.ts +++ b/client-web/src/modules/auth/infrastructure/authApi.ts @@ -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, From d9462069e2c9b5ec3bf85818674123a8652d1208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Sun, 19 Apr 2026 22:22:43 +0300 Subject: [PATCH 2/2] =?UTF-8?q?=D0=9C=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Application/Users/Register/RegisterUserCommandHandler.cs | 4 +++- .../20270419220000_AddRefreshTokenExpiry.Designer.cs | 5 ++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/src/Modules/Auth/Application/Users/Register/RegisterUserCommandHandler.cs b/backend/src/Modules/Auth/Application/Users/Register/RegisterUserCommandHandler.cs index 57d4b8a..331abb9 100644 --- a/backend/src/Modules/Auth/Application/Users/Register/RegisterUserCommandHandler.cs +++ b/backend/src/Modules/Auth/Application/Users/Register/RegisterUserCommandHandler.cs @@ -69,8 +69,9 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler("Bio") .HasColumnType("text"); - b.Property("Birthday") - .HasColumnType("timestamp with time zone") - .IsRequired(false); + b.Property("Birthday") + .HasColumnType("timestamp with time zone"); b.Property("CreatedAt") .HasColumnType("timestamp with time zone");