diff --git a/apps/vben5/packages/@abp/account/src/utils/auth.ts b/apps/vben5/packages/@abp/account/src/utils/auth.ts index 39260906b..4f38c61a1 100644 --- a/apps/vben5/packages/@abp/account/src/utils/auth.ts +++ b/apps/vben5/packages/@abp/account/src/utils/auth.ts @@ -157,6 +157,7 @@ const oidcSettings: UserManagerSettings = { silent_redirect_uri: `${window.location.origin}/silent-renew.html`, automaticSilentRenew: true, loadUserInfo: true, + prompt: 'select_account', userStore: new WebStorageStateStore({ store: import.meta.env.DEV ? localStorage diff --git a/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/AbpAccountWebOpenIddictModule.cs b/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/AbpAccountWebOpenIddictModule.cs index aa41061d3..81a6cb805 100644 --- a/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/AbpAccountWebOpenIddictModule.cs +++ b/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/AbpAccountWebOpenIddictModule.cs @@ -1,7 +1,8 @@ -using LINGYUN.Abp.Account.Web.OpenIddict.ViewModels.Authorize; +using LINGYUN.Abp.Account.Web.OpenIddict.Pages.Account; using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.AspNetCore.Mvc.Localization; using Volo.Abp.AspNetCore.Mvc.UI.Bundling; using Volo.Abp.Localization; using Volo.Abp.Modularity; @@ -18,6 +19,11 @@ public class AbpAccountWebOpenIddictModule : AbpModule { public override void PreConfigureServices(ServiceConfigurationContext context) { + context.Services.PreConfigure(options => + { + options.AddAssemblyResource(typeof(AbpOpenIddictResource), typeof(AbpAccountWebOpenIddictModule).Assembly); + }); + PreConfigure(mvcBuilder => { mvcBuilder.AddApplicationPartIfNotExists(typeof(AbpAccountWebOpenIddictModule).Assembly); @@ -39,6 +45,7 @@ public class AbpAccountWebOpenIddictModule : AbpModule Configure(options => { options.Conventions.AuthorizePage("/Authorize"); + options.Conventions.AuthorizePage("/Account/SelectAccount"); }); Configure(options => @@ -47,5 +54,19 @@ public class AbpAccountWebOpenIddictModule : AbpModule .Get() .AddVirtualJson("/Localization/Resources"); }); + + Configure(options => + { + options.ScriptBundles + .Add(typeof(SelectAccountModel).FullName, bundle => + { + bundle.AddFiles("/Pages/Account/SelectAccount.js"); + }); + options.StyleBundles + .Add(typeof(SelectAccountModel).FullName, bundle => + { + bundle.AddFiles("/css/select-account.css"); + }); + }); } } diff --git a/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/LINGYUN.Abp.Account.Web.OpenIddict.csproj b/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/LINGYUN.Abp.Account.Web.OpenIddict.csproj index de2e10a4c..30e496ecd 100644 --- a/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/LINGYUN.Abp.Account.Web.OpenIddict.csproj +++ b/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/LINGYUN.Abp.Account.Web.OpenIddict.csproj @@ -18,9 +18,13 @@ + + + + diff --git a/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/Localization/Resources/en.json b/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/Localization/Resources/en.json index 8451ed8fa..7ea3063b4 100644 --- a/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/Localization/Resources/en.json +++ b/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/Localization/Resources/en.json @@ -1,6 +1,19 @@ { "culture": "en", "texts": { - "Required": "Required" + "Required": "Required", + "InvalidSelectAccountRequest": "Invalid request for a selected account!", + "NoAvailableAccounts": "No Available Accounts!", + "InvalidSelectAccount": "Invalid Select Account!", + "SelectAccount": "Select Account", + "CurrentAccount": "Current", + "SignedInAs": "Select to login to the account of {0}", + "LastLoginTime": "Last Login", + "Continue": "Continue", + "SwitchToAnotherAccount": "Switch to another account", + "CreateNewAccount": "Create a new account", + "SelectedAccountId": "Selected a cccount", + "RememberSelection": "Remember selection", + "RememberMe": "Remember me" } } \ No newline at end of file diff --git a/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/Localization/Resources/zh-Hans.json b/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/Localization/Resources/zh-Hans.json index 1da95d775..5b4d79831 100644 --- a/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/Localization/Resources/zh-Hans.json +++ b/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/Localization/Resources/zh-Hans.json @@ -1,6 +1,19 @@ { "culture": "zh-Hans", "texts": { - "Required": "必须" + "Required": "必须", + "InvalidSelectAccountRequest": "无效的选择账户请求!", + "NoAvailableAccounts": "未找到可用账户!", + "InvalidSelectAccount": "选择的账户无效!", + "SelectAccount": "选择账户", + "CurrentAccount": "当前账户", + "SignedInAs": "选择登录到 {0} 的账户", + "LastLoginTime": "上次登录", + "Continue": "继续", + "SwitchToAnotherAccount": "使用其他账户", + "CreateNewAccount": "创建新账户", + "SelectedAccountId": "请选择一个账户", + "RememberSelection": "记住我的选择", + "RememberMe": "保持登录状态" } } \ No newline at end of file diff --git a/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/Pages/Account/SelectAccount.cshtml b/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/Pages/Account/SelectAccount.cshtml new file mode 100644 index 000000000..9dde8b19b --- /dev/null +++ b/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/Pages/Account/SelectAccount.cshtml @@ -0,0 +1,133 @@ +@page +@using Microsoft.AspNetCore.Mvc.Localization +@using Volo.Abp.AspNetCore.Mvc.UI.Layout +@using Volo.Abp.AspNetCore.Mvc.UI.Theming; +@using Volo.Abp.OpenIddict.Localization +@using LINGYUN.Abp.Account.Web.OpenIddict.Pages.Account +@inject IThemeManager ThemeManager +@inject IPageLayout PageLayout +@inject IHtmlLocalizer L +@model SelectAccountModel + +@{ + Layout = ThemeManager.CurrentTheme.GetAccountLayout(); + PageLayout.Content.Title = L["SelectAccount"].Value; +} + +@section styles +{ + +} + +@section scripts +{ + +} + + + + @if (Model.AvailableAccounts.Any()) + { +
+ @Html.AntiForgeryToken() + + + + + + +
+ + @string.Format(@L["SignedInAs"].Value, @Model.UserName) +
+ +
+ @for (int i = 0; i < Model.AvailableAccounts.Count; i++) + { + var account = Model.AvailableAccounts[i]; + var accountId = $"{account.UserId}@{account.TenantId}"; + var accountName = $"{account.UserName}"; + if (!string.IsNullOrWhiteSpace(account.TenantName)) + { + accountName += "/" + account.TenantName; + } + // TODO: Date format + var lastLoginTime = account.LastLoginTime.HasValue ? account.LastLoginTime.Value.ToString("yyyy-MM:dd HH:mm:ss") : ""; + var isCurrent = account.IsCurrentAccount; + // TODO: 实现用户头像 + var avatar = !string.IsNullOrWhiteSpace(account.UserName) ? account.UserName[0].ToString().ToUpper() : "U"; + + + } +
+ + +
+ +
+
+ } + else + { +
+ + @L["NoAccountsAvailable"].Value +
+ } + + + +
+
\ No newline at end of file diff --git a/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/Pages/Account/SelectAccount.cshtml.cs b/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/Pages/Account/SelectAccount.cshtml.cs new file mode 100644 index 000000000..88cd626db --- /dev/null +++ b/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/Pages/Account/SelectAccount.cshtml.cs @@ -0,0 +1,468 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using OpenIddict.Abstractions; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp.Account.Web.Pages.Account; +using Volo.Abp.Data; +using Volo.Abp.Identity; +using Volo.Abp.MultiTenancy; +using Volo.Abp.OpenIddict.Localization; +using Volo.Abp.Users; +using Volo.Abp.Validation; +using static LINGYUN.Abp.Account.Web.OpenIddict.Pages.Account.SelectAccountModel; + +namespace LINGYUN.Abp.Account.Web.OpenIddict.Pages.Account; + +[Authorize] +public class SelectAccountModel : AccountPageModel +{ + private const string LastLoginTimeFieldName = "LastLoginTime"; + + [BindProperty(SupportsGet = true)] + public string RedirectUri { get; set; } + + public string ClientName { get; set; } + + public string UserName { get; set; } + + [BindProperty] + public SelectAccountInput Input { get; set; } + + public List AvailableAccounts { get; set; } = new(); + + protected IOpenIddictApplicationManager ApplicationManager => LazyServiceProvider.LazyGetRequiredService(); + + protected ITenantStore TenantStore => LazyServiceProvider.LazyGetRequiredService(); + + public SelectAccountModel() + { + LocalizationResourceType = typeof(AbpOpenIddictResource); + } + + public async virtual Task OnGetAsync() + { + // ûǷѵ¼ + if (!User.Identity.IsAuthenticated) + { + // δ¼ض򵽵¼ҳ + return RedirectToPage("/Account/Login", new + { + ReturnUrl = Url.Page("/Account/SelectAccount", new { RedirectUri }), + Prompt = "select_account" + }); + } + + var requestInfo = await ParseOriginalRequestFromRedirectUriAsync(); + if (requestInfo == null) + { + Alerts.Warning(L["InvalidSelectAccountRequest"]); + return Page(); + } + + var application = await ApplicationManager.FindByClientIdAsync(requestInfo.ClientId); + ClientName = await ApplicationManager.GetLocalizedDisplayNameAsync(application) ?? requestInfo.ClientId; + + var currentUser = await UserManager.GetUserAsync(User); + if (currentUser == null) + { + await SignInManager.SignOutAsync(); + return RedirectToPage("/Account/Login", new + { + ReturnUrl = requestInfo.RedirectUri + }); + } + + UserName = currentUser.UserName; + + AvailableAccounts = await DiscoverUserAccountsAsync(currentUser, requestInfo.ClientId); + + if (AvailableAccounts.Count == 0) + { + Alerts.Warning(L["NoAvailableAccounts"]); + } + + // һ˻ʱֱӵ¼ + if (AvailableAccounts.Count == 1) + { + return await HandleLoginAsync(currentUser, Input.RememberMe, Input.RememberSelection); + } + + return Page(); + } + + public async virtual Task OnPostAsync() + { + try + { + ValidateModel(); + + await IdentityOptions.SetAsync(); + + var tenantUser = ParseSelectedAccountId(Input.SelectedAccountId); + if (tenantUser == null) + { + Alerts.Warning(L["InvalidSelectAccount"]); + return Page(); + } + + var user = await ValidateSelectedAccountAsync(tenantUser.UserId, tenantUser.TenantId); + if (user == null) + { + Alerts.Warning(L["InvalidSelectAccount"]); + return Page(); + } + + using (CurrentTenant.Change(tenantUser.TenantId)) + { + return await HandleLoginAsync(user, Input.RememberMe, Input.RememberSelection); + } + } + catch (AbpIdentityResultException e) + { + if (!string.IsNullOrWhiteSpace(e.Message)) + { + Alerts.Warning(GetLocalizeExceptionMessage(e)); + return Page(); + } + + throw; + } + catch (AbpValidationException) + { + return Page(); + } + } + + protected async virtual Task HandleLoginAsync(IdentityUser user, bool rememberMe = false, bool rememberSelection = false) + { + // ǷѾǵǰû + var currentUser = await UserManager.GetUserAsync(User); + if (currentUser == null || currentUser.Id == user.Id) + { + // ʹѡ˻¼ + // TODO: ʵ RememberMe + await SignInManager.SignInAsync(user, rememberMe); + + // TODO: date format + user.SetProperty(LastLoginTimeFieldName, Clock.Now.ToString("yyyy-MM-dd HH:mm:ss")); + + // TODO: ʵ RememberSelection + if (rememberSelection) + { + await SaveAccountSelectionAsync( + Input.ClientId, + user.Id, + user.TenantId); + } + } + + // ضԭʼȨ + return await RedirectSafelyAsync(Input.RedirectUri); + } + + protected virtual Task ParseOriginalRequestFromRedirectUriAsync() + { + if (string.IsNullOrWhiteSpace(RedirectUri)) + { + return Task.FromResult(null); + } + + try + { + var info = new OriginalRequestInfo(); + string queryString = null; + + if (RedirectUri.StartsWith("/")) + { + int queryIndex = RedirectUri.IndexOf('?'); + if (queryIndex >= 0) + { + queryString = RedirectUri.Substring(queryIndex + 1); + } + else + { + // ûвѯֱӽַ + queryString = RedirectUri; + } + } + else if (RedirectUri.Contains("://")) + { + try + { + var uri = new Uri(RedirectUri); + queryString = uri.Query; + if (queryString.StartsWith("?")) + { + queryString = queryString.Substring(1); + } + } + catch (UriFormatException) + { + queryString = RedirectUri; + } + } + else + { + queryString = RedirectUri; + } + + // ѯ + if (!string.IsNullOrWhiteSpace(queryString)) + { + var query = QueryHelpers.ParseQuery(queryString); + + info.ClientId = GetQueryValue(query, "client_id"); + info.RedirectUri = GetQueryValue(query, "redirect_uri"); + info.ResponseType = GetQueryValue(query, "response_type"); + info.Scope = GetQueryValue(query, "scope"); + info.State = GetQueryValue(query, "state"); + info.Nonce = GetQueryValue(query, "nonce"); + info.CodeChallenge = GetQueryValue(query, "code_challenge"); + info.CodeChallengeMethod = GetQueryValue(query, "code_challenge_method"); + info.Prompt = GetQueryValue(query, "prompt"); + } + + return Task.FromResult(info); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "RedirectUri: {message}", ex.Message); + return Task.FromResult(null); + } + } + + protected async virtual Task> DiscoverUserAccountsAsync( + IdentityUser currentUser, + string clientId) + { + var accounts = new List + { + new UserAccountInfo + { + IsCurrentAccount = true, + UserId = currentUser.Id.ToString(), + TenantId = CurrentTenant.Id, + TenantName = CurrentTenant.Name, + UserName = currentUser.UserName, + Email = currentUser.Email, + LastLoginTime = currentUser.GetProperty(LastLoginTimeFieldName), + } + }; + + // ȡͻ⻧ + var allowedTenants = await GetAllowedTenantsForClientAsync(clientId); + + foreach (var tenant in allowedTenants) + { + var tenantUserAccount = await GetTenantUserAccountInfoAsync(currentUser.UserName, tenant); + if (tenantUserAccount != null) + { + accounts.Add(tenantUserAccount); + } + } + + // ¼ʱ򣬵ǰ˻ŵһ + return accounts + .OrderByDescending(a => a.IsCurrentAccount) + .ThenByDescending(a => a.LastLoginTime) + .ToList(); + } + + protected async virtual Task GetTenantUserAccountInfoAsync(string userName, TenantInfo tenant) + { + using (CurrentTenant.Change(tenant.Id, tenant.Name)) + { + var user = await UserManager.FindByNameAsync(userName); + if (user != null) + { + // ûǷЧ + if (user.IsActive && !await UserManager.IsLockedOutAsync(user)) + { + var lastLoginTime = user.GetProperty(LastLoginTimeFieldName); + return new UserAccountInfo + { + IsCurrentAccount = false, + UserId = user.Id.ToString(), + TenantId = tenant.Id, + TenantName = tenant.Name, + UserName = user.UserName, + Email = user.Email, + LastLoginTime = lastLoginTime, + }; + } + } + return null; + } + } + + protected async virtual Task> GetAllowedTenantsForClientAsync(string clientId) + { + var tenants = new List(); + + if (string.IsNullOrWhiteSpace(clientId)) + { + return tenants; + } + + var application = await ApplicationManager.FindByClientIdAsync(clientId); + if (application == null) + { + return tenants; + } + + var properties = await ApplicationManager.GetPropertiesAsync(application); + if (properties.TryGetValue("AllowedTenants", out var allowedTenantsValue)) + { + var tenantIds = allowedTenantsValue.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries); + + foreach (var tenantIdString in tenantIds) + { + if (Guid.TryParse(tenantIdString.Trim(), out var tenantId)) + { + var tenant = await TenantStore.FindAsync(tenantId); + if (tenant != null && tenant.IsActive) + { + tenants.Add(new TenantInfo + { + Id = tenant.Id, + Name = tenant.Name + }); + } + } + } + } + + return tenants; + } + + protected virtual TenantUser ParseSelectedAccountId(string selectedAccountId) + { + // ˻ʽΪ: UserId@TenantId + if (string.IsNullOrWhiteSpace(selectedAccountId)) + { + return new TenantUser(); + } + + var parts = selectedAccountId.Split('@', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 1) + { + return new TenantUser(); + } + + if (Guid.TryParse(parts[0], out var userId)) + { + if (parts.Length > 1 && + Guid.TryParse(parts[1], out var tenantId)) + { + return new TenantUser(tenantId, userId); + } + return new TenantUser(null, userId); + } + + return new TenantUser(); + } + + protected virtual string GetQueryValue(Dictionary query, string key) + { + return query.TryGetValue(key, out var value) ? value.ToString() : null; + } + + protected async virtual Task ValidateSelectedAccountAsync(Guid userId, Guid? tenantId) + { + using (CurrentTenant.Change(tenantId)) + { + var user = await UserManager.FindByIdAsync(userId.ToString()); + if (user == null) + { + return null; + } + + if (!user.IsActive) + { + return null; + } + + if (await UserManager.IsLockedOutAsync(user)) + { + return null; + } + + return user; + } + } + + protected virtual Task SaveAccountSelectionAsync(string clientId, Guid userId, Guid? tenantId) + { + // TODO: ûǰѡ˻, ´ѡ˻ʱĬѡ˻ + return Task.CompletedTask; + } + + public class OriginalRequestInfo + { + public string ClientId { get; set; } + public string RedirectUri { get; set; } + public string Scope { get; set; } + public string State { get; set; } + public string Nonce { get; set; } + public string ResponseType { get; set; } + public string CodeChallenge { get; set; } + public string CodeChallengeMethod { get; set; } + public string Prompt { get; set; } + } + + public class SelectAccountInput + { + [Required] + public string SelectedAccountId { get; set; } + + [Required] + public string ClientId { get; set; } + + [Required] + public string RedirectUri { get; set; } + + public bool RememberSelection { get; set; } = true; + + public bool RememberMe { get; set; } = true; + } + + public class UserAccountInfo + { + public string UserId { get; set; } + public Guid? TenantId { get; set; } + public string TenantName { get; set; } + public string UserName { get; set; } + public string Email { get; set; } + public DateTime? LastLoginTime { get; set; } + public bool IsCurrentAccount { get; set; } + } + + public class TenantInfo + { + public Guid Id { get; set; } + public string Name { get; set; } + } + + public class TenantUser + { + public Guid? TenantId { get; set; } + public Guid UserId { get; set; } + public TenantUser() + { + + } + public TenantUser(Guid? tenantId, Guid userId) + { + TenantId = tenantId; + UserId = userId; + } + } +} diff --git a/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/Pages/Account/SelectAccount.js b/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/Pages/Account/SelectAccount.js new file mode 100644 index 000000000..e4a5abf15 --- /dev/null +++ b/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/Pages/Account/SelectAccount.js @@ -0,0 +1,43 @@ +$(function () { + var l = abp.localization.getResource('AbpOpenIddict'); + + // 账户选择交互 - 使用 Bootstrap 的 list-group-item-action + $('.account-item').on('click', function (e) { + // 如果点击的是链接或按钮,不处理 + if ($(e.target).is('a, button, .btn') || + $(e.target).closest('a, button, .btn').length) { + return; + } + + // 移除所有 active 状态 + $('.account-item').removeClass('active'); + + // 添加当前选中状态 + $(this).addClass('active'); + + // 选中对应的 radio button + var radio = $(this).find('input[type="radio"]'); + radio.prop('checked', true); + }); + + // 表单提交验证 + $('#selectAccountForm').on('submit', function (e) { + var selectedAccount = $('input[name="Input.SelectedAccountId"]:checked').val(); + + if (!selectedAccount) { + e.preventDefault(); + abp.message.warn(l('SelectedAccountId')); + return false; + } + + return true; + }); + + // 初始选中第一个账户(如果不是当前账户) + if (!$('.account-item.active').length) { + $('.account-item:first').trigger('click'); + } + + // 自动聚焦到选中的账户 + $('.account-item.active').find('input[type="radio"]').focus(); +}); diff --git a/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/wwwroot/css/select-account.css b/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/wwwroot/css/select-account.css new file mode 100644 index 000000000..19a07aaf1 --- /dev/null +++ b/aspnet-core/modules/account/LINGYUN.Abp.Account.Web.OpenIddict/wwwroot/css/select-account.css @@ -0,0 +1,29 @@ +/* */ +.cursor-pointer { + cursor: pointer; +} + +/* ˻бͣЧǿ */ +.account-item:hover { + background-color: var(--bs-list-group-action-hover-bg); + transform: translateY(-1px); + transition: all 0.2s ease; +} + +/* ǰ˻ʽ */ +.account-item.active { + background-color: var(--bs-primary-bg-subtle); + border-left: 3px solid var(--bs-primary); +} + +/* Ӧʽ */ +@media (max-width: 576px) { + .min-vh-100 { + min-height: auto; + padding: 1rem 0; + } + + .card-body { + padding: 1rem !important; + } +}