- users 表加 password_hash 列;后台账号(owner+admin)密码 bcrypt 存行内, 首次登录把 env / settings 引导凭据自迁移成行哈希 - 会话 token 从用户名改为携带用户 ID,角色与停用状态每请求查库, 改角色 / 停用账号即时生效(存量会话立即 401) - 登录:先查 users 表,再走 settings 哈希 / env 引导链; admin/admin 开发模式在任何账号设过密码后失效 - 权限:系统设置、用户管理仅 owner;内容管理 admin+owner; admin 后台新增 用户 页(创建 / 重置密码 / 停用 / 删除), 设置页「登录与存储」tab 对管理员隐藏 - 账户页加修改密码表单(旧密码校验,OAuth/Passkey 首设免旧密码); 评论区管理员身份跟随各自账号,不再统一挂站主名下 - 修复:providers 为 nil 时账户页白屏(Go nil slice 序列化成 null)
211 lines
6.3 KiB
Go
211 lines
6.3 KiB
Go
// 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, u.ID)
|
||
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
|
||
}
|