Files
ONE/backend/internal/api/providers.go
T
Sakurasan baf14ec6ca 评论登录新增 Google OAuth 与 Telegram Login Widget
- Google:授权码流程(openid email profile),handle 优先用已验证邮箱,头像取 picture
- Telegram:官方 widget 直接回传签名资料,后端按官方算法验签(secret=SHA256(bot_token),除 hash 外全字段排序比对)+ auth_date 24h 时效
- providers 列表自动按配置下发(未配置的不显示);widget 型带 bot 用户名
- 三个登录方式共用读者会话与 upsert;Telegram 走 POST 无跳转
- .env.example 补 ONE_GOOGLE_* / ONE_TELEGRAM_* 模板
2026-09-28 01:32:39 +08:00

176 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}