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