Add read-only sharing, API tokens and OpenAPI spec
- Share a note as a live read-only page at /s/{token} with optional password and expiry
- Share page renders with the shared parser (TOC, footnotes, math, Mermaid, code highlighting)
and only serves images from the note's own .assets directory
- Shares follow renames made through the API or externally
- Manage shares and API tokens from the command palette and file tree
- Accept read/write API tokens as Bearer credentials; management endpoints require a session
- Serve a hand-written openapi.json, checked against the route table in tests
This commit is contained in:
1 parent
7b076346a4
commit
5ac01894e6
26 files changed
+3147
-52
No files matched your search
+98
-1
@@ -1,7 +1,8 @@
|
||||
// Package auth 实现单用户登录:Argon2id 密码哈希、会话令牌与登录限速。
|
||||
// Package auth 实现单用户登录(Argon2id 密码哈希、会话令牌、登录限速),以及分享密码与 API 令牌的校验。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
@@ -20,6 +21,8 @@ import (
|
||||
|
||||
const (
|
||||
passwordKey = "auth.password"
|
||||
shareSecretKey = "share.secret"
|
||||
apiTokenPrefix = "rm_"
|
||||
SessionTTL = 30 * 24 * time.Hour
|
||||
MinPasswordLen = 8
|
||||
|
||||
@@ -39,6 +42,7 @@ var (
|
||||
ErrWeakPassword = fmt.Errorf("密码至少 %d 位", MinPasswordLen)
|
||||
ErrBadCredentials = errors.New("密码错误")
|
||||
ErrRateLimited = errors.New("尝试次数过多,请稍后再试")
|
||||
ErrBadScope = errors.New("权限范围只能是 read 或 write")
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
@@ -135,6 +139,92 @@ func (m *Manager) Validate(token string) (bool, error) {
|
||||
|
||||
func (m *Manager) Logout(token string) error { return m.store.DeleteSession(hashToken(token)) }
|
||||
|
||||
// ---- 分享 ----
|
||||
|
||||
// HashSharePassword 为分享密码生成哈希;空密码表示不设密码,返回空字符串
|
||||
func HashSharePassword(password string) string {
|
||||
if password == "" {
|
||||
return ""
|
||||
}
|
||||
return hashPassword(password)
|
||||
}
|
||||
|
||||
// CheckSharePassword 校验分享密码,与登录共用按来源的失败限速
|
||||
func (m *Manager) CheckSharePassword(ip, hash, password string) error {
|
||||
if !m.allow(ip) {
|
||||
return ErrRateLimited
|
||||
}
|
||||
ok, err := verifyPassword(hash, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
m.recordFailure(ip)
|
||||
return ErrBadCredentials
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShareCookie 生成输入分享密码后发给访客的凭据:绑定分享令牌与密码哈希,撤销分享即失效
|
||||
func (m *Manager) ShareCookie(token, passwordHash string) (string, error) {
|
||||
secret, err := m.shareSecret()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
mac.Write([]byte(token + "\x00" + passwordHash))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func (m *Manager) ValidShareCookie(cookie, token, passwordHash string) (bool, error) {
|
||||
want, err := m.ShareCookie(token, passwordHash)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return hmac.Equal([]byte(cookie), []byte(want)), nil
|
||||
}
|
||||
|
||||
// shareSecret 读取签名密钥,首次使用时生成并保存
|
||||
func (m *Manager) shareSecret() ([]byte, error) {
|
||||
v, ok, err := m.store.Setting(shareSecretKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
if _, err := m.store.SetSettingIfAbsent(shareSecretKey, randomToken()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 并发首次生成时以先写入的为准
|
||||
if v, _, err = m.store.Setting(shareSecretKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return base64.RawURLEncoding.DecodeString(v)
|
||||
}
|
||||
|
||||
// ---- API 令牌 ----
|
||||
|
||||
// CreateAPIToken 创建令牌,返回只显示这一次的明文
|
||||
func (m *Manager) CreateAPIToken(name, scope string) (string, store.APIToken, error) {
|
||||
if scope != "read" && scope != "write" {
|
||||
return "", store.APIToken{}, ErrBadScope
|
||||
}
|
||||
plain := apiTokenPrefix + randomToken()
|
||||
t := store.APIToken{ID: randomCode(), Name: name, Hash: hashToken(plain), Scope: scope, CreatedAt: time.Now().UnixMilli()}
|
||||
if err := m.store.CreateAPIToken(t); err != nil {
|
||||
return "", store.APIToken{}, err
|
||||
}
|
||||
return plain, t, nil
|
||||
}
|
||||
|
||||
// ValidateAPIToken 返回令牌的权限范围;无效时 ok 为 false
|
||||
func (m *Manager) ValidateAPIToken(plain string) (scope string, ok bool, err error) {
|
||||
if !strings.HasPrefix(plain, apiTokenPrefix) {
|
||||
return "", false, nil
|
||||
}
|
||||
return m.store.UseAPIToken(hashToken(plain), time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
// ---- 限速:同一来源在时间窗口内失败过多则拒绝 ----
|
||||
|
||||
func (m *Manager) allow(ip string) bool {
|
||||
@@ -204,6 +294,13 @@ func randomToken() string {
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// NewShareToken 生成分享链接中的令牌(128 位随机数,比会话令牌短,便于复制)
|
||||
func NewShareToken() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
|
||||
Reference in new issue
Block a user