Release v1.0.0 #16
.envDbInitializer.csDependencyInjection.cs
.gitea/workflows
.gitignoreApiDto
ApiDto.csproj
Backend.slnCommon
Requests
Responses
Endpoint
Backend.http
Common
Attributes
BadRequestResponseAttribute.csLocalhostAttribute.csMaintenanceModeIgnoreAttribute.csNotFoundResponseAttribute.csSwaggerDefaultAttribute.csTokenAuthenticationAttribute.cs
Exceptions
Interfaces
Model
Services
Configuration
AppConfig
ApiVersioningConfiguration.csCacheConfiguration.csEnvironmentConfiguration.csJwtConfiguration.csLoggerConfiguration.csSecureConfiguration.csSwaggerConfiguration.cs
General
Swagger
Controllers
BaseController.cs
Endpoint.csprojConfiguration
V1
AuthController.csCampusController.csDisciplineController.csFacultyController.csGroupController.csLectureHallController.csProfessorController.csScheduleController.cs
WeatherForecastController.csMiddleware
Program.csWeatherForecast.cswwwroot
css
swagger
Security
SqlData
Application
Application.csprojDependencyInjection.cs
Common
Cqrs
Campus
Queries
Discipline
Queries
Faculty
Queries
Group
Queries
LectureHall
Queries
Professor
Queries
Schedule
Interfaces
Domain
Domain.csproj
Schedule
Migrations
MysqlMigrations
Migrations
20240601023106_InitialMigration.Designer.cs20240601023106_InitialMigration.csUberDbContextModelSnapshot.cs
MysqlMigrations.csprojPsqlMigrations
Migrations
20240601021702_InitialMigration.Designer.cs20240601021702_InitialMigration.csUberDbContextModelSnapshot.cs
PsqlMigrations.csprojSqliteMigrations
Persistence
Common
BaseDbContext.csConfigurationResolver.csDatabaseProvider.csDbContextFactory.csModelBuilderExtensions.cs
Contexts
Schedule
EntityTypeConfigurations
Persistence.csprojUberDbContext.cs
12
ApiDto/Common/AuthRoles.cs
Normal file
12
ApiDto/Common/AuthRoles.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Mirea.Api.Dto.Common;
|
||||
|
||||
/// <summary>
|
||||
/// An enumeration that indicates which role the user belongs to
|
||||
/// </summary>
|
||||
public enum AuthRoles
|
||||
{
|
||||
/// <summary>
|
||||
/// Administrator
|
||||
/// </summary>
|
||||
Admin
|
||||
}
|
21
ApiDto/Requests/LoginRequest.cs
Normal file
21
ApiDto/Requests/LoginRequest.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Mirea.Api.Dto.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Request to receive protected content
|
||||
/// </summary>
|
||||
public class LoginRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Login or Email to identify the client.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string Username { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The client's password.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string Password { get; set; }
|
||||
}
|
23
ApiDto/Responses/TokenResponse.cs
Normal file
23
ApiDto/Responses/TokenResponse.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Mirea.Api.Dto.Responses;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a JWT and RT token.
|
||||
/// </summary>
|
||||
public class TokenResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// A JWT token for accessing protected resources.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required string AccessToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The date and time when the JWT token expires.
|
||||
/// </summary>
|
||||
/// <remarks>After this date, a new JWT token must be requested.</remarks>
|
||||
[Required]
|
||||
public required DateTime ExpiresIn { get; set; }
|
||||
}
|
@@ -17,7 +17,7 @@ public class DistributedCacheService(IDistributedCache cache) : ICacheService
|
||||
SlidingExpiration = slidingExpiration
|
||||
};
|
||||
|
||||
var serializedValue = JsonSerializer.SerializeToUtf8Bytes(value);
|
||||
var serializedValue = value as byte[] ?? JsonSerializer.SerializeToUtf8Bytes(value);
|
||||
await cache.SetAsync(key, serializedValue, options, cancellationToken);
|
||||
}
|
||||
|
||||
|
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Mirea.Api.Security.Common.Interfaces;
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -16,14 +17,17 @@ public class MemoryCacheService(IMemoryCache cache) : ICacheService
|
||||
SlidingExpiration = slidingExpiration
|
||||
};
|
||||
|
||||
cache.Set(key, value, options);
|
||||
cache.Set(key, value as byte[] ?? JsonSerializer.SerializeToUtf8Bytes(value), options);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cache.TryGetValue(key, out T? value);
|
||||
return Task.FromResult(value);
|
||||
return Task.FromResult(
|
||||
cache.TryGetValue(key, out byte[]? value) ?
|
||||
JsonSerializer.Deserialize<T>(value) :
|
||||
default
|
||||
);
|
||||
}
|
||||
|
||||
public Task RemoveAsync(string key, CancellationToken cancellationToken = default)
|
||||
|
@@ -2,6 +2,7 @@
|
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Mirea.Api.Endpoint.Configuration.Swagger;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using System;
|
||||
@@ -19,6 +20,29 @@ public static class SwaggerConfiguration
|
||||
options.OperationFilter<SwaggerDefaultValues>();
|
||||
var basePath = AppDomain.CurrentDomain.BaseDirectory;
|
||||
|
||||
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||
{
|
||||
In = ParameterLocation.Header,
|
||||
Description = "Keep the JWT token in the field (Bearer token)",
|
||||
Name = "Authorization",
|
||||
Type = SecuritySchemeType.ApiKey
|
||||
});
|
||||
|
||||
options.AddSecurityRequirement(new OpenApiSecurityRequirement
|
||||
{
|
||||
{
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
Type = ReferenceType.SecurityScheme,
|
||||
Id = "Bearer"
|
||||
}
|
||||
},
|
||||
[]
|
||||
}
|
||||
});
|
||||
|
||||
options.IncludeXmlComments(Path.Combine(basePath, "docs.xml"));
|
||||
options.IncludeXmlComments(Path.Combine(basePath, "ApiDtoDocs.xml"));
|
||||
});
|
||||
|
158
Endpoint/Controllers/V1/AuthController.cs
Normal file
158
Endpoint/Controllers/V1/AuthController.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Mirea.Api.Dto.Common;
|
||||
using Mirea.Api.Dto.Requests;
|
||||
using Mirea.Api.Dto.Responses;
|
||||
using Mirea.Api.Endpoint.Common.Model;
|
||||
using Mirea.Api.Security.Common.Dto.Requests;
|
||||
using Mirea.Api.Security.Services;
|
||||
using System;
|
||||
using System.Security;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Mirea.Api.Endpoint.Controllers.V1;
|
||||
|
||||
[ApiVersion("1.0")]
|
||||
public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, PasswordHashService passwordService) : BaseController, IActionFilter
|
||||
{
|
||||
private string Fingerprint { get; set; } = string.Empty;
|
||||
private string Ip { get; set; } = string.Empty;
|
||||
private string UserAgent { get; set; } = string.Empty;
|
||||
private string RefreshToken { get; set; } = string.Empty;
|
||||
|
||||
private void SetCookie(string name, string value, DateTimeOffset? expires = null)
|
||||
{
|
||||
var cookieOptions = new CookieOptions
|
||||
{
|
||||
Expires = expires,
|
||||
Path = "/api",
|
||||
Domain = Request.Headers["X-Forwarded-Host"],
|
||||
Secure = true,
|
||||
HttpOnly = true
|
||||
};
|
||||
|
||||
Response.Cookies.Append(name, value, cookieOptions);
|
||||
}
|
||||
|
||||
private void SetRefreshToken(string value, DateTimeOffset? expires = null) =>
|
||||
SetCookie("refresh_token", value, expires);
|
||||
|
||||
private void SetFirstToken(string value, DateTimeOffset? expires = null) =>
|
||||
SetCookie("authentication_token", value, expires);
|
||||
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public void OnActionExecuting(ActionExecutingContext context)
|
||||
{
|
||||
Ip = context.HttpContext.Connection.RemoteIpAddress?.ToString()!;
|
||||
UserAgent = context.HttpContext.Request.Headers.UserAgent.ToString();
|
||||
Fingerprint = context.HttpContext.Request.Cookies["user_key"] ?? string.Empty;
|
||||
RefreshToken = Request.Cookies["refresh_token"] ?? string.Empty;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Fingerprint)) return;
|
||||
|
||||
Fingerprint = Guid.NewGuid().ToString().Replace("-", "");
|
||||
SetCookie("user_key", Fingerprint);
|
||||
}
|
||||
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public void OnActionExecuted(ActionExecutedContext context) { }
|
||||
|
||||
/// <summary>
|
||||
/// Handles user authentication by verifying the username/email and password,
|
||||
/// then generating and returning an authentication token if successful.
|
||||
/// </summary>
|
||||
/// <param name="request">The login request containing the username/email and password.</param>
|
||||
/// <returns>A TokenResponse containing the access token and its expiry if successful, otherwise an Unauthorized response.</returns>
|
||||
[HttpPost("Login")]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<TokenResponse>> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
var userEntity = user.Value;
|
||||
|
||||
if (!userEntity.Username.Equals(request.Username, StringComparison.OrdinalIgnoreCase) &&
|
||||
!userEntity.Email.Equals(request.Username, StringComparison.OrdinalIgnoreCase) ||
|
||||
!passwordService.VerifyPassword(request.Password, userEntity.Salt, userEntity.PasswordHash))
|
||||
return Unauthorized("Invalid username/email or password");
|
||||
|
||||
var token = await auth.GenerateAuthTokensAsync(new TokenRequest
|
||||
{
|
||||
Fingerprint = Fingerprint,
|
||||
Ip = Ip,
|
||||
UserAgent = UserAgent
|
||||
}, "1");
|
||||
|
||||
SetRefreshToken(token.RefreshToken, token.RefreshExpiresIn);
|
||||
|
||||
return Ok(new TokenResponse
|
||||
{
|
||||
AccessToken = token.AccessToken,
|
||||
ExpiresIn = token.AccessExpiresIn
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the authentication token using the existing refresh token.
|
||||
/// </summary>
|
||||
/// <returns>A TokenResponse containing the new access token and its expiry if successful, otherwise an Unauthorized response.</returns>
|
||||
[HttpGet("ReLogin")]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<TokenResponse>> ReLogin()
|
||||
{
|
||||
if (string.IsNullOrEmpty(RefreshToken))
|
||||
return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
var token = await auth.RefreshTokenAsync(
|
||||
new TokenRequest
|
||||
{
|
||||
Ip = Ip,
|
||||
UserAgent = UserAgent,
|
||||
Fingerprint = Fingerprint
|
||||
},
|
||||
RefreshToken
|
||||
);
|
||||
|
||||
SetRefreshToken(token.RefreshToken, token.RefreshExpiresIn);
|
||||
|
||||
return Ok(new TokenResponse
|
||||
{
|
||||
AccessToken = token.AccessToken,
|
||||
ExpiresIn = token.AccessExpiresIn
|
||||
});
|
||||
}
|
||||
catch (SecurityException)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs the user out by clearing the refresh token and performing any necessary cleanup.
|
||||
/// </summary>
|
||||
/// <returns>An Ok response if the logout was successful.</returns>
|
||||
[HttpGet("Logout")]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Logout()
|
||||
{
|
||||
SetRefreshToken("", DateTimeOffset.MinValue);
|
||||
SetFirstToken("", DateTimeOffset.MinValue);
|
||||
|
||||
await auth.LogoutAsync(Fingerprint);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the role of the authenticated user.
|
||||
/// </summary>
|
||||
/// <returns>The role of the authenticated user.</returns>
|
||||
[HttpGet("GetRole")]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[Authorize]
|
||||
public ActionResult<AuthRoles> GetRole() => Ok(AuthRoles.Admin);
|
||||
}
|
23
Endpoint/Middleware/JwtRevocationMiddleware.cs
Normal file
23
Endpoint/Middleware/JwtRevocationMiddleware.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Mirea.Api.Security.Common.Interfaces;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Mirea.Api.Endpoint.Middleware;
|
||||
|
||||
public class JwtRevocationMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task Invoke(HttpContext context, IRevokedToken revokedTokenStore)
|
||||
{
|
||||
if (context.Request.Headers.ContainsKey("Authorization"))
|
||||
{
|
||||
var token = context.Request.Headers.Authorization.ToString().Replace("Bearer ", "");
|
||||
if (await revokedTokenStore.IsTokenRevokedAsync(token))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await next(context);
|
||||
}
|
||||
}
|
@@ -6,6 +6,7 @@ using Mirea.Api.DataAccess.Application;
|
||||
using Mirea.Api.DataAccess.Persistence;
|
||||
using Mirea.Api.DataAccess.Persistence.Common;
|
||||
using Mirea.Api.Endpoint.Common.Interfaces;
|
||||
using Mirea.Api.Endpoint.Common.Model;
|
||||
using Mirea.Api.Endpoint.Common.Services;
|
||||
using Mirea.Api.Endpoint.Configuration.AppConfig;
|
||||
using Mirea.Api.Endpoint.Configuration.General;
|
||||
@@ -37,6 +38,8 @@ public class Program
|
||||
builder.Configuration.AddConfiguration(EnvironmentConfiguration.GetEnvironment());
|
||||
builder.Configuration.AddJsonFile(PathBuilder.Combine(GeneralConfig.FilePath), optional: true, reloadOnChange: true);
|
||||
builder.Services.Configure<GeneralConfig>(builder.Configuration);
|
||||
builder.Configuration.AddJsonFile(PathBuilder.Combine(Admin.PathToSave), optional: true, reloadOnChange: true);
|
||||
builder.Services.Configure<Admin>(builder.Configuration);
|
||||
|
||||
builder.Host.AddCustomSerilog();
|
||||
AddDatabase(builder.Services, builder.Configuration);
|
||||
@@ -95,6 +98,7 @@ public class Program
|
||||
|
||||
app.UseMiddleware<MaintenanceModeMiddleware>();
|
||||
app.UseMiddleware<CustomExceptionHandlerMiddleware>();
|
||||
app.UseMiddleware<JwtRevocationMiddleware>();
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
|
@@ -5,6 +5,7 @@ namespace Mirea.Api.Security.Common.Dto.Responses;
|
||||
public class AuthTokenResponse
|
||||
{
|
||||
public required string AccessToken { get; set; }
|
||||
public DateTime AccessExpiresIn { get; set; }
|
||||
public required string RefreshToken { get; set; }
|
||||
public DateTime ExpiresIn { get; set; }
|
||||
public DateTime RefreshExpiresIn { get; set; }
|
||||
}
|
@@ -34,7 +34,7 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
||||
public async Task<AuthTokenResponse> GenerateAuthTokensAsync(TokenRequest request, string userId, CancellationToken cancellation = default)
|
||||
{
|
||||
var refreshToken = GenerateRefreshToken();
|
||||
var accessToken = GenerateAccessToken(userId);
|
||||
var (token, expireIn) = GenerateAccessToken(userId);
|
||||
|
||||
var authTokenStruct = new AuthToken
|
||||
{
|
||||
@@ -43,16 +43,17 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
||||
RefreshToken = refreshToken,
|
||||
UserAgent = request.UserAgent,
|
||||
UserId = userId,
|
||||
AccessToken = accessToken.Token
|
||||
AccessToken = token
|
||||
};
|
||||
|
||||
await SetAuthTokenDataToCache(request.Fingerprint, authTokenStruct, cancellation);
|
||||
|
||||
return new AuthTokenResponse
|
||||
{
|
||||
AccessToken = accessToken.Token,
|
||||
ExpiresIn = accessToken.ExpireIn,
|
||||
RefreshToken = authTokenStruct.RefreshToken
|
||||
AccessToken = token,
|
||||
AccessExpiresIn = expireIn,
|
||||
RefreshToken = authTokenStruct.RefreshToken,
|
||||
RefreshExpiresIn = DateTime.UtcNow.Add(Lifetime),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,23 +72,28 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
||||
authToken.UserAgent != request.UserAgent &&
|
||||
authToken.Ip != request.Ip)
|
||||
{
|
||||
await cache.RemoveAsync(request.Fingerprint, cancellation);
|
||||
await cache.RemoveAsync(GetAuthCacheKey(request.Fingerprint), cancellation);
|
||||
await RevokeAccessToken(authToken.AccessToken);
|
||||
|
||||
throw new SecurityException(request.Fingerprint);
|
||||
}
|
||||
|
||||
var accessToken = GenerateAccessToken(authToken.UserId);
|
||||
var (token, expireIn) = GenerateAccessToken(authToken.UserId);
|
||||
await RevokeAccessToken(authToken.AccessToken);
|
||||
|
||||
authToken.AccessToken = accessToken.Token;
|
||||
var newRefreshToken = GenerateRefreshToken();
|
||||
|
||||
authToken.AccessToken = token;
|
||||
authToken.RefreshToken = newRefreshToken;
|
||||
|
||||
await SetAuthTokenDataToCache(request.Fingerprint, authToken, cancellation);
|
||||
|
||||
return new AuthTokenResponse
|
||||
{
|
||||
AccessToken = accessToken.Token,
|
||||
ExpiresIn = accessToken.ExpireIn,
|
||||
RefreshToken = GenerateRefreshToken()
|
||||
AccessToken = token,
|
||||
AccessExpiresIn = expireIn,
|
||||
RefreshToken = newRefreshToken,
|
||||
RefreshExpiresIn = DateTime.UtcNow.Add(Lifetime)
|
||||
};
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user