Files
ONE/backend/internal/auth/auth.go
T
Sakurasan cfd6948987 评论阶段二:GitHub OAuth 登录 + 评论表/用户表 + 先审后显开关 + 禁言 + 后台评论管理页
- auth 包:读者会话(one_reader,与后台令牌互斥)+ GitHub OAuth 客户端;Verify 校验 HMAC 与 reader: 前缀
- 公开 API:auth 五端点、评论列表(顶层可见+自己待审、回复内嵌)、发表(登录/禁言/开关/500 字校验)、10 分钟编辑窗、软删墓碑
- 管理端:评论列表(待审/已通过/全部)、通过、软删、读者列表、禁言切换
- 前台:登录卡/禁言卡;后台:评论管理页 + 设置页审核开关
2026-09-27 23:41:40 +08:00

173 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}