Compare commits
5
Commits
No files matched your search
@@ -0,0 +1,84 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/auth/security")]
|
||||||
|
public sealed class AccountSecurityController(UserManager<ApplicationUser> userManager) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("totp")]
|
||||||
|
public async Task<ActionResult> GetTotpStatus()
|
||||||
|
{
|
||||||
|
var user = await CurrentUserAsync();
|
||||||
|
return user is null ? Unauthorized() : Ok(new { enabled = user.TwoFactorEnabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("totp/setup")]
|
||||||
|
public async Task<ActionResult<TotpSetupResponse>> SetupTotp(PasswordConfirmationRequest request)
|
||||||
|
{
|
||||||
|
var user = await CurrentUserAsync();
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
if (!await userManager.CheckPasswordAsync(user, request.CurrentPassword))
|
||||||
|
return Unauthorized(PasswordProblem());
|
||||||
|
|
||||||
|
await userManager.ResetAuthenticatorKeyAsync(user);
|
||||||
|
var key = await userManager.GetAuthenticatorKeyAsync(user);
|
||||||
|
if (string.IsNullOrWhiteSpace(key)) return Problem("无法创建验证器密钥。");
|
||||||
|
var issuer = "明序教务";
|
||||||
|
var account = Uri.EscapeDataString(user.UserName ?? user.Id.ToString("D"));
|
||||||
|
var label = Uri.EscapeDataString($"{issuer}:{user.UserName}");
|
||||||
|
var uri = $"otpauth://totp/{label}?secret={key}&issuer={Uri.EscapeDataString(issuer)}&digits=6";
|
||||||
|
return Ok(new TotpSetupResponse(key, uri));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("totp/enable")]
|
||||||
|
public async Task<ActionResult<RecoveryCodesResponse>> EnableTotp(TotpEnableRequest request)
|
||||||
|
{
|
||||||
|
var user = await CurrentUserAsync();
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
if (!await userManager.VerifyTwoFactorTokenAsync(user,
|
||||||
|
TokenOptions.DefaultAuthenticatorProvider, request.Code.Replace(" ", string.Empty)))
|
||||||
|
{
|
||||||
|
ModelState.AddModelError("code", "验证码不正确或已过期。");
|
||||||
|
return ValidationProblem(ModelState);
|
||||||
|
}
|
||||||
|
|
||||||
|
await userManager.SetTwoFactorEnabledAsync(user, true);
|
||||||
|
var codes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
|
||||||
|
return Ok(new RecoveryCodesResponse((codes ?? []).ToArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("totp/disable")]
|
||||||
|
public async Task<IActionResult> DisableTotp(PasswordConfirmationRequest request)
|
||||||
|
{
|
||||||
|
var user = await CurrentUserAsync();
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
if (!await userManager.CheckPasswordAsync(user, request.CurrentPassword))
|
||||||
|
return Unauthorized(PasswordProblem());
|
||||||
|
await userManager.SetTwoFactorEnabledAsync(user, false);
|
||||||
|
await userManager.ResetAuthenticatorKeyAsync(user);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ApplicationUser?> CurrentUserAsync()
|
||||||
|
{
|
||||||
|
var id = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
return id is null ? null : await userManager.FindByIdAsync(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProblemDetails PasswordProblem() => new()
|
||||||
|
{
|
||||||
|
Title = "验证失败", Detail = "当前密码不正确。", Status = StatusCodes.Status401Unauthorized,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PasswordConfirmationRequest([Required, MaxLength(100)] string CurrentPassword);
|
||||||
|
public sealed record TotpEnableRequest([Required, MinLength(6), MaxLength(12)] string Code);
|
||||||
|
public sealed record TotpSetupResponse(string Secret, string OtpauthUri);
|
||||||
|
public sealed record RecoveryCodesResponse(string[] RecoveryCodes);
|
||||||
@@ -19,6 +19,7 @@ public sealed class AuthController(
|
|||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
IAuthSessionService authSessionService,
|
IAuthSessionService authSessionService,
|
||||||
|
ITwoFactorLoginTicketService twoFactorTickets,
|
||||||
IAppCache cache) : ControllerBase
|
IAppCache cache) : ControllerBase
|
||||||
{
|
{
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
@@ -137,7 +138,7 @@ public sealed class AuthController(
|
|||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[EnableRateLimiting("public-auth")]
|
[EnableRateLimiting("public-auth")]
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
public async Task<ActionResult<LoginResponse>> Login(
|
public async Task<ActionResult> Login(
|
||||||
LoginRequest request,
|
LoginRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -164,6 +165,11 @@ public sealed class AuthController(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.TwoFactorEnabled)
|
||||||
|
{
|
||||||
|
return Ok(new TwoFactorRequiredResponse(twoFactorTickets.Create(user.Id, request.IsNativeApp)));
|
||||||
|
}
|
||||||
|
|
||||||
await userManager.ResetAccessFailedCountAsync(user);
|
await userManager.ResetAccessFailedCountAsync(user);
|
||||||
user.LastLoginAt = DateTime.UtcNow;
|
user.LastLoginAt = DateTime.UtcNow;
|
||||||
await userManager.UpdateAsync(user);
|
await userManager.UpdateAsync(user);
|
||||||
@@ -176,6 +182,36 @@ public sealed class AuthController(
|
|||||||
? AuthenticationClientType.App
|
? AuthenticationClientType.App
|
||||||
: AuthenticationClientType.Web,
|
: AuthenticationClientType.Web,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
return Ok(CreateLoginResponse(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting("public-auth")]
|
||||||
|
[HttpPost("login/totp")]
|
||||||
|
public async Task<ActionResult<LoginResponse>> CompleteTotpLogin(
|
||||||
|
TotpLoginRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!twoFactorTickets.TryRead(request.TwoFactorTicket, out var userId, out var isNativeApp))
|
||||||
|
return Unauthorized(LoginProblem());
|
||||||
|
var user = await userManager.FindByIdAsync(userId.ToString("D"));
|
||||||
|
var code = request.Code.Replace(" ", string.Empty);
|
||||||
|
var isValid = user is not null && (await userManager.VerifyTwoFactorTokenAsync(
|
||||||
|
user, TokenOptions.DefaultAuthenticatorProvider, code) ||
|
||||||
|
(await userManager.RedeemTwoFactorRecoveryCodeAsync(user, code)).Succeeded);
|
||||||
|
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user) || !isValid)
|
||||||
|
{
|
||||||
|
if (user is not null) await userManager.AccessFailedAsync(user);
|
||||||
|
return Unauthorized(LoginProblem());
|
||||||
|
}
|
||||||
|
|
||||||
|
await userManager.ResetAccessFailedCountAsync(user);
|
||||||
|
user.LastLoginAt = DateTime.UtcNow;
|
||||||
|
await userManager.UpdateAsync(user);
|
||||||
|
var roles = await userManager.GetRolesAsync(user);
|
||||||
|
var session = await authSessionService.CreateAsync(user, roles,
|
||||||
|
isNativeApp ? AuthenticationClientType.App : AuthenticationClientType.Web,
|
||||||
|
cancellationToken);
|
||||||
return CreateLoginResponse(session);
|
return CreateLoginResponse(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +284,12 @@ public sealed class AuthController(
|
|||||||
Status = status
|
Status = status
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private static ProblemDetails LoginProblem() => new()
|
||||||
|
{
|
||||||
|
Title = "登录失败", Detail = "验证码无效、已过期或账号已停用。",
|
||||||
|
Status = StatusCodes.Status401Unauthorized
|
||||||
|
};
|
||||||
|
|
||||||
internal static LoginResponse CreateLoginResponse(AuthSessionResult session) =>
|
internal static LoginResponse CreateLoginResponse(AuthSessionResult session) =>
|
||||||
new(
|
new(
|
||||||
session.AccessToken,
|
session.AccessToken,
|
||||||
@@ -268,6 +310,15 @@ public sealed record LoginRequest(
|
|||||||
[Required, MaxLength(100)] string Password,
|
[Required, MaxLength(100)] string Password,
|
||||||
bool IsNativeApp = false);
|
bool IsNativeApp = false);
|
||||||
|
|
||||||
|
public sealed record TotpLoginRequest(
|
||||||
|
[Required, MinLength(20), MaxLength(2048)] string TwoFactorTicket,
|
||||||
|
[Required, MinLength(6), MaxLength(12)] string Code);
|
||||||
|
|
||||||
|
public sealed record TwoFactorRequiredResponse(string TwoFactorTicket)
|
||||||
|
{
|
||||||
|
public bool RequiresTotp => true;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed record RefreshTokenRequest(
|
public sealed record RefreshTokenRequest(
|
||||||
[Required, MinLength(40), MaxLength(200)] string RefreshToken);
|
[Required, MinLength(40), MaxLength(200)] string RefreshToken);
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public sealed class SsoController(
|
|||||||
{
|
{
|
||||||
private const string BindingIntentProperty = "sso-binding-intent";
|
private const string BindingIntentProperty = "sso-binding-intent";
|
||||||
private const string NativeAppProperty = "sso-native-app";
|
private const string NativeAppProperty = "sso-native-app";
|
||||||
|
private const string NativeAppStateProperty = "sso-native-app-state";
|
||||||
private readonly SsoOptions _options = options.Value;
|
private readonly SsoOptions _options = options.Value;
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
@@ -44,6 +45,7 @@ public sealed class SsoController(
|
|||||||
[FromQuery] string? returnUrl = null,
|
[FromQuery] string? returnUrl = null,
|
||||||
[FromQuery] string? bindingIntent = null,
|
[FromQuery] string? bindingIntent = null,
|
||||||
[FromQuery] bool nativeApp = false,
|
[FromQuery] bool nativeApp = false,
|
||||||
|
[FromQuery] string? nativeState = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (!_options.Enabled)
|
if (!_options.Enabled)
|
||||||
@@ -52,7 +54,12 @@ public sealed class SsoController(
|
|||||||
var safeReturnUrl = NormalizeReturnUrl(returnUrl);
|
var safeReturnUrl = NormalizeReturnUrl(returnUrl);
|
||||||
var properties = new AuthenticationProperties();
|
var properties = new AuthenticationProperties();
|
||||||
if (nativeApp)
|
if (nativeApp)
|
||||||
|
{
|
||||||
|
if (!IsValidNativeState(nativeState))
|
||||||
|
return SsoProblem("原生应用登录校验已失效,请返回应用重新发起登录。", StatusCodes.Status400BadRequest);
|
||||||
properties.Items[NativeAppProperty] = bool.TrueString;
|
properties.Items[NativeAppProperty] = bool.TrueString;
|
||||||
|
properties.Items[NativeAppStateProperty] = nativeState!;
|
||||||
|
}
|
||||||
if (!string.IsNullOrWhiteSpace(bindingIntent))
|
if (!string.IsNullOrWhiteSpace(bindingIntent))
|
||||||
{
|
{
|
||||||
var targetUserId = await cache.GetStringAsync(
|
var targetUserId = await cache.GetStringAsync(
|
||||||
@@ -116,6 +123,10 @@ public sealed class SsoController(
|
|||||||
externalProperties.Items.TryGetValue(NativeAppProperty, out var nativeAppValue) &&
|
externalProperties.Items.TryGetValue(NativeAppProperty, out var nativeAppValue) &&
|
||||||
bool.TryParse(nativeAppValue, out var isNativeApp) &&
|
bool.TryParse(nativeAppValue, out var isNativeApp) &&
|
||||||
isNativeApp;
|
isNativeApp;
|
||||||
|
var nativeState = authentication.Properties is { } nativeProperties &&
|
||||||
|
nativeProperties.Items.TryGetValue(NativeAppStateProperty, out var storedNativeState)
|
||||||
|
? storedNativeState
|
||||||
|
: null;
|
||||||
if (!string.IsNullOrWhiteSpace(bindingIntent))
|
if (!string.IsNullOrWhiteSpace(bindingIntent))
|
||||||
{
|
{
|
||||||
var targetUserId = await cache.GetStringAsync(
|
var targetUserId = await cache.GetStringAsync(
|
||||||
@@ -165,7 +176,7 @@ public sealed class SsoController(
|
|||||||
subject;
|
subject;
|
||||||
await cache.SetStringAsync(
|
await cache.SetStringAsync(
|
||||||
BindingCacheKey(bindingCode),
|
BindingCacheKey(bindingCode),
|
||||||
JsonSerializer.Serialize(new SsoBindingTicket(subject, externalUserName)),
|
JsonSerializer.Serialize(new SsoBindingTicket(subject, externalUserName, nativeState)),
|
||||||
new DistributedCacheEntryOptions
|
new DistributedCacheEntryOptions
|
||||||
{
|
{
|
||||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
|
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
|
||||||
@@ -190,7 +201,7 @@ public sealed class SsoController(
|
|||||||
RandomNumberGenerator.GetBytes(32));
|
RandomNumberGenerator.GetBytes(32));
|
||||||
await cache.SetStringAsync(
|
await cache.SetStringAsync(
|
||||||
ExchangeCacheKey(exchangeCode),
|
ExchangeCacheKey(exchangeCode),
|
||||||
user.Id.ToString("D"),
|
JsonSerializer.Serialize(new SsoExchangeTicket(user.Id, nativeState)),
|
||||||
new DistributedCacheEntryOptions
|
new DistributedCacheEntryOptions
|
||||||
{
|
{
|
||||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
|
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
|
||||||
@@ -215,14 +226,25 @@ public sealed class SsoController(
|
|||||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
var cacheKey = ExchangeCacheKey(request.Code);
|
var cacheKey = ExchangeCacheKey(request.Code);
|
||||||
var userId = await cache.GetStringAsync(cacheKey, cancellationToken);
|
var ticketJson = await cache.GetStringAsync(cacheKey, cancellationToken);
|
||||||
if (userId is null)
|
SsoExchangeTicket? ticket;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ticket = ticketJson is null
|
||||||
|
? null
|
||||||
|
: JsonSerializer.Deserialize<SsoExchangeTicket>(ticketJson);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
ticket = null;
|
||||||
|
}
|
||||||
|
if (ticket is null || !NativeStateMatches(ticket.NativeState, request.NativeState))
|
||||||
return SsoProblem(
|
return SsoProblem(
|
||||||
"统一身份认证结果已失效,请重新登录。",
|
"统一身份认证结果已失效,请重新登录。",
|
||||||
StatusCodes.Status401Unauthorized);
|
StatusCodes.Status401Unauthorized);
|
||||||
|
|
||||||
await cache.RemoveAsync(cacheKey, cancellationToken);
|
await cache.RemoveAsync(cacheKey, cancellationToken);
|
||||||
var user = await userManager.FindByIdAsync(userId);
|
var user = await userManager.FindByIdAsync(ticket.UserId.ToString("D"));
|
||||||
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||||
return SsoProblem(
|
return SsoProblem(
|
||||||
"本地账号不存在、已停用或已锁定。",
|
"本地账号不存在、已停用或已锁定。",
|
||||||
@@ -269,7 +291,7 @@ public sealed class SsoController(
|
|||||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
var ticket = await ReadBindingTicketAsync(request.Code, cancellationToken);
|
var ticket = await ReadBindingTicketAsync(request.Code, cancellationToken);
|
||||||
if (ticket is null)
|
if (ticket is null || !NativeStateMatches(ticket.NativeState, request.NativeState))
|
||||||
return SsoProblem(
|
return SsoProblem(
|
||||||
"账户绑定请求已失效,请重新使用统一身份认证登录。",
|
"账户绑定请求已失效,请重新使用统一身份认证登录。",
|
||||||
StatusCodes.Status401Unauthorized);
|
StatusCodes.Status401Unauthorized);
|
||||||
@@ -353,7 +375,8 @@ public sealed class SsoController(
|
|||||||
[HttpPost("prepare-binding")]
|
[HttpPost("prepare-binding")]
|
||||||
public async Task<ActionResult<SsoBindingStartResponse>> PrepareBinding(
|
public async Task<ActionResult<SsoBindingStartResponse>> PrepareBinding(
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
[FromQuery] bool nativeApp = false)
|
[FromQuery] bool nativeApp = false,
|
||||||
|
[FromQuery] string? nativeState = null)
|
||||||
{
|
{
|
||||||
if (!_options.Enabled)
|
if (!_options.Enabled)
|
||||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||||
@@ -383,7 +406,8 @@ public sealed class SsoController(
|
|||||||
{
|
{
|
||||||
returnUrl = "/account",
|
returnUrl = "/account",
|
||||||
bindingIntent = intentCode,
|
bindingIntent = intentCode,
|
||||||
nativeApp
|
nativeApp,
|
||||||
|
nativeState
|
||||||
})!;
|
})!;
|
||||||
return new SsoBindingStartResponse(loginUrl);
|
return new SsoBindingStartResponse(loginUrl);
|
||||||
}
|
}
|
||||||
@@ -427,20 +451,26 @@ public sealed class SsoController(
|
|||||||
? returnUrl
|
? returnUrl
|
||||||
: "/dashboard";
|
: "/dashboard";
|
||||||
|
|
||||||
private string BuildFrontendUrl(string path, bool requireAbsoluteUrl = false)
|
private string BuildFrontendUrl(string path, bool nativeApp = false)
|
||||||
{
|
{
|
||||||
if (requireAbsoluteUrl && string.IsNullOrWhiteSpace(_options.FrontendBaseUrl))
|
if (nativeApp)
|
||||||
{
|
return "mingxu://open" + path;
|
||||||
throw new InvalidOperationException(
|
return string.IsNullOrWhiteSpace(_options.FrontendBaseUrl)
|
||||||
"原生单点登录需要配置 Sso:FrontendBaseUrl 为已验证的 HTTPS 地址。");
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
string.IsNullOrWhiteSpace(_options.FrontendBaseUrl)
|
|
||||||
? path
|
? path
|
||||||
: _options.FrontendBaseUrl.TrimEnd('/') + path;
|
: _options.FrontendBaseUrl.TrimEnd('/') + path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsValidNativeState(string? value) =>
|
||||||
|
!string.IsNullOrWhiteSpace(value) && value.Length is >= 32 and <= 200;
|
||||||
|
|
||||||
|
private static bool NativeStateMatches(string? expected, string? actual) =>
|
||||||
|
expected is null
|
||||||
|
? string.IsNullOrWhiteSpace(actual)
|
||||||
|
: IsValidNativeState(actual) &&
|
||||||
|
CryptographicOperations.FixedTimeEquals(
|
||||||
|
System.Text.Encoding.UTF8.GetBytes(expected),
|
||||||
|
System.Text.Encoding.UTF8.GetBytes(actual!));
|
||||||
|
|
||||||
private RedirectResult RedirectToFrontendError(
|
private RedirectResult RedirectToFrontendError(
|
||||||
string error,
|
string error,
|
||||||
string path = "/login") =>
|
string path = "/login") =>
|
||||||
@@ -543,7 +573,8 @@ public sealed record SsoSettingsResponse(
|
|||||||
|
|
||||||
public sealed record SsoExchangeRequest(
|
public sealed record SsoExchangeRequest(
|
||||||
[Required, MinLength(20), MaxLength(200)] string Code,
|
[Required, MinLength(20), MaxLength(200)] string Code,
|
||||||
bool IsNativeApp = false);
|
bool IsNativeApp = false,
|
||||||
|
string? NativeState = null);
|
||||||
|
|
||||||
public sealed record SsoBindingInfoResponse(
|
public sealed record SsoBindingInfoResponse(
|
||||||
string ProviderDisplayName,
|
string ProviderDisplayName,
|
||||||
@@ -553,9 +584,14 @@ public sealed record SsoBindRequest(
|
|||||||
[Required, MinLength(20), MaxLength(200)] string Code,
|
[Required, MinLength(20), MaxLength(200)] string Code,
|
||||||
[Required, MaxLength(100)] string UserName,
|
[Required, MaxLength(100)] string UserName,
|
||||||
[Required, MaxLength(100)] string Password,
|
[Required, MaxLength(100)] string Password,
|
||||||
bool IsNativeApp = false);
|
bool IsNativeApp = false,
|
||||||
|
string? NativeState = null);
|
||||||
|
|
||||||
internal sealed record SsoBindingTicket(string Subject, string ExternalUserName);
|
internal sealed record SsoBindingTicket(
|
||||||
|
string Subject,
|
||||||
|
string ExternalUserName,
|
||||||
|
string? NativeState = null);
|
||||||
|
internal sealed record SsoExchangeTicket(Guid UserId, string? NativeState);
|
||||||
|
|
||||||
public sealed record SsoAccountResponse(
|
public sealed record SsoAccountResponse(
|
||||||
bool Enabled,
|
bool Enabled,
|
||||||
|
|||||||
Loaded 3 of 21 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user