MireaBackend/Security/Services/OAuthService.cs

178 lines
7.5 KiB
C#

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.Security.Cryptography;
using System.Text;
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, string secretKey)
{
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();
}
private static string GetHmacString(RequestContextInfo contextInfo, string secretKey)
{
var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey));
return Convert.ToBase64String(hmac.ComputeHash(
Encoding.UTF8.GetBytes($"{contextInfo.Fingerprint}_{contextInfo.Ip}_{contextInfo.UserAgent}")));
}
public Uri GetProviderRedirect(HttpContext context, CookieOptionsParameters cookieOptions, string redirectUri, OAuthProvider provider)
{
var (clientId, _) = providers[provider];
var redirectUrl = $"?client_id={clientId}" +
"&response_type=code" +
$"&redirect_uri={redirectUri}" +
$"&scope={ProviderData[provider].Scope}" +
$"&state={GetHmacString(new RequestContextInfo(context, cookieOptions), secretKey)}_{Enum.GetName(provider)}";
return new Uri(ProviderData[provider].RedirectUrl + redirectUrl);
}
public (OAuthProvider Provider, Uri Redirect)[] GetAvailableProviders(string redirectUri)
{
return [.. providers.Select(x => (x.Key, new Uri(redirectUri.TrimEnd('/') + "/?provider=" + (int)x.Key)))];
}
public async Task<(OAuthProvider provider, OAuthUser User)> LoginOAuth(HttpContext context, CookieOptionsParameters cookieOptions, string redirectUrl, string code, string state, CancellationToken cancellation = default)
{
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 secretStateData = string.Join("_", partsState.SkipLast(1));
var secretData = GetHmacString(new RequestContextInfo(context, cookieOptions), secretKey);
if (secretData != secretStateData)
{
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);
}
}