根据单点登录的定义,客户端可以完全不用创建自己的用户系统,它只需要接入 SSO 中心的服务就好。SSO 中心关于用户的常规业务都在其内。那么客户端接入单点登录,需要做什么工作呢?首先用户一般常规操作有:
- 用户注册。这部分 SSO 中心提供注册接口。客户端自定义自己风格注册 UI,跨域请求数据到 SSO 中心接口即可;
- 用户登录。这部分 SSO 中心提供登录接口。客户端自定义自己风格登录 UI,跨域请求数据到 SSO 中心接口即可;
- 用户注销登陆。这部分 SSO 中心提供登录接口,跨域请求数据到 SSO 中心接口即可;
- 用户常规查询操作,例如查询列表、单个用户详情等,这部分 SSO 中心开放相关 API。
一般常规接口上文已经讨论过了。可见 SSO 中心一个特性要求便是允许“跨域访问”,这个问题不大,进行相关配置即可。
SSO 中心,即认证中心,关键一点在于用户的认证。除了上述登录是重要的认证过程外,每次涉及相关操作都必须进行认证,否则就是非法访问。
认证的问题
如果按照 OAuth 本来的目的,资源服务器跟认证服务器是在一块的,比如说微博,它有个开放平台你可以根据 AccessToken 获取它微博内容。每次访问都有提供 AccessToken 参数,看是否合法才允许访问。
但目前我们搞的不是纯粹 OAuth,上文《SSO 与 OAuth 傻傻分不清?》小节已经说过了。SSO 认证中心往往不是跟资源服务器在一起的“单体”结构,而是独立部署的;而且应用端(即客户端)肯定都有自己的资源服务,肯定需要用户认证、权限校验之类的操作。那么问题来了,校验客户端凭证令牌(即 AccessToken)这项工作,——是放在应用端还是 SSO 中心呢?
显然易见,作为统一的认证中心,SSO 中心无疑拥有最根本的用户状态记录,一切皆以 SSO 中心的为准。但每次访问资源的认证工作都要通讯 SSO 中心,性能成本会不会太高呢?对于 SSO 中心服务器的性能也是严重的考验。对此,笔者考虑了以下几个个解决方案。
- 还是在 SSO 中心校验,但采取优化手段:对已验证的 token 进行缓存,仅首次访问时调用 SSO 验证一次,一般缓存10分钟这种,便于 SSO 进行 token 撤销。
- 无须 SSO 校验 token,采用自描述的 token。这种自描述的 Token 比普通的 Token 的复杂,解密之后包含了更多的信息,根据这些信息对比、校验便能清楚是否合法,以及一定的用户信息。举个例子,如“重置密码”,在邮件中包含一个带 token 的连接,后端得到这 token 后其实有时间戳的信息的,再对比一下便能知道是否超时的请求。
- 采用自描述的 Token,其实跟大家说 JWT 就可以了,它就是干这事的。不过笔者说实话还不太懂 JWT,当前方案中还没有使用 JWT。
- 应用端自建用户登录会话。其实就是冗余一套 SSO 中心的,用户登录之后回来马上搞自己的 Session。但怎么同步是个问题,而且隐约好像不是“单点”的意思了。当前我正在使用这方案。
应用端自建用户登录会话
既然选定了这个方案,那我们就看看怎么做吧。首先是用户登录之后马上建立 Session。源码在这里。
这属于客户端登录的一部分,得到授权码之后在服务端发起请求。
@GetMapping(value = "clientLogin", produces = JSON)
public String clientLogin(@RequestParam String code, HttpServletRequest req) {
Map<String, Object> params = new HashMap<>();
params.put("code", code);
params.put("grant_type", GRANT_TYPE);
params.put("client_id", clientId);
params.put("client_secret", clientSecret);
Map<String, Object> result = Post.api(api + "/sso/authorize", params);
UserSession saveSession = saveSession(result);
req.getSession().setAttribute(saveSession.accessToken.getAccessToken(), saveSession);
return "${User.home}".equals(userHome) ? toJson(result) : "redirect:/" + userHome;
}
static UserSession saveSession(Map<String, Object> result) {
AccessToken accessToken = new AccessToken();
accessToken.setAccessToken(result.get("access_token").toString());
accessToken.setRefreshToken(result.get("refresh_token").toString());
accessToken.setScope(result.get("scope").toString());
accessToken.setExpiresIn(((Integer) result.get("expires_in")).longValue());
@SuppressWarnings("unchecked")
Map<String, Object> userJson = (Map<String, Object>) result.get("user");
User user = MapTool.map2Bean(userJson, User.class, true);
UserSession userSession = new UserSession();
userSession.accessToken = accessToken;
userSession.user = user;
return userSession;
}
若登录成功,就在客户端本地产生 Session。其中重点就是 UserSession ,它包含了用户和 AccessToken 两种对象,以 Token 为 key 存到 Session 中。
校验拦截 Token
有了本地的用户登录状态,就无须访问 SSO 中心校验了,于是也变得简单和高效了。所有校验都发生在本地进行。我们看看这个拦截器 SsoAccessTokenInterceptor ,它是标准的 Spring 拦截器。
你先需要在 yaml 配置中定义一下要保护资源的访问路径,即接口,按照 Spring 拦截器的配置。
User:
resources: /api/**, /user/**
excludeResources: /user/login/**
记得路径后面要加上 ** 同贝所有子路径。
@Value("${User.resources}")
private String[] protectPerfix;
@Value("${User.excludeResources}")
private String[] excludeResources;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tokenInterceptor).addPathPatterns(protectPerfix).excludePathPatterns(excludeResources);
super.addInterceptors(registry);
}
拦截器代码
import java.io.IOException;
import java.time.LocalDateTime;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import com.ajaxjs.framework.BaseController;
import com.ajaxjs.user.sso.model.AccessToken;
import com.ajaxjs.user.sso.model.UserSession;
import com.ajaxjs.util.date.LocalDateUtils;
@Component
public class SsoAccessTokenInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) {
String accessToken = req.getParameter("access_token");
if (!StringUtils.hasText(accessToken)) {
err(resp, "缺少 access_token 参数");
return false;
}
Object object = req.getSession().getAttribute(accessToken);
if (object == null) {
err(resp, "非法 AccessToken");
return false;
} else {
}
UserSession userSess = (UserSession) object;
if (checkIfExpire(userSess.accessToken)) {
err(resp, "access_token 已超时");
return false;
} else
return true;
}
static boolean checkIfExpire(AccessToken token) {
long expiresIn = token.getExpiresIn();
LocalDateTime expiresDateTime = LocalDateUtils.ofEpochSecond(expiresIn);
return expiresDateTime.isBefore(LocalDateTime.now());
}
static void err(HttpServletResponse resp, String msg) {
resp.setStatus(HttpStatus.UNAUTHORIZED.value());
resp.setHeader("Content-type", "application/json;charset=UTF-8");
try {
resp.getWriter().write(BaseController.jsonNoOk(msg));
} catch (IOException e) {
e.printStackTrace();
}
}
}
SSO Client
上面所述的所有代码都在 SSO Client 这个工程中,可以通过 Maven 加入到你的工程中。
设置 Session 超时时间,在 web.xml 配置一下。
<session-config>
<session-timeout>15</session-timeout>
</session-config>
Spring Boot 设置 yml
server:
port: 8089
session:
timeout: 1800
Ja va 设置:
session.setMaxInactiveInterval(30*60);
|