后台新增 /admin/account 一页,四块:公开资料、密码、第三方账号、Passkey。 - schema:users 加 role(默认 reader),新表 user_identities、passkeys。 user_identities 上双 UNIQUE —— (provider, extern_uid) 防一个外部账号顶两个身份, (user_id, provider) 防一站主绑两个同平台号,绑错也劫持不了。 extern_uid 存平台稳定 ID,不存用户名(用户名可改)。 - 头像存 files 里的 key 而非 URL,换存储/CDN 不失效;单 key SetSetting 写入, 避开 UpdateSettings 的整表替换会把它抹掉。站主名/简介从设置页挪到账户页, 一个字段只留一个编辑入口。 - OAuth 绑定要求先有后台会话(绑定动作本身是提权路径);已绑的站主身份登录后 直接发 one_session,读者身份仍发 one_reader。 - passkey 走 go-webauthn v0.15.0(最后一条吃 go 1.24 的版本线),可发现凭据登录。 必须显式设 ONE_WEBAUTHN_ORIGINS 才启用,不配就安静关掉。 签名计数只记克隆警告、不硬拦 —— 云同步 passkey 的计数本就不单调。 - 密码故意留在 ONE_ADMIN_PASSWORD,不做哈希入库:这是「解绑一切、删光 passkey 也还能进门」的保底,比 env 明文更值得守。memos 那个 SSO 建号随机密码无重置 入口的坑,从设计上绕开。 已知限制:会话仍是有状态无关的 HMAC cookie,删 passkey / 解绑不会让已发出的 7 天后台会话失效 —— 要修得加一张吊销表。
180 lines
5.3 KiB
Go
180 lines
5.3 KiB
Go
// Package auth 提供评论区两侧的基础设施:读者会话(cookie one_reader,
|
||
// payload 带 reader: 前缀,与后台令牌不可互换)和 GitHub OAuth 客户端。
|
||
package auth
|
||
|
||
import (
|
||
"context"
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// ReaderCookie 是读者会话的 cookie 名(与后台的 one_session 区分开)
|
||
const ReaderCookie = "one_reader"
|
||
|
||
// ReaderSessions 签发 / 校验读者会话令牌:
|
||
// base64("reader:<readerID>:<expiryUnix>") + "." + HMAC-SHA256。
|
||
type ReaderSessions struct {
|
||
secret []byte
|
||
ttl time.Duration
|
||
}
|
||
|
||
func NewReaderSessions(secret string, ttl time.Duration) *ReaderSessions {
|
||
if ttl <= 0 {
|
||
ttl = 30 * 24 * time.Hour
|
||
}
|
||
return &ReaderSessions{secret: []byte(secret), ttl: ttl}
|
||
}
|
||
|
||
var ErrBadSession = errors.New("invalid reader session")
|
||
|
||
func (s *ReaderSessions) Issue(readerID int64) (string, time.Time) {
|
||
exp := time.Now().Add(s.ttl)
|
||
payload := readerPayload(readerID, exp)
|
||
enc := base64.RawURLEncoding.EncodeToString([]byte(payload))
|
||
return enc + "." + s.sign(payload), exp
|
||
}
|
||
|
||
// TTL 是会话秒数,供 cookie 的 MaxAge 用。
|
||
// 之前两处硬编码 30*24h,改 ttl 时容易和 Issue 不同步。
|
||
func (s *ReaderSessions) TTL() int { return int(s.ttl.Seconds()) }
|
||
|
||
func (s *ReaderSessions) Verify(token string) (int64, error) {
|
||
parts := strings.Split(token, ".")
|
||
if len(parts) != 2 {
|
||
return 0, ErrBadSession
|
||
}
|
||
raw, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||
if err != nil {
|
||
return 0, ErrBadSession
|
||
}
|
||
// 必须以 reader: 开头——后台令牌(base64("admin:<exp>"))复制过来也无效
|
||
if len(raw) < 10 || string(raw)[:7] != "reader:" {
|
||
return 0, ErrBadSession
|
||
}
|
||
// 余下是 "<id>:<exp>"
|
||
rest := string(raw)[7:]
|
||
i := strings.IndexByte(rest, ':')
|
||
if i <= 0 {
|
||
return 0, ErrBadSession
|
||
}
|
||
id, err := strconv.ParseInt(rest[:i], 10, 64)
|
||
if err != nil {
|
||
return 0, ErrBadSession
|
||
}
|
||
expUnix, err := strconv.ParseInt(rest[i+1:], 10, 64)
|
||
if err != nil {
|
||
return 0, ErrBadSession
|
||
}
|
||
if time.Now().After(time.Unix(expUnix, 0)) {
|
||
return 0, ErrBadSession
|
||
}
|
||
// 签名放在最后验证:payload 与 exp 都验过再比对 MAC
|
||
if !hmac.Equal([]byte(s.sign(string(raw))), []byte(parts[1])) {
|
||
return 0, ErrBadSession
|
||
}
|
||
return id, nil
|
||
}
|
||
|
||
func readerPayload(id int64, exp time.Time) string {
|
||
return "reader:" + strconv.FormatInt(id, 10) + ":" + strconv.FormatInt(exp.Unix(), 10)
|
||
}
|
||
|
||
func (s *ReaderSessions) sign(payload string) string {
|
||
mac := hmac.New(sha256.New, s.secret)
|
||
mac.Write([]byte(payload))
|
||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||
}
|
||
|
||
// ---------- GitHub OAuth ----------
|
||
|
||
// GitHub 持有 OAuth 应用凭据。没配 = GitHub 登录不开放,
|
||
// 前台登录卡自动不显示该入口。
|
||
type GitHub struct {
|
||
ClientID string
|
||
ClientSecret string
|
||
}
|
||
|
||
func (g GitHub) Enabled() bool { return g.ClientID != "" && g.ClientSecret != "" }
|
||
|
||
const (
|
||
githubAuthURL = "https://github.com/login/oauth/authorize"
|
||
githubTokenURL = "https://github.com/login/oauth/access_token"
|
||
githubUserURL = "https://api.github.com/user"
|
||
)
|
||
|
||
// LoginURL 生成 GitHub 授权页跳转地址
|
||
func (g GitHub) LoginURL(redirectURI, state string) string {
|
||
v := url.Values{}
|
||
v.Set("client_id", g.ClientID)
|
||
v.Set("redirect_uri", redirectURI)
|
||
v.Set("scope", "read:user")
|
||
v.Set("state", state)
|
||
return githubAuthURL + "?" + v.Encode()
|
||
}
|
||
|
||
// GitHubUser 是 GitHub 用户接口里我们关心的字段
|
||
type GitHubUser struct {
|
||
// ID 是 GitHub 的数字主键:永不复用、改名不变。身份绑定必须用它,
|
||
// 用 login 的话对方一改用户名,绑定就指向了另一个人。
|
||
ID int64 `json:"id"`
|
||
Login string `json:"login"`
|
||
Name string `json:"name"`
|
||
AvatarURL string `json:"avatar_url"`
|
||
HTMLURL string `json:"html_url"`
|
||
}
|
||
|
||
// Exchange 用授权码换 access token
|
||
func (g GitHub) Exchange(ctx context.Context, code, redirectURI string) (string, error) {
|
||
v := url.Values{}
|
||
v.Set("client_id", g.ClientID)
|
||
v.Set("client_secret", g.ClientSecret)
|
||
v.Set("code", code)
|
||
v.Set("redirect_uri", redirectURI)
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, githubTokenURL, strings.NewReader(v.Encode()))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
req.Header.Set("Accept", "application/json")
|
||
res, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer res.Body.Close()
|
||
var out struct {
|
||
AccessToken string `json:"access_token"`
|
||
}
|
||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil || out.AccessToken == "" {
|
||
return "", fmt.Errorf("github: token exchange failed")
|
||
}
|
||
return out.AccessToken, nil
|
||
}
|
||
|
||
// FetchUser 拉取 GitHub 用户资料
|
||
func (g GitHub) FetchUser(ctx context.Context, accessToken string) (GitHubUser, error) {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubUserURL, nil)
|
||
if err != nil {
|
||
return GitHubUser{}, err
|
||
}
|
||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||
res, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
return GitHubUser{}, err
|
||
}
|
||
defer res.Body.Close()
|
||
var u GitHubUser
|
||
if err := json.NewDecoder(res.Body).Decode(&u); err != nil || u.Login == "" {
|
||
return GitHubUser{}, fmt.Errorf("github: fetch user failed")
|
||
}
|
||
return u, nil
|
||
}
|