oauth2实现单点登录
电子书资源见
https://www.yuque.com/docs/share/37bd8227-eece-435a-9805-87254f4b34e9?# 《oauth2授权》
blog 地址 https://youngboy.vip
oauth2是什么?
OAuth2: OAuth2(开放授权)是一个开放标准,允许用户授权第三方网站访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方网站或分享他们数据的所有内容。
举个栗子:第三方网站登录,oschina通过gitee登录
访问oschina,点击登录可以选择使用gitee登录,gitee登录之后,oschina就能获取到登录的gitee账号相关信息
根据以上示例,可以将OAuth2分为四个角色:
- Resource Owner:资源所有者 即上述中的gitee用户
- Resource Server:资源服务器 即上述中的gitee服务器,提供gitee用户基本信息给到第三方应用
- Client:第三方应用客户端 即上述中oschina网站
- Authorication Server:授权服务器 该角色可以理解为管理其余三者关系的中间层
四种授权模式的使用场景
模式 | 适用场景 |
---|---|
授权码模式 | 适用于网页第三方授权 |
密码模式 | 适用于手机等客户端使用 |
客户端模式 | 适用于授权接口给第三方客户端 |
简化模式 | 适用于web端应用 |
简化模式
客户端模式
密码模式
授权码模式
四种模式的选择流程
参考文章 http://www.ruanyifeng.com/blog/2019/04/oauth-grant-types.html
常见安全问题
授权码安全
open redirect 攻击
Referer 攻击
redirect_url 控制攻击
凭证安全
保证client_secret的保密性,不能在日志中输出client_secret或者用户的密码或token
csrf攻击
cross-site request forgery (CSRF)
授权码模式和简化模式都应该带上state参数,防止可能的csrf攻击
官方原文6
An opaque value used by the client to maintain state between the request and callback.
The authorization server includes this value when redirecting the user-agent back to the
client. The parameter SHOULD be used for preventing cross-site request forgery (CSRF).
安全建议
- 客户端带上state参数,登陆页面接口请求都要加上 CSRF token校验
- 日志脱敏,避免泄露凭证或token
- 全站https并开启HSTS防止请求劫持
- 禁止登陆页面被嵌入iframe
- 避免在url参数中传递token,游览器的referrer头可能会泄露token·
- 选择正确的授权模式,手机端游览器禁止使用简化模式,前端页面禁止使用密码模式(前端不能保存client_secret)
- 前端后端开启xss过滤器
- access_token有效期不应该大于通常使用时间
- 使用成熟的oauth2框架
Tips:如果您想对OAuth2.0开放标准进行扩展阅读,请参看:OAuth标准(英文) | OAuth维基百科(中文)
SSO单点登录
sso 是什么?
SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
权限控制主要分两步
- Authentication 认证 (证明自己是自己)
- Authorization 授权(证明自己是自己后能够做什么)
登录系统
首先,我们要为“登录”做一个简要的定义,令后续的讲述更准确。之前的两篇文章有意无意地混淆了“登录”与“身份验证”的说法,因为在本篇之前,不少“传统Web应用”都将对身份的识别看作整个登录的过程,很少出现像企业应用环境中那样复杂的情景和需求。但从之前的文章中我们看到,现代Web应用对身份验证相关的需求已经向复杂化发展了。
我们有必要重新认识一下登录系统。 登录指的是从识别用户身份,到允许用户访问其权限相应的资源的过程。 举个例子,在网上买好了票之后去影院观影的过程就是一个典型的登录过程:我们先去取票机,输入验证码取票;接着拿到票去影厅检票进入。取票的过程即身份验证,它能够证明我们拥有这张票;而后面检票的过程,则是授权访问的过程。之所以要分成这两个过程,最直接的原因还是业务形态本身具有复杂性——如果观景过程是免费匿名的,也就免去了这些过程。
在登录的过程中,“鉴权”与“授权”是两个最关键的过程。接下来要介绍的一些技术和实践,也包含在这两个方面中。虽然现代Web应用的登录需求比较复杂,但只要处理好了鉴权和授权两个方面,其余各个方面的问题也将迎刃而解。在现代Web应用的登录工程实践中,需要结合传统Web应用的典型实践,以及一些新的思路,才能既解决好登录需求,又能符合Web的轻量级架构思路。
解析常见的登录场景
在简单的Web系统中,典型的鉴权也就是要求用户输入并比对用户名和密码的过程,而授权则是确保会话Cookie存在。而在稍微复杂的Web系统中,则需要考虑多种鉴权方式,以及多种授权场景。上一篇文章中所述的“多种登录方式”和“双因子鉴权”就是多种鉴权方式的例子。有经验的人经常调侃说,只要理解了鉴权与授权,就能清晰地理解登录系统了。不光如此,这也是安全登录系统的基础所在。
鉴权的形式丰富多彩,有传统的用户名密码对、客户端证书,有人们越来越熟悉的第三方登录、手机验证,以及新兴的扫码和指纹等方式,它们都能用于对用户的身份进行识别。在成功识别用户之后,在用户访问资源或执行操作之前,我们还需要对用户的操作进行授权。
在一些特别简单的情形中——用户一经识别,就可以无限制地访问资源、执行所有操作——系统直接对所有“已登录的人”放行。比如高速公路收费站,只要车辆有合法的号牌即可放行,不需要给驾驶员发一张用于指示“允许行驶的方向或时间”的票据。除了这类特别简单的情形之外,授权更多时候是比较复杂的工作。
在单一的传统Web应用中,授权的过程通常由会话Cookie来完成——只要服务器发现浏览器携带了对应的Cookie,即允许用户访问资源、执行操作。而在浏览器之外,例如在Web API调用、移动应用和富 Web 应用等场景中,要提供安全又不失灵活的授权方式,就需要借助令牌技术。
令牌
令牌是一个在各种介绍登录技术的文章中常被提及的概念,也是现代Web应用系统中非常关键的技术。令牌是一个非常简单的概念,它指的是在用户通过身份验证之后,为用户分配的一个临时凭证。在系统内部,各个子系统只需要以统一的方式正确识别和处理这个凭证即可完成对用户的访问和操作进行授权。在上文所提到的例子中,电影票就是一个典型的令牌。影厅门口的工作人员只需要确认来客手持印有对应场次的电影票即视为合法访问,而不需要理会客户是从何种渠道取得了电影票(比如自行购买、朋友赠予等),电影票在本场次范围内可以持续使用(比如可以中场出去休息等)、过期作废。通过电影票这样一个简单的令牌机制,电影票的出售渠道可以丰富多样,检票人员的工作却仍然简单轻松。
OAuth 2、Open ID Connect
令牌在广为使用的OAuth技术中被采用来完成授权的过程。OAuth是一种开放的授权模型,它规定了一种供资源拥有方与消费方之间简单又直观的交互方法,即从消费方向资源拥有方发起使用AccessToken(访问令牌)签名的HTTP请求。这种方式让消费方应用在无需(也无法)获得用户凭据的情况下,只要用户完成鉴权过程并同意消费方以自己的身份调用数据和操作,消费方就可以获得能够完成功能的访问令牌。OAuth简单的流程和自由的编程模型让它很好地满足了开放平台场景中授权第三方应用使用用户数据的需求。不少互联网公司建设开放平台,将它们的用户在其平台上的数据以 API 的形式开放给第三方应用来使用,从而让用户享受更丰富的服务。
OAuth在各个开放平台的成功使用,令更多开发者了解到它,并被它简单明确的流程所吸引。此外,OAuth协议规定的是授权模型,并不规定访问令牌的数据格式,也不限制在整个登录过程中需要使用的鉴权方法。人们很快发现,只要对OAuth进行合适的利用即可将其用于各种自有系统中的场景。例如,将 Web 服务视作资源拥有方,而将富Web应用或者移动应用视作消费方应用,就与开放平台的场景完全吻合。
另一个大量实践的场景是基于OAuth的单点登录。OAuth并没有对鉴权的部分做规定,也不要求在握手交互过程中包含用户的身份信息,因此它并不适合作为单点登录系统来使用。然而,由于OAuth的流程中隐含了鉴权的步骤,因而仍然有不少开发者将这一鉴权的步骤用作单点登录系统,这也俨然衍生成为一种实践模式。更有人将这个实践进行了标准化,它就是Open ID Connect——基于OAuth的身份上下文协议,通过它即可以JWT的形式安全地在多个应用中共享用户身份。接下来,只要让鉴权服务器支持较长的会话时间,就可以利用OAuth为多个业务系统提供单点登录功能了。
我们还没有讨论OAuth对鉴权系统的影响。实际上,OAuth对鉴权系统没有影响,在它的框架内,只是假设已经存在了一种可用于识别用户的有效机制,而这种机制具体是怎么工作的,OAuth并不关心。因此我们既可以使用用户名密码(大多数开放平台提供商都是这种方式),也可以使用扫码登录来识别用户,更可以提供诸如“记住密码”,或者双因子验证等其他功能。
汇总
上面罗列了大量术语和解释,那么具体到一个典型的Web系统中,又应该如何对安全系统进行设计呢?综合这些技术,从端到云,从Web门户到内部服务,本文给出如下架构方案建议:
推荐为整个应用的所有系统、子系统都部署全程的HTTPS,如果出于性能和成本考虑做不到,那么至少要保证在用户或设备直接访问的Web应用中全程使用HTTPS。
用不同的系统分别用作身份和登录,以及业务服务。当用户登录成功之后,使用OpenID Connect向业务系统颁发JWT格式的访问令牌和身份信息。如果需要,登录系统可以提供多种登录方式,或者双因子登录等增强功能。作为安全令牌服务(STS),它还负责颁发、刷新、验证和取消令牌的操作。在身份验证的整个流程的每一个步骤,都使用OAuth及JWT中内置的机制来验证数据的来源方是可信的:登录系统要确保登录请求来自受认可的业务应用,而业务在获得令牌之后也需要验证令牌的有效性。
在Web页面应用中,应该申请时效较短的令牌。将获取到的令牌向客户端页面中以httponly的方式写入会话Cookie,以用于后续请求的授权;在后绪请求到达时,验证请求中所携带的令牌,并延长其时效。基于JWT自包含的特性,辅以完备的签名认证,Web 应用无需额外地维护会话状态。
在富客户端Web应用(单页应用),或者移动端、客户端应用中,可按照应用业务形态申请时效较长的令牌,或者用较短时效的令牌、配合专用的刷新令牌使用。
在Web应用的子系统之间,调用其他子服务时,可灵活使用“应用程序身份”(如果该服务完全不直接对用户提供调用),或者将用户传入的令牌直接传递到受调用的服务,以这种方式进行授权。各个业务系统可结合基于角色的访问控制(RBAC)开发自有专用权限系统。
http://insights.thoughtworkers.org/traditional-web-app-authentication/
各大开发平台oauth2的使用
- qq开发平台 https://wiki.open.qq.com/wiki/website/OAuth2.0%E7%AE%80%E4%BB%8B
- gitee平台 https://gitee.com/api/v5/oauth_doc#/
- 微信平台 https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Wechat_webpage_authorization.html
cas 解决方案
cas 是一种协议,cas server 是开源的实现
官网地址 https://www.apereo.org/projects/cas
cas 协议
CAS协议是一种简单且功能强大的基于票证(ticket)的协议。它涉及一个或多个客户端和一台服务器。中央身份验证服务(CAS)是Web的单点登录/单点退出协议。用户向中央CAS Server应用程序提供一次凭据(例如用户ID和密码),就可以访问多个应用程序。客户端嵌入在CASified应用程序中(称为“ CAS服务”),而CAS服务器是独立组件:
- CAS服务器负责验证用户并授予访问应用程序
- CAS客户保护CAS应用程序和检索CAS服务器的授权用户的身份。
关键概念:
- TGT存储在TGCcookie中的(“票证授予票证”)代表用户的SSO会话。
- ST(服务票据),作为传输GET参数的URL,代表由CAS服务器授予访问CASified应用程序对特定用户。
官方地址 https://www.apereo.org/projects/cas
流程参考 https://blog.csdn.net/isyoungboy/article/details/103242009[图片上传失败...(image-d12fde-1602203995839)]
cas server
cas server 不仅仅是对cas协议的实现,cas server 还实现了 oidc smal 等协议
部署要求
优点
缺点
keycloak 解决方案
Keycloak 是一个为浏览器和 RESTful Web 服务提供 SSO 的集成。基于OIDC规范。最开始是面向 JBoss 和 Wildfly 通讯
源码地址是:https://github.com/keycloak/keycloak/
部署要求
优点
- 支持ladp等中间件
- 功能丰富,有多种认证协议实现 cas oidc smal
- 自带有完善的用户角色组织管理系统,同时还有自带的web控制台管理用户角色组织客户端等信息
- 客户端支持齐全,支持spring security spring boot
缺点
spring security oauth2 解决方案
oauth2 只是授权协议并不包含完整的认证协议,需要在oauth2的基础上进行改造才能使用,需要写一些代码,亦可以使用oidc协议可以简单理解为(openid + oauth2),改动后以前的前端登陆实现都需要从项目中迁移出来到用户认证中心,用户统一到认证中心登陆(和qq github 等平台一样,上面的解决方案也差不多)
优点
可以灵活添加功能改动成本较小如果使用oidc协议,接入方可以选择标准的开源成熟的 oidc sdk,不需要再自己写sdk
缺点
oauth2 sso 简单认证流程
代号 | 描述 |
---|---|
UAA | 认证中心 |
A | 系统A |
B | 系统B |
USER | 用户 |
第一次访问A系统
[图片上传失败...(image-586f67-1602203995839)]> 访问A系统之后再访问B系统
[图片上传失败...(image-68bba7-1602203995839)]
现有用户体系问题
Q: 接入方系统以有现成的用户怎么解决?
A:接入方自己解决(接入方可以通过绑定已有用户,也可以新建用户)
权限控制问题
Q: 接入方系统有现成的用户权限怎么控制?
A: 单点登陆只管认证的问题,不需要考虑接入方的权限怎么控制
Q: 接入方系统怎么控制权限?
A: 使用用户详情接口获取用户详细信息,使用client的scope 或者 authorities
Q: 接入方系统与接入方系统间需要相互调用吗?
A: 不需要相互调用
单系统登出功能,全局登出功能
- “SSO” 是指单点登录。
- “SLO” 是指单一注销。
目前系统只实现了全局登出,参考了oidc使用前端游览器通知第三方退出的方法
参考链接
open id 官网 https://openid.net/connect/
oidc 文档 https://openid.net/specs/openid-connect-core-1_0.html
oidc core 协议 https://www.cnblogs.com/linianhui/p/openid-connect-core.html
oidc 认证流程 http://www.sohu.com/a/206818578_468635
基于spring security oauth2单点登录的实现
实现功能点
- 用户认证(用户名密码,钉钉扫码)
- 跨域名单点登录
- 统一登出(参考了oidc实现)
- 客户端token获取
- password模式token获取
- 简化模式token获取
spring security oauth2配置项
配置授权服务
@EnableAuthorizationServer
@Configuration
public class OAuth2ServerConfiguration extends AuthorizationServerConfigurerAdapter {
@Autowired
private UaaProperties uaaProperties;
@Autowired
private ApplicationContext applicationContext;
@Autowired
private RemoteClientDetailsService remoteClientDetailsService;
@Autowired
private RedisTokenStoreEnhance redisTokenStoreEnhance;
@Autowired
private AuthorizationCodeServices authorizationCodeServices;
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails(remoteClientDetailsService);//配置客户端详情服务
}
@Override
public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
//设置token的访问权限为permitAll 检查token 的权限为 isAuthenticated() 在httpSecurity 中配置权限是没用的
oauthServer
.tokenKeyAccess("permitAll()")//设置oauth/token_key 的访问权限
.checkTokenAccess("isAuthenticated()") //设置checkToken 接口的访问权限
.allowFormAuthenticationForClients();//设置允许Client通过Form认证,否则就只能使用basic认证,from认证相关过滤器为 ClientCredentialsTokenEndpointFilter
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
//配置token处理链
Collection
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(new ArrayList<>(tokenEnhancers));
//配置自定义授权码服务
endpoints.authorizationCodeServices(authorizationCodeServices);
endpoints
.tokenStore(tokenStore())//配置token储存
.tokenEnhancer(tokenEnhancerChain)//配置token处理链
.reuseRefreshTokens(false)
//设置自定义的oauth2异常处理器
.exceptionTranslator(new OAuth2ResponseExceptionTranslator()); //don't reuse or we will run into session inactivity timeouts
}
//把token转换为jwttoken的转换器
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new VerificationCode.RedisTokenConverter(redisTokenStoreEnhance);
KeyPair keyPair = new KeyStoreKeyFactory(
new ClassPathResource(uaaProperties.getKeyStore().getName()), uaaProperties.getKeyStore().getPassword().toCharArray())
.getKeyPair(uaaProperties.getKeyStore().getAlias());
converter.setKeyPair(keyPair);
return converter;
}
@Bean
public JwtTokenStore tokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
}
spring security oauth2 拓展项
定制用户确认授权页面
@Bean
public TokenStoreUserApprovalPlusHandler tokenStoreUserApprovalPlusHandler(TokenStore tokenStore, ClientDetailsService clientDetailsService, AuthorizationEndpoint authorizationEndpoint){
TokenStoreUserApprovalPlusHandler approvalHandler = new TokenStoreUserApprovalPlusHandler();
approvalHandler.setTokenStore(tokenStore);
approvalHandler.setClientDetailsService(clientDetailsService);
approvalHandler.setRequestFactory(new DefaultOAuth2RequestFactory(clientDetailsService));
authorizationEndpoint.setUserApprovalHandler(approvalHandler);
authorizationEndpoint.setUserApprovalPage("access");//设置授权页面为access
return approvalHandler;
}
定制授权码生成服务实现
@Component
public class RedisAuthorizationCodeServices extends RandomValueAuthorizationCodeServices {
@Autowired
private RedisTemplate redisTemplate;
/**
* 存储code到redis,并设置过期时间,10分钟
* value为OAuth2Authentication序列化后的字节
* 因为OAuth2Authentication没有无参构造函数
* redisTemplate.opsForValue().set(key, value, timeout, unit);
* 这种方式直接存储的话,redisTemplate.opsForValue().get(key)的时候有些问题,
* 所以这里采用最底层的方式存储,get的时候也用最底层的方式获取
*/
@Override
protected void store(String code, OAuth2Authentication authentication) {
redisTemplate.opsForValue().set(codeKey(code), authentication,10, TimeUnit.MINUTES);
}
@Override
protected OAuth2Authentication remove(String code) {
try{
return (OAuth2Authentication) redisTemplate.opsForValue().get(codeKey(code));
}catch (Exception e){
CodeAuthDTO codeAuthDTO = (CodeAuthDTO) redisTemplate.opsForValue().get(codeKey(code));
Set
OAuth2Request oAuth2Request = new OAuth2Request(codeAuthDTO.getRequestParameters(), codeAuthDTO.getClientId(), convertAuth(codeAuthDTO.getClientAuths()), codeAuthDTO.isApproved(), codeAuthDTO.getScope(), codeAuthDTO.getResourceIds(), codeAuthDTO.getRedirectUri(), codeAuthDTO.getResponseTypes(), codeAuthDTO.getExtensions());
return new OAuth2Authentication(oAuth2Request,new UsernamePasswordAuthenticationToken(codeAuthDTO.getPrincipal(),codeAuthDTO.getCredentials(),auths));
}
}
public Set
return auths.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toSet());
}
public Set
return auths.stream().map(GrantedAuthority::getAuthority).collect(Collectors.toSet());
}
private String codeKey(String code) {
return "oauth2:codes:" + code;
}
}
定制登出处理器(统一登出)
@Component
public class DefaultSSOLogoutSuccessHandler implements SSOLogoutSuccessHandler {
@Autowired
private RedisTokenStoreEnhance redisTokenStoreEnhance;
@Autowired
private ClientDetailsService clientDetailsService;
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication, Model model) {
//查询当前用户登录的客户端
Object principal = authentication.getPrincipal();
if(principal instanceof SystemUser){
String userName = ((SystemUser) principal).getUsername();
//通过用户登录名查询在线的客户端
Set
//构建登出地址
if(!CollectionUtils.isEmpty(clients)){
Set
Set
for (TokenAndClientId client : clients) {
String clientId = client.getClientId();
String token = client.getToken();
//记录要注销的token
tokens.add(token);
ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId);
Map
//获取客户端配置的登出地址
String logoutUrl = (String) additionalInformation.get("logoutUrl");
if(StringUtils.hasText(logoutUrl)){
//拼接客户端的退出url,参数为access_token
logoutUrls.add(resolveLogoutUrl(logoutUrl,token));
}
}
model.addAttribute("logoutUrls",logoutUrls);
//清空所有的授权token
redisTokenStoreEnhance.deleteUserAccessKey(userName,tokens);
}
}
}
public String resolveLogoutUrl(String logoutUrl,String token){
UriComponentsBuilder template = UriComponentsBuilder.fromUriString(logoutUrl);
template.queryParam("access_token",token);
return template.build().toUriString();
}
}
定制用户同意拒绝处理器
public class TokenStoreUserApprovalPlusHandler extends TokenStoreUserApprovalHandler {
private String scopePrefix = OAuth2Utils.SCOPE_PREFIX;
private ClientDetailsService clientDetailsService;
@Override
public Map
Map
String clientId = authorizationRequest.getClientId();
ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId);
userApprovalRequest.put("clientName",clientDetails.getAdditionalInformation().getOrDefault("clientName","未知客户端"));
userApprovalRequest.put("iconUrl",clientDetails.getAdditionalInformation().getOrDefault("iconUrl","未知客户端"));
String scope = (String) userApprovalRequest.getOrDefault("scope","user_info");
if(scope.indexOf(" ")==-1){
Constants.SCOPE_INFOS.stream().findFirst().filter(s->s.getName().equals(scope)).ifPresent(scopeInfo -> userApprovalRequest.put("scope_info", JSON.toJSONString(Arrays.asList(scopeInfo))));
}else {
List
.map(scopeInfo -> Constants.SCOPE_INFOS.stream().filter(s -> s.getName().equals(scopeInfo)).findAny().orElse(null))
.filter(i -> i != null)
.collect(Collectors.toList());
userApprovalRequest.put("scope_info", JSON.toJSONString(scopeInfos));
}
return userApprovalRequest;
}
@Override
public void setClientDetailsService(ClientDetailsService clientDetailsService) {
super.setClientDetailsService(clientDetailsService);
this.clientDetailsService = clientDetailsService;
}
@Override
public AuthorizationRequest updateAfterApproval(AuthorizationRequest authorizationRequest,
Authentication userAuthentication) {
// Get the approved scopes
Set
Set
Set
Date expiry = computeExpiry();
// Store the scopes that have been approved / denied
Map
for (String requestedScope : requestedScopes) {
String approvalParameter = scopePrefix + requestedScope;
String value = approvalParameters.get(approvalParameter);
value = value == null ? "" : value.toLowerCase();
if ("true".equals(value) || value.startsWith("approve")) {
approvedScopes.add(requestedScope);
approvals.add(new Approval(userAuthentication.getName(), authorizationRequest.getClientId(),
requestedScope, expiry, Approval.ApprovalStatus.APPROVED));
}
else {
approvals.add(new Approval(userAuthentication.getName(), authorizationRequest.getClientId(),
requestedScope, expiry, Approval.ApprovalStatus.DENIED));
}
}
boolean approved;
authorizationRequest.setScope(approvedScopes);
if (approvedScopes.isEmpty() && !requestedScopes.isEmpty()) {
approved = false;
}
else {
approved = true;
}
authorizationRequest.setApproved(approved);
return authorizationRequest;
}
private Date computeExpiry() {
Calendar expiresAt = Calendar.getInstance();
expiresAt.add(Calendar.MONTH, 1);
return expiresAt.getTime();
}
}
定制认证授权入口
package com.ecidi.uaa.security;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.;
import org.springframework.security.web.util.RedirectUrlBuilder;
import org.springframework.security.web.util.UrlUtils;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
public class OAuth2PlusAuthenticationEntryPoint implements AuthenticationEntryPoint , InitializingBean {
private PortMapper portMapper = new PortMapperImpl();
private PortResolver portResolver = new PortResolverImpl();
private String loginFormUrl;
private boolean forceHttps = false;
private boolean useForward = false;
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
public OAuth2PlusAuthenticationEntryPoint(String loginFormUrl) {
Assert.notNull(loginFormUrl, "loginFormUrl cannot be null");
this.loginFormUrl = loginFormUrl;
}
public void afterPropertiesSet() {
Assert.isTrue(
StringUtils.hasText(loginFormUrl)
&& UrlUtils.isValidRedirectUrl(loginFormUrl),
"loginFormUrl must be specified and must be a valid redirect URL");
if (useForward && UrlUtils.isAbsoluteUrl(loginFormUrl)) {
throw new IllegalArgumentException(
"useForward must be false if using an absolute loginFormURL");
}
Assert.notNull(portMapper, "portMapper must be specified");
Assert.notNull(portResolver, "portResolver must be specified");
}
protected String determineUrlToUseForThisRequest(HttpServletRequest request,
HttpServletResponse response, AuthenticationException exception) {
return getLoginFormUrl();
}
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
String redirectUrl = null;
if (useForward) {
if (forceHttps && "http".equals(request.getScheme())) {
// First redirect the current request to HTTPS.
// When that request is received, the forward to the login page will be
// used.
redirectUrl = buildHttpsRedirectUrlForRequest(request);
}
if (redirectUrl == null) {
String loginForm = determineUrlToUseForThisRequest(request, response,
authException);
if (log.isDebugEnabled()) {
log.debug("Server side forward to: " + loginForm);
}
RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);
dispatcher.forward(request, response);
return;
}
}
else {
redirectUrl = buildRedirectUrlToLoginPage(request, response, authException);
}
redirectStrategy.sendRedirect(request, response, redirectUrl);
}
protected String buildRedirectUrlToLoginPage(HttpServletRequest request,
HttpServletResponse response, AuthenticationException authException) {
String loginForm = determineUrlToUseForThisRequest(request, response,
authException);
if (UrlUtils.isAbsoluteUrl(loginForm)) {
return loginForm;
}
int serverPort = portResolver.getServerPort(request);
String scheme = request.getScheme();
RedirectUrlBuilder urlBuilder = new RedirectUrlBuilder();
urlBuilder.setScheme(scheme);
urlBuilder.setServerName(request.getServerName());
urlBuilder.setPort(serverPort);
urlBuilder.setContextPath(request.getContextPath());
urlBuilder.setPathInfo(loginForm);
if (forceHttps && "http".equals(scheme)) {
Integer httpsPort = portMapper.lookupHttpsPort(serverPort);
if (httpsPort != null) {
// Overwrite scheme and port in the redirect URL
urlBuilder.setScheme("https");
urlBuilder.setPort(httpsPort);
}
else {
log.warn("Unable to redirect to HTTPS as no port mapping found for HTTP port "
+ serverPort);
}
}
return urlBuilder.getUrl();
}
/*
* Builds a URL to redirect the supplied request to HTTPS. Used to redirect the
* current request to HTTPS, before doing a forward to the login page.
*/
protected String buildHttpsRedirectUrlForRequest(HttpServletRequest request) {
int serverPort = portResolver.getServerPort(request);
Integer httpsPort = portMapper.lookupHttpsPort(serverPort);
if (httpsPort != null) {
RedirectUrlBuilder urlBuilder = new RedirectUrlBuilder();
urlBuilder.setScheme("https");
urlBuilder.setServerName(request.getServerName());
urlBuilder.setPort(httpsPort);
urlBuilder.setContextPath(request.getContextPath());
urlBuilder.setServletPath(request.getServletPath());
urlBuilder.setPathInfo(request.getPathInfo());
urlBuilder.setQuery(request.getQueryString());
return urlBuilder.getUrl();
}
// Fall through to server-side forward with warning message
log.warn("Unable to redirect to HTTPS as no port mapping found for HTTP port "
+ serverPort);
return null;
}
public void setForceHttps(boolean forceHttps) {
this.forceHttps = forceHttps;
}
protected boolean isForceHttps() {
return forceHttps;
}
public String getLoginFormUrl() {
return loginFormUrl;
}
public void setPortMapper(PortMapper portMapper) {
Assert.notNull(portMapper, "portMapper cannot be null");
this.portMapper = portMapper;
}
protected PortMapper getPortMapper() {
return portMapper;
}
public void setPortResolver(PortResolver portResolver) {
Assert.notNull(portResolver, "portResolver cannot be null");
this.portResolver = portResolver;
}
protected PortResolver getPortResolver() {
return portResolver;
}
public void setUseForward(boolean useForward) {
this.useForward = useForward;
}
protected boolean isUseForward() {
return useForward;
}
}
定制用户认证过滤器
public class UsernamePasswordPlusAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
/*
* 认证类型 用户名密码认证 钉钉扫码认证
/
public static final String AUTH_TYPE = "encrypt_key";
private static final String PASSWORD_AUTH = "password";
private static final String DING_AUTH = "ding";
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "user[login]";
public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "encrypt_data[password]";
public static final String CODE_KEY = "code";
public static final String NOISE_KEY = "noise";
private String privateKey = "xxx";
private String authType = AUTH_TYPE;
private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;
private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;
private String codeParameter = CODE_KEY;
private String noiseParameter = NOISE_KEY;
private boolean postOnly = true;
private VerificationCode verificationCode;
public UsernamePasswordPlusAuthenticationFilter(AntPathRequestMatcher antPathRequestMatcher,VerificationCode verificationCode) {
super(antPathRequestMatcher);
this.verificationCode=verificationCode;
}
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
String authType = obtainAuthType(request);
AbstractAuthenticationToken authRequest = null;
if(PASSWORD_AUTH.equals(authType)){
//用户名密码认证
preCheckCode(request);
String username = obtainUsername(request);
String credentials = obtainPassword(request);
if (username == null) {
username = "";
}
if (credentials == null) {
credentials = "";
}
username = username.trim();
//验证码校验加在哪比较合适?加载过滤器这行注释之前比较合适,这样验证码错误不用每次都解密一遍密码
String decrcyptData = RSAUtils.decryptDataOnJava(credentials, privateKey);
String separator = (String) request.getSession().getAttribute("separator");
String cleanSeparator = separator.replace("");
String[] split = decrcyptData.split(cleanSeparator);
String csrf_token = split[0];//这里可以再校验一次csrf_token,不过能进到这里说明已经通过csrf校验了
String password= split[1];
authRequest = new UsernamePasswordAuthenticationToken(
username, password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
}
return this.getAuthenticationManager().authenticate(authRequest);
}
/
* 校验验证码
* @param request 请求对象
*/
private void preCheckCode(HttpServletRequest request) {
String code = obtainCode(request);
String noise = obtainNoise(request);
verificationCode.verifyCode(noise,code);
}
@Nullable
protected String obtainPassword(HttpServletRequest request) {
return request.getParameter(passwordParameter);
}
@Nullable
protected String obtainCode(HttpServletRequest request) {
return request.getParameter(codeParameter);
}
@Nullable
protected String obtainNoise(HttpServletRequest request) {
return request.getParameter(noiseParameter);
}
@Nullable
protected String obtainAuthType(HttpServletRequest request) {
return request.getParameter(authType);
}
@Nullable
protected String obtainUsername(HttpServletRequest request) {
return request.getParameter(usernameParameter);
}
protected void setDetails(HttpServletRequest request,
AbstractAuthenticationToken authRequest) {
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
}
public void setUsernameParameter(String usernameParameter) {
Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
this.usernameParameter = usernameParameter;
}
public void setPasswordParameter(String passwordParameter) {
Assert.hasText(passwordParameter, "Password parameter must not be empty or null");
this.passwordParameter = passwordParameter;
}
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
public final String getUsernameParameter() {
return usernameParameter;
}
public final String getPasswordParameter() {
return passwordParameter;
}
}
定制登录页面,授权页面
@Override
protected void configure(HttpSecurity http) throws Exception {
http.exceptionHandling()
.authenticationEntryPoint(new OAuth2PlusAuthenticationEntryPoint(LOGIN_PAGE)) //指定登录页
.accessDeniedHandler(new OAuth2AccessDeniedHandler())
.accessDeniedPage("/403")
.and()
.authorizeRequests()
.antMatchers("/asserts/**").permitAll()
.antMatchers("/oauth/token").permitAll()
.antMatchers("/oauth/authorize").permitAll()
.antMatchers("/code").permitAll()
.antMatchers("/login2").permitAll()
.antMatchers("/loginpage").permitAll()
.antMatchers("/userprofile").access("#oauth2.hasScope('user_info')")
.anyRequest().authenticated()
.and().sessionManagement().sessionAuthenticationErrorUrl(LOGIN_PAGE);//指定登录页
addUsernamePasswordPlusAuthenticationFilter(http);
}
@Bean
public TokenStoreUserApprovalPlusHandler tokenStoreUserApprovalPlusHandler(TokenStore tokenStore, ClientDetailsService clientDetailsService, AuthorizationEndpoint authorizationEndpoint){
TokenStoreUserApprovalPlusHandler approvalHandler = new TokenStoreUserApprovalPlusHandler();
approvalHandler.setTokenStore(tokenStore);
approvalHandler.setClientDetailsService(clientDetailsService);
approvalHandler.setRequestFactory(new DefaultOAuth2RequestFactory(clientDetailsService));
authorizationEndpoint.setUserApprovalHandler(approvalHandler);
authorizationEndpoint.setUserApprovalPage("access");//设置授权页
return approvalHandler;
}
oauth2权限控制
客户端模式接口权限控制
通过客户端模式获取的token没有用户信息,只有client的信息,client 可以通过 scope 字段控制权限,也可以通过自authority字段控制权限(权限用逗号隔开)
客户端信息存储表为 oauth_client_details
通过scope控制权限
oauth2表达式参考:org.springframework.security.oauth2.provider.expression.OAuth2SecurityExpressionMethods
.antMatchers("/userprofile").access("#oauth2.hasScope('user_info')")
.antMatchers("/userprofile").access("#oauth2.clientHasRole('ROLE_USER')")//判断是否有权限,spring security中role本质就是权限
oauth2权限表达式拓展类参考
org.springframework.security.oauth2.provider.expression.OAuth2WebSecurityExpressionHandler
用户接口权限控制
普通表达式参考:org.springframework.security.access.expression.SecurityExpressionOperations
.antMatchers("/asserts/**").permitAll()
.antMatchers("/oauth/token").permitAll()
.antMatchers("/oauth/authorize").permitAll()
网关统一鉴权
实现逻辑
spring cloud gateway api网关简单实现
基于spring-security-oauth2实现,(未实现鉴权策略)
定制认证管理器
@Component
public class OAuth2AuthenticationManager implements ReactiveAuthenticationManager {
@Autowired
private ResourceServerTokenServices tokenServices;
@Autowired(required = false)
private ClientDetailsService clientDetailsService;
private String resourceId;
@Override
public Mono
return Mono.defer(()->{
//获取token
String token = (String) authentication.getPrincipal();
//从tokenservice中加载用户凭证
OAuth2Authentication auth = tokenServices.loadAuthentication(token);
if (auth == null) {
throw new InvalidTokenException("Invalid token: " + token);
}
Collection
if (resourceId != null && resourceIds != null && !resourceIds.isEmpty() && !resourceIds.contains(resourceId)) {
throw new OAuth2AccessDeniedException("Invalid token does not contain resource id (" + resourceId + ")");
}
//检查客户端详情
checkClientDetails(auth);
if (authentication.getDetails() instanceof OAuth2AuthenticationDetails) {
OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails();
// Guard against a cached copy of the same details
if (!details.equals(auth.getDetails())) {
// Preserve the authentication details from the one loaded by token services
details.setDecodedDetails(auth.getDetails());
}
}
auth.setDetails(authentication.getDetails());
auth.setAuthenticated(true);
return Mono.just(auth);
});
}
/**
* 校验第三方客户端
*
* @param auth
*/
private void checkClientDetails(OAuth2Authentication auth) {
if (clientDetailsService != null) {
ClientDetails client;
try {
client = clientDetailsService.loadClientByClientId(auth.getOAuth2Request().getClientId());
}
catch (ClientRegistrationException e) {
throw new OAuth2AccessDeniedException("Invalid token contains invalid client id");
}
Set
for (String scope : auth.getOAuth2Request().getScope()) {
if (!allowed.contains(scope)) {
throw new OAuth2AccessDeniedException(
"Invalid token contains disallowed scope (" + scope + ") for this client");
}
}
}
}
}
定制权限管理器
public class OAuth2ReactiveAuthorizationManager
private static final SecurityExpressionHandler EXPRESSION_HANDLER = new OAuth2WebSecurityExpressionHandler();
private static final AuthorizationDecision ACCESS_DENIED = new AuthorizationDecision(false);
private static final AuthorizationDecision ACCESS_GRANTED = new AuthorizationDecision(true);
private Expression express;
public OAuth2ReactiveAuthorizationManager(Expression express) {
this.express = express;
}
@Override
public Mono
return authentication
.defaultIfEmpty(createAnonymouseAuthentication())
.map(a->{
StandardEvaluationContext ctx = createEvaluationContext(a);
return ExpressionUtils.evaluateAsBoolean(express, ctx) ?
ACCESS_GRANTED : ACCESS_DENIED;
});
}
private Authentication createAnonymouseAuthentication(){
return new AnonymousAuthenticationToken("anonymouse","anonymouse",Lists.newArrayList(new SimpleGrantedAuthority("anonymouse")));
}
//创建权限控制表达式上下文
private StandardEvaluationContext createEvaluationContext(Authentication authentication){
//创建root对象
SecurityExpressionRoot root = new WebFluxSecurityExpressRoot(authentication);
root.setPermissionEvaluator(new DenyAllPermissionEvaluator());
root.setTrustResolver( new AuthenticationTrustResolverImpl());
root.setDefaultRolePrefix("ROLE_");
//构建上下文对象,支持oauth2的权限校验方法 如 .antMatchers("/userprofile").access("#oauth2.hasScope('user_info')")
StandardEvaluationContext ctx = new StandardEvaluationContext();
ctx.setVariable("oauth2", new OAuth2SecurityExpressionMethods(authentication));
ctx.setRootObject(root);
return ctx;
}
public static List
ExpressionParser expressionParser = EXPRESSION_HANDLER.getExpressionParser();
//TODO 获取权限控制元信息 目前匹配器只支持 PathPatternParserServerWebExchangeMatcher
return apis.stream().map(api-> convertServerWebExchangeMatcherEntry(api,expressionParser)).collect(Collectors.toList());
}
public static ServerWebExchangeMatcherEntry
@NotNull String antPath = api.getAntPath();
@NotNull String method = api.getMethod();
@NotNull String express = api.getExpress();
//解析表达式
Expression expression = expressionParser.parseExpression(express);
//创建匹配器
ServerWebExchangeMatcher matcher = new PathPatternParserServerWebExchangeMatcher(antPath, method==null?null:HttpMethod.valueOf(method.toUpperCase()));
return new ServerWebExchangeMatcherEntry<>(matcher,new OAuth2ReactiveAuthorizationManager<>(expression));
}
}