預備知識: 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>
很簡單就這些.
最後操作一下試試: 最好自己除錯一下:
選單那幾個都是好用的頁面.