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);
|
.Returns(authResult);
|
||||||
stub.RefreshTokenAsync(Arg.Any<string>()).Returns(authResult);
|
stub.RefreshTokenAsync(Arg.Any<string>()).Returns(authResult);
|
||||||
stub.GetCurrentUserAsync(Arg.Any<int>())
|
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;
|
return stub;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -198,9 +198,11 @@ public class AuthController : ControllerBase
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <response code="200">Новая пара токенов.</response>
|
/// <response code="200">Новая пара токенов.</response>
|
||||||
/// <response code="401">Refresh token отсутствует, просрочен или отозван.</response>
|
/// <response code="401">Refresh token отсутствует, просрочен или отозван.</response>
|
||||||
|
/// <response code="403">Аккаунт деактивирован или refresh token недействителен.</response>
|
||||||
[HttpPost("refresh")]
|
[HttpPost("refresh")]
|
||||||
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult<AuthResponse>> Refresh()
|
public async Task<ActionResult<AuthResponse>> Refresh()
|
||||||
{
|
{
|
||||||
var refreshToken = Request.Cookies["refreshToken"];
|
var refreshToken = Request.Cookies["refreshToken"];
|
||||||
@@ -236,8 +238,9 @@ public class AuthController : ControllerBase
|
|||||||
/// <response code="404">Пользователь не найден в БД (рассинхронизация токена).</response>
|
/// <response code="404">Пользователь не найден в БД (рассинхронизация токена).</response>
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[HttpGet("me")]
|
[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.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult> Me()
|
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": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"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": {
|
"404": {
|
||||||
"description": "Пользователь не найден в БД (рассинхронизация токена).",
|
"description": "Пользователь не найден в БД (рассинхронизация токена).",
|
||||||
"content": {
|
"content": {
|
||||||
@@ -4563,6 +4583,51 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"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": {
|
"DevLoginRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ public record UserDto(
|
|||||||
DateTime CreatedAt
|
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(
|
public record UserStatsDto(
|
||||||
int TotalLectures,
|
int TotalLectures,
|
||||||
int AttendedLectures,
|
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> DevLoginAsync(string email, string? displayName, IReadOnlyCollection<Domain.Enums.UserRole> roles, string? ipAddress = null);
|
||||||
Task<AuthResult> RefreshTokenAsync(string refreshToken);
|
Task<AuthResult> RefreshTokenAsync(string refreshToken);
|
||||||
Task RevokeRefreshTokenAsync(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
|
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(
|
public static UserAuthDto ToAuthDto(this User user) => new(
|
||||||
user.Id, user.Email, user.DisplayName, user.Roles.Select(r => r.Role).OrderBy(r => r).ToList()
|
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)
|
if (token == null || !token.IsActive)
|
||||||
throw new ForbiddenException("Неверный или просроченный токен обновления.");
|
throw new ForbiddenException("Неверный или просроченный токен обновления.");
|
||||||
|
|
||||||
|
if (!token.User.IsActive)
|
||||||
|
{
|
||||||
|
token.RevokedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
throw new ForbiddenException("Аккаунт деактивирован.");
|
||||||
|
}
|
||||||
|
|
||||||
// Revoke old token
|
// Revoke old token
|
||||||
token.RevokedAt = DateTime.UtcNow;
|
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
|
var user = await _db.Users
|
||||||
.Include(u => u.Roles)
|
.Include(u => u.Roles)
|
||||||
.FirstOrDefaultAsync(u => u.Id == userId)
|
.FirstOrDefaultAsync(u => u.Id == userId)
|
||||||
?? throw new NotFoundException("User", 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)
|
private async Task TrySendLoginNotificationAsync(User user, string? ipAddress)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
SyncStatusDto,
|
SyncStatusDto,
|
||||||
TagDto,
|
TagDto,
|
||||||
UserAchievementDto,
|
UserAchievementDto,
|
||||||
|
CurrentUserDto,
|
||||||
UserDto,
|
UserDto,
|
||||||
UserQuery,
|
UserQuery,
|
||||||
UserNotificationDto,
|
UserNotificationDto,
|
||||||
@@ -30,7 +31,7 @@ export const authApi = {
|
|||||||
}),
|
}),
|
||||||
refresh: () => apiRequest<AuthResponse>('/auth/refresh', { method: 'POST' }),
|
refresh: () => apiRequest<AuthResponse>('/auth/refresh', { method: 'POST' }),
|
||||||
logout: () => apiRequest<void>('/auth/logout', { method: 'POST' }),
|
logout: () => apiRequest<void>('/auth/logout', { method: 'POST' }),
|
||||||
me: () => apiRequest<UserDto>('/auth/me'),
|
me: () => apiRequest<CurrentUserDto>('/auth/me'),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const lecturesApi = {
|
export const lecturesApi = {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
CoinTransactionDto,
|
CoinTransactionDto,
|
||||||
LectureDto,
|
LectureDto,
|
||||||
ReviewDto,
|
ReviewDto,
|
||||||
|
CurrentUserDto,
|
||||||
UserAuthDto,
|
UserAuthDto,
|
||||||
UserDto,
|
UserDto,
|
||||||
UserStatsDto,
|
UserStatsDto,
|
||||||
@@ -29,7 +30,7 @@ function getDefaultActiveRole(roles: UserRole[]): UserRole {
|
|||||||
return 'student'
|
return 'student'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapApiUser(user: UserAuthDto | UserDto, stats?: UserStatsDto): User {
|
export function mapApiUser(user: UserAuthDto | UserDto | CurrentUserDto, stats?: UserStatsDto): User {
|
||||||
const roles = mapApiRoles(user.roles)
|
const roles = mapApiRoles(user.roles)
|
||||||
return {
|
return {
|
||||||
id: String(user.id),
|
id: String(user.id),
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ export interface UserDto extends UserAuthDto {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CurrentUserDto extends UserAuthDto {
|
||||||
|
avatarUrl?: string | null
|
||||||
|
xp: number
|
||||||
|
coins: number
|
||||||
|
level: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserQuery {
|
export interface UserQuery {
|
||||||
Search?: string
|
Search?: string
|
||||||
Role?: ApiUserRole
|
Role?: ApiUserRole
|
||||||
|
|||||||
Reference in New Issue
Block a user