新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。

作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-31 16:58:22 +08:00
parent 9487c8ab02
commit 822eef96be
83 changed files with 8578 additions and 1222 deletions

View File

@@ -40,7 +40,6 @@ func (h *Handlers) AdminDashboard(c *gin.Context) {
"PostCount": postCount,
"BoardCount": boardCount,
"CommentCount": commentCount,
"OnlineCount": h.Online.Count(),
"RecentPosts": recentPosts,
}))
}
@@ -223,7 +222,7 @@ func (h *Handlers) AdminAPILogin(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
token, user, err := h.Auth.Login(req.Username, req.Password)
token, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP())
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return

View File

@@ -2,13 +2,16 @@ package handler
import (
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/middleware"
"git.iioio.com/freefire/jiang13-forum/model"
"git.iioio.com/freefire/jiang13-forum/service"
)
@@ -22,11 +25,13 @@ func (h *Handlers) APIMe(c *gin.Context) {
}
user, err := h.User.GetByID(uid)
if err != nil {
// 账号已删或不存在:清掉失效 cookie与未登录态一致
c.SetCookie(middleware.CookieName, "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"user": nil})
return
}
c.JSON(http.StatusOK, gin.H{
"user": user,
"user": user.ToSelf(),
})
}
@@ -125,7 +130,7 @@ func (h *Handlers) APIAdminDashboard(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"users": userCount, "posts": postCount, "boards": boardCount,
"comments": commentCount, "online": h.Online.Count(),
"comments": commentCount,
"recent_posts": recentPosts,
})
}
@@ -242,7 +247,7 @@ func (h *Handlers) APIAdminUsers(c *gin.Context) {
users = []model.User{}
}
c.JSON(http.StatusOK, gin.H{
"users": users, "total": total, "page": page,
"users": model.UsersToAdmin(users), "total": total, "page": page,
"total_pages": calcTotalPages(total, size),
})
}
@@ -298,17 +303,110 @@ func (h *Handlers) APIAdminDownloadBackup(c *gin.Context) {
func (h *Handlers) APIAdminSettings(c *gin.Context) {
limits := h.Settings.Limits()
filterContent, _ := service.ReadFilterWordsFile(h.Cfg.FilterWordsPath())
clients, _ := h.Settings.ListOAuthClients()
c.JSON(http.StatusOK, gin.H{
"filter_path": h.Cfg.FilterWordsPath(),
"data_dir": h.Cfg.DataDir,
"db_path": h.Cfg.DBPath(),
"port": h.Cfg.Port,
"limits": limits,
"filter_words": filterContent,
"filter_path": h.Cfg.FilterWordsPath(),
"data_dir": h.Cfg.DataDir,
"db_path": h.Cfg.DBPath(),
"port": h.Cfg.Port,
"limits": limits,
"mail": h.Settings.MailConfigPublic(),
"oidc": h.Settings.OIDCConfigPublic(),
"oauth_clients": clients,
"gitea": h.Settings.GiteaSyncConfigPublic(),
"branding": h.Settings.SiteBranding(),
"filter_words": filterContent,
"filter_word_count": service.CountFilterWords(filterContent),
})
}
// APISiteBranding 前台公开的站点品牌配置
func (h *Handlers) APISiteBranding(c *gin.Context) {
c.JSON(http.StatusOK, h.Settings.SiteBranding())
}
// APIAdminUpdateBranding 更新站点品牌文案
func (h *Handlers) APIAdminUpdateBranding(c *gin.Context) {
var req service.SiteBranding
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Settings.UpdateSiteBranding(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "站点品牌已保存",
"branding": h.Settings.SiteBranding(),
})
}
// APIAdminUploadBrandingAsset 上传 Logo 或 Faviconform: file + kind=logo|favicon
func (h *Handlers) APIAdminUploadBrandingAsset(c *gin.Context) {
kind := strings.TrimSpace(c.PostForm("kind"))
if kind != "logo" && kind != "favicon" {
c.JSON(http.StatusBadRequest, gin.H{"error": "kind 须为 logo 或 favicon"})
return
}
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件"})
return
}
const maxBytes = 2 * 1024 * 1024
if file.Size > maxBytes {
c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 2MB"})
return
}
url, err := service.SaveUploadedImage(file, h.Cfg.SiteUploadDir(), "/uploads/site", kind)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
prev := h.Settings.SiteBranding()
if kind == "logo" {
_ = h.Settings.SetSiteLogo(url)
h.removeSiteUploadIfLocal(prev.Logo)
} else {
_ = h.Settings.SetSiteFavicon(url)
h.removeSiteUploadIfLocal(prev.Favicon)
}
c.JSON(http.StatusOK, gin.H{
"message": "上传成功",
"url": url,
"branding": h.Settings.SiteBranding(),
})
}
// APIAdminClearBrandingAsset 清除 Logo 或 Favicon
func (h *Handlers) APIAdminClearBrandingAsset(c *gin.Context) {
var req struct {
Kind string `json:"kind"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
kind := strings.TrimSpace(req.Kind)
brand := h.Settings.SiteBranding()
switch kind {
case "logo":
_ = h.Settings.SetSiteLogo("")
h.removeSiteUploadIfLocal(brand.Logo)
case "favicon":
_ = h.Settings.SetSiteFavicon("")
h.removeSiteUploadIfLocal(brand.Favicon)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "kind 须为 logo 或 favicon"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "已清除",
"branding": h.Settings.SiteBranding(),
})
}
// APIAdminUpdateForumSettings 更新论坛设置
func (h *Handlers) APIAdminUpdateForumSettings(c *gin.Context) {
var req service.ForumLimits
@@ -326,6 +424,206 @@ func (h *Handlers) APIAdminUpdateForumSettings(c *gin.Context) {
})
}
// APIAdminUpdateMailSettings 更新邮件 SMTP 配置
func (h *Handlers) APIAdminUpdateMailSettings(c *gin.Context) {
var req service.MailConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Settings.UpdateMailConfig(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "邮件设置已保存",
"mail": h.Settings.MailConfigPublic(),
})
}
// APIAdminUpdateOIDCSettings 更新 OIDC Provider 全局配置
func (h *Handlers) APIAdminUpdateOIDCSettings(c *gin.Context) {
var req service.OIDCConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Settings.UpdateOIDCConfig(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "OIDC 设置已保存",
"oidc": h.Settings.OIDCConfigPublic(),
})
}
// APIProjects 会员公开 Gitea 项目列表(本地缓存)
func (h *Handlers) APIProjects(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("limit", c.DefaultQuery("size", "30")))
if page < 1 {
page = 1
}
if size < 1 {
size = 30
}
if size > 100 {
size = 100
}
if h.Gitea == nil {
c.JSON(http.StatusOK, gin.H{"projects": []any{}, "total": 0, "page": page, "total_pages": 0})
return
}
list, total, err := h.Gitea.ListPublic(page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"projects": list,
"total": total,
"page": page,
"total_pages": calcTotalPages(total, size),
})
}
// APIAdminUpdateGiteaSettings 更新 Gitea 同步配置
func (h *Handlers) APIAdminUpdateGiteaSettings(c *gin.Context) {
var req service.GiteaSyncConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Settings.UpdateGiteaSyncConfig(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Gitea 同步设置已保存",
"gitea": h.Settings.GiteaSyncConfigPublic(),
})
}
// APIAdminSyncGitea 立即同步 Gitea 公开仓库
func (h *Handlers) APIAdminSyncGitea(c *gin.Context) {
if h.Gitea == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrGiteaNotConfigured.Error()})
return
}
n, err := h.Gitea.SyncRepos()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": fmt.Sprintf("同步完成,共更新 %d 个仓库", n),
"count": n,
"gitea": h.Settings.GiteaSyncConfigPublic(),
})
}
// APIAdminListOAuthClients 列出 OAuth 应用
func (h *Handlers) APIAdminListOAuthClients(c *gin.Context) {
list, err := h.Settings.ListOAuthClients()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取失败"})
return
}
c.JSON(http.StatusOK, gin.H{"clients": list})
}
// APIAdminCreateOAuthClient 创建 OAuth 应用
func (h *Handlers) APIAdminCreateOAuthClient(c *gin.Context) {
var req service.OAuthClientInput
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
view, err := h.Settings.CreateOAuthClient(req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "应用已创建,请立即保存客户端密钥(仅显示一次)",
"client": view,
"oidc": h.Settings.OIDCConfigPublic(),
})
}
// APIAdminUpdateOAuthClient 更新 OAuth 应用
func (h *Handlers) APIAdminUpdateOAuthClient(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效 ID"})
return
}
var req service.OAuthClientInput
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
view, err := h.Settings.UpdateOAuthClient(uint(id), req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := "应用已更新"
if view.ClientSecret != "" {
msg = "应用已更新,新密钥仅显示一次,请立即保存"
}
c.JSON(http.StatusOK, gin.H{
"message": msg,
"client": view,
"oidc": h.Settings.OIDCConfigPublic(),
})
}
// APIAdminDeleteOAuthClient 删除 OAuth 应用
func (h *Handlers) APIAdminDeleteOAuthClient(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效 ID"})
return
}
if err := h.Settings.DeleteOAuthClient(uint(id)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "应用已删除",
"oidc": h.Settings.OIDCConfigPublic(),
})
}
// APIAdminTestMail 发送测试邮件
func (h *Handlers) APIAdminTestMail(c *gin.Context) {
var req struct {
To string `json:"to" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写收件邮箱"})
return
}
if err := service.ValidateEmail(req.To); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !h.Settings.MailReady() {
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrMailNotConfigured.Error()})
return
}
siteName := h.Settings.SiteBranding().Name
err := h.Mail.Send(service.NormalizeEmail(req.To), "邮件配置测试",
fmt.Sprintf("这是一封来自%s的测试邮件说明 SMTP 配置正常。", siteName))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "测试邮件已发送"})
}
// APIAdminFilterWords 读取敏感词配置
func (h *Handlers) APIAdminFilterWords(c *gin.Context) {
content, err := service.ReadFilterWordsFile(h.Cfg.FilterWordsPath())
@@ -458,42 +756,28 @@ func (h *Handlers) APIHotPosts(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"posts": items})
}
// APINotifications 最新动态通知
func (h *Handlers) APINotifications(c *gin.Context) {
posts, _, _ := h.Post.List(service.PostListQuery{Page: 1, Size: 8})
type notice struct {
ID uint `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
CreatedAt string `json:"created_at"`
// APITags 标签云(按使用次数聚合)
func (h *Handlers) APITags(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "40"))
tags, err := h.Post.PopularTags(limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
list := make([]notice, 0, len(posts))
for _, p := range posts {
list = append(list, notice{
ID: p.ID, Title: p.Title, Type: "post",
CreatedAt: p.CreatedAt.Format("01-02 15:04"),
})
}
c.JSON(http.StatusOK, gin.H{"notifications": list})
c.JSON(http.StatusOK, gin.H{"tags": tags})
}
// APIOnline 当前浏览统计
func (h *Handlers) APIOnline(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"count": h.Online.Count(),
"members": h.Online.CountMembers(),
"guests": h.Online.CountGuests(),
"users": h.Online.List(20),
})
}
// APIPresence 上报浏览心跳(会员与游客均可)
func (h *Handlers) APIPresence(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"count": h.Online.Count(),
"members": h.Online.CountMembers(),
"guests": h.Online.CountGuests(),
})
// APIRecentComments 最新公开评论
func (h *Handlers) APIRecentComments(c *gin.Context) {
list, err := h.Comment.ListRecentPublic(8)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if list == nil {
list = []service.RecentCommentItem{}
}
c.JSON(http.StatusOK, gin.H{"comments": list})
}
// APIFavorites 我的收藏
@@ -556,8 +840,18 @@ func (h *Handlers) APIPostRevisionDetail(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"revision": rev})
}
func (h *Handlers) APIPing(c *gin.Context) {
h.APIPresence(c)
// removeSiteUploadIfLocal 删除本站 uploads/site 下的旧资源文件
func (h *Handlers) removeSiteUploadIfLocal(urlPath string) {
urlPath = strings.TrimSpace(urlPath)
const prefix = "/uploads/site/"
if !strings.HasPrefix(urlPath, prefix) {
return
}
name := filepath.Base(urlPath)
if name == "" || name == "." || name == ".." {
return
}
_ = os.Remove(filepath.Join(h.Cfg.SiteUploadDir(), name))
}
func isClientLimitError(err error) bool {

View File

@@ -1,6 +1,7 @@
package handler
import (
"encoding/base64"
"fmt"
"net/http"
"strconv"
@@ -15,17 +16,21 @@ import (
// Handlers 聚合所有 HTTP 处理器
type Handlers struct {
Cfg *config.Config
Auth *service.AuthService
User *service.UserService
Board *service.BoardService
Post *service.PostService
Comment *service.CommentService
Backup *service.BackupService
Filter *service.SensitiveFilter
Limiter *service.RateLimiter
Online *service.OnlineService
Settings *service.ForumSettingsService
Cfg *config.Config
Auth *service.AuthService
User *service.UserService
Board *service.BoardService
Post *service.PostService
Comment *service.CommentService
Backup *service.BackupService
Filter *service.SensitiveFilter
Limiter *service.RateLimiter
Settings *service.ForumSettingsService
Captcha *service.CaptchaService
Mail *service.MailService
EmailCode *service.EmailCodeService
OIDC *service.OIDCService
Gitea *service.GiteaService
}
func (h *Handlers) setAuthCookie(c *gin.Context, token string) {
@@ -70,8 +75,9 @@ func (h *Handlers) pageData(c *gin.Context, title string, data gin.H) gin.H {
data = gin.H{}
}
data["Title"] = title
data["SiteName"] = "姜十三论坛"
data["SiteEN"] = "Jiang13 Forum"
brand := h.Settings.SiteBranding()
data["SiteName"] = brand.Name
data["SiteEN"] = brand.NameEN
if uid := h.currentUserID(c); uid > 0 {
data["CurrentUserID"] = uid
if u, err := h.User.GetByID(uid); err == nil {
@@ -170,22 +176,82 @@ func (h *Handlers) FavoritesPage(c *gin.Context) {
// --- API ---
func (h *Handlers) APIRegister(c *gin.Context) {
func (h *Handlers) APICaptcha(c *gin.Context) {
id, svg, err := h.Captcha.Generate()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "验证码生成失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"id": id,
"image": "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg)),
})
}
// APIRegisterConfig 注册页所需公开配置
func (h *Handlers) APIRegisterConfig(c *gin.Context) {
userCount := h.Auth.UserCount()
mailReady := h.Settings.MailReady()
c.JSON(http.StatusOK, gin.H{
"is_first_user": userCount == 0,
"mail_ready": mailReady,
"require_email_code": mailReady,
"register_open": userCount == 0 || mailReady,
})
}
// APISendRegisterEmailCode 发送注册邮箱验证码
func (h *Handlers) APISendRegisterEmailCode(c *gin.Context) {
var req struct {
Username string `json:"username" form:"username" binding:"required"`
Password string `json:"password" form:"password" binding:"required"`
Nickname string `json:"nickname" form:"nickname"`
Email string `json:"email" form:"email" binding:"required"`
}
if err := c.ShouldBind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
user, err := h.Auth.Register(req.Username, req.Password, req.Nickname)
if !h.Settings.MailReady() {
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrMailNotConfigured.Error()})
return
}
if err := h.EmailCode.SendRegisterCode(req.Email); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "验证码已发送"})
}
func (h *Handlers) APIRegister(c *gin.Context) {
var req struct {
Username string `json:"username" form:"username" binding:"required"`
Password string `json:"password" form:"password" binding:"required"`
Nickname string `json:"nickname" form:"nickname"`
Email string `json:"email" form:"email" binding:"required"`
EmailCode string `json:"email_code" form:"email_code"`
}
if err := c.ShouldBind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
userCount := h.Auth.UserCount()
mailReady := h.Settings.MailReady()
if userCount > 0 && !mailReady {
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrRegisterClosed.Error()})
return
}
if mailReady {
if !h.EmailCode.Verify(req.Email, req.EmailCode) {
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrEmailCodeInvalid.Error()})
return
}
}
user, err := h.Auth.Register(req.Username, req.Password, req.Nickname, req.Email)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
token, _, _ := h.Auth.Login(req.Username, req.Password)
token, _, _ := h.Auth.Login(req.Username, req.Password, c.ClientIP())
h.setAuthCookie(c, token)
c.JSON(http.StatusOK, gin.H{"message": "注册成功", "user_id": user.ID})
}
@@ -199,7 +265,7 @@ func (h *Handlers) APILogin(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
token, user, err := h.Auth.Login(req.Username, req.Password)
token, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP())
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
@@ -220,7 +286,11 @@ func (h *Handlers) APIUpdateProfile(c *gin.Context) {
return
}
user, _ := h.User.GetByID(h.currentUserID(c))
c.JSON(http.StatusOK, gin.H{"message": "昵称已更新", "user": user})
var userView any
if user != nil {
userView = user.ToSelf()
}
c.JSON(http.StatusOK, gin.H{"message": "昵称已更新", "user": userView})
}
func (h *Handlers) APIUpdatePassword(c *gin.Context) {
@@ -368,3 +438,14 @@ func (h *Handlers) APIDeleteComment(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"message": "评论已删除"})
}
func (h *Handlers) APIUpdateComment(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
content := c.PostForm("content")
saved, err := h.Comment.Update(h.currentUserID(c), uint(id), h.isAdmin(c), content)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "评论已更新", "content": saved})
}

224
handler/oidc.go Normal file
View File

@@ -0,0 +1,224 @@
package handler
import (
"encoding/base64"
"errors"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/middleware"
"git.iioio.com/freefire/jiang13-forum/service"
)
// OIDCDiscovery OpenID Provider 元数据
func (h *Handlers) OIDCDiscovery(c *gin.Context) {
if h.OIDC == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC 未启用"})
return
}
doc, err := h.OIDC.Discovery()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, doc)
}
// OIDCJWKS JSON Web Key Set
func (h *Handlers) OIDCJWKS(c *gin.Context) {
if h.OIDC == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC 未启用"})
return
}
doc, err := h.OIDC.JWKS()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, doc)
}
// OIDCAuthorize 授权端点:已登录则静默发码;未登录跳转论坛登录
func (h *Handlers) OIDCAuthorize(c *gin.Context) {
if h.OIDC == nil || !h.OIDC.Enabled() {
c.String(http.StatusServiceUnavailable, "OIDC 未配置,请在管理后台「系统设置 → OIDC / SSO」启用并创建 OAuth 应用")
return
}
req := service.AuthorizeRequest{
ClientID: c.Query("client_id"),
RedirectURI: c.Query("redirect_uri"),
ResponseType: c.Query("response_type"),
Scope: c.Query("scope"),
State: c.Query("state"),
Nonce: c.Query("nonce"),
CodeChallenge: c.Query("code_challenge"),
CodeChallengeMethod: c.Query("code_challenge_method"),
}
if err := h.OIDC.ValidateAuthorize(req); err != nil {
// redirect_uri 未通过校验时不能重定向,避免开放重定向
if errors.Is(err, service.ErrOIDCInvalidRedirect) || errors.Is(err, service.ErrOIDCInvalidClient) {
c.String(http.StatusBadRequest, err.Error())
return
}
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "invalid_request", err.Error())
return
}
uid := h.currentUserID(c)
if uid == 0 {
from := c.Request.URL.RequestURI()
c.Redirect(http.StatusFound, "/login?from="+url.QueryEscape(from))
return
}
callback, err := h.OIDC.IssueAuthCode(uid, req)
if err != nil {
if errors.Is(err, service.ErrOIDCUserBanned) {
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "access_denied", "账号已被禁言")
return
}
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "server_error", err.Error())
return
}
c.Redirect(http.StatusFound, callback)
}
func (h *Handlers) oidcErrorRedirect(c *gin.Context, redirectURI, state, code, desc string) {
if redirectURI == "" {
c.String(http.StatusBadRequest, desc)
return
}
u, err := url.Parse(redirectURI)
if err != nil {
c.String(http.StatusBadRequest, desc)
return
}
q := u.Query()
q.Set("error", code)
q.Set("error_description", desc)
if state != "" {
q.Set("state", state)
}
u.RawQuery = q.Encode()
c.Redirect(http.StatusFound, u.String())
}
// OIDCToken 令牌端点
func (h *Handlers) OIDCToken(c *gin.Context) {
if h.OIDC == nil || !h.OIDC.Enabled() {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "temporarily_unavailable", "error_description": "OIDC 未配置"})
return
}
clientID, clientSecret := c.PostForm("client_id"), c.PostForm("client_secret")
if clientID == "" && clientSecret == "" {
if id, secret, ok := parseBasicAuth(c.GetHeader("Authorization")); ok {
clientID, clientSecret = id, secret
}
}
resp, err := h.OIDC.ExchangeCode(service.TokenRequest{
GrantType: c.PostForm("grant_type"),
Code: c.PostForm("code"),
RedirectURI: c.PostForm("redirect_uri"),
ClientID: clientID,
ClientSecret: clientSecret,
CodeVerifier: c.PostForm("code_verifier"),
})
if err != nil {
status := http.StatusBadRequest
code := "invalid_grant"
switch {
case errors.Is(err, service.ErrOIDCInvalidClient):
status = http.StatusUnauthorized
code = "invalid_client"
case errors.Is(err, service.ErrOIDCInvalidRequest):
code = "invalid_request"
case errors.Is(err, service.ErrOIDCPKCEFailed):
code = "invalid_grant"
}
c.JSON(status, gin.H{"error": code, "error_description": err.Error()})
return
}
c.JSON(http.StatusOK, resp)
}
// OIDCUserInfo 用户信息端点
func (h *Handlers) OIDCUserInfo(c *gin.Context) {
if h.OIDC == nil || !h.OIDC.Enabled() {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC 未配置"})
return
}
token := extractBearer(c)
if token == "" {
c.Header("WWW-Authenticate", `Bearer`)
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
return
}
info, err := h.OIDC.UserInfo(token)
if err != nil {
c.Header("WWW-Authenticate", `Bearer error="invalid_token"`)
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
return
}
c.JSON(http.StatusOK, info)
}
// OIDCLogout RP-Initiated Logout清除论坛会话并可选跳回客户端
func (h *Handlers) OIDCLogout(c *gin.Context) {
postLogout := c.Query("post_logout_redirect_uri")
if postLogout == "" {
postLogout = c.PostForm("post_logout_redirect_uri")
}
state := c.Query("state")
if state == "" {
state = c.PostForm("state")
}
c.SetCookie(middleware.CookieName, "", -1, "/", "", false, true)
if h.OIDC == nil {
c.Redirect(http.StatusFound, "/")
return
}
target, err := h.OIDC.ResolveLogoutRedirect(postLogout, state)
if err != nil {
c.String(http.StatusBadRequest, err.Error())
return
}
c.Redirect(http.StatusFound, target)
}
func extractBearer(c *gin.Context) string {
auth := c.GetHeader("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
return strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
}
return c.Query("access_token")
}
func parseBasicAuth(header string) (user, pass string, ok bool) {
const prefix = "Basic "
if !strings.HasPrefix(header, prefix) {
return "", "", false
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(header[len(prefix):]))
if err != nil {
return "", "", false
}
parts := strings.SplitN(string(raw), ":", 2)
if len(parts) != 2 {
return "", "", false
}
// client_id / client_secret 可能被 URL 编码
uid, err1 := url.QueryUnescape(parts[0])
sec, err2 := url.QueryUnescape(parts[1])
if err1 != nil || err2 != nil {
return parts[0], parts[1], true
}
return uid, sec, true
}