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 logger, Dictionary providers, string secretKey) { private static readonly Dictionary 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 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 { { "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(data); } private static async Task 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(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); } }