评论登录新增 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_* 模板
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user