一、背景
上次做淘宝客项目用到了shiro,做了一个简单的单点登陆系统,同时,前后端分离是公司必走并且正在走的道路,所以公司也有想法做统一登陆。现在的情况是,公司处于半前后端分离状态,只是代码之间分开了,发布还是前端依赖后端容器,所以没有实现真正的前后端分离,公司想趁着这次完全改造完成,于是,sso项目启动了,后端开发设计就落到了我身上,同时找了个专业前端配合我。
二、项目中的要求
1、a系统登陆后,跳转到b系统不再登陆
2、a和b系统可能不在同一个域名下面
3、路由跳转完全交给前端,后端不做控制
4、支持分布式架构,当用户暴增的时候,能弹性扩展,实现分流
5、每个系统用户都有自己的角色、权限,必须保证没有权限不能访问数据
6、必须支持两种路径风格,比如restful风格和传统的请求风格
7、有些路径是不需要权限严重的,比如说开放接口。
三、技术选型
基于公司的要求,我还是选择了redis+shiro来做分布式支持和权限的验证,毕竟有了shiro经验并且shiro也是一个不错的安全框架。
四、登陆流程
1.登陆
用户a登陆 →请求该用户再该域名下的菜单权限→请求数据
2.跳转
已登陆用户a点击跳到b系统→请求该用户在该域名下的菜单权限→前端渲染菜单
已登陆a用户输入url跳转到b系统→cookie换取token→请求该用户在该域名下的菜单权限→前端渲染菜单
注意:
后端在做权限验证的时候token是必须的,所以在多个系统里面跳转,其实就是在做token的传递问题。
五、代码
1、spring配置代码
/login/** = anon
/logout/** = logout
/** = token
2、登陆代码
/**
* 认证信息,主要针对用户登录,
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
SsoUserNameToken ssoUserNameToken = (SsoUserNameToken) authenticationToken;
LoginEntity loginEntity = ssoUserNameToken.getLoginEntity();
UserInfo userInfo = null;
try {
userInfo = userService.login(loginEntity);
Serializable id = SecurityUtils.getSubject().getSession().getId();
userInfo.setToken((String) id);
redisClient.set((String) id, SerializeUtil.serialize(userInfo), LOGIN_EXPIRE);
} catch (RuhnnException e) {
if (e.getErrorCode().equals(ErrorType.USER_NO_EXIST)) {
throw new UnknownAccountException();
} else if (e.getErrorCode().equals(ErrorType.PASSWORD_ERROR)) {
throw new IncorrectCredentialsException();
} else if (e.getErrorCode().equals(ErrorType.TOKEN_INVALID)) {
throw new ExpiredCredentialsException();
}
}
if (loginEntity.getWay().intValue() == LoginWayEnum.Token_LOGIN.getWay().intValue()) {
return new SimpleAuthenticationInfo(userInfo, userInfo.getToken(), getName());
} else {
return new SimpleAuthenticationInfo(userInfo, userInfo.getInfo().getPassword(), getName());
}
}
3、验证代码
/**
* 授权
*
* @param principalCollection
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
UserInfo userInfo = (UserInfo) SecurityUtils.getSubject().getPrincipal();
byte[] value = redisClient.get(userInfo.getToken());
if (value != null) {
userInfo = SerializeUtil.deserialize(value, UserInfo.class);
}
String key = SsoConstants.REDIS_ROLE_KEY + userInfo.getToken();//getSession().getId()
Set allPermissions = new HashSet<>();
byte[] bytes = redisClient.get(key);
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
if (bytes == null || bytes.length <= 0) {
Set functionDOS = userService.queryUserFunction(userInfo.getInfo().getId(), userInfo.getWebId());
if (CollectionUtils.isNotEmpty(functionDOS)) {
Set permissions = functionDOS.stream().map(FunctionDO::getUrl).collect(Collectors.toSet());
allPermissions.addAll(permissions);
redisClient.set(key, SerializeUtil.serialize(permissions));
}
} else {
Set permissions = SerializeUtil.deserialize(bytes, Set.class);
allPermissions.addAll(permissions);
}
String ssoPublicLoginKey = SsoConstants.REDIS_PUBLIC_LOGIN_KEY;
byte[] ssoPublicLoginValue = redisClient.get(ssoPublicLoginKey);
if (ssoPublicLoginValue == null) {
List publicLoginFunctionDOS = functionDao.queryPublicFunction(userInfo.getWebId());
if (CollectionUtils.isNotEmpty(publicLoginFunctionDOS)) {
Set publicLoginPermissions = publicLoginFunctionDOS.stream().map(FunctionDO::getUrl).collect(Collectors.toSet());
redisClient.set(ssoPublicLoginKey, SerializeUtil.serialize(publicLoginPermissions));
allPermissions.addAll(publicLoginPermissions);
}
} else {
Set publicLoginPermissions = SerializeUtil.deserialize(ssoPublicLoginValue, Set.class);
allPermissions.addAll(publicLoginPermissions);
}
info.setStringPermissions(allPermissions);
return info;
}
4、支持分布式验证,重写sessionDAO
/**
* @author star
* @date 2018/5/22 下午3:49
*/
public class RedisCacheSessionDAO extends AbstractSessionDAO {
@Resource
private RedisClient redisClient;
@Override
protected Serializable doCreate(Session session) {
Serializable sessionId = this.generateSessionId(session);
this.assignSessionId(session, sessionId);
redisClient.set(SsoConstants.REDIS_KEY + session.getId(), SerializeUtil.serialize(session), session.getTimeout() / 1000);
return sessionId;
}
@Override
protected Session doReadSession(Serializable serializable) {
byte[] value = redisClient.get(SsoConstants.REDIS_KEY + serializable);
return SerializeUtil.deserialize(value, Session.class);
}
@Override
public void update(Session session) throws UnknownSessionException {
if (session == null || session.getId() == null) {
throw new NullPointerException("session is empty");
}
redisClient.set(SsoConstants.REDIS_KEY + session.getId(), SerializeUtil.serialize(session));
}
@Override
public void delete(Session session) {
if (session == null || session.getId() == null) {
throw new NullPointerException("session is empty");
}
redisClient.remove(SsoConstants.REDIS_KEY + session.getId());
}
@Override
public Collection getActiveSessions() {
Set keys = redisClient.keys(SsoConstants.REDIS_KEY);
if (CollectionUtils.isEmpty(keys)) {
return null;
}
Collection collection = new HashSet<>();
for (byte[] key : keys) {
collection.add(SerializeUtil.deserialize(key, Session.class));
}
return collection;
}
}
5、支持两种路径风格
public class SsoPathMatcher implements PatternMatcher {
@Override
public boolean matches(String p, String source) {
//pattern数据库, source访问链接
Pattern pattern = Pattern.compile(p);
Matcher matcher = pattern.matcher(source);
if (matcher.matches()) {
return true;
}
return false;
}
}
public class UrlPermission implements Permission {
private static final Logger logger = LoggerFactory.getLogger(UrlPermission.class);
private String url;
public UrlPermission(String url){
this.url = url;
}
@Override
public boolean implies(Permission p) {
if(! (p instanceof UrlPermission)){
return false;
}
UrlPermission urlPermission = (UrlPermission) p;
PatternMatcher patternMatcher = new RuhnnPathMatcher();
logger.info("this.url(来自数据库中存放的通配符数据),在 Realm 的授权方法中注入的 => " + this.url);
logger.info("urlPermission.url(来自浏览器正在访问的链接) => " + urlPermission.url);
System.out.println("this.url(来自数据库中存放的通配符数据),在 Realm 的授权方法中注入的 => " + this.url);
System.out.println("urlPermission.url(来自浏览器正在访问的链接) => " + urlPermission.url);
boolean matches = patternMatcher.matches(this.url, urlPermission.url);
return matches;
}
}
public class UrlPermissionResolver implements PermissionResolver {
@Override
public Permission resolvePermission(String permissionString) {
return new UrlPermission(permissionString);
}
}
6、重写token的获取
public class SsoSessionManager extends DefaultWebSessionManager {
@Override
protected Serializable getSessionId(ServletRequest httpRequest, ServletResponse response) {
HttpServletRequest request = (HttpServletRequest) httpRequest;
return request.getHeader("token");
}
}
7、需要用到sso的项目过滤器
public class SsoFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
PrintWriter out = null;
out = response.getWriter();
String token = request.getHeader("token");
if (StringUtils.isEmpty(token)) {
token = request.getParameter("token");
}
if (StringUtils.isEmpty(token)) {
out.write(JSON.toJSONString(new SsoResponse(ErrorType.INVALID_ARGUMENT)));
return;
}
String uri = request.getRequestURI();
JSONObject result = JSON.parseObject(HttpUtils.get("localhost:9999" + uri, token));
if (result.getString("success").equals("true")) {
filterChain.doFilter(servletRequest, servletResponse);
} else {
out.write(result.toJSONString());
return;
}
}
@Override
public void destroy() {
}
}
六、总结
后端逻辑很简单,很多事情都放在了前端处理,后端只有2个要求,一个是token值一个是域名值,token 能判断用户权限,域名能判断该用户第一次访问的时候的菜单权限。所以在前端跟很短对接的时候 主要问题是token获取不到,因为跨域穿值问题,所以才有了用cookie换取token的接口,并且controller层也有了改动,由于前端需要跨域请求,所以用了jsonp,在获取成功后,controller 返回的其实是一段jsonp可执行的代码。以上就是我设计的sso登陆系统。目前还在测试阶段,但是逻辑是通的,有兴趣的朋友可以加我QQ:695234456
代码:[email protected]:civism/civism-sso.git
一起装逼一起飞