diff --git a/Endpoint/Configuration/Model/Admin.cs b/Endpoint/Configuration/Model/Admin.cs index d31d7d0..26492ec 100644 --- a/Endpoint/Configuration/Model/Admin.cs +++ b/Endpoint/Configuration/Model/Admin.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? OAuthProviders { get; set; } + public void SaveSetting() { File.WriteAllText(FilePath, JsonSerializer.Serialize(this)); diff --git a/Endpoint/Controllers/V1/AuthController.cs b/Endpoint/Controllers/V1/AuthController.cs index 1b969db..69f0ed0 100644 --- a/Endpoint/Controllers/V1/AuthController.cs +++ b/Endpoint/Controllers/V1/AuthController.cs @@ -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 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); diff --git a/Security/Common/Domain/OAuth2/OAuthProviderUrisData.cs b/Security/Common/Domain/OAuth2/OAuthProviderUrisData.cs new file mode 100644 index 0000000..cca96f2 --- /dev/null +++ b/Security/Common/Domain/OAuth2/OAuthProviderUrisData.cs @@ -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; } +} \ No newline at end of file diff --git a/Security/Common/Domain/OAuth2/OAuthTokenResponse.cs b/Security/Common/Domain/OAuth2/OAuthTokenResponse.cs new file mode 100644 index 0000000..920fa03 --- /dev/null +++ b/Security/Common/Domain/OAuth2/OAuthTokenResponse.cs @@ -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; } +} \ No newline at end of file diff --git a/Security/Common/Domain/OAuth2/UserInfo/GoogleUserInfo.cs b/Security/Common/Domain/OAuth2/UserInfo/GoogleUserInfo.cs new file mode 100644 index 0000000..1579f9c --- /dev/null +++ b/Security/Common/Domain/OAuth2/UserInfo/GoogleUserInfo.cs @@ -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 + }; +} \ No newline at end of file diff --git a/Security/Common/Domain/OAuth2/UserInfo/MailRuUserInfo.cs b/Security/Common/Domain/OAuth2/UserInfo/MailRuUserInfo.cs new file mode 100644 index 0000000..4920cdb --- /dev/null +++ b/Security/Common/Domain/OAuth2/UserInfo/MailRuUserInfo.cs @@ -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 + }; +} \ No newline at end of file diff --git a/Security/Common/Domain/OAuth2/UserInfo/YandexUserInfo.cs b/Security/Common/Domain/OAuth2/UserInfo/YandexUserInfo.cs new file mode 100644 index 0000000..f1a9bab --- /dev/null +++ b/Security/Common/Domain/OAuth2/UserInfo/YandexUserInfo.cs @@ -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 + }; +} \ No newline at end of file diff --git a/Security/Common/Domain/OAuthProvider.cs b/Security/Common/Domain/OAuthProvider.cs new file mode 100644 index 0000000..cec561b --- /dev/null +++ b/Security/Common/Domain/OAuthProvider.cs @@ -0,0 +1,8 @@ +namespace Mirea.Api.Security.Common.Domain; + +public enum OAuthProvider +{ + Google, + Yandex, + MailRu +} \ No newline at end of file diff --git a/Security/Common/Domain/OAuthUser.cs b/Security/Common/Domain/OAuthUser.cs new file mode 100644 index 0000000..539a244 --- /dev/null +++ b/Security/Common/Domain/OAuthUser.cs @@ -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; } +} \ No newline at end of file diff --git a/Security/Common/Domain/User.cs b/Security/Common/Domain/User.cs index 27ea522..61dad9f 100644 --- a/Security/Common/Domain/User.cs +++ b/Security/Common/Domain/User.cs @@ -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? OAuthProviders { get; set; } } \ No newline at end of file diff --git a/Security/Common/Interfaces/IUserInfo.cs b/Security/Common/Interfaces/IUserInfo.cs new file mode 100644 index 0000000..9e96223 --- /dev/null +++ b/Security/Common/Interfaces/IUserInfo.cs @@ -0,0 +1,8 @@ +using Mirea.Api.Security.Common.Domain; + +namespace Mirea.Api.Security.Common.Interfaces; + +internal interface IUserInfo +{ + OAuthUser MapToInternalUser(); +} \ No newline at end of file diff --git a/Security/DependencyInjection.cs b/Security/DependencyInjection.cs index 0549ff4..aa36553 100644 --- a/Security/DependencyInjection.cs +++ b/Security/DependencyInjection.cs @@ -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(); + + foreach (var provider in Enum.GetValues()) + { + 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>(), providers)); + return services; } } \ No newline at end of file diff --git a/Security/Services/AuthService.cs b/Security/Services/AuthService.cs index 7da44ab..30cd2e8 100644 --- a/Security/Services/AuthService.cs +++ b/Security/Services/AuthService.cs @@ -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 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 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; } diff --git a/Security/Services/OAuthService.cs b/Security/Services/OAuthService.cs new file mode 100644 index 0000000..7721b86 --- /dev/null +++ b/Security/Services/OAuthService.cs @@ -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 logger, Dictionary providers) +{ + 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(); + } + + 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(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); + } +} \ No newline at end of file