单点登录原理
单点登录(Single Sign On),简称 SSO。在公司项目有多个时,用户只要登录一次,就可以在任意子系统中完成统一登录、统一退出。
例如在百度网( https://www.baidu.com/ )登录之后,以下的站带都默认进行了登录
- 百度网盘: https://pan.baidu.com/
- 百度贴吧: https://tieba.baidu.com/
- 百度文库: https://wenku.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 免费获取
参考: