在前面的 IdentityServer实现单点登录及API访问控制 这篇文章中,跟大家分享了如何借助IdentityServer4框架实现单点登录功能,在这篇文章中我们来介绍如何实现单点登出功能。
单点登出意味着接入IdentityServer认证授权服务器登录功能的各个第三方客户端应用,只要其中有一个应用退出登录,那么其他的第三方客户端应用也跟着一起退出,同时还要让IdentityServer认证服务器跟着退出,本质上,不管是第三方客户端应用,还是Identity Server认证授权服务器,登出操作都只是删除自己站点下相应的认证Cookie来实现退出登录的目的,单点登出不同的地方在于在任意的第三方客户端应用上登出,都需要通过IdentityServer认证服务器去通知其他各个第三方应用去自动登出。
实现单点登出功能,主要有如下两种方式,
- 通过任意第三方客户端应用来实现单点登出
- 通过IdentityServer认证授权服务器实现单点登出。
下面我们将分别来介绍如何通过这两种方式来实现单点登出,并大致介绍其工作原理。
一、通过第三方客户端应用实现单点登出
在前一篇文章 IdentityServer实现单点登录及API访问控制 我们的客户端应用MvcClient 借助了 Microsoft.AspNetCore.Authentication.OpenIdConnect 这个 Nuget包来实现客户端应用来登录认证功能,那么这个时候要实现单点登出,则可以在MvcClient -> HomeController中加如以下Action来实现单点登出
public IActionResult Logout()
{
return SignOut("Cookies", "oidc");
}
SignOut为ControllerBase中的一个基类方法,本质上最终还是调用的是IAuthenticationService.SignOutAsync方法,IAuthenticationService的默认实现类为:AuthenticationService
其中Cookies和oidc为我们注册的两个认证方案名称,上面这个方法执行的流程大概如下:
- 请求客户端应用的登出页面 /Home/Logout,这个页面会删除本地登录认证Cookie,同时删除oidc登录认证cookie,
- 上个步骤执行完后,会自动跳转到IdentityServer认证服务器的/connect/endssion这个地址。
- /connect/endsession这个页面带上了一些发出登出请求的客户端应用的一些信息,然后重新跳转到IdentityServer服务器的登出页面/Account/Logout
- /Account/Logout页面获取到logoutId后,直接删除IdentityServer服务器的登录认证Cookie,并跳转到/connect/endsession/callback页面。
- /connect/endsession/callback 页面中生成一个或多个iframe,去通知已经登录的客户端应用退出登录。
我们点击客户端应用的登出按钮后,请求Home/Logout页面,然后该页面设置本地登录认证Cookie的过期时间为1970年,这样本地登录认证Cookie就会被浏览器自动清除,
并响应 302 跳转到 IdentityServer认证服务器的 /connect/endsession 地址
浏览器跳转到 IdentityServer认证服务器 /connect/endsession页面后再次重定向到IdentityServer认证服务器的/Account/Logout 页面,这个页面响应的时候清除Identity Server 认证授权服务器的本地登录信息Cookie,并且页面中带有一个iframe,地址链接到IdentityServer认证授权服务器的/connect/endsession/callback 这个页面,这个页面响应后,页面中又会带一个iframe,地址为客户端应用的 /signout-oidc页面,这个页面用于通知客户端应用进行再次登出,这个时候,signout-oidc页面响应的时候会再次清空客户端应用的本地登录cookie。
二、通过IdentityServer认证授权服务器单点登出
通过IdentityServer认证服务器试下你单点登出的情景其实和客户端发起单点登出请求流程其实大部分是一致的,只不过是客户端这次不是自己主动删除自己本站的登录认证Cookie,而是通过IdentityServer认证授权服务器单点登出后,统一通知到客户端应用的指定地址来实现删除客户端应用的登出信息。
认证服务器单点登出流程类似于如下:
- Post提交跳转到/Account/LogOut页面,一般需要用户确认登出的情况下,才会用POST提交方式。
- /Account/Logout页面中包含一个iframe,这个iframe地址指向IdentityServer服务器的 /connect/endsession/callback 地址。
- /connect/endsession/callback 页面中包含一个或者多个iframe,分别指向各个客户端应用接收登出通知的页面地址,客户端应用如果接入了如:Microsoft.AspNetCore.Authentication.OpenIdConnect 这类Nuget包,那么会拦截这个接收登出通知这个页面地址,直接删除客户端应用的登录认证Cookie信息,来实现客户端应用的登出。
下面看看IdentityServer认证服务器上的关于登出功能的代码
/// <summary>
/// Show logout page
/// 不管是客户端应用发起的登出,还是IdentityServer认证服务器自己本地登出,
/// 都会先跳转到这里。
/// </summary>
[HttpGet]
public async Task<IActionResult> Logout(string logoutId)
{
// build a model so the logout page knows what to display
var vm = await BuildLogoutViewModelAsync(logoutId);
//根据请求上下文,如果发现不需要提示用户登出,则直接调用Post提交的那个Action进行直接登出。
if (vm.ShowLogoutPrompt == false)
{
// if the request for logout was properly authenticated from IdentityServer, then
// we don't need to show the prompt and can just log the user out directly.
return await Logout(vm);
}
return View(vm);
}
/// <summary>
/// 这里主要是用于判断当前登出请求是否需要让用户确认登出。
/// </summary>
/// <param name="logoutId"></param>
/// <returns></returns>
private async Task<LogoutViewModel> BuildLogoutViewModelAsync(string logoutId)
{
var vm = new LogoutViewModel { LogoutId = logoutId, ShowLogoutPrompt = AccountOptions.ShowLogoutPrompt };
if (User?.Identity.IsAuthenticated != true)
{
// if the user is not authenticated, then just show logged out page
vm.ShowLogoutPrompt = false;
return vm;
}
var context = await _interaction.GetLogoutContextAsync(logoutId);
if (context?.ShowSignoutPrompt == false)
{
// it's safe to automatically sign-out
vm.ShowLogoutPrompt = false;
return vm;
}
// show the logout prompt. this prevents attacks where the user
// is automatically signed out by another malicious web page.
return vm;
}
/// <summary>
/// Handle logout page postback
/// 这个action是用户在点击了确认登出后触发的。
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Logout(LogoutInputModel model)
{
// build a model so the logged out page knows what to display
var vm = await BuildLoggedOutViewModelAsync(model.LogoutId);
if (User?.Identity.IsAuthenticated == true)
{
// delete local authentication cookie
//这里删除IdentityServer认证服务器本地的登录认证Cookie,并跳转到/Account/Logout页面
//变成登出状态,同时通知已经登录的客户端应用退出登录(通过响应的页面中带一个iframe,并且指向IdentityServer认证授权服务器中的
// /connect/endsession/callback页面,这个页面又会根据当前已经登录的客户端应用在页面中生成多个iframe,分别指向各个客户端应用接收登出通知的地址,来通知各个
//客户端应用进行登出。
await HttpContext.SignOutAsync();
// raise the logout event
await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName()));
}
// check if we need to trigger sign-out at an upstream identity provider
if (vm.TriggerExternalSignout)
{
// build a return URL so the upstream provider will redirect back
// to us after the user has logged out. this allows us to then
// complete our single sign-out processing.
string url = Url.Action("Logout", new { logoutId = vm.LogoutId });
// this triggers a redirect to the external provider for sign-out
return SignOut(new AuthenticationProperties { RedirectUri = url }, vm.ExternalAuthenticationScheme);
}
//这里跳转到登出成功的提示页面,并且页面中带一个iframe,指向 vm.SignOutIframeUrl中的页面地址(其实就是:/connect/endsession/callback),去通知各个已通过IdentityServer认证授权服务器登录的客户端应用登出
//注意:这个地址并不是指向客户端应用接收登出通知的地址,而是指向IdentityServer认证服务器的页面/connect/endsession/callback,这个页面获取到当前所有已经登录的
//客户端应用对其进行分别通知登出(vm.SignOutIframeUrl 指向的页面中生成一个或者多个iframe元素,
//并且指向各个客户端应用接收登出通知的页面地址,
//如:https://localhost:5002/signout-oidc,
//这个地址通过 FrontChannelLogoutUri 属性指定。
return View("LoggedOut", vm);
}
private async Task<LoggedOutViewModel> BuildLoggedOutViewModelAsync(string logoutId)
{
// get context information (client name, post logout redirect URI and iframe for federated signout)
//GetLogoutContextAsync 如果当前有客户端应用通过IdentityServer认证授权服务器登录,那么,logout?.SignOutIFrameUrl不为空,表示要通知
//客户端应用登出,如果是客户端应用发起的登出请求,那么logoutId不为空,则还能获取到ClientName信息,以及PostLogoutRedirectUri 地址,改地址
//用于单点登出成功后,要跳转回到的客户端应用地址。
// _interaction 为 IIdentityServerInteractionService类型,这个服务可以重点了解下,实际开发中。
var logout = await _interaction.GetLogoutContextAsync(logoutId);
var vm = new LoggedOutViewModel
{
//这个选项用于配置如果是客户端应用发起的登出请求,是否需要自动跳转到PostLogoutRedirectUri所对应的客户端页面。
AutomaticRedirectAfterSignOut = AccountOptions.AutomaticRedirectAfterSignOut,
PostLogoutRedirectUri = logout?.PostLogoutRedirectUri,
ClientName = string.IsNullOrEmpty(logout?.ClientName) ? logout?.ClientId : logout?.ClientName,
SignOutIframeUrl = logout?.SignOutIFrameUrl,
LogoutId = logoutId
};
if (User?.Identity.IsAuthenticated == true)
{
var idp = User.FindFirst(JwtClaimTypes.IdentityProvider)?.Value;
if (idp != null && idp != IdentityServer4.IdentityServerConstants.LocalIdentityProvider)
{
var providerSupportsSignout = await HttpContext.GetSchemeSupportsSignOutAsync(idp);
if (providerSupportsSignout)
{
if (vm.LogoutId == null)
{
// if there's no current logout context, we need to create one
// this captures necessary info from the current logged in user
// before we signout and redirect away to the external IdP for signout
vm.LogoutId = await _interaction.CreateLogoutContextAsync();
}
vm.ExternalAuthenticationScheme = idp;
}
}
}
return vm;
}
/Account/LoggedOut页面如下:
@model LoggedOutViewModel
@{
// set this so the layout rendering sees an anonymous user
ViewData["signed-out"] = true;
}
<div class="logged-out-page">
<h1>
Logout
<small>You are now logged out</small>
</h1>
@if (Model.PostLogoutRedirectUri != null)
{
<div>
Click <a class="PostLogoutRedirectUri" href="@Model.PostLogoutRedirectUri">here</a> to return to the
<span>@Model.ClientName</span> application.
</div>
}
@if (Model.SignOutIframeUrl != null)
{
<iframe width="0" height="0" class="signout" src="@Model.SignOutIframeUrl"></iframe>
}
</div>
@section scripts
{
@*这里根据配置,如果需要登出成功后需要自动跳转到客户端应用则跳转到客户端应用指定页面(PostLogoutRedirectUri属性指定的页面)。*@
@if (Model.AutomaticRedirectAfterSignOut)
{
<script src="~/js/signout-redirect.js"></script>
}
}
上面这种通过IdentityServer认证服务器发起统一登出的场景也叫做 FrontChannelLogout ,也就是通过前端登出,通过这种登出方式登出,客户端应用如果也要跟着一起登出,必须在客户端应用配置中通过 FrontChannelLogoutUri 属性指定要接收登出通知的地址,并在该地址对应的页面中处理客户端应用的本地登出的逻辑。
通过IdentityServer统一登出还有另一外一种叫:BackChannelLogout,也就是IdentityServer服务器通过调用客户端应用中指定的Api接口来通知客户端应用登出,这个API接口地址是通过客户端配置中的 BackChannelLogout 属性来指定的,这种方式和FrontChannelLogout的最大不同就是,FrontChannelLogout是通过浏览器页面跳转的形式去通知客户端应用登出,而BackChannelLogout则是通过接口调用方式来通知客户端登出,一般FrontChannelLogout用于退出客户端应用是通过Cookie进行认证的系统,而BackChannelLogout则一般用于退出认证信息存储在服务端中的情况,BackChannelLogout本质上是IdentityServer认证服务器通过HttpClient调用客户端应用的指定接口来实现单点登出通知。