评论阶段二:GitHub OAuth 登录 + 评论表/用户表 + 先审后显开关 + 禁言 + 后台评论管理页

- auth 包:读者会话(one_reader,与后台令牌互斥)+ GitHub OAuth 客户端;Verify 校验 HMAC 与 reader: 前缀
- 公开 API:auth 五端点、评论列表(顶层可见+自己待审、回复内嵌)、发表(登录/禁言/开关/500 字校验)、10 分钟编辑窗、软删墓碑
- 管理端:评论列表(待审/已通过/全部)、通过、软删、读者列表、禁言切换
- 前台:登录卡/禁言卡;后台:评论管理页 + 设置页审核开关
This commit is contained in:
Sakurasan
2026-09-27 23:41:40 +08:00
parent 740d47bbf6
commit cfd6948987
14 changed files with 1403 additions and 29 deletions
+172
View File
@@ -0,0 +1,172 @@
// 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
}
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 {
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
}