Fork me on GitHub

Java实现WEB-SSO单点登录

SSO的基本原理与Java代码实现

推荐阅读:单点登录原理与简单实现

基本原理

  • Http无状态协议。浏览器使用http协议对服务器发出的每一次请求,服务器都会独立处理,不与之前或之后的请求产生关联,即无状态。所以为了保护服务器的某些资源,必须限制浏览器请求,鉴定请求的合法性。既然http无状态,就让服务器和浏览器共同维护一个状态,即会话机制。

  • 会话。浏览器第一次请求服务器,服务器创建一个会话,并将会话的id作为响应的一部分发送给浏览器。浏览器存储该id,并在第二次第三次请求时带上该id,服务器取得请求中的会话id就知道是不是同一个用户了。

  • 会话机制。服务器在内存中保存session对象。浏览器在cookie中保存sessionId,在Tomcat中sessionId用的是JSESSIONID,流程如下:

  • 浏览器第一次输入帐密,服务器拿到帐密去数据库比对,比对正确说明是合法用户,将该会话标记为“已授权”或“已登录”的状态,该会话状态被服务器保存在会话对象中,当用户再次访问时,服务器在会话对象中查看登录状态,判断是否合法,合法后才允许访问。

  • 单系统登录解决方案的核心是cookie,cookie携带会话id在浏览器和服务器之间维护会话状态,但cookie受到域的限制(通常对应网站的域名)。浏览器在发送http请求时会自动携带与该域匹配的cookie,而不是所有cookie。。需要注意的是,曾经流行过的顶级域名的方式虽然可行,但面临着应用群域名不统一,技术不同,共享cookie无法跨语言平台登录,cookie本身不安全等诸多问题。

  • 单点登录(SSO)。举例:淘宝网上登录后,打开天猫、闲鱼、飞猪等网站会自动登录,无需重复进行身份验证,好处是方便管理、保障安全、节省登录时间。

  • 认证中心。相比单系统登录,sso需要一个独立的认证中心,只有认证中心能接受帐密等安全信息,其他系统不提供登录入口。

  • 间接授权ticket。sso认证中心验证用户的帐密没有问题,创建授权令牌ticket,在接下来的跳转中,授权令牌作为参数发送给各个子系统,子系统拿到令牌,即得到授权,可以借此创建局部session,局部session的登录方式与单系统登录方式相同。

    1. 用户访问系统1的受保护资源,系统1发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数
    2. sso认证中心发现用户未登录,将用户引导至登录页面

用户输入用户名密码提交登录申请
3. sso认证中心校验用户信息,创建用户与sso认证中心之间的会话,称为全局会话,同时创建授权令牌
4. sso认证中心带着令牌跳转回最初的请求地址(系统1)
5. 系统1拿到令牌,去sso认证中心校验令牌是否有效
6. sso认证中心校验令牌,返回有效,注册系统1
7. 系统1使用该令牌创建与用户的会话,称为局部会话,返回受保护资源
8. 用户访问系统2的受保护资源
9. 系统2发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数
10. sso认证中心发现用户已登录,跳转回系统2的地址,并附上令牌
11. 系统2拿到令牌,去sso认证中心校验令牌是否有效
12. sso认证中心校验令牌,返回有效,注册系统2
13. 系统2使用该令牌创建与用户的局部会话,返回受保护资源

  • 用户登录成功之后,会与SSO认证中心及各个子系统建立会话,用户与SSO认证中心建立全局会话,用户与子系统建立局部会话。局部会话存在,全局会话一定存在;但全局会话存在,局部会话不一定存在

  • 单点注销。在一个子系统中注销,所有子系统的会话都将被注销。sso认证中心一直监听全局会话的状态,一旦全局会话销毁,监听器将通知所有注册系统执行注销操作。如下图:

    1. 用户向系统1发起注销请求
    2. 系统1根据用户与系统1建立的会话id拿到令牌,向sso认证中心发起注销请求
    3. sso认证中心校验令牌有效,销毁全局会话,同时取出所有用此令牌注册的系统地址
    4. sso认证中心向所有注册系统发起注销请求
    5. 各注册系统接收sso认证中心的注销请求,销毁局部会话
    6. sso认证中心引导用户至登录页面

代码实现

课程网址:慕课网——Java实现单点登录

文件结构(仅列出关键部分):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
1.SSO_Server
|_src
|_com.imooc.sso.servlet
|_LoginServlet
|_web
|_index.jsp
|_WEB-INF
|_web.xml
2.WebApp1
|_src
|_com.imooc.sso
|_filter
|_UserFilter
|_servlet
|_MainServlet
|_web
|_index.jsp
|_WEB-INF
|_web.xml

3. WebApp2与WebApp1目录结构一致,此处略
  1. 采用一个认证服务器,两个应用服务器来模拟SSO;
  2. 使用idea作IDE,并使用Tomcat的local模拟,SSO_server使用localhost:8080,WebApp1使用localhost:8081,WebApp1使用localhost:8082
LoginServlet.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package com.imooc.sso.servlet;

import *;

public class LoginServlet extends HttpServlet {

private String domains;

@Override
public void init(ServletConfig sc) throws ServletException {
super.init(sc);
domains = sc.getInitParameter("domains");
}

@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {

if (Objects.equals("/login", request.getServletPath())) {
// 如果是登录操作
String username = request.getParameter("username");
String password = request.getParameter("password");
String source = request.getParameter("source");

if(null==source||Objects.equals("",source)){
//referer是当前的url,刚被filter拦截下来,source内无值
String referer=request.getHeader("referer");
source=referer.substring(referer.indexOf("source=")+7);
}

if (Objects.equals(username, password)) {
//设定用户名与密码一致时表示登录成功,申请ticket放入url,并将source和domains放入url
String ticket = UUID.randomUUID().toString().replace("-", "");
System.out.println("****************:" + ticket);

// response.sendRedirect(source+"/main?ticket="+ticket+"&domains="+domains.replace(source,""));//将source之外的domain都通知到
response.sendRedirect(source + "/main?ticket=" + ticket + "&domains=" + domains.replace(source + ",", "").replace("," + source, "").replace(source, ""));
} else {
//登录失败
request.setAttribute("source",source);
request.getRequestDispatcher("/WEB-INF/views/login.jsp").forward(request, response);

}

} else if (Objects.equals("/ssoLogin", request.getServletPath())) {
//如果是跳转登录的请求
request.getRequestDispatcher("/WEB-INF/views/login.jsp").forward(request, response);

} else if(Objects.equals("/ssoLogout",request.getServletPath())){
//如果退出请求
String source = request.getParameter("source");
if(null==source||Objects.equals("",source)){
String referer=request.getHeader("referer");
source=referer.substring(referer.indexOf("source=")+7);
}
response.sendRedirect(source+"/logout?domains"+domains.replace(source+",","").replace(","+source,"").replace(source,""));

}
}
}

  1. 对login和ssoLogin两种不同路径采用不同的处理方式。若login路径,则取出帐密和source(url中从头到端口号的位置);
  2. 如果帐密验证通过,获取随机ticket,与init时获取的domains(替换掉source部分)一起放入url中重定向到main页面;
  3. 如果帐密验证失败,则将source加入属性,并转发到login页面;
  4. 如果是ssoLogin路径,则转发到login页面。
  5. 如果时ssoLogout路径,则携带source和domains转发到各WebApp的logout路径
SSO Server的web.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">

<servlet>
<servlet-name>LoginServlet</servlet-name>
<servlet-class>com.imooc.sso.servlet.LoginServlet</servlet-class>
<init-param>
<param-name>domains</param-name>
<param-value>http://127.0.0.1:8081,http://127.0.0.1:8082</param-value>
</init-param>
</servlet>
<servlet-mapping>
<servlet-name>LoginServlet</servlet-name>
<url-pattern>/login</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>LoginServlet</servlet-name>
<url-pattern>/ssoLogin</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>LoginServlet</servlet-name>
<url-pattern>/ssoLogout</url-pattern>
</servlet-mapping>
</web-app>
  1. 建立ssoLogin和login的servlet,并设置初始化参数domains;
  2. domains中包含所有WebApp的地址,如代码块中的http://127.0.0.1:8081http://127.0.0.1:8082
UserFilter.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
package com.imooc.sso.filter;


import *;

public class UserFilter implements Filter {

private String server;
private String app;

@Override
public void init(FilterConfig filterConfig) throws ServletException {

server = filterConfig.getInitParameter("server");
app = filterConfig.getInitParameter("app");
}

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {

/**
* 进入WebApp的情况分类:
* 1。首次进入,无cookie,无ticket,由情况2的else处理后response
* 2。首次SSO_Server判定成功,重定向回本WebApp的main方法,url携带着source、ticket、domains,由 情况2的if处理request,然后
* 在MainServlet对其他app设置cookie后,跳转到main.jsp页面,并对response设置cookie超时时间,由 情况2的if处理response
* 3。第二次、第三次进入被拦截时,从cookie中提取ticket,若未超时则放行;若超时,则分情况,当属性ticket为空,即未登录访问main路径时,跳转到 ssoLogin路径
* 当属性ticket不为空,即单点登录后回来的请求,ticket加上超时时间,然后放行
* 4。如果是ssoLogout进来,则携带source转发到Server的ssoLogout路径
*
*/

request.setAttribute("app",app);
if(Objects.equals("/ssoLogout",((HttpServletRequest)request).getServletPath())){
((HttpServletResponse) response).sendRedirect(server + "/ssoLogout?source=" + app);
return;
}

String ticket = null;

//情况1:cookie有该用户
if (null != ((HttpServletRequest) request).getCookies()) {
for (Cookie cookie : ((HttpServletRequest) request).getCookies()) {
if (Objects.equals(cookie.getName(), "Ticket_Granting_Ticket")) {
ticket = cookie.getValue();
break;
}
}
}
if (!Objects.equals(null, ticket)) {
//判断超时时间
String[] values = ticket.split(":");
ticket = request.getParameter("ticket");
if (Long.valueOf(values[1]) < System.currentTimeMillis()) {
//超时
if (Objects.equals(null, ticket)) {
//非单点登录后跳过来的请求
((HttpServletResponse) response).sendRedirect(server + "/ssoLogin?source=" + app);
return;
} else {
//单点登录之后回来的请求
ticket = ticket + ":" + (System.currentTimeMillis() + 100000);
//登录成功,在response时,从url取出ticket放入cookie中
((HttpServletResponse) response).addCookie(new Cookie("Ticket_Granting_Ticket", ticket));
filterChain.doFilter(request, response);
return;
}
}
filterChain.doFilter(request, response);
return;
}

//情况2:cookie里没有该用户,第一次时走此逻辑
ticket = request.getParameter("ticket");
if (!Objects.equals(null, ticket) && !Objects.equals("", ticket.trim())) {
ticket = ticket + ":" + (System.currentTimeMillis() + 100000);
//登录成功,在response时,从url取出ticket放入cookie中
((HttpServletResponse) response).addCookie(new Cookie("Ticket_Granting_Ticket", ticket));
filterChain.doFilter(request, response);
} else {
//cookie和ticket均登录失败
((HttpServletResponse) response).sendRedirect(server + "/ssoLogin?source=" + app);
}
}

@Override
public void destroy() {

}
}

  1. UserFilter用来过滤登录操作和注销操作;
  2. 当cookie中有该用户且ticket不为空时,为请求放行;(经过app1,ssoServer,login成功之后,访问app2的情形)
  3. 当cookie没有该用户且ticket不为空时,说明登录成功,从请求的url中获取该ticket参数,放入cookie中,为请求放行;(经过app1,ssoServer,login登录success的进行时)
  4. 当cookie没有该用户,且ticket为空时,说明登录不成功,在url中添加上server和app参数,重定向到ssoLogin中;(未经过ssoServer认证,直接路径链接到功能页面的非法情形)
  5. 仅当ssoLogout路径进来时请求会被转发到ssoServer最终抵达本app的MainServlet.java中完成剩下的注销步骤,其余情况会被拦截
MainServlet.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
package com.imooc.sso.servlet;

import *;

public class MainServlet extends HttpServlet {

private ExecutorService service = Executors.newFixedThreadPool(10);

private String servers;

private void syncCookie(String server, String ticket,String method) {
service.submit(new Runnable() {
@Override
public void run() {
HttpPost httpPost = new HttpPost(server + "/setCookie?ticket=" + ticket);
CloseableHttpClient httpClient = null;
CloseableHttpResponse response = null;
try {
httpClient = HttpClients.createDefault();
response = httpClient.execute(httpPost);
HttpEntity entity = response.getEntity();
String responseContent = EntityUtils.toString(entity, "UTF-8");
System.out.println("=========" + responseContent);
} catch (Exception e) {
e.printStackTrace();
} finally {

try {
if (null != response) {
response.close();
}
} catch (IOException e) {
e.printStackTrace();
}

try {
if (null != httpClient) {
httpClient.close();
}
} catch (IOException e) {
e.printStackTrace();
}

}
}
});
}

@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
if (Objects.equals("/main", request.getServletPath())) {
// 从server端登录后转发过来
String domains = request.getParameter("domains");
if(null!=domains){
this.servers=domains;
}
String ticket = request.getParameter("ticket");
if(null!=domains&&null!=ticket){
for (String server : domains.split(",")) {
if (!Objects.equals(null, server) && !Objects.equals("", server.trim())) {
syncCookie(server, ticket ,"setCookie");
}
}
}

request.getRequestDispatcher("/WEB-INF/views/main.jsp").forward(request, response);

} else if (Objects.equals("/setCookie", request.getServletPath())) {
String ticket = request.getParameter("ticket");
response.addCookie(new Cookie("Ticket_Granting_Ticket", ticket));
response.setCharacterEncoding("UTF-8");
response.setContentType("application/text; charset=utf-8");
PrintWriter out = null;
try {
out = response.getWriter();
out.write("ok");
} catch (IOException e) {

e.printStackTrace();
} finally {
if (null != out) {
out.close();
}
}

try {
Desktop.getDesktop().browse(new URI(request.getAttribute("app")+"/?ticket="+ticket));
} catch (URISyntaxException e) {
e.printStackTrace();
}


}else if(Objects.equals("/logout",request.getServletPath())){
Cookie cookie=new Cookie("Ticket_Granting_Ticket",null);
cookie.setMaxAge(0);
response.addCookie(cookie);
if(null!=servers){
for(String server:servers.split(",")){
if(Objects.equals(null,server)&&!Objects.equals("",server.trim())){
syncCookie(server,"","removeCookie");
}
}
}
request.getRequestDispatcher("/WEB-INF/views/logout.jsp").forward(request,response);
} else if(Objects.equals("/removeCookie",request.getServletPath())){
Cookie cookie =new Cookie("Ticket_Granting_Ticket",null);
cookie.setMaxAge(0);
response.addCookie(cookie);

response.setCharacterEncoding("UTF-8");
response.setContentType("application/text,charset=utf-8");
PrintWriter out =null;
try{
out=response.getWriter();
out.write("removeOK");
}catch(IOException e){
e.printStackTrace();
}finally {
if(null!=out){
out.close();
}
}
}
}
}

  1. 先看service方法,如果是main路径,说明是经过LoginServlet.java的登录成功操作后转发过来的,需要先将domains中的各个app的cookie中写入ticket;如果是setCookie路径,则只能是上一步写入ticket的后续操作;(例:app1登录成功进入service方法的main路径,将其他app的server进入syncCookie方法,其他app会进入setCookie路径)
  2. 如果是logout路径,将cookie置空,将其他WebApp调用syncCookie方法将对应cookie依次置空;
  3. 再看setCookie方法,使用线程池的方法,使用HttpClient进行申请异步Http服务;
WebApp1的Web.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<filter>
<filter-name>UserFilter</filter-name>
<filter-class>com.imooc.sso.filter.UserFilter</filter-class>
<init-param>
<param-name>server</param-name>
<param-value>http://127.0.0.1:8080</param-value>
</init-param>
<init-param>
<param-name>app</param-name>
<param-value>http://127.0.0.1:8081</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>UserFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

<servlet>
<servlet-name>MainServlet</servlet-name>
<servlet-class>com.imooc.sso.servlet.MainServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>MainServlet</servlet-name>
<url-pattern>/main</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>MainServlet</servlet-name>
<url-pattern>/setCookie</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>MainServlet</servlet-name>
<url-pattern>/logout</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>MainServlet</servlet-name>
<url-pattern>/removeCookie</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>MainServlet</servlet-name>
<url-pattern>/ssoLogout</url-pattern>
</servlet-mapping>
</web-app>
  1. 配置UserFilter和MainServlet;
  2. 为UserFilter设置初始化参数server和app
WebApp2的java类和xml与WebApp1的基本一致,此处代码略
-------------The End-------------