作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。 Co-authored-by: Cursor <cursoragent@cursor.com>
225 lines
6.2 KiB
Go
225 lines
6.2 KiB
Go
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
|
||
}
|