跳到主要内容

谈谈认证

· 阅读需 17 分钟
林林
在西安读大二的小菜鸡

HTTP是一个无状态的协议,但Web服务又需要认出访客(比如登录等功能),这两者就产生了一个矛盾。为了解决这个矛盾,前人想出了一些认证的办法,来证明“我是我,你是你”。

HTTP Basic Auth

HTTP状态码中有一个401 unauthorized状态码,服务端用来告诉客户端“未验证”,并在WWW-Authenticate头告诉客户端应该进行何种验证。Basic Auth算是验证方式最简单的一种。

我们用Flask构建一个要求basic验证的服务。

from flask import Flask, render_template
from flask_basicauth import BasicAuth

app = Flask(__name__)

app.config['BASIC_AUTH_USERNAME'] = 'example'
app.config['BASIC_AUTH_PASSWORD'] = 'foo'

basic_auth = BasicAuth(app)

@app.route('/secret')
@basic_auth.required
def secret_view():
return render_template('secret.html')

if __name__ == "__main__":
app.run()

当我们访问/secret这个路由时,浏览器会跳出输入框让我们输入用户名和密码(严格来说是口令),当我们输入完examplefoo后,secret.html就渲染出来了。

basic-auth-1

可以看到刚才的鉴权页面服务器给浏览器发送了一个401状态码,和WWW-Authenticate: Basic realm=""头,这跟我们刚才的描述一样。它告诉浏览器要用Basic Auth进行身份验证。这里的realm有什么用呢?它描述受保护区域的字符串。realm允许服务器对它受保护的区域进行区分(如果允许支持这种划分方案),并通知用户需要哪个特定的用户名/密码。如果未指定 realm,客户端通常会显示格式化的主机名。1Emm,感觉不太会用到。但这个字段又是必须的(参见 RFC7235 第2.2节,但 MDN 又标明这个字段是可选的)。

basic-auth-challenge-header

我们可以看到浏览器在请求这个页面的时候在Authorization里面加了一个Basic ZXhhbXBsZTpmb28=Basic代表使用的鉴权方式是Basic Auth,后面那个字符串是一个base64编码。解码之后得到example:foo,也就是username:passwd

basic-auth-2

浏览器有时会重用之前的验证信息,这样就不用每次都验证了。比如在http://example.com/docs/index.html经过的身份验证,访问http://example.com/docs/http://example.com/docs/test.dochttp://example.com/docs/?page=1时浏览器会自动发送先前的Authorization Header,而http://example.com/other/https://example.com/docs/不会发送,需要重新验证。(参见 RFC 7617 2.2. Reusing Credentials)

Basic Auth最大的安全漏洞在于对密码进行明文传输(尽管经过了base64编码,但跟明文无异),所以Basic Auth通常需要配合HTTPS使用。这种验证方式还不防重放(Replay Attack)。现在很少见有服务采用这种验证方式。

Digest Auth

Digest Auth只用摘要值来验证身份,不用明文传输密码,还可以防重放。我们实现一个最简单的应用。

from flask import Flask, request, redirect, render_template
from flask_digest_auth import DigestAuth, make_password_hash

app = Flask(__name__)
app.config['SECRET_KEY'] = 'foo-secret-key'
auth: DigestAuth = DigestAuth("secret-path")

users = {}
users['admin'] = make_password_hash("secret-path", 'admin', '123456')

@auth.register_get_password
def get_password_hash(username):
return users.get(username)

@auth.register_get_user
def get_user(username):
if username in users:
return {"username": username}
return None

auth.init_app(app)

@app.get("/admin")
@auth.login_required
def admin():
return render_template("secret.html")

if __name__ == "__main__":
app.run()

初次访问/admin路由时服务器返回WWW-Authenticate: Digest realm="secret-path", nonce="MjQ5ODkxODU0Nw.aqgMag.Bbxymw21v8cFvF1T9REueRbsi_w", opaque="Mjk0NDc5Njc1Nw.aqgMag.PL6QtVO2qRimpCSEwdMxEuRSdng", qop="auth,auth-int"

用户输入对应的密码后,浏览器在Authorization Header放Authorization: Digest username="admin", realm="secret-path", nonce="MzU2MjU1MzAw.aqgL2A.ceyzaVy7zEo1z5Xde9BtRoEz1iI", uri="/admin", response="aee55ad787a9c950b831ddfe9df88cdf", opaque="MTQwNzgzODUxOA.aqgL2A.wP4E08IN0BzYKQqrgSoxG26Yihg", qop=auth, nc=0000000f, cnonce="cda1b7777dcd4e90",用户就能访问到受保护的路由了。(这两次请求的nonce不一样,每次请求都会生成一个随机的nonce,后面计算的时候需要用浏览器这一段的信息)

参数比刚才的Basic Auth多多了,我们看一下发生了什么。

WWW-Authenticate头部中各字段的作用:

名称作用
nonce服务端生成的随机唯一字符串
opaque服务端指定的字符串,客户端需要原封不动返回
stale标志,指示由于nonce值过时而拒绝了来自客户端的上一个请求
algorithm哈希算法,SHA256、MD5等
qop必填,值auth表示身份验证,值auth-int表示具有完整性保护的身份验证
charset字符集
userhash标志,指示服务端是否支持用户名哈希

Authorization头部各字段的作用:

名称作用
response按照规则计算出的十六进制数字串(hex)
username用户名
realm
uri请求的uri
qop指示客户端采用的qop
cnonce客户端提供的字符串
nc必填,nonce count,防重放
userhash指示用户名是否被哈希

response 的计算规则

response=KD(H(A1),  nonce:nc:cnonce:qop:H(A2))\text{response} = \text{KD}\Big( H(A_1),\; \text{nonce} : \text{nc} : \text{cnonce} : \text{qop} : H(A_2) \Big)

H():哈希函数,如 MD5、SHA-256

KD():密钥派生函数,本质是 H(secret, data)

对于普通算法来说,

A1=username:realm:passwdA_1 = \text{username} : \text{realm} : \text{passwd}

对于会话算法(-sess),

A1=H(username:realm:passwd):nonce:cnonceA_1 = H(\text{username} : \text{realm} : \text{passwd}) : \text{nonce} : \text{cnonce}

qop = auth 或未指定:

A2=Method:request-uriA_2 = \text{Method} : \text{request-uri}

qop = auth-int

A2=Method:request-uri:H(entity-body)A_2 = \text{Method} : \text{request-uri} : H(\text{entity-body})

用户名哈希

username=H(username:realm)\text{username} = H(\text{username} : \text{realm})

我们用这种方法计算一下刚才试验的结果。

名称
realmsecret-path
nonceMzU2MjU1MzAw.aqgL2A.ceyzaVy7zEo1z5Xde9BtRoEz1iI
cnoncecda1b7777dcd4e90
opaqueMjk0NDc5Njc1Nw.aqgMag.PL6QtVO2qRimpCSEwdMxEuRSdng
usernameadmin
password123456
uri/admin
qopauth
algorithm没有指定,默认MD5
methodGET
nc0000000f
A1=admin:secretpath:123456A2=GET:/adminA_1 = admin : secret-path : 123456 \\ A_2 = GET : /admin \\

A1哈希得到05a6b7c3154d30026f5009344ec192ab,A2哈希得到0b039339067eeaeff93a7d25514492d1

response = H(05a6b7c3154d30026f5009344ec192ab:MzU2MjU1MzAw.aqgL2A.ceyzaVy7zEo1z5Xde9BtRoEz1iI:0000000f:cda1b7777dcd4e90:auth:0b039339067eeaeff93a7d25514492d1),得到aee55ad787a9c950b831ddfe9df88cdf,跟刚才试验的结果相同。

对于服务器来说应该怎么验证response呢?客户端明文给服务端传了用户名,服务端里存储着用户名对应的密码,只要在服务端这里再进行一次哈希,比对一下两次哈希是否相同即可。

在Postman中对请求进行重放,发现服务器不认。注意直接在浏览器重放没用,因为服务器在浏览器验证成功后存了一个session cookie。

Cookie是以键值对形式存储在浏览器的一些短文本。但cookie可以被轻易查看和修改,如果将一些重要的东西以明文形式存在cookie中,不亚于直接将钥匙给小偷。CTF Web也只有教新手查看cookie的题目会这么出。

但如果服务器将一些信息存在服务器本地(session),然后提供了一个标识字符串给浏览器(cookie),服务器可以通过访客的cookie识别到对应的信息,信息对访客来说一般是不可读的(二般情况我们等会讲),避免了查看和篡改的风险。

session-cookie

下面看用flask构建一个简单的服务。

from flask import Flask, session, redirect, url_for, request

app = Flask(__name__)
app.secret_key = "change-me"

@app.route("/")
def index():
if "username" in session:
return f"""
<h1>欢迎回来,{session["username"]}!</h1>
"""
return "<h1>请登录</h1>"

@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "")
if username:
session["username"] = username
return redirect(url_for("index"))

return """
<form method="post">
<input type="text" name="username">
<input type="submit" value="登录">
</form>
"""

登录之后会发现多了一个名为session的cookie,值为eyJ1c2VybmFtZSI6ImFkbWluIiwidmlzaXRzIjowfQ.aqlKYw.uJksUqvQTxa_pmVIkBDyTe9AqQ8。此前有接触过类似东西的小伙伴可能会觉得这是jwt,实则并非。我们可以在Flask Session Cookie Decoder中读出session包含的内容。比如我们这个session的内容是:

{
"username": "admin",
"visits": 0
}

Flask session由三个部分组成,分别是数据、时间戳和签名组成,数据实际上是可读的,但不好篡改,因为有secret key。2服务器用相同的方式生成一个session比对后就能发现是否被篡改。这点跟后面的jwt有些相似,这样服务器就不用再维护一个数据库来存储一堆随机的cookie与它们的session的对应关系了。

flask-session-body

但如果这个secret key够弱(比如我们文中的这个),就可以用弱口令词典去爆破。

PS C:\> flask-unsign --unsign --cookie "eyJ1c2VybmFtZSI6ImFkbWluIiwidmlzaXRzIjowfQ.aqlKYw.uJksUqvQTxa_pmVIkBDyTe9AqQ8"
[*] Session decodes to: {'username': 'admin', 'visits': 0}
[*] No wordlist selected, falling back to default wordlist..
[*] Starting brute-forcer with 8 threads..
[+] Found secret key after 42240 attempts
'change-me'

得到secret key之后就可以随意伪造session了。除了用flask-unsign之外,还可以自己搭一个flask去签发假session,也可以用脚本签发。

from flask.sessions import SecureCookieSessionInterface
from itsdangerous import URLSafeTimedSerializer

class S(SecureCookieSessionInterface):
def get_signing_serializer(self, secret_key):
return URLSafeTimedSerializer(
secret_key,
salt=self.salt,
serializer=self.serializer,
signer_kwargs=dict(
key_derivation=self.key_derivation,
digest_method=self.digest_method,
),
)

serializer = S().get_signing_serializer('change-me')
fake_cookie = serializer.dumps({'username': 'admin', 'visits': 999})
print(fake_cookie)

Flask也有其他session相关的库,比如flask-session,它提供了一种server-side session,也就是session的内容存储在服务器上。对应的有client-side session,也就是我们刚才见识的flask默认的session。

我们可以用这个代码来看看server-side session。

from flask import Flask, session
from flask_session import Session

app = Flask(__name__)
# Check Configuration section for more details
SESSION_TYPE = 'filesystem'
app.config.from_object(__name__)
Session(app)

@app.route('/set/')
def set():
session['key'] = 'admin'
return 'ok'

@app.route('/get/')
def get():
return session.get('key', 'not set')

if __name__ == "__main__":
app.run()

浏览器先访问/set/,再访问/get,名为session的cookie值为FGN9IPiXxrCrjRLy5uPCMru-Y8zOxnHMfuipu9hSFtU。这个字符串就不太能读了。我们发现在我们的代码目录下多出了一个名为flask_session的文件夹,session的信息就存储在里面。感觉里面存储了序列化的东西,可以用下面这个脚本大致读出来{'_permanent': True, 'key': 'admin'},但在此之前还存了一些字符,究竟是什么意思,具体机制有待探究。

import pickle

with open('./f98473cdd5c467387ed7280ce6be805d', 'rb') as f:
data = f.read()

obj = pickle.loads(data[15:])
print(obj)

Session Cookie这种验证方式不防重放,如果被窃取到了攻击者就可以原封不动地用session伪装用户进行操作。要防重放的话就要用其他手段(比如多加一个nonce等等)。对于client-side session来说保护内容不被篡改其实就是保证有一个强的secret key,而server-side session则没有篡改的问题。

当然server-side session虽然不存在secret key导致的问题,但面临着会话劫持攻击的风险。尽管目前成熟应用的sessionid都很复杂,难以预测和暴力破解,但需要采取措施防范攻击者窃取sessionid。这一点在本文上下都有提到。

Bearer Token

这个验证本身非常简单,很多服务都在用,就是在Authorization头里面加服务器给的token就可以了。

Authorization: Bearer <token>

对于服务器来说一般需要维护一个token的数据表,跟server-side session差不多。本身不防重放,需要使用HTTPS等手段保护token。

这个鉴权机制本身非常简单,但如何保护token,防护攻击需要一些策略。

首先,最基础的就是HTTPS,没有它一方面数据会像前面提及的那样裸奔,用它来抵御中间人攻击等。

其次,token本身不能直接放在cookie里,因为如果用户被诱骗访问https://example.com/buy之类的接口,浏览器会自动发送cookie内容,完成鉴权,在不经意间完成敏感操作。这就是跨站请求伪造(CSRF)攻击。

那么,token应该存哪呢?token应该作为返回值,由前端存到localStorage中。这样浏览器在访问链接时并不会主动发送token,而是由前端将token放到接口请求的Authorization头部。

但即便这样,恶意的js脚本还是能访问到localStorage,面临xss攻击的问题,可以交由Content-Security-Policy和前后端的过滤机制来防范。

JWT

前面Bearer Token一般是不可读的,前端收到服务器发过来的token不知道这个字符串代表着什么意思,如果要获取用户信息的话还需要向类似auth/me之类的接口获取数据。有没有既能鉴权,又能包含用户信息的呢?

你可能想到前面的client-side session。除了这个,还可以用JWT(JSON Web Token)。

在介绍JWT之前,讲一个场景。拿server-side session举例子,服务器先将用户的id等信息存到session中,并将session-id存到cookie,后续用户发送携带session-id的请求,服务器再到session中查找信息。这个场景对单点服务没有问题,但如果后端是一个服务器集群呢?上游的cdn会将请求发送到其中一个节点,此时很有可能会出现session不在该节点的问题,用户的鉴权态就无效了。

要解决这个问题,session就不能存储在节点自己的内存中了,可以存储到统一的数据库中。但每次验证都要从数据库中取信息,多了来回的耗时,压力也给到数据库这里。而且数据库一旦挂了,所有需要鉴权的服务统统都挂了。

另一种方案是服务器索性不保存session了,所有数据都保存在客户端,每次请求都发回服务器。这就是JWT。

JWT由三个部分构成,头部(Header)、载荷(Payload)和签名(Signature),每个部分用.分隔。

下面用PyJWT库生成json web token。

import jwt

payload = {"username": "admin"}
secret = "secret"
encoded_jwt = jwt.encode(payload, secret, algorithm="HS256")
print(encoded_jwt)

运行得到eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.qQSekbR5BFKQPc3_7gUiDY6Q9y7RojKzvBTLJ9jGtec,可以在JWT 解析工具查看这个字符串每个部分的含义。

header部分描述jwt的元数据,alg为jwt签名用的算法(默认为HMAC SHA256),typ为JWT

{
"alg": "HS256",
"typ": "JWT"
}

body也是一个json对象,要使用Base64URL算法转成字符串,官方提供了一些字段供选用:

  • iss (issuer):签发人
  • exp (expiration time):过期时间
  • sub (subject):主题
  • aud (audience):受众
  • nbf (Not Before):生效时间
  • iat (Issued At):签发时间
  • jti (JWT ID):编号

可以用相同的库进行jwt的解析,当密钥不相同时会抛出jwt.exceptions.InvalidSignatureError异常。

secret = "fakesecret"
decoded_jwt = jwt.decode(encoded_jwt, secret, algorithms="HS256")
print(decoded_jwt)

签发jwt同样需要一个强密钥,不然会被暴力破解。这里尝试用字符集进行暴力破解,用时比较长,用弱密码字典会更快,比如说用rockyou字典77ms就出来了。

./jwt_tool.exe brute -token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.qQSekbR5BFKQPc3_7gUiDY6Q9y7RojKzvBTLJ9jGtec" -minLen 4 -maxLen 10 -charset "qwertyuiopasdfghjklzxcvbnm" -threads 0
尝试次数: 143933669 当前密钥: sescms
成功破解密钥!密钥为: secret
总尝试次数: 144096418, 耗时: 4m7.4560252s
./jwt_tool.exe crack -token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.qQSekbR5BFKQPc3_7gUiDY6Q9y7RojKzvBTLJ9jGtec" -dict ".\rockyou-65.txt"
成功破解密钥!密钥为: secret
Measure-Command { ./jwt_tool.exe crack -token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.qQSekbR5BFKQPc3_7gUiDY6Q9y7RojKzvBTLJ9jGtec" -dict ".\rockyou-65.txt" }

Milliseconds : 77
Ticks : 770263
TotalMilliseconds : 77.0263

除此之外,jwt payload并没有加密,所以不能把一些敏感信息放到jwt中。

jwt在应用的过程中有很多漏洞,比如alg=none和加密算法混淆等,对应的也有很多的利用方法。可以阅读Burpsuite靶场-JWT漏洞原理总结及复现这篇文章了解。后续再对jwt有关的漏洞进行整理,这里暂不赘述。

TOTP

认证因素分为这样几类:

  • 知识因素(Knowledge factors):用户所知内容,例:密码、PIN码、共享秘钥、与自身相关的信息
  • 持有因素(Possession factors):用户拥有的东西,比如身份证、护照、安全令牌或智能手机
  • 属性因素(Inherence factors):用户具备的特征,通常是生物识别技术,物理特征映射的个人属性,如指纹、面部、声音,还包括行为识别,例如说话方式、走路方式、打字方式识别。

TOTP作为双因素验证(2Fa)中比较常见的一种方法,可以通过验证用户的持有因素来提高安全性。

因为用户的持有因素可能丢失、失窃,所以TOTP一般不作为服务唯一的验证因素。

在开启TOTP时,服务器会先生成一个密钥。

import pyotp

secret_key = pyotp.random_base32()
print(secret_key)

随后会向前端返回一个链接,label是用户的名字,secret是上面经过Base32编码后的密钥,issuer代表应用名。

totp = pyotp.TOTP(secret_key)
uri = totp.provisioning_uri(name="admin", issuer_name="totp test")
print(uri) # otpauth://totp/{label}?secret={secret}&issuer={issuer}

前端将链接转换为二维码供用户扫描,用户将这个链接的信息添加到设备的TOTP生成器上。用户再次登录时,后端可以计算用户的TOTP,并将两者进行比对。

user_code = input()
if totp.verify(user_code):
print("Valid")
else:
print("Invalid")

OAuth / OpenID Connect(OIDC)

to be written

WebAuthn

to be written

Reference

Basic Auth:

Digest Auth:

Bearer Token:

Session Cookie:

JWT:

TOTP:

OAuth:

Footnotes

  1. 参考WWW-Authenticate - HTTP | MDN

  2. 国外有位大佬对flask session进行分析,并开发了flask-unsign这个在文中使用到的爆破工具,描述session内容的图片就来自他的博客,但博客现在没有办法访问了,可以去互联网档案馆阅读。

高考之后(贰)

· 阅读需 2 分钟
林林
在西安读大二的小菜鸡

西电礼仪广场

西安的天逐渐热了起来,太阳大,云少,空气都是热的。相比于福建的桑拿天,貌似这里的天气比较好。前段时间没了解过我们宿舍门口的小卖部有没有冰淇淋,于是常骑自行车去老综合楼的蜜雪冰城买圣代吃。前些天知道小卖部有卖方糕了,便开始每天吃一个方糕。

高三走读那年,四五月的下午也很热,去学校的路上几乎都会从同一家小卖部买个方糕吃。至于为何选方糕,甜筒融化之后会粘到手上,没卖圣代,三色杯那种还要一只手拿着一只手挖,就方糕省事不麻烦。高考考完之后,离开了学校,几乎没怎么在大热天出过门,就再没吃过。

不知对不对,人死前脑子会像走马灯一样过一遍所有瞬间。是当时看到成绩、看到录取结果时的欣喜,还是平日里的滋味,亦或是暴雨中的困顿与焦虑,更值得被回忆?高二的时候我给我的班主任画了一张图,三个圆圈交叉在一起,分别是作业完成质量高、睡眠充足和独立完成,两个圆圈交叉的地方分别写上“抄来的”、“乱写的”和“熬夜”,又在三个圆圈交叉的地方写了一个“滚”字。当时对我来说算是至暗时刻,如今想来当时的压力与挣扎都是过往云烟。有些事情原本以为放不下,过段时间就放下了;而有些事情当初以为能过去,至今过不去。

高考往后的三个月,刚毕业的高三学生将沉浸在对大学美好生活的向往当中,直到进入大学才逐渐破灭。

高考,是集体的狂欢,还是青春的眼泪?

「进行中」XDSec SSO开发小记

· 阅读需 4 分钟
林林
在西安读大二的小菜鸡

因为信安协会原本的SSO改用户信息比较麻烦,协会的周报系统和论坛两个系统打通的任务从很早以前咕到现在,重新开发一版SSO的任务就这样提上日程。这也算是我第二个项目?

谨以本篇文章记录我在整个项目设计、开发、测试和部署过程中的一些经验。能将协会各位大佬传授的经验记录沉淀下来,算是我的一份荣幸。

因为这个项目的周期可能比较长,就先发,随时更新。

如何防止邮箱枚举

我们系统有一些端点需要对外暴露,既要让用户使用,也要防止邮箱枚举造成信息泄露。大概有这些接口:

  • 通过用户名登录
  • 通过邮箱重置密码

我们在设计的过程中,明确不允许用户自主注册账户,只能通过管理员在后台添加或导入,所以「注册用户」这个接口不列在这里。

「通过用户名登录」这个接口可以很容易想到「用户名或密码错误」的提示信息来规避明确告知用户名不存在,但「通过邮箱重置密码」这个如果提示邮箱错了就直接表明邮箱不存在,告知邮箱存在容易误导用户。直到我问了Deepseek,它说了一句「如果该邮箱已注册,验证码已发送至邮箱」。这句话点醒了我,怎么会有这么美妙的设计。

后来,为了对开启TOTP的用户进行验证,我们又设计了一个接口,输入邮箱,返回该用户是否开启TOTP。这个也容易泄露邮箱,我想到了一招:

  • 当邮箱存在时,返回真实的信息;
  • 当邮箱不存在时,不报错,从true和false里随机返回一个。

这样攻击者就区分不出哪些邮箱有账户,哪些没有了。当然这样设计TOTP的验证流程在大佬眼中欠佳,他给出了更好的解决方案。

如何利用jwt并设计TOTP验证流程

我之前在设计TOTP验证流程的时候注意过,腾讯云和Cloudflare的登录验证都是单独腾出一个页面来弄。尝试看看他们怎么处理这个流程的,可惜我网页逆向水平太差,看不懂,只能从网络Tab里捕风捉影。大概猜了一个这个。

但是感觉这样还要生成一个token给前端太麻烦了,于是改了一下——让前端先看看需不需要TOTP,再把TOTP、用户名、密码一块返回给后端,也就是上一节的那个接口对应的方案。

emmmm
很有想法(
不过让我设计的话我可能先正常登录之后重新开个页面说totp的事

Reverier对这份方案的评价

看来他倾向于另开一个页面的方案,为什么呢?

因为用户登录接口本身是有密码鉴权这个慢哈希接口来做限流的,而且登录接口本身风控比较严。你这么设计相当于开放了一个非登录鉴权的api出去,并且这个api还会查库,攻击者如果只是想把你网站弄垮的话,可以dos这个api给你数据库压力打满,而且因为没有登录态,你还追不到来源。

然后他分享了一个非常美的设计。

jwt不应该存在cookie里,这样前端拿不到,应该作为接口返回值给前端存本地存储里

可以把totp登录态直接放在jwt字段里。这样前端拿到jwt一解,发现totp未登录,就继续要求totp验证。验证之后,服务器重新签发一个totp已登录的jwt。两套系统就合一起了,不用维护两拨token。服务端解码jwt发现totp开启并且未登录的时候直接视为整个未登录就好了。

这个设计实在优雅,把两套token合在一起,还让totp的验证流程更简洁了。

试驳友谊之传递链

· 阅读需 4 分钟
林林
在西安读大二的小菜鸡

每当我遇到生活上的困惑时,我都会打开《人生哲思录》看看周先生写过的文字。之前几次对朋友感到困惑,于是几次翻开这本书。如今,朋友这个词再一次让我感到困惑,我也就再翻起这本书。

我问:“你认为网络上的友谊应该有传递链吗?比如说如果我知道一个人和我讨厌的人在一起,我对这个人的信任值就会降低。”他答:“我也这样。”

我其实对这种传递链并不感冒。“朋友的朋友是我的朋友,敌人的朋友是我的敌人。”这句话实际上并没有什么道理。将既是朋友也是敌人的冲突按下不表,这种方法把人几乎分成了两类,一类是某个人的朋友,或者朋友的朋友,另一类是某个人的敌人,或者敌人的朋友,又或者朋友的敌人。一个人要是遵循这种原则交友,找的朋友基本就在一个“朋友圈”里面,一个人的朋友的朋友是一个人的朋友,也是圈子里其他人的朋友。在这样的圈子当中,发言、表达情绪都要格外慎重。如果圈子里的人都遵循友谊的传递链原则,那么若是因为口角之类的事情跟一个曾经的朋友关系不好,这种关系不好的状态会顺着友情的网络传递下去,直到被整个圈子驱逐为止。

再论先前按下不表的冲突,一个人既可以是某个人的敌人,也可以是另一个人的朋友,若他既是我某个朋友的朋友,又是我某个敌人的朋友,那么根据传递链,我能否与他做朋友?一个人与另一个人是否是朋友这个二元关系非常复杂,而且根据我们实际的生活经历知道这个二元关系不满足传递性,换句话说“是否是朋友”不是一个传递闭包。我们可以用代码举一个例子,尽管单单用代码证明不了这件事。

a = {"like": [1, 2, 3], "is": 1}
b = {"like": [1, 3, 4], "is": 3}
c = {"like": [3, 4, 5], "is": 4}

print(a["is"] in b["like"] and b["is"] in a["like"]) # True a和b是朋友
print(b["is"] in c["like"] and c["is"] in b["like"]) # True b和c是朋友
print(a["is"] in c["like"] and c["is"] in a["like"]) # False a和c不是朋友

那么为什么这种传递链会存在呢?我看,是因为“朋友圈”。如果我被朋友发现我跟他们讨厌的人来往,我在他们眼里就成了“不忠”。我也可以用同样的方法识别出那些对我忠诚的朋友。照此,朋友圈变成等级森严的组织,一个人在圈子里就像拴上绳子的狗,限定与圈子里的狗交往。传递链也是一种省事的方式,但它真的用起来其实并不省事。传递链实质上将择友的权利让渡给我的朋友们,他们的眼光也就是我的眼光。很荒谬,这就是问题所在。

朋友圈应该是流动的,仅仅只是我的朋友组成的一个概念。我有独立判断的权利,是否与一个人做朋友应该取决于我们之间有没有默契和互相欣赏,而不是他属于哪个圈子里。一个人不应该被困在圈子里。若是从一个圈子里跳出来,对一个圈子怯魅,那他还可能会跳进别的圈子,然后对别的圈子怯魅。他其实只是对特定的某个圈子怯魅,没有真正跳出圈子这个体系,往后要么找到让他安心的圈子,要么在圈子之间跳来跳去。他会找到让他安心的圈子吗?会有圈子让人安心吗?

圈子不好玩,圈子里的人有的需要崇拜,有的需要归属和认同,大家都只是得到自己想要的东西。进圈子简单,给自己贴上标签即可。人喜欢给自己贴标签,说是让自己更了解自己,也让别人更了解自己。贴标签不是什么没有代价的事情,贴完之后便从立体的人坍缩成标签化的人。

“他在朋友圈挂我了,他那群朋友会怎么想?会怎么看我?”传递链找不来真朋友,圈子也不解放人。

小东西:用FreshRSS实现带AI摘要的订阅推送

· 阅读需 4 分钟
林林
在西安读大二的小菜鸡

前几天在协会问了一下有没有什么开发任务,然后找了一个开发订阅推送的活。

聊天记录(昵称和头像已用白色遮罩)

工具需要实现的功能是:定时爬取一些安全newsletter和博客的订阅源,并将爬取到的文章推送到协会的QQ群,要有AI的摘要。

模块任务
FreshRSS爬取、存储内容
Napcat部署QQ机器人
Python脚本对接AI、FreshRSS和Napcat

流程图

FreshRSS我用Docker方式部署,在应用中开放接口登录并设置一下API密钥,原本打算自己看着接口文档搞的,结果一搜发现Python有对应的接口库freshrss-api,直接就拿来用了。

from freshrss_api import FreshRSSAPI
client = FreshRSSAPI(
host="xxx",
username="xxx",
password="xxx",
verbose=False
)

unread_items = client.get_unreads()

passages = []
pass_text = ""

for i in unread_items:
passages.append([i.author, i.title, i.url, i.html, str(trafilatura.extract(trafilatura.fetch_url(i.url), output_format='markdown', include_tables=True))])
client.set_mark(as_="read", id=i.id)

思路大概是这样,每次推送的时候都从未读的文章里面取,取出来就把文章设置为已读。

在获取到还未推送的文章(未读文章)之后,接着需要爬取文章的内容,供后面AI推荐和生成摘要使用。此处使用的是trafilatura库(星火杯参赛小记 用过的),可以将网页内容清洗成Markdown。因为遇到反爬时可能会返回None,导致后面字符串拼接时可能报错,所以对清洗出的结果用str( )进行强制转换。

if len(passages) > 5:
pass_text += "本次抓取文章数大于5篇,根据AI推荐,推送五篇较有价值的文章。\n"
push_index = getAIrecom(passage_list=passages)
for i in range(5):
pass_text += f"Title: {passages[int(push_index[i])][1]} \nURL: {passages[int(push_index[i])][2]} \nBrief: {aibrief(passages[int(push_index[i])][4], passages[int(push_index[i])][3])}\n\n"
elif len(passages) == 0:
exit(0)
else:
for i in passages:
pass_text += f"Title: {i[1]} \nURL: {i[2]} \nBrief: {aibrief(i[4], i[3])}\n\n"

接下来对未读文章的数量进行判断,小于等于5篇就都推送,大于5篇就让AI判断哪些东西有价值再推送。getAIrecom(passages)的作用是将所有文章的内容发给AI让其判断,返回一个文章序号的列表。aibrief(content, rsscontent)的作用是根据爬取到的文章内容和rss里面的摘要生成一段AI摘要。

def aibrief(content, rsscontent):
client = OpenAI(
api_key="sk-xxx",
base_url="https://api.deepseek.com")

response = client.chat.completions.create(
model="deepseek-v4-flash",
messages=[
{"role": "system", "content": "你是一个专业的秘书,负责总结文章的内容,供网络安全协会的推送使用。请你根据给定的文章内容,生成一段不长于75字的摘要,概括文章的主要内容、思路、技术方法,供网络安全协会的成员快速判断是否对文章感兴趣。"},
{"role": "user", "content": "trafilatura得到的文章内容,可能会因为反爬而为None或无意义字符" + str(content) + "\n 以下是订阅软件从 rss 中获取到的内容" + rsscontent}
],
stream=False,
reasoning_effort="high",
extra_body={"thinking": {"type": "enabled"}}
)

return response.choices[0].message.content

我原本不太熟悉类型怎么限定的,但之前好像看过写这种限定的代码,在这里加-> list的原因是前面代码用这个函数返回值的地方静态判断会报错。

Napcat有HTTP接口可以发送群消息,弄好要推送的文章和摘要之后调用接口发群消息即可。

token = "xxx"
url = "http://xxx/send_group_msg"

headers = {'User-Agent': 'Mozilla/5.0', 'Authorization': f"Bearer {token}"}

data = {"group_id": xxx, "message": f"最近几小时爬取到了{len(passages)}篇文章,信息如下:\n{pass_text}\n各位成员可以在 xxx 查看所有文章。"}

x = requests.post(url, headers=headers, data=data)

print(x.text)

小脚本的完整代码:XDSec Push Bot