您好,登錄后才能下訂單哦!
本文介紹了Keycloak基礎知識、ADFS和Salesforce IDP配置、Spring Boot和Angular集成Keycloak實現單點登錄的方法。
本文代碼以Angular 8集成Spring Boot 2詳解為基礎,刪除了原JWT、用戶、權限、登錄等代碼。Angular代碼使用了keycloak-angular,稍做修改。GitHub源碼地址:heroes-api 、heroes-web 。
軟件環境:
Keycloak 8.0.1
Spring Boot 2.2.2
Angular 8.2
ADFS 2016
Salesforce Cloud
Keycloak為現代應用和服務提供開源的認證和訪問管理,即通常所說的認證和授權。Keycloak支持OpenID、OAuth 2.0和SAML 2.0協議;支持用戶注冊、用戶管理、權限管理;支持OTP,支持代理OpenID、SAML 2.0 IDP,支持GitHub、LinkedIn等第三方登錄,支持整合LDAP和Active Directory;支持自定義認證流程、自定義用戶界面,支持國際化。
Keycloak支持Java、C#、Python、Android、iOS、JavaScript、Nodejs等平臺或語言,提供簡單易用的Adapter,僅需少量配置和代碼即可實現SSO。
Keycloak新的發行版命名為Quarkus,專為GraalVM和OpenJDK HotSpot量身定制的一個Kurbernetes Native Java框架,計劃2019年底正式發布。
Keycloak構建在WildFly application server之上,從官網下載Standalone server distribution解壓后運行bin/standalone.sh即可啟動。默認使用h3數據庫,可以修改配置使用其它數據庫。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。
Themes Tab可以配置界面主題、啟用國際化:
Tokens Tab可以配置token簽名算法、過期時間等。
Client是realm中受信任的應用。
創建realm后自動創建以下client:
如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:
解釋一下第7、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>8.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.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();
}
}
說明:
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());
}
}
默認,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.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);
}
}
調用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);
}
...
}
為了mock KeycloakSecurityContext,定義WithMockKeycloakUser注解和實現類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。
測試示例
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));
}
}
引入keycloak-js,版本要與Keycloak Server一致。
...
"keycloak-js": "8.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)
.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實例時若未提供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'
},
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']
}
};
參數說明:
為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.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
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。