本文介紹了Keycloak基礎知識、ADFS和Salesforce IDP配置、Spring Boot和Angular集成Keycloak實現單點登陸的方法。javascript
本文代碼以Angular 8集成Spring Boot 2詳解爲基礎,刪除了原JWT、用戶、權限、登陸等代碼。Angular代碼使用了keycloak-angular,稍作修改。GitHub源碼地址:heroes-api 、heroes-web 。html
軟件環境:
Keycloak 7.0.1
Spring Boot 2.2.0
Angular 8.2
ADFS 2016
Salesforce Cloudjava
Keycloak爲現代應用和服務提供開源的認證和訪問管理,即一般所說的認證和受權。Keycloak支持OpenID、OAuth 2.0和SAML 2.0協議;支持用戶註冊、用戶管理、權限管理;支持OTP,支持代理OpenID、SAML 2.0 IDP,支持GitHub、LinkedIn等第三方登陸,支持整合LDAP和Active Directory;支持自定義認證流程、自定義用戶界面,支持國際化。git
Keycloak支持Java、C#、Python、Android、iOS、JavaScript、Nodejs等平臺或語言,提供簡單易用的Adapter,僅需少許配置和代碼便可實現SSO。github
Keycloak新的發行版命名爲Quarkus,專爲GraalVM和OpenJDK HotSpot量身定製的一個Kurbernetes Native Java框架,計劃2019年末正式發佈。web
Keycloak構建在WildFly application server之上,從官網下載Standalone server distribution解壓後運行bin/standalone.sh便可啓動。默認使用h2數據庫,能夠修改配置使用其它數據庫。Standalone Clustered Mode、Domain Clustered Mode啓動模式和更多配置請參閱官方文檔。
默認,本地網址爲http://localhost:8080/auth ,首次登陸時必須建立admin用戶:
直接登陸Admin Console http://localhost:8080/auth/admin/ :
算法
爲保護不一樣的應用,一般建立不一樣的Realm,各Realm間的數據和配置是獨立的。初始建立的Realm爲Master,Master是最高級別的Realm。Master Realm內的admin用戶(授予admin角色的用戶)擁有查看和管理任何其它realm的權限。所以,不推薦使用master realm管理用戶和應用,而應僅供超級管理員來建立和管理realm。
每一個realm有專用的管理控制檯,能夠設置自已的管理員帳號,好比接下來咱們建立的heroes realm,控制檯網址爲http://localhost:8080/auth/admin/heroes/console 。
建立Heroes realm
點擊左上角下拉菜單 -> Add realm:
Login Tab中有多個可配置選項:用戶註冊、編輯用戶名、忘記密碼、記住我、驗證email、使用email登陸、須要SSL。
其中,Require SSL有三個選項:all requests、external requests、none,默認爲external requests,在生產環境中應配置爲all requests。spring
Themes Tab能夠配置界面主題、啓用國際化:
Tokens Tab能夠配置token簽名算法、過時時間等。數據庫
Client是realm中受信任的應用。
建立realm後自動建立如下client:json
如Realm配置中啓用了User-Managed Access則能夠管理本身的Resource:
建立heroes client
點擊Clients右上方的Create:
Client Protocol使用默認值openid-connect。Access Type有三個選項confidential、public、bearer-only,保持默認值public。confidential須要client secret,但咱們將在web應用中使用此client,web沒法以安全的方式傳輸secret,所以必須使用public client。只要嚴格使用HTTPS,能夠保證安全。Valid Redirect URIs輸入 http://localhost:4200/* 。
認證流程:
調用示例,POST請求地址:http://localhost:8080/auth/realms/heroes/protocol/openid-connect/token :
OIDC URI Endpoints
查詢網址:http://localhost:8080/auth/realms/heroes/.well-known/openid-configuration ,這些Endpoint是很是有用的,好比REST調用。
Client Scope定義了協議映射關係,keycloak預約義了一些Scope,每一個client會自動繼承,這樣就沒必要在client內重複定義mapper了。Client Scope分爲default和optional兩種, default scope會自動生效,optional scope指定使用時才生效。
啓用optional scope須要使用scope參數:
啓用相應scope或配置mapper後,才能在token或userinfo中顯示相應的屬性。好比,上圖中咱們啓用了phone scope,其mapper中定義了phone number:
若是用戶屬性中定義了phoneNumber,在token中則會顯示phone_number,能夠在heroes client -> Client Scopes -> Evaluate查看效果:
Role
Role分爲兩種級別:Realm、Client,默認Realm Role:offline_access、uma_authorization。
Role、Group和User的關係
User能夠屬於一個或多個Group,Role能夠授予User和Group。
建立Realm管理用戶
添加用戶:
授予realm-management權限:
Keycloak預約義了Browser、Direct Grant、Registration、Reset Credentials等認證流程,用戶也可自定義流程。以Brower流程爲例:
Required是必須執行的,Alternative至少須執行一個,Optional則由用戶決定是否啓用。Browser流程中Cookie(Session Cookie)、Identity Provider Redirector、Forms均爲Alternative,所以只有前者沒有驗證成功纔會執行後者。其中Identity Provider能夠配置默認IDP;當執行Form認證時,用戶名/密碼是必須的,OTP爲可選的。
用戶啓用OTP的方法,登陸Account Console,點擊認證方,根聽說明操做便可:
支持代理OpenID、SAML 2.0 IDP,支持社交登陸。不管您採用什麼認證方式,token都由keycloak簽發,徹底與外部IDP解耦,客戶端不需知道keycloak與IDP使用的協議,簡化了認證和受權管理。
Identity Broker Flow:
解釋一下第七、8步:
IDP認證成功後,重定向到keycloak,一般返回的響應中包含一個security token。Keycloak檢查response是否有效,若是有效將在keycloak建立一個新用戶(若是用戶已存在則跳過此步,若是IDP更新了用戶信息則會同步信息),以後keycloak頒發本身的token。
Keycloak支持配置默認IDP,客戶端也能夠請求指定的IDP。
若要配置IDP,Keycloak須要啓用SSL/HTTPS。在生產環境通常使用reverse proxy或load balancer啓用HTTPS。爲了演示,咱們在keycloak server中配置。
$ keytool -genkey -alias sso.itrunner.org -keyalg RSA -keystore keycloak.jks -validity 10950 Enter keystore password: Re-enter new password: What is your first and last name? [Unknown]: sso.itrunner.org What is the name of your organizational unit? [Unknown]: itrunner What is the name of your organization? [Unknown]: itrunner What is the name of your City or Locality? [Unknown]: Beijing What is the name of your State or Province? [Unknown]: Beijing What is the two-letter country code for this unit? [Unknown]: CN Is CN=sso.itrunner.org, OU=itrunner, O=itrunner, L=Beijing, ST=Beijing, C=CN correct? [no]: yes Enter key password for <sso.itrunner.org> (RETURN if same as keystore password): Re-enter new password:
將keycloak.jks拷貝到configuration/目錄,鏈接Jboss CLI後執行如下命令建立新的security-realm:
$ /core-service=management/security-realm=UndertowRealm:add() $ /core-service=management/security-realm=UndertowRealm/server-identity=ssl:add(keystore-path=keycloak.jks, keystore-relative-to=jboss.server.config.dir, keystore-password=secret)
修改https-listener使用新建立的realm:
$ /subsystem=undertow/server=default-server/https-listener=https:write-attribute(name=security-realm, value=UndertowRealm)
下面介紹如何配置SAML 2.0協議的ADFS和Salesforce IDP。
配置Keycloak Identity Provider
Identity Providers -> Add provider -> SAML v2.0:
填入Alias、Display Name後滾動到底部,導入ADFS FederationMetadata:
ADFS FederationMetadata地址爲:https://adfs.domain.name/FederationMetadata/2007-06/FederationMetadata.xml ,也能夠保存後從文件導入。
導入成功後,NameID Policy Format選擇Email,啓用Want AuthnRequests Signed和Validate Signature,SAML Signature Key Name選擇CERT_SUBJECT。
保存後配置映射關係email、firstName、lastName,使ADFS和Keycloak的用戶信息相對應:
Mapper Type選擇Attribute Importer,Attribute Name分別爲:
email -> http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress
firstName -> http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname
lastName -> http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname
配置ADFS
先從IDP獲取SAML descriptor:https://sso.itrunner.org:8443/auth/realms/heroes/broker/adfs/endpoint/descriptor ,也能夠從Identity Provider -> Export下載。
進入AD FS管理控制檯,右擊Relying Party Trusts -> Add Relying Party Trust:
選擇Claims aware -> Start:
導入以前的descriptor XML文件。
輸入Display Name,接下來的設置保持默認值。
咱們須要配置兩個Rule:Name ID和User屬性。在彈出的Edit Claim Issuance Policy窗口中點擊Add Rule:
Name ID的rule template選擇Transform an incoming claim:
User屬性的rule template選擇Send LDAP attributes as Claims,而後添加如下屬性:
說明:若是ADFS爲自簽名證書,須要將證書導入Java truststore
前提,Salesforce已啓用Identity Provider並分配了域名。若是未啓用,依次進入 Setup -> Setttins -> Identity -> Identity Provider -> Enable。啓用後點擊Download Metadata下載Metadata。
配置Keycloak Identity Provider
Identity Providers -> Add provider -> SAML v2.0:
填入Alias、Display Name後滾動到底部,導入Salesforce Metadata:
導入成功後,NameID Policy Format選擇Persistent,啓用Want AuthnRequests Signed和Validate Signature,SAML Signature Key Name選擇KEYI_ID。
保存後配置映射關係email、firstName、lastName:
配置Salesforce Connected App
在Salesforce Identity Provider頁面,點擊底部Service Providers的連接"Click here",建立新的Connected App:
接下來配置SAML,一樣先從IDP獲取SAML descriptor:https://sso.itrunner.org:8443/auth/realms/heroes/broker/salesforce/endpoint/descriptor ,其中包含了下面須要的內容:
保存,而後點擊頁面頂部的Manage,配置Profiles和Permission Sets:
最後定義Custom Attributes:firstName、lastName:
採用Keycloak結合Spring security的方式。
<dependencies> ... <dependency> <groupId>org.keycloak</groupId> <artifactId>keycloak-spring-boot-starter</artifactId> </dependency> ... </dependencies> <dependencyManagement> <dependencies> <dependency> <groupId>org.keycloak.bom</groupId> <artifactId>keycloak-adapter-bom</artifactId> <version>7.0.1</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>
application.yml:
keycloak: cors: true cors-allowed-methods: GET,POST,DELETE,PUT,OPTIONS cors-allowed-headers: Accept,Accept-Encoding,Accept-Language,Authorization,Connection,Content-Type,Host,Origin,Referer,User-Agent,X-Requested-With
application-dev.yml
keycloak: enabled: true auth-server-url: http://localhost:8090/auth realm: heroes resource: heroes public-client: true bearer-only: true
application-prod.yml
keycloak: enabled: true auth-server-url: https://sso.itrunner.org/auth realm: heroes resource: heroes public-client: true ssl-required: all disable-trust-manager: true bearer-only: true
Keycloak提供了便利的基類KeycloakWebSecurityConfigurerAdapter來建立WebSecurityConfigurer。
package org.itrunner.heroes.config; import org.keycloak.adapters.springsecurity.KeycloakSecurityComponents; import org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider; import org.keycloak.adapters.springsecurity.client.KeycloakClientRequestFactory; import org.keycloak.adapters.springsecurity.client.KeycloakRestTemplate; import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter; import org.keycloak.adapters.springsecurity.filter.KeycloakAuthenticatedActionsFilter; import org.keycloak.adapters.springsecurity.filter.KeycloakAuthenticationProcessingFilter; import org.keycloak.adapters.springsecurity.filter.KeycloakPreAuthActionsFilter; import org.keycloak.adapters.springsecurity.filter.KeycloakSecurityContextRequestFilter; import org.keycloak.adapters.springsecurity.management.HttpSessionManager; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Scope; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.WebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper; import org.springframework.security.web.authentication.session.NullAuthenticatedSessionStrategy; import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; @Configuration @EnableWebSecurity @ComponentScan(basePackageClasses = KeycloakSecurityComponents.class) public class WebSecurityConfig extends KeycloakWebSecurityConfigurerAdapter { private static final String ROLE_ADMIN = "ADMIN"; @Value("${management.endpoints.web.exposure.include}") private String[] actuatorExposures; @Autowired public KeycloakClientRequestFactory keycloakClientRequestFactory; @Override public void configure(WebSecurity web) { web.ignoring().antMatchers("/api-docs", "/swagger-resources/**", "/swagger-ui.html**", "/webjars/**"); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider(); SimpleAuthorityMapper grantedAuthoritiesMapper = new SimpleAuthorityMapper(); grantedAuthoritiesMapper.setConvertToUpperCase(true); keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(grantedAuthoritiesMapper); auth.authenticationProvider(keycloakAuthenticationProvider); } @Override protected void configure(HttpSecurity http) throws Exception { super.configure(http); http.csrf().disable().authorizeRequests().requestMatchers(EndpointRequest.to(actuatorExposures)).permitAll().anyRequest().hasRole(ROLE_ADMIN); } @Bean @Override protected SessionAuthenticationStrategy sessionAuthenticationStrategy() { return new NullAuthenticatedSessionStrategy(); } @Bean @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) public KeycloakRestTemplate keycloakRestTemplate() { return new KeycloakRestTemplate(keycloakClientRequestFactory); } @Bean public FilterRegistrationBean keycloakAuthenticationProcessingFilterRegistrationBean(KeycloakAuthenticationProcessingFilter filter) { FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter); registrationBean.setEnabled(false); return registrationBean; } @Bean public FilterRegistrationBean keycloakPreAuthActionsFilterRegistrationBean(KeycloakPreAuthActionsFilter filter) { FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter); registrationBean.setEnabled(false); return registrationBean; } @Bean public FilterRegistrationBean keycloakAuthenticatedActionsFilterBean(KeycloakAuthenticatedActionsFilter filter) { FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter); registrationBean.setEnabled(false); return registrationBean; } @Bean public FilterRegistrationBean keycloakSecurityContextRequestFilterBean(KeycloakSecurityContextRequestFilter filter) { FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter); registrationBean.setEnabled(false); return registrationBean; } @Bean @Override @ConditionalOnMissingBean(HttpSessionManager.class) protected HttpSessionManager httpSessionManager() { return new HttpSessionManager(); } }
說明:
@Service public class RemoteProductService { @Autowired private KeycloakRestTemplate template; private String endpoint; public List<String> getProducts() { ResponseEntity<String[]> response = template.getForEntity(endpoint, String[].class); return Arrays.asList(response.getBody()); } }
默認,Keycloak Spring Security Adapter將查找keycloak.json配置文件, 爲確保使用Keycloak Spring Boot Adapter的配置增長KeycloakSpringBootConfigResolver:
@SpringBootApplication @EnableJpaRepositories(basePackages = {"org.itrunner.heroes.repository"}) @EntityScan(basePackages = {"org.itrunner.heroes.domain"}) @EnableJpaAuditing public class HeroesApplication { public static void main(String[] args) { SpringApplication.run(HeroesApplication.class, args); } @Bean public KeycloakSpringBootConfigResolver KeycloakConfigResolver() { return new KeycloakSpringBootConfigResolver(); } }
工具類,從SecurityContext Authentication中獲取登陸用戶的信息。
package org.itrunner.heroes.util; import org.keycloak.adapters.RefreshableKeycloakSecurityContext; import org.keycloak.representations.AccessToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import java.util.Optional; import static java.util.Optional.empty; import static java.util.Optional.of; public class KeycloakContext { private KeycloakContext() { } public static Optional<AccessToken> getAccessToken() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication == null || !authentication.isAuthenticated() || !(authentication.getCredentials() instanceof RefreshableKeycloakSecurityContext)) { return empty(); } return of(((RefreshableKeycloakSecurityContext) authentication.getCredentials()).getToken()); } public static Optional<String> getUsername() { Optional<AccessToken> accessToken = getAccessToken(); return accessToken.map(AccessToken::getPreferredUsername); } public static Optional<String> getEmail() { Optional<AccessToken> accessToken = getAccessToken(); return accessToken.map(AccessToken::getEmail); } }
調用Keycloak token endpoint獲取access token,而後添加到BearerAuth Header。
@Before public void setup() { HttpHeaders requestHeaders = new HttpHeaders(); requestHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED); MultiValueMap<String, String> map = new LinkedMultiValueMap<>(); map.add("grant_type", "password"); map.add("client_id", "heroes"); map.add("username", "admin"); map.add("password", "admin"); HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(map, requestHeaders); Map<String, String> response = restTemplate.postForObject("http://localhost:8090/auth/realms/heroes/protocol/openid-connect/token", requestEntity, Map.class); String token = response.get("access_token"); restTemplate.getRestTemplate().setInterceptors( Collections.singletonList((request, body, execution) -> { HttpHeaders headers = request.getHeaders(); headers.setBearerAuth(token); return execution.execute(request, body); })); }
使用MockMvc進行測試的小夥伴,可使用下面定義的WithMockKeycloakUser註解來mock KeycloakSecurityContext:
WithMockKeycloakUser
package org.itrunner.heroes.base; import org.springframework.security.test.context.support.WithSecurityContext; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @Retention(RetentionPolicy.RUNTIME) @WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class) public @interface WithMockKeycloakUser { String username() default "admin"; String email() default "admin@itrunner.org"; String[] roles() default {"USER", "ADMIN"}; }
WithMockCustomUserSecurityContextFactory
package org.itrunner.heroes.base; import org.keycloak.KeycloakPrincipal; import org.keycloak.adapters.RefreshableKeycloakSecurityContext; import org.keycloak.adapters.spi.KeycloakAccount; import org.keycloak.adapters.springsecurity.account.KeycloakRole; import org.keycloak.adapters.springsecurity.account.SimpleKeycloakAccount; import org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken; import org.keycloak.representations.AccessToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.test.context.support.WithSecurityContextFactory; import java.util.ArrayList; import java.util.HashSet; import java.util.List; public class WithMockCustomUserSecurityContextFactory implements WithSecurityContextFactory<WithMockKeycloakUser> { @Override public SecurityContext createSecurityContext(WithMockKeycloakUser keycloakUser) { AccessToken accessToken = new AccessToken(); accessToken.setPreferredUsername(keycloakUser.username()); accessToken.setEmail(keycloakUser.email()); accessToken.expiration(Integer.MAX_VALUE); accessToken.type("Bearer"); RefreshableKeycloakSecurityContext keycloakSecurityContext = new RefreshableKeycloakSecurityContext(null, null, "access-token-string", accessToken, null, null, null); KeycloakPrincipal<RefreshableKeycloakSecurityContext> principal = new KeycloakPrincipal<>("user-id", keycloakSecurityContext); HashSet<String> roles = new HashSet<>(); List<GrantedAuthority> grantedAuthorities = new ArrayList<>(); for (String role : keycloakUser.roles()) { roles.add(role); grantedAuthorities.add(new KeycloakRole(role)); } KeycloakAccount account = new SimpleKeycloakAccount(principal, roles, keycloakSecurityContext); Authentication auth = new KeycloakAuthenticationToken(account, false, grantedAuthorities); SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(auth); return context; } }
用法:
@Test @WithMockKeycloakUser(username = "username", email = "email") public void testMethod() { ... }
引入keycloak-js,版本要與Keycloak Server一致。
... "keycloak-js": "7.0.1", ...
KeycloakService建立Keycloak實例,提供與Keycloak交互的基本方法。
import {Injectable} from '@angular/core'; import {HttpHeaders} from '@angular/common/http'; import {Observable} from 'rxjs'; import {ExcludedUrl, ExcludedUrlRegex, KeycloakOptions} from './keycloak-options'; import * as Keycloak from 'keycloak-js'; @Injectable({providedIn: 'root'}) export class KeycloakService { private keycloak: Keycloak.KeycloakInstance; private userProfile: Keycloak.KeycloakProfile; private loadUserProfileAtStartUp: boolean; private _enableBearerInterceptor: boolean; private _excludedUrls: ExcludedUrlRegex[]; /** * Keycloak initialization. It should be called to initialize the adapter. * Options is a object with 2 main parameters: config and initOptions. The first one will be used to create the Keycloak instance. * The second one are options to initialize the keycloak instance. * * @param options * Config: may be a string representing the keycloak URI or an object with the following content: * - url: Keycloak json URL * - realm: realm name * - clientId: client id * * initOptions: * - onLoad: Specifies an action to do on load. Supported values are 'login-required' or 'check-sso'. * - token: Set an initial value for the token. * - refreshToken: Set an initial value for the refresh token. * - idToken: Set an initial value for the id token (only together with token or refreshToken). * - timeSkew: Set an initial value for skew between local time and Keycloak server in seconds(only together with token or refreshToken). * - checkLoginIframe: Set to enable/disable monitoring login state (default is true). * - checkLoginIframeInterval: Set the interval to check login state (default is 5 seconds). * - responseMode: Set the OpenID Connect response mode send to Keycloak server at login request. * Valid values are query or fragment . Default value is fragment, which means that after successful authentication will Keycloak redirect to * javascript application with OpenID Connect parameters added in URL fragment. This is generally safer and recommended over query. * - flow: Set the OpenID Connect flow. Valid values are standard, implicit or hybrid. * * enableBearerInterceptor: Flag to indicate if the bearer will added to the authorization header. * * loadUserProfileInStartUp: Indicates that the user profile should be loaded at the keycloak initialization, just after the login. * * bearerExcludedUrls: String Array to exclude the urls that should not have the Authorization Header automatically added. * * @returns A Promise with a boolean indicating if the initialization was successful. */ init(options: KeycloakOptions = {}): Promise<boolean> { return new Promise((resolve, reject) => { this.initServiceValues(options); const {config, initOptions} = options; this.keycloak = Keycloak(config); this.keycloak.init(initOptions) .then(async authenticated => { if (authenticated && this.loadUserProfileAtStartUp) { await this.loadUserProfile(); } resolve(authenticated); }) .catch((kcError) => { let msg = 'An error happened during Keycloak initialization.'; if (kcError) { msg = msg.concat(`\nAdapter error details:\nError: ${kcError.error}\nDescription: ${kcError.error_description}` ); } reject(msg); }); }); } /** * Loads all bearerExcludedUrl content in a uniform type: ExcludedUrl, * so it becomes easier to handle. * * @param bearerExcludedUrls array of strings or ExcludedUrl that includes * the url and HttpMethod. */ private loadExcludedUrls(bearerExcludedUrls: (string | ExcludedUrl)[]): ExcludedUrlRegex[] { const excludedUrls: ExcludedUrlRegex[] = []; for (const item of bearerExcludedUrls) { let excludedUrl: ExcludedUrlRegex; if (typeof item === 'string') { excludedUrl = {urlPattern: new RegExp(item, 'i'), httpMethods: []}; } else { excludedUrl = { urlPattern: new RegExp(item.url, 'i'), httpMethods: item.httpMethods }; } excludedUrls.push(excludedUrl); } return excludedUrls; } /** * Handles the class values initialization. */ private initServiceValues({enableBearerInterceptor = true, loadUserProfileAtStartUp = true, bearerExcludedUrls = []}): void { this._enableBearerInterceptor = enableBearerInterceptor; this.loadUserProfileAtStartUp = loadUserProfileAtStartUp; this._excludedUrls = this.loadExcludedUrls(bearerExcludedUrls); } /** * Redirects to login form */ login(options: Keycloak.KeycloakLoginOptions = {}): Promise<void> { return new Promise((resolve, reject) => { this.keycloak.login(options) .then(async () => { if (this.loadUserProfileAtStartUp) { await this.loadUserProfile(); } resolve(); }) .catch(() => reject(`An error happened during the login.`)); }); } /** * Redirects to logout. * * @param redirectUri Specifies the uri to redirect to after logout. * @returns A void Promise if the logout was successful, cleaning also the userProfile. */ logout(redirectUri?: string): Promise<void> { return new Promise((resolve, reject) => { const options: any = {redirectUri}; this.keycloak.logout(options) .then(() => { this.userProfile = undefined; resolve(); }) .catch(() => reject('An error happened during logout.')); }); } /** * Redirects to the Account Management Console */ account() { this.keycloak.accountManagement(); } /** * Check if the user has access to the specified role. * * @param role role name * @param resource resource name If not specified, `clientId` is used * @returns A boolean meaning if the user has the specified Role. */ hasRole(role: string, resource?: string): boolean { let hasRole: boolean; hasRole = this.keycloak.hasResourceRole(role, resource); if (!hasRole) { hasRole = this.keycloak.hasRealmRole(role); } return hasRole; } /** * Check if user is logged in. * * @returns A boolean that indicates if the user is logged in. */ async isLoggedIn(): Promise<boolean> { try { if (!this.keycloak.authenticated) { return false; } await this.updateToken(20); return true; } catch (error) { return false; } } /** * Returns true if the token has less than minValidity seconds left before it expires. * * @param minValidity Seconds left. (minValidity) is optional. Default value is 0. * @returns Boolean indicating if the token is expired. */ isTokenExpired(minValidity: number = 0): boolean { return this.keycloak.isTokenExpired(minValidity); } /** * If the token expires within minValidity seconds the token is refreshed. If the * session status iframe is enabled, the session status is also checked. * Returns a promise telling if the token was refreshed or not. If the session is not active * anymore, the promise is rejected. * * @param minValidity Seconds left. (minValidity is optional, if not specified 5 is used) * @returns Promise with a boolean indicating if the token was successfully updated. */ updateToken(minValidity: number = 5): Promise<boolean> { return new Promise(async (resolve, reject) => { if (!this.keycloak) { reject('Keycloak Angular library is not initialized.'); return; } this.keycloak.updateToken(minValidity) .then(refreshed => { resolve(refreshed); }) .catch(() => reject('Failed to refresh the token, or the session is expired')); }); } /** * Returns the authenticated token, calling updateToken to get a refreshed one if * necessary. If the session is expired this method calls the login method for a new login. * * @returns Promise with the generated token. */ getToken(): Promise<string> { return new Promise(async (resolve) => { try { await this.updateToken(10); resolve(this.keycloak.token); } catch (error) { this.login(); } }); } /** * Loads the user profile. * Returns promise to set functions to be invoked if the profile was loaded * successfully, or if the profile could not be loaded. * * @param forceReload * If true will force the loadUserProfile even if its already loaded. * @returns * A promise with the KeycloakProfile data loaded. */ loadUserProfile(forceReload: boolean = false): Promise<Keycloak.KeycloakProfile> { return new Promise(async (resolve, reject) => { if (this.userProfile && !forceReload) { resolve(this.userProfile); return; } if (!this.keycloak.authenticated) { reject('The user profile was not loaded as the user is not logged in.'); return; } this.keycloak.loadUserProfile() .then(result => { this.userProfile = result as Keycloak.KeycloakProfile; resolve(this.userProfile); }) .catch(() => reject('The user profile could not be loaded.')); }); } /** * Returns the logged username. */ getUsername(): string { if (!this.userProfile) { throw new Error('User not logged in or user profile was not loaded.'); } return this.userProfile.username; } /** * Returns email of the logged user */ getUserEmail(): string { if (!this.userProfile) { throw new Error('User not logged in or user profile was not loaded.'); } return this.userProfile.email; } /** * Clear authentication state, including tokens. This can be useful if application * has detected the session was expired, for example if updating token fails. * Invoking this results in onAuthLogout callback listener being invoked. */ clearToken(): void { this.keycloak.clearToken(); } /** * Adds a valid token in header. The key & value format is: Authorization Bearer <token>. * If the headers param is undefined it will create the Angular headers object. * * @param headers Updated header with Authorization and Keycloak token. * @returns An observable with the HTTP Authorization header and the current token. */ addTokenToHeader(headers: HttpHeaders = new HttpHeaders()): Observable<HttpHeaders> { return new Observable((observer) => { this.getToken().then(token => { headers = headers.set('Authorization', 'bearer ' + token); observer.next(headers); observer.complete(); }).catch(error => { observer.error(error); }); }); } get enableBearerInterceptor(): boolean { return this._enableBearerInterceptor; } get excludedUrls(): ExcludedUrlRegex[] { return this._excludedUrls; } }
建立Keycloak實例時若未提供config參數,則將使用keycloak.json。爲適用不一樣的環境,咱們在environment中配置Keycloak參數。
environment.ts
export const environment = { production: false, apiUrl: 'http://localhost:8080', keycloak: { config: { url: 'http://localhost:8090/auth', realm: 'heroes', clientId: 'heroes', sslRequired: 'external' }, initOptions: { onLoad: 'login-required', checkLoginIframe: false, promiseType: 'native' }, enableBearerInterceptor: true, loadUserProfileAtStartUp: true, bearerExcludedUrls: ['/assets'] } };
environment.prod.ts
export const environment = { production: true, apiUrl: 'http://heroes-api.apps.itrunner.org', keycloak: { config: { url: 'https://sso.itrunner.org/auth', realm: 'heroes', clientId: 'heroes', sslRequired: 'all' }, initOptions: { onLoad: 'login-required', checkLoginIframe: false, promiseType: 'native' }, enableBearerInterceptor: true, loadUserProfileAtStartUp: true, bearerExcludedUrls: ['/assets'] } };
參數說明:
爲HTTP請求添加bearer token。
import {Injectable} from '@angular/core'; import {HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from '@angular/common/http'; import {Observable} from 'rxjs'; import {KeycloakService} from './keycloak.service'; import {mergeMap} from 'rxjs/operators'; import {ExcludedUrlRegex} from './keycloak-options'; @Injectable() export class KeycloakBearerInterceptor implements HttpInterceptor { constructor(private keycloakService: KeycloakService) { } intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { const {enableBearerInterceptor, excludedUrls} = this.keycloakService; if (!enableBearerInterceptor) { return next.handle(req); } const shallPass: boolean = excludedUrls.findIndex(item => this.isUrlExcluded(req, item)) > -1; if (shallPass) { return next.handle(req); } return this.keycloakService.addTokenToHeader(req.headers).pipe( mergeMap(headersWithBearer => { const kcReq = req.clone({headers: headersWithBearer}); return next.handle(kcReq); }) ); } /** * Checks if the url is excluded from having the Bearer Authorization header added. * * @param req http request from @angular http module. * @param excludedUrlRegex contains the url pattern and the http methods, * excluded from adding the bearer at the Http Request. */ private isUrlExcluded({method, url}: HttpRequest<any>, {urlPattern, httpMethods}: ExcludedUrlRegex): boolean { const httpTest = httpMethods.length === 0 || httpMethods.join().indexOf(method.toUpperCase()) > -1; const urlTest = urlPattern.test(url); return httpTest && urlTest; } }
import {Injectable} from '@angular/core'; import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot} from '@angular/router'; import {KeycloakService} from './keycloak.service'; @Injectable({providedIn: 'root'}) export class CanActivateAuthGuard implements CanActivate { constructor(private router: Router, private keycloakService: KeycloakService) { } canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean> { return new Promise(async (resolve) => { const authenticated = await this.keycloakService.isLoggedIn(); if (authenticated) { resolve(true); } else { this.keycloakService.login(); resolve(false); } }); } }
爲提升性能,在app.module.ts中初始化KeycloakService。
... export function initKeycloak(keycloak: KeycloakService): () => Promise<any> { return (): Promise<any> => { return new Promise(async (resolve, reject) => { try { // @ts-ignore await keycloak.init(environment.keycloak); resolve(); } catch (error) { reject(error); } }); }; } ... providers: [ [ {provide: APP_INITIALIZER, useFactory: initKeycloak, deps: [KeycloakService], multi: true}, {provide: HTTP_INTERCEPTORS, useClass: KeycloakBearerInterceptor, multi: true}, ... ] ], ...
Angular與Keycloak集成完畢,啓動服務後訪問頁面會自動跳轉到Keycloak登陸界面:
用戶能夠直接輸入用戶名/密碼、能夠選擇IDP登陸。
配置Keycloak IDP時能夠控制是否在登陸界面顯示,認證流程中能夠設置默認IDP,客戶端調用時能夠指定IDP,多種方式靈活組合能夠知足不一樣需求。
指定IDP,Angular調用時僅需指定idpHint參數,其值爲IDP的alias:
keycloakService.login({idpHint: 'adfs'});
Keycloak
AD FS Docs
Salesforce Identity Providers and Service Providers
A Quick Guide to Using Keycloak with Spring Boot
How to Setup MS AD FS 3.0 as Brokered Identity Provider in Keycloak