Release v1.0.0 #16
.editorconfig.envDbInitializer.csDependencyInjection.csnuget.config
.gitea/workflows
.gitignoreApiDto
ApiDto.csproj
Backend.slnDockerfileCommon
AuthRoles.csCacheType.csDatabaseType.csOAuthProvider.csPairPeriodTime.csPasswordPolicy.csTwoFactorAuthentication.cs
Requests
Responses
Endpoint
Backend.httpISaveSettings.cs
README.mdCommon
Attributes
BadRequestResponseAttribute.csCacheMaxAgeAttribute.csLocalhostAttribute.csMaintenanceModeIgnoreAttribute.csNotFoundResponseAttribute.csSwaggerDefaultAttribute.csTokenAuthenticationAttribute.cs
Exceptions
Interfaces
MapperDto
AvailableProvidersConverter.csPairPeriodTimeConverter.csPasswordPolicyConverter.csTwoFactorAuthenticationConverter.cs
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.csSecurityController.cs
WeatherForecastController.csSync
WeatherForecast.cswwwroot
css
swagger
Security
Common
CookieNames.cs
DependencyInjection.csDomain
Caching
CookieOptionsParameters.csLoginOAuthResult.csOAuth2
OAuthProvider.csOAuthUser.csPasswordPolicy.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@ -33,94 +33,73 @@ public class AuthController(IOptionsSnapshot<Admin> user, IOptionsSnapshot<Gener
|
||||
Path = UrlHelper.GetSubPathWithoutFirstApiName + "api"
|
||||
};
|
||||
|
||||
private static string GenerateHtmlResponse(string title, string message, OAuthProvider? provider, bool isError = false, string? traceId = null)
|
||||
private static string GenerateHtmlResponse(
|
||||
string title,
|
||||
string message,
|
||||
Uri? callback,
|
||||
string traceId,
|
||||
bool isError)
|
||||
{
|
||||
var messageColor = isError ? "red" : "white";
|
||||
var script = "<script>setTimeout(()=>{if(window.opener){window.opener.postMessage(" +
|
||||
"{success:" + (!isError).ToString().ToLower() +
|
||||
",provider:'" + (provider == null ? "null" : (int)provider) +
|
||||
"',providerName:'" + (provider == null ? "null" : Enum.GetName(provider.Value)) +
|
||||
"',message:'" + message.Replace("'", "\\'") +
|
||||
"'},'*');}window.close();}, 15000);</script>";
|
||||
var callbackUrl = callback?.ToString();
|
||||
|
||||
return $"<!DOCTYPE html><html lang=ru><head><meta charset=UTF-8><meta content=\"width=device-width,initial-scale=1\"name=viewport><link href=\"https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap\"rel=stylesheet><style>body{{background-color:#121212;color:#fff;font-family:Roboto,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;text-align:center}}.container{{max-width:600px;padding:20px;border-radius:8px;background-color:#1e1e1e;box-shadow:0 4px 20px rgba(0,0,0,.5)}}h1{{font-size:24px;margin-bottom:20px}}p{{font-size:16px;color:{messageColor}}}</style><title>{title}</title></head><body><div class=container><h1>{title}</h1><p>{message}</p><p style=font-size:14px;color:silver;>Это информационная страница. Вы можете закрыть её.</p>{(!string.IsNullOrEmpty(traceId) ? $"<code style=font-size:12px;color:gray;>TraceId={traceId}</code>" : string.Empty)}</div>{script}</body></html>";
|
||||
var script = callback == null ? string.Empty :
|
||||
$"<script>setTimeout(()=>{{window.location.href='{callbackUrl}';}}, {(isError ? 15000 : 5000)});</script>";
|
||||
|
||||
var blockInfo = "<p>" + (callback == null ?
|
||||
"Вернитесь обратно и попробуйте снова позже.</p>" :
|
||||
$"Если вы не будете автоматически перенаправлены, нажмите ниже.</p>" +
|
||||
$"<a href=\"{callbackUrl}\" style=\"color:inherit;text-decoration:underline;\">Перейти вручную</a>");
|
||||
|
||||
return $"<!DOCTYPE html><html lang=ru><head><meta charset=UTF-8><meta content=\"width=device-width,initial-scale=1\"name=viewport><link href=\"https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap\"rel=stylesheet><style>body{{background-color:#121212;color:#fff;font-family:Roboto,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;text-align:center}}.container{{max-width:600px;padding:20px;border-radius:8px;background-color:#1e1e1e;box-shadow:0 4px 20px rgba(0,0,0,.5)}}h1{{font-size:24px;margin-bottom:20px}}</style><title>{title}</title></head><body><div class=container><h1>{title}</h1>{blockInfo}<p style=font-size:14px;color:silver;>{message}</p><code style=font-size:12px;color:gray;>TraceId={traceId}</code></div>{script}</body></html>";
|
||||
}
|
||||
|
||||
[HttpGet("OAuth2")]
|
||||
[BadRequestResponse]
|
||||
[Produces("text/html")]
|
||||
[MaintenanceModeIgnore]
|
||||
public async Task<ContentResult> OAuth2([FromQuery] string code, [FromQuery] string state)
|
||||
public async Task<ContentResult> OAuth2([FromQuery] string? code, [FromQuery] string? state)
|
||||
{
|
||||
var userId = HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
string title;
|
||||
string message;
|
||||
OAuthProvider provider;
|
||||
OAuthUser oAuthUser;
|
||||
var traceId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
|
||||
|
||||
try
|
||||
{
|
||||
(provider, oAuthUser) = await oAuthService.LoginOAuth(HttpContext, GetCookieParams(),
|
||||
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
|
||||
return Content(GenerateHtmlResponse(
|
||||
"Ошибка передачи данных!",
|
||||
"Провайдер OAuth не передал нужных данных.",
|
||||
null,
|
||||
traceId,
|
||||
true), "text/html");
|
||||
|
||||
var result = await oAuthService.LoginOAuth(HttpContext, GetCookieParams(),
|
||||
HttpContext.GetApiUrl(Url.Action("OAuth2")!), code, state);
|
||||
}
|
||||
catch (Exception e)
|
||||
|
||||
string? callbackUrl = null;
|
||||
|
||||
if (result.Callback != null)
|
||||
callbackUrl = result.Callback + (result.Callback.Query.Length > 0 ? "&" : "?") +
|
||||
$"result={Uri.EscapeDataString(result.Token)}";
|
||||
|
||||
string title, message;
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
title = "Произошла ошибка при общении с провайдером OAuth!";
|
||||
message = e.Message;
|
||||
return Content(GenerateHtmlResponse(title, message, null, true, traceId), "text/html");
|
||||
if (callbackUrl != null)
|
||||
callbackUrl += $"&traceId={Uri.EscapeDataString(traceId)}";
|
||||
|
||||
title = "Ошибка авторизации!";
|
||||
message = result.ErrorMessage ?? "Произошла ошибка. Попробуйте ещё раз.";
|
||||
}
|
||||
|
||||
var userEntity = user.Value;
|
||||
|
||||
if (userId != null)
|
||||
else
|
||||
{
|
||||
userEntity.OAuthProviders ??= [];
|
||||
|
||||
if (!userEntity.OAuthProviders.TryAdd(provider, oAuthUser))
|
||||
{
|
||||
title = "Ошибка связи аккаунта!";
|
||||
message = "Этот OAuth провайдер уже связан с вашей учетной записью. " +
|
||||
"Пожалуйста, используйте другого провайдера или удалите связь с аккаунтом.";
|
||||
return Content(GenerateHtmlResponse(title, message, provider, true, traceId), "text/html");
|
||||
}
|
||||
|
||||
userEntity.SaveSetting();
|
||||
|
||||
title = "Учетная запись успешно связана.";
|
||||
message = "Вы успешно связали свою учетную запись с провайдером OAuth. Вы можете продолжить использовать приложение.";
|
||||
return Content(GenerateHtmlResponse(title, message, provider), "text/html");
|
||||
title = "Авторизация завершена!";
|
||||
message = "Вы будете перенаправлены обратно через несколько секунд.";
|
||||
}
|
||||
|
||||
if (userEntity.OAuthProviders != null &&
|
||||
userEntity.OAuthProviders.TryGetValue(provider, out var userOAuth) &&
|
||||
userOAuth.Id == oAuthUser.Id)
|
||||
{
|
||||
await auth.LoginOAuthAsync(GetCookieParams(), HttpContext, new User
|
||||
{
|
||||
Id = 1.ToString(),
|
||||
Username = userEntity.Username,
|
||||
Email = userEntity.Email,
|
||||
PasswordHash = userEntity.PasswordHash,
|
||||
Salt = userEntity.Salt,
|
||||
TwoFactorAuthenticator = userEntity.TwoFactorAuthenticator,
|
||||
SecondFactorToken = userEntity.Secret,
|
||||
OAuthProviders = userEntity.OAuthProviders
|
||||
});
|
||||
|
||||
title = "Успешный вход в аккаунт.";
|
||||
message = "Вы успешно вошли в свою учетную запись. Добро пожаловать!";
|
||||
return Content(GenerateHtmlResponse(title, message, provider), "text/html");
|
||||
}
|
||||
|
||||
title = "Вы успешно зарегистрированы.";
|
||||
message = "Процесс завершен. Вы можете закрыть эту страницу.";
|
||||
userEntity.Email = string.IsNullOrEmpty(oAuthUser.Email) ? string.Empty : oAuthUser.Email;
|
||||
userEntity.Username = string.IsNullOrEmpty(oAuthUser.Username) ? string.Empty : oAuthUser.Username;
|
||||
userEntity.OAuthProviders ??= [];
|
||||
userEntity.OAuthProviders.Add(provider, oAuthUser);
|
||||
userEntity.SaveSetting();
|
||||
return Content(GenerateHtmlResponse(title, message, provider), "text/html");
|
||||
return Content(GenerateHtmlResponse(
|
||||
title,
|
||||
message,
|
||||
callbackUrl == null ? null : new Uri(callbackUrl),
|
||||
traceId,
|
||||
!result.Success), "text/html");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
11
Security/Common/Domain/LoginOAuthResult.cs
Normal file
11
Security/Common/Domain/LoginOAuthResult.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using System;
|
||||
|
||||
namespace Mirea.Api.Security.Common.Domain;
|
||||
|
||||
public class LoginOAuthResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public required string Token { get; set; }
|
||||
public Uri? Callback { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
@ -10,7 +10,6 @@ using System.IO;
|
||||
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;
|
||||
@ -19,7 +18,8 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace Mirea.Api.Security.Services;
|
||||
|
||||
public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider, (string ClientId, string Secret)> providers, string secretKey)
|
||||
public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider, (string ClientId, string Secret)> providers,
|
||||
ICacheService cache)
|
||||
{
|
||||
public required ReadOnlyMemory<byte> SecretKey { private get; init; }
|
||||
|
||||
@ -195,21 +195,26 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
|
||||
public (OAuthProvider Provider, Uri Redirect)[] GetAvailableProviders(string redirectUri) =>
|
||||
[.. 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,
|
||||
public async Task<LoginOAuthResult> LoginOAuth(HttpContext context, CookieOptionsParameters cookieOptions,
|
||||
string redirectUrl, string code, string state, CancellationToken cancellation = default)
|
||||
{
|
||||
|
||||
var result = new LoginOAuthResult()
|
||||
{
|
||||
Token = GeneratorKey.GenerateBase64(32)
|
||||
};
|
||||
var parts = state.Split('_');
|
||||
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
throw new SecurityException("The request data is invalid or malformed.");
|
||||
|
||||
result.ErrorMessage = "The request data is invalid or malformed.";
|
||||
return result;
|
||||
}
|
||||
|
||||
var payload = DecryptPayload(parts[0]);
|
||||
var checksum = parts[1];
|
||||
|
||||
result.Callback = new Uri(payload.Callback);
|
||||
|
||||
if (!providers.TryGetValue(payload.Provider, out var providerInfo) ||
|
||||
!ProviderData.TryGetValue(payload.Provider, out var currentProviderStruct))
|
||||
{
|
||||
@ -217,12 +222,15 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
|
||||
"is not registered as a possible data recipient from state: {State}",
|
||||
state);
|
||||
|
||||
throw new SecurityException("Invalid authorization request. Please try again later.");
|
||||
result.ErrorMessage = "Invalid authorization request. Please try again later.";
|
||||
return result;
|
||||
}
|
||||
|
||||
var requestInfo = new RequestContextInfo(context, cookieOptions);
|
||||
var checksumRequest = GetHmacString(requestInfo);
|
||||
|
||||
|
||||
result.ErrorMessage = "Authorization failed. Please try again later.";
|
||||
|
||||
if (checksumRequest != checksum)
|
||||
{
|
||||
logger.LogWarning(
|
||||
@ -231,10 +239,11 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
|
||||
checksumRequest,
|
||||
checksum
|
||||
);
|
||||
throw new SecurityException("Suspicious activity detected. Please try again.");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
OAuthTokenResponse? accessToken = null;
|
||||
OAuthTokenResponse? accessToken;
|
||||
try
|
||||
{
|
||||
accessToken = await ExchangeCodeForTokensAsync(currentProviderStruct.TokenUrl, redirectUrl, code, providerInfo.ClientId,
|
||||
@ -243,27 +252,41 @@ public class OAuthService(ILogger<OAuthService> logger, Dictionary<OAuthProvider
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to exchange code for access token with provider {Provider}. State: {State}",
|
||||
provider,
|
||||
secretStateData);
|
||||
payload.Provider,
|
||||
checksum);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (accessToken == null)
|
||||
throw new SecurityException("Unable to complete authorization with the provider. Please try again later.");
|
||||
return result;
|
||||
|
||||
OAuthUser? result = null;
|
||||
OAuthUser? user;
|
||||
try
|
||||
{
|
||||
result = await GetUserProfileAsync(currentProviderStruct.UserInfoUrl, currentProviderStruct.AuthHeader, accessToken.AccessToken,
|
||||
provider, cancellation);
|
||||
user = await GetUserProfileAsync(currentProviderStruct.UserInfoUrl, currentProviderStruct.AuthHeader, accessToken.AccessToken,
|
||||
payload.Provider, cancellation);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to retrieve user information from provider {Provider}", provider);
|
||||
logger.LogWarning(ex, "Failed to retrieve user information from provider {Provider}",
|
||||
payload.Provider);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result == null)
|
||||
throw new SecurityException("Unable to retrieve user information. Please check the details and try again.");
|
||||
if (user == null)
|
||||
return result;
|
||||
|
||||
return (provider, result);
|
||||
result.ErrorMessage = null;
|
||||
result.Success = true;
|
||||
|
||||
await cache.SetAsync(
|
||||
result.Token,
|
||||
JsonSerializer.SerializeToUtf8Bytes(user),
|
||||
absoluteExpirationRelativeToNow: TimeSpan.FromMinutes(15),
|
||||
cancellationToken: cancellation);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user