我正试图在Blazor WASM中重新创建一个最初在Angular SPA中开发的登录场景,其中我使用HttpIntercepter捕获任何401响应,弹出一个重定向到我们的ADFS登录的登录窗口,然后关闭并返回登录信息,并重试失败的(401(请求。以下是它在Angular中的样子:
Angular LoginInterceptor
export class LoginInterceptor implements HttpInterceptor {
constructor(private loginService: LoginService) { }
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(req).pipe(
catchError((errorResponse: HttpErrorResponse) => {
switch (errorResponse.status) {
case 401:
{
console.log("Unauthorized");
// call the LoginService's openLoginWindow and wait for it to complete
return this.loginService.openLoginWindow().pipe(
mergeMap((result) => {
if (result) {
// retry the request again
return next.handle(req);
}
})
);
}
default:
break;
}
throw errorResponse;
})
) as Observable<HttpEvent<any>>;
}
}
Angular登录服务
export class LoginService {
loginWindow: Window;
userName: BehaviorSubject<string> = new BehaviorSubject(null);
private windowsMessageObservable: Observable<MessageEvent>;
constructor() {
// Handle the Window.OnMessage event which listens for a successful login message in the new window
this.windowsMessageObservable = fromEvent<MessageEvent>(window, 'message');
}
openLoginWindow() {
// Open the new window
this.loginWindow = window.open("/SSOSignIn", 'loginWindow');
// Return an observable that fires when the login message is received
const signInObservable = new Observable<boolean>(obs => {
this.windowsMessageObservable.subscribe(evt => {
if (evt.origin === location.origin) {
if (evt.data?.type === 'signIn') {
this.userName.next(evt.data.name);
obs.next(true)
}
}
});
});
return signInObservable;
}
}
这在Angular中非常有效。当页面加载或登录过期时,对数据的请求会以401失败,被拦截,弹出登录窗口,在SSO完成后自动关闭,请求无缝重试,而无需重新加载或点击按钮,但在Blazor/C#中,我似乎无法思考如何重试原始请求,因为我们不处理可观察的内容。
在Blazor/C#中,据我所知,HttpInterceptors
的概念是使用DelegatingHandlers
实现的。我已经创建了一个处理程序,它会弹出登录窗口并登录,但我不知道有什么好方法可以推迟重试并返回响应,直到登录完成。这是我的管理员:
namespace BlazorPlayground.Client.Handlers
{
public class UnauthorizedMessageHandler : DelegatingHandler, IDisposable
{
public UnauthorizedMessageHandler(IJSRuntime iJSRuntime)
{
JS = iJSRuntime;
}
private IJSRuntime JS { get; set; }
protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = await base.SendAsync(request, cancellationToken);
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
// This opens a new window but the operation continues immediately after.
// Need to somehow wait for login to complete here so I can retry request
await JS.InvokeVoidAsync("openUrl", "/SSOSignIn", "_blank");
}
return response;
}
}
}
有什么想法吗?基本上,我需要这个SendAsync代码来等待JSWindow.Message事件,然后才能完成并返回响应。
好的,我找到了一个可行的解决方案。我提出的基本概念是:创建一个可等待的任务,该任务在从JS调用函数时完成。这背后的关键是使用TaskCompletionSource<>
,它允许您在任何地方等待标记完成。在我的例子中,当我的Window.Message事件处理程序调用我的C#ReceiveMessage
方法时,我正在等待TaskCompletionSource<>
完成。这是我的拦截器:
public class UnauthorizedMessageHandler : DelegatingHandler, IDisposable
{
private DotNetObjectReference<UnauthorizedMessageHandler> objRef;
private TaskCompletionSource<string> tcs;
public UnauthorizedMessageHandler(IJSRuntime iJSRuntime)
{
JS = iJSRuntime;
}
private IJSRuntime JS { get; set; }
protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = await base.SendAsync(request, cancellationToken);
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
// Create a reference to this object so ReceiveMessage can be invoked from JS
objRef = DotNetObjectReference.Create(this);
// This allows us to wait for another method to complete before we continue
tcs = new TaskCompletionSource<string>();
// Open up the sign-in page
await JS.InvokeVoidAsync("openUrl", "/SSOSignIn", "_blank", objRef);
// Wait until ReceiveMessage is fired off
var message = await tcs.Task;
// Retry the original request
response = await base.SendAsync(request, cancellationToken);
}
return response;
}
[JSInvokable]
public void ReceiveMessage(string message)
{
// Get the message from JS and return it to the awaitable task
tcs.TrySetResult(message);
}
}
这是我的javascript
var windowMessageObjRef = null;
window.addEventListener('message', (evt) => {
// Make sure the message came from us
// Need to add checks to make sure it's got the data we expect
if (evt.origin === location.origin) {
// Check to make sure we have a reference to our DotNet interop object
if (windowMessageObjRef) {
// Send the name of the person who logged in
console.log('Invoking ReceiveMessage with data ' + evt.data.name);
windowMessageObjRef.invokeMethodAsync('ReceiveMessage', evt.data.name);
}
}
});
function openUrl(url, target, objRef) {
if (objRef) {
windowMessageObjRef = objRef;
}
console.log("Opening " + url + " with target " + target);
window.open(url, target);
}
由于这是一个SPA应用程序,我不想离开原始页面,所以我的SSOSignIn页面会在一个新的选项卡/窗口中弹出,它只会触发重定向到ADFS的登录挑战,并将我们返回到SSOComplete页面:
public class SSOSignInModel : PageModel
{
public ChallengeResult OnGet()
{
return Challenge(new AuthenticationProperties
{
RedirectUri = "/SSOComplete"
});
}
}
SSOComplete页面将带有登录用户名称的消息发布到opener(SPA(窗口,然后关闭自己。
<html>
<head>
<title>Redirecting to sign-in...</title>
</head>
<body>
<script type="text/javascript">
(function () {
var message = {
type: 'signIn',
success: true,
name: '@User.Identity.Name'
};
window.opener.postMessage(message, location.origin);
window.close();
})();
</script>
</body>
</html>
现在,我可以在Blazor中自动弹出登录窗口,并在Blazor中登录完成后重试原始请求,而无需重新加载我的SPA。我现在要去打个盹。