作者:蓝雄威,叩丁狼教育高级讲师。原创文章,转载请注明出处。
一、前言
我们自己动手写单点登录的服务端目的是为了加深对单点登录的理解.如果你们公司想实现单点登录/单点注销功能,推荐使用开源的单点登录框架CAS.我们后面的章节也会带同学们快速搭建CAS Server和CAS Client的环境.
二、条件
如果没看前面章节的同学,请返回去观看这几章内容,不然这代码是不太好理解的.
- SSO单点登录教程(一)多系统的复杂性
- SSO单点登录教程(二)单点登录流程分析
- SSO单点登录教程(三)单点注销流程分析
三、环境要求
- JDK1.7+
- Maven3.3
- Eclipse/IDEA
四、准备工作
因为我们主要讲的是跨域的单点登录,所以我们需要把不同项目部署到不同域名下.不可能为了完成这个代码,让同学们去阿里云买三台主机,映射三个IP.所以我们的实验就在本机来实现.我们需要修改host文件,让三个域名映射到本机.
host文件存放的位置:C:\Windows\System32\drivers\etc
打开host文件之后,在最后追加如下配置:
127.0.0.1 www.sso.com
127.0.0.1 www.crm.com
127.0.0.1 www.wms.com
这段配置的意思是,我们在浏览器中输入:
http://www.sso.com
http://www.crm.com
http://www.wms.com
其实访问的都是本机:127.0.0.1
PS:有些同学打开这个文件之后,保存的时候可能被拒绝.原因可能是权限不够.解决方法:把host文件拷贝到桌面(有权限的地方即可),修改好之后再把:C:\Windows\System32\drivers\etc
的host文件覆盖.
五、下载基础项目
基础项目代码下载链接在页面底部.
我在github上传的是maven结构的项目.如果需要导入到Eclipse/IDEA中需要生成对应的Eclipse/IDEA的配置文件.
cmd
命令进入到项目的根目录 $项目存放位置/sso-server-base-project
- 如果是Eclipse,运行
mvn eclipse:eclipse
- 如果是IDEA,运行
mvn idea:idea
处理好之后,把项目导入到工具中,我们就可以开始开发了.
六、项目结构说明
服务端
sso-server-base-project目录
src
main
java
resources
-applicationContext.xml
webapp
static
WEB-INF
views
-login.jsp
-logOut.jsp
-web.xml
-pom.xml
服务端项目就只配置了SpringMVC的环境.
pom.xml
:项目的pom文件,已经配置的Tomcat插件端口为:8443
applicationContext.xml
:spring配置文件
static
:静态资源目录,存放css,js
login.jsp
:登陆页面
logOut.jsp
:登出页面
web.xml
:web的配置文件,配置前端请求DispatherServlet
客户端
sso-client-base-project目录
src
main
java
-cn.wolfcode.sso.controller.MainServlet.java
-cn.wolfcode.sso.controller.LogOutServlet.java
webapp
WEB-INF
views
-main.jsp
-web.xml
客户端没有使用Spring框架.使用Servlet3.0
@WebServlet(name = "mainServlet", urlPatterns = "/main")
在Servlet类上贴这个注解就可以进行映射.
MainServlet.java
:处理主页请求/main的servlet.
LogOutServlet.java
:处理登出的请求/logOut的servlet
main.jsp
:首页
客户端项目导入之后,运行tomcat7:run
命令,在浏览器中输入
http://www.crm.com:8088/main
会看到如下界面:
七、执行流程图
我们代码的开发就参考着单点登录流程图来实现,所以我在这也把这张图放过来.
八、代码实现
准备阶段:
一:在resources目录创建sso.properties
,内容如下:
#统一认证中心的地址
server-url-prefix=http://www.sso.com:8443
#本项目的地址
client-host-url=http://www.crm.com:8088
二:添加工具类.
我们在后续的开发中需要使用这个工具类,写得比较简单,可以先看看,我们用到再给同学们解释啥意思.
SSOClientUtil.java
package cn.wolfcode.sso.util;
import java.io.IOException;
import java.util.Properties;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class SSOClientUtil {
private static Properties ssoProperties = new Properties();
public static String SERVER_URL_PREFIX;//统一认证中心地址:http://www.sso.com:8443,在sso.properties配置
public static String CLIENT_HOST_URL;//当前客户端地址:http://www.crm.com:8088,在sso.properties配置
static{
try {
ssoProperties.load(SSOClientUtil.class.getClassLoader().getResourceAsStream("sso.properties"));
} catch (IOException e) {
e.printStackTrace();
}
SERVER_URL_PREFIX = ssoProperties.getProperty("server-url-prefix");
CLIENT_HOST_URL = ssoProperties.getProperty("client-host-url");
}
/**
* 当客户端请求被拦截,跳往统一认证中心,需要带redirectUrl的参数,统一认证中心登录后回调的地址
* 通过Request获取这次请求的地址 http://www.crm.com:8088/main
*
* @param request
* @return
*/
public static String getRedirectUrl(HttpServletRequest request){
//获取请求URL
return CLIENT_HOST_URL+request.getServletPath();
}
/**
* 根据request获取跳转到统一认证中心的地址 http://www.sso.com:8443//checkLogin?redirectUrl=http://www.crm.com:8088/main
* 通过Response跳转到指定的地址
* @param request
* @param response
* @throws IOException
*/
public static void redirectToSSOURL(HttpServletRequest request,HttpServletResponse response) throws IOException {
String redirectUrl = getRedirectUrl(request);
StringBuilder url = new StringBuilder(50)
.append(SERVER_URL_PREFIX)
.append("/checkLogin?redirectUrl=")
.append(redirectUrl);
response.sendRedirect(url.toString());
}
/**
* 获取客户端的完整登出地址 http://www.crm.com:8088/logOut
* @return
*/
public static String getClientLogOutUrl(){
return CLIENT_HOST_URL+"/logOut";
}
/**
* 获取认证中心的登出地址 http://www.sso.com:8443/logOut
* @return
*/
public static String getServerLogOutUrl(){
return SERVER_URL_PREFIX+"/logOut";
}
}
HttpUtil.java
package cn.wolfcode.sso.util;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.Charset;
import java.util.Map;
import java.util.Map.Entry;
import org.springframework.util.StreamUtils;
public class HttpUtil {
/**
* 模拟浏览器的请求
* @param httpURL 发送请求的地址
* @param params 请求参数
* @return
* @throws Exception
*/
public static String sendHttpRequest(String httpURL,Map<String,String> params) throws Exception{
//建立URL连接对象
URL url = new URL(httpURL);
//创建连接
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
//设置请求的方式(需要是大写的)
conn.setRequestMethod("POST");
//设置需要输出
conn.setDoOutput(true);
//判断是否有参数.
if(params!=null&¶ms.size()>0){
StringBuilder sb = new StringBuilder();
for(Entry<String,String> entry:params.entrySet()){
sb.append("&").append(entry.getKey()).append("=").append(entry.getValue());
}
//sb.substring(1)去除最前面的&
conn.getOutputStream().write(sb.substring(1).toString().getBytes("utf-8"));
}
//发送请求到服务器
conn.connect();
//获取远程响应的内容.
String responseContent = StreamUtils.copyToString(conn.getInputStream(),Charset.forName("utf-8"));
conn.disconnect();
return responseContent;
}
/**
* 模拟浏览器的请求
* @param httpURL 发送请求的地址
* @param jesssionId 会话Id
* @return
* @throws Exception
*/
public static void sendHttpRequest(String httpURL,String jesssionId) throws Exception{
//建立URL连接对象
URL url = new URL(httpURL);
//创建连接
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
//设置请求的方式(需要是大写的)
conn.setRequestMethod("POST");
//设置需要输出
conn.setDoOutput(true);
conn.addRequestProperty("Cookie","JSESSIONID="+jesssionId);
//发送请求到服务器
conn.connect();
conn.getInputStream();
conn.disconnect();
}
}
阶段一:
阶段一代码下载链接在页面底部.
第一阶段我们先完成,拦截客户端的请求,判断是否有局部会话,没有局部会话就重定向到统一认证中心的登陆界面.
需求分析:
我们要在客户端拦截请求,应该使用啥技术呢?如果使用的是Spring框架,我们可以使用拦截器.但我们的客户端啥框架都没用.要拦截请求,可以使用过滤器Filter
.
客户端
创建:SSOClientFilter.java
,实现javax.servlet.Filter
接口,并贴上Servlet3.0的注解
@WebFilter(filterName="SSOClientFilter",urlPatterns="/*")
public class SSOClientFilter implements Filter {
....
}
步骤:
1.判断是否有局部会话
2.如果有局部会话,直接放行
3.如果没有,重定向到统一认证中心的checkLogin方法,检查是否有全局会话.
package cn.wolfcode.sso.filter;
import java.io.IOException;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import cn.wolfcode.sso.util.SSOClientUtil;
@WebFilter(filterName="SSOClientFilter",urlPatterns="/*")
public class SSOClientFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {}
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
HttpSession session = req.getSession();
//1.判断是否有局部的会话
Boolean isLogin = (Boolean) session.getAttribute("isLogin");
if(isLogin!=null && isLogin){
//有局部会话,直接放行.
chain.doFilter(request, response);
return;
}
//没有局部会话,重定向到统一认证中心,检查是否有其他的系统已经登录过.
// http://www.sso.com:8443/checkLogin?redirectUrl=http://www.crm.com:8088
//这是我们自己写工具类的方法,同学们可以自己看一下,很简单能看懂的.
SSOClientUtil.redirectToSSOURL(req, resp);
}
@Override
public void destroy() {}
}
服务端
步骤:
1.接受重定向过来的checkLogin请求.判断是否有全局的会话
2.如果没有全局会话,获取地址栏的redirectUrl参数,放入到request域中.并转发到登陆页面.
3.如果有全局会话,目前还没到这个阶段,这个逻辑我们先不写.我们先按执行流程来写代码.
在java目录创建SSOServerController.java
,并贴上@Controller
注解
@Controller
public class SSOServerController {
}
编写checkLogin方法.
package cn.wolfcode.sso.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.servlet.http.HttpSession;
/**
* Created by wolfcode-lanxw
*/
@Controller
public class SSOServerController {
/**
* 检查是否有全局会话.
* @param redirectUrl 客户端被拦截的请求地址
* @param session 统一认证中心的会话对象
* @param model 数据模型
* @return 视图地址
*/
@RequestMapping("/checkLogin")
public String checkLogin(String redirectUrl, HttpSession session, Model model){
//1.判断是否有全局的会话
//从会话中获取令牌信息,如果取不到说明没有全局会话,如果能取到说明有全局会话
String token = (String) session.getAttribute("token");
if(StringUtils.isEmpty(token)){
//表示没有全局会话
model.addAttribute("redirectUrl",redirectUrl);
//跳转到统一认证中心的登陆页面.已经配置视图解析器,
// 会找/WEB-INF/views/login.jsp视图
return "login";
}else{
//有全局会话
//目前这段逻辑我们先不写,按着图解流程编写代码
return "";
}
}
}
测试:
服务端和客户端代码写好之后,两个项目都运行tomcat7:run
的命令.
在浏览器地址栏输入:
www.crm.com:8088/main
发现我们的这个请求被拦截了,跳转到了统一认证中心的登陆界面.如下图所示:
阶段二:
基础项目代码下载链接在页面底部.
服务端:
步骤:
1.编写登陆方法,实现认证功能.
2.认证通过,创建令牌.
3.创建全局会话存储令牌信息
4.把令牌存入到数据库t_token表中.
为了减低学习的难度,我们这个案例里面就不去连接数据库(当然要连接数据库也不难),我们的认证就使用静态的认证(账户名:zhangsan,密码:666).
我们使用java中的Set
集合来模拟t_token表.
创建MockDatabaseUtil.java
来模拟数据库
package cn.wolfcode.sso.util;
import java.util.*;
/**
* Created by wolfcode-lanxw
*/
public class MockDatabaseUtil {
//模拟数据库中的t_token表
public static Set<String> T_TOKEN = new HashSet<String>();
}
编写统一认证中心的登陆方法,在SSOServerController.java
中添加login
方法.
/**
* 登陆方法
* @param username 前台登陆的用户名
* @param password 前台登陆的密码
* @param redirectUrl 客户端被拦截的地址
* @param session 服务端会话对象
* @param model 模型数据
* @return 响应的视图地址
*/
@RequestMapping("/login")
public String login(String username,String password,String redirectUrl,HttpSession session,Model model){
if("zhangsan".equals(username)&&"666".equals(password)){
//账号密码匹配
//1.创建令牌信息,只要保证唯一即可,我们就使用UUID.
String token = UUID.randomUUID().toString();
//2.创建全局的会话,把令牌信息放入会话中.
session.setAttribute("token",token);
//3.需要把令牌信息放到数据库中.
MockDatabaseUtil.T_TOKEN.add(token);
//4.重定向到redirectUrl,把令牌信息带上. http://www.crm.com:8088/main?token=
model.addAttribute("token",token);
return "redirect:"+redirectUrl;
}
//如果账号密码有误,重新回到登录页面,还需要把redirectUrl放入request域中.
model.addAttribute("redirectUrl",redirectUrl);
return "login";
}
客户端:
1.统一认证中心登录成功之后,会重定向到之前客户端被拦截的地址,并会把令牌信息在地址栏中作为参数http://www.crm.com:8088/main?token=VcnVMguCDWJX5zHa
此时访问的是客户端的地址,这个地址会被SSOClientFilter
拦截到.
我们在Filter里面需要判断用户地址栏中是否有携带token信息,如果有,说明拥有令牌信息.但是我们得校验令牌token的有效性,使用HttpUrlConnection发送请求到统一认证中心进行校验.
2.如果统一认证中心给我们返回true,表示令牌有效.
3.我们创建局部会话,并放行请求.
在SSOClientFilter.java
中添加如下代码
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
HttpSession session = req.getSession();
//1.判断是否有局部的会话
Boolean isLogin = (Boolean) session.getAttribute("isLogin");
if(isLogin!=null && isLogin){
//有局部会话,直接放行.
chain.doFilter(request, response);
return;
}
/**-------------------------阶段二添加的代码start---------------------------------**/
//判断地址栏中是否有携带token参数.
String token = req.getParameter("token");
if(StringUtils.isNoneBlank(token)){
//token信息不为null,说明地址中包含了token,拥有令牌.
//判断token信息是否由认证中心产生的.
//验证地址为:http://www.sso.com:8443/verify
String httpURL = SSOClientUtil.SERVER_URL_PREFIX+"/verify";
Map<String,String> params = new HashMap<String,String>();
//把客户端地址栏添加到的token信息传递给统一认证中心进行校验
params.put("token", token);
try {
String isVerify = HttpUtil.sendHttpRequest(httpURL, params);
if("true".equals(isVerify)){
//如果返回的字符串是true,说明这个token是由统一认证中心产生的.
//创建局部的会话.
session.setAttribute("isLogin", true);
//放行该次的请求
chain.doFilter(request, response);
return;
}
} catch (Exception e) {
//这里可以完善,比如出现异常在前台显示具体页面
//我们这个案例就不做这个哈.
e.printStackTrace();
}
}
/**-------------------------阶段二添加的代码end---------------------------------**/
//没有局部会话,重定向到统一认证中心,检查是否有其他的系统已经登录过.
// http://www.sso.com:8443/checkLogin?redirectUrl=http://www.crm.com:8088
SSOClientUtil.redirectToSSOURL(req, resp);
}
服务端:
1.需要在统一认证中心添加一个认证令牌信息的方法.
在SSOServerController.java
中添加verifyToken
方法,具体代码如下:
/**
* 校验客户端传过来的令牌信息是否有效
* @param token 客户端传过来的令牌信息
* @return
*/
@RequestMapping("/verify")
@ResponseBody
public String verifyToken(String token){
//在模拟的数据库表t_token中查找是否有这条记录
if(MockDatabaseUtil.T_TOKEN.contains(token)){
//说明令牌有效,返回true
return "true";
}
return "false";
}
测试:
到这里为止,阶段二代码就搞定了.单点登录功能的95%代码完成.
客户端和服务端都运行tomcat7:run
命令
在浏览器中按下Ctrl+Shift+Delete
按键清楚cookie和缓存,避免干扰.
在浏览器中输入:http://www.crm.com:8088/main
,浏览器跳转到统一认证中心的登陆页面.输入zhangsan:666
,点击登陆.此时就访问到了CRM系统的首页.界面如下.
阶段三:
阶段三代码下载链接在页面底部.
在前面的代码我们完成了单系统的登陆,现在我们先看看如果在多系统的环境下,我们是否能实现多系统的下一次登陆,处处运行的功能.
客户端:
1.拷贝sso-client-base-project
项目,命名为sso-client-base-project2
2.修改新项目的pom.xml
文件第41行,Tomcat插件的启动端口,修改为:8089
3.修改sso.properties
文件,修改如下:
server-url-prefix=http://www.sso.com:8443
client-host-url=http://www.wms.com:8089
4.修改/WEB-INF/views/main.jsp
的标题,和内容,主要方便测试的时候看到不同的效果.(可改可不改)
服务端:
需要完善checkLogin
方法,添加如果有全局会话的逻辑.
@RequestMapping("/checkLogin")
public String checkLogin(String redirectUrl, HttpSession session, Model model){
//1.判断是否有全局的会话
//从会话中获取令牌信息,如果取不到说明没有全局会话,如果能取到说明有全局会话
String token = (String) session.getAttribute("token");
if(StringUtils.isEmpty(token)){
//表示没有全局会话
model.addAttribute("redirectUrl",redirectUrl);
//跳转到统一认证中心的登陆页面.已经配置视图解析器,
// 会找/WEB-INF/views/login.jsp视图
return "login";
}else{
/**---------------------------阶段三添加的代码start--------------------**/
//有全局会话
//取出令牌信息,重定向到redirectUrl,把令牌带上
// http://www.wms.com:8089/main?token=
model.addAttribute("token",token);
/**---------------------------阶段三添加的代码end-----------------------**/
return "redirect:"+redirectUrl;
}
}
测试:
在服务端和两个客户端运行tomcat7:run
命令.
在浏览器中按下Ctrl+Shift+Delete
按键清楚cookie和缓存,避免干扰.
在浏览器中输入:http://www.crm.com:8088/main
,浏览器跳转到统一认证中心的登陆页面.输入zhangsan:666
,点击登陆.此时就访问到了CRM系统的首页.说明已经登录成功.
接着浏览器中输入:http://www.wms.com:8089/main
,发现这次请求就不需要登陆,可以直接访问了.到此为止,我们就完成单点登录所有的代码.可以实现一次登陆,处处穿梭.
九、单点登录步骤梳理:
客户端
1.拦截客户端的请求判断是否有局部的session
2.1如果有局部的session,放行请求.
2.2如果没有局部session
2.2.1请求中有携带token参数
2.2.1.1如果有,使用HttpURLConnection发送请求校验token是否有效.
2.2.1.1.1如果token有效,建立局部的session.
2.2.1.1.2如果token无效,重定向到统一认证中心页面进行登陆.
2.2.1.2如果没有,重定向到统一认证中心页面进行登陆.
2.2.2请求中没有携带token参数,重定向到统一认证中心页面进行登陆.
服务端
1.检测客户端在服务端是否已经登录了.(checkLogin方法)
1.1获取session中的token.
1.2如果token不为空,说明服务端已经登录过了,此时重定向到客户端的地址,并把token带上
1.3如果token为空,跳转到统一认证中心的的登录页面,并把redirectUrl放入到request域中.
2.统一认证中心的登录方法(login方法)
2.1判断用户提交的账号密码是否正确.
2.2如果正确
2.2.1创建token(可以使用UUID,保证唯一就可以)
2.2.2把token放入到session中,还需要把token放入到数据库表t_token中
2.2.3这个token要知道有哪些客户端登陆了,存入数据库t_client_info表中.);
2.2.4转发到redirectUrl地址,把token带上.
2.3如果错误
转发到login.jsp,还需要把redirectUrl参数放入到request域中.
3.统一认证中心认证token方法(verifyToken方法),返回值为String,贴@ResponseBody
3.1如果MockDatabaseUtil.T_TOKEN.contains(token)结果为true,说明token是有效的.
3.1.1返回true字符串.
3.1如果MockDatabaseUtil.T_TOKEN.contains(token)结果为false,说明token是无效的,返回false字符串.
十、代码下载
0.初始项目Demo
熟悉git命令的同学:
客户端的基础项目:
git clone git@github.com:javalanxiongwei/sso-client-base-project.git
cd sso-client-base-project/
git reset --hard 8401333ea845eb32e5f6091e7326ada1983d1ea3
服务顿的基础项目:
git clone git@github.com:javalanxiongwei/sso-server-base-project.git
cd sso-server-base-project/
git reset --hard 6334d9afa08b3d5fc886ad212b3ec62376f5ff32
不熟悉git命令的同学
1.阶段一Demo
熟悉git命令的同学:
客户端阶段一:
git reset --hard b53e0234895b2044ed3042f8f856676c69160281
服务顿阶段一:
git reset --hard 0ee718f408ef82d230fbc61c63b07b29b1277e45
不熟悉git命令的同学
2.阶段二Demo
熟悉git命令的同学:
客户端阶段二:
git reset --hard b53e0234895b2044ed3042f8f856676c69160281
服务顿阶段二:
git reset --hard 0ee718f408ef82d230fbc61c63b07b29b1277e45
不熟悉git命令的同学
3.阶段三Demo
熟悉git命令的同学:
客户端2阶段三下载:
git clone git@github.com:javalanxiongwei/sso-client-base-project2.git
cd sso-client-base-project2/
git reset --hard 01db6af390ff9f765121d3f9e9b1895b0e671bd5
服务顿阶段三:
git reset --hard 80e7ad5a1d67b5d63d00e3532fed9ef58fe74fd9