本教程會(huì)介紹如何在前端JS程序中集成IdentityServer。因?yàn)樗械奶幚矶荚谇岸?,我們?huì)使用一個(gè)JS庫(kù)oidc-client-js, 來(lái)處理諸如獲取,驗(yàn)證tokens的工作。
本教程的代碼在這里.
本教程分為三大塊:
- 在前端JS程序中使用
IdentityServer進(jìn)行認(rèn)證 - 在前端JS中調(diào)用API
- 僚機(jī)如何在前端更新令牌,登出和檢查會(huì)話
第一部分 - 在前端JS程序中使用IdentityServer進(jìn)行認(rèn)證
第一部分,我們專注在如何前端認(rèn)證。我們準(zhǔn)備了兩個(gè)項(xiàng)目,一個(gè)是JS前端程序,一個(gè)是IdentityServer.
創(chuàng)建JS前端程序
在Visual Studio中創(chuàng)建一個(gè)空Web應(yīng)用。

注意項(xiàng)目的URL,后面需要在瀏覽器中使用:

創(chuàng)建IdentityServer 項(xiàng)目
在Visual Studio中創(chuàng)建另外一個(gè)空Web應(yīng)用程序來(lái)托管IdentityServer.

切換到項(xiàng)目屬性,啟用SSL:

提醒
不要忘了把Web程序的啟動(dòng)URL改成https的鏈接(具體鏈接參看你項(xiàng)目的SSL URL).
譯者注: identityserver3不支持http的網(wǎng)站,必須有SSL保護(hù)
增加IdentityServer
IdentityServer is based on OWIN/Katana and distributed as a NuGet package. To add it to the newly created web host, install the following two packages:
IdentityServer是一個(gè)OWIN/Katana的中間件,通過(guò)Nuget分發(fā)。運(yùn)行下面的命令安裝nuget包到IdentityServer托管程序。
Install-Package Microsoft.Owin.Host.SystemWeb
Install-Package IdentityServer3
配置IdentityServer的客戶端
IdentityServer需要知道客戶端的一些信息,可以通過(guò)返回Client對(duì)象集合告訴IdentityServer.
public static class Clients
{
public static IEnumerable<Client> Get()
{
return new[]
{
new Client
{
Enabled = true,
ClientName = "JS Client",
ClientId = "js",
Flow = Flows.Implicit,
RedirectUris = new List<string>
{
"http://localhost:56668/popup.html" //請(qǐng)檢查端口號(hào),確保和你剛才創(chuàng)建的JS項(xiàng)目一樣
},
AllowedCorsOrigins = new List<string>
{
"http://localhost:56668"
},
AllowAccessToAllScopes = true
}
};
}
}
特別注意AllowedCorsOrigins屬性,上面代碼的設(shè)置,讓IdentityServer接受這個(gè)指定網(wǎng)站的認(rèn)證請(qǐng)求。
譯者注: 考慮到安全性, 網(wǎng)站一般不接受不同域的請(qǐng)求,這里是設(shè)置可以接受指定的跨域請(qǐng)求
popup.html會(huì)在后面詳細(xì)講解,這里你照樣填就好了.
備注 現(xiàn)在這個(gè)客戶端可以接受任何作用域(AllowAccessToAllScopes設(shè)置為true).在生產(chǎn)環(huán)境,必須通過(guò)AllowScopes來(lái)限制作用域范圍。
配置IdentityServer - 用戶
接下來(lái),我們?cè)贗dentityServer里硬編碼一些用戶--同樣的,這個(gè)可以通過(guò)一個(gè)簡(jiǎn)單的C#類來(lái)實(shí)現(xiàn)。生產(chǎn)環(huán)境中,我們應(yīng)該從數(shù)據(jù)庫(kù)里獲取用戶信息。 IdentityServer也直接支持ASP.NET 的Identity和MembershipReboot.
public static class Users
{
public static List<InMemoryUser> Get()
{
return new List<InMemoryUser>
{
new InMemoryUser
{
Username = "bob",
Password = "secret",
Subject = "1",
Claims = new[]
{
new Claim(Constants.ClaimTypes.GivenName, "Bob"),
new Claim(Constants.ClaimTypes.FamilyName, "Smith"),
new Claim(Constants.ClaimTypes.Email, "bob.smith@email.com")
}
}
};
}
}
配置IdentityServer - 作用域
最后,我們加上作用域。 純粹認(rèn)證功能,我們只需要支持標(biāo)準(zhǔn)的OIDC作用域。將來(lái)我們授權(quán)API調(diào)用,我們會(huì)創(chuàng)建我們自己的作用域。
public static class Scopes
{
public static List<Scope> Get()
{
return new List<Scope>
{
StandardScopes.OpenId,
StandardScopes.Profile
};
}
}
添加Startup
IdentityServer是一個(gè)OWIN中間件,需要在Startup類中配置。這個(gè)教程中,我們會(huì)配置客戶端,用戶,作用域,認(rèn)證證書(shū)和一些配置選項(xiàng)。
在生產(chǎn)環(huán)境需要從windows證書(shū)倉(cāng)庫(kù)或者其它安全的地方裝載證書(shū)。簡(jiǎn)化起見(jiàn),這個(gè)教程我們把證書(shū)文件直接保存在項(xiàng)目中。(演示用的證書(shū)可以從 這里下載.下載后直接添加到項(xiàng)目中,并把文件的Copy to Output Directory property 改為 Copy always).
關(guān)于如何從Azure中裝載證書(shū),請(qǐng)看 這里.
public class Startup
{
public void Configuration(IAppBuilder app)
{
app.UseIdentityServer(new IdentityServerOptions
{
SiteName = "Embedded IdentityServer",
SigningCertificate = LoadCertificate(),
Factory = new IdentityServerServiceFactory()
.UseInMemoryUsers(Users.Get())
.UseInMemoryClients(Clients.Get())
.UseInMemoryScopes(Scopes.Get())
});
}
private static X509Certificate2 LoadCertificate()
{
return new X509Certificate2(
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"bin\Config\idsrv3test.pfx"), "idsrv3test");
}
}
完成上面的步驟后,一個(gè)全功能的IdentityServer就好了,你可以瀏覽探索端點(diǎn)來(lái)了解相信配置信息。

RAMMFAR(Run All Managed Modules For All Requests )
最后不要忘了在Web.config中添加RAMMFAR支持,否則有一些內(nèi)嵌的資源無(wú)法被IIS裝載:
<system.webServer>
<modules runAllManagedModulesForAllRequests="true" />
</system.webServer>
JS 客戶端- 設(shè)置
我們使用下面的第三方庫(kù)來(lái)簡(jiǎn)化我們的JS客戶端開(kāi)發(fā):
我們通過(guò)npm-- the Node.js 前段包管理器--來(lái)安裝這些前端庫(kù). 如果你還沒(méi)有安裝npm, 你可以按照 npm安裝說(shuō)明來(lái)安裝npm.
npm安裝好了后,打開(kāi)命令行(CMD),轉(zhuǎn)到JSApplication目錄下,運(yùn)行:
$ npm install jquery
$ npm install bootstrap
$ npm install oidc-client
npm會(huì)把上述包按照到默認(rèn)目錄node_modules.
重要npm包一般不會(huì)提交到源碼倉(cāng)庫(kù),如果你是從github倉(cāng)庫(kù)中克隆代碼, 你需要在命令行(cmd)下,轉(zhuǎn)到JSApplication目錄,然后運(yùn)行npm install來(lái)恢復(fù)這幾個(gè)前端包。
在JSApplication項(xiàng)目,加入一個(gè)基礎(chǔ)的Index.html文件:
<!DOCTYPE html>
<html>
<head>
<title>JS Application</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="node_modules/bootstrap/dist/css/bootstrap.css" />
<style>
.main-container {
padding-top: 70px;
}
pre:empty {
display: none;
}
</style>
</head>
<body>
<nav class="navbar navbar-inverse navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<a class="navbar-brand" href="#">JS Application</a>
</div>
</div>
</nav>
<div class="container main-container">
<div class="row">
<div class="col-xs-12">
<ul class="list-inline list-unstyled requests">
<li><a href="index.html" class="btn btn-primary">Home</a></li>
<li><button type="button" class="btn btn-default js-login">Login</button></li>
</ul>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<div class="panel panel-default">
<div class="panel-heading">ID Token Contents</div>
<div class="panel-body">
<pre class="js-id-token"></pre>
</div>
</div>
</div>
</div>
</div>
<script src="node_modules/jquery/dist/jquery.js"></script>
<script src="node_modules/bootstrap/dist/js/bootstrap.js"></script>
<script src="node_modules/oidc-client/dist/oidc-client.js"></script>
</body>
</html>
和 popup.html 文件:
<!DOCTYPE html>
<html>
<head>
<title></title>
<meta charset="utf-8" />
</head>
<body>
<script src="node_modules/oidc-client/dist/oidc-client.js"></script>
</body>
</html>
因?yàn)?code>oidc-client可以打開(kāi)一個(gè)彈出窗口讓用戶登錄,所以我們做了一個(gè)popup頁(yè)面
JS 客戶端 - 認(rèn)證
好了,現(xiàn)在零件已經(jīng)組裝好了,我們需要加一點(diǎn)邏輯代碼讓它動(dòng)起來(lái). 感謝UserManager JS類,它做了大部分骯臟的工作,我們只要一點(diǎn)簡(jiǎn)單代碼就好。
// helper function to show data to the user
function display(selector, data) {
if (data && typeof data === 'string') {
data = JSON.parse(data);
}
if (data) {
data = JSON.stringify(data, null, 2);
}
$(selector).text(data);
}
var settings = {
authority: 'https://localhost:44300',
client_id: 'js',
popup_redirect_uri: 'http://localhost:56668/popup.html',
response_type: 'id_token',
scope: 'openid profile',
filterProtocolClaims: true
};
var manager = new Oidc.UserManager(settings);
var user;
manager.events.addUserLoaded(function (loadedUser) {
user = loadedUser;
display('.js-user', user);
});
$('.js-login').on('click', function () {
manager
.signinPopup()
.catch(function (error) {
console.error('error while logging in through the popup', error);
});
});
簡(jiǎn)單了解一下這些配置項(xiàng):
-
authority是IdentityServer的入口URL. 通過(guò)這個(gè)URL,oidc-client可以查詢?nèi)绾闻c這個(gè)IdentityServer通信, 并驗(yàn)證token的有效性。 -
client_id這是客戶端標(biāo)識(shí),認(rèn)證服務(wù)器用這個(gè)標(biāo)識(shí)來(lái)區(qū)別不同的客戶端。 -
popup_redirect_uri是使用signinPopup方法是的重定向URL。如果你不想用彈出框來(lái)登陸,希望用戶能到主登錄界面登陸,那么你需要使用redirect_uri屬性和signinRedirect方法。 -
response_type定義響應(yīng)類型,在我們的例子中,我們只需要服務(wù)器返回身份令牌 -
scope定義了我們要求的作用域 -
filterProtocolClaims告訴oidc-client過(guò)濾掉OIDC協(xié)議內(nèi)部用的聲明信息,如:nonce,at_hash,iat,nbf,exp,aud,iss和idp
我們監(jiān)聽(tīng)處理Login按鈕的單擊事件,當(dāng)用戶單擊登陸的時(shí)候,打開(kāi)登陸彈出框(signinPopup). signinPopup返回一個(gè)Promise。只有收到用戶信息并驗(yàn)證通過(guò)后才會(huì)標(biāo)記成功。
有兩種方式得到identityServer返回的數(shù)據(jù):
- 從Promise 的成功(done)處理函數(shù)得到
- 從
userLoaded事件的參數(shù)得到
這個(gè)例子中,我們通過(guò)events.addUserLoaded·掛載了userLoaded事件處理函數(shù),把用戶信息保存到全局的user對(duì)象中。這個(gè)對(duì)象有:id_token,scope和profile`等屬性, 這些屬性包含各種用戶具體的數(shù)據(jù)。
popup.html頁(yè)面也需要配置下:
new Oidc.UserManager().signinPopupCallback();
登陸內(nèi)部過(guò)程:在index.html頁(yè)面的UserManager實(shí)例會(huì)打開(kāi)一個(gè)彈出框,然后把它重定向到登陸頁(yè)面。當(dāng)identityServer認(rèn)證好用戶,把用戶信息發(fā)回到彈出框,彈出框發(fā)現(xiàn)登陸已經(jīng)成功后自動(dòng)關(guān)閉。
代碼抄到這里,登陸可以工作啦:
你可以把filterProtocolClaims 屬性設(shè)置為false,看看profile下面會(huì)多出那些聲明?
JS 應(yīng)用 - 作用域
我們定義了一個(gè)email聲明,但是它好像沒(méi)有在我們的身份令牌里面?這是因?yàn)槲覀兊腏S應(yīng)用只要了openid和profile作用域,沒(méi)有包括email聲明。
如果JS應(yīng)用想拿到郵件地址,JS應(yīng)用必須在UserManager的scopes屬性中申請(qǐng)獲取email作用域.
在我們的例子中,我們首先需要修改IdentityServer包含Email作用域,代碼如下:
public static class Scopes
{
public static List<Scope> Get()
{
return new List<Scope>
{
StandardScopes.OpenId,
StandardScopes.Profile,
// New scope
StandardScopes.Email
};
}
}
在這個(gè)教程中,JS應(yīng)用不需要改,因?yàn)槲覀兩暾?qǐng)了所有的作用域。 但是在生產(chǎn)環(huán)境中,我們應(yīng)該只返回用戶需要的作用域,這種情況下,客戶端代碼也需要修改。
完成上面的改動(dòng)后,我們現(xiàn)在可以看到email信息啦:
第二部分 - 調(diào)用API
第二部分,我們演示如何從JS應(yīng)用中調(diào)用受保護(hù)的API。
為了調(diào)用被保護(hù)的API,除了身份令牌,我們還要從IdentityServer得到訪問(wèn)令牌,并用這個(gè)訪問(wèn)令牌調(diào)用被保護(hù)的API。
創(chuàng)建一個(gè)API項(xiàng)目
在Visual Studio中創(chuàng)建一個(gè)空應(yīng)用程序.

API項(xiàng)目的URL需要指定為 http://localhost:60136.
配置API
在本教程,我們將創(chuàng)建一個(gè)非常簡(jiǎn)單的API
首先安裝下面的nuget包:
Install-Package Microsoft.Owin.Host.SystemWeb -ProjectName Api
Install-Package Microsoft.Owin.Cors -ProjectName Api
Install-Package Microsoft.AspNet.WebApi.Owin -ProjectName Api
Install-Package IdentityServer3.AccessTokenValidation -ProjectName Api
注意 IdentityServer3.AccessTokenValidation 包間接依賴于System.IdentityModel.Tokens.Jwt.在編寫本教程時(shí),如果更新System.IdentityModel.Tokens.Jwt 到5.0.0會(huì)導(dǎo)致API項(xiàng)目無(wú)法啟動(dòng):
譯者注:在我翻譯的時(shí)候好像已經(jīng)解決這個(gè)問(wèn)題了
解決辦法是把System.IdentityModel.Tokens.Jwt降級(jí)到4.0.2.xxx版本:
Install-Package System.IdentityModel.Tokens.Jwt -ProjectName Api -Version 4.0.2.206221351
現(xiàn)在讓我們創(chuàng)建Startup類,并構(gòu)建OWIN/Katana管道。
public class Startup
{
public void Configuration(IAppBuilder app)
{
// Allow all origins
app.UseCors(CorsOptions.AllowAll);
// Wire token validation
app.UseIdentityServerBearerTokenAuthentication(new IdentityServerBearerTokenAuthenticationOptions
{
Authority = "https://localhost:44300",
// For access to the introspection endpoint
ClientId = "api",
ClientSecret = "api-secret",
RequiredScopes = new[] { "api" }
});
// Wire Web API
var httpConfiguration = new HttpConfiguration();
httpConfiguration.MapHttpAttributeRoutes();
httpConfiguration.Filters.Add(new AuthorizeAttribute());
app.UseWebApi(httpConfiguration);
}
}
代碼很直觀,但是我們還是仔細(xì)看看我們?cè)诠艿乐杏昧诵┦裁?
因?yàn)镴S應(yīng)用一般都要跨域,所以我們啟用了CORS。我們?cè)试S來(lái)自任何網(wǎng)站的跨域請(qǐng)求,在生產(chǎn)中,我們需要限制一下,改成只允許我們希望的網(wǎng)站來(lái)跨域請(qǐng)求。
API項(xiàng)目需要驗(yàn)證令牌的有效性,我們通過(guò)IdentityServer3.AccessTokenValidation包來(lái)實(shí)現(xiàn)。在指定Authority 屬性后,AccessTokenValidation會(huì)自動(dòng)下載元數(shù)據(jù)并完成令牌驗(yàn)證的設(shè)置。
2.2版本以后,IdentityServer實(shí)現(xiàn)了introspection endpoint 來(lái)驗(yàn)證令牌。這個(gè)端點(diǎn)會(huì)進(jìn)行作用域認(rèn)證,比傳統(tǒng)的令牌驗(yàn)證更安全。
最后是WebAPI配置。我們使用AuthroizeAttribute來(lái)指定所有的API請(qǐng)求都需要認(rèn)證。
現(xiàn)在我們來(lái)加上一個(gè)簡(jiǎn)單的API方法:
[Route("values")]
public class ValuesController : ApiController
{
private static readonly Random _random = new Random();
public IEnumerable<string> Get()
{
var random = new Random();
return new[]
{
_random.Next(0, 10).ToString(),
_random.Next(0, 10).ToString()
};
}
}
更新identityServer 配置
我們?cè)?code>IdentityServer項(xiàng)目中的Scopes增加一個(gè)api作用域:
public static class Scopes
{
public static List<Scope> Get()
{
return new List<Scope>
{
StandardScopes.OpenId,
StandardScopes.Profile,
StandardScopes.Email,
// New scope registration
new Scope
{
Name = "api",
DisplayName = "Access to API",
Description = "This will grant you access to the API",
ScopeSecrets = new List<Secret>
{
new Secret("api-secret".Sha256())
},
Type = ScopeType.Resource
}
};
}
}
新的作用域是資源作用域,也就是說(shuō)它會(huì)在訪問(wèn)令牌中體現(xiàn)。當(dāng)然例子中的JS應(yīng)用不需要修改,因?yàn)樗?qǐng)求了全部作用域,但是在生產(chǎn)環(huán)境中,應(yīng)該限制申請(qǐng)那些作用域。
更新 JS 應(yīng)用
現(xiàn)在我們更新JS應(yīng)用,申請(qǐng)新的api作用域
var settings = {
authority: 'https://localhost:44300',
client_id: 'js',
popup_redirect_uri: 'http://localhost:56668/popup.html',
// We add `token` to specify we expect an access token too
response_type: 'id_token token',
// We add the new `api` scope to the list of requested scopes
scope: 'openid profile email api',
filterProtocolClaims: true
};
修改包括:
- 一個(gè)新的用于顯示訪問(wèn)令牌的Panel
- 更新
response_type來(lái)同時(shí)請(qǐng)求身份令牌和訪問(wèn)令牌 - 請(qǐng)求
api作用域
訪問(wèn)令牌通過(guò)access_token屬性獲取,過(guò)期時(shí)間放在expires_at屬性上。oidc-client會(huì)處理簽名證書(shū),令牌驗(yàn)證等麻煩的部分,我們不需要編寫任何代碼。
登陸以后我們會(huì)得到下面的信息:
調(diào)用 API
拿到訪問(wèn)令牌,我們就可以在JS應(yīng)用里調(diào)用API了。
[...]
<div class="container main-container">
<div class="row">
<div class="col-xs-12">
<ul class="list-inline list-unstyled requests">
<li><a href="index.html" class="btn btn-primary">Home</a></li>
<li><button type="button" class="btn btn-default js-login">Login</button></li>
<!-- New button to trigger an API call -->
<li><button type="button" class="btn btn-default js-call-api">Call API</button></li>
</ul>
</div>
</div>
<div class="row">
<!-- Make the existing sections 6-column wide -->
<div class="col-xs-6">
<div class="panel panel-default">
<div class="panel-heading">User data</div>
<div class="panel-body">
<pre class="js-user"></pre>
</div>
</div>
</div>
<!-- And add a new one for the result of the API call -->
<div class="col-xs-6">
<div class="panel panel-default">
<div class="panel-heading">API call result</div>
<div class="panel-body">
<pre class="js-api-result"></pre>
</div>
</div>
</div>
</div>
</div>
[...]
$('.js-call-api').on('click', function () {
var headers = {};
if (user && user.access_token) {
headers['Authorization'] = 'Bearer ' + user.access_token;
}
$.ajax({
url: 'http://localhost:60136/values',
method: 'GET',
dataType: 'json',
headers: headers
}).then(function (data) {
display('.js-api-result', data);
}).catch(function (error) {
display('.js-api-result', {
status: error.status,
statusText: error.statusText,
response: error.responseJSON
});
});
});
代碼改好了,我們現(xiàn)在有一個(gè)調(diào)用API的按鈕和一個(gè)顯示API結(jié)果的Panel。
注意,訪問(wèn)令牌會(huì)放到Authroization請(qǐng)求頭里。
登錄前調(diào)用,結(jié)果如下:
登陸后調(diào)用,結(jié)果如下:
登陸前訪問(wèn)API,JS應(yīng)用沒(méi)有得到訪問(wèn)令牌,所以不會(huì)添加Authorization請(qǐng)求頭,那么訪問(wèn)令牌驗(yàn)證中間件不會(huì)介入。請(qǐng)求做為未認(rèn)證的請(qǐng)求發(fā)送到API,全局特性AuthroizeAttribute會(huì)拒絕請(qǐng)求,返回`401未授權(quán)錯(cuò)誤。
登陸后訪問(wèn)API, 令牌驗(yàn)證中間件在請(qǐng)求頭中發(fā)現(xiàn)了Authorization,把它傳給introspection端點(diǎn)驗(yàn)證,收到身份信息及包含的聲明。好了,請(qǐng)求帶著認(rèn)證信息流向了Web API,全局特性AuthroizeAttribute約束滿足了,具體的API成功調(diào)用。
Part 3 - 更新令牌,登出及檢查會(huì)話
現(xiàn)在JS應(yīng)用可以登錄,可以調(diào)用受保護(hù)的API了。但是,令牌一旦過(guò)期,受保護(hù)的API又用不了啦。
好消息是,oidc-token-manager可以配置成在令牌過(guò)期前來(lái)自動(dòng)更新訪問(wèn)令牌,無(wú)需用戶介入。
過(guò)期的令牌
首先我們來(lái)看看如何讓令牌過(guò)期,我們必須縮短過(guò)期時(shí)間,過(guò)期時(shí)間是基于客戶端的一個(gè)設(shè)置項(xiàng),我們編輯IdentityServer中的Clients類。
public static class Clients
{
public static IEnumerable<Client> Get()
{
return new[]
{
new Client
{
Enabled = true,
ClientName = "JS Client",
ClientId = "js",
Flow = Flows.Implicit,
RedirectUris = new List<string>
{
"http://localhost:56668/popup.html"
},
AllowedCorsOrigins = new List<string>
{
"http://localhost:56668"
},
AllowAccessToAllScopes = true,
AccessTokenLifetime = 10
}
};
}
}
訪問(wèn)令牌過(guò)期時(shí)間默認(rèn)是1小時(shí),我們把它改成10秒。
現(xiàn)在你登陸JS應(yīng)用后,過(guò)10秒鐘在訪問(wèn)API,你又會(huì)得到401未授權(quán)錯(cuò)誤啦。
更新令牌
我們將依賴oidc-client-js幫我們自動(dòng)更新令牌
在oidc-client-js內(nèi)部會(huì)記錄訪問(wèn)令牌的過(guò)期時(shí)間,并在過(guò)期前向IdentityServer發(fā)送授權(quán)請(qǐng)求來(lái)獲取新的訪問(wèn)令牌。按照prompt 設(shè)置 --默認(rèn)設(shè)置為none, 在會(huì)話有效期內(nèi),用戶不需要重新授權(quán)來(lái)得到訪問(wèn)令牌--,這些動(dòng)作是用戶不可見(jiàn)的。IdentityServer會(huì)返回一個(gè)新的訪問(wèn)令牌替代即將過(guò)期的舊令牌。
下面是訪問(wèn)令牌過(guò)期和更新的設(shè)置說(shuō)明:
-
accessTokenExpiring事件在過(guò)期前會(huì)激發(fā) -
accessTokenExpiringNotificationTime用來(lái)調(diào)整accessTokenExpiring激發(fā)時(shí)間.默認(rèn)是過(guò)期前60秒。 - 另外一個(gè)是
automaticSilentRenew,用來(lái)在令牌過(guò)期前自動(dòng)更新令牌。 - 最后
silent_redirect_uri是指得到新令牌后需要重定向到的URL。
oidc-client-js更新令牌的大致步驟如下:
當(dāng)令牌快過(guò)期的時(shí)候,oidc-client-js會(huì)創(chuàng)建一個(gè)不可見(jiàn)的iframe,并在其中啟動(dòng)要給新的授權(quán)請(qǐng)求,如果請(qǐng)求成功,identityServer會(huì)讓iframe重定向到silent_redirect_uri指定的URL,這部分的的JS代碼會(huì)自動(dòng)更新全局用戶信息,這樣主窗口就可以得到更新后的令牌。
理論講完了,我們現(xiàn)在來(lái)按照上述內(nèi)容改代碼:
var settings = {
authority: 'https://localhost:44300',
client_id: 'js',
popup_redirect_uri: 'http://localhost:56668/popup.html',
// Add the slient renew redirect URL
silent_redirect_uri: 'http://localhost:56668/silent-renew.html'
response_type: 'id_token token',
scope: 'openid profile email api',
// Add expiration nofitication time
accessTokenExpiringNotificationTime: 4,
// Setup to renew token access automatically
automaticSilentRenew: true,
filterProtocolClaims: true
};
silent_redirect_uri需要一個(gè)頁(yè)面來(lái)處理更新用戶信息,代碼如下:
<!DOCTYPE html>
<html>
<head>
<title></title>
<meta charset="utf-8" />
</head>
<body>
<script src="node_modules/oidc-client/dist/oidc-client.js"></script>
<script>
new Oidc.UserManager().signinSilentCallback();
</script>
</body>
</html>
現(xiàn)在需要告訴IdentityServer,新的重定向地址也是合法的。
public static class Clients
{
public static IEnumerable<Client> Get()
{
return new[]
{
new Client
{
Enabled = true,
ClientName = "JS Client",
ClientId = "js",
Flow = Flows.Implicit,
RedirectUris = new List<string>
{
"http://localhost:56668/popup.html",
// The new page is a valid redirect page after login
"http://localhost:56668/silent-renew.html"
},
AllowedCorsOrigins = new List<string>
{
"http://localhost:56668"
},
AllowAccessToAllScopes = true,
AccessTokenLifetime = 70
}
};
}
}
當(dāng)更新成功,UserManager會(huì)觸發(fā)一個(gè)userLoaded事件,因?yàn)槲覀冊(cè)谇懊嬉呀?jīng)寫好了事件處理器,更新的數(shù)據(jù)會(huì)自動(dòng)顯示在UI上。
當(dāng)失敗的時(shí)候,silentRenewError事件會(huì)觸發(fā),我們可以訂閱這個(gè)事件來(lái)了解具體什么錯(cuò)了。
manager.events.addSilentRenewError(function (error) {
console.error('error while renewing the access token', error);
});
我們把訪問(wèn)令牌生存期設(shè)置為10秒,并告訴oidc-client-js過(guò)期前4秒更新令牌。
現(xiàn)在登陸以后,每6秒會(huì)向identityserver請(qǐng)求更新訪問(wèn)令牌一次。
登出
前端程序的登出和服務(wù)端程序的登出不一樣,比如,你在瀏覽器里刷新頁(yè)面,訪問(wèn)令牌就丟失了,你需要重新登陸。但是當(dāng)?shù)顷憦棾隹虼蜷_(kāi)時(shí),它發(fā)現(xiàn)你還有一個(gè)IdentityServer的有效會(huì)話Cookie,所以它不會(huì)問(wèn)你要用戶名密碼,反而立刻關(guān)閉自己。整個(gè)過(guò)程和自動(dòng)后臺(tái)更新令牌差不多。
真正的登出意味著從IdentityServer登出,下次進(jìn)入由IdentityServer保護(hù)的程序時(shí),必須重新輸入用戶名密碼。
過(guò)程不復(fù)雜,我們只需要在登出按鈕事件里面調(diào)用UserManager的signoutRedirect方法,當(dāng)然,我們也需要在IdentityServer注冊(cè)登出重定向url:
public static class Clients
{
public static IEnumerable<Client> Get()
{
return new[]
{
new Client
{
Enabled = true,
ClientName = "JS Client",
ClientId = "js",
Flow = Flows.Implicit,
RedirectUris = new List<string>
{
"http://localhost:56668/popup.html",
"http://localhost:56668/silent-renew.html"
},
// Valid URLs after logging out
PostLogoutRedirectUris = new List<string>
{
"http://localhost:56668/index.html"
},
AllowedCorsOrigins = new List<string>
{
"http://localhost:56668"
},
AllowAccessToAllScopes = true,
AccessTokenLifetime = 70
}
};
}
[...]
<div class="row">
<div class="col-xs-12">
<ul class="list-inline list-unstyled requests">
<li><a href="index.html" class="btn btn-primary">Home</a></li>
<li><button type="button" class="btn btn-default js-login">Login</button></li>
<li><button type="button" class="btn btn-default js-call-api">Call API</button></li>
<!-- New logout button -->
<li><button type="button" class="btn btn-danger js-logout">Logout</button></li>
</ul>
</div>
</div>
var settings = {
authority: 'https://localhost:44300',
client_id: 'js',
popup_redirect_uri: 'http://localhost:56668/popup.html',
silent_redirect_uri: 'http://localhost:56668/silent-renew.html',
// Add the post logout redirect URL
post_logout_redirect_uri: 'http://localhost:56668/index.html',
response_type: 'id_token token',
scope: 'openid profile email api',
accessTokenExpiringNotificationTime: 4,
automaticSilentRenew: true,
filterProtocolClaims: true
};
[...]
$('.js-logout').on('click', function () {
manager
.signoutRedirect()
.catch(function (error) {
console.error('error while signing out user', error);
});
});
當(dāng)點(diǎn)擊logout按鈕時(shí),用戶會(huì)重定向到IdentityServer,所以回話cookie會(huì)被清除。

注意,上面圖片顯示的是IdentityServer的頁(yè)面,不是JS應(yīng)用的界面
上面的例子是通過(guò)主頁(yè)面登出,oidc-client-js提供了一種在彈出框中登出的方式,和登錄差不多,具體的信息可以參考 oidc-client-js的文檔.
檢查會(huì)話
JS應(yīng)用的會(huì)話開(kāi)始于我們從IdentityServer得到有效的身份令牌。IdentityServer自身也要維護(hù)一個(gè)會(huì)話管理,在響應(yīng)授權(quán)請(qǐng)求的時(shí)候會(huì)返回一個(gè)session_state。關(guān)于OpenID Connect詳細(xì)規(guī)格說(shuō)明,請(qǐng)參看這里.
有些情況下,我們想知道用戶是否結(jié)束了IdentityServer上的回話,比如說(shuō),在另外一個(gè)應(yīng)用程序中登出引起在IdentityServer上登出。檢查的方式是計(jì)算 session_state 的值. 如果它和IdentityServer發(fā)出來(lái)的一樣,那么說(shuō)明用戶還處于登陸狀態(tài)。如果變化了,用戶就有可能已經(jīng)登出了,這時(shí)候建議啟動(dòng)一次后臺(tái)登陸請(qǐng)求(帶上prompt=none).如果成功,我們會(huì)得到一個(gè)新的身份令牌,也說(shuō)明在IdentityServer上,用戶還是處于登陸狀態(tài)。失敗了,則說(shuō)明用戶已經(jīng)登出了,我們需要讓用戶重新登陸。
不幸的是,JS應(yīng)用自己沒(méi)辦法計(jì)算session_state的值,因?yàn)?code>session_state是IdentityServer的cookie,我們的JS應(yīng)用無(wú)法訪問(wèn)。OpenID的規(guī)格 要求裝載一個(gè)不可見(jiàn)的iframe調(diào)用IdentityServer的checksession端點(diǎn)。JS應(yīng)用和iframe可以通過(guò)postMessage API通信.
checksession 端點(diǎn)
這個(gè)端點(diǎn)監(jiān)聽(tīng)來(lái)自postMessage的消息,按要求提供一個(gè)簡(jiǎn)單的頁(yè)面。傳送到端點(diǎn)的數(shù)據(jù)用來(lái)計(jì)算會(huì)話的哈希值。如果和IdentityServer上的一樣,這個(gè)頁(yè)面返回unchanged值,否則返回changed值。如果出現(xiàn)錯(cuò)誤,則返回error.
實(shí)現(xiàn)會(huì)話檢查功能
好消息是oidc-client-js啥都會(huì) O(∩_∩)O.
事實(shí)上,默認(rèn)設(shè)置就會(huì)監(jiān)視會(huì)話狀態(tài)。
相關(guān)的屬性是 monitorSession.
當(dāng)用戶一登陸進(jìn)來(lái),oidc-clieng-js就會(huì)創(chuàng)建一個(gè)不可見(jiàn)的iframe,這個(gè)iframe會(huì)裝載identityserver的會(huì)話檢查端點(diǎn)。
每隔一段時(shí)間,這個(gè)iframe都會(huì)發(fā)送client id 和會(huì)話狀態(tài)給IdentityServer,并檢查收到的結(jié)果來(lái)判定會(huì)話是否已經(jīng)改變。
我們可以利用oidc-client-js的日志系統(tǒng)來(lái)認(rèn)識(shí)整個(gè)過(guò)程是如何進(jìn)行的。默認(rèn)情況下oidc-client-js配置的是無(wú)操作(no-op)日志記錄器,我們可以簡(jiǎn)單的讓它輸出到瀏覽器控制臺(tái)。
Oidc.Log.logger = console;
為了減少日志量,我們?cè)黾釉L問(wèn)令牌的生存期。
更新令牌會(huì)產(chǎn)生大量日志,現(xiàn)在的設(shè)置沒(méi)6秒要來(lái)一次,我們都沒(méi)有時(shí)間來(lái)詳細(xì)檢查日志。所以我們把它改成1分鐘。
public static class Clients
{
public static IEnumerable<Client> Get()
{
return new[]
{
new Client
{
Enabled = true,
ClientName = "JS Client",
ClientId = "js",
Flow = Flows.Implicit,
RedirectUris = new List<string>
{
"http://localhost:56668/popup.html",
"http://localhost:56668/silent-renew.html"
},
PostLogoutRedirectUris = new List<string>
{
"http://localhost:56668/index.html"
},
AllowedCorsOrigins = new List<string>
{
"http://localhost:56668"
},
AllowAccessToAllScopes = true,
// Access token lifetime increased to 1 minute
AccessTokenLifetime = 60
}
};
}
最后,當(dāng)用戶會(huì)話已經(jīng)改變,自動(dòng)登錄也沒(méi)成功。 UserManager會(huì)觸發(fā)一個(gè)userSinedOut事件,現(xiàn)在讓我們來(lái)處理這個(gè)事件。
manager.events.addUserSignedOut(function () {
alert('The user has signed out');
});
現(xiàn)在重新回到JS應(yīng)用,登出,打開(kāi)瀏覽器控制臺(tái),重新登陸; 你會(huì)發(fā)現(xiàn)每隔2秒鐘(默認(rèn)設(shè)置)--oidc-client-js會(huì)檢查會(huì)話是否還是有效。

現(xiàn)在我們來(lái)證明它按照我們?cè)O(shè)想的那樣工作,我們打開(kāi)一個(gè)新的瀏覽器tab,轉(zhuǎn)到JS應(yīng)用并登陸?,F(xiàn)在這兩個(gè)tab都在檢查會(huì)話狀態(tài)。從其中要給tab登出,你會(huì)看到另外一個(gè)tab會(huì)顯示如下窗口:
