一、单点登录SSO介绍
目前每家企业或者平台都存在不止一套系统,由于历史原因每套系统采购于不同厂商,所以系统间都是相互独立的,都有自己的用户鉴权认证体系,当用户进行登录系统时,不得不记住每套系统的用户名密码,同时,管理员也需要为同一个用户设置多套系统登录账号,这对系统的使用者来说显然是不方便的。我们期望的是如果存在多个系统,只需要登录一次就可以访问多个系统,只需要在其中一个系统执行注销登录操作,则所有的系统都注销登录,无需重复操作,这就是单点登录(Single Sign On 简称SSO)系统实现的功能。
单点登录是系统功能的定义,而实现单点登录功能,目前开源且流行的有CAS和OAuth2两种方式,过去我们用的最多的是CAS,现在随着SpringCloud的流行,更多人选择使用SpringSecurity提供的OAuth2认证授权服务器实现单点登录功能。
OAuth2是一种授权协议的标准,任何人都可以基于这个标准开发Oauth2授权服务器,现在百度开放平台、腾讯开放平台等大部分的开放平台都是基于OAuth2协议实现, OAuth2.0定义了四种授权类型,最新版OAuth2.1协议定义了七种授权类型,其中有两种因安全问题已不再建议使用:
【OAuth2.1 建议使用的五种授权类型】
【OAuth2.1 不建议/禁止使用的两种授权类型】
【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默认实现的授权类型】:
在GitEgg微服务框架中,gitegg-oauth已经引入了spring-security-oauth2,代码中使用了了Oauth2的密码授权和刷新令牌授权,并且自定义扩展了【短信验证码授权类型】和【图形验证码授权】,这其实是密码授权的扩展授权类型。
目前,基本上所有的SpringCloud微服务授权方式都是使用的OAuth2密码授权模式获取token,可能你会有疑惑,为什么上面最新的Oauth2协议已经不建议甚至是禁止使用密码授权类型了,而我们GitEgg框架的系统管理界面还要使用密码授权模式来获取token?因为不建议使用密码授权类型的原因是第三方客户端会收集用户名密码,存在安全风险。而在我们这里,我们的客户端是自有系统管理界面,不是第三方客户端,所有的用户名密码都是我们自有系统的用户名密码,只要做好系统安全防护,就可最大限度的避免用户名密码泄露给第三方的风险。
在使用spring-security-oauth2实现单点登录之前,首先我们一定要搞清楚单点登录SSO、OAuth2、spring-security-oauth2的区别和联系:
二、SpringSecurity单点登录服务端和客户端实现流程解析
单点登录业务流程时序图:
A系统(单点登录客户端)首次访问受保护的资源触发单点登录流程说明
B系统(单点登录客户端)访问受保护的资源流程说明
spring-security-oauth2 单点登录代码实现流程说明:
三、使用【授权码授权】和【刷新令牌授权】来实现单点登录服务器
1、自定义单点登录服务器页面
当我们的gitegg-oauth作为授权服务器使用时,我们希望定制自己的登录页等信息,下面我们自定义登录、主页、错误提示页、找回密码页。其他需要的页面可以自己定义,比如授权确认页,我们此处业务不需要用户二次确认,所以这里没有自定义此页面。
org.springframework.boot
spring-boot-starter-thymeleaf
/**
* 单点登录-登录页
* @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";
}
统一身份认证平台
GitEgg Cloud 统一身份认证平台
- 账号密码登录
- 验证码登录
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、授权服务器配置
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/assets/**", "/css/**", "/images/**");
}
# 以下配置为新增
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"
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参数。