【学习】有关单点登录的一切

【学习】有关单点登录的一切

一、单点登录概述

(一)什么是单点登录

单点登录(Single Sign-On,简称SSO)是一种身份验证机制,允许用户使用一组凭证(通常是用户名和密码)访问多个相关但独立的系统或应用程序,而无需在每个系统中单独登录。用户只需登录一次,就可以访问所有授权的系统,无需重复认证过程。

(二)为什么需要单点登录

  1. 提升用户体验:用户无需记忆多套凭证,减少重复登录操作
  2. 降低密码疲劳:减少用户创建和管理多个账号的负担
  3. 简化身份管理:集中管理用户身份和权限
  4. 提高安全性:统一的身份验证系统更易于实施强安全策略
  5. 降低管理成本:减少密码重置和账户管理的支持请求

(三)单点登录应用场景

  1. 企业内部系统集成(OA、邮件系统、HR系统等)
  2. 互联网产品生态(如阿里系、谷歌系产品)
  3. 教育机构(校园网统一身份认证)
  4. 政府部门内部系统
  5. 医疗系统集成

二、单点登录原理

(一)基本工作流程

单点登录的基本原理是通过一个集中的身份认证服务器来管理用户会话和认证状态。其基本流程如下:

  1. 用户尝试访问受保护的应用系统
  2. 应用系统检查用户是否已通过认证
  3. 如未认证,重定向到SSO认证服务器
  4. 用户在SSO服务器完成身份验证
  5. SSO服务器创建全局会话并设置认证凭证(通常是cookie或token)
  6. 用户被重定向回原应用,带有身份凭证
  7. 应用系统验证凭证,创建本地会话
  8. 用户访问其他受保护应用时,该应用会验证用户的认证状态
  9. 如已通过认证,无需重新登录;否则重定向到SSO服务器

(二)常见的实现模式

  1. 基于Cookie的SSO

    • 适用于同域应用
    • 共享同一域下的Cookie
    • 简单易实现但受同源策略限制
  2. 基于Token的SSO

    • 使用JWT等令牌机制
    • 适用于跨域应用
    • 无状态设计,更适合分布式系统
  3. 基于SAML的SSO

    • 使用安全断言标记语言
    • 基于XML的开放标准
    • 适合企业级应用集成
  4. 基于OAuth/OpenID Connect的SSO

    • 结合授权和认证
    • 适用于第三方应用集成
    • 广泛应用于互联网服务

三、主流单点登录解决方案

(一)CAS(Central Authentication Service)

CAS是Yale大学开发的开源单点登录协议,后来由Apereo基金会维护,是Java生态中最常用的SSO解决方案之一。

1. CAS架构组件

1
2
3
4
5
6
7
8
9
+--------+     +--------+     +--------+
| 浏览器 | <-> | CAS客户端| <-> | 业务系统 |
+--------+ +--------+ +--------+
^ ^
| |
v v
+--------+ +--------+
| CAS服务器| <-> | 用户数据库|
+--------+ +--------+

2. CAS工作流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// CAS认证流程伪代码
if (!isAuthenticated()) {
// 1. 重定向到CAS服务器
redirect("https://cas-server/login?service=https://app/callback");

// 2. 用户在CAS服务器输入凭证
CasServer.authenticate(username, password);

// 3. 认证成功后重定向回应用,带上ticket
redirect("https://app/callback?ticket=ST-123456");

// 4. 应用服务器验证ticket
boolean valid = CasClient.validateTicket("ST-123456");
if (valid) {
createLocalSession();
}
}

3. CAS特点

  • 开源、成熟,有丰富的文档和社区支持
  • 支持多种认证方式(LDAP、数据库、OAuth等)
  • 提供多种客户端(Java、.NET、PHP等)
  • 支持单点登出功能
  • 票据(Ticket)有效期短,安全性高

(二)OAuth 2.0和OpenID Connect

OAuth 2.0是一个授权框架,而OpenID Connect是基于OAuth 2.0的身份认证层。

1. 基本组件

  • 资源所有者(用户)
  • 客户端(第三方应用)
  • 授权服务器(认证服务)
  • 资源服务器(API服务)

2. 认证流程(授权码模式)

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
// OAuth2授权码流程伪代码
// 1. 引导用户到授权服务器
String authUrl = "https://auth-server/oauth/authorize?" +
"client_id=client123&" +
"redirect_uri=https://client-app/callback&" +
"response_type=code&" +
"scope=openid profile";
redirect(authUrl);

// 2. 用户在授权服务器登录并同意授权
// 3. 授权服务器重定向回客户端应用,带上授权码
// https://client-app/callback?code=auth_code_123

// 4. 客户端应用使用授权码交换访问令牌
HttpResponse response = HttpClient.post("https://auth-server/oauth/token",
{
"grant_type": "authorization_code",
"code": "auth_code_123",
"client_id": "client123",
"client_secret": "secret456",
"redirect_uri": "https://client-app/callback"
});

// 5. 获取访问令牌和ID令牌
TokenResponse tokens = parseJson(response.body);
String accessToken = tokens.access_token;
String idToken = tokens.id_token; // OpenID Connect提供的身份令牌

3. 特点

  • 广泛应用于互联网服务和移动应用
  • 支持多种授权模式(授权码、隐式、密码、客户端凭证)
  • 可扩展性强,支持自定义scope和声明
  • OpenID Connect提供标准化的用户信息获取方式
  • 适合跨域和跨平台场景

(三)SAML(安全断言标记语言)

SAML是一种基于XML的开放标准,用于在不同实体间交换身份验证和授权数据。

1. 主要组件

  • 身份提供者(IdP):负责认证用户身份
  • 服务提供者(SP):提供服务的应用
  • 断言(Assertion):包含用户身份信息的XML文档

2. 工作流程

1
2
3
4
5
6
7
8
9
10
11
12
+--------+    1.访问    +--------+
| 浏览器 | ----------> | SP |
+--------+ +--------+
| ^ | ^
| | | |
2.重| |4.带断言 3.| |验证
| |返回 请| |断言
| || |
v | v |
+--------+ +--------+
| IdP | <---------> |元数据交换|
+--------+ +--------+

3. 特点

  • 企业级应用的标准选择
  • 提供丰富的用户属性传输能力
  • 支持复杂的身份联合场景
  • 实现比较复杂,配置工作量大
  • 适合安全要求高的场景

四、单点登录实现步骤

(一)基于Session的单点登录实现

对于同域应用,可以使用共享Session的方式实现简单的单点登录:

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
// 1. 创建统一的登录服务
@RestController
public class SsoController {

@PostMapping("/login")
public String login(String username, String password, HttpServletResponse response) {
// 验证用户名密码
if (userService.verify(username, password)) {
// 创建会话
String token = UUID.randomUUID().toString();
// 存储在Redis中,设置过期时间
redisTemplate.opsForValue().set("sso:token:" + token, username, 30, TimeUnit.MINUTES);

// 设置Cookie
Cookie cookie = new Cookie("SSO_TOKEN", token);
cookie.setDomain(".example.com"); // 设置顶级域名,使子域名共享
cookie.setPath("/");
cookie.setMaxAge(1800); // 30分钟
response.addCookie(cookie);

return "登录成功";
}
return "登录失败";
}

@GetMapping("/verify")
public String verifyToken(String token) {
return redisTemplate.opsForValue().get("sso:token:" + token);
}

@GetMapping("/logout")
public String logout(HttpServletRequest request, HttpServletResponse response) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("SSO_TOKEN".equals(cookie.getName())) {
// 删除Redis中的token
redisTemplate.delete("sso:token:" + cookie.getValue());

// 清除Cookie
cookie.setMaxAge(0);
cookie.setPath("/");
cookie.setDomain(".example.com");
response.addCookie(cookie);
break;
}
}
}
return "登出成功";
}
}

// 2. 各应用系统的过滤器
public class SsoFilter implements Filter {

@Autowired
private RestTemplate restTemplate;

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;

// 检查是否有SSO Token
Cookie[] cookies = req.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("SSO_TOKEN".equals(cookie.getName())) {
// 验证Token
String username = restTemplate.getForObject(
"https://sso-server/verify?token=" + cookie.getValue(), String.class);

if (username != null) {
// Token有效,放行请求
chain.doFilter(request, response);
return;
}
break;
}
}
}

// 未登录,重定向到SSO登录页
resp.sendRedirect("https://sso-server/login?redirect=" +
URLEncoder.encode(req.getRequestURL().toString(), "UTF-8"));
}
}

(二)基于JWT的跨域单点登录

对于跨域应用,可以使用JWT实现无状态的单点登录:

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
// 1. JWT工具类
public class JwtUtil {

private static final String SECRET_KEY = "your-secret-key";
private static final long EXPIRATION_TIME = 1800000; // 30分钟

public static String generateToken(String username) {
Date now = new Date();
Date expiration = new Date(now.getTime() + EXPIRATION_TIME);

return Jwts.builder()
.setSubject(username)
.setIssuedAt(now)
.setExpiration(expiration)
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();
}

public static String validateToken(String token) {
try {
Claims claims = Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody();

return claims.getSubject();
} catch (Exception e) {
return null;
}
}
}

// 2. SSO服务端
@RestController
public class SsoJwtController {

@PostMapping("/api/login")
public Map<String, String> login(@RequestBody LoginRequest request) {
// 验证用户名密码
if (userService.verify(request.getUsername(), request.getPassword())) {
// 生成JWT令牌
String token = JwtUtil.generateToken(request.getUsername());

Map<String, String> response = new HashMap<>();
response.put("token", token);
return response;
}

throw new UnauthorizedException("用户名或密码错误");
}
}

// 3. 客户端应用验证JWT
@Component
public class JwtAuthFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {

String authHeader = request.getHeader("Authorization");

if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
String username = JwtUtil.validateToken(token);

if (username != null) {
// 创建认证对象
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(username, null, new ArrayList<>());

SecurityContextHolder.getContext().setAuthentication(auth);
}
}

filterChain.doFilter(request, response);
}
}

(三)接入CAS实现单点登录

在Spring Boot应用中集成CAS:

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
// 1. 添加依赖
/*
<dependency>
<groupId>org.jasig.cas.client</groupId>
<artifactId>cas-client-core</artifactId>
<version>3.6.4</version>
</dependency>
*/

// 2. 配置CAS客户端
@Configuration
public class CasConfig {

@Bean
public FilterRegistrationBean<AuthenticationFilter> authenticationFilterRegistration() {
FilterRegistrationBean<AuthenticationFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new AuthenticationFilter());

// 设置CAS服务器URL
registration.addInitParameter("casServerLoginUrl", "https://cas.example.com/cas/login");
// 设置当前服务URL
registration.addInitParameter("serverName", "https://app.example.com");

registration.addUrlPatterns("/*");
registration.setOrder(1);
return registration;
}

@Bean
public FilterRegistrationBean<TicketValidationFilter> ticketValidationFilterRegistration() {
FilterRegistrationBean<TicketValidationFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new Cas30ProxyReceivingTicketValidationFilter());

// 设置CAS服务器URL
registration.addInitParameter("casServerUrlPrefix", "https://cas.example.com/cas");
// 设置当前服务URL
registration.addInitParameter("serverName", "https://app.example.com");

registration.addUrlPatterns("/*");
registration.setOrder(2);
return registration;
}

@Bean
public FilterRegistrationBean<HttpServletRequestWrapperFilter> requestWrapperFilterRegistration() {
FilterRegistrationBean<HttpServletRequestWrapperFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new HttpServletRequestWrapperFilter());
registration.addUrlPatterns("/*");
registration.setOrder(3);
return registration;
}
}

五、单点登录安全考虑

(一)常见安全风险

  1. 会话劫持:攻击者获取用户的会话标识
  2. 跨站脚本攻击(XSS):注入恶意脚本获取用户凭证
  3. 跨站请求伪造(CSRF):利用用户已认证的身份执行恶意操作
  4. 中间人攻击:拦截通信并获取认证信息
  5. 重放攻击:重复使用已截获的有效请求
  6. 密码破解:通过暴力破解或字典攻击获取用户密码

(二)安全最佳实践

  1. 使用HTTPS:所有SSO通信必须加密传输

  2. 设置安全的Cookie属性

    1
    2
    3
    4
    Cookie cookie = new Cookie("SSO_TOKEN", token);
    cookie.setSecure(true); // 仅HTTPS传输
    cookie.setHttpOnly(true); // 防止JavaScript访问
    cookie.setSameSite("Lax"); // 防止CSRF攻击
  3. 实施短时间令牌

    1
    2
    3
    4
    // 设置较短的过期时间,定期刷新
    String token = Jwts.builder()
    .setExpiration(new Date(System.currentTimeMillis() + 15 * 60 * 1000)) // 15分钟
    .compact();
  4. 防止暴力攻击

    1
    2
    3
    4
    5
    // 账户锁定机制
    if (failedAttempts >= MAX_ATTEMPTS) {
    lockAccount(username, LOCK_DURATION);
    throw new AccountLockedException("账户已锁定,请稍后再试");
    }
  5. 实现安全的登出机制

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 清除服务端会话
    sessionRegistry.removeSessionInformation(sessionId);

    // 清除客户端Cookie
    cookie.setMaxAge(0);
    response.addCookie(cookie);

    // 对于JWT,可维护一个黑名单
    jwtBlacklist.add(token, expirationTime);
  6. 加强认证机制

    • 实施多因素认证
    • 密码策略(复杂度、定期更换)
    • 生物识别(如适用)

六、单点登录优缺点分析

(一)优点

  1. 提升用户体验:减少登录次数,简化操作流程
  2. 统一身份管理:集中管理用户身份和权限
  3. 减少凭证数量:用户只需记住一组凭证
  4. 提高安全性:可在认证服务上实施严格的安全策略
  5. 降低运维成本:减少密码重置和账户管理工作

(二)缺点

  1. 单点故障风险:认证服务故障可能影响所有系统
  2. 安全风险集中:一旦认证服务被攻破,所有系统都面临风险
  3. 实现复杂性:特别是跨域、跨平台场景
  4. 性能挑战:认证服务需要处理大量请求
  5. 标准兼容性:不同系统可能采用不同的认证机制

(三)适用场景分析

场景 推荐方案 理由
企业内部系统 CAS 或 SAML 安全性高,支持企业级集成
互联网应用 OAuth 2.0 + OpenID Connect 灵活性强,适合跨域场景
移动应用 JWT 或 OAuth 2.0 轻量级,适合客户端存储
高安全要求场景 SAML 提供丰富的安全断言
微服务架构 JWT 无状态设计,易于扩展

七、实际案例分析

(一)某企业OA系统集成

某企业拥有OA、HR、邮件、CRM等多个内部系统,通过CAS实现单点登录,主要步骤:

  1. 部署CAS服务器,连接LDAP目录
  2. 各系统集成CAS客户端
  3. 定制登录页面,符合企业VI
  4. 实现统一登出功能
  5. 设置合理的会话过期时间

效果:员工只需登录一次,即可访问所有内部系统,提升工作效率。

(二)电子商务平台的OAuth实现

某电商平台包括网站、移动APP、小程序等多个前端,通过OAuth 2.0实现统一认证:

  1. 构建授权服务器,支持多种授权模式
  2. 网站使用授权码模式
  3. 移动APP使用PKCE增强的授权码模式
  4. 小程序使用客户端凭证模式
  5. 实现令牌刷新机制
  6. 提供第三方应用接入能力

效果:用户在任何终端登录一次后,可以无缝切换不同平台,同时支持第三方应用接入生态。

八、总结与展望

单点登录技术极大地提升了用户体验和系统安全性,是现代应用架构中不可或缺的组成部分。通过选择合适的SSO方案,企业可以实现统一的身份管理,降低运维成本,提高用户满意度。

随着技术发展,无密码认证、生物识别、零信任架构等新趋势将进一步革新单点登录技术。在实施SSO时,需要权衡安全性、易用性和复杂性,选择最适合自身业务场景的解决方案。

笔者建议,无论选择哪种SSO方案,都应当重视身份安全,采用多层次的防护措施,并定期进行安全审计和渗透测试,确保认证系统的稳定可靠。