评论阶段二:GitHub OAuth 登录 + 评论表/用户表 + 先审后显开关 + 禁言 + 后台评论管理页
- auth 包:读者会话(one_reader,与后台令牌互斥)+ GitHub OAuth 客户端;Verify 校验 HMAC 与 reader: 前缀 - 公开 API:auth 五端点、评论列表(顶层可见+自己待审、回复内嵌)、发表(登录/禁言/开关/500 字校验)、10 分钟编辑窗、软删墓碑 - 管理端:评论列表(待审/已通过/全部)、通过、软删、读者列表、禁言切换 - 前台:登录卡/禁言卡;后台:评论管理页 + 设置页审核开关
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user