Files
ONE/backend/internal/api/providers.go
T
Sakurasan e68400b389 系统设置迁入后台:站点地址 / OAuth 凭据 / R2 存储 / 管理员账号,保存即生效无需重启
- 生效规则统一为「后台填了用后台的,没填回落环境变量」,老部署不改 env 照常跑
- config.Resolver:短缓存解析有效配置,存储后端按配置签名热重建;后台保存主动失效
- 秘密项(client secret / bot token / R2 密钥 / 管理员密码)接口永不回显明文,
  只报「是否已配置、来自哪里」;留空保存 = 保持现值
- 管理员密码 bcrypt 入库,DB 哈希优先、显式设置的 env 密码作解锁后路;
  后台改过密码后 admin/admin 开发模式立即失效
- 设置页新增「登录与存储」标签,基础信息加站点地址;秘密项带来源提示
- 监听地址 / 数据库 / 目录 / ONE_SECRET / Passkey 仍留环境变量(启动期依赖)
2026-10-01 12:14:10 +08:00

211 lines
6.3 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"
"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.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.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
}