// 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::") + "." + 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:"))复制过来也无效 if len(raw) < 10 || string(raw)[:7] != "reader:" { return 0, ErrBadSession } // 余下是 ":" 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 }