Skip to content

单点登录原理

单点登录(Single Sign On),简称 SSO。在公司项目有多个时,用户只要登录一次,就可以在任意子系统中完成统一登录、统一退出。

例如在百度网( https://www.baidu.com/ )登录之后,以下的站带都默认进行了登录

SSO 系统原理

在这里我就一两个网站为例子,实现最小的 SSO 登录。其中网站A、网站B都有自己的登录、退出逻辑,但是只要涉及到登录就会被跳转到 SSO 服务,我们也可称之为注册中心或者集中认证服务(Central Authentication Service 简称 CAS)。当访问 网站A 时跳转到注册中心完成登录,在这里完成登录之后再跳回之前的站点,就可以完成当前网站的登录了。然后访问 网站B 时跳转到 注册中心,这时会发现注册中心已经完成了登录,然后直接跳会 网站B 就可以了。

假设 网站A 地址为 http:site-a ,网站B 的地址为 http:site-b ,而注册中心的地址为 http:site-sso。

第一次登录

第一次登录,假设是从 网站A( http:site-a )进行登录时,然后跳转到注册中心(http:localhost),登录成功把 cookie 在注册中心设置一份,之后再携带登录成功的消息跳回 网站A 完成登录。

第N次登录

第二次登录,从网站B( http:site-b )进行登录,然后跳转到注册中心( http:localhost ) 进行登录,因为之前已经登录过了,请求服务器是就能被识别之前已经登录过了,请求服务器,然后服务器携带登录信息直接重定向到 网站B。

登出逻辑

任意一个站点进行登出,假设是从站点A 进行登出,首先请求服务器进行登出,然后清空客户端cookie、服务器 cookie 。然后被服务器重定向到注册中心登出,注册中心清空 cookie ,然后重定向到 站点B 进行登出,站点B 登出之后再重定向会最开始的登出页面。

上面是主要实现的逻辑,但是因为技术有很多种,具体的方案要根据实际的方式来。

实现方案

掌握了单点登录系统的原理之后,想要实现的话也比较麻烦。首先是技术选型,可以选择 cookie、session、session-ID、jwt token,同时也可以把数据存在客户端、服务器两种方案。在这里我只是为了演示一下效果,为了减小难度,所以选择最简单的 cookie 进行操作。

提前准备

先修改本地 host ,进入

C:\Windows\System32\drivers\etc

然后编辑 hosts 文件,追加以下内容

127.0.0.1 site-a
127.0.0.1 site-b
127.0.0.1 site-sso

修改 nginx

server {
       listen 80;
       listen [::]:80;

       server_name site-sso;

       location / {
               proxy_pass http://127.0.0.1:5000;
       }
}
server {
       listen 80;
       listen [::]:80;

       server_name site-a;

       location / {
               proxy_pass http://127.0.0.1:5001;
       }
}
server {
       listen 80;
       listen [::]:80;

       server_name site-b;

       location / {
               proxy_pass http://127.0.0.1:5002;
       }
}

HTML 模板

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <link rel="stylesheet" href="/static/css/layui.css">
</head>
<body>
<div class="layui-layout layui-layout-admin">
    <div class="layui-header">
        <div class="layui-logo layui-hide-xs layui-bg-black">正心全栈编程</div>
        <ul class="layui-nav layui-layout-left">
        </ul>

        <ul class="layui-nav layui-layout-right">
            <li class="layui-nav-item layui-hide layui-show-sm-inline-block">
                <a href="/login">
                    <img src="//unpkg.com/outeres@0.0.10/img/layui/icon-v2.png" class="layui-nav-img">
                    登录 / 正心
                </a>
                <dl class="layui-nav-child">
                    <dd><a href="/admin">后台管理</a></dd>
                    <dd><a href="/logout">退出</a></dd>
                </dl>
            </li>
        </ul>
    </div>
    <div class="layui-body">
        <h1>站点A-首页</h1>
    </div>
</div>

<!--导航模块需要引入 js -->
<script src="/static/layui.js"></script>
</body>
</html>

admin.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <link rel="stylesheet" href="/static/css/layui.css">
</head>
<body>
<div class="layui-layout layui-layout-admin">
    <div class="layui-header">
        <div class="layui-logo layui-hide-xs layui-bg-black">正心全栈编程</div>
        <ul class="layui-nav layui-layout-left">
        </ul>

        <ul class="layui-nav layui-layout-right">
            <li class="layui-nav-item layui-hide layui-show-sm-inline-block">
                <a href="/login">
                    <img src="//unpkg.com/outeres@0.0.10/img/layui/icon-v2.png" class="layui-nav-img">
                    登录 / 正心
                </a>
                <dl class="layui-nav-child">
                    <dd><a href="/admin">后台管理</a></dd>
                    <dd><a href="/logout">退出</a></dd>
                </dl>
            </li>
        </ul>
    </div>
    <div class="layui-body">
        <h1>站点A-后台管理</h1>
    </div>
</div>

<!--导航模块需要引入 js -->
<script src="/static/layui.js"></script>
</body>
</html>

后端接口

site-a

from functools import wraps

from flask import Flask, render_template, redirect, request, make_response

app = Flask(__name__)


@app.get('/')
def hello_world():
    return render_template('index.html')


@app.get('/login')
def login():
    _next = request.args.get('next')
    _next = _next if _next else '/'
    # call_url: sso 登录成功之后的回调地址
    # call_logout: sso 登出的回调地址
    # next: 当前页面权限拦截的地址
    query = f'?call_url=http://site-a/login-sso&next={_next}&call_logout=http://site-a/logout-sso'
    return redirect('http://site-sso/login' + query)


@app.get('/login-sso')
def login_sso():
    _next = request.args.get('next')
    _next = _next if _next else '/'

    is_login = request.args.get('is_login')
    mobile = request.args.get('mobile')
    response = make_response(redirect(f'{_next}'))
    response.set_cookie('is_login', is_login)
    response.set_cookie('mobile', mobile)
    return response


def login_required(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        is_login = request.cookies.get('is_login')
        if not is_login:
            return redirect(f'/login?next={request.path}', )
        return func(*args, **kwargs)

    return wrapper


@app.get('/admin')
@login_required
def admin():
    return render_template('admin.html')


@app.get('/logout')
def logout():
    _next = request.args.get('next')
    _next = _next if _next else '/'
    query = f'?call_url=http://{request.host}/logout-sso&next={_next}'
    response = make_response(redirect('http://site-sso/logout?' + query))
    response.delete_cookie('is_login')
    response.delete_cookie('mobile')
    return response


if __name__ == '__main__':
    app.run(debug=True, port=5001)

site-sso

import json

from flask import Flask, render_template, request, make_response, redirect, url_for
from werkzeug.urls import url_encode, url_decode

app = Flask(__name__)


@app.route('/')
def hello_world():
    return render_template('index.html')


@app.get('/login')
def login():
    is_login = request.cookies.get('is_login')
    if is_login:
        call_url = request.args.get('call_url')
        call_logout = request.args.get('call_logout')

        mobile = request.cookies.get('mobile')
        login_site = request.cookies.get('login_site')

        args = url_encode(request.args)
        response = make_response(redirect(f'{call_url}?{args}&is_login=true&mobile={mobile}'))

        login_site = json.loads(login_site)

        host = call_url.split('?')[0].strip('http://') if '?' in call_url else call_url.strip('http://')
        login_site[host] = call_logout
        response.set_cookie('login_site', json.dumps(login_site))
        return response

    args = url_encode(request.args)
    action = f'/login?' + args
    return render_template('login.html', action=action)


@app.post('/login')
def login_post():
    mobile = request.form.get('mobile')
    password = request.form.get('password')

    args_back = request.args.copy()
    # 登录成功
    call_url = request.args.get('call_url')
    call_logout = request.args.get('call_logout')

    args_str = url_encode(args_back) + f'&is_login=true&mobile={mobile}'
    response = make_response(redirect(f'{call_url}?' + args_str))
    response.set_cookie('is_login', 'true')
    response.set_cookie('mobile', mobile)

    host = call_url.split('?')[0].strip('http://') if '?' in call_url else call_url.strip('http://')

    login_site = {
        host: call_logout
    }
    response.set_cookie('login_site', json.dumps(login_site))
    return response


@app.get('/logout')
def logout():
    call_url = request.args.get('call_url')

    login_site = request.cookies.get('login_site')
    if call_url:
        # 登出其他站点
        login_site = json.loads(login_site)
        host = call_url.split('?')[0].strip('http://') if '?' in call_url else call_url.strip('http://')
        # 删除当前站点登录记录
        del login_site[host]
        # 如果还有其他站点没有删除
        if login_site:
            key, value = login_site.popitem()
            # 重定向删除其他站点 cookie
            response = make_response(redirect(f'{value}?call_url=http://site-sso/logout'))
            # 更新当前站点登录记录
            response.set_cookie('login_site', json.dumps(login_site))
            response.set_cookie('logout_end', url_encode(request.args))
            return response
    else:
        login_site = json.loads(login_site)
        # 如果还有其他站点没有删除
        if login_site:
            key, value = login_site.popitem()
            # 重定向删除其他站点 cookie
            response = make_response(redirect(f'{value}?call_server_url=http://site-sso/logout'))
            # 更新当前站点登录记录
            response.set_cookie('login_site', json.dumps(login_site))
            return response
        else:

            logout_end = request.cookies.get('logout_end', None)
            if logout_end:
                args = url_decode(logout_end)
                call_url = args.get('call_url')
                response = make_response(redirect(f'{call_url}?{call_url}'))
                # 更新当前站点登录记录
                response.set_cookie('login_site', json.dumps(login_site))
                return response
            else:
                return redirect('/')


if __name__ == '__main__':
    app.run(debug=True, port=5000)

附录

完整项目代码加微信:zhengxinonly 免费获取

参考:

  1. https://segmentfault.com/a/1190000011371576
  2. https://ken.io/note/sso-design-implement#H3-1
  3. https://blog.csdn.net/jiezaizone/article/details/105815530