From e2094cf0e93dc1c49c2c0044906999e7e4a69fed Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Wed, 6 May 2026 14:35:02 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B8=D0=BB=20ms=20sso?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../Controllers/AuthController.cs | 10 +- backend/UniVerse.Api/UniVerse.Api.csproj | 4 +- .../UniVerse.Api/appsettings.Development.json | 2 +- .../DTOs/Auth/AuthDtos.cs | 1 + .../Interfaces/IAuthService.cs | 6 +- .../Exceptions/UnauthorizedException.cs | 7 ++ .../Services/AuthService.cs | 98 ++++++++++++++++--- .../UniVerse.Infrastructure.csproj | 2 + 9 files changed, 107 insertions(+), 25 deletions(-) create mode 100644 backend/UniVerse.Domain/Exceptions/UnauthorizedException.cs diff --git a/README.md b/README.md index 993191b..beed8e9 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ docker run --rm --name universe-postgres \ -e POSTGRES_USER=postgres \ -e POSTGRES_PASSWORD=postgres \ -p 5432:5432 \ - postgres:16 + postgres:18 ``` 2) Применить миграции (первый раз потребуется `dotnet-ef`): diff --git a/backend/UniVerse.Api/Controllers/AuthController.cs b/backend/UniVerse.Api/Controllers/AuthController.cs index 369f800..d0cf3cb 100644 --- a/backend/UniVerse.Api/Controllers/AuthController.cs +++ b/backend/UniVerse.Api/Controllers/AuthController.cs @@ -17,7 +17,8 @@ public class AuthController : ControllerBase public async Task> LoginMicrosoft([FromBody] LoginMicrosoftRequest request) { var result = await _auth.LoginWithMicrosoftAsync(request.AuthorizationCode); - return Ok(result); + SetRefreshTokenCookie(result.RefreshToken); + return Ok(result.Response); } [HttpPost("login/dev")] @@ -26,8 +27,8 @@ public class AuthController : ControllerBase if (!HttpContext.RequestServices.GetRequiredService().IsDevelopment()) return NotFound(); var result = await _auth.DevLoginAsync(request.Email, request.DisplayName, request.Role); - SetRefreshTokenCookie(result.AccessToken); // simplified: set cookie logic - return Ok(result); + SetRefreshTokenCookie(result.RefreshToken); + return Ok(result.Response); } [HttpPost("refresh")] @@ -36,7 +37,8 @@ public class AuthController : ControllerBase var refreshToken = Request.Cookies["refreshToken"]; if (string.IsNullOrEmpty(refreshToken)) return Unauthorized(); var result = await _auth.RefreshTokenAsync(refreshToken); - return Ok(result); + SetRefreshTokenCookie(result.RefreshToken); + return Ok(result.Response); } [Authorize] diff --git a/backend/UniVerse.Api/UniVerse.Api.csproj b/backend/UniVerse.Api/UniVerse.Api.csproj index 592c48a..86f5e3f 100644 --- a/backend/UniVerse.Api/UniVerse.Api.csproj +++ b/backend/UniVerse.Api/UniVerse.Api.csproj @@ -10,11 +10,13 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all + + diff --git a/backend/UniVerse.Api/appsettings.Development.json b/backend/UniVerse.Api/appsettings.Development.json index 0c208ae..3e1a225 100644 --- a/backend/UniVerse.Api/appsettings.Development.json +++ b/backend/UniVerse.Api/appsettings.Development.json @@ -2,7 +2,7 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Information" } } } diff --git a/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs b/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs index e7c5f3c..a9a6770 100644 --- a/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs +++ b/backend/UniVerse.Application/DTOs/Auth/AuthDtos.cs @@ -3,6 +3,7 @@ using UniVerse.Domain.Enums; namespace UniVerse.Application.DTOs.Auth; public record AuthResponse(string AccessToken, DateTime ExpiresAt, UserAuthDto User); +public record AuthResult(AuthResponse Response, string RefreshToken); public record UserAuthDto(int Id, string Email, string? DisplayName, UserRole Role); diff --git a/backend/UniVerse.Application/Interfaces/IAuthService.cs b/backend/UniVerse.Application/Interfaces/IAuthService.cs index 26a8b4c..9e33059 100644 --- a/backend/UniVerse.Application/Interfaces/IAuthService.cs +++ b/backend/UniVerse.Application/Interfaces/IAuthService.cs @@ -5,9 +5,9 @@ namespace UniVerse.Application.Interfaces; public interface IAuthService { - Task LoginWithMicrosoftAsync(string authorizationCode); - Task DevLoginAsync(string email, string? displayName, Domain.Enums.UserRole role); - Task RefreshTokenAsync(string refreshToken); + Task LoginWithMicrosoftAsync(string authorizationCode); + Task DevLoginAsync(string email, string? displayName, Domain.Enums.UserRole role); + Task RefreshTokenAsync(string refreshToken); Task RevokeRefreshTokenAsync(string refreshToken); Task GetCurrentUserAsync(int userId); } diff --git a/backend/UniVerse.Domain/Exceptions/UnauthorizedException.cs b/backend/UniVerse.Domain/Exceptions/UnauthorizedException.cs new file mode 100644 index 0000000..dc67a35 --- /dev/null +++ b/backend/UniVerse.Domain/Exceptions/UnauthorizedException.cs @@ -0,0 +1,7 @@ +namespace UniVerse.Domain.Exceptions; + +public class UnauthorizedException : Exception +{ + public UnauthorizedException() : base() { } + public UnauthorizedException(string message) : base(message) { } +} diff --git a/backend/UniVerse.Infrastructure/Services/AuthService.cs b/backend/UniVerse.Infrastructure/Services/AuthService.cs index 30ec28c..5b684bc 100644 --- a/backend/UniVerse.Infrastructure/Services/AuthService.cs +++ b/backend/UniVerse.Infrastructure/Services/AuthService.cs @@ -1,3 +1,4 @@ +using Microsoft.Identity.Client; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Security.Cryptography; @@ -29,15 +30,76 @@ public class AuthService : IAuthService _gamification = gamification; } - public async Task LoginWithMicrosoftAsync(string authorizationCode) + public async Task LoginWithMicrosoftAsync(string authorizationCode) { - // Stub: in production, exchange authorizationCode with Microsoft Entra ID - // For now, create/find a demo user - throw new NotImplementedException( - "Microsoft Entra ID integration not yet configured. Use /api/v1/auth/login/dev in Development mode."); + var tenantId = _config["AzureAd:TenantId"]; + var clientId = _config["AzureAd:ClientId"]; + var clientSecret = _config["AzureAd:ClientSecret"]; + + var app = ConfidentialClientApplicationBuilder.Create(clientId) + .WithClientSecret(clientSecret) + .WithAuthority(new Uri($"https://login.microsoftonline.com/{tenantId}")) + .WithRedirectUri(_config["AzureAd:RedirectUri"] ?? "http://localhost:5173/auth/callback") + .Build(); + + AuthenticationResult result; + try + { + result = await app.AcquireTokenByAuthorizationCode(new[] { "User.Read" }, authorizationCode) + .ExecuteAsync(); + } + catch (MsalException ex) + { + throw new UnauthorizedException($"Microsoft authentication failed: {ex.Message}"); + } + + // Parse claims directly from the ID token provided by Microsoft + var handler = new JwtSecurityTokenHandler(); + var idToken = handler.ReadJwtToken(result.IdToken); + + var email = idToken.Claims.FirstOrDefault(c => c.Type == "preferred_username" || c.Type == "email" || c.Type == ClaimTypes.Upn)?.Value; + var name = idToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value; + + if (string.IsNullOrEmpty(email)) + throw new UnauthorizedException("Email not found in Microsoft token claims."); + + // Automatically provision user + var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email); + if (user == null) + { + user = new User + { + Email = email, + DisplayName = name ?? email.Split('@')[0], + Role = UserRole.Student, // Default role + IsActive = true + }; + _db.Users.Add(user); + await _db.SaveChangesAsync(); + + // Create corresponding profile + _db.StudentProfiles.Add(new StudentProfile { UserId = user.Id }); + await _db.SaveChangesAsync(); + } + else if (!user.IsActive) + { + throw new ForbiddenException("Account is deactivated."); + } + + var accessToken = GenerateAccessToken(user); + var refreshToken = await GenerateRefreshTokenAsync(user.Id); + + return new AuthResult( + new AuthResponse( + accessToken, + DateTime.UtcNow.AddMinutes(GetAccessTokenExpiration()), + user.ToAuthDto() + ), + refreshToken + ); } - public async Task DevLoginAsync(string email, string? displayName, UserRole role) + public async Task DevLoginAsync(string email, string? displayName, UserRole role) { var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email); @@ -72,14 +134,17 @@ public class AuthService : IAuthService var accessToken = GenerateAccessToken(user); var refreshToken = await GenerateRefreshTokenAsync(user.Id); - return new AuthResponse( - accessToken, - DateTime.UtcNow.AddMinutes(GetAccessTokenExpiration()), - user.ToAuthDto() + return new AuthResult( + new AuthResponse( + accessToken, + DateTime.UtcNow.AddMinutes(GetAccessTokenExpiration()), + user.ToAuthDto() + ), + refreshToken ); } - public async Task RefreshTokenAsync(string refreshToken) + public async Task RefreshTokenAsync(string refreshToken) { var token = await _db.RefreshTokens .Include(rt => rt.User) @@ -97,10 +162,13 @@ public class AuthService : IAuthService await _db.SaveChangesAsync(); - return new AuthResponse( - accessToken, - DateTime.UtcNow.AddMinutes(GetAccessTokenExpiration()), - token.User.ToAuthDto() + return new AuthResult( + new AuthResponse( + accessToken, + DateTime.UtcNow.AddMinutes(GetAccessTokenExpiration()), + token.User.ToAuthDto() + ), + newRefreshToken ); } diff --git a/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj b/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj index bec92ca..a0ba73b 100644 --- a/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj +++ b/backend/UniVerse.Infrastructure/UniVerse.Infrastructure.csproj @@ -8,8 +8,10 @@ + +