SAS-Saml2-Login-Grant
SpringBoot集成文档
- Spring Security Github
- Spring Security Samples Github
- Spring Security Samples SAML2 Github
- Spring Security Document
- Spring Security Document SAML2
- SAML-2.0-Migration-Guide
文章
- How to configure Keeper SSO Connect Cloud with Microsoft AD FS for seamless and secure SAML 2.0 authentication
- Active Directory 联合身份验证服务概述
- 如何将Spring Security 集成 SAML2 ADFS 实现SSO单点登录?
SAML认证 Demo
- Spring Security SAML Login Logout
- Spring Security SAML Migrate
- spring-security-saml2-azure-ad-example
- https://download.csdn.net/blog/column/12259144/131198433
一些可本地部署的Idp服务
Okta
- Okta
- Okta Spring Security SAML
- Springboot、React集成Okta SAML2单点登录
- Get Started with Spring Boot and SAML
- Okta Spring Boot SAML Example Github
一、前言
二、分析
三、准备
1. 域名准备
| 模块 | 域名 | IP地址 | 备注 |
|---|---|---|---|
| IDP | idp.light.local | 172.18.0.99 | Docker容器 |
| SP | sp.light.local | 192.168.137.1 | 物理机 |
需要将此Hosts配置到IDP和SP服务上
cat >> /etc/hosts << EOF
172.100.0.99 idp.light.local
192.168.137.1 sp.light.local
EOF
因为Docker容器的IP映射到了物理机上,所以物理机的Hosts中IP可以都设置为 127.0.0.1
172.100.0.99 idp.light.local
192.168.137.1 sp.light.local
2. 部署一个SAML2 Identity Provider (IdP)
# 创建证书目录
mkdir -p keycloak/{certs,data}
cd keycloak/certs
# 生成私钥和自签名证书
openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout light.local.key -out light.local.crt -subj "/C=CN/ST=Beijing/L=Beijing/O=Light/CN=*.light.local" -addext "subjectAltName=DNS:*.light.local,DNS:localhost"
# 转换为 PKCS12 格式(Java/Keycloak 使用)
openssl pkcs12 -export -in light.local.crt -inkey light.local.key -out light.local.p12 -name keycloak -passout pass:changeit
# 生成 JKS 格式(备用)
keytool -importkeystore -srckeystore light.local.p12 -srcstoretype pkcs12 -srcstorepass changeit -destkeystore light.local.jks -deststoretype JKS -deststorepass changeit
# 将证书导入本地JVM 防止程序解析HTTPS报错
cd D:\Develop\jdk\graalvm-openjdk-21
.\bin\keytool -import -alias light.local -keystore D:\Develop\jdk\graalvm-openjdk-21\lib\security\cacerts -file .\certs\light.local.crt -storepass changeit
services:
keycloak:
image: quay.io/keycloak/keycloak:24.0.0
container_name: idp_keycloak
hostname: idp.light.local
networks:
default: null
develop:
ipv4_address: 172.100.0.99
aliases:
- idp.light.local
dns:
- 192.168.137.1
- 8.8.8.8
extra_hosts:
- idp.light.local:172.100.0.99
- sp.light.local:192.168.137.1
ports:
- 8443:8443 # HTTPS
- 8880:8080 # HTTP(可选,用于测试)
expose:
- 8080
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://sp.light.local:5432/keycloak
KC_DB_SCHEMA: public
KC_DB_USER: keycloak
KC_DB_PASSWORD: keycloak
# 管理员凭据
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
# 外部访问使用的域名端口信息,与 KC_HOSTNAME_URL 二选一
# KC_HOSTNAME: idp.light.local
# KC_HOSTNAME_PORT: 443
# 外部访问使用的域名地址,解决初始化403问题
KC_HOSTNAME_URL: https://idp.light.local:8443
KC_HOSTNAME_ADMIN_URL: https://idp.light.local:8443
# 配置上下文地址
# KEYCLOAK_FRONTEND_URL: https://idp.light.local/auth
# KC_HTTP_RELATIVE_PATH: /auth
# 关闭HTTPS强制校验
KC_HTTP_PORT: 8080
KC_HTTP_ENABLED: true
KC_HOSTNAME_PORT: 8443
KC_HOSTNAME_STRICT: false
KC_HOSTNAME_STRICT_HTTPS: false
# PROXY_ADDRESS_FORWARDING: true
# HTTPS 配置
KC_HTTPS_CERTIFICATE_FILE: /opt/keycloak/conf/server.crt
KC_HTTPS_CERTIFICATE_KEY_FILE: /opt/keycloak/conf/server.key
KC_HTTPS_PORT: 8443
# 其他配置
KC_HEALTH_ENABLED: true
KC_METRICS_ENABLED: true
KC_LOG_LEVEL: INFO
# KC_LOG_CONSOLE_OUTPUT: json
# KC_LOG_CONSOLE_COLOR: false
volumes:
- ./certs/light.local.crt:/opt/keycloak/conf/server.crt:ro
- ./certs/light.local.key:/opt/keycloak/conf/server.key:ro
# - ./data:/opt/keycloak/data
command:
- start
- --hostname-strict=false
- --https-port=8443
- --https-certificate-file=/opt/keycloak/conf/server.crt
- --https-certificate-key-file=/opt/keycloak/conf/server.key
- --spi-login-protocol-openid-connect-legacy-logout-redirect-uri=true
restart: no
networks:
develop:
external: true
docker run --detach \
--publish 8880:8080 \
--env KEYCLOAK_ADMIN=admin \
--env KEYCLOAK_ADMIN_PASSWORD=admin \
--env KC_BOOTSTRAP_ADMIN_USERNAME=admin \
--env KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
--env KC_HOSTNAME=idp.light.local \
--env KC_HOSTNAME_STRICT=false \
--env KC_HOSTNAME_STRICT_HTTPS=false \
--env KC_PROXY=edge \
--env KC_HTTP_ENABLED=true \
--env KC_HOSTNAME_PORT=8880 \
--ip 172.100.0.99 \
--hostname idp.light.local \
--add-host sp.light.local:192.168.137.1 \
--network develop \
--restart=no \
--name keycloak-test \
quay.io/keycloak/keycloak:24.0 start-dev
docker exec -it -u root keycloak /bin/bash
# 查看域名解析
cat /etc/hosts
# idp的ip为容器虚拟ip,用于自身识别
# sp的ip为物理机的ip,用于访问sp
172.100.0.99 idp.light.local
192.168.137.1 sp.light.local
- Dashboard HTTPs Dashboard
- admin / admin
关于Keycloak的使用配置见Spring-Security-Saml-With-Keycloak
3. 生成客户端证书秘钥
openssl req -newkey rsa:2048 -nodes -keyout sp-private.key -x509 -days 365 -out sp-certificate.crt
生成备用
- 将私钥和证书复制到SpringBoot项目中,在SP收发IDP信息时加解密使用
- 将证书导入到SAML IDP服务器中
三、编码
1. 引入依赖
添加OpenSAML仓库地址,原因见Why Shibboleth DONOT publish jar to Maven Central
<repository>
<id>shibboleth-repos</id>
<name>Shibboleth Repository</name>
<url>https://build.shibboleth.net/maven/releases/</url>
</repository>
添加SAML2集成依赖
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-saml2-service-provider</artifactId>
</dependency>
2. 配置 application.yaml
spring:
security:
saml2:
relyingparty:
registration:
# 依赖方的实体ID,任意的值,你可以选择它来区分不同的注册
# SP元数据:http://sp.light.local:8080/saml2/service-provider-metadata/keycloak
keycloak:
# entity-id 需要和keycloak的client_id保持一致,否则会认证失败
# entity-id: "{baseUrl}"
entity-id: "saml_client"
# 用于构建签名和解密的 Saml2X509Credential
signing:
credentials:
- private-key-location: classpath:credentials/rp-private.key
# 证书文件需要导入到Client中
certificate-location: classpath:credentials/rp-certificate.crt
acs:
# 登录的回调地址,即客户端的 Master SAML Processing URL
location: "{baseUrl}/login/saml2/sso/{registrationId}"
# 登出配置
singlelogout:
binding: POST
# 退出登录的回调地址 Valid post logout redirect URIs
url: "{baseUrl}/logout/saml2/slo"
responseUrl: "{baseUrl}/logout/saml2/slo"
assertingparty:
entity-id: http://idp.light.local:8880/realms/Test
# IDP的 元数据访问地址
metadata-uri: http://idp.light.local:8880/realms/Test/protocol/saml/descriptor
singlesignon:
# 登录认证的地址,从元数据中获取
url: http://idp.light.local:8880/auth/realms/Test/protocol/saml
sign-request: false
# 此证书配置可以不要,元数据中有证书信息
verification:
credentials:
# IDP 的证书,从元数据中获取
- certificate-location: classpath:credentials/keycloak/keycloak.crt
3. SamlAuthenticationConfig
package com.light.sas.authorization.saml2;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.security.saml2.Saml2RelyingPartyProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.saml2.core.Saml2X509Credential;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal;
import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrations;
import org.springframework.security.web.SecurityFilterChain;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPrivateKey;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Configuration
public class SamlAuthenticationConfig {
@Resource
private ProviderManager authenticationManager;
@Resource
private OpenSaml4AuthenticationProvider openSaml4AuthenticationProvider;
@Bean
public SecurityFilterChain samlSecurityFilterChain(HttpSecurity http) throws Exception {
List<AuthenticationProvider> providers = authenticationManager.getProviders();
providers.add(new SamlLoginAuthenticationProvider(openSaml4AuthenticationProvider));
// SAML2
http
.saml2Login((saml2) ->
saml2.loginPage("/saml2/login")
.loginProcessingUrl("/login/saml2/sso/{registrationId}")
.authenticationManager(authenticationManager)
)
.saml2Logout(Customizer.withDefaults())
.saml2Metadata(Customizer.withDefaults());
return http.build();
}
//@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/error").permitAll()
.anyRequest().authenticated()
)
// 自定义URL示例
// .saml2Login((saml2) -> saml2.loginProcessingUrl("/saml/SSO"))
// .saml2Logout((saml2) -> saml2.logoutRequest((request) -> request.logoutUrl("/saml/logout")))
// .saml2Logout((saml2) -> saml2.logoutResponse((response) -> response.logoutUrl("/saml/SingleLogout")))
// .saml2Metadata((saml2) -> saml2.metadataUrl("/saml/metadata"))
// 使用默认的URL示例
// /login/saml2/sso/{registrationId}
.saml2Login(Customizer.withDefaults())
// /logout/saml2/slo
.saml2Logout(Customizer.withDefaults())
// /saml2/service-provider-metadata/{registrationId}
.saml2Metadata(Customizer.withDefaults());
// @formatter:on
return http.build();
}
@Bean
public OpenSaml4AuthenticationProvider openSaml4AuthenticationProvider() {
OpenSaml4AuthenticationProvider authenticationProvider = new OpenSaml4AuthenticationProvider();
authenticationProvider.setResponseAuthenticationConverter(groupsConverter());
return authenticationProvider;
}
@Bean
public InMemoryRelyingPartyRegistrationRepository repositories(Saml2RelyingPartyProperties properties,
@Value("classpath:credentials/rp-private.key") RSAPrivateKey key,
@Value("classpath:credentials/rp-certificate.crt") File cert) {
List<RelyingPartyRegistration> registrationList = new ArrayList<>();
Saml2X509Credential credential = Saml2X509Credential.signing(key, x509Certificate(cert));
Map<String, Saml2RelyingPartyProperties.Registration> registrationMap = properties.getRegistration();
for (Map.Entry<String, Saml2RelyingPartyProperties.Registration> entry : registrationMap.entrySet()) {
String registrationId = entry.getKey();
Saml2RelyingPartyProperties.Registration registration = entry.getValue();
List<RelyingPartyRegistration> registrations = RelyingPartyRegistrations
.collectionFromMetadataLocation(registration.getAssertingparty().getMetadataUri())
.stream().map((builder) -> builder.registrationId(registrationId)
.entityId(registration.getEntityId())
.assertionConsumerServiceLocation(registration.getAcs().getLocation())
.singleLogoutServiceLocation(registration.getSinglelogout().getUrl())
.singleLogoutServiceResponseLocation(registration.getSinglelogout().getResponseUrl())
.signingX509Credentials((credentials) -> credentials.add(credential)).build()
).toList();
registrationList.addAll(registrations);
}
return new InMemoryRelyingPartyRegistrationRepository(registrationList);
}
private Converter<OpenSaml4AuthenticationProvider.ResponseToken, Saml2Authentication> groupsConverter() {
Converter<OpenSaml4AuthenticationProvider.ResponseToken, Saml2Authentication> delegate =
OpenSaml4AuthenticationProvider.createDefaultResponseAuthenticationConverter();
return (responseToken) -> {
Saml2Authentication authentication = delegate.convert(responseToken);
Saml2AuthenticatedPrincipal principal = (Saml2AuthenticatedPrincipal) authentication.getPrincipal();
List<String> groups = principal.getAttribute("groups");
Set<GrantedAuthority> authorities = new HashSet<>();
if (groups != null) {
groups.stream().map(SimpleGrantedAuthority::new).forEach(authorities::add);
} else {
authorities.addAll(authentication.getAuthorities());
}
return new Saml2Authentication(principal, authentication.getSaml2Response(), authorities);
};
}
public X509Certificate x509Certificate(File location) {
try (InputStream source = new FileInputStream(location)) {
return (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(source);
} catch (CertificateException | IOException ex) {
throw new IllegalArgumentException(ex);
}
}
}
4. SamlLoginAuthenticationProvider
package com.light.sas.authorization.saml2;
import cn.hutool.core.util.ArrayUtil;
import com.light.sas.constant.SamlParameterNames;
import com.light.sas.constant.SecurityConstants;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.saml2.provider.service.authentication.DefaultSaml2AuthenticatedPrincipal;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* SAML登录认证提供者
* @see OpenSaml4AuthenticationProvider
*/
public class SamlLoginAuthenticationProvider implements AuthenticationProvider {
private final OpenSaml4AuthenticationProvider delegate;
public SamlLoginAuthenticationProvider(OpenSaml4AuthenticationProvider delegate) {
this.delegate = delegate;
}
@Override
public boolean supports(Class<?> authentication) {
String loginType = getLoginType(SecurityConstants.LOGIN_TYPE_NAME);
return delegate.supports(authentication) || SamlParameterNames.THIRD_LOGIN_SAML.equalsIgnoreCase(loginType);
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Authentication authenticate = delegate.authenticate(authentication);
if (Objects.nonNull(authenticate) && authenticate.isAuthenticated()) {
syncSamlUser(authenticate.getPrincipal());
}
return authenticate;
}
/**
* 将SAML用户同步到系统
*
* @param principal
*/
public void syncSamlUser(Object principal) {
Map<String, Object> userInfo = new HashMap<>();
if (principal instanceof DefaultSaml2AuthenticatedPrincipal saml2Principal) {
String name = saml2Principal.getName();
Map<String, List<Object>> attributes = saml2Principal.getAttributes();
List<String> sessionIndexes = saml2Principal.getSessionIndexes();
String relyingPartyRegistrationId = saml2Principal.getRelyingPartyRegistrationId();
userInfo.put(SecurityConstants.LOGIN_TYPE_NAME, SamlParameterNames.THIRD_LOGIN_SAML);
userInfo.put(SamlParameterNames.NAME, name);
userInfo.put(SamlParameterNames.REGISTRATION_ID, relyingPartyRegistrationId);
userInfo.putAll(attributes);
}
// TODO 保存到数据库
System.out.println("同步用户信息:" + userInfo);
}
/**
* 从Query参数,Header Cookie中依次读取请求类型
*
* @param name 参数名称
* @return 参数值
*/
public String getLoginType(String name) {
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();
// 从参数读取
String value = request.getParameter(name);
if (!StringUtils.hasText(value)) {
// 从Header读取
value = request.getHeader(name);
}
Cookie[] cookies = request.getCookies();
if (!StringUtils.hasText(value) && ArrayUtil.isNotEmpty(cookies)) {
// 从Cookie读取
value = Arrays.stream(cookies)
.filter(cookie -> cookie.getName().equals(name))
.findFirst().map(Cookie::getName).orElse(null);
}
return value;
}
}
5. SamlParameterNames
package com.light.sas.constant;
/**
* Saml认证相关常量参数
*/
public class SamlParameterNames {
/**
* 三方登录类型——Saml
*/
public static final String THIRD_LOGIN_SAML = "saml";
/**
* 自定义 grant type —— Saml
*/
public static final String GRANT_TYPE_LDAP = "urn:ietf:params:oauth:grant-type:saml";
public static final String NAME = "name";
public static final String REGISTRATION_ID = "registrationId";
}
6. CORS配置
Keycloak 认证响应头中 Origin: null ,需要在跨域配置中允许这个Origin,否则会出现 403 Forbidden Invalid CORS Request
package com.light.sas.config;
import com.light.sas.authorization.baisc.BasicAuthorizationRequestResolver;
import com.light.sas.authorization.baisc.adapter.AuthorizationRequestCustomizerAdapter;
import com.light.sas.authorization.baisc.adapter.OAuth2AccessTokenResponseClientAdapter;
import com.light.sas.authorization.baisc.adapter.OAuth2UserRequestEntityConverterAdapter;
import com.light.sas.authorization.baisc.delegator.AuthorizationRequestCustomizerDelegator;
import com.light.sas.authorization.baisc.delegator.OAuth2AccessTokenResponseClientDelegator;
import com.light.sas.authorization.baisc.delegator.OAuth2UserRequestEntityConverterDelegator;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.List;
/**
* 将bean注入至ioc的配置类
*/
@Configuration
@EnableConfigurationProperties
public class BeanConfig {
/**
* 跨域过滤器配置
*
* @return CorsFilter
*/
@Bean
public CorsFilter corsFilter() {
// 初始化cors配置对象
CorsConfiguration configuration = new CorsConfiguration();
// 设置允许跨域的域名,如果允许携带cookie的话,路径就不能写*号, *表示所有的域名都可以跨域访问
configuration.addAllowedOrigin("http://127.0.0.1:5173");
configuration.addAllowedOrigin("http://192.168.3.49:5173");
configuration.addAllowedOrigin("null");
// 设置跨域访问可以携带cookie
configuration.setAllowCredentials(true);
// 允许所有的请求方法 ==> GET POST PUT Delete
configuration.addAllowedMethod("*");
// 允许携带任何头信息
configuration.addAllowedHeader("*");
// 初始化cors配置源对象
UrlBasedCorsConfigurationSource configurationSource = new UrlBasedCorsConfigurationSource();
// 给配置源对象设置过滤的参数
// 参数一: 过滤的路径 == > 所有的路径都要求校验是否跨域
// 参数二: 配置类
configurationSource.registerCorsConfiguration("/**", configuration);
// 返回配置好的过滤器
return new CorsFilter(configurationSource);
}
/**
* 认证请求委托类,支持多个认证请求自定义
* @param customizers 自定义的认证请求处理类,如:微信 企业微信 钉钉等
* @return 认证请求委托对象
*/
@Bean
public AuthorizationRequestCustomizerDelegator authorizationRequestCustomizerDelegator(List<AuthorizationRequestCustomizerAdapter> customizers) {
return new AuthorizationRequestCustomizerDelegator(customizers);
}
/**
* Token响应委托类,支持多个Token响应自定义
* @param clients 自定义的Token响应处理类,如:微信 企业微信 钉钉等
* @return Token响应委托对象
*/
@Bean
public OAuth2AccessTokenResponseClientDelegator accessTokenResponseClientDelegator(List<OAuth2AccessTokenResponseClientAdapter> clients) {
return new OAuth2AccessTokenResponseClientDelegator(clients);
}
/**
* 用户请求委托类,支持多个用户请求自定义
* @param converters 自定义的用户请求转换器,如:微信 企业微信 钉钉等
* @return 用户请求委托对象
*/
@Bean
public OAuth2UserRequestEntityConverterDelegator requestEntityConverterDelegator(List<OAuth2UserRequestEntityConverterAdapter> converters) {
return new OAuth2UserRequestEntityConverterDelegator(converters);
}
/**
* 认证请求解析类
* @param clientRegistrationRepository 认证客户端持久层对象
* @param authorizationRequestCustomizerDelegator 认证请求委托对象
* @return 认证请求解析对象
*/
@Bean
public BasicAuthorizationRequestResolver basicAuthorizationRequestResolver(
ClientRegistrationRepository clientRegistrationRepository,
AuthorizationRequestCustomizerDelegator authorizationRequestCustomizerDelegator) {
// DI通过构造器自动注入clientRegistrationRepository,实例化DefaultOAuth2AuthorizationRequestResolver处理
DefaultOAuth2AuthorizationRequestResolver authorizationRequestResolver = new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository,
OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI);
// 兼容微信登录授权申请
authorizationRequestResolver.setAuthorizationRequestCustomizer(authorizationRequestCustomizerDelegator);
return new BasicAuthorizationRequestResolver(authorizationRequestResolver);
}
}
7. 登录页添加SAML登录
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport"
content="width=device-width, initial-scale=1 minimum-scale=1 maximum-scale=1 user-scalable=no"/>
<link rel="stylesheet" href="./assets/css/style.css" type="text/css"/>
<title>统一认证平台</title>
</head>
<body>
<div class="bottom-container">
</div>
<!-- <div th:if="${error}" class="alert" id="alert">
<div class="error-alert">
<img src="./image/logo.png" alt="logo" width="30">
<div th:text="${error}">
</div>
</div>
</div> -->
<div id="error_box">
</div>
<div class="form-container">
<form class="form-signin" method="post" th:action="@{/login}">
<input type="hidden" id="loginType" name="loginType" value="passwordLogin"/>
<input type="hidden" id="captchaId" name="captchaId" value=""/>
<!-- <div th:if="${param.error}" class="alert alert-danger" role="alert" th:text="${param}">
Invalid username or password.
</div>
<div th:if="${param.logout}" class="alert alert-success" role="alert">
你已经登出成功.
</div> -->
<!-- <div class="text-placeholder" style="padding-bottom: 20px;">-->
<!-- 平台登录-->
<!-- </div>-->
<div class="welcome-text">
<img src="./assets/img/logo.png" alt="logo" width="60">
<span>
统一认证平台
</span>
</div>
<div>
<input type="text" id="username" name="username" class="form-control" placeholder="手机 / 邮箱"
autofocus onblur="leave()"/>
</div>
<div id="passContainer">
<input type="password" id="password" name="password" class="form-control" placeholder="请输入密码"
onblur="leave()"/>
</div>
<div class="code-container" id="codeContainer">
<input type="text" id="code" name="code" class="form-control" placeholder="请输入验证码"
onblur="leave()"/>
<img src="" id="code-image" onclick="getVerifyCode()"/>
</div>
<div style="display: none; margin-bottom: 0" class="code-container" id="smsContainer">
<input type="text" name="" class="form-control" placeholder="请输入验证码" onblur="leave()"/>
<a id="getSmsCaptchaBtn" class="btn btn-light btn-block bg-white getCaptcha"
href="javascript:getSmsCaptcha()">获取验证码</a>
</div>
<div class="change-login-type">
<div></div>
<a id="changeLoginType" href="javascript:showSmsCaptchaPage()">短信验证登录</a>
</div>
<button class="btn btn-lg btn-primary btn-block" type="submit">登 录</button>
<div class="text-placeholder">
第三方登录
</div>
<!-- <a class="btn btn-light btn-block bg-white" href="/oauth2/authorization/gitee" role="link"
style="text-transform: none;">
Sign in with Gitee
</a>
<div>
<a class="btn btn-light bg-white" href="/oauth2/authorization/github" role="link"
style="text-transform: none;">
<img width="24" style="margin-right: 5px;" alt="Sign in with GitHub"
src="https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" />
Github
</a>
</div> -->
<div class="third-box">
<a href="/oauth2/authorization/gitee" title="Gitee登录">
<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg" name="zi_tmGitee"
viewBox="0 0 2000 2000">
<path fill="red"
d="M898 1992q183 0 344-69.5t283-191.5q122-122 191.5-283t69.5-344q0-183-69.5-344T1525 477q-122-122-283-191.5T898 216q-184 0-345 69.5T270 477Q148 599 78.5 760T9 1104q0 183 69.5 344T270 1731q122 122 283 191.5t345 69.5zm199-400H448q-17 0-30.5-14t-13.5-30V932q0-89 43.5-163.5T565 649q74-45 166-45h616q17 0 30.5 14t13.5 31v111q0 16-13.5 30t-30.5 14H731q-54 0-93.5 39.5T598 937v422q0 17 14 30.5t30 13.5h416q55 0 94.5-39.5t39.5-93.5v-22q0-17-14-30.5t-31-13.5H842q-17 0-30.5-14t-13.5-31v-111q0-16 13.5-30t30.5-14h505q17 0 30.5 14t13.5 30v250q0 121-86.5 207.5T1097 1592z"/>
</svg>
</a>
<a href="/oauth2/authorization/github" title="GitHub登录">
<img width="36" style="margin-right: 5px;" alt="Sign in with GitHub"
src="https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png"/>
</a>
<a href="/oauth2/authorization/wechat" title="Wechat登录">
<img width="28" style="margin-right: 5px; position: static;" alt="Sign in with Wechat"
src="./assets/img/wechat_login.png"/>
</a>
<a href="/saml2/authenticate/keycloak" title="Keycloak登录">
<img width="28" style="margin-right: 5px; position: static;" alt="Sign in with Keycloak"
src="./assets/img/keycloak.png"/>
</a>
</div>
</form>
</div>
</body>
</html>
<script>
function showSmsCaptchaPage() {
// 隐藏密码框
let passContainer = document.getElementById('passContainer');
passContainer.style.display = 'none';
// 设置password输入框的name为空
passContainer.children[0].setAttribute('name', '')
// 隐藏验证码框
let codeContainer = document.getElementById('codeContainer');
codeContainer.style.display = 'none';
// 设置登录类型为短信验证码
let loginType = document.getElementById('loginType');
loginType.value = 'smsCaptcha';
// 显示获取短信验证码按钮与输入框
let smsContainer = document.getElementById('smsContainer');
smsContainer.style.display = '';
smsContainer.children[0].setAttribute('name', 'password')
// 设置切换按钮文字与点击效果
let changeLoginType = document.getElementById('changeLoginType');
changeLoginType.innerText = '账号密码登录';
changeLoginType.setAttribute('href', 'javascript:showPasswordPage()')
changeLoginType.style.paddingTop = '25px';
changeLoginType.style.paddingBottom = '5px';
}
function showPasswordPage() {
// 显示密码框
let passContainer = document.getElementById('passContainer');
passContainer.style.display = '';
// 设置password输入框
passContainer.children[0].setAttribute('name', 'password')
// 显示验证码框
let codeContainer = document.getElementById('codeContainer');
codeContainer.style.display = '';
// 设置登录类型为账号密码
let loginType = document.getElementById('loginType');
loginType.value = 'passwordLogin';
// 隐藏获取短信验证码按钮与输入框
let smsContainer = document.getElementById('smsContainer');
smsContainer.style.display = 'none';
smsContainer.children[0].setAttribute('name', '')
// 设置切换按钮文字与点击效果
let changeLoginType = document.getElementById('changeLoginType');
changeLoginType.innerText = '短信验证登录'
changeLoginType.setAttribute('href', 'javascript:showSmsCaptchaPage()')
changeLoginType.style.paddingTop = '0';
}
function leave() {
document.body.scrollTop = document.documentElement.scrollTop = 0;
}
function getVerifyCode() {
let requestOptions = {
method: 'GET',
redirect: 'follow'
};
fetch(`${window.location.origin}/getCaptcha`, requestOptions)
.then(response => response.text())
.then(r => {
if (r) {
let result = JSON.parse(r);
document.getElementById('captchaId').value = result.data.captchaId
document.getElementById('code-image').src = result.data.imageData
document.getElementById('code').value = result.data.code
document.getElementById('username').value = "admin"
document.getElementById('password').value = "123456"
}
})
.catch(error => console.log('error', error));
}
function getSmsCaptcha() {
let phone = document.getElementById('username').value;
if (phone === null || phone === '' || typeof phone === 'undefined') {
showError('手机号码不能为空.')
return;
}
// 禁用按钮
let getSmsCaptchaBtn = document.getElementById('getSmsCaptchaBtn');
getSmsCaptchaBtn.style.pointerEvents = 'none';
// 开始1分钟倒计时
resetBtn(getSmsCaptchaBtn);
let requestOptions = {
method: 'GET',
redirect: 'follow'
};
fetch(`${window.location.origin}/getSmsCaptcha?phone=${phone}`, requestOptions)
.then(response => response.text())
.then(r => {
if (r) {
let result = JSON.parse(r);
if (result.success) {
document.getElementById('username').value = "admin"
document.getElementsByName('password')[0].value = "1234"
showError('获取验证码成功.固定为:' + result.data)
}
}
})
.catch(error => console.log('error', error));
}
/**
* 1分钟倒计时
*/
function resetBtn(getSmsCaptchaBtn) {
let s = 60;
getSmsCaptchaBtn.innerText = `重新获取(${--s})`
// 定时器 每隔一秒变化一次(1000ms = 1s)
let t = setInterval(() => {
getSmsCaptchaBtn.innerText = `重新获取(${--s})`
if (s === 0) {
clearInterval(t)
getSmsCaptchaBtn.innerText = '获取验证码'
getSmsCaptchaBtn.style.pointerEvents = '';
}
}, 1000);
}
getVerifyCode();
</script>
<script th:inline="javascript">
function showError(message) {
let errorBox = document.getElementById("error_box");
errorBox.innerHTML = message;
errorBox.style.display = "block";
setTimeout(() => {
closeError();
}, 3000)
}
function closeError() {
let errorBox = document.getElementById("error_box");
errorBox.style.display = "none";
}
let error = [[${ error }]]
if (error) {
if (window.Notification) {
Notification.requestPermission(function () {
if (Notification.permission === 'granted') {
// 用户点击了允许
let n = new Notification('