SSO和OAUTH2的区别
oauth2是:很多小程序需要获取微信授权,同意后,该小程序就会获取你的微信信息,作为用户信息,登录进去,不用用户再填写注册信息。在OAuth2中,有授权服务器、资源服务器、客户端这样几个角色,当我们用它来实现SSO的时候是不需要资源服务器这个角色的,有授权服务器和客户端就够了。
sso是:将登录认证和业务系统分离,使用独立的登录中心,实现了在登录中心登录后,所有相关的业务系统都能免登录访问资源。
都是使用令牌的方式来代替用户密码访问应用。
Cookie Session Token JWT之间的关系
1、Cookie and Session
session 是基于 cookie 实现的,session存储在服务器端,sessionId 会被存储到客户端的cookie 中。
存取值的类型不同:Cookie 只支持存字符串数据,想要设置其他类型的数据,需要将其转换成字符串,Session 可以存任意数据类型。
有效期不同:Cookie 可设置为长时间保持,比如我们经常使用的默认登录功能,Session 一般失效时间较短,客户端关闭(默认情况下)或者 Session 超时都会失效。
2、Token
组成:uid(用户唯一的身份标识)、time(当前时间的时间戳)、sign(签名,token的前几位以哈希算法压缩成的一定长度的十六进制字符串)
服务端验证完客户端的账号密码后,服务端会签发一个 token 并把这个 token 发送给客户端,客户端收到 token 以后,会把它存储起来,比如放在 cookie 里或者 localStorage 里。客户端每次向服务端请求资源的时候需要带着服务端签发的 token。
Token and Session
Token 使服务端无状态化,不会存储会话信息。Session 是一种记录服务器和客户端会话状态的机制,使服务端有状态化,可以记录会话信息。如果你的用户数据可能需要和第三方共享,或者允许第三方调用 API 接口,用 Token 。
3、JSON Web Token(简称 JWT)
用户输入用户名/密码登录,服务端认证成功后,会返回给客户端一个 JWT
客户端将 token 保存到本地(通常使用 localstorage,也可以使用 cookie)
当用户希望访问一个受保护的路由或者资源的时候,需要请求头的 Authorization 字段中使用Bearer 模式添加 JWT,其内容看起来是下面这样
Authorization: Bearer
服务端的保护路由将会检查请求头 Authorization 中的 JWT 信息,如果合法,则允许用户的行为
SSO服务端
参数、model
1、登录参数
@Data
public class LoginParam {
/**
* 用户名
*/
private String username;
/**
* 密码
*/
private String password;
/**
* 重定向地址
*/
private String redirectUrl;
}
2、客户端系统信息:地址、jsession
@Data
public class ClientRegisterModel {
/**
* 客户端-退出登录地址
*/
private String loginOutUrl;
/**
* 客户端-jsessionid
*/
private String jsessionid;
}
3、常量资源池:存放有效的token 、已经登录的系统信息(名字、客户端系统信息)
public class SSOConstantPool {
/**
* 存储已经登录有效-token
*/
public static Set<String> TOKEN_POOL = new HashSet<>();
/**
* 存储已经登录的系统
* 注销时候用
*/
public static Map<String, List<ClientRegisterModel>> CLIENT_REGISTER_POOL = new HashMap<>();
}
session监听器
@Slf4j
@WebListener
public class SSOSessionListener implements HttpSessionListener {
//session创建事件监听
@Override
public void sessionCreated(HttpSessionEvent se) {
}
/**
* session销毁事件监听
* 1.session超时的时候会调用
* 2.手动调用session.invalidate()方法时会调用.
*/
@Override
public void sessionDestroyed(HttpSessionEvent se) {
HttpSession session = se.getSession();
String token = (String) session.getAttribute("ssoToken");
log.debug("[ SSOSessionListener ] ...start..... sessionId:{},token:{}", session.getId(), token);
//注销全局会话,SSOSessionListener监听类删除对应的信息
session.invalidate();
if (StringUtils.isEmpty(token)) {
log.debug("[ SSOSessionListener ] token is null sessionId:{}", session.getId());
return;
}
//清除存储的有效token数据
SSOConstantPool.TOKEN_POOL.remove(token);
//清除并返回已经注册的系统信息
List<ClientRegisterModel> clientRegisterList = SSOConstantPool.CLIENT_REGISTER_POOL.remove(token);
if (CollectionUtils.isEmpty(clientRegisterList)) {
return;
}
for (ClientRegisterModel client : clientRegisterList) {
if (null == client) {
continue;
}
//取出注册的子系统,依次调用子系统的登出方法(通过会话ID退出子系统的局部会话)
sendHttpRequest(client.getLoginOutUrl(), client.getJsessionid());
log.info("[ SSOSessionListener ] 注销系统 url:{},Jsessionid:{}", client.getLoginOutUrl(), client.getJsessionid());
}
log.debug("[ SSOSessionListener ] ...end..... sessionId:{},token:{}", session.getId(), token);
}
/**
* 发送退出登录请求
* 模拟浏览器访问形式
*
* @param reqUrl 发送请求的地址
* @param jesssionId 会话Id
*/
private static void sendHttpRequest(String reqUrl, String jesssionId) {
try {
//建立URL连接对象
URL url = new URL(reqUrl);
//创建连接
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
//设置请求的方式(需要是大写的)
conn.setRequestMethod("POST");
//设置需要响应结果
conn.setDoOutput(true);
//通过设置JSESSIONID模拟浏览器端操作
conn.addRequestProperty("Cookie", "JSESSIONID=" + jesssionId);
//发送请求到服务器
conn.connect();
conn.getInputStream();
conn.disconnect();
} catch (Exception e) {
log.error("[ sendHttpRequest ] exception >> reqUrl:{}", reqUrl, e);
}
}
}
【session.invalidate();】注销session
【SSOConstantPool.TOKEN_POOL.remove(token);】常量资源池移走token
在访问logout时,客户端需要添加cookie【conn.addRequestProperty("Cookie", "JSESSIONID=" + jesssionId);】
Web拦截器
1、WebConfig
注入、配置拦截器,配置需要被拦截和不需要拦截的地址
@Slf4j
@Configuration
public class WebConfig extends WebMvcConfigurationSupport {
/**
* 创建拦截器
*/
@Bean
WebInterceptor webInterceptor() {
return new WebInterceptor();
}
/**
* 添加拦截器-进行拦截
* addPathPatterns 添加拦截
* excludePathPatterns 排除拦截
**/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(this.webInterceptor())
.addPathPatterns("/**")
//====== 以下为不需要拦截的地址 ======
//跳转到登录页
.excludePathPatterns("/login")
//跳转到登出成功页
.excludePathPatterns("/logout")
//统一登录
.excludePathPatterns("/sso/login")
//统一登出
.excludePathPatterns("/sso/logOut")
//校验登录开放接口
.excludePathPatterns("/sso/checkLogin")
//校验令牌开放接口
.excludePathPatterns("/sso/checkToken");
super.addInterceptors(registry);
}
/**
* 返回值-编码 UTF-8
*/
@Bean
public HttpMessageConverter<String> responseBodyConverter() {
return new StringHttpMessageConverter(StandardCharsets.UTF_8);
}
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer.favorPathExtension(false);
}
/**
* 资源处理器-资源路径 映射
*
* @param registry
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/webjars/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/");
}
}
2、WebInterceptor
1.preHandle():前置处理回调方法,返回true继续执行,返回false中断流程,不会继续调用其它拦截器 2.postHandle():后置处理回调方法,但在渲染视图之前 3.afterCompletion():全部后置处理之后,整个请求处理完毕后回调。
查找是否有Token判断是否已经登录过
@Slf4j
public class WebInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
log.debug("[ WebInterceptor ] >> preHandle start requestUrl:{} ", request.getRequestURI());
//从认证中心-session中判断是否已经登录过(判断是否有全局会话)
Object ssoToken = request.getSession().getAttribute("ssoToken");
// ssoToken为空 - 没有全局回话
if (StringUtils.isEmpty(ssoToken)) {
log.debug("[ WebInterceptor ] >> preHandle check fail need login requestUrl:{}", request.getRequestURI());
//未登录认证中心-跳转到登录页面
response.sendRedirect("/login");
}
log.debug("[ WebInterceptor ] >> preHandle end requestUrl:{},ssoToken:{}", request.getRequestURI(), ssoToken);
return true;
}
}
Controller层
1、基础跳转-index页和登录页
@Slf4j
@Controller
public class IndexController {
/**
* 跳转index页
*/
@RequestMapping({"/", "index"})
public String index() {
return "index";
}
/**
* 跳转登录页
*/
@RequestMapping("/login")
public String login() {
return "login";
}
}
2、SSO认证中心统一相关接口,如/sso/login /sso/checkToken /sso/checkLogin /sso/logout
@Slf4j
@Controller
@RequestMapping("/sso")
public class SSOLoginController {
/**
* 认证中心SSO统一登录方法
*/
@RequestMapping("/login")
public String login(LoginParam loginParam, RedirectAttributes redirectAttributes,
HttpSession session, Model model) {
//Demo 项目此处模拟数据库账密校验
if (!"admin".equals(loginParam.getUsername()) || !"123456".equals(loginParam.getPassword())) {
model.addAttribute("msg", "账户或密码错误,请重新登录!");
model.addAttribute("redirectUrl", loginParam.getRedirectUrl());
return "login";
}
//登录成功
//创建令牌
String ssoToken = UUID.randomUUID().toString();
//把令牌放到全局会话中
session.setAttribute("ssoToken", ssoToken);
//设置session失效时间-单位秒
session.setMaxInactiveInterval(3600);
//将有效的令牌-放到map容器中(存在该容器中的token都是合法的,正式环境建议存库或者redis)
SSOConstantPool.TOKEN_POOL.add(ssoToken);
//未携带重定向跳转地址-默认跳转到认证中心首页
if (StringUtils.isEmpty(loginParam.getRedirectUrl())) {
return "index";
}
// 携带令牌到客户端
redirectAttributes.addAttribute("ssoToken", ssoToken);
log.info("[ SSO登录 ] login success ssoToken:{} , sessionId:{}", ssoToken, session.getId());
// 跳转到客户端
return "redirect:" + loginParam.getRedirectUrl();
}
/**
* 校验令牌是否合法
*
* @param ssoToken 令牌
* @param loginOutUrl 退出登录访问地址
* @param jsessionid
* @return 令牌是否有效
*/
@ResponseBody
@RequestMapping("/checkToken")
public String verify(String ssoToken, String loginOutUrl, String jsessionid) {
// 判断token是否存在map容器中,如果存在则代表合法
boolean isVerify = SSOConstantPool.TOKEN_POOL.contains(ssoToken);
if (!isVerify) {
log.info("[ SSO-令牌校验 ] checkToken 令牌已失效 ssoToken:{}", ssoToken);
return "false";
}
//把客户端的登出地址记录起来,后面注销的时候需要根据使用(生产环境建议存库或者redis)
List<ClientRegisterModel> clientInfoList =
SSOConstantPool.CLIENT_REGISTER_POOL.computeIfAbsent(ssoToken, k -> new ArrayList<>());
ClientRegisterModel vo = new ClientRegisterModel();
vo.setLoginOutUrl(loginOutUrl);
vo.setJsessionid(jsessionid);
clientInfoList.add(vo);
log.info("[ SSO-令牌校验 ] checkToken success ssoToken:{} , clientInfoList:{}", ssoToken, clientInfoList);
return "true";
}
/**
* 校验是否已经登录认证中心(是否有全局会话)
* 1.若存在则携带令牌ssoToken跳转至目标页面
* 2.若不存在则跳转到登录页面
*/
@RequestMapping("/checkLogin")
public String checkLogin(String redirectUrl, RedirectAttributes redirectAttributes,
Model model, HttpServletRequest request) {
//从认证中心-session中判断是否已经登录过(判断是否有全局会话)
Object ssoToken = request.getSession().getAttribute("ssoToken");
// ssoToken为空 - 没有全局回话
if (StringUtils.isEmpty(ssoToken)) {
log.info("[ SSO-登录校验 ] checkLogin fail 没有全局回话 ssoToken:{}", ssoToken);
//登录成功需要跳转的地址继续传递
model.addAttribute("redirectUrl", redirectUrl);
//跳转到统一登录页面
return "login";
}
log.info("[ SSO-登录校验 ] checkLogin success 有全局回话 ssoToken:{}", ssoToken);
//重定向参数拼接(将会在url中拼接)
redirectAttributes.addAttribute("ssoToken", ssoToken);
//重定向到目标系统
return "redirect:" + redirectUrl;
}
/**
* 统一注销
* 1.注销全局会话
* 2.通过监听全局会话session时效性,向已经注册的所有子系统发起注销请求
*/
@RequestMapping("/logOut")
public String logOut(HttpServletRequest request) {
HttpSession session = request.getSession();
log.info("[ SSO-统一退出 ] ....start.... sessionId:{}", session.getId());
//注销全局会话, SSOSessionListener 监听器会处理后续操作
request.getSession().invalidate();
log.info("[ SSO-统一退出 ] ....end.... sessionId:{}", session.getId());
return "logout";
}
}
前端页面
1、login.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>SSODemo</title>
</head>
<style>body.center {
text-align: center;
}
</style>
<body class="center">
<div>
<h1>SSO用户统一登录</h1>
</div>
<div>
<form name="loginForm" action="/sso/login" method="POST" accept-charset="UTF-8">
<div><input placeholder="用户名" value="admin" name="username" type="text"/></div>
<div><input placeholder="密码" value="123456" name="password" type="password"/></div>
<div style="color: red"><span th:text="${msg}"></span></div>
<div><input name="redirectUrl" type="hidden" th:value="${redirectUrl}"/></div>
<input type="submit" value="登录"/>
<input type="reset" value="重置"/>
</form>
</div>
</body>
</html>
2、index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>SSODemo</title>
</head>
<body>
<h3 class="panel-title">欢迎访问SSO认证中心服务-index</h3>
<a href="/sso/logOut">退出登录(同时推出所有子系统)</a>
</body>
</html>
3、logout.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>SSODemo</title>
</head>
<body>
<h3 class="panel-title">您已成功退处系统</h3>
</body>
</html>
其他服务系统
工具类
1、Cookie生成、获取、删除
public class CookieUtil {
/**
* 根据key名称-获取cookie值
*
* @param request
* @param cookieName
*/
public static String getCookie(HttpServletRequest request, String cookieName) {
Cookie[] cookies = request.getCookies();
if (null == cookies) {
return null;
}
for (Cookie cookie : cookies) {
if (cookie.getName().equals(cookieName)) {
return cookie.getValue();
}
}
return null;
}
/**
* 设置cookie
*
* @param response
* @param cookieName
* @param value
* @param cookieMaxAge
*/
public static void setCookie(HttpServletResponse response, String cookieName, String value, int cookieMaxAge) {
Cookie cookie = new Cookie(cookieName, value);
cookie.setPath("/");
cookie.setMaxAge(cookieMaxAge);
response.addCookie(cookie);
}
/**
* 删除cookie
*
* @param response
* @param cookieName
*/
public static void deleteCookie(HttpServletResponse response, String cookieName) {
setCookie(response, cookieName, null, 0);
}
}
2、访问SSO统一认证系统的Helper
public class SSOClientHelper {
/**
* SSO统一认证中心地址
* http://localhost:8081
*/
private static String SSO_SERVER_URL = "http://localhost:8081";
/**
* Client客户端地址【当前服务端口】
*/
private static String CLIENT_SERVER_URL = "http://localhost:8083";
/**
* 请求认证中心-校验登录或首次登录
* 1.若存在全局会话则携带令牌重定向回客户端(已经登录)
* 2.若无全局会话则返回统一登录页面进行登录(首次登录)
*
* @param request
* @param response
*/
public static void checkLogin(HttpServletRequest request, HttpServletResponse response) throws IOException {
StringBuilder url = new StringBuilder();
//获取当前客户端在访问的地址
String redirectUrl = getCurrentServletPath(request);
url.append(SSO_SERVER_URL)
.append(SSOReqUrl.CHECK_LOGIN_URL)
.append(redirectUrl);
response.sendRedirect(url.toString());
}
/**
* 请求认证中心-校验令牌token是否有效
*
* @param ssoToken 令牌 token
* @param jsessionid
* @return 是否有效
*/
public static Boolean checkToken(String ssoToken, String jsessionid) {
Map<String, Object> params = new HashMap<>(4);
params.put(SSOProperty.TOKEN_NAME, ssoToken);
params.put(SSOProperty.LOGIN_OUT_URL, SSOClientHelper.getClientLogOutUrl());
params.put(SSOProperty.JSESSIONID, jsessionid);
String checkToken = HttpUtil.get(SSOClientHelper.getCheckTokenUrl(), params);
return "true".equals(checkToken);
}
/**
* 获取 ssoToken
* 1.优先从请求参数中获取
* 2.二次尝试从cookie中获取
*/
public static String getSsoToken(HttpServletRequest request) {
//从参数中获取
String token = request.getParameter(SSOProperty.TOKEN_NAME);
//若参数未携带-尝试从cookie获取
if (StringUtils.isEmpty(token)) {
token = CookieUtil.getCookie(request, SSOProperty.TOKEN_NAME);
}
return token;
}
/**
* 获取sso认证中心token校验请求地址
* 示例:http://localhost:8081/sso/checkToken
*/
public static String getCheckTokenUrl() {
return SSO_SERVER_URL + SSOReqUrl.CHECK_TOKEN_URL;
}
/**
* 获取SSO认证中心的登出地址
* 示例:http://localhost:8081/logOut
*/
public static String getSSOLogOutUrl() {
return SSO_SERVER_URL + SSOReqUrl.SSO_LOGOUT_URL;
}
/**
* 获取当前访问地址
* 示例:http://localhost:8082/index
*
* @param request
*/
public static String getCurrentServletPath(HttpServletRequest request) {
return CLIENT_SERVER_URL + request.getServletPath();
}
/**
* 获取客户端的登出地址
* 示例:http://localhost:8082/logOut
*/
public static String getClientLogOutUrl() {
return CLIENT_SERVER_URL + ClientReqUrl.CLIENT_LOGOUT_URL;
}
/**
* 认证中心-参数key 相关常量
*/
public static class SSOProperty {
/**
* 令牌统一参数名
*/
public static final String TOKEN_NAME = "ssoToken";
/**
* 统一认证中心的token认证方法的登出地址参数名
*/
public static final String LOGIN_OUT_URL = "loginOutUrl";
/**
* 统一认证中心的token认证方法的jsessionid参数名
*/
public static final String JSESSIONID = "jsessionid";
}
/**
* 认证中心-请求URL 相关常量
*/
public static class SSOReqUrl {
/**
* token校验地址
*/
private static final String CHECK_TOKEN_URL = "/sso/checkToken";
/**
* 认证中心-退出登录地址
*/
private static final String SSO_LOGOUT_URL = "/sso/logOut";
/**
* 登录校验地址
*/
private static final String CHECK_LOGIN_URL = "/sso/checkLogin?redirectUrl=";
}
/**
* 客户端-请求URL 相关常量
*/
public static class ClientReqUrl {
/**
* 认证中心-退出登录地址
*/
private static final String CLIENT_LOGOUT_URL = "/logOut";
}
}
拦截器
和SSO认证系统相似
Controller层
1、基本页面 index和 该服务的welcome
@Controller
public class IndexController {
@RequestMapping({"/", "/index"})
protected String index(Model model) {
model.addAttribute("logOutUrl", SSOClientHelper.getSSOLogOutUrl());
return "index";
}
@RequestMapping("/welcome")
protected String welcome(Model model) {
return "welcome";
}
}
2、退出系统
@Slf4j
@Controller
public class LogOutController {
@RequestMapping("/logOut")
protected void logOut(HttpServletRequest request, HttpServletResponse response) {
HttpSession session = request.getSession();
log.info("[ logOut ] sessionId:{}", session.getId());
session.invalidate();
}
}
前端页面
1、index.html
<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>SSO-client2</title>
</head>
<body>
<h1>SSO-client2</h1>
<h2>欢迎访问SSO-client2。。。。。。</h2>
<a href="/welcome">welcome.html</a>
<a th:href="${logOutUrl}">统一退出</a>
</body>
</html>
2、welcome.html
<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>SSO-client1</title>
</head>
<body>
<h1>SSO-client1-welcome.html</h1>
<h2>欢迎访问SSO-client1。。。。。。welcome.html</h2>
</body>
</html>