:::color4 警告:

单点登录系统是整个数字化安全生态中至关重要的一环,一般建议使用成熟的解决方案。自己实现的话,不仅开发成本高,而且安全性没有保障。

常见的 SSO 协议有 OAuth 2、OpenID Connect 和 SAML,常用的 SSO 提供商有 Okta、Keycloak、Auth0、以及中国的 authing.cn 等。

:::

由于安全涉及到的方面非常繁多,哪怕一个细节有隐患,都会成为黑客攻击的突破口。因此,非常不建议自己实现单点登录系统。但是,对于学习来说,却正好相反,需要剥离各种细节,从相对简单的模型开始,循序渐进。

我们今天使用 Python 来搭建一个最简单的固定用户名密码的中心用户认证系统,然后以 Java SpringBoot 举例,介绍下游客户端应用如何接入这样的中心用户认证系统,实现单点登录。

基于 Python Flask 的用户认证系统

代码详见: Jeff-Tian/sso (github.com) ,核心代码是:

python import jwt import datetime from flask import Flask, request, redirect, url_for, session, render_template

Initialize the Flask application

app = Flask(name)

Secret key for session management

app.secret_key = 0a86d121-f01f-4524-a922-c86138e4f88a

Secret key for JWT encoding/decoding

JWT_SECRET = your_jwt_secret

Dummy user data for authentication

users = {user1: password1}

@app.route(/) def home(): # Retrieve the token from the session token = session.get(token) if token: try: # Decode the JWT token payload = jwt.decode(token, JWT_SECRET, algorithms=[HS256]) # Render the home page with the users information return render_template(SSO-home.html, user=payload[user]) except jwt.ExpiredSignatureError: # Redirect to login if the token has expired return redirect(url_for(login)) except jwt.InvalidTokenError: # Redirect to login if the token is invalid return redirect(url_for(login))

# Render the home page without user information if no token is found
return render_template(SSO-home.html)

@app.route(/login, methods=[POST, GET]) def login(): if request.method == POST: # Get username and password from the form username = request.form[username] password = request.form[password] # Check if the username and password match if username in users and users[username] == password: # Create a JWT token with user information and expiration time token = jwt.encode({ user: username, exp: datetime.datetime.utcnow() + datetime.timedelta(hours=1) }, JWT_SECRET, algorithm=HS256) # Store the token in the session session[token] = token # Redirect to the next page or home page next_url = request.args.get(next) or url_for(home) return redirect(f{next_url}?token={token})

# Render the login page
return render_template(login.html)

@app.route(/logout) def logout(): # Remove the token from the session session.pop(token, None) # Redirect to the home page return redirect(url_for(home))

if name == main: # Run the Flask application on port 5000 app.run(port=5000)

注意其中的 login 方法,当登录成功之后,会为用户颁发一个 JWT 令牌,并且将该令牌以查询字符串的形式传递回发起的客户端应用。

基于 SpringBoot 的应用接入该身份认证中心

完整代码见:Jeff-Tian/ssoclient (github.com)

增加两个依赖

因为我们用 Python 实现的极简单点登录系统是基于 JWT 的,所以需要引用 jjwt。另外,SpringBoot Security 不可少,后面会用到很多 Security 中的机制来对系统进行保护。

xml org.springframework.boot spring-boot-starter-security ${spring-boot.version} io.jsonwebtoken jjwt 0.9.1

SecurityConfig 配置

增加一个配置项,以在 Spring Security 中,添加对 JWT 的支持,代码如下:

java import org.springframework.context.annotation.Bean; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import com.warrenluo.ssoclient.demos.web.filter.JwtAuthenticationFilter;

@EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers(/).permitAll() .antMatchers(/login).permitAll() .anyRequest().authenticated() .and() .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); }

@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
    return new JwtAuthenticationFilter();
}

}

以上配置对根路由和 /login 路由开放,允许所以人访问,而对其他路由,都要求登录后才能访问。并且添加了一个 JWT 验证的过滤器。

过滤器

<font style=color:rgb(31, 31, 33);background-color:rgb(249, 249, 251);>创建一个过滤器类 <font style=color:rgb(31, 31, 33);background-color:rgb(249, 249, 251);>JwtAuthenticationFilter<font style=color:rgb(31, 31, 33);background-color:rgb(249, 249, 251);>,用于解析和验证 JWT:

java

import java.io.IOException; import java.util.ArrayList;

import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter;

import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts;

public class JwtAuthenticationFilter extends OncePerRequestFilter { // 与 SSO 系统中的密钥保持一致 private final String JWT_SECRET = your_jwt_secret;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {
    // 从请求头中获取 JWT Token
    String token = getTokenFromRequest(request);

    if (token != null) {
        logger.info(Token:  + token);
        try {
            Claims claims = Jwts.parser()
                    .setSigningKey(JWT_SECRET.getBytes())
                    .parseClaimsJws(token)
                    .getBody();

            String username = claims.get(user, String.class);

            logger.info(User  + username +  is authenticated);

            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = new org.springframework.security.core.userdetails.User(username, ,
                        new ArrayList<>());

                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());

                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception e) {
            logger.error(Fail to set user authentication, e);
        }
    } else {
        logger.info(No token found);
    }

    filterChain.doFilter(request, response);
}

private String getTokenFromRequest(HttpServletRequest request) {
    String bearerToken = request.getHeader(Authorization);
    if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(Bearer )) {
        return bearerToken.substring(7);
    } else if (request.getCookies() != null) {
        for (Cookie cookie : request.getCookies()) {
            if (cookie.getName().equals(token)) {
                return cookie.getValue();
            }
        }
    } else {
        Object token = request.getSession().getAttribute(token);

        if (token != null) {
            return token.toString();
        }
    }

    return null;
}

}

需要注意的是 getTokenFromRequest方法,会尝试先从 Header 中获取令牌,如果没有获取到,就会从 Cookie 最后从 Session 中获取 Token。

Auth Controller

在这里我们来配置登录和注销。<font style=color:rgb(31, 31, 33);background-color:rgb(249, 249, 251);>由于我们的 SSO 系统在 <font style=color:rgb(31, 31, 33);background-color:rgb(249, 249, 251);>/login<font style=color:rgb(31, 31, 33);background-color:rgb(249, 249, 251);> 和 <font style=color:rgb(31, 31, 33);background-color:rgb(249, 249, 251);>/logout<font style=color:rgb(31, 31, 33);background-color:rgb(249, 249, 251);> 路径上处理登录和注销,所以相应地我们需要在 Spring Boot 项目中配置这些路径的重定向:

java

import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession;

import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam;

@Controller public class AuthController { @GetMapping(/login) public String login(@RequestParam(required = false) String token, HttpServletResponse response, HttpSession session) { if (token != null) { // 如果从 SSO 系统中获取到了 token,将其存储在 Cookie 或 Session 中 Cookie cookie = new Cookie(token, token); cookie.setHttpOnly(true); cookie.setPath(/); cookie.setMaxAge(3600); response.addCookie(cookie);

        session.setAttribute(token, token);

        return redirect:/user;
    }

    return redirect:http://127.0.0.1:5000/login?next=http://localhost:8080/login;
}

@GetMapping(/logout)
public String logout(HttpServletResponse response, HttpSession session) {
    Cookie cookie = new Cookie(token, null);
    cookie.setHttpOnly(true);
    cookie.setPath(/);
    cookie.setMaxAge(0);
    response.addCookie(cookie);

    session.removeAttribute(token);
    // 以下这行很重要,否则不能正常退出。
    session.invalidate();

    return redirect:/;
}

}

注意登录方法,也是先设置 Cookie,再设置 Session,接着才跳转到登录前的页面。拿出方法相应地移除 Cookie 和 Session,然后跳转到首页。其中退出登录时,一定要调用 session.invalidate();,否则会不能正常退出登录。

测试集成

先启动 SSO,再启动 java sso client。通过 http://localhost:8080/login 登录,通过 http://localhost:8080/logout 登出。

优化

未登录时自动跳转到 SSO 系统

1729420889733 0a0e2157 fdff 4127 b1a0 ed21b51ddf34

希望在未登录时,自动跳转到身份认证中心。为了在未登录时自动重定向到 SSO 登录页面,我们可以自定义一个未认证处理器,这个处理器将在用户尝试访问受保护资源但未认证时触发。我们可以通过扩展 AuthenticationEntryPoint 来实现这一点。详见这个提交: feat: 未登录时自动跳转 · Jeff-Tian/ssoclient@a4b7ba4 · GitHub

增加依赖

再在 pom.xml 里增加一个新的依赖:

xml org.springframework.security spring-security-web

增加一个 Authentication Filter

java

import java.io.IOException;

import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse;

import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component;

@Component public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
        AuthenticationException authException) throws IOException, ServletException {

    // 重定向到 SSO 登录页面,并带上当前 URL 作为 next 参数
    String redirectUrl = http://127.0.0.1:5000/login?next= + request.getRequestURL();
    response.sendRedirect(redirectUrl);
}

}

修改安全配置

在 Spring Security 配置类中,配置自定义的 AuthenticationEntryPoint:

java

import com.warrenluo.ssoclient.demos.web.CustomAuthenticationEntryPoint; import com.warrenluo.ssoclient.demos.web.filter.JwtAuthenticationFilter;

@EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private CustomAuthenticationEntryPoint customAuthenticationEntryPoint;

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            .antMatchers(/).permitAll()
            .antMatchers(/login).permitAll()
            .anyRequest().authenticated()
            .and()
            .exceptionHandling()
            // 使用自定义的 AuthenticationEntryPoint
            .authenticationEntryPoint(customAuthenticationEntryPoint) 
            .and()
            .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
// ...

}

这样就达到了效果!

统一处理登录回调

现在可以自动跳转了,但还有一个问题。我们前面的实现,只在 /login 里处理登录成功后设置 Cookie 和 Session 的逻辑。现在一旦跳转回来不是这个路由,系统仍然会再次跳转到 SSO 系统。

为了确保在 SSO 登录成功后能够正确设置 JWT 并重定向到用户请求的原始页面,我们需要确保 SSO 回调到一个可以处理 JWT 并设置 Cookie 和 Session 的页面。我们可以将所有回调都统一处理到一个专门的回调处理器上。 1729420996300 b19c3747 761e 4446 a87f d61d68296f82

修改 SSO 系统中的登录回调

确保 SSO 系统在登录成功后始终回调到一个统一的处理页面,例如 /callback,并在回调 URL 中包含 JWT。

因为修改 Python SSO 系统中的登录代码,由于发起登录的客户端不只一个,所以在 SSO redirect 时不能写死 localhost:8080,要从 next_url 分析出 host。

为了处理来自多个客户端的登录请求,并确保在 SSO 登录成功后正确回调到发起请求的客户端,你可以在 SSO 系统中动态生成回调 URL,并从 next_url 中提取主机信息。从 next_url 中提取主机信息,并动态生成回调 URL。

修改 login 路由:

python

@app.route(/login, methods=[POST, GET]) def login(): if request.method == POST: # Get username and password from the form username = request.form[username] password = request.form[password] # Check if the username and password match if username in users and users[username] == password: # Create a JWT token with user information and expiration time token = jwt.encode({ user: username, exp: datetime.datetime.utcnow() + datetime.timedelta(hours=1) }, JWT_SECRET, algorithm=HS256) # Store the token in the session session[token] = token

        next_url = request.args.get(next) or url_for(home)
        
        # 从 next_url 中提取主机信息
        parsed_url = urlparse(next_url)
        host = f{parsed_url.scheme}://{parsed_url.netloc}

        # 动态生成回调 URL
        callback_url = f{host}/callback
        query_params = urlencode({token: token, next: next_url}) 

        # 回调到统一的处理页面
        return redirect(f{callback_url}?{query_params})

# Render the login page
return render_template(login.html)

在 Java 客户端应用中创建一个统一的回调处理器

这就是把原先 login 中的逻辑移动到了这里,这样不仅工作得更好,从代码质量上看,也更加职责单一了。

java import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession;

import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam;

@Controller public class CallbackController { @GetMapping(callback) public String callback(@RequestParam String token, @RequestParam String next, HttpServletResponse response, HttpSession session) { // 设置 JWT 到 Cookie 和 Session Cookie cookie = new Cookie(token, token); cookie.setHttpOnly(true); cookie.setPath(/); cookie.setMaxAge(3600); // 1 hour response.addCookie(cookie);

    session.setAttribute(token, token);

    // 重定向到用户请求的原始页面
    return redirect: + next;
}

}

1729421061701 d813da51 fb6f 426f bc08 1cf7ba574156 放通 callback 的访问

由于 callback 是用来处理用户登录的,也就是在未登录情况下,将页面访问设置成登录状态。所以必须放通允许未登录的访问,否则系统就不能正常进行登录态了,从而卡死在未登录情况下,不停地往认证中心跳转。

java

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            .antMatchers(/login, /callback).permitAll()
            .anyRequest().authenticated()
            .and()
            .exceptionHandling()
            // 使用自定义的 AuthenticationEntryPoint
            .authenticationEntryPoint(customAuthenticationEntryPoint) 
            .and()
            .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}

删除 /login 中原有的登录处理逻辑

这一步是可选的,即使不删除,系统也能正常工作了。由于在未登录时,现在系统已经能够自动跳转到论证中心,因此直接删除所有的 /login 代码和这个接口都是可以的,不再详述。