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, GetClientIpAddress());
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, GetClientIpAddress());
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, GetClientIpAddress());
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 string? GetClientIpAddress()
{
if (Request.Headers.TryGetValue("X-Forwarded-For", out var forwardedFor))
{
var firstForwardedAddress = forwardedFor.ToString().Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
if (!string.IsNullOrWhiteSpace(firstForwardedAddress))
return firstForwardedAddress;
}
if (Request.Headers.TryGetValue("X-Real-IP", out var realIp) && !string.IsNullOrWhiteSpace(realIp))
return realIp;
return HttpContext.Connection.RemoteIpAddress?.ToString();
}
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;
}
}