fix: скрыл инфу о активности ака и перестал выдавать рефреши если ак не активен
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using UniVerse.Application.DTOs.Notifications;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Domain.Entities;
|
||||
using UniVerse.Domain.Enums;
|
||||
using UniVerse.Domain.Exceptions;
|
||||
using UniVerse.Infrastructure.Data;
|
||||
using UniVerse.Infrastructure.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace UniVerse.Api.Tests.Auth;
|
||||
|
||||
public class AuthServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RefreshTokenAsync_InactiveUser_RevokesTokenAndThrowsForbidden()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
db.Users.Add(new User
|
||||
{
|
||||
Id = 1,
|
||||
Email = "blocked@test.local",
|
||||
IsActive = false,
|
||||
Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }]
|
||||
});
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Id = 1,
|
||||
UserId = 1,
|
||||
Token = "refresh-token",
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(1),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var service = CreateService(db);
|
||||
|
||||
await Assert.ThrowsAsync<ForbiddenException>(() => service.RefreshTokenAsync("refresh-token"));
|
||||
|
||||
var token = await db.RefreshTokens.SingleAsync(t => t.Token == "refresh-token");
|
||||
Assert.NotNull(token.RevokedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrentUserAsync_InactiveUser_ThrowsForbidden()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
db.Users.Add(new User
|
||||
{
|
||||
Id = 1,
|
||||
Email = "blocked@test.local",
|
||||
IsActive = false,
|
||||
Roles = [new UserRoleAssignment { UserId = 1, Role = UserRole.Student }]
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var service = CreateService(db);
|
||||
|
||||
await Assert.ThrowsAsync<ForbiddenException>(() => service.GetCurrentUserAsync(1));
|
||||
}
|
||||
|
||||
private static AppDbContext CreateDbContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase($"AuthServiceTests_{Guid.NewGuid()}")
|
||||
.Options;
|
||||
return new AppDbContext(options);
|
||||
}
|
||||
|
||||
private static AuthService CreateService(AppDbContext db)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Jwt:Secret"] = "test-secret-test-secret-test-secret-test-secret",
|
||||
["Jwt:Issuer"] = "UniVerse.Tests",
|
||||
["Jwt:Audience"] = "UniVerse.Tests",
|
||||
["Jwt:AccessTokenExpirationMinutes"] = "15",
|
||||
["Jwt:RefreshTokenExpirationDays"] = "30"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var gamification = Substitute.For<IGamificationService>();
|
||||
gamification.CalculateLevelAsync(Arg.Any<int>()).Returns(1);
|
||||
|
||||
var notifications = Substitute.For<INotificationService>();
|
||||
notifications.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
return new AuthService(db, config, gamification, notifications, NullLogger<AuthService>.Instance);
|
||||
}
|
||||
}
|
||||
@@ -123,7 +123,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
|
||||
.Returns(authResult);
|
||||
stub.RefreshTokenAsync(Arg.Any<string>()).Returns(authResult);
|
||||
stub.GetCurrentUserAsync(Arg.Any<int>())
|
||||
.Returns(new UserDto(1, "test@test.com", "Test", null, [UserRole.Student], true, 0, 0, 1, DateTime.UtcNow));
|
||||
.Returns(new CurrentUserDto(1, "test@test.com", "Test", null, [UserRole.Student], 0, 0, 1, DateTime.UtcNow));
|
||||
return stub;
|
||||
}
|
||||
|
||||
|
||||
@@ -198,9 +198,11 @@ public class AuthController : ControllerBase
|
||||
/// </remarks>
|
||||
/// <response code="200">Новая пара токенов.</response>
|
||||
/// <response code="401">Refresh token отсутствует, просрочен или отозван.</response>
|
||||
/// <response code="403">Аккаунт деактивирован или refresh token недействителен.</response>
|
||||
[HttpPost("refresh")]
|
||||
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<AuthResponse>> Refresh()
|
||||
{
|
||||
var refreshToken = Request.Cookies["refreshToken"];
|
||||
@@ -236,8 +238,9 @@ public class AuthController : ControllerBase
|
||||
/// <response code="404">Пользователь не найден в БД (рассинхронизация токена).</response>
|
||||
[Authorize]
|
||||
[HttpGet("me")]
|
||||
[ProducesResponseType(typeof(UniVerse.Application.DTOs.Users.UserDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(UniVerse.Application.DTOs.Users.CurrentUserDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> Me()
|
||||
{
|
||||
|
||||
@@ -547,6 +547,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Аккаунт деактивирован или refresh token недействителен.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -593,7 +603,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UserDto"
|
||||
"$ref": "#/components/schemas/CurrentUserDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -608,6 +618,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Пользователь не найден в БД (рассинхронизация токена).",
|
||||
"content": {
|
||||
@@ -4563,6 +4583,51 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"CurrentUserDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"avatarUrl": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/UserRole"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"xp": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"coins": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"level": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DevLoginRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -15,6 +15,18 @@ public record UserDto(
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
public record CurrentUserDto(
|
||||
int Id,
|
||||
string Email,
|
||||
string? DisplayName,
|
||||
string? AvatarUrl,
|
||||
IReadOnlyList<UserRole> Roles,
|
||||
int Xp,
|
||||
int Coins,
|
||||
int Level,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
public record UserStatsDto(
|
||||
int TotalLectures,
|
||||
int AttendedLectures,
|
||||
|
||||
@@ -9,5 +9,5 @@ public interface IAuthService
|
||||
Task<AuthResult> DevLoginAsync(string email, string? displayName, IReadOnlyCollection<Domain.Enums.UserRole> roles, string? ipAddress = null);
|
||||
Task<AuthResult> RefreshTokenAsync(string refreshToken);
|
||||
Task RevokeRefreshTokenAsync(string refreshToken);
|
||||
Task<UserDto> GetCurrentUserAsync(int userId);
|
||||
Task<CurrentUserDto> GetCurrentUserAsync(int userId);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ public static class MappingExtensions
|
||||
user.Roles.Select(r => r.Role).OrderBy(r => r).ToList(), user.IsActive, user.Xp, user.Coins, level, user.CreatedAt
|
||||
);
|
||||
|
||||
public static CurrentUserDto ToCurrentUserDto(this User user, int level) => new(
|
||||
user.Id, user.Email, user.DisplayName, user.AvatarUrl,
|
||||
user.Roles.Select(r => r.Role).OrderBy(r => r).ToList(), user.Xp, user.Coins, level, user.CreatedAt
|
||||
);
|
||||
|
||||
public static UserAuthDto ToAuthDto(this User user) => new(
|
||||
user.Id, user.Email, user.DisplayName, user.Roles.Select(r => r.Role).OrderBy(r => r).ToList()
|
||||
);
|
||||
|
||||
@@ -186,6 +186,13 @@ public class AuthService : IAuthService
|
||||
if (token == null || !token.IsActive)
|
||||
throw new ForbiddenException("Неверный или просроченный токен обновления.");
|
||||
|
||||
if (!token.User.IsActive)
|
||||
{
|
||||
token.RevokedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
throw new ForbiddenException("Аккаунт деактивирован.");
|
||||
}
|
||||
|
||||
// Revoke old token
|
||||
token.RevokedAt = DateTime.UtcNow;
|
||||
|
||||
@@ -215,13 +222,16 @@ public class AuthService : IAuthService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UserDto> GetCurrentUserAsync(int userId)
|
||||
public async Task<CurrentUserDto> GetCurrentUserAsync(int userId)
|
||||
{
|
||||
var user = await _db.Users
|
||||
.Include(u => u.Roles)
|
||||
.FirstOrDefaultAsync(u => u.Id == userId)
|
||||
?? throw new NotFoundException("User", userId);
|
||||
return user.ToDto(await _gamification.CalculateLevelAsync(user.Xp));
|
||||
if (!user.IsActive)
|
||||
throw new ForbiddenException("Аккаунт деактивирован.");
|
||||
|
||||
return user.ToCurrentUserDto(await _gamification.CalculateLevelAsync(user.Xp));
|
||||
}
|
||||
|
||||
private async Task TrySendLoginNotificationAsync(User user, string? ipAddress)
|
||||
|
||||
Reference in New Issue
Block a user