溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊(cè)×
其他方式登錄
點(diǎn)擊 登錄注冊(cè) 即表示同意《億速云用戶服務(wù)條款》

Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄

發(fā)布時(shí)間:2020-06-28 08:35:37 來源:網(wǎng)絡(luò) 閱讀:4430 作者:川川Jason 欄目:軟件技術(shù)

本文介紹了Keycloak基礎(chǔ)知識(shí)、ADFS和Salesforce IDP配置、Spring Boot和Angular集成Keycloak實(shí)現(xiàn)單點(diǎn)登錄的方法。

本文代碼以Angular 8集成Spring Boot 2詳解為基礎(chǔ),刪除了原JWT、用戶、權(quán)限、登錄等代碼。Angular代碼使用了keycloak-angular,稍做修改。GitHub源碼地址:heroes-api 、heroes-web 。

軟件環(huán)境:
Keycloak 8.0.1
Spring Boot 2.2.2
Angular 8.2
ADFS 2016
Salesforce Cloud

Keycloak

Keycloak為現(xiàn)代應(yīng)用和服務(wù)提供開源的認(rèn)證和訪問管理,即通常所說的認(rèn)證和授權(quán)。Keycloak支持OpenID、OAuth 2.0和SAML 2.0協(xié)議;支持用戶注冊(cè)、用戶管理、權(quán)限管理;支持OTP,支持代理OpenID、SAML 2.0 IDP,支持GitHub、LinkedIn等第三方登錄,支持整合LDAP和Active Directory;支持自定義認(rèn)證流程、自定義用戶界面,支持國際化。

Keycloak支持Java、C#、Python、Android、iOS、JavaScript、Nodejs等平臺(tái)或語言,提供簡(jiǎn)單易用的Adapter,僅需少量配置和代碼即可實(shí)現(xiàn)SSO。

Keycloak新的發(fā)行版命名為Quarkus,專為GraalVM和OpenJDK HotSpot量身定制的一個(gè)Kurbernetes Native Java框架,計(jì)劃2019年底正式發(fā)布。

安裝

Keycloak構(gòu)建在WildFly application server之上,從官網(wǎng)下載Standalone server distribution解壓后運(yùn)行bin/standalone.sh即可啟動(dòng)。默認(rèn)使用h3數(shù)據(jù)庫,可以修改配置使用其它數(shù)據(jù)庫。Standalone Clustered Mode、Domain Clustered Mode啟動(dòng)模式和更多配置請(qǐng)參閱官方文檔。
默認(rèn),本地網(wǎng)址為http://localhost:8080/auth ,首次登錄時(shí)必須創(chuàng)建admin用戶:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
直接登錄Admin Console http://localhost:8080/auth/admin/ :
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄

Realm

Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
為保護(hù)不同的應(yīng)用,通常創(chuàng)建不同的Realm,各Realm間的數(shù)據(jù)和配置是獨(dú)立的。初始創(chuàng)建的Realm為Master,Master是最高級(jí)別的Realm。Master Realm內(nèi)的admin用戶(授予admin角色的用戶)擁有查看和管理任何其它realm的權(quán)限。因此,不推薦使用master realm管理用戶和應(yīng)用,而應(yīng)僅供超級(jí)管理員來創(chuàng)建和管理realm。
每個(gè)realm有專用的管理控制臺(tái),可以設(shè)置自已的管理員賬號(hào),比如接下來我們創(chuàng)建的heroes realm,控制臺(tái)網(wǎng)址為http://localhost:8080/auth/admin/heroes/console 。
創(chuàng)建Heroes realm
點(diǎn)擊左上角下拉菜單 -> Add realm:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
Login Tab中有多個(gè)可配置選項(xiàng):用戶注冊(cè)、編輯用戶名、忘記密碼、記住我、驗(yàn)證email、使用email登錄、需要SSL。
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
其中,Require SSL有三個(gè)選項(xiàng):all requests、external requests、none,默認(rèn)為external requests,在生產(chǎn)環(huán)境中應(yīng)配置為all requests。

  • all requests 所有請(qǐng)求都需通過HTTPS訪問
  • external requests localhost和私有IP不需通過HTTPS訪問
  • none 任何客戶端都不需HTTPS

Themes Tab可以配置界面主題、啟用國際化:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
Tokens Tab可以配置token簽名算法、過期時(shí)間等。

Client

Client是realm中受信任的應(yīng)用。
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
創(chuàng)建realm后自動(dòng)創(chuàng)建以下client:

  • account 賬戶管理

Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
如Realm配置中啟用了User-Managed Access則可以管理自己的Resource:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄

  • admin-cli
  • broker
  • realm-management 預(yù)置了realm管理角色,創(chuàng)建realm管理員時(shí)需要分配這些角色
  • security-admin-console realm管理控制臺(tái)

創(chuàng)建heroes client
點(diǎn)擊Clients右上方的Create:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
Client Protocol使用默認(rèn)值openid-connect。Access Type有三個(gè)選項(xiàng)confidential、public、bearer-only,保持默認(rèn)值public。confidential需要client secret,但我們將在web應(yīng)用中使用此client,web無法以安全的方式傳輸secret,因此必須使用public client。只要嚴(yán)格使用HTTPS,可以保證安全。Valid Redirect URIs輸入 http://localhost:4200/* 。

認(rèn)證流程:

  • Standard Flow 即OAuth 2.0規(guī)范中的Authorization Code Flow,推薦使用的認(rèn)證流程,安全性高。keycloak驗(yàn)證用戶后附帶一次性、臨時(shí)的Authorization Code重定向到瀏覽器,瀏覽器憑此Code與keycloak交換token(identity、access和refresh token)
  • Implicit Flow keycloak驗(yàn)證用戶后直接返回identity和access token
  • Direct Access Grants REST client獲取token的方式,使用HTTP Post請(qǐng)求,響應(yīng)結(jié)果包含access和refresh token

調(diào)用示例,POST請(qǐng)求地址:http://localhost:8080/auth/realms/heroes/protocol/openid-connect/token :
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
OIDC URI Endpoints
查詢網(wǎng)址:http://localhost:8080/auth/realms/heroes/.well-known/openid-configuration ,這些Endpoint是非常有用的,比如REST調(diào)用。

Client Scope

Client Scope定義了協(xié)議映射關(guān)系,keycloak預(yù)定義了一些Scope,每個(gè)client會(huì)自動(dòng)繼承,這樣就不必在client內(nèi)重復(fù)定義mapper了。Client Scope分為default和optional兩種, default scope會(huì)自動(dòng)生效,optional scope指定使用時(shí)才生效。
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
啟用optional scope需要使用scope參數(shù):
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
啟用相應(yīng)scope或配置mapper后,才能在token或userinfo中顯示相應(yīng)的屬性。比如,上圖中我們啟用了phone scope,其mapper中定義了phone number:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
如果用戶屬性中定義了phoneNumber,在token中則會(huì)顯示phone_number,可以在heroes client -> Client Scopes -> Evaluate查看效果:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄

Role、Group、User

Role
Role分為兩種級(jí)別:Realm、Client,默認(rèn)Realm Role:offline_access、uma_authorization。

  • offline access
    OpenID規(guī)范中定義了offline access,用戶登錄獲得offline token,當(dāng)用戶退出后offline token仍可使用。在很多場(chǎng)景中是非常有用的,比如每日離線備份數(shù)據(jù)。要獲得offline token除需offline_access角色外,還需指定offline_access Scope。默認(rèn),offline token不會(huì)過期,但需每30天刷新一次。offline token可以撤銷:
    Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
  • uma_authorization
    uma是User-Managed Access的縮寫,Keycloak是符合UMA 2.0功能的授權(quán)服務(wù)器。

Role、Group和User的關(guān)系
User可以屬于一個(gè)或多個(gè)Group,Role可以授予User和Group。
創(chuàng)建Realm管理用戶
添加用戶:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
授予realm-management權(quán)限:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄

Authentication

Keycloak預(yù)定義了Browser、Direct Grant、Registration、Reset Credentials等認(rèn)證流程,用戶也可自定義流程。以Brower流程為例:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
Required是必須執(zhí)行的,Alternative至少須執(zhí)行一個(gè),Optional則由用戶決定是否啟用。Browser流程中Cookie(Session Cookie)、Identity Provider Redirector、Forms均為Alternative,因此只有前者沒有驗(yàn)證成功才會(huì)執(zhí)行后者。其中Identity Provider可以配置默認(rèn)IDP;當(dāng)執(zhí)行Form認(rèn)證時(shí),用戶名/密碼是必須的,OTP為可選的。
用戶啟用OTP的方法,登錄Account Console,點(diǎn)擊認(rèn)證方,根據(jù)說明操作即可:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄

Identity Provider

支持代理OpenID、SAML 2.0 IDP,支持社交登錄。無論您采用什么認(rèn)證方式,token都由keycloak簽發(fā),完全與外部IDP解耦,客戶端不需知道keycloak與IDP使用的協(xié)議,簡(jiǎn)化了認(rèn)證和授權(quán)管理。
Identity Broker Flow:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
解釋一下第7、8步:
IDP認(rèn)證成功后,重定向到keycloak,通常返回的響應(yīng)中包含一個(gè)security token。Keycloak檢查response是否有效,如果有效將在keycloak創(chuàng)建一個(gè)新用戶(如果用戶已存在則跳過此步,如果IDP更新了用戶信息則會(huì)同步信息),之后keycloak頒發(fā)自己的token。

Keycloak支持配置默認(rèn)IDP,客戶端也可以請(qǐng)求指定的IDP。

若要配置IDP,Keycloak需要啟用SSL/HTTPS。在生產(chǎn)環(huán)境一般使用reverse proxy或load balancer啟用HTTPS。為了演示,我們?cè)趉eycloak server中配置。

  1. 創(chuàng)建自簽名證書和Java Keystore
$ 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:
  1. 配置keycloak使用Keystore

將keycloak.jks拷貝到configuration/目錄,連接Jboss CLI后執(zhí)行以下命令創(chuàng)建新的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使用新創(chuàng)建的realm:

$ /subsystem=undertow/server=default-server/https-listener=https:write-attribute(name=security-realm, value=UndertowRealm)

下面介紹如何配置SAML 2.0協(xié)議的ADFS和Salesforce IDP。

ADFS

配置Keycloak Identity Provider
Identity Providers -> Add provider -> SAML v2.0:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
填入Alias、Display Name后滾動(dòng)到底部,導(dǎo)入ADFS FederationMetadata:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
ADFS FederationMetadata地址為:https://adfs.domain.name/FederationMetadata/2007-06/FederationMetadata.xml ,也可以保存后從文件導(dǎo)入。
導(dǎo)入成功后,NameID Policy Format選擇Email,啟用Want AuthnRequests Signed和Validate Signature,SAML Signature Key Name選擇CERT_SUBJECT。
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
保存后配置映射關(guān)系email、firstName、lastName,使ADFS和Keycloak的用戶信息相對(duì)應(yīng):
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
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下載。

  1. 配置Relying Party

進(jìn)入AD FS管理控制臺(tái),右擊Relying Party Trusts -> Add Relying Party Trust:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
選擇Claims aware -> Start:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
導(dǎo)入之前的descriptor XML文件。
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
輸入Display Name,接下來的設(shè)置保持默認(rèn)值。

  1. 配置Claim映射

我們需要配置兩個(gè)Rule:Name ID和User屬性。在彈出的Edit Claim Issuance Policy窗口中點(diǎn)擊Add Rule:
Name ID的rule template選擇Transform an incoming claim:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄

User屬性的rule template選擇Send LDAP attributes as Claims,然后添加以下屬性:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄

說明:如果ADFS為自簽名證書,需要將證書導(dǎo)入Java truststore

Salesforce

前提,Salesforce已啟用Identity Provider并分配了域名。如果未啟用,依次進(jìn)入 Setup -> Setttins -> Identity -> Identity Provider -> Enable。啟用后點(diǎn)擊Download Metadata下載Metadata。
配置Keycloak Identity Provider
Identity Providers -> Add provider -> SAML v2.0:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
填入Alias、Display Name后滾動(dòng)到底部,導(dǎo)入Salesforce Metadata:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
導(dǎo)入成功后,NameID Policy Format選擇Persistent,啟用Want AuthnRequests Signed和Validate Signature,SAML Signature Key Name選擇KEYI_ID。
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
保存后配置映射關(guān)系email、firstName、lastName:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
配置Salesforce Connected App
在Salesforce Identity Provider頁面,點(diǎn)擊底部Service Providers的鏈接"Click here",創(chuàng)建新的Connected App:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
接下來配置SAML,同樣先從IDP獲取SAML descriptor:https://sso.itrunner.org:8443/auth/realms/heroes/broker/salesforce/endpoint/descriptor ,其中包含了下面需要的內(nèi)容:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
保存,然后點(diǎn)擊頁面頂部的Manage,配置Profiles和Permission Sets:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
最后定義Custom Attributes:firstName、lastName:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄

Spring Boot

采用Keycloak結(jié)合Spring security的方式。

POM Dependency

<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>8.0.1</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

Keycloak配置

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

WebSecurityConfig

Keycloak提供了便利的基類KeycloakWebSecurityConfigurerAdapter來創(chuàng)建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.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.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;

@EnableWebSecurity
@ComponentScan(basePackageClasses = KeycloakSecurityComponents.class)
public class WebSecurityConfig extends KeycloakWebSecurityConfigurerAdapter {
    private static final String ROLE_ADMIN = "ADMIN";

    @Value("${security.ignore-paths}")
    private String[] ignorePaths;

    @Value("${management.endpoints.web.exposure.include}")
    private String[] actuatorExposures;

    public final KeycloakClientRequestFactory keycloakClientRequestFactory;

    public WebSecurityConfig(KeycloakClientRequestFactory keycloakClientRequestFactory) {
        this.keycloakClientRequestFactory = keycloakClientRequestFactory;
    }

    @Override
    public void configure(WebSecurity web) {
        web.ignoring().antMatchers(ignorePaths);
    }

    @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();
    }
}

說明:

  1. Spring Security默認(rèn)的角色前綴是 “ROLE_”,為保持一致性,將KeycloakAuthenticationProvider的grantedAuthoritiesMapper設(shè)置為SimpleAuthorityMapper
  2. Session策略設(shè)置為NullAuthenticatedSessionStrategy,無狀態(tài)REST不需Session
  3. 添加FilterRegistrationBean防止重復(fù)注冊(cè)filter bean
  4. Spring Boot 2默認(rèn)禁用spring.main.allow-bean-definition-overriding,給httpSessionManager方法添加@ConditionalOnMissingBean注解,否則會(huì)拋出BeanDefinitionOverrideException
  5. KeycloakRestTemplate擴(kuò)展了RestTemplate,在受Keycloak保護(hù)的應(yīng)用間調(diào)用時(shí)會(huì)自動(dòng)驗(yàn)證,為啟用這一功能必須添加KeycloakRestTemplate bean,KeycloakRestTemplate用法如下:

KeycloakRestTemplate

@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());
    }
}

SpringBootApplication

默認(rèn),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

工具類,從SecurityContext Authentication中獲取登錄用戶的信息。

package org.itrunner.heroes.util;

import org.keycloak.KeycloakSecurityContext;
import org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken;
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 final class KeycloakContext {
    private KeycloakContext() {
    }

    public static Optional<AccessToken> getAccessToken() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null || !(authentication instanceof KeycloakAuthenticationToken) || !authentication.isAuthenticated()) {
            return empty();
        }
        KeycloakSecurityContext credentials = (KeycloakSecurityContext) authentication.getCredentials();
        return of(credentials.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);
    }

}

TestRestTemplate測(cè)試

調(diào)用Keycloak token endpoint獲取access token,然后添加到BearerAuth Header。

package org.itrunner.heroes;

import org.itrunner.heroes.domain.Hero;
import org.itrunner.heroes.exception.ErrorMessage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class HeroesApplicationTests {

    @Autowired
    private TestRestTemplate restTemplate;

    @BeforeEach
    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);
                }));
    }

    @Test
    void crudSuccess() {
        Hero hero = new Hero();
        hero.setName("Jack");

        // add hero
        hero = restTemplate.postForObject("/api/heroes", hero, Hero.class);
        assertThat(hero.getId()).isNotNull();

        // update hero
        hero.setName("Jacky");
        HttpEntity<Hero> requestEntity = new HttpEntity<>(hero);
        hero = restTemplate.exchange("/api/heroes", HttpMethod.PUT, requestEntity, Hero.class).getBody();
        assertThat(hero.getName()).isEqualTo("Jacky");

        // find heroes by name
        Map<String, String> urlVariables = new HashMap<>();
        urlVariables.put("name", "m");
        List<Hero> heroes = restTemplate.getForObject("/api/heroes/?name={name}", List.class, urlVariables);
        assertThat(heroes.size()).isEqualTo(5);

        // get hero by id
        hero = restTemplate.getForObject("/api/heroes/" + hero.getId(), Hero.class);
        assertThat(hero.getName()).isEqualTo("Jacky");

        // delete hero successfully
        ResponseEntity<String> response = restTemplate.exchange("/api/heroes/" + hero.getId(), HttpMethod.DELETE, null, String.class);
        assertThat(response.getStatusCodeValue()).isEqualTo(200);

        // delete hero
        response = restTemplate.exchange("/api/heroes/9999", HttpMethod.DELETE, null, String.class);
        assertThat(response.getStatusCodeValue()).isEqualTo(400);
    }

    ...
}

MockMvc測(cè)試

為了mock KeycloakSecurityContext,定義WithMockKeycloakUser注解和實(shí)現(xiàn)類WithMockCustomUserSecurityContextFactory:
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.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.authority.SimpleGrantedAuthority;
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.setScope("openid profile email");
        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 SimpleGrantedAuthority("ROLE_" + role));
        }
        KeycloakAccount account = new SimpleKeycloakAccount(principal, roles, keycloakSecurityContext);
        Authentication auth = new KeycloakAuthenticationToken(account, false, grantedAuthorities);

        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(auth);
        return context;
    }
}

注意,WebSecurityConfig中我們使用了SimpleAuthorityMapper,這里要使用SimpleGrantedAuthority。
測(cè)試示例

package org.itrunner.heroes.controller;

import org.itrunner.heroes.base.WithMockKeycloakUser;
import org.itrunner.heroes.domain.Hero;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.itrunner.heroes.util.JsonUtils.asJson;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
class HeroControllerTest {
    @Autowired
    private MockMvc mvc;

    @Test
    @WithMockKeycloakUser
    public void crudSuccess() throws Exception {
        Hero hero = new Hero();
        hero.setName("Jack");

        // add hero
        mvc.perform(post("/api/heroes").content(asJson(hero)).contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk()).andExpect(content().json("{'id':11, 'name':'Jack', 'createBy':'admin'}"));

        // update hero
        hero.setId(11l);
        hero.setName("Jacky");
        mvc.perform(put("/api/heroes").content(asJson(hero)).contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk()).andExpect(content().json("{'name':'Jacky'}"));

        // find heroes by name
        mvc.perform(get("/api/heroes/?name=m").accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk());

        // get hero by id
        mvc.perform(get("/api/heroes/11").accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk()).andExpect(content().json("{'name':'Jacky'}"));

        // delete hero successfully
        mvc.perform(delete("/api/heroes/11").accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk());

        // delete hero
        mvc.perform(delete("/api/heroes/9999")).andExpect(status().is4xxClientError());
    }

    @Test
    @WithMockKeycloakUser
    void addHeroValidationFailed() throws Exception {
        Hero hero = new Hero();
        mvc.perform(post("/api/heroes").content(asJson(hero)).contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().is(400));
    }
}

Angular

package.json

引入keycloak-js,版本要與Keycloak Server一致。

...
"keycloak-js": "8.0.1",
...

KeycloakService

KeycloakService創(chuàng)建Keycloak實(shí)例,提供與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)
        .success(async authenticated => {
          if (authenticated && this.loadUserProfileAtStartUp) {
            await this.loadUserProfile();
          }
          resolve(authenticated);
        })
        .error((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)
        .success(async () => {
          if (this.loadUserProfileAtStartUp) {
            await this.loadUserProfile();
          }
          resolve();
        })
        .error(() => 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)
        .success(() => {
          this.userProfile = undefined;
          resolve();
        })
        .error(() => 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)
        .success(refreshed => {
          resolve(refreshed);
        })
        .error(() => 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()
        .success(result => {
          this.userProfile = result as Keycloak.KeycloakProfile;
          resolve(this.userProfile);
        })
        .error(() => 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配置

創(chuàng)建Keycloak實(shí)例時(shí)若未提供config參數(shù),則將使用keycloak.json。為適用不同的環(huán)境,我們?cè)趀nvironment中配置Keycloak參數(shù)。
environment.ts

export const environment = {
  production: false,
  apiUrl: 'http://localhost:8080',
  keycloak: {
    config: {
      url: 'http://localhost:8090/auth',
      realm: 'heroes',
      clientId: 'heroes'
    },
    initOptions: {
      onLoad: 'login-required',
      checkLoginIframe: false
    },
    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'
    },
    initOptions: {
      onLoad: 'login-required',
      checkLoginIframe: false
    },
    enableBearerInterceptor: true,
    loadUserProfileAtStartUp: true,
    bearerExcludedUrls: ['/assets']
  }
};

參數(shù)說明:

  • onLoad 可選值為login-required' 和 'check-sso',login-required將驗(yàn)證客戶端,如果用戶未登錄則顯示Keycloak登錄頁面;check-sso僅驗(yàn)證是否登錄。如不需驗(yàn)證可以刪除onLoad。
  • checkLoginIframe 是否啟用login監(jiān)控,若啟用,默認(rèn)間隔時(shí)間checkLoginIframeInterval為5秒
  • flow 可選值standard、implicit、hybrid,默認(rèn)為standard

KeycloakBearerInterceptor

為HTTP請(qǐng)求添加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;
  }
}

CanActivateAuthGuard

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);
      }
    });
  }
}

初始化KeycloakService

為提高性能,在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},
      ...
    ]
  ],
  ...

指定IDP

Angular與Keycloak集成完畢,啟動(dòng)服務(wù)后訪問頁面會(huì)自動(dòng)跳轉(zhuǎn)到Keycloak登錄界面:
Spring Boot/Angular整合Keycloak實(shí)現(xiàn)單點(diǎn)登錄
用戶可以直接輸入用戶名/密碼、可以選擇IDP登錄。
配置Keycloak IDP時(shí)可以控制是否在登錄界面顯示,認(rèn)證流程中可以設(shè)置默認(rèn)IDP,客戶端調(diào)用時(shí)可以指定IDP,多種方式靈活組合可以滿足不同需求。
指定IDP,Angular調(diào)用時(shí)僅需指定idpHint參數(shù),其值為IDP的alias:

keycloakService.login({idpHint: 'adfs'});

附錄

Apache配置

keycloak.conf

ServerTokens Prod
Header always set Strict-Transport-Security "max-age=8640000; includeSubDomains; preload"
Header always append X-Frame-Options SAMEORIGIN

<VirtualHost *:443>
    ServerName   sso.itrunner.org
    ServerAlias  sso.itrunner.org
    ErrorLog     logs/keycloak_error_log
    TransferLog  logs/keycloak_access_log
    LogLevel warn

    SSLEngine on
    SSLProtocol all -SSLv2 -SSLv3
    SSLCipherSuite HIGH:3DES:!aNULL:!MD5:!SEED:!IDEA
    SSLCertificateFile /etc/pki/tls/certs/ca.crt
    SSLCertificateKeyFile /etc/pki/tls/private/ca.key

    RewriteEngine On
    RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} -f [OR]
    RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} -d
    RewriteRule ^ - [L]

    ProxyPreserveHost on
    ProxyPass /auth http://127.0.0.1:8080/auth timeout=600
    ProxyPa***everse /auth http://127.0.0.1:8080/auth
</VirtualHost>

參考文檔

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

向AI問一下細(xì)節(jié)

免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場(chǎng),如果涉及侵權(quán)請(qǐng)聯(lián)系站長郵箱:is@yisu.com進(jìn)行舉報(bào),并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。

AI