using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.WebUtilities; using UniVerse.Application.DTOs.Auth; using UniVerse.Application.Interfaces; using System.Security.Cryptography; using System.Security.Claims; namespace UniVerse.Api.Controllers; /// Аутентификация и управление сессией пользователя. [ApiController] [Route("api/v1/auth")] [Produces("application/json")] public class AuthController : ControllerBase { private readonly IAuthService _auth; private readonly IConfiguration _config; private const string MicrosoftStateCookieName = "msAuthState"; private const string MicrosoftReturnUrlCookieName = "msAuthReturnUrl"; public AuthController(IAuthService auth, IConfiguration config) { _auth = auth; _config = config; } /// Вход через Microsoft Entra ID (SPA/PKCE flow). /// /// Фронтенд самостоятельно обрабатывает редирект к Microsoft и передаёт сюда /// полученный authorization code. В ответ возвращается пара JWT-токенов; /// refresh token устанавливается в HttpOnly cookie. /// /// Authorization code и redirect URI из Microsoft OAuth2. /// Успешный вход — возвращает access token и данные пользователя. /// Неверный или просроченный authorization code. [HttpPost("login/microsoft")] [ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task> LoginMicrosoft([FromBody] LoginMicrosoftRequest request) { var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode, request.RedirectUri); SetRefreshTokenCookie(result.RefreshToken); return Ok(result.Response); } /// Инициация server-driven входа через Microsoft (редирект-flow). /// /// Браузер переходит на этот URL; backend строит Microsoft authorize URL с CSRF state /// и редиректит пользователя на `login.microsoftonline.com`. /// После успешного входа Microsoft редиректит на `GET /api/v1/auth/callback/microsoft`. /// /// URL для редиректа после успешного входа (опционально). /// Редирект на Microsoft authorize endpoint. /// Microsoft authentication не настроен (AzureAd:TenantId/ClientId отсутствуют). [HttpGet("login/microsoft")] [AllowAnonymous] [ProducesResponseType(StatusCodes.Status302Found)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] public IActionResult LoginMicrosoftRedirect([FromQuery] string? returnUrl = null) { var tenantId = _config["AzureAd:TenantId"]; var clientId = _config["AzureAd:ClientId"]; var instance = _config["AzureAd:Instance"] ?? "https://login.microsoftonline.com/"; if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId)) return Problem("Microsoft authentication is not configured (AzureAd:TenantId/ClientId).", statusCode: StatusCodes.Status500InternalServerError); var redirectUri = _config["AzureAd:RedirectUri"] ?? BuildAbsoluteUrl("/api/v1/auth/callback/microsoft"); var state = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); Response.Cookies.Append(MicrosoftStateCookieName, state, new CookieOptions { HttpOnly = true, Secure = Request.IsHttps, SameSite = SameSiteMode.Lax, Expires = DateTimeOffset.UtcNow.AddMinutes(10) }); if (!string.IsNullOrWhiteSpace(returnUrl) && IsAllowedReturnUrl(returnUrl)) { Response.Cookies.Append(MicrosoftReturnUrlCookieName, returnUrl, new CookieOptions { HttpOnly = true, Secure = Request.IsHttps, SameSite = SameSiteMode.Lax, Expires = DateTimeOffset.UtcNow.AddMinutes(10) }); } var authorizeEndpoint = $"{instance.TrimEnd('/')}/{tenantId}/oauth2/v2.0/authorize"; var scope = _config["AzureAd:Scopes"] ?? "openid profile email offline_access User.Read"; var authorizeUrl = QueryHelpers.AddQueryString(authorizeEndpoint, new Dictionary { ["client_id"] = clientId, ["response_type"] = "code", ["redirect_uri"] = redirectUri, ["response_mode"] = "query", ["scope"] = scope, ["state"] = state }); return Redirect(authorizeUrl); } /// OAuth2 callback — обмен code на токены (server-driven flow). /// /// Microsoft редиректит браузер сюда после успешного входа. /// Backend валидирует CSRF state, обменивает code на токены, /// устанавливает refresh token cookie и редиректит на `returnUrl` с access token в URL-фрагменте. /// /// Authorization code от Microsoft. /// CSRF state для верификации. /// Код ошибки от Microsoft (если вход не удался). /// Описание ошибки от Microsoft. /// Успешный вход — редирект на returnUrl с токеном в URL-фрагменте. /// Если returnUrl не задан — возвращает JSON с токенами (удобно для тестирования). /// Отсутствует authorization code. /// Ошибка от Microsoft или невалидный CSRF state. [HttpGet("callback/microsoft")] [AllowAnonymous] [ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status302Found)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task CallbackMicrosoft( [FromQuery] string? code = null, [FromQuery] string? state = null, [FromQuery] string? error = null, [FromQuery(Name = "error_description")] string? errorDescription = null) { if (!string.IsNullOrEmpty(error)) { return Unauthorized(new { error, errorDescription }); } if (string.IsNullOrWhiteSpace(code)) return BadRequest(new { error = "missing_code" }); var expectedState = Request.Cookies[MicrosoftStateCookieName]; if (string.IsNullOrWhiteSpace(expectedState) || string.IsNullOrWhiteSpace(state) || !string.Equals(expectedState, state, StringComparison.Ordinal)) return Unauthorized(new { error = "invalid_state" }); Response.Cookies.Delete(MicrosoftStateCookieName); var redirectUri = _config["AzureAd:RedirectUri"] ?? BuildAbsoluteUrl("/api/v1/auth/callback/microsoft"); var result = await _auth.LoginWithMicrosoftAsync(code, redirectUri); SetRefreshTokenCookie(result.RefreshToken); var returnUrl = Request.Cookies[MicrosoftReturnUrlCookieName] ?? _config["AzureAd:PostLoginRedirectUri"]; Response.Cookies.Delete(MicrosoftReturnUrlCookieName); if (!string.IsNullOrWhiteSpace(returnUrl) && IsAllowedReturnUrl(returnUrl)) { // Put access token in URL fragment so it is not sent as Referer to the backend. // Frontend can read it from location.hash on the landing page. var fragment = $"access_token={Uri.EscapeDataString(result.Response.AccessToken)}&expires_at={Uri.EscapeDataString(result.Response.ExpiresAt.ToString("O"))}"; return Redirect($"{returnUrl}#{fragment}"); } // Useful for manual testing without frontend: you'll see JSON in the browser. return Ok(result.Response); } /// Dev-only вход без OAuth (только в Development-окружении). /// /// Создаёт или находит пользователя по email без реального OAuth flow. /// Возвращает 404 в Production и Staging. /// /// Email, отображаемое имя и роль тестового пользователя. /// Успешный вход. /// Endpoint недоступен вне Development. [HttpPost("login/dev")] [ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> DevLogin([FromBody] DevLoginRequest request) { if (!HttpContext.RequestServices.GetRequiredService().IsDevelopment()) return NotFound(); var result = await _auth.DevLoginAsync(request.Email, request.DisplayName, request.Role); SetRefreshTokenCookie(result.RefreshToken); return Ok(result.Response); } /// Обновление access token по refresh token из HttpOnly cookie. /// /// Refresh token читается из HttpOnly cookie `refreshToken` (устанавливается при входе). /// Возвращает новую пару токенов и обновляет cookie. /// /// Новая пара токенов. /// Refresh token отсутствует, просрочен или отозван. [HttpPost("refresh")] [ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task> Refresh() { var refreshToken = Request.Cookies["refreshToken"]; if (string.IsNullOrEmpty(refreshToken)) return Unauthorized(); var result = await _auth.RefreshTokenAsync(refreshToken); SetRefreshTokenCookie(result.RefreshToken); return Ok(result.Response); } /// Выход из системы — отзыв refresh token. /// /// Инвалидирует текущий refresh token в БД и удаляет cookie. /// После этого вызова access token остаётся валидным до истечения его TTL (30 минут). /// /// Выход выполнен успешно. /// Требуется аутентификация. [Authorize] [HttpPost("logout")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task Logout() { var refreshToken = Request.Cookies["refreshToken"]; if (!string.IsNullOrEmpty(refreshToken)) await _auth.RevokeRefreshTokenAsync(refreshToken); Response.Cookies.Delete("refreshToken"); return NoContent(); } /// Получение профиля текущего авторизованного пользователя. /// Данные текущего пользователя. /// Требуется аутентификация. /// Пользователь не найден в БД (рассинхронизация токена). [Authorize] [HttpGet("me")] [ProducesResponseType(typeof(UniVerse.Application.DTOs.Users.UserDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task Me() { var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0"); var user = await _auth.GetCurrentUserAsync(userId); return Ok(user); } private void SetRefreshTokenCookie(string token) { Response.Cookies.Append("refreshToken", token, new CookieOptions { HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict, Expires = DateTime.UtcNow.AddDays(30) }); } private string BuildAbsoluteUrl(string path) { if (!path.StartsWith('/')) path = "/" + path; return $"{Request.Scheme}://{Request.Host}{path}"; } private bool IsAllowedReturnUrl(string returnUrl) { if (Uri.TryCreate(returnUrl, UriKind.Relative, out _)) return true; if (!Uri.TryCreate(returnUrl, UriKind.Absolute, out var absolute)) return false; var allowedOrigins = _config.GetSection("Cors:Origins").Get() ?? Array.Empty(); foreach (var origin in allowedOrigins) { if (!Uri.TryCreate(origin, UriKind.Absolute, out var allowed)) continue; if (string.Equals(allowed.Scheme, absolute.Scheme, StringComparison.OrdinalIgnoreCase) && string.Equals(allowed.Host, absolute.Host, StringComparison.OrdinalIgnoreCase) && allowed.Port == absolute.Port) return true; } return false; } }