入門教程: JS認(rèn)證和WebAPI

本教程會(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)用。


create js app
create js app

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

js app url
js app url

創(chuàng)建IdentityServer 項(xiàng)目

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

create web app
create web app

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

set ssl
set 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):

  • authorityIdentityServer的入口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, issidp

我們監(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,scopeprofile`等屬性, 這些屬性包含各種用戶具體的數(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)閉。

代碼抄到這里,登陸可以工作啦:

login-popup
login-popup
login-complete
login-complete

你可以把filterProtocolClaims 屬性設(shè)置為false,看看profile下面會(huì)多出那些聲明?

JS 應(yīng)用 - 作用域

我們定義了一個(gè)email聲明,但是它好像沒(méi)有在我們的身份令牌里面?這是因?yàn)槲覀兊腏S應(yīng)用只要了openidprofile作用域,沒(méi)有包括email聲明。
如果JS應(yīng)用想拿到郵件地址,JS應(yīng)用必須在UserManagerscopes屬性中申請(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信息啦:

login-email
login-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)用程序.

create api
create api

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)題了

api-update-microsoft-identity-tokens
api-update-microsoft-identity-tokens

解決辦法是把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ì)得到下面的信息:

access-token
access-token

調(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é)果如下:

api-without-access-token
api-without-access-token

登陸后調(diào)用,結(jié)果如下:

api-with-access-token
api-with-access-token

登陸前訪問(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)用UserManagersignoutRedirect方法,當(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ì)被清除。

logout
logout

注意,上面圖片顯示的是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ì)話是否還是有效。

session-check
session-check

現(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ì)顯示如下窗口:
logout-event
logout-event

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容