Files

305 lines
15 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using UniVerse.Application.DTOs.Auth;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Enums;
using System.Security.Cryptography;
using System.Security.Claims;
namespace UniVerse.Api.Controllers;
/// <summary>Аутентификация и управление сессией пользователя.</summary>
[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;
}
/// <summary>Вход через Microsoft Entra ID (SPA/PKCE flow).</summary>
/// <remarks>
/// Фронтенд самостоятельно обрабатывает редирект к Microsoft и передаёт сюда
/// полученный authorization code. В ответ возвращается пара JWT-токенов;
/// refresh token устанавливается в HttpOnly cookie.
/// </remarks>
/// <param name="request">Authorization code и redirect URI из Microsoft OAuth2.</param>
/// <response code="200">Успешный вход — возвращает access token и данные пользователя.</response>
/// <response code="400">Неверный или просроченный authorization code.</response>
[HttpPost("login/microsoft")]
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<AuthResponse>> LoginMicrosoft([FromBody] LoginMicrosoftRequest request)
{
var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode, request.RedirectUri, GetClientIpAddress());
SetRefreshTokenCookie(result.RefreshToken);
return Ok(result.Response);
}
/// <summary>Инициация server-driven входа через Microsoft (редирект-flow).</summary>
/// <remarks>
/// Браузер переходит на этот URL; backend строит Microsoft authorize URL с CSRF state
/// и редиректит пользователя на `login.microsoftonline.com`.
/// После успешного входа Microsoft редиректит на `GET /api/v1/auth/callback/microsoft`.
/// </remarks>
/// <param name="returnUrl">URL для редиректа после успешного входа (опционально).</param>
/// <response code="302">Редирект на Microsoft authorize endpoint.</response>
/// <response code="500">Microsoft authentication не настроен (AzureAd:TenantId/ClientId отсутствуют).</response>
[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<string, string?>
{
["client_id"] = clientId,
["response_type"] = "code",
["redirect_uri"] = redirectUri,
["response_mode"] = "query",
["scope"] = scope,
["state"] = state
});
return Redirect(authorizeUrl);
}
/// <summary>OAuth2 callback — обмен code на токены (server-driven flow).</summary>
/// <remarks>
/// Microsoft редиректит браузер сюда после успешного входа.
/// Backend валидирует CSRF state, обменивает code на токены,
/// устанавливает refresh token cookie и редиректит на `returnUrl` с access token в URL-фрагменте.
/// </remarks>
/// <param name="code">Authorization code от Microsoft.</param>
/// <param name="state">CSRF state для верификации.</param>
/// <param name="error">Код ошибки от Microsoft (если вход не удался).</param>
/// <param name="errorDescription">Описание ошибки от Microsoft.</param>
/// <response code="302">Успешный вход — редирект на returnUrl с токеном в URL-фрагменте.</response>
/// <response code="200">Если returnUrl не задан — возвращает JSON с токенами (удобно для тестирования).</response>
/// <response code="400">Отсутствует authorization code.</response>
/// <response code="401">Ошибка от Microsoft или невалидный CSRF state.</response>
[HttpGet("callback/microsoft")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> 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);
}
/// <summary>Dev-only вход без OAuth (только в Development-окружении).</summary>
/// <remarks>
/// Создаёт или находит пользователя по email без реального OAuth flow.
/// Возвращает 404 в Production и Staging.
/// </remarks>
/// <param name="request">Email, отображаемое имя и роль тестового пользователя.</param>
/// <response code="200">Успешный вход.</response>
/// <response code="404">Endpoint недоступен вне Development.</response>
[HttpPost("login/dev")]
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AuthResponse>> DevLogin([FromBody] DevLoginRequest request)
{
if (!HttpContext.RequestServices.GetRequiredService<IWebHostEnvironment>().IsDevelopment())
return NotFound();
var roles = request.Roles?.Count > 0 ? request.Roles : [UserRole.Student];
var result = await _auth.DevLoginAsync(request.Email, request.DisplayName, roles, GetClientIpAddress());
SetRefreshTokenCookie(result.RefreshToken);
return Ok(result.Response);
}
/// <summary>Обновление access token по refresh token из HttpOnly cookie.</summary>
/// <remarks>
/// Refresh token читается из HttpOnly cookie `refreshToken` (устанавливается при входе).
/// Возвращает новую пару токенов и обновляет cookie.
/// </remarks>
/// <response code="200">Новая пара токенов.</response>
/// <response code="401">Refresh token отсутствует, просрочен или отозван.</response>
/// <response code="403">Аккаунт деактивирован или refresh token недействителен.</response>
[HttpPost("refresh")]
[ProducesResponseType(typeof(AuthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AuthResponse>> 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);
}
/// <summary>Выход из системы — отзыв refresh token.</summary>
/// <remarks>
/// Инвалидирует текущий refresh token в БД и удаляет cookie.
/// После этого вызова access token остаётся валидным до истечения его TTL (30 минут).
/// </remarks>
/// <response code="204">Выход выполнен успешно.</response>
/// <response code="401">Требуется аутентификация.</response>
[Authorize]
[HttpPost("logout")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Logout()
{
var refreshToken = Request.Cookies["refreshToken"];
if (!string.IsNullOrEmpty(refreshToken))
await _auth.RevokeRefreshTokenAsync(refreshToken);
Response.Cookies.Delete("refreshToken");
return NoContent();
}
/// <summary>Получение профиля текущего авторизованного пользователя.</summary>
/// <response code="200">Данные текущего пользователя.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="404">Пользователь не найден в БД (рассинхронизация токена).</response>
[Authorize]
[HttpGet("me")]
[ProducesResponseType(typeof(UniVerse.Application.DTOs.Users.CurrentUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> 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<string[]>() ?? Array.Empty<string>();
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;
}
}