diff --git a/.env.example b/.env.example index 52e7f94..7ae6970 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,13 @@ Bucket= # 回调地址填 http://localhost:8080/api/auth/callback/github(线上换成正式域名) ONE_GITHUB_CLIENT_ID= ONE_GITHUB_CLIENT_SECRET= + +# Telegram 登录(Login Widget):@BotFather 建 bot 拿 token, +# 再用 /setdomain 把域名登记给 bot(本地开发填 localhost) +ONE_TELEGRAM_BOT= +ONE_TELEGRAM_BOT_TOKEN= + +# Google 登录:https://console.cloud.google.com/apis/credentials 建 OAuth 客户端, +# 回调地址填 http://localhost:8080/api/auth/callback/google(线上换成正式域名) +ONE_GOOGLE_CLIENT_ID= +ONE_GOOGLE_CLIENT_SECRET= diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 0b87f5d..09adb88 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -32,6 +32,9 @@ type API struct { AdminSessions interface { Verify(token string) (string, error) } + // 其余登录方式(main.go 装配,未配置的自动不开放) + GG auth.Google + TG auth.Telegram } func (a *API) Routes() http.Handler { @@ -49,6 +52,9 @@ func (a *API) Routes() http.Handler { mux.HandleFunc("/api/auth/logout", a.authLogout) mux.HandleFunc("/api/auth/github/login", a.githubLogin) mux.HandleFunc("/api/auth/callback/github", a.githubCallback) + mux.HandleFunc("/api/auth/google/login", a.googleLogin) + mux.HandleFunc("/api/auth/callback/google", a.googleCallback) + mux.HandleFunc("/api/auth/telegram", a.telegramAuth) mux.HandleFunc("/api/comments", a.comments) mux.HandleFunc("/api/comments/", a.commentSub) mux.HandleFunc("/api/site", a.site) diff --git a/backend/internal/api/comments.go b/backend/internal/api/comments.go index 1921166..c545d1f 100644 --- a/backend/internal/api/comments.go +++ b/backend/internal/api/comments.go @@ -76,16 +76,6 @@ func (a *API) ownerReader() (model.Reader, error) { }) } -func (a *API) authProviders(w http.ResponseWriter, r *http.Request) { - providers := []map[string]any{} - if a.GH.Enabled() { - providers = append(providers, map[string]any{ - "id": "github", "label": "GitHub", "kind": "redirect", - }) - } - httpx.OK(w, map[string]any{"providers": providers}) -} - func (a *API) authMe(w http.ResponseWriter, r *http.Request) { var user any // 匿名时 {user: null},前端判空即「未登录」 if reader, ok, err := a.resolveReader(r); err == nil && ok { @@ -158,18 +148,7 @@ func (a *API) githubCallback(w http.ResponseWriter, r *http.Request) { httpx.ServerError(w, err) return } - token, _ := a.ReaderSessions.Issue(reader.ID) - http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: token, Path: "/", - HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((30 * 24 * time.Hour).Seconds())}) - // 回到发起登录的前台;没有记录(直接敲 URL 进来的)就回站点根 - back := a.Cfg.SiteURL - if ck, err := r.Cookie(oauthBackCook); err == nil && ck.Value != "" { - if u, err := url.Parse(ck.Value); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" && u.Path == "" { - back = u.Scheme + "://" + u.Host - } - } - http.SetCookie(w, &http.Cookie{Name: oauthBackCook, Value: "", Path: "/", MaxAge: -1}) - http.Redirect(w, r, back, http.StatusFound) + http.Redirect(w, r, a.issueReaderCookie(w, r, reader.ID), http.StatusFound) } func randHex(n int) string { diff --git a/backend/internal/api/providers.go b/backend/internal/api/providers.go new file mode 100644 index 0000000..a736cb6 --- /dev/null +++ b/backend/internal/api/providers.go @@ -0,0 +1,175 @@ +// Google / Telegram 登录的 HTTP 端点。GitHub 的在 comments.go—— +// 三个 Provider 共用同一套读者会话与 upsert 逻辑,只是凭据交换方式不同: +// GitHub / Google 是授权码换 token,Telegram 是官方 widget 直接带签名资料。 +package api + +import ( + "encoding/json" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "oneblog/internal/auth" + "oneblog/internal/httpx" + "oneblog/internal/model" +) + +// authProviders 列出已配置的登录方式。 +// redirect 型前端直接跳 /api/auth/{id}/login;widget 型(Telegram) +// 前端内联官方脚本,需要 bot 用户名。 +func (a *API) authProviders(w http.ResponseWriter, r *http.Request) { + providers := []map[string]any{} + if a.GH.Enabled() { + providers = append(providers, map[string]any{ + "id": "github", "label": "GitHub", "kind": "redirect", + }) + } + if a.GG.Enabled() { + providers = append(providers, map[string]any{ + "id": "google", "label": "Google", "kind": "redirect", + }) + } + if a.TG.Enabled() { + providers = append(providers, map[string]any{ + "id": "telegram", "label": "Telegram", "kind": "widget", "login": a.TG.Bot, + }) + } + httpx.OK(w, map[string]any{"providers": providers}) +} + +// issueReaderCookie 登录成功后的公共收尾:发读者会话 + 决定跳回去的地址 +func (a *API) issueReaderCookie(w http.ResponseWriter, r *http.Request, readerID int64) string { + token, _ := a.ReaderSessions.Issue(readerID) + http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: token, Path: "/", + HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((30 * 24 * time.Hour).Seconds())}) + // 回到发起登录的前台;没有记录(直接敲 URL 进来的)就回站点根 + back := a.Cfg.SiteURL + if ck, err := r.Cookie(oauthBackCook); err == nil && ck.Value != "" { + if u, err := url.Parse(ck.Value); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" && u.Path == "" { + back = u.Scheme + "://" + u.Host + } + } + http.SetCookie(w, &http.Cookie{Name: oauthBackCook, Value: "", Path: "/", MaxAge: -1}) + return back +} + +// googleLogin 跳 Google 授权页(state 防 CSRF 同 GitHub) +func (a *API) googleLogin(w http.ResponseWriter, r *http.Request) { + if !a.GG.Enabled() { + httpx.NotFound(w) + return + } + state := randHex(16) + http.SetCookie(w, &http.Cookie{Name: oauthStateCook, Value: state, Path: "/", + HttpOnly: true, MaxAge: 600}) + if ref := r.Referer(); ref != "" { + if u, err := url.Parse(ref); err == nil && u.Scheme != "" && u.Host != "" { + http.SetCookie(w, &http.Cookie{Name: oauthBackCook, + Value: u.Scheme + "://" + u.Host, Path: "/", HttpOnly: true, MaxAge: 600}) + } + } + http.Redirect(w, r, a.GG.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/google", state), http.StatusFound) +} + +// googleCallback 用 code 换身份:Google 用户 → upsert 读者 → 发会话 +func (a *API) googleCallback(w http.ResponseWriter, r *http.Request) { + if !a.GG.Enabled() { + httpx.NotFound(w) + return + } + ck, err := r.Cookie(oauthStateCook) + if err != nil || ck.Value == "" || ck.Value != r.FormValue("state") { + httpx.BadRequest(w, "state 不匹配,请重新登录") + return + } + accessToken, err := a.GG.Exchange(r.Context(), r.FormValue("code"), a.Cfg.SiteURL+"/api/auth/callback/google") + if err != nil { + httpx.ServerError(w, err) + return + } + u, err := a.GG.FetchUser(r.Context(), accessToken) + if err != nil { + httpx.ServerError(w, err) + return + } + // handle 优先用已验证邮箱(可读),否则退回 sub(Google 的稳定唯一 id) + handle := u.Sub + if u.EmailVerified && u.Email != "" { + handle = u.Email + } + name := u.Name + if name == "" { + name = handle + } + reader, err := a.Store.UpsertReader(model.Reader{ + Provider: "google", Handle: handle, Name: name, + AvatarURL: u.Picture, + }) + if err != nil { + httpx.ServerError(w, err) + return + } + http.Redirect(w, r, a.issueReaderCookie(w, r, reader.ID), http.StatusFound) +} + +// telegramAuth 校验 Login Widget 回传的签名资料并登录。 +// 前端把 widget 的 user 对象原样 POST 过来(见 reader.js 的 oneTelegramAuth)。 +func (a *API) telegramAuth(w http.ResponseWriter, r *http.Request) { + if !a.TG.Enabled() { + httpx.NotFound(w) + return + } + // 原样读 body:验签必须用收到的全部字段(官方规则), + // 身份字段再单独解一次 + body, err := io.ReadAll(r.Body) + if err != nil { + httpx.BadRequest(w, "invalid body") + return + } + var fields map[string]any + if err := json.Unmarshal(body, &fields); err != nil || len(fields) == 0 { + httpx.BadRequest(w, "invalid body") + return + } + if err := a.TG.VerifyMap(fields); err != nil { + httpx.Error(w, http.StatusForbidden, "Telegram 登录校验失败,请重试") + return + } + var in auth.TelegramUser + if err := json.Unmarshal(body, &in); err != nil || in.IDInt() == 0 { + httpx.BadRequest(w, "invalid body") + return + } + handle := in.Username + if handle == "" { + // 没有公开 username 的用户用数字 id,保证 provider+handle 稳定唯一 + handle = strconv.FormatInt(in.IDInt(), 10) + } + reader, err := a.Store.UpsertReader(model.Reader{ + Provider: "telegram", Handle: handle, Name: in.DisplayName(), + AvatarURL: in.PhotoURL, + URL: tgProfileURL(in.Username), + }) + if err != nil { + httpx.ServerError(w, err) + return + } + // 会话同样落 httpOnly cookie,前端 POST 完刷新 /api/auth/me 即可见 + token, _ := a.ReaderSessions.Issue(reader.ID) + http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: token, Path: "/", + HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((30 * 24 * time.Hour).Seconds())}) + httpx.OK(w, map[string]any{"user": map[string]any{ + "id": reader.ID, "name": reader.Name, "handle": reader.Handle, + "avatar_url": reader.AvatarURL, "url": reader.URL, + "provider": reader.Provider, "is_owner": false, "banned": reader.Banned, + }}) +} + +func tgProfileURL(username string) string { + if username == "" { + return "" + } + return "https://t.me/" + username +} diff --git a/backend/internal/auth/google.go b/backend/internal/auth/google.go new file mode 100644 index 0000000..799ea56 --- /dev/null +++ b/backend/internal/auth/google.go @@ -0,0 +1,95 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" +) + +// Google OAuth2(OIDC 简化用法:openid email profile 三个 scope, +// userinfo 接口拿资料)。配置来源 .env 的 ONE_GOOGLE_CLIENT_ID / SECRET。 +const ( + googleAuthURL = "https://accounts.google.com/o/oauth2/v2/auth" + googleTokenURL = "https://oauth2.googleapis.com/token" + googleUserURL = "https://openidconnect.googleapis.com/v1/userinfo" +) + +type Google struct { + ClientID string + ClientSecret string +} + +func (g Google) Enabled() bool { return g.ClientID != "" && g.ClientSecret != "" } + +func (g Google) LoginURL(redirectURI, state string) string { + v := url.Values{} + v.Set("client_id", g.ClientID) + v.Set("redirect_uri", redirectURI) + v.Set("response_type", "code") + v.Set("scope", "openid email profile") + v.Set("state", state) + return googleAuthURL + "?" + v.Encode() +} + +// GoogleUser 是 userinfo 接口里我们关心的字段。 +// sub 是 Google 账号的稳定唯一 id;邮箱需要已验证才当 handle 用。 +type GoogleUser struct { + Sub string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + Picture string `json:"picture"` +} + +func (g Google) 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) + v.Set("grant_type", "authorization_code") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, googleTokenURL, strings.NewReader(v.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + 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("google: token exchange failed") + } + return out.AccessToken, nil +} + +func (g Google) FetchUser(ctx context.Context, accessToken string) (GoogleUser, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, googleUserURL, nil) + if err != nil { + return GoogleUser{}, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + res, err := http.DefaultClient.Do(req) + if err != nil { + return GoogleUser{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return GoogleUser{}, fmt.Errorf("google: userinfo %d", res.StatusCode) + } + var u GoogleUser + if err := json.NewDecoder(res.Body).Decode(&u); err != nil { + return GoogleUser{}, err + } + if u.Sub == "" { + return GoogleUser{}, fmt.Errorf("google: userinfo missing sub") + } + return u, nil +} diff --git a/backend/internal/auth/telegram.go b/backend/internal/auth/telegram.go new file mode 100644 index 0000000..cbe23f2 --- /dev/null +++ b/backend/internal/auth/telegram.go @@ -0,0 +1,121 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + "time" +) + +// Telegram Login Widget:没有授权码流程,官方脚本把用户资料连同 +// hash 一起交给前端,前端 POST 到 /api/auth/telegram,后端用 bot +// token 验签。算法见官方文档:secret = SHA256(bot_token), +// data-check-string 是除 hash 外所有收到的字段按 key 排序的 "k=v" 行。 +type Telegram struct { + Bot string // bot 用户名(不含 @),下发给 widget + Token string // bot token,只用于验签,不出后端 +} + +func (t Telegram) Enabled() bool { return t.Bot != "" && t.Token != "" } + +// telegramAuthTTL 是验签窗口:widget 回传的 auth_date 超过它视为过期 +const telegramAuthTTL = 24 * time.Hour + +// TelegramUser 是 widget 回传的身份字段(验签通过后从中取)。 +// widget 把所有值都当字符串发(id 也不例外),但前端测试或手工 +// 调用可能发数字 —— FlexStr 两种都收。 +type TelegramUser struct { + ID FlexStr `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Username string `json:"username"` + PhotoURL string `json:"photo_url"` +} + +// FlexStr 兼容 JSON 里的字符串和数字 +type FlexStr string + +func (f *FlexStr) UnmarshalJSON(b []byte) error { + if len(b) > 0 && b[0] == '"' { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + *f = FlexStr(s) + return nil + } + *f = FlexStr(b) + return nil +} + +func (f FlexStr) Int64() (int64, error) { + return strconv.ParseInt(strings.Trim(string(f), `"`), 10, 64) +} + +func (u TelegramUser) IDInt() int64 { id, _ := u.ID.Int64(); return id } + +func (u TelegramUser) DisplayName() string { + name := strings.TrimSpace(u.FirstName + " " + u.LastName) + if name == "" { + name = u.Username + } + return name +} + +// normalize 把 JSON 值规整成 widget 发送时的字符串形态 +// (widget 的数值字段也是字符串,但调用方可能发真数字) +func normalize(v any) string { + switch x := v.(type) { + case string: + return x + case float64: + return strconv.FormatInt(int64(x), 10) + case json.Number: + return x.String() + case bool: + return strconv.FormatBool(x) + default: + return fmt.Sprint(x) + } +} + +// VerifyMap 校验 hash 与时效。fields 是前端原样 POST 的 JSON 对象; +// exclude 里的 key(我们附加的非 Telegram 字段)不参与验签。 +func (t Telegram) VerifyMap(fields map[string]any, exclude ...string) error { + hash := normalize(fields["hash"]) + if hash == "" { + return fmt.Errorf("telegram: missing hash") + } + authDate, err := strconv.ParseInt(normalize(fields["auth_date"]), 10, 64) + if err != nil || authDate == 0 || time.Since(time.Unix(authDate, 0)) > telegramAuthTTL { + return fmt.Errorf("telegram: auth_date expired") + } + skip := make(map[string]bool, len(exclude)+1) + skip["hash"] = true + for _, k := range exclude { + skip[k] = true + } + keys := make([]string, 0, len(fields)) + for k := range fields { + if !skip[k] { + keys = append(keys, k) + } + } + sort.Strings(keys) + lines := make([]string, 0, len(keys)) + for _, k := range keys { + lines = append(lines, k+"="+normalize(fields[k])) + } + secret := sha256.Sum256([]byte(t.Token)) + mac := hmac.New(sha256.New, secret[:]) + mac.Write([]byte(strings.Join(lines, "\n"))) + if !hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(strings.ToLower(hash))) { + return fmt.Errorf("telegram: hash mismatch") + } + return nil +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index e6efe8e..947d87f 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -25,6 +25,15 @@ type Config struct { GitHubClientID string GitHubClientSecret string + // 评论区 Google 登录(OAuth 客户端凭据,Google Cloud Console 创建) + GoogleClientID string + GoogleClientSecret string + + // 评论区 Telegram 登录(Login Widget):Bot 是用户名(不含 @,下发给 + // 前端 widget),Token 用于验签。两者都要,缺一该入口不开放。 + TelegramBot string + TelegramToken string + // 对象存储(文件上传)。变量名与站主 .env 里的写法一致(站主已整理): // S3Api = R2 的 S3 API 端点(https://<账户ID>.r2.cloudflarestorage.com, // 控制台 R2 概览可复制),上传走它 —— 公开域名收不了上传请求 @@ -129,6 +138,10 @@ func Load() (*Config, error) { // 评论区 GitHub 登录(OAuth App 凭据,站主在 GitHub 上创建后填入) c.GitHubClientID = getenv("ONE_GITHUB_CLIENT_ID", "") c.GitHubClientSecret = getenv("ONE_GITHUB_CLIENT_SECRET", "") + c.GoogleClientID = getenv("ONE_GOOGLE_CLIENT_ID", "") + c.GoogleClientSecret = getenv("ONE_GOOGLE_CLIENT_SECRET", "") + c.TelegramBot = getenv("ONE_TELEGRAM_BOT", "") + c.TelegramToken = getenv("ONE_TELEGRAM_BOT_TOKEN", "") return c, nil } diff --git a/backend/main.go b/backend/main.go index 363187c..904127f 100644 --- a/backend/main.go +++ b/backend/main.go @@ -72,6 +72,8 @@ func main() { Blobs: blobs, ReaderSessions: readerSessions, GH: auth.GitHub{ClientID: cfg.GitHubClientID, ClientSecret: cfg.GitHubClientSecret}, + GG: auth.Google{ClientID: cfg.GoogleClientID, ClientSecret: cfg.GoogleClientSecret}, + TG: auth.Telegram{Bot: cfg.TelegramBot, Token: cfg.TelegramToken}, AdminSessions: adminSessions, } adminAPI := admin.NewAPI(st, cfg, adminSessions)