千家信息网

Spring Boot/Angular整合Keycloak实现单点登录

发表于:2026-11-04 作者:千家信息网编辑
千家信息网最后更新 2026年11月04日,本文介绍了Keycloak基础知识、ADFS和Salesforce IDP配置、Spring Boot和Angular集成Keycloak实现单点登录的方法。本文代码以Angular 8集成Sprin
千家信息网最后更新 2026年11月04日Spring Boot/Angular整合Keycloak实现单点登录

本文介绍了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为现代应用和服务提供开源的认证和访问管理,即通常所说的认证和授权。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间的数据和配置是独立的。初始创建的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。

  • all requests 所有请求都需通过HTTPS访问
  • external requests localhost和私有IP不需通过HTTPS访问
  • none 任何客户端都不需HTTPS

Themes Tab可以配置界面主题、启用国际化:

Tokens Tab可以配置token签名算法、过期时间等。

Client

Client是realm中受信任的应用。

创建realm后自动创建以下client:

  • account 账户管理


如Realm配置中启用了User-Managed Access则可以管理自己的Resource:

  • admin-cli
  • broker
  • realm-management 预置了realm管理角色,创建realm管理员时需要分配这些角色
  • security-admin-console realm管理控制台

创建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/* 。

认证流程:

  • Standard Flow 即OAuth 2.0规范中的Authorization Code Flow,推荐使用的认证流程,安全性高。keycloak验证用户后附带一次性、临时的Authorization Code重定向到浏览器,浏览器凭此Code与keycloak交换token(identity、access和refresh token)
  • Implicit Flow keycloak验证用户后直接返回identity和access token
  • Direct Access Grants REST client获取token的方式,使用HTTP Post请求,响应结果包含access和refresh token

调用示例,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

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、Group、User

Role
Role分为两种级别:Realm、Client,默认Realm Role:offline_access、uma_authorization。

  • offline access
    OpenID规范中定义了offline access,用户登录获得offline token,当用户退出后offline token仍可使用。在很多场景中是非常有用的,比如每日离线备份数据。要获得offline token除需offline_access角色外,还需指定offline_access Scope。默认,offline token不会过期,但需每30天刷新一次。offline token可以撤销:
  • uma_authorization
    uma是User-Managed Access的缩写,Keycloak是符合UMA 2.0功能的授权服务器。

Role、Group和User的关系
User可以属于一个或多个Group,Role可以授予User和Group。
创建Realm管理用户
添加用户:

授予realm-management权限:

Authentication

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,点击认证方,根据说明操作即可:

Identity Provider

支持代理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中配置。

  1. 创建自签名证书和Java Keystore
$ keytool -genkey -alias sso.itrunner.org -keyalg RSA -keystore keycloak.jks -validity 10950Enter keystore password:Re-enter new password:What is your first and last name?  [Unknown]:  sso.itrunner.orgWhat is the name of your organizational unit?  [Unknown]:  itrunnerWhat is the name of your organization?  [Unknown]:  itrunnerWhat is the name of your City or Locality?  [Unknown]:  BeijingWhat is the name of your State or Province?  [Unknown]:  BeijingWhat is the two-letter country code for this unit?  [Unknown]:  CNIs CN=sso.itrunner.org, OU=itrunner, O=itrunner, L=Beijing, ST=Beijing, C=CN correct?  [no]:  yesEnter key password for         (RETURN if same as keystore password):Re-enter new password:
  1. 配置keycloak使用Keystore

将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。

ADFS

配置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下载。

  1. 配置Relying Party

进入AD FS管理控制台,右击Relying Party Trusts -> Add Relying Party Trust:

选择Claims aware -> Start:

导入之前的descriptor XML文件。

输入Display Name,接下来的设置保持默认值。

  1. 配置Claim映射

我们需要配置两个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

前提,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:

Spring Boot

采用Keycloak结合Spring security的方式。

POM Dependency

    ...            org.keycloak        keycloak-spring-boot-starter        ...                        org.keycloak.bom            keycloak-adapter-bom            8.0.1            pom            import            

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来创建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默认的角色前缀是 "ROLE_",为保持一致性,将KeycloakAuthenticationProvider的grantedAuthoritiesMapper设置为SimpleAuthorityMapper
  2. Session策略设置为NullAuthenticatedSessionStrategy,无状态REST不需Session
  3. 添加FilterRegistrationBean防止重复注册filter bean
  4. Spring Boot 2默认禁用spring.main.allow-bean-definition-overriding,给httpSessionManager方法添加@ConditionalOnMissingBean注解,否则会抛出BeanDefinitionOverrideException
  5. KeycloakRestTemplate扩展了RestTemplate,在受Keycloak保护的应用间调用时会自动验证,为启用这一功能必须添加KeycloakRestTemplate bean,KeycloakRestTemplate用法如下:

KeycloakRestTemplate

@Servicepublic class RemoteProductService {    @Autowired    private KeycloakRestTemplate template;    private String endpoint;    public List getProducts() {        ResponseEntity response = template.getForEntity(endpoint, String[].class);        return Arrays.asList(response.getBody());    }}

SpringBootApplication

默认,Keycloak Spring Security Adapter将查找keycloak.json配置文件, 为确保使用Keycloak Spring Boot Adapter的配置增加KeycloakSpringBootConfigResolver:

@SpringBootApplication@EnableJpaRepositories(basePackages = {"org.itrunner.heroes.repository"})@EntityScan(basePackages = {"org.itrunner.heroes.domain"})@EnableJpaAuditingpublic 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 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 getUsername() {        Optional accessToken = getAccessToken();        return accessToken.map(AccessToken::getPreferredUsername);    }    public static Optional getEmail() {        Optional accessToken = getAccessToken();        return accessToken.map(AccessToken::getEmail);    }}

TestRestTemplate测试

调用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 map = new LinkedMultiValueMap<>();        map.add("grant_type", "password");        map.add("client_id", "heroes");        map.add("username", "admin");        map.add("password", "admin");        HttpEntity> requestEntity = new HttpEntity<>(map, requestHeaders);        Map 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 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 urlVariables = new HashMap<>();        urlVariables.put("name", "m");        List 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 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测试

为了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 {    @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 principal = new KeycloakPrincipal<>("user-id", keycloakSecurityContext);        HashSet roles = new HashSet<>();        List 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@AutoConfigureMockMvcclass 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创建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 {    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 {    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 {    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 {    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 {    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 {    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 {    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 .   * 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 {    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配置

创建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']  }};

参数说明:

  • onLoad 可选值为login-required' 和 'check-sso',login-required将验证客户端,如果用户未登录则显示Keycloak登录页面;check-sso仅验证是否登录。如不需验证可以删除onLoad。
  • checkLoginIframe 是否启用login监控,若启用,默认间隔时间checkLoginIframeInterval为5秒
  • flow 可选值standard、implicit、hybrid,默认为standard

KeycloakBearerInterceptor

为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, next: HttpHandler): Observable> {    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, {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 {    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 {  return (): Promise => {    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集成完毕,启动服务后访问页面会自动跳转到Keycloak登录界面:

用户可以直接输入用户名/密码、可以选择IDP登录。
配置Keycloak IDP时可以控制是否在登录界面显示,认证流程中可以设置默认IDP,客户端调用时可以指定IDP,多种方式灵活组合可以满足不同需求。
指定IDP,Angular调用时仅需指定idpHint参数,其值为IDP的alias:

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

附录

Apache配置

keycloak.conf

ServerTokens ProdHeader always set Strict-Transport-Security "max-age=8640000; includeSubDomains; preload"Header always append X-Frame-Options SAMEORIGIN    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

参考文档

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

配置 用户 管理 登录 支持 认证 选择 流程 验证 应用 参数 客户 客户端 属性 方式 角色 认证流程 控制 不同 成功 数据库的安全要保护哪些东西 数据库安全各自的含义是什么 生产安全数据库录入 数据库的安全性及管理 数据库安全策略包含哪些 海淀数据库安全审计系统 建立农村房屋安全信息数据库 易用的数据库客户端支持安全管理 连接数据库失败ssl安全错误 数据库的锁怎样保障安全 互联网科技有限公司图片 平安科技 互联网 美国互联网科技 期待互联网科技 善班互联网科技有限公司 有木互联网科技 金融科技和互联网金融 科技赋能互联网健康 小米互联网科技园设置 中化能源互联网科技招聘 厦门比较好的科技互联网公司 想开互联网科技公司怎么样 浙南科技城互联网大厦建设工程 徐州市互联网科技有限公司 扫呗互联网科技有限公司 世界互联网科技成果奖 恒生科技指数 中概互联网 承德科技产业互联网平台 宁波允成互联网科技有限公司 株洲百竞科技的互联网营销顾问 易互联网科技有限公司 互联网 卓越科技 菲比寻常互联网科技 科技互联网创业计划书 互联网金融科技委员会 中国的互联网科技企业的崛起 华夏恒生科技与互联网科技 江苏互联网智能科技软件 数字科技和互联网金融哪个好 互联网企业和网络科技有限公司
0