using Asp.Versioning; 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.Attributes; using Mirea.Api.Endpoint.Common.Exceptions; using Mirea.Api.Endpoint.Common.Services; using Mirea.Api.Endpoint.Common.Settings; 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 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 = UrlHelper.GetSubPathWithoutFirstApiName + "api", Domain = HttpContext.GetCurrentDomain(), HttpOnly = true, #if !DEBUG Secure = true #endif }; Response.Cookies.Append(name, value, cookieOptions); } private void SetRefreshToken(string value, DateTimeOffset? expires = null) { SetCookie("refresh_token", value, expires); SetCookie("user_key", Fingerprint, expires); } private void SetFirstToken(string value, DateTimeOffset? expires = null) { SetCookie("authentication_token", value, expires); SetCookie("user_key", Fingerprint, expires); } [ApiExplorerSettings(IgnoreApi = true)] public void OnActionExecuting(ActionExecutingContext context) { Ip = HttpContext.Connection.RemoteIpAddress?.ToString()!; UserAgent = Request.Headers.UserAgent.ToString(); Fingerprint = Request.Cookies["user_key"] ?? string.Empty; RefreshToken = Request.Cookies["refresh_token"] ?? string.Empty; if (!string.IsNullOrWhiteSpace(Fingerprint)) return; Fingerprint = Guid.NewGuid().ToString().Replace("-", ""); } [ApiExplorerSettings(IgnoreApi = true)] public void OnActionExecuted(ActionExecutedContext context) { } /// /// Handles user authentication by verifying the username/email and password, /// then generating and returning an authentication token if successful. /// /// The login request containing the username/email and password. /// A TokenResponse containing the access token and its expiry if successful, otherwise an Unauthorized response. [HttpPost("Login")] [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task> 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 }); } /// /// Refreshes the authentication token using the existing refresh token. /// /// A TokenResponse containing the new access token and its expiry if successful, otherwise an Unauthorized response. [HttpGet("ReLogin")] [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task> 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(); } } /// /// Logs the user out by clearing the refresh token and performing any necessary cleanup. /// /// An Ok response if the logout was successful. [HttpGet("Logout")] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [Authorize] public async Task Logout() { SetRefreshToken("", DateTimeOffset.MinValue); SetFirstToken("", DateTimeOffset.MinValue); await auth.LogoutAsync(Fingerprint); return Ok(); } /// /// Retrieves the role of the authenticated user. /// /// The role of the authenticated user. [HttpGet("GetRole")] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [Authorize] [CacheMaxAge(0, 0, 1)] public ActionResult GetRole() => Ok(AuthRoles.Admin); [HttpPost("RenewPassword")] [ApiExplorerSettings(IgnoreApi = true)] [Localhost] [BadRequestResponse] public ActionResult RenewPassword([FromBody] string? password = null) { if (string.IsNullOrEmpty(password)) password = string.Empty; else if (!PasswordHashService.HasPasswordInPolicySecurity(password)) throw new ControllerArgumentException("The password must be at least 8 characters long and contain at least one uppercase letter and one special character."); while (!PasswordHashService.HasPasswordInPolicySecurity(password)) password = GeneratorKey.GenerateAlphaNumeric(16, includes: "!@#%^"); var (salt, hash) = passwordService.HashPassword(password); var admin = user.Value; admin.Salt = salt; admin.PasswordHash = hash; admin.SaveSetting(); return Ok(password); } }