本文已参与「新人创作礼」活动,一起开启掘金创作之路。
一,背景
单点登录顾名思义就是在多个应用系统中,只需要登录一次,就可以访问其他相互信任的应用系统,免除多次登录的烦恼。比如我们登录了百度账号,再去百度百科,百度文库就不需要再次登录了。
二,原理说明
单点登录主流都是基于共享 cookie 来实现的,下面分别介绍 同域 和 跨域 下两种场景具体怎样实现共享cookie的。
2.1. 同域单点登录
适用场景:都是企业自己的系统,所有系统都使用同一个一级域名通过不同的二级域名来区分。
举个例子:公司有一个一级域名为 xxx.com ,我们有三个系统分别是:门户系统(sso.xxx.com)、应用1(app1.xxx.com)和应用2(app2.xxx.com),需要实现系统之间的单点登录,实现架构如下:
编辑
核心原理:
-
门户系统设置 Cookie 的 domain 为一级域名也就是 xxx.com,这样就可以共享门户的 Cookie 给所有的使用该域名(xxx.xxx.com)的系统
-
使用Spring Session等技术让所有系统共享Session
-
这样只要门户系统登录之后无论跳转应用1或者应用2,都能通过门户Cookie中的sessionId读取到Session中的登录信息实现单点登录
2.2. 跨域单点登录
单点登录之间的系统域名不一样,例如第三方系统。由于域名不一样不能共享Cookie了,这样就需要通过一个单独的授权服务(UAA)来做统一登录,并基于共享UAA的Cookie来实现单点登录。
举个例子:有两个系统分别是:应用1(webApp.com)和应用2(xxx.com)需要实现单点登录,另外有一个授权中心(sso.com),实现架构如下:
编辑
核心原理:
-
访问系统1判断未登录,则跳转到UAA系统请求授权
-
在系统域名sso.com下的登录地址中输入用户名/密码完成登录
-
登录成功后UAA系统把登录信息保存到Session中,并在浏览器写入域为sso.com的Cookie
-
访问系统2判断未登录,则跳转到UAA系统请求授权
-
由于是跳转到UAA系统的域名sso.com下,所以能通过浏览器中UAA的Cookie读取到Session中之前的登录信息完成单点登录
三,技术实现
3.1,基于Spring Security实现(前后端不分离)
编辑
Oauth2单点登录除了需要授权中心完成统一登录/授权逻辑之外
各个系统本身(sso客户端)也需要实现以下逻辑:
-
拦截请求判断登录状态
-
与授权中心通过Oauth2授权码模式交互完成登录/单点登录
-
保存用户登录信息
以上逻辑只需使用一个 @EnableOAuth2Sso
注解即可实现
@EnableOAuth2Sso
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Value("${security.oauth2.sso.login-path:}")
private String loginPath;
@Resource
private LogoutSuccessHandler ssoLogoutSuccessHandler;
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated()
.and()
.csrf().disable()
.logout()
.logoutSuccessHandler(ssoLogoutSuccessHandler);
if (StrUtil.isNotEmpty(loginPath)) {
http.formLogin().loginProcessingUrl(loginPath);
}
}
}
@Component
public class SsoLogoutSuccessHandler implements LogoutSuccessHandler {
@Value("${zlt.logout-uri:''}")
private String logoutUri;
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
OAuth2Authentication oauth2Authentication = (OAuth2Authentication)authentication;
OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails)oauth2Authentication.getDetails();
String accessToken = details.getTokenValue();
redirectStrategy.sendRedirect(request, response, logoutUri+accessToken);
}
}
zlt:
api-uaa:
url: http://127.0.0.1:9900/api-uaa/oauth
logout-uri: ${zlt.api-uaa.url}/remove/token?redirect_uri=http://127.0.0.1:8080&access_token=
security:
oauth2:
sso:
login-path: /singleLogin
client:
client-id: zlt
client-secret: zlt
access-token-uri: ${zlt.api-uaa.url}/token
user-authorization-uri: ${zlt.api-uaa.url}/authorize
resource:
token-info-uri: ${zlt.api-uaa.url}/check_token
3.2,基于Spring Security实现(前后端分离)
跨域间的前后端分离项目也是基于共享统一授权服务的cookie来实现单点登录的,但是与非前后分离不一样的是存在以下问题需要解决
-
没有过滤器/拦截器,需要在前端判断登录状态
-
需要自己实现oauth2的授权码模式交互逻辑
-
需要解决安全性问题,oauth2的clientSecret参数放在前端不安全
下面是前后端分离项目的三个角色(前端WEB工程、后端API工程、授权中心)间进行登录/单点登录时的交互逻辑架构图
编辑
前端WEB工程有几个点需要注意:
-
红色线条为重定向跳转
-
前端工程可通过是否存在
access_token
判断登录状态 -
前端工程跳转UAA之前需记录用户访问的页面地址,方便登录完成后重定向回去
PS:为什么获取access_token需要请求后端API工程去完成,而不是前端WEB工程自己直接请求UAA呢?因为安全性问题!这一步需要传clientSecret参数,而通过后台来配置这个参数就不需要暴露给前端了。
@Slf4j
@RestController
public class ApiController {
@Value("${zlt.sso.client-id:}")
private String clientId;
@Value("${zlt.sso.client-secret:}")
private String clientSecret;
@Value("${zlt.sso.redirect-uri:}")
private String redirectUri;
@Value("${zlt.sso.access-token-uri:}")
private String accessTokenUri;
@Value("${zlt.sso.user-info-uri:}")
private String userInfoUri;
private final static Map<String, Map<String, Object>> localTokenMap = new HashMap<>();
@GetMapping("/token/{code}")
public String tokenInfo(@PathVariable String code) throws UnsupportedEncodingException {
//获取token
Map tokenMap = getAccessToken(code);
String accessToken = (String) tokenMap.get("access_token");
//获取用户信息
Map userMap = getUserInfo(accessToken);
List<String> roles = getRoles(userMap);
Map result = new HashMap(2);
String username = (String) userMap.get("username");
result.put("username", username);
result.put("roles", roles);
localTokenMap.put(accessToken, result);
return accessToken;
}
/**
* 获取token
*/
public Map getAccessToken(String code) throws UnsupportedEncodingException {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
byte[] authorization = (clientId + ":" + clientSecret).getBytes("UTF-8");
BASE64Encoder encoder = new BASE64Encoder();
String base64Auth = encoder.encode(authorization);
headers.add("Authorization", "Basic " + base64Auth);
MultiValueMap<String, String> param = new LinkedMultiValueMap<>();
param.add("code", code);
param.add("grant_type", "authorization_code");
param.add("redirect_uri", redirectUri);
param.add("scope", "app");
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(param, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(accessTokenUri, request, Map.class);
Map result = response.getBody();
return result;
}
/**
* 获取用户信息
*/
public Map getUserInfo(String accessToken) {
RestTemplate restTemplate = new RestTemplate();
Map result = restTemplate.getForObject(userInfoUri + "?access_token=" + accessToken, Map.class);
return (Map) result.get("datas");
}
private List<String> getRoles(Map userMap) {
List<Map<String, String>> roles = (List<Map<String, String>>) userMap.get("roles");
List<String> result = new ArrayList<>();
if (CollectionUtil.isNotEmpty(roles)) {
roles.forEach(e -> {
result.add(e.get("code"));
});
}
return result;
}
@GetMapping("/user")
public Map<String, Object> user(HttpServletRequest request) {
String token = request.getParameter("access_token");
return localTokenMap.get(token);
}
@GetMapping("/logoutNotify")
public void logoutNotify(HttpServletRequest request) {
String tokens = request.getParameter("tokens");
log.info("=====logoutNotify: " + tokens);
if (StrUtil.isNotEmpty(tokens)) {
for (String accessToken : tokens.split(",")) {
localTokenMap.remove(accessToken);
}
}
}
}
3.3,基于Security实现OIDC单点登录
OIDC
是 OpenID Connect 的简称,OIDC=(Identity, Authentication) + OAuth 2.0。它在 OAuth2
上构建了一个身份层,是一个基于 OAuth2 协议的身份认证标准协议。我们都知道 OAuth2 是一个授权协议,它无法提供完善的身份认证功能,OIDC 使用 OAuth2 的授权服务器来为第三方客户端提供用户的身份认证,并把对应的身份认证信息传递给客户端,且完全兼容 OAuth2。
OAuth2 提供了 Access Token
来解决授权第三方 客户端
访问受保护资源的问题;OIDC 在这个基础上提供了 ID Token
来解决第三方客户端标识用户身份认证的问题。OIDC 的核心在于 OAuth2 的授权流程中,一并提供用户的身份认证信息 ID Token
给到第三方 客户端
,ID Token
使用 JWT
格式来包装。
OIDC协议授权返回示例:
{
"resp_code": 200,
"resp_msg": "ok",
"datas": {
"access_token": "d1186597-aeb4-4214-b176-08ec09b1f1ed",
"token_type": "bearer",
"refresh_token": "37fd65d8-f017-4b5a-9975-22b3067fb30b",
"expires_in": 3599,
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vemx0MjAwMC5jbiIsImlhdCI6MTYyMTY5NjU4MjYxNSwiZXhwIjoxNjIxNjk2NjQyNjE1LCJzdWIiOiIxIiwibmFtZSI6IueuoeeQhuWRmCIsImxvZ2luX25hbWUiOiJhZG1pbiIsInBpY3R1cmUiOiJodHRwOi8vcGtxdG1uMHAxLmJrdC5jbG91ZGRuLmNvbS_lpLTlg48ucG5nIiwiYXVkIjoiYXBwIiwibm9uY2UiOiJ0NDlicGcifQ.UhsJpHYMWRmny45K0CygXeaASFawqtP2-zgWPDnn0XiBJ6yeiNo5QAwerjf9NFP1YBxuobRUzzhkzRikWGwzramNG9na0NPi4yUQjPNZitX1JzlIA8XSq4LNsuPKO7hS1ALqqiAEHS3oUqKAsjuE-ygt0fN9iVj2LyL3-GFpql0UAFIHhew_J7yIpR14snSh3iLVTmSWNknGu2boDvyO5LWonnUjkNB3XSGD0ukI3UEEFXBJWyOD9rPqfTDOy0sTG_-9wjDEV0WbtJf4FyfO3hPu--bwtM_U0kxRbfLnOujFXyVUStiCKG45wg7iI4Du2lamPJoJCplwjHKWdPc6Zw"
}
}
可以看到与普通的 OAuth2 相比返回的信息中除了有 access_token 之外还多出了 id_token 属性。
ID Token 是一个安全令牌,由授权服务器提供的包含用户信息的 JWT 格式的数据结构,得益于 JWT(JSON Web Token)的自包含性,紧凑性以及防篡改机制,使得 ID Token 可以安全的传递给第三方客户端程序并且容易被验证。
id_token包含以下内容
{
"iss": "http://zlt2000.cn",
"iat": 1621696582615,
"exp": 1621696642615,
"sub": "1",
"name": "管理员",
"login_name": "admin",
"picture": "http://xxx/头像.png",
"aud": "app",
"nonce": "t49bpg"
}
「iss」:令牌颁发者
「iat」:令牌颁发时间戳
「exp」:令牌过期时间戳
「sub」:用户id
「name」:用户姓名
「login_name」:用户登录名
「picture」:用户头像
「aud」:令牌接收者,OAuth应用ID
「nonce」:随机字符串,用来防止重放攻击
与 JWT 的 Access Token 区别
是否可以直接使用 JWT 方式的 Access Token 并在 Payload 中加入用户信息来代替 ID Token 呢?
虽然在 Access Token 中可以加入用户的信息,并且是防篡改的,但是用户的每次请求都需要携带着 Access Token,这样不但增加了带宽,而且很容易泄露用户的信息。
与 UserInfo 端点的区别
通常 OIDC 协议都需要另外提供了一个 Get /userinfo
的 Endpoint,需要通过 Access Token 调用该 Endpoint 来获取详细的用户信息,这个方法和 ID Token 同样都可以获取用户信息,那两者有什么区别呢?
相比较于 Get /userinfo
的接口使用 ID Token 可以减少远程 API 调用的额外开销;使用那个主要是看 「需求」,当你只需要获取用户的基本信息直接使用 ID Token 就可以了,并不需要每次都通过 Access Token 去调用 Get /userinfo
获取详细的用户信息。
OIDC 单点登录流程
编辑
大部分的流程与 OAuth2 的授权码模式相同这里就不多讲述了,其中下面两个步骤需要说明一下:
-
解析 ID Token 的公钥可以是预先提供给第三方系统也可以是提供接口获取。
-
「自动注册用户」 指的是第一次单点登录的时候,由于用户信息不存在需要在本系统中生成该用户数据;例如你从未在 CSDN 中注册也可以使用微信来登录该网站。
实现
先说一下扩展最终的目标是需要达到以下效果:
-
授权码模式:
/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code
-
OIDC 模式:
/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code id_token
目标是要通过在 response_type 中的传值来控制是否使用 OIDC 模式,如果使用则在 response_type 中增加 id_token 的值。
由于需要在 OAuth2 返回的内容中添加 ID Token 属性,所以实现这个扩展的关键就是需要通过 Security 的 TokenEnhancer
来为 Token 添加自定义字段;
定义 TokenEnhancer 的 Bean 来扩展 Token:
@Bean
@Order(1)
public TokenEnhancer tokenEnhancer(@Autowired(required = false) KeyProperties keyProperties
, IClientService clientService
, TokenStoreProperties tokenStoreProperties) {
return (accessToken, authentication) -> {
Set<String> responseTypes = authentication.getOAuth2Request().getResponseTypes();
Map<String, Object> additionalInfo = new HashMap<>(3);
String accountType = AuthUtils.getAccountType(authentication.getUserAuthentication());
if (StrUtil.isNotEmpty(accountType)) {
additionalInfo.put(SecurityConstants.ACCOUNT_TYPE_PARAM_NAME, accountType);
}
if (responseTypes.contains(SecurityConstants.ID_TOKEN)
|| "authJwt".equals(tokenStoreProperties.getType())) {
Object principal = authentication.getPrincipal();
//增加id参数
if (principal instanceof SysUser) {
SysUser user = (SysUser)principal;
if (responseTypes.contains(SecurityConstants.ID_TOKEN)) {
//生成id_token
setIdToken(additionalInfo, authentication, keyProperties, clientService, user);
}
if ("authJwt".equals(tokenStoreProperties.getType())) {
additionalInfo.put("id", user.getId());
}
}
}
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
return accessToken;
};
}
通过授权的 response_type 参数来判断是否需要生成 id_token。
/**
* 生成id_token
* @param additionalInfo 存储token附加信息对象
* @param authentication 授权对象
* @param keyProperties 密钥
* @param clientService 应用service
*/
private void setIdToken(Map<String, Object> additionalInfo, OAuth2Authentication authentication
, KeyProperties keyProperties, IClientService clientService, SysUser user) {
String clientId = authentication.getOAuth2Request().getClientId();
Client client = clientService.loadClientByClientId(clientId);
if (client.getSupportIdToken()) {
String nonce = authentication.getOAuth2Request().getRequestParameters().get(IdTokenClaimNames.NONCE);
long now = System.currentTimeMillis();
long expiresAt = System.currentTimeMillis() + client.getIdTokenValiditySeconds() * 1000;
String idToken = OidcIdTokenBuilder.builder(keyProperties)
.issuer(SecurityConstants.ISS)
.issuedAt(now)
.expiresAt(expiresAt)
.subject(String.valueOf(user.getId()))
.name(user.getNickname())
.loginName(user.getUsername())
.picture(user.getHeadImgUrl())
.audience(clientId)
.nonce(nonce)
.build();
additionalInfo.put(SecurityConstants.ID_TOKEN, idToken);
}
}