From 32ca5963c84934fb5584dce2c2cea02c67c9754a Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Sun, 10 May 2026 21:57:19 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B4=D0=B5=D0=BB=D0=B0?= =?UTF-8?q?=D0=BB=20MS=20Auth=20=D0=B4=D0=BB=D1=8F=20=D1=84=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- .../Controllers/AuthController.cs | 140 +++++++++++++++++- .../DTOs/Auth/AuthDtos.cs | 2 +- .../Interfaces/IAuthService.cs | 2 +- .../Services/AuthService.cs | 16 +- docker-compose-prod.yml | 3 + docker-compose-test.yml | 2 + 7 files changed, 163 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index beed8e9..4cb1b4f 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,13 @@ docker run --rm -p 8080:8080 \ ## Аутентификация - `POST /api/v1/auth/login/dev` — дев-логин (только в `Development`). Удобно для локальной разработки. -- `POST /api/v1/auth/login/microsoft` — заготовка под Microsoft Entra ID (сейчас не реализовано). +- `GET /api/v1/auth/login/microsoft` — старт входа через Microsoft Entra ID (бэкенд сам делает редирект на Microsoft). +- `GET /api/v1/auth/callback/microsoft` — callback, куда Microsoft возвращает `code`. +- `POST /api/v1/auth/login/microsoft` — обмен `authorizationCode` на токены (полезно для интеграций/ручных тестов). Тело: `{ "authorizationCode": "...", "redirectUri"?: "..." }`. - `POST /api/v1/auth/refresh`, `POST /api/v1/auth/logout`, `GET /api/v1/auth/me` +Для Microsoft Entra ID нужны настройки (через env или appsettings): `AzureAd:TenantId`, `AzureAd:ClientId`, `AzureAd:ClientSecret` (и при необходимости `AzureAd:Instance`, `AzureAd:RedirectUri`, `AzureAd:PostLoginRedirectUri`). + Большинство методов API защищены `[Authorize]`. ## Фоновый LLM-анализ отзывов diff --git a/backend/UniVerse.Api/Controllers/AuthController.cs b/backend/UniVerse.Api/Controllers/AuthController.cs index d0cf3cb..38b5303 100644 --- a/backend/UniVerse.Api/Controllers/AuthController.cs +++ b/backend/UniVerse.Api/Controllers/AuthController.cs @@ -1,7 +1,9 @@ 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; @@ -11,16 +13,122 @@ namespace UniVerse.Api.Controllers; public class AuthController : ControllerBase { private readonly IAuthService _auth; - public AuthController(IAuthService auth) => _auth = 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; + } [HttpPost("login/microsoft")] public async Task> LoginMicrosoft([FromBody] LoginMicrosoftRequest request) { - var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode); + var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode, request.RedirectUri); SetRefreshTokenCookie(result.RefreshToken); return Ok(result.Response); } + // Server-driven auth flow: frontend just navigates here; backend builds Microsoft authorize URL. + // Optional returnUrl is stored in a short-lived cookie and used by callback. + [HttpGet("login/microsoft")] + [AllowAnonymous] + 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); + } + + [HttpGet("callback/microsoft")] + [AllowAnonymous] + 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); + } + [HttpPost("login/dev")] public async Task> DevLogin([FromBody] DevLoginRequest request) { @@ -70,4 +178,32 @@ public class AuthController : ControllerBase 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; + } } diff --git a/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs b/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs index a9a6770..efd97ed 100644 --- a/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs +++ b/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs @@ -7,6 +7,6 @@ public record AuthResult(AuthResponse Response, string RefreshToken); public record UserAuthDto(int Id, string Email, string? DisplayName, UserRole Role); -public record LoginMicrosoftRequest(string AuthorizationCode); +public record LoginMicrosoftRequest(string AuthorizationCode, string? RedirectUri = null); public record DevLoginRequest(string Email, string? DisplayName = null, UserRole Role = UserRole.Student); diff --git a/backend/UniVerse.Application/Interfaces/IAuthService.cs b/backend/UniVerse.Application/Interfaces/IAuthService.cs index 9e33059..c0d615b 100644 --- a/backend/UniVerse.Application/Interfaces/IAuthService.cs +++ b/backend/UniVerse.Application/Interfaces/IAuthService.cs @@ -5,7 +5,7 @@ namespace UniVerse.Application.Interfaces; public interface IAuthService { - Task LoginWithMicrosoftAsync(string authorizationCode); + Task LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null); Task DevLoginAsync(string email, string? displayName, Domain.Enums.UserRole role); Task RefreshTokenAsync(string refreshToken); Task RevokeRefreshTokenAsync(string refreshToken); diff --git a/backend/UniVerse.Infrastructure/Services/AuthService.cs b/backend/UniVerse.Infrastructure/Services/AuthService.cs index 5b684bc..94ff77d 100644 --- a/backend/UniVerse.Infrastructure/Services/AuthService.cs +++ b/backend/UniVerse.Infrastructure/Services/AuthService.cs @@ -30,16 +30,26 @@ public class AuthService : IAuthService _gamification = gamification; } - public async Task LoginWithMicrosoftAsync(string authorizationCode) + public async Task LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null) { var tenantId = _config["AzureAd:TenantId"]; var clientId = _config["AzureAd:ClientId"]; var clientSecret = _config["AzureAd:ClientSecret"]; + var instance = _config["AzureAd:Instance"] ?? "https://login.microsoftonline.com/"; + + if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret)) + throw new UnauthorizedException("Microsoft authentication is not configured (AzureAd:TenantId/ClientId/ClientSecret)."); + + var effectiveRedirectUri = redirectUri + ?? _config["AzureAd:RedirectUri"] + ?? "http://localhost:5173/auth/callback"; + + var authority = $"{instance.TrimEnd('/')}/{tenantId}"; var app = ConfidentialClientApplicationBuilder.Create(clientId) .WithClientSecret(clientSecret) - .WithAuthority(new Uri($"https://login.microsoftonline.com/{tenantId}")) - .WithRedirectUri(_config["AzureAd:RedirectUri"] ?? "http://localhost:5173/auth/callback") + .WithAuthority(new Uri(authority)) + .WithRedirectUri(effectiveRedirectUri) .Build(); AuthenticationResult result; diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index b5a96db..a2c3dc8 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -14,6 +14,9 @@ services: - AzureAd:ClientSecret=${AzureAd_ClientSecret} - AzureAd:Domain=${AzureAd_Domain:-sfedu.onmicrosoft.com} - AzureAd:CallbackPath=${AzureAd_CallbackPath:-/signin-oidc} + # https:///api/v1/auth/callback/microsoft + - AzureAd:RedirectUri=${AzureAd_RedirectUri} + - AzureAd:PostLoginRedirectUri=${AzureAd_PostLoginRedirectUri:-} - Jwt:Secret=${JWT_SECRET} - Jwt:Issuer=${JWT_ISSUER:-UniVerse} diff --git a/docker-compose-test.yml b/docker-compose-test.yml index 8915fd7..7000317 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -17,6 +17,8 @@ services: - AzureAd:ClientSecret=${AzureAd_ClientSecret} - AzureAd:Domain=${AzureAd_Domain:-sfedu.onmicrosoft.com} - AzureAd:CallbackPath=${AzureAd_CallbackPath:-/signin-oidc} + - AzureAd:RedirectUri=${AzureAd_RedirectUri:-http://localhost:8088/api/v1/auth/callback/microsoft} + - AzureAd:PostLoginRedirectUri=${AzureAd_PostLoginRedirectUri:-} - Jwt:Secret=${JWT_SECRET} - Jwt:Issuer=${JWT_ISSUER:-UniVerse}