// Google / Telegram 登录的 HTTP 端点。GitHub 的在 comments.go—— // 三个 Provider 共用同一套读者会话与 upsert 逻辑,只是凭据交换方式不同: // GitHub / Google 是授权码换 token,Telegram 是官方 widget 直接带签名资料。 package api import ( "encoding/json" "errors" "io" "net/http" "strconv" "oneblog/internal/auth" "oneblog/internal/httpx" "oneblog/internal/model" "oneblog/internal/ratelimit" "oneblog/internal/store" ) // 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}) } // googleLogin 跳 Google 授权页(state 防 CSRF 同 GitHub) func (a *API) googleLogin(w http.ResponseWriter, r *http.Request) { if !a.GG.Enabled() { httpx.NotFound(w) return } a.startOAuth(w, r, func(state string) string { return a.GG.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/google", state) }) } // googleCallback 用 code 换身份,交给统一分流(绑定 / 已绑账号 / 新读者)。 func (a *API) googleCallback(w http.ResponseWriter, r *http.Request) { if !a.GG.Enabled() { httpx.NotFound(w) return } ip := ratelimit.SourceKey(r) if a.authFails.Blocked(ip) { httpx.Error(w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试") return } ck, err := r.Cookie(oauthStateCook) if err != nil || ck.Value == "" || ck.Value != r.FormValue("state") { a.authFails.Add(ip) 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 { a.authFails.Add(ip) httpx.ServerError(w, err) return } u, err := a.GG.FetchUser(r.Context(), accessToken) if err != nil { a.authFails.Add(ip) 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 } persona := model.Reader{ Provider: "google", Handle: handle, Name: name, AvatarURL: u.Picture, } if back := a.afterIdentity(w, r, "google", u.Sub, handle, persona); back != "" { http.Redirect(w, r, back, 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 } // widget 回传是纯表单 POST,签名可被伪造重放——失败计数最必要的一路 ip := ratelimit.SourceKey(r) if a.authFails.Blocked(ip) { httpx.Error(w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试") 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 { a.authFails.Add(ip) httpx.BadRequest(w, "invalid body") return } if err := a.TG.VerifyMap(fields); err != nil { a.authFails.Add(ip) 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) } externUID := strconv.FormatInt(in.IDInt(), 10) // Telegram 是 XHR + JSON 响应(不是整页跳转),所以这里走与 afterIdentity // 同语义、但自己写响应的一份分流。 if bindRequested(r) { if !a.adminSessionValid(r) { clearBindCookie(w) httpx.Unauthorized(w) return } owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser) if err != nil { clearBindCookie(w) httpx.ServerError(w, err) return } err = a.Store.BindIdentity(owner.ID, "telegram", externUID, handle) clearBindCookie(w) if errors.Is(err, store.ErrConflict) { httpx.Error(w, http.StatusConflict, "该账号已绑定到其他用户") return } if err != nil { httpx.ServerError(w, err) return } httpx.OK(w, map[string]any{"ok": true, "bound": "telegram"}) return } clearBindCookie(w) // 已绑定的身份优先:站主用绑定的 Telegram 登录要拿到后台会话 if u, err := a.Store.GetUserByIdentity("telegram", externUID); err == nil { if u.Role == model.RoleOwner { a.issueAdminSession(w, r) httpx.OK(w, map[string]any{"ok": true, "role": u.Role}) return } a.issueReaderSession(w, r, u.ID) httpx.OK(w, map[string]any{"ok": true, "role": u.Role}) return } else if !errors.Is(err, store.ErrNotFound) { httpx.ServerError(w, err) return } 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 即可见 a.issueReaderSession(w, r, reader.ID) 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": reader.Role == model.RoleOwner, "banned": reader.Banned, }}) } func tgProfileURL(username string) string { if username == "" { return "" } return "https://t.me/" + username }