Dev #11
@@ -101,9 +101,13 @@ docker run --rm -p 8080:8080 \
|
|||||||
## Аутентификация
|
## Аутентификация
|
||||||
|
|
||||||
- `POST /api/v1/auth/login/dev` — дев-логин (только в `Development`). Удобно для локальной разработки.
|
- `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`
|
- `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]`.
|
Большинство методов API защищены `[Authorize]`.
|
||||||
|
|
||||||
## Фоновый LLM-анализ отзывов
|
## Фоновый LLM-анализ отзывов
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
using UniVerse.Application.DTOs.Auth;
|
using UniVerse.Application.DTOs.Auth;
|
||||||
using UniVerse.Application.Interfaces;
|
using UniVerse.Application.Interfaces;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace UniVerse.Api.Controllers;
|
namespace UniVerse.Api.Controllers;
|
||||||
@@ -11,16 +13,122 @@ namespace UniVerse.Api.Controllers;
|
|||||||
public class AuthController : ControllerBase
|
public class AuthController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IAuthService _auth;
|
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")]
|
[HttpPost("login/microsoft")]
|
||||||
public async Task<ActionResult<AuthResponse>> LoginMicrosoft([FromBody] LoginMicrosoftRequest request)
|
public async Task<ActionResult<AuthResponse>> LoginMicrosoft([FromBody] LoginMicrosoftRequest request)
|
||||||
{
|
{
|
||||||
var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode);
|
var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode, request.RedirectUri);
|
||||||
SetRefreshTokenCookie(result.RefreshToken);
|
SetRefreshTokenCookie(result.RefreshToken);
|
||||||
return Ok(result.Response);
|
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<string, string?>
|
||||||
|
{
|
||||||
|
["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<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);
|
||||||
|
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")]
|
[HttpPost("login/dev")]
|
||||||
public async Task<ActionResult<AuthResponse>> DevLogin([FromBody] DevLoginRequest request)
|
public async Task<ActionResult<AuthResponse>> DevLogin([FromBody] DevLoginRequest request)
|
||||||
{
|
{
|
||||||
@@ -70,4 +178,32 @@ public class AuthController : ControllerBase
|
|||||||
Expires = DateTime.UtcNow.AddDays(30)
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ public record AuthResult(AuthResponse Response, string RefreshToken);
|
|||||||
|
|
||||||
public record UserAuthDto(int Id, string Email, string? DisplayName, UserRole Role);
|
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);
|
public record DevLoginRequest(string Email, string? DisplayName = null, UserRole Role = UserRole.Student);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace UniVerse.Application.Interfaces;
|
|||||||
|
|
||||||
public interface IAuthService
|
public interface IAuthService
|
||||||
{
|
{
|
||||||
Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode);
|
Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null);
|
||||||
Task<AuthResult> DevLoginAsync(string email, string? displayName, Domain.Enums.UserRole role);
|
Task<AuthResult> DevLoginAsync(string email, string? displayName, Domain.Enums.UserRole role);
|
||||||
Task<AuthResult> RefreshTokenAsync(string refreshToken);
|
Task<AuthResult> RefreshTokenAsync(string refreshToken);
|
||||||
Task RevokeRefreshTokenAsync(string refreshToken);
|
Task RevokeRefreshTokenAsync(string refreshToken);
|
||||||
|
|||||||
@@ -30,16 +30,26 @@ public class AuthService : IAuthService
|
|||||||
_gamification = gamification;
|
_gamification = gamification;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode)
|
public async Task<AuthResult> LoginWithMicrosoftAsync(string authorizationCode, string? redirectUri = null)
|
||||||
{
|
{
|
||||||
var tenantId = _config["AzureAd:TenantId"];
|
var tenantId = _config["AzureAd:TenantId"];
|
||||||
var clientId = _config["AzureAd:ClientId"];
|
var clientId = _config["AzureAd:ClientId"];
|
||||||
var clientSecret = _config["AzureAd:ClientSecret"];
|
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)
|
var app = ConfidentialClientApplicationBuilder.Create(clientId)
|
||||||
.WithClientSecret(clientSecret)
|
.WithClientSecret(clientSecret)
|
||||||
.WithAuthority(new Uri($"https://login.microsoftonline.com/{tenantId}"))
|
.WithAuthority(new Uri(authority))
|
||||||
.WithRedirectUri(_config["AzureAd:RedirectUri"] ?? "http://localhost:5173/auth/callback")
|
.WithRedirectUri(effectiveRedirectUri)
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
AuthenticationResult result;
|
AuthenticationResult result;
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ services:
|
|||||||
- AzureAd:ClientSecret=${AzureAd_ClientSecret}
|
- AzureAd:ClientSecret=${AzureAd_ClientSecret}
|
||||||
- AzureAd:Domain=${AzureAd_Domain:-sfedu.onmicrosoft.com}
|
- AzureAd:Domain=${AzureAd_Domain:-sfedu.onmicrosoft.com}
|
||||||
- AzureAd:CallbackPath=${AzureAd_CallbackPath:-/signin-oidc}
|
- AzureAd:CallbackPath=${AzureAd_CallbackPath:-/signin-oidc}
|
||||||
|
# https://<domain>/api/v1/auth/callback/microsoft
|
||||||
|
- AzureAd:RedirectUri=${AzureAd_RedirectUri}
|
||||||
|
- AzureAd:PostLoginRedirectUri=${AzureAd_PostLoginRedirectUri:-}
|
||||||
|
|
||||||
- Jwt:Secret=${JWT_SECRET}
|
- Jwt:Secret=${JWT_SECRET}
|
||||||
- Jwt:Issuer=${JWT_ISSUER:-UniVerse}
|
- Jwt:Issuer=${JWT_ISSUER:-UniVerse}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ services:
|
|||||||
- AzureAd:ClientSecret=${AzureAd_ClientSecret}
|
- AzureAd:ClientSecret=${AzureAd_ClientSecret}
|
||||||
- AzureAd:Domain=${AzureAd_Domain:-sfedu.onmicrosoft.com}
|
- AzureAd:Domain=${AzureAd_Domain:-sfedu.onmicrosoft.com}
|
||||||
- AzureAd:CallbackPath=${AzureAd_CallbackPath:-/signin-oidc}
|
- 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:Secret=${JWT_SECRET}
|
||||||
- Jwt:Issuer=${JWT_ISSUER:-UniVerse}
|
- Jwt:Issuer=${JWT_ISSUER:-UniVerse}
|
||||||
|
|||||||
Reference in New Issue
Block a user