feat: add authorize in OAuth
This commit is contained in:
parent
65d928ec2d
commit
e977de3e4f
@ -1,5 +1,6 @@
|
|||||||
using Mirea.Api.Endpoint.Common.Services;
|
using Mirea.Api.Endpoint.Common.Services;
|
||||||
using Mirea.Api.Security.Common.Domain;
|
using Mirea.Api.Security.Common.Domain;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
@ -20,6 +21,8 @@ public class Admin : ISaveSettings
|
|||||||
public TwoFactorAuthenticator TwoFactorAuthenticator { get; set; } = TwoFactorAuthenticator.None;
|
public TwoFactorAuthenticator TwoFactorAuthenticator { get; set; } = TwoFactorAuthenticator.None;
|
||||||
public string? Secret { get; set; }
|
public string? Secret { get; set; }
|
||||||
|
|
||||||
|
public Dictionary<OAuthProvider, OAuthUser>? OAuthProviders { get; set; }
|
||||||
|
|
||||||
public void SaveSetting()
|
public void SaveSetting()
|
||||||
{
|
{
|
||||||
File.WriteAllText(FilePath, JsonSerializer.Serialize(this));
|
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.Common.Domain;
|
||||||
using Mirea.Api.Security.Services;
|
using Mirea.Api.Security.Services;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace Mirea.Api.Endpoint.Controllers.V1;
|
namespace Mirea.Api.Endpoint.Controllers.V1;
|
||||||
@ -47,7 +48,8 @@ public class AuthController(IOptionsSnapshot<Admin> user, AuthService auth, Pass
|
|||||||
PasswordHash = userEntity.PasswordHash,
|
PasswordHash = userEntity.PasswordHash,
|
||||||
Salt = userEntity.Salt,
|
Salt = userEntity.Salt,
|
||||||
TwoFactorAuthenticator = userEntity.TwoFactorAuthenticator,
|
TwoFactorAuthenticator = userEntity.TwoFactorAuthenticator,
|
||||||
SecondFactorToken = userEntity.Secret
|
SecondFactorToken = userEntity.Secret,
|
||||||
|
OAuthProviders = userEntity.OAuthProviders
|
||||||
},
|
},
|
||||||
HttpContext, request.Password);
|
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
|
public class User
|
||||||
{
|
{
|
||||||
@ -9,4 +11,5 @@ public class User
|
|||||||
public required string Salt { get; set; }
|
public required string Salt { get; set; }
|
||||||
public required TwoFactorAuthenticator TwoFactorAuthenticator { get; set; }
|
public required TwoFactorAuthenticator TwoFactorAuthenticator { get; set; }
|
||||||
public string? SecondFactorToken { 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.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Mirea.Api.Security.Common.Domain;
|
||||||
using Mirea.Api.Security.Common.Interfaces;
|
using Mirea.Api.Security.Common.Interfaces;
|
||||||
using Mirea.Api.Security.Services;
|
using Mirea.Api.Security.Services;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace Mirea.Api.Security;
|
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;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -23,7 +23,7 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
|||||||
accessTokenService.GenerateToken(userId);
|
accessTokenService.GenerateToken(userId);
|
||||||
|
|
||||||
private static string GetAuthCacheKey(string fingerprint) => $"{fingerprint}_auth_token";
|
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) =>
|
private Task SetAuthTokenDataToCache(AuthToken data, CancellationToken cancellation) =>
|
||||||
cache.SetAsync(
|
cache.SetAsync(
|
||||||
@ -32,6 +32,18 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
|||||||
slidingExpiration: Lifetime,
|
slidingExpiration: Lifetime,
|
||||||
cancellationToken: cancellation);
|
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) =>
|
private Task RevokeAccessToken(string token) =>
|
||||||
revokedToken.AddTokenToRevokedAsync(token, accessTokenService.GetExpireDateTime(token));
|
revokedToken.AddTokenToRevokedAsync(token, accessTokenService.GetExpireDateTime(token));
|
||||||
|
|
||||||
@ -94,6 +106,21 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
|||||||
authToken.Fingerprint);
|
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)
|
public async Task<bool> LoginAsync(CookieOptionsParameters cookieOptions, HttpContext context, TwoFactorAuthenticator authenticator, string code, CancellationToken cancellation = default)
|
||||||
{
|
{
|
||||||
var requestContext = new RequestContextInfo(context, cookieOptions);
|
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.");
|
throw new SecurityException("Invalid verification code. Please try again.");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case TwoFactorAuthenticator.None:
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
throw new InvalidOperationException("Unsupported authorization method.");
|
throw new InvalidOperationException("Unsupported authorization method.");
|
||||||
}
|
}
|
||||||
@ -138,14 +163,7 @@ public class AuthService(ICacheService cache, IAccessToken accessTokenService, I
|
|||||||
return TwoFactorAuthenticator.None;
|
return TwoFactorAuthenticator.None;
|
||||||
}
|
}
|
||||||
|
|
||||||
var firstAuthToken = new FirstAuthToken(requestContext)
|
await CreateFirstAuthTokenToCache(user, requestContext, cancellation);
|
||||||
{
|
|
||||||
UserId = user.Id,
|
|
||||||
Secret = user.SecondFactorToken,
|
|
||||||
TwoFactorAuthenticator = user.TwoFactorAuthenticator
|
|
||||||
};
|
|
||||||
|
|
||||||
await cache.SetAsync(GetFirstAuthCacheKey(requestContext.Fingerprint), firstAuthToken, absoluteExpirationRelativeToNow: LifetimeFirstAuth, cancellationToken: cancellation);
|
|
||||||
|
|
||||||
return user.TwoFactorAuthenticator;
|
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);
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user