Release v1.0.0 #16
.envDbInitializer.csDependencyInjection.csnuget.config
.gitea/workflows
.gitignoreApiDto
ApiDto.csproj
Backend.slnDockerfileCommon
Requests
Responses
Endpoint
Backend.httpISaveSettings.cs
README.mdCommon
Attributes
BadRequestResponseAttribute.csCacheMaxAgeAttribute.csLocalhostAttribute.csMaintenanceModeIgnoreAttribute.csNotFoundResponseAttribute.csSwaggerDefaultAttribute.csTokenAuthenticationAttribute.cs
Exceptions
Interfaces
MapperDto
Services
Configuration
Core
BackgroundTasks
Middleware
CacheMaxAgeMiddleware.csCookieAuthorizationMiddleware.csCustomExceptionHandlerMiddleware.csJwtRevocationMiddleware.csMaintenanceModeMiddleware.cs
Startup
Model
SwaggerOptions
Validation
Controllers
BaseController.cs
Endpoint.csprojProgram.csConfiguration
V1
AuthController.csCampusController.csDisciplineController.csFacultyController.csGroupController.csImportController.csLectureHallController.csProfessorController.csScheduleController.cs
WeatherForecastController.csSync
WeatherForecast.cswwwroot
css
swagger
Security
Common
CookieNames.cs
DependencyInjection.csDomain
Caching
CookieOptionsParameters.csOAuth2
OAuthProvider.csOAuthUser.csRequestContextInfo.csTwoFactorAuthenticator.csUser.csInterfaces
Properties
Security.csprojServices
SqlData
Application
Application.csprojDependencyInjection.cs
Common
Cqrs
Campus
Queries
Discipline
Queries
Faculty
Queries
Group
Queries
LectureHall
Queries
Professor
Queries
GetProfessorDetails
GetProfessorDetailsBySearch
GetProfessorList
Schedule
Interfaces
Domain
Domain.csproj
Schedule
Migrations
MysqlMigrations
Migrations
20240601023106_InitialMigration.Designer.cs20240601023106_InitialMigration.cs20241027034820_RemoveUnusedRef.Designer.cs20241027034820_RemoveUnusedRef.csUberDbContextModelSnapshot.cs
MysqlMigrations.csprojPsqlMigrations
Migrations
20240601021702_InitialMigration.Designer.cs20240601021702_InitialMigration.cs20241027032753_RemoveUnusedRef.Designer.cs20241027032753_RemoveUnusedRef.csUberDbContextModelSnapshot.cs
PsqlMigrations.csprojSqliteMigrations
Persistence
Common
BaseDbContext.csConfigurationResolver.csDatabaseProvider.csDbContextFactory.csModelBuilderExtensions.cs
Contexts
Schedule
EntityTypeConfigurations
Persistence.csprojUberDbContext.cs@ -1,5 +1,6 @@
|
||||
using Mirea.Api.Endpoint.Common.Services;
|
||||
using Mirea.Api.Security.Common.Domain;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
@ -20,6 +21,8 @@ public class Admin : ISaveSettings
|
||||
public TwoFactorAuthenticator TwoFactorAuthenticator { get; set; } = TwoFactorAuthenticator.None;
|
||||
public string? Secret { get; set; }
|
||||
|
||||
public Dictionary<OAuthProvider, OAuthUser>? OAuthProviders { get; set; }
|
||||
|
||||
public void SaveSetting()
|
||||
{
|
||||
File.WriteAllText(FilePath, JsonSerializer.Serialize(this));
|
||||
|
@ -13,6 +13,7 @@ using Mirea.Api.Endpoint.Configuration.Model;
|
||||
using Mirea.Api.Security.Common.Domain;
|
||||
using Mirea.Api.Security.Services;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Mirea.Api.Endpoint.Controllers.V1;
|
||||
@ -47,7 +48,8 @@ public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, Pass
|
||||
PasswordHash = userEntity.PasswordHash,
|
||||
Salt = userEntity.Salt,
|
||||
TwoFactorAuthenticator = userEntity.TwoFactorAuthenticator,
|
||||
SecondFactorToken = userEntity.Secret
|
||||
SecondFactorToken = userEntity.Secret,
|
||||
OAuthProviders = userEntity.OAuthProviders
|
||||
},
|
||||
HttpContext, request.Password);
|
||||
|
||||
|
13
Security/Common/Domain/OAuth2/OAuthProviderUrisData.cs
Normal file
13
Security/Common/Domain/OAuth2/OAuthProviderUrisData.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using System;
|
||||
|
||||
namespace Mirea.Api.Security.Common.Domain.OAuth2;
|
||||
|
||||
internal struct OAuthProviderUrisData
|
||||
{
|
||||
public string RedirectUrl { get; init; }
|
||||
public string TokenUrl { get; init; }
|
||||
public string UserInfoUrl { get; init; }
|
||||
public string AuthHeader { get; init; }
|
||||
public string Scope { get; init; }
|
||||
public Type UserInfoType { get; init; }
|
||||
}
|
12
Security/Common/Domain/OAuth2/OAuthTokenResponse.cs
Normal file
12
Security/Common/Domain/OAuth2/OAuthTokenResponse.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Mirea.Api.Security.Common.Domain.OAuth2;
|
||||
|
||||
public class OAuthTokenResponse
|
||||
{
|
||||
[JsonPropertyName("access_token")]
|
||||
public required string AccessToken { get; set; }
|
||||
|
||||
[JsonPropertyName("expires_in")]
|
||||
public int ExpiresIn { get; set; }
|
||||
}
|
38
Security/Common/Domain/OAuth2/UserInfo/GoogleUserInfo.cs
Normal file
38
Security/Common/Domain/OAuth2/UserInfo/GoogleUserInfo.cs
Normal file
@ -0,0 +1,38 @@
|
||||
using Mirea.Api.Security.Common.Interfaces;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Mirea.Api.Security.Common.Domain.OAuth2.UserInfo;
|
||||
|
||||
internal class GoogleUserInfo : IUserInfo
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; set; }
|
||||
|
||||
[JsonPropertyName("email")]
|
||||
public required string Email { get; set; }
|
||||
|
||||
[JsonPropertyName("given_name")]
|
||||
public required string GivenName { get; set; }
|
||||
|
||||
[JsonPropertyName("family_name")]
|
||||
public required string FamilyName { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; set; }
|
||||
|
||||
[JsonPropertyName("picture")]
|
||||
public required string Picture { get; set; }
|
||||
|
||||
[JsonPropertyName("verified_email")]
|
||||
public bool? VerifiedEmail { get; set; }
|
||||
|
||||
public OAuthUser MapToInternalUser() =>
|
||||
new()
|
||||
{
|
||||
Id = Id,
|
||||
Email = VerifiedEmail.HasValue && VerifiedEmail.Value ? Email : null,
|
||||
FirstName = GivenName,
|
||||
LastName = FamilyName,
|
||||
IconUri = Picture
|
||||
};
|
||||
}
|
36
Security/Common/Domain/OAuth2/UserInfo/MailRuUserInfo.cs
Normal file
36
Security/Common/Domain/OAuth2/UserInfo/MailRuUserInfo.cs
Normal file
@ -0,0 +1,36 @@
|
||||
using Mirea.Api.Security.Common.Interfaces;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Mirea.Api.Security.Common.Domain.OAuth2.UserInfo;
|
||||
|
||||
internal class MailRuUserInfo : IUserInfo
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; set; }
|
||||
|
||||
[JsonPropertyName("email")]
|
||||
public required string Email { get; set; }
|
||||
|
||||
[JsonPropertyName("first_name")]
|
||||
public required string FirstName { get; set; }
|
||||
|
||||
[JsonPropertyName("last_name")]
|
||||
public required string LastName { get; set; }
|
||||
|
||||
[JsonPropertyName("nickname")]
|
||||
public required string Username { get; set; }
|
||||
|
||||
[JsonPropertyName("image")]
|
||||
public string? Image { get; set; }
|
||||
|
||||
public OAuthUser MapToInternalUser() =>
|
||||
new()
|
||||
{
|
||||
Id = Id,
|
||||
Email = Email,
|
||||
FirstName = FirstName,
|
||||
LastName = LastName,
|
||||
Username = Username,
|
||||
IconUri = Image
|
||||
};
|
||||
}
|
41
Security/Common/Domain/OAuth2/UserInfo/YandexUserInfo.cs
Normal file
41
Security/Common/Domain/OAuth2/UserInfo/YandexUserInfo.cs
Normal file
@ -0,0 +1,41 @@
|
||||
using Mirea.Api.Security.Common.Interfaces;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Mirea.Api.Security.Common.Domain.OAuth2.UserInfo;
|
||||
|
||||
internal class YandexUserInfo : IUserInfo
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; set; }
|
||||
|
||||
[JsonPropertyName("login")]
|
||||
public required string Login { get; set; }
|
||||
|
||||
[JsonPropertyName("default_email")]
|
||||
public required string DefaultEmail { get; set; }
|
||||
|
||||
[JsonPropertyName("first_name")]
|
||||
public string? FirstName { get; set; }
|
||||
|
||||
[JsonPropertyName("last_name")]
|
||||
public string? LastName { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("is_avatar_empty")]
|
||||
public bool IsAvatarEmpty { get; set; }
|
||||
|
||||
[JsonPropertyName("default_avatar_id")]
|
||||
public string? DefaultAvatarId { get; set; }
|
||||
|
||||
public OAuthUser MapToInternalUser() =>
|
||||
new()
|
||||
{
|
||||
Id = Id,
|
||||
Email = DefaultEmail,
|
||||
FirstName = FirstName,
|
||||
LastName = LastName,
|
||||
IconUri =
|
||||
IsAvatarEmpty ? null : $"https://avatars.yandex.net/get-yapic/{DefaultAvatarId}/islands-retina-50",
|
||||
Username = Login
|
||||
};
|
||||
}
|
8
Security/Common/Domain/OAuthProvider.cs
Normal file
8
Security/Common/Domain/OAuthProvider.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace Mirea.Api.Security.Common.Domain;
|
||||
|
||||
public enum OAuthProvider
|
||||
{
|
||||
Google,
|
||||
Yandex,
|
||||
MailRu
|
||||
}
|
11
Security/Common/Domain/OAuthUser.cs
Normal file
11
Security/Common/Domain/OAuthUser.cs
Normal file
@ -0,0 +1,11 @@
|
||||
namespace Mirea.Api.Security.Common.Domain;
|
||||
|
||||
public class OAuthUser
|
||||
{
|
||||
public required string Id { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? FirstName { get; set; }
|
||||
public string? LastName { get; set; }
|
||||
public string? IconUri { get; set; }
|
||||
}
|
@ -1,4 +1,6 @@
|
||||
namespace Mirea.Api.Security.Common.Domain;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Mirea.Api.Security.Common.Domain;
|
||||
|
||||
public class User
|
||||
{
|
||||
@ -9,4 +11,5 @@ public class User
|
||||
public required string Salt { get; set; }
|
||||
public required TwoFactorAuthenticator TwoFactorAuthenticator { get; set; }
|
||||
public string? SecondFactorToken { get; set; }
|
||||
public Dictionary<OAuthProvider, OAuthUser>? OAuthProviders { get; set; }
|
||||
}
|
8
Security/Common/Interfaces/IUserInfo.cs
Normal file
8
Security/Common/Interfaces/IUserInfo.cs
Normal file
@ -0,0 +1,8 @@
|
||||
using Mirea.Api.Security.Common.Domain;
|
||||
|
||||
namespace Mirea.Api.Security.Common.Interfaces;
|
||||
|
||||
internal interface IUserInfo
|
||||
{
|
||||
OAuthUser MapToInternalUser();
|
||||
}
|
@ -1,9 +1,11 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Mirea.Api.Security.Common.Domain;
|
||||
using Mirea.Api.Security.Common.Interfaces;
|
||||
using Mirea.Api.Security.Services;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Mirea.Api.Security;
|
||||
|
||||
@ -45,6 +47,22 @@ public static class DependencyInjection
|
||||
};
|
||||
});
|
||||
|
||||
var providers = new Dictionary<OAuthProvider, (string ClientId, string Secret)>();
|
||||
|
||||
foreach (var provider in Enum.GetValues<OAuthProvider>())
|
||||
{
|
||||
var providerName = Enum.GetName(provider)!.ToUpper();
|
||||
var clientId = configuration[$"{providerName}_CLIENT_ID"];
|
||||
var secret = configuration[$"{providerName}_CLIENT_SECRET"];
|
||||
|
||||
if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(secret))
|
||||
continue;
|
||||
|
||||
providers.Add(provider, (clientId, secret));
|
||||
}
|
||||
|
||||
services.AddSingleton(provider => new OAuthService(provider.GetRequiredService<ILogger<OAuthService>>(), providers));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
@ -23,7 +23,7 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
||||
accessTokenService.GenerateToken(userId);
|
||||
|
||||
private static string GetAuthCacheKey(string fingerprint) => $"{fingerprint}_auth_token";
|
||||
internal static string GetFirstAuthCacheKey(string fingerprint) => $"{fingerprint}_auth_token_first";
|
||||
private static string GetFirstAuthCacheKey(string fingerprint) => $"{fingerprint}_auth_token_first";
|
||||
|
||||
private Task SetAuthTokenDataToCache(AuthToken data, CancellationToken cancellation) =>
|
||||
cache.SetAsync(
|
||||
@ -32,6 +32,18 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
||||
slidingExpiration: Lifetime,
|
||||
cancellationToken: cancellation);
|
||||
|
||||
private Task CreateFirstAuthTokenToCache(User data, RequestContextInfo requestContext, CancellationToken cancellation) =>
|
||||
cache.SetAsync(
|
||||
GetFirstAuthCacheKey(requestContext.Fingerprint),
|
||||
JsonSerializer.SerializeToUtf8Bytes(new FirstAuthToken(requestContext)
|
||||
{
|
||||
UserId = data.Id,
|
||||
Secret = data.SecondFactorToken,
|
||||
TwoFactorAuthenticator = data.TwoFactorAuthenticator
|
||||
}),
|
||||
slidingExpiration: LifetimeFirstAuth,
|
||||
cancellationToken: cancellation);
|
||||
|
||||
private Task RevokeAccessToken(string token) =>
|
||||
revokedToken.AddTokenToRevokedAsync(token, accessTokenService.GetExpireDateTime(token));
|
||||
|
||||
@ -94,6 +106,21 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
||||
authToken.Fingerprint);
|
||||
}
|
||||
|
||||
public async Task<TwoFactorAuthenticator> LoginOAuthAsync(CookieOptionsParameters cookieOptions, HttpContext context, User user, OAuthProvider provider, CancellationToken cancellation = default)
|
||||
{
|
||||
var requestContext = new RequestContextInfo(context, cookieOptions);
|
||||
|
||||
if (user.TwoFactorAuthenticator == TwoFactorAuthenticator.None)
|
||||
{
|
||||
await GenerateAuthTokensAsync(cookieOptions, context, requestContext, user.Id, cancellation);
|
||||
return TwoFactorAuthenticator.None;
|
||||
}
|
||||
|
||||
await CreateFirstAuthTokenToCache(user, requestContext, cancellation);
|
||||
|
||||
return user.TwoFactorAuthenticator;
|
||||
}
|
||||
|
||||
public async Task<bool> LoginAsync(CookieOptionsParameters cookieOptions, HttpContext context, TwoFactorAuthenticator authenticator, string code, CancellationToken cancellation = default)
|
||||
{
|
||||
var requestContext = new RequestContextInfo(context, cookieOptions);
|
||||
@ -116,8 +143,6 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
||||
throw new SecurityException("Invalid verification code. Please try again.");
|
||||
}
|
||||
break;
|
||||
case TwoFactorAuthenticator.None:
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException("Unsupported authorization method.");
|
||||
}
|
||||
@ -138,14 +163,7 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
||||
return TwoFactorAuthenticator.None;
|
||||
}
|
||||
|
||||
var firstAuthToken = new FirstAuthToken(requestContext)
|
||||
{
|
||||
UserId = user.Id,
|
||||
Secret = user.SecondFactorToken,
|
||||
TwoFactorAuthenticator = user.TwoFactorAuthenticator
|
||||
};
|
||||
|
||||
await cache.SetAsync(GetFirstAuthCacheKey(requestContext.Fingerprint), firstAuthToken, absoluteExpirationRelativeToNow: LifetimeFirstAuth, cancellationToken: cancellation);
|
||||
await CreateFirstAuthTokenToCache(user, requestContext, cancellation);
|
||||
|
||||
return user.TwoFactorAuthenticator;
|
||||
}
|
||||
|
166
Security/Services/OAuthService.cs
Normal file
166
Security/Services/OAuthService.cs
Normal file
@ -0,0 +1,166 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Mirea.Api.Security.Common.Domain;
|
||||
using Mirea.Api.Security.Common.Domain.OAuth2;
|
||||
using Mirea.Api.Security.Common.Domain.OAuth2.UserInfo;
|
||||
using Mirea.Api.Security.Common.Interfaces;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Mirea.Api.Security.Services;
|
||||
|
||||
public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider, (string ClientId, string Secret)> providers)
|
||||
{
|
||||
private static readonly Dictionary<OAuthProvider, OAuthProviderUrisData> ProviderData = new()
|
||||
{
|
||||
[OAuthProvider.Google] = new OAuthProviderUrisData
|
||||
{
|
||||
RedirectUrl = "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
TokenUrl = "https://oauth2.googleapis.com/token",
|
||||
UserInfoUrl = "https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
Scope = "openid email profile",
|
||||
AuthHeader = "Bearer",
|
||||
UserInfoType = typeof(GoogleUserInfo)
|
||||
},
|
||||
[OAuthProvider.Yandex] = new OAuthProviderUrisData
|
||||
{
|
||||
RedirectUrl = "https://oauth.yandex.ru/authorize",
|
||||
TokenUrl = "https://oauth.yandex.ru/token",
|
||||
UserInfoUrl = "https://login.yandex.ru/info?format=json",
|
||||
Scope = "login:email login:info login:avatar",
|
||||
AuthHeader = "OAuth",
|
||||
UserInfoType = typeof(YandexUserInfo)
|
||||
},
|
||||
[OAuthProvider.MailRu] = new OAuthProviderUrisData
|
||||
{
|
||||
RedirectUrl = "https://oauth.mail.ru/login",
|
||||
TokenUrl = "https://oauth.mail.ru/token",
|
||||
UserInfoUrl = "https://oauth.mail.ru/userinfo",
|
||||
AuthHeader = "",
|
||||
Scope = "",
|
||||
UserInfoType = typeof(MailRuUserInfo)
|
||||
}
|
||||
};
|
||||
|
||||
private static async Task<OAuthTokenResponse?> ExchangeCodeForTokensAsync(string requestUri, string redirectUrl, string code, string clientId, string secret, CancellationToken cancellation)
|
||||
{
|
||||
var tokenRequest = new HttpRequestMessage(HttpMethod.Post, requestUri)
|
||||
{
|
||||
Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
{ "code", code },
|
||||
{ "client_id", clientId },
|
||||
{ "client_secret", secret },
|
||||
{ "redirect_uri", redirectUrl},
|
||||
{ "grant_type", "authorization_code" }
|
||||
})
|
||||
};
|
||||
|
||||
using var httpClient = new HttpClient();
|
||||
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("MireaSchedule/1.0 (Winsomnia)");
|
||||
|
||||
var response = await httpClient.SendAsync(tokenRequest, cancellation);
|
||||
var data = await response.Content.ReadAsStringAsync(cancellation);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new HttpRequestException(data);
|
||||
|
||||
return JsonSerializer.Deserialize<OAuthTokenResponse>(data);
|
||||
}
|
||||
|
||||
private static async Task<OAuthUser?> GetUserProfileAsync(string requestUri, string authHeader, string accessToken, OAuthProvider provider, CancellationToken cancellation)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
|
||||
if (string.IsNullOrEmpty(authHeader))
|
||||
request.RequestUri = new Uri(request.RequestUri?.AbsoluteUri + "?access_token=" + accessToken);
|
||||
else
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(authHeader, accessToken);
|
||||
|
||||
using var httpClient = new HttpClient();
|
||||
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("MireaSchedule/1.0 (Winsomnia)");
|
||||
|
||||
var response = await httpClient.SendAsync(request, cancellation);
|
||||
var data = await response.Content.ReadAsStringAsync(cancellation);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new HttpRequestException(data);
|
||||
|
||||
var userInfo = JsonSerializer.Deserialize(data, ProviderData[provider].UserInfoType) as IUserInfo;
|
||||
return userInfo?.MapToInternalUser();
|
||||
}
|
||||
|
||||
public (OAuthProvider Provider, Uri Redirect)[] GetAvailableProviders(HttpContext context, CookieOptionsParameters cookieOptions, string redirectUrl)
|
||||
{
|
||||
var redirectUri = "?client_id={0}" +
|
||||
"&response_type=code" +
|
||||
$"&redirect_uri={redirectUrl}" +
|
||||
"&scope={1}" +
|
||||
$"&state={new RequestContextInfo(context, cookieOptions).Fingerprint}_{{2}}";
|
||||
|
||||
return providers.Select(x => (x.Key, new Uri(ProviderData[x.Key].RedirectUrl.TrimEnd('/') +
|
||||
string.Format(redirectUri,
|
||||
x.Value.ClientId,
|
||||
ProviderData[x.Key].Scope,
|
||||
Enum.GetName(x.Key))))
|
||||
).ToArray();
|
||||
}
|
||||
|
||||
public async Task<(OAuthProvider provider, OAuthUser User)> LoginOAuth(HttpContext context, CookieOptionsParameters cookieOptions, string redirectUrl, string code, string state, CancellationToken cancellation = default)
|
||||
{
|
||||
var requestContext = new RequestContextInfo(context, cookieOptions);
|
||||
|
||||
var partsState = state.Split('_');
|
||||
|
||||
if (!Enum.TryParse<OAuthProvider>(partsState.Last(), true, out var provider) ||
|
||||
!providers.TryGetValue(provider, out var providerInfo) ||
|
||||
!ProviderData.TryGetValue(provider, out var currentProviderStruct))
|
||||
{
|
||||
logger.LogWarning("Failed to parse OAuth provider from state: {State}", state);
|
||||
throw new InvalidOperationException("Invalid authorization request.");
|
||||
}
|
||||
|
||||
var fingerprint = string.Join("_", partsState.SkipLast(1));
|
||||
|
||||
if (requestContext.Fingerprint != fingerprint)
|
||||
{
|
||||
logger.LogWarning("Fingerprint mismatch. Possible CSRF attack detected.");
|
||||
throw new SecurityException("Suspicious activity detected. Please try again.");
|
||||
}
|
||||
|
||||
OAuthTokenResponse? accessToken = null;
|
||||
try
|
||||
{
|
||||
accessToken = await ExchangeCodeForTokensAsync(currentProviderStruct.TokenUrl, redirectUrl, code, providerInfo.ClientId, providerInfo.Secret, cancellation);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to exchange authorization code for tokens with provider {Provider}", provider);
|
||||
}
|
||||
|
||||
if (accessToken == null)
|
||||
throw new SecurityException("Unable to complete authorization with the provider. Please try again later.");
|
||||
|
||||
OAuthUser? result = null;
|
||||
try
|
||||
{
|
||||
result = await GetUserProfileAsync(currentProviderStruct.UserInfoUrl, currentProviderStruct.AuthHeader, accessToken.AccessToken, provider, cancellation);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to retrieve user information from provider {Provider}", provider);
|
||||
}
|
||||
|
||||
if (result == null)
|
||||
throw new SecurityException("Unable to retrieve user information. Please check the details and try again.");
|
||||
|
||||
return (provider, result);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user