一、单点登录SSO介绍
目前每家企业或者平台都存在不止一套系统,由于历史原因每套系统采购于不同厂商,所以系统间都是相互独立的,都有自己的用户鉴权认证体系,当用户进行登录系统时,不得不记住每套系统的用户名密码,同时,管理员也需要为同一个用户设置多套系统登录账号,这对系统的使用者来说显然是不方便的。我们期望的是如果存在多个系统,只需要登录一次就可以访问多个系统,只需要在其中一个系统执行注销登录操作,则所有的系统都注销登录,无需重复操作,这就是单点登录(Single Sign On 简称SSO)系统实现的功能。
单点登录是系统功能的定义,而实现单点登录功能,目前开源且流行的有CAS和OAuth2两种方式,过去我们用的最多的是CAS,现在随着SpringCloud的流行,更多人选择使用SpringSecurity提供的OAuth2认证授权服务器实现单点登录功能。
OAuth2是一种授权协议的标准,任何人都可以基于这个标准开发Oauth2授权服务器,现在百度开放平台、腾讯开放平台等大部分的开放平台都是基于OAuth2协议实现, OAuth2.0定义了四种授权类型,最新版OAuth2.1协议定义了七种授权类型,其中有两种因安全问题已不再建议使用:
【OAuth2.1 建议使用的五种授权类型】
- Authorization Code 【授权码授权】:用户通过授权服务器重定向URL返回到客户端后,应用程序从URL中获取授权码,并使用授权码请求访问令牌。
- PKCE【Proof Key for Code Exchange 授权码交换证明密钥】:授权码类型的扩展,用于防止CSRF和授权码注入攻击。
- Client Credentials【客户端凭证授权】:直接由客户端使用客户端 ID 和客户端密钥向授权服务器请求访问令牌,无需用户授权,通常用与系统和系统之间的授权。
- Device Code【设备代码授权】:用于无浏览器或输入受限的设备,使用提前获取好的设备代码获取访问令牌。
- Refresh Token【刷新令牌授权】:当访问令牌失效时,可以通过刷新令牌获取访问令牌,不需要用户进行交互。
【OAuth2.1 不建议/禁止使用的两种授权类型】
- Implicit Flow【隐式授权】:隐式授权是以前推荐用于本机应用程序和 JavaScript 应用程序的简化 OAuth 流程,其中访问令牌立即返回,无需额外的授权代码交换步骤。其通过HTTP重定向直接返回访问令牌,存在很大的风险,不建议使用,有些授权服务器直接禁止使用此授权类型。
- Password Grant【密码授权】:客户端通过用户名密码向授权服务器获取访问令牌。因客户端需收集用户名和密码,所以不建议使用,最新的 OAuth 2 安全最佳实践完全不允许密码授权。
【SpringSecurity对OAuth2协议的支持】:
通过SpringSecurity官网可知,通过长期的对OAuth2的支持,以及对实际业务的情景考虑,大多数的系统都不需要授权服务器,所以,Spring官方不再推荐使用spring-security-oauth2,SpringSecurity逐渐将spring-security-oauth2中的OAuth2登录、客户端、资源服务器等功能抽取出来,集成在SpringSecurity中,并单独新建spring-authorization-server项目实现授权服务器功能。
目前我们了解最多的是Spring Security OAuth对OAuth2协议的实现和支持,这里需要区分Spring Security OAuth和Spring Security是两个项目,过去OAth2相关功能都在Spring Security OAuth项目中实现,但是自SpringSecurity5.X开始,SpringSecurity项目开始逐渐增加Spring Security OAuth中的功能,自SpringSecurity5.2开始,添加了OAuth 2.0 登录, 客户端, 资源服务器的功能。但授权服务器的功能,并不打算集成在SpringSecurity项目中,而是新建了spring-authorization-server项目作为单独的授权服务器:详细介绍。spring-security实现的是OAuth2.1协议,spring-security-oauth2实现的是OAuth2.0协议。
Spring未来的计划是将 Spring Security OAuth 中当前的所有功能构建到 Spring Security 5.x 中。 在 Spring Security 达到与 Spring Security OAuth 的功能对等之后,他们将继续支持错误和安全修复至少一年。
【GitEgg框架单点登录实现计划】:
因spring-authorization-server目前最新发布版本0.2.3,部分功能仍在不断的修复和完善,还不足以应用到实际生产环境中,所以,我们目前使用spring-security-oauth2作为授权服务器,待后续spring-authorization-server发布稳定版本后,再进行迁移升级。
【spring-security-oauth2默认实现的授权类型】:
- 隐式授权(Implicit Flow)【spring-authorization-server不再支持此类型】
- 授权码授权(Authorization Code)
- 密码授权(Password Grant)【spring-authorization-server不再支持此类型】
- 客户端凭证授权(Client Credentials)
- 刷新令牌授权 (Refresh Token)
在GitEgg微服务框架中,gitegg-oauth已经引入了spring-security-oauth2,代码中使用了了Oauth2的密码授权和刷新令牌授权,并且自定义扩展了【短信验证码授权类型】和【图形验证码授权】,这其实是密码授权的扩展授权类型。
目前,基本上所有的SpringCloud微服务授权方式都是使用的OAuth2密码授权模式获取token,可能你会有疑惑,为什么上面最新的Oauth2协议已经不建议甚至是禁止使用密码授权类型了,而我们GitEgg框架的系统管理界面还要使用密码授权模式来获取token?因为不建议使用密码授权类型的原因是第三方客户端会收集用户名密码,存在安全风险。而在我们这里,我们的客户端是自有系统管理界面,不是第三方客户端,所有的用户名密码都是我们自有系统的用户名密码,只要做好系统安全防护,就可最大限度的避免用户名密码泄露给第三方的风险。
在使用spring-security-oauth2实现单点登录之前,首先我们一定要搞清楚单点登录SSO、OAuth2、spring-security-oauth2的区别和联系:
- 单点登录SSO是一种系统登录解决方案的定义,企业内部系统登录以及互联网上第三方QQ、微信、GitHub登录等都是单点登录。
- OAuth2是一种系统授权协议,它包含多种授权类型,我们可以使用授权码授权和刷新令牌授权两种授权类型来实现单点登录功能。
- spring-security-oauth2是对OAuth2协议中授权类型的具体实现,也是我们实现单点登录功能实际用到的代码。
二、SpringSecurity单点登录服务端和客户端实现流程解析
单点登录业务流程时序图:
A系统(单点登录客户端)首次访问受保护的资源触发单点登录流程说明
- 1、用户通过浏览器访问A系统被保护的资源链接
- 2、A系统判断当前会话是否登录,如果没有登录则跳转到A系统登录地址/login
- 3、A系统首次接收到/login请求时没有state和code参数,此时A系统拼接系统配置的单点登录服务器授权url,并重定向至授权链接。
- 4、单点登录服务器判断此会话是否登录,如果没有登录,那么返回单点登录服务器的登录页面。
- 5、用户在登录页面填写用户名、密码等信息执行登录操作。
- 6、单点登录服务器校验用户名、密码并将登录信息设置到上下文会话中。
- 7、单点登录服务器重定向到A系统的/login链接,此时链接带有code和state参数。
- 8、A系统再次接收到/login请求,此请求携带state和code参数,系统A通过OAuth2RestTemplate请求单点登录服务端/oauth/token接口获取token。
- 9、A系统获取到token后,首先会对token进行解析,并使用配置的公钥对token进行校验(非对称加密),如果校验通过,则将token设置到上下文,下次访问请求时直接从上下文中获取。
- 10、A系统处理完上下问会话之后重定向到登录前请求的受保护资源链接。
B系统(单点登录客户端)访问受保护的资源流程说明
- 1、用户通过浏览器访问B系统被保护的资源链接
- 2、B系统判断当前会话是否登录,如果没有登录则跳转到B系统登录地址/login
- 3、B系统首次接收到/login请求时没有state和code参数,此时B系统拼接系统配置的单点登录服务器授权url,并重定向至授权链接。
- 4、单点登录服务器判断此会话是否登录,因上面访问A系统时登陆过,所以此时不会再返回登录界面。
- 5、单点登录服务器重定向到B系统的/login链接,此时链接带有code和state参数。
- 6、B系统再次接收到/login请求,此请求携带state和code参数,系统B通过OAuth2RestTemplate请求单点登录服务端/oauth/token接口获取token。
- 7、B系统获取到token后,首先会对token进行解析,并使用配置的公钥对token进行校验(非对称加密),如果校验通过,则将token设置到上下文,下次访问请求时直接从上下文中获取。
- 8、B系统处理完上下问会话之后重定向到登录前请求的受保护资源链接。
spring-security-oauth2 单点登录代码实现流程说明:
- 1、用户通过浏览器访问单点登录被保护的资源链接
- 2、SpringSecurity通过上下文判断是否登录(SpringSecurity单点登录服务端和客户端默认都是基于session的),如果没有登录则跳转到单点登录客户端地址/login
- 3、单点登录客户端OAuth2ClientAuthenticationProcessingFilter拦截器通过上下文获取token,因第一次访问单点登录客户端/login时,没有code和state参数,所以抛出UserRedirectRequiredException异常
- 4、单点登录客户端捕获UserRedirectRequiredException异常,并根据配置文件中的配置,组装并跳转到单点登录服务端的授权链接/oauth/authorize,链接及请求中会带相关配置参数
- 5、单点登录服务端收到授权请求,根据session判断是否此会话是否登录,如果没有登录则跳转到单点登录服务器的统一登录界面(单点登录服务端也是根据session判断是否登录的,在这里为了解决微服务的session集群共享问题,引入了spring-session-data-redis)
- 6、用户完成登录操作后,单点登录服务端重定向到单点登录客户端的/login链接,此时链接带有code和state参数
- 7、再次用到第三步的OAuth2ClientAuthenticationProcessingFilter拦截器通过上下文获取token,此时上下文中肯定没有token,所以会通过OAuth2RestTemplate请求单点登录服务端/oauth/token接口使用重定向获得的code和state换取token
- 8、单点登录客户端获取到token后,首先会对token进行解析,并使用配置的公钥对token进行校验(非对称加密),如果校验通过,则将token设置到上下文,下次访问请求时直接从上下文中获取。
- 9、单点登录客户端处理完上下问会话之后重定向到登录前请求的受保护资源链接。
三、使用【授权码授权】和【刷新令牌授权】来实现单点登录服务器
1、自定义单点登录服务器页面
当我们的gitegg-oauth作为授权服务器使用时,我们希望定制自己的登录页等信息,下面我们自定义登录、主页、错误提示页、找回密码页。其他需要的页面可以自己定义,比如授权确认页,我们此处业务不需要用户二次确认,所以这里没有自定义此页面。
- 在gitegg-oauth工程的pom.xml中添加Thymeleaf依赖,作为Spring官方推荐的模板引擎,我们使用Thymeleaf来实现前端页面的渲染展示。
org.springframework.boot
spring-boot-starter-thymeleaf
- 在GitEggOAuthController中新增页面跳转路径
/**
* 单点登录-登录页
* @return
*/
@GetMapping("/login") public String login() {
return "login";
}
/**
* 单点登录-首页:当直接访问单点登录系统成功后进入的页面。从客户端系统进入的,直接返回到客户端页面
* @return
*/
@GetMapping("/index") public String index() {
return "index";
}
/**
* 单点登录-错误页
* @return
*/
@GetMapping("/error") public String error() {
return "error";
}
/**
* 单点登录-找回密码页
* @return
*/
@GetMapping("/find/pwd") public String findPwd() {
return "findpwd";
}
-
在resources目录下新建static(静态资源)目录和templates(页面代码)目录,新增favicon.ico文件
- 自定义登录页login.html代码
统一身份认证平台
GitEgg Cloud 统一身份认证平台
- 账号密码登录
- 验证码登录
- 自定义登录login.js代码
var countdown=60;
jQuery(function ($) {
countdown = 60;
$('.account-form').bootstrapValidator({
message: '输入错误',
feedbackIcons: {
valid: 'glyphicon glyphicon-ok',
invalid: 'glyphicon glyphicon-remove',
validating: 'glyphicon glyphicon-refresh'
},
fields: {
username: {
container: '.input-account-wrapper',
message: '输入错误',
validators: {
notEmpty: {
message: '用户账号不能为空'
},
stringLength: {
min: 2,
max: 32,
message: '账号长度范围2-32个字符。'
},
regexp: {
regexp: /^[a-zA-Z0-9_\.]+$/,
message: '用户名只能由字母、数字、点和下划线组成'
}
}
},
password: {
container: '.input-psw-wrapper',
validators: {
notEmpty: {
message: '密码不能为空'
},
stringLength: {
min: 5,
max: 32,
message: '密码长度范围6-32个字符。'
}
}
}
}
});
$('.mobile-form').bootstrapValidator({
message: '输入错误',
feedbackIcons: {
valid: 'glyphicon glyphicon-ok',
invalid: 'glyphicon glyphicon-remove',
validating: 'glyphicon glyphicon-refresh'
},
fields: {
phone: {
message: '输入错误',
container: '.input-phone-wrapper',
validators: {
notEmpty: {
message: '手机号不能为空'
},
regexp: {
regexp: /^1\d{10}$/,
message: '手机号格式错误'
}
}
},
code: {
container: '.input-sms-wrapper',
validators: {
notEmpty: {
message: '验证码不能为空'
},
stringLength: {
min: 6,
max: 6,
message: '验证码长度为6位。'
}
}
}
}
});
var options={
beforeSerialize: beforeFormSerialize,
success: formSuccess,//提交成功后执行的回掉函数
error: formError,//提交失败后执行的回掉函数
headers : {"TenantId" : 0},
clearForm: true,//提交成功后是否清空表单中的字段值
restForm: true,//提交成功后是否充值表单中的字段值,即恢复到页面加载是的状态
timeout: 6000//设置请求时间,超过时间后,自动退出请求,单位(毫秒)
}
var mobileOptions={
success: mobileFormSuccess,//提交成功后执行的回掉函数
error: mobileFormError,//提交失败后执行的回掉函数
headers : {"TenantId" : 0},
clearForm: true,//提交成功后是否清空表单中的字段值
restForm: true,//提交成功后是否充值表单中的字段值,即恢复到页面加载是的状态
timeout: 6000//设置请求时间,超过时间后,自动退出请求,单位(毫秒)
}
function beforeFormSerialize(){
$("#account-err").html("");
$("#username").val($.trim($("#username").val()));
$("#password").val($.md5($.trim($("#password").val())));
}
function formSuccess(response){
$(".account-form").data('bootstrapValidator').resetForm();
if (response.success)
{
window.location.href = response.targetUrl;
}
else
{
$("#account-err").html(response.message);
}
}
function formError(response){
$("#account-err").html(response);
}
function mobileFormSuccess(response){
$(".mobile-form").data('bootstrapValidator').resetForm();
if (response.success)
{
window.location.href = response.targetUrl;
}
else
{
$("#mobile-err").html(response.message);
}
}
function mobileFormError(response){
$("#mobile-err").html(response);
}
$(".account-form").ajaxForm(options);
$(".mobile-form").ajaxForm(mobileOptions);
$(".nav-left a").click(function(e){
$(".account-login").show();
$(".mobile-login").hide();
});
$(".nav-right a").click(function(e){
$(".account-login").hide();
$(".mobile-login").show();
});
$("#forget").click(function(e){
window.location.href = "/find/pwd";
});
$('.title-list li').click(function(){
var liindex = $('.title-list li').index(this);
$(this).addClass('on').siblings().removeClass('on');
$('.login_right div.login-form-container').eq(liindex).fadeIn(150).siblings('div.login-form-container').hide();
var liWidth = $('.title-list li').width();
if (liindex == 0)
{
$('.login_right .title-list p').css("transform","translate3d(0px, 0px, 0px)");
}
else {
$('.login_right .title-list p').css("transform","translate3d("+liWidth+"px, 0px, 0px)");
}
});
});
function sendCode(){
$(".mobile-form").data('bootstrapValidator').validateField('phone');
if(!$(".mobile-form").data('bootstrapValidator').isValidField("phone"))
{
return;
}
if(countdown != 60)
{
return;
}
sendmsg();
var phone = $.trim($("#phone").val());
var tenantId = $("#tenantId").val();
$.ajax({
//请求方式
type : "POST",
//请求的媒体类型
contentType: "application/x-www-form-urlencoded;charset=UTF-8",
dataType: 'json',
//请求地址
url : "/code/sms/login",
//数据,json字符串
data : {
tenantId: tenantId,
phoneNumber: phone,
code: "aliValidateLogin"
},
//请求成功
success : function(result) {
$("#smsId").val(result.data);
},
//请求失败,包含具体的错误信息
error : function(e){
console.log(e);
}
});
};
function sendmsg(){
if(countdown==0){
$("#sendBtn").css("color","#181818");
$("#sendBtn").html("获取验证码");
countdown=60;
return false;
}
else{
$("#sendBtn").css("color","#74777b");
$("#sendBtn").html("重新发送("+countdown+")");
countdown--;
}
setTimeout(function(){
sendmsg();
},1000);
}
2、授权服务器配置
- 修改web安全配置WebSecurityConfig,将静态文件添加到不需要授权就能访问
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/assets/**", "/css/**", "/images/**");
}
- 修改Nacos配置,将新增页面访问路径添加到访问白名单,使资源服务器配置ResourceServerConfig中的配置不进行鉴权就能够访问,同时增加tokenUrls配置,此配置在网关不进行鉴权,但是需要OAuth2进行Basic鉴权,授权码模式必须要用到此鉴权。
# 以下配置为新增
whiteUrls:
- "/gitegg-oauth/oauth/login"
- "/gitegg-oauth/oauth/find/pwd"
- "/gitegg-oauth/oauth/error"
authUrls:
- "/gitegg-oauth/oauth/index"
whiteUrls:
- "/*/v2/api-docs"
- "/gitegg-oauth/oauth/public_key"
- "/gitegg-oauth/oauth/token_key"
- "/gitegg-oauth/find/pwd"
- "/gitegg-oauth/code/sms/login"
- "/gitegg-oauth/change/password"
- "/gitegg-oauth/error"
- "/gitegg-oauth/oauth/sms/captcha/send"
# 新增OAuth2认证接口,此处网关放行,由认证中心进行认证
tokenUrls:
- "/gitegg-oauth/oauth/token"
- 因GitEgg框架使用用户名+密码再加密存储的密码,所以这里需要自定义登录过滤器来做相应处理,也可以用同样的方式新增手机验证码登录、扫码登录等功能。
package com.gitegg.oauth.filter;
import cn.hutool.core.bean.BeanUtil;
import com.gitegg.oauth.token.PhoneAuthenticationToken;
import com.gitegg.platform.base.constant.AuthConstant;
import com.gitegg.platform.base.domain.GitEggUser;
import com.gitegg.platform.base.result.Result;
import com.gitegg.service.system.client.feign.IUserFeign;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.util.StringUtils;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 自定义登陆
* @author GitEgg
*/
public class GitEggLoginAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
public static final String SPRING_SECURITY_RESTFUL_TYPE_PHONE = "phone";
public static final String SPRING_SECURITY_RESTFUL_TYPE_QR = "qr";
public static final String SPRING_SECURITY_RESTFUL_TYPE_DEFAULT = "user";
// 登陆类型:user:用户密码登陆;phone:手机验证码登陆;qr:二维码扫码登陆
private static final String SPRING_SECURITY_RESTFUL_TYPE_KEY = "type";
// 登陆终端:1:移动端登陆,包括微信公众号、小程序等;0:PC后台登陆
private static final String SPRING_SECURITY_RESTFUL_MOBILE_KEY = "mobile";
private static final String SPRING_SECURITY_RESTFUL_USERNAME_KEY = "username";
private static final String SPRING_SECURITY_RESTFUL_PASSWORD_KEY = "password";
private static final String SPRING_SECURITY_RESTFUL_PHONE_KEY = "phone";
private static final String SPRING_SECURITY_RESTFUL_VERIFY_CODE_KEY = "code";
private static final String SPRING_SECURITY_RESTFUL_QR_CODE_KEY = "qrCode";
@Autowired
private IUserFeign userFeign;
private boolean postOnly = true;
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (postOnly && !"POST".equals(request.getMethod())) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
String type = obtainParameter(request, SPRING_SECURITY_RESTFUL_TYPE_KEY);
String mobile = obtainParameter(request, SPRING_SECURITY_RESTFUL_MOBILE_KEY);
AbstractAuthenticationToken authRequest;
String principal;
String credentials;
// 手机验证码登陆
if(SPRING_SECURITY_RESTFUL_TYPE_PHONE.equals(type)){
principal = obtainParameter(request, SPRING_SECURITY_RESTFUL_PHONE_KEY);
credentials = obtainParameter(request, SPRING_SECURITY_RESTFUL_VERIFY_CODE_KEY);
principal = principal.trim();
authRequest = new PhoneAuthenticationToken(principal, credentials);
}
// 账号密码登陆
else {
principal = obtainParameter(request, SPRING_SECURITY_RESTFUL_USERNAME_KEY);
credentials = obtainParameter(request, SPRING_SECURITY_RESTFUL_PASSWORD_KEY);
Result
四、实现单点登录客户端
spring-security-oauth2提供OAuth2授权服务器的同时也提供了单点登录客户端的实现,通用通过几行注解即可实现单点登录功能。
1、新建单点登录客户端工程,引入oauth2客户端相关jar包
org.springframework.boot
spring-boot-starter-oauth2-client
org.springframework.boot
spring-boot-starter-security
org.springframework.security.oauth.boot
spring-security-oauth2-autoconfigure
2、新建WebSecurityConfig类,添加@EnableOAuth2Sso注解
@EnableOAuth2Sso
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.csrf().disable();
}
}
3、配置单点登录服务端相关信息
server:
port: 8080
servlet:
context-path: /ssoclient1
security:
oauth2:
client:
# 配置在授权服务器配置的客户端id和secret
client-id: ssoclient
client-secret: 123456
# 获取token的url
access-token-uri: http://127.0.0.1/gitegg-oauth/oauth/token
# 授权服务器的授权地址
user-authorization-uri: http://127.0.0.1/gitegg-oauth/oauth/authorize
resource:
jwt:
# 获取公钥的地址,验证token需使用,系统启动时会初始化,不会每次验证都请求
key-uri: http://127.0.0.1/gitegg-oauth/oauth/token_key
备注:
1、GitEgg框架中自定义了token返回格式,SpringSecurity获取token的/oauth/token默认返回的是ResponseEntity
2、Gateway网关鉴权需要的公钥地址是gitegg-oauth/oauth/public_key,单点登录客户端需要公钥地址
/oauth/token_key,两者返回的格式不一样,需注意区分。
3、请求/oauth/tonen和/oauth/token_key时,默认都需要使用Basic认证,也就是请求时需添加client_id和client_security参数。
GitEgg-Cloud是一款基于SpringCloud整合搭建的企业级微服务应用开发框架,开源项目地址:
Gitee: https://gitee.com/wmz1930/GitEgg
GitHub: https://github.com/wmz1930/GitEgg
欢迎感兴趣的小伙伴Star支持一下。