1. 程式人生 > >使用Identity Server 4建立Authorization Server (6) - js(angular5) 客戶端

使用Identity Server 4建立Authorization Server (6) - js(angular5) 客戶端

include 節點 ogr 包含 發的 for icon ets list

預備知識: http://www.cnblogs.com/cgzl/p/7746496.html

第一部分: http://www.cnblogs.com/cgzl/p/7780559.html

第二部分: http://www.cnblogs.com/cgzl/p/7788636.html

第三部分: http://www.cnblogs.com/cgzl/p/7793241.html

第四部分: http://www.cnblogs.com/cgzl/p/7795121.html

第五部分: http://www.cnblogs.com/cgzl/p/7799567.html

由於手頭目前用項目, 所以與前幾篇文章不同, 這次要講的js客戶端這部分是通過我剛剛開發的真是項目的代碼來講解的.

這是後端的代碼: https://github.com/solenovex/asp.net-core-2.0-web-api-boilerplate

這裏面有幾個dbcontext, 需要分別對Identity Server和Sales.DataContext進行update-database, 如果使用的是Package Manager Console的話.

進行update-database的時候, 如果是針對IdentityServer這個項目的要把IdentityServer設為啟動項目, 如果是針對Sales.DataContext的, 那麽要把SalesApi.Web設為啟動項目, 然後再進行update-database.

項目結構如圖:

技術分享圖片

目前項目只用到AuthorizationServer和Sales這兩部分.

首先查看AuthorizationServer的相關配置: 打開Configuration/Config.cs

ApiResource:

public static IEnumerable<ApiResource> GetApiResources()
        {
            return new List<ApiResource>
            {
                new ApiResource(CoreApiSettings.ApiResource.Name, CoreApiSettings.ApiResource.DisplayName) { },
                
new ApiResource(SalesApiSettings.ApiResource.Name, SalesApiSettings.ApiResource.DisplayName) { UserClaims = { JwtClaimTypes.Name, JwtClaimTypes.PreferredUserName, JwtClaimTypes.Email } } }; }

紅色部分是相關代碼, 是所需要的ApiResource的定義.

其中需要註意的是, 像user的name, email等這些claims按理說應該可以通過id_token傳遞給js客戶端, 也就是IdentityResource應該負責的. 但是我之所以這樣做是因為想把這些信息包含在access_token裏面, 以便js可以使用包含這些信息的access_token去訪問web api, 這樣 web api就可以直接獲得到當前的用戶名(name), email了. 標準的做法應該是web api通過訪問authorization server的user profile節點來獲得用戶信息, 我這麽做就是圖簡單而已.

所以我把這幾個claims添加到了ApiResource裏面.

配置好整個項目之後你可以把 name 去掉試試, 如果去掉的話, 在web api的controller裏面就無法取得到user的name了, 因為js收到的access token裏面沒有name這個claim, 所以js傳給web api的token裏面也沒有name. 這個一定要自己修改下試試.

然後配置Client:

public static IEnumerable<Client> GetClients()
        {
            return new List<Client>
            {
                // Core JavaScript Client
                new Client
                {
                    ClientId = CoreApiSettings.Client.ClientId,
                    ClientName = CoreApiSettings.Client.ClientName,
                    AllowedGrantTypes = GrantTypes.Implicit,
                    AllowAccessTokensViaBrowser = true,

                    RedirectUris =           { CoreApiSettings.Client.RedirectUri, CoreApiSettings.Client.SilentRedirectUri },
                    PostLogoutRedirectUris = { CoreApiSettings.Client.PostLogoutRedirectUris },
                    AllowedCorsOrigins =     { CoreApiSettings.Client.AllowedCorsOrigins },

                    AllowedScopes =
                    {
                        IdentityServerConstants.StandardScopes.OpenId,
                        IdentityServerConstants.StandardScopes.Profile,
                        IdentityServerConstants.StandardScopes.Email,
                        CoreApiSettings.ApiResource.Name
                    }
                },
                // Sales JavaScript Client
                new Client
                {
                    ClientId = SalesApiSettings.Client.ClientId,
                    ClientName = SalesApiSettings.Client.ClientName,
                    AllowedGrantTypes = GrantTypes.Implicit,
                    AllowAccessTokensViaBrowser = true,
                    AccessTokenLifetime = 60 * 10,
                    AllowOfflineAccess = true,
                    RedirectUris =           { SalesApiSettings.Client.RedirectUri, SalesApiSettings.Client.SilentRedirectUri },
                    PostLogoutRedirectUris = { SalesApiSettings.Client.PostLogoutRedirectUris },
                    AllowedCorsOrigins =     { SalesApiSettings.Client.AllowedCorsOrigins },
                    //AlwaysIncludeUserClaimsInIdToken = true,
                    AllowedScopes =
                    {
                        IdentityServerConstants.StandardScopes.OpenId,
                        IdentityServerConstants.StandardScopes.Profile,
                        IdentityServerConstants.StandardScopes.Email,
                        SalesApiSettings.ApiResource.Name,
                        CoreApiSettings.ApiResource.Name
                    }
                }
            };
        }

紅色部分是相關的代碼.

AccessTokenLifeTime是token的有效期, 單位是秒, 這裏設置的是 10 分鐘.

AlwaysIncludeUserClaimsInIdToken默認是false, 如果寫true的話, 那麽返回給客戶端的id_token裏面就會有user的name, email等等user相關的claims信息.

然後是IdentityResource:

        public static IEnumerable<IdentityResource> GetIdentityResources()
        {
            return new List<IdentityResource>
            {
                new IdentityResources.OpenId(),
                new IdentityResources.Profile(),
                new IdentityResources.Email()
            };
        }

這裏需要這三個IdentityResource, 其中的openId scope(identity resource)是必須要加上的, 如果沒有這個openid scope, 那麽這個請求也許是一個合理的OAuth2.0請求, 但它肯定不會被當作OpenId Connect 請求.

如果你把profile這項去掉, 其他相關代碼也去掉profile, 那麽客戶端新請求的id_token是無論如何也不會包括profile所包含的信息的(name等), 但是並不影響api resource裏面包含相關的claim(access_token還是可以獲得到user的name等的).

其他的Identity Scopes(Identity Resource)所代表的內容請看文檔: http://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims:

profile: name, family_name, given_name, middle_name, nickname, preferred_username,profile, picture, website, gender, birthdate, zoneinfo, locale, and updated_at.

email: email and email_verified Claims.

address: address Claim.

phone: phone_number and phone_number_verified Claims.

看一下Authorization Server的Startup.cs:

namespace AuthorizationServer
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }
        
        public void ConfigureServices(IServiceCollection services)
        {
            var connectionString = Configuration.GetConnectionString("DefaultConnection");
            var migrationsAssembly = typeof(Startup).GetTypeInfo().Assembly.GetName().Name;

            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(connectionString));

            services.AddIdentity<ApplicationUser, IdentityRole>(options =>
            {
                // Password settings
                options.Password.RequireDigit = false;
                options.Password.RequiredLength = 4;
                options.Password.RequireNonAlphanumeric = false;
                options.Password.RequireUppercase = false;
                options.Password.RequireLowercase = false;
                options.Password.RequiredUniqueChars = 1;
                // Lockout settings
                options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
                options.Lockout.MaxFailedAccessAttempts = 5;
                options.Lockout.AllowedForNewUsers = true;
                // Signin settings
                options.SignIn.RequireConfirmedEmail = false;
                options.SignIn.RequireConfirmedPhoneNumber = false;
                // User settings
                options.User.RequireUniqueEmail = false;                
            })
                .AddEntityFrameworkStores<ApplicationDbContext>()
                .AddDefaultTokenProviders();

            services.ConfigureApplicationCookie(options =>
            {
                options.Cookie.Name = "MLHAuthorizationServerCookie";
                options.Cookie.HttpOnly = true;
                options.ExpireTimeSpan = TimeSpan.FromMinutes(60);
                options.LoginPath = "/Account/Login";
                options.LogoutPath = "/Account/Logout";
                options.AccessDeniedPath = "/Account/AccessDenied";
                options.SlidingExpiration = true;
                options.ReturnUrlParameter = CookieAuthenticationDefaults.ReturnUrlParameter;
            });

            services.AddTransient<IEmailSender, EmailSender>();
            services.AddMvc();

            services.AddAutoMapper();

            services.AddIdentityServer()
#if DEBUG
                .AddDeveloperSigningCredential()
#else
                .AddSigningCredential(new System.Security.Cryptography.X509Certificates.X509Certificate2(
                    SharedSettings.Settings.AuthorizationServerSettings.Certificate.Path, 
                    SharedSettings.Settings.AuthorizationServerSettings.Certificate.Password))
#endif
                .AddInMemoryIdentityResources(Config.GetIdentityResources())
                .AddInMemoryApiResources(Config.GetApiResources())
                .AddInMemoryClients(Config.GetClients())
                .AddOperationalStore(options =>
                {
                    options.ConfigureDbContext = builder =>
                        builder.UseSqlServer(connectionString,
                            sql => sql.MigrationsAssembly(migrationsAssembly));
                    options.EnableTokenCleanup = true;
                    options.TokenCleanupInterval = 30;
                })
                .AddAspNetIdentity<ApplicationUser>();

            services.AddAuthorization(options =>
            {
                options.AddPolicy(CoreApiAuthorizationPolicy.PolicyName, policy =>
                    policy.RequireClaim(CoreApiAuthorizationPolicy.ClaimName, CoreApiAuthorizationPolicy.ClaimValue));
            });
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.InitializeDatabase();
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
                app.UseBrowserLink();
                app.UseDatabaseErrorPage();
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");
            }

            app.UseStaticFiles();
            app.UseIdentityServer();
            app.UseMvc(routes =>
            {
                routes.MapRoute(
                    name: "default",
                    template: "{controller=Home}/{action=Index}/{id?}");
            });
        }
    }
}

這裏我只將Operation數據保存到了數據庫. 而Client和ApiResource, IdentityResource等定義還是放在了內存中, 我感覺這樣比較適合我.

Sales Web Api:

打開SalesApi.Web的Startup ConfigureServices: 這個非常簡單:

            services.AddAuthentication("Bearer")
                .AddIdentityServerAuthentication(options =>
                {
                    options.Authority = AuthorizationServerSettings.AuthorizationServerBase;
                    options.RequireHttpsMetadata = false;

                    options.ApiName = SalesApiSettings.ApiResource.Name;
                });

沒什麽可說的.

js 客戶端 和 oidc-client.js

無論你使用什麽樣的前端框架, 最後都使用oidc-client.js來和identity server 4來配套操作.

我使用的是 angular 5: 由於這個代碼是公司的項目, 後端處於早期階段, 被我開源了, 沒什麽問題.

但是前端是某機構買的一套收費的皮膚, 所以沒法開源, 這裏我嘗試提供部分代碼, 我相信您一定可以從頭搭建出完整的js客戶端的.

我的前端應用流程是:

訪問前端地址, 如果沒有登錄用戶, 那麽跳轉到Authorization Server進行登陸, 同意後, 返回到前端的網站.

如果前端網站有登錄的用戶, 那麽在用戶快過期的時候自動刷新token. 以免登陸過期.

前端應用訪問api時, 自動攔截所有請求, 把登陸用戶的access token添加到請求的authorization header, 然後再發送給 web api.

我把前端精簡了一下, 放到了網盤,是好用的

鏈接: https://pan.baidu.com/s/1minARgc 密碼: ipyw

首先需要安裝angular-cli:

npm install -g @angular/cli

然後在項目根目錄執行:

npm install

雖然npm有點慢, 但是也不要使用cnpm, 有bug.

js客戶端參考

你可以參考官方文檔: http://docs.identityserver.io/en/release/quickstarts/7_javascript_client.html

安裝oidc-client:

地址是: https://github.com/IdentityModel/oidc-client-js, 查看文檔的話點wiki即可.

在你的框架裏面執行:

npm install oidc-client --save

配置oidc-client:

我的配置放在了angular5項目的environments裏面, 因為這個配置根據環境的不同(開發和生產)裏面的設定是不同的:

import { WebStorageStateStore } from ‘oidc-client‘;

// The file contents for the current environment will overwrite these during build.
// The build system defaults to the dev environment which uses `environment.ts`, but if you do
// `ng build --env=prod` then `environment.prod.ts` will be used instead.
// The list of which env maps to which file can be found in `angular-cli.json`.

export const environment = {
    production: false,
    authConfig: {
        authority: ‘http://localhost:5000‘,
        client_id: ‘sales‘,
        redirect_uri: ‘http://localhost:4200/login-callback‘,
        response_type: ‘id_token token‘,
        scope: ‘openid profile salesapi email‘,
        post_logout_redirect_uri: ‘http://localhost:4200‘,

        silent_redirect_uri: ‘http://localhost:4200/silent-renew.html‘,
        automaticSilentRenew: true,
        accessTokenExpiringNotificationTime: 4,
        // silentRequestTimeout:10000,
        userStore: new WebStorageStateStore({ store: window.localStorage })
    },
    salesApiBase: ‘http://localhost:5100/api/sales/‘,
    themeKey: ‘MLHSalesApiClientThemeKeyForDevelopment‘
};

authority就是authorization server的地址.

redirect_url是登陸成功後跳轉回來的地址.

silent_redirect_uri是自動刷新token的回掉地址.

automaticSilentRenew為true是啟用自動安靜刷新token.

userStore默認是放在sessionStorage裏面的, 我需要使用localStorage, 所以改了.

建立AuthService:

import { Injectable, EventEmitter } from ‘@angular/core‘;
import { Router } from ‘@angular/router‘;
import { Observable } from ‘rxjs/Observable‘;
import { User, UserManager, Log } from ‘oidc-client‘;
import ‘rxjs/add/observable/fromPromise‘;
import { environment } from ‘../../../environments/environment‘;

Log.logger = console;
Log.level = Log.DEBUG;

@Injectable()
export class AuthService {

    private manager: UserManager = new UserManager(environment.authConfig);
    public loginStatusChanged: EventEmitter<User> = new EventEmitter();
    private userKey = `oidc.user:${environment.authConfig.authority}:${environment.authConfig.client_id}`;

    constructor(
        private router: Router
    ) {
        this.manager.events.addAccessTokenExpired(() => {
            this.login();
        });
    }

    login() {
        this.manager.signinRedirect();
    }

    loginCallBack() {
        return Observable.create(observer => {
            Observable.fromPromise(this.manager.signinRedirectCallback())
                .subscribe((user: User) => {
                    this.loginStatusChanged.emit(user);
                    observer.next(user);
                    observer.complete();
                });
        });
    }

    tryGetUser() {
        return Observable.fromPromise(this.manager.getUser());
    }

    logout() {
        this.manager.signoutRedirect();
    }

    get type(): string {
        return ‘Bearer‘;
    }

    get token(): string | null {
        const temp = localStorage.getItem(this.userKey);
        if (temp) {
            const user: User = JSON.parse(temp);
            return user.access_token;
        }
        return null;
    }

    get authorizationHeader(): string | null {
        if (this.token) {
            return `${this.type} ${this.token}`;
        }
        return null;
    }
}

UserManager就是oidc-client裏面的東西. 我們主要是用它來操作.

constructor裏面那個事件是表示, 如果用戶登錄已經失效了或者沒登錄, 那麽自動調用login()登陸方法.

login()方法裏面的signInRedirect()會直接跳轉到Authorization Server的登陸窗口.

logout()裏的signoutRedirect()就會跳轉到AuthorizationServer並執行登出.

其中的userKey字符串是oidc-client在localStorage默認存放用戶信息的key, 這個可以通過oidc-client的配置來更改.

我沒有改, 所以key是這樣的: "oidc.user:http://localhost:5000:sales":

技術分享圖片

Token Interceptor 請求攔截器:

針對angular 5 所有的請求, 都應該加上authorization header, 其內容就是 access token, 所以token.interceptor.ts就是做這個工作的:

import { Injectable } from ‘@angular/core‘;
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from ‘@angular/common/http‘;
import { Observable } from ‘rxjs/Observable‘;
import { User } from ‘oidc-client‘;
import { environment } from ‘../../../environments/environment‘;
import { AuthService } from ‘./auth.service‘;

@Injectable()
export class TokenInterceptor implements HttpInterceptor {

    constructor(
        private authService: AuthService
    ) { }

    intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        const authHeader = this.authService.authorizationHeader;
        const authReq = req.clone({ headers: req.headers.set(‘Authorization‘, authHeader) });
        return next.handle(authReq);
    }
}

angular 5 的interceptor不會修改request, 所以只能clone.

設置AuthGuard:

angular5的authguard就是裏面有個方法, 如果返回true就可以訪問這個路由, 否則就不可以訪問.

所以我在幾乎最外層添加了這個authguard, 裏面的代碼是:

import { Injectable } from ‘@angular/core‘;
import { CanActivate } from ‘@angular/router‘;
import { Router } from ‘@angular/router‘;
import { User } from ‘oidc-client‘;
import { AuthService } from ‘./../services/auth.service‘;
import { Observable } from ‘rxjs/Observable‘;
import ‘rxjs/add/operator/map‘;

@Injectable()
export class AuthGuard implements CanActivate {

    constructor(
        private router: Router,
        private authService: AuthService) { }

    canActivate(): Observable<boolean> {
        return this.authService.tryGetUser().map((user: User) => {
            if (user) {
                return true;
            }
            this.authService.login();
            return false;
        });
    }
}

意思就是, 取當前用戶, 如果有用戶那麽就可以繼續訪問路由, 否走執行登陸動作.

技術分享圖片

所以訪問訪問網站後會跳轉到這, 這裏有個內置用戶 admin 密碼也是admin, 可以使用它登陸.

外層路由代碼app-routing.module.ts:

import { NgModule } from ‘@angular/core‘;
import { Routes } from ‘@angular/router‘;

import { AuthGuard } from ‘./shared/guards/auth.guard‘;

import { MainComponent } from ‘./main/main.component‘;
import { LoginCallbackComponent } from ‘./shared/components/login-callback/login-callback.component‘;
import { NotFoundComponent } from ‘./shared/components/not-found/not-found.component‘;

export const AppRoutes: Routes = [{
    path: ‘‘,
    redirectTo: ‘dashboard‘,
    pathMatch: ‘full‘,
}, {
    path: ‘login-callback‘,
    component: LoginCallbackComponent
}, {
    path: ‘‘,
    component: MainComponent,
    canActivate: [AuthGuard],
    children: [{
        path: ‘dashboard‘,
        loadChildren: ‘./dashboard/dashboard.module#DashboardModule‘
    }, {
        path: ‘settings‘,
        loadChildren: ‘./settings/settings.module#SettingsModule‘
    }]
},
{ path: ‘**‘, component: NotFoundComponent }];

登陸成功後首先會跳轉到設置好的redirect_uri, 這裏就是login-callback這個路由地址對應的component:

import { Component, OnInit } from ‘@angular/core‘;
import { AuthService } from ‘../../../shared/services/auth.service‘;
import { User } from ‘oidc-client‘;
import { ToastrService } from ‘ngx-toastr‘;

@Component({
    selector: ‘app-login-callback‘,
    templateUrl: ‘./login-callback.component.html‘,
    styleUrls: [‘./login-callback.component.css‘]
})
export class LoginCallbackComponent implements OnInit {

    constructor(
        private authService: AuthService,
        private toastr: ToastrService
    ) { }

    ngOnInit() {
        this.authService.loginCallBack().subscribe(
            (user: User) => {
                this.toastr.info(‘登陸成功, 跳轉中...‘, ‘登陸成功‘);
                if (user) {
                    window.location.href = ‘/‘;
                }
            }
        );
    }

}

我在這裏沒做什麽, 就是重新加載了一下頁面, 我感覺這並不是好的做法.

您可以單獨建立一個簡單的頁面就像官方文檔那樣, 然後再跳轉到angular5項目裏面.

這個頁面一閃而過:

技術分享圖片

回到angular5項目後就可以正常訪問api了.

自動刷新Token:

oidc-client的自動刷新token是只要配置好了, 你就不用再做什麽操作了.

刷新的時候, 它好像是會在頁面上弄一個iframe, 然後在iframe裏面操作.

不過還是需要建立一個頁面, 用於刷新:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title></title>
</head>
<body>
    <h1 id="waiting">Waiting...</h1>
    <div id="error"></div>
    <script src="assets/js/oidc-client.min.js"></script>
    <script>
        new Oidc.UserManager().signinSilentCallback();
    </script>
</body>
</html>

很簡單就這些.

最後操作一下試試: 最好自己調試一下:

技術分享圖片

技術分享圖片

技術分享圖片

菜單那幾個都是好用的頁面.

使用Identity Server 4建立Authorization Server (6) - js(angular5) 客戶端