// 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 }