feat: opaque session、安装/发帖 SSR 与最小 Admin 后台
浏览器登录改为 DB sessions(可吊销);敏感词与 OIDC PEM 入 settings; 落地安装向导、注册发帖与 /admin 仪表盘/板块/审核/设置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,18 +3,20 @@
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
CtxUserID = "user_id"
|
||||
CtxUsername = "username"
|
||||
CtxRole = "role"
|
||||
CookieName = "jiang13_token"
|
||||
CtxUserID = "user_id"
|
||||
CtxUsername = "username"
|
||||
CtxRole = "role"
|
||||
CtxSessionID = "session_id"
|
||||
CookieName = "jiang13_session"
|
||||
)
|
||||
|
||||
type AuthMiddleware struct {
|
||||
@@ -25,67 +27,81 @@ func NewAuthMiddleware(auth *services.AuthService) *AuthMiddleware {
|
||||
return &AuthMiddleware{auth: auth}
|
||||
}
|
||||
|
||||
// OptionalAuth 可选鉴权:有 token 则解析,无 token 不拦截。
|
||||
// 用户已删除或不存在时清除失效 cookie,避免前端误显示为已登录。
|
||||
// OptionalAuth 可选鉴权:有会话则加载用户;禁言/无效则清 Cookie
|
||||
func (m *AuthMiddleware) OptionalAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := extractToken(c)
|
||||
if token != "" {
|
||||
if claims, err := m.auth.ParseToken(token); err == nil {
|
||||
var user models.User
|
||||
if err := models.DB.Select("id", "username", "role").First(&user, claims.UserID).Error; err != nil {
|
||||
c.SetCookie(CookieName, "", -1, "/", "", false, true)
|
||||
} else {
|
||||
c.Set(CtxUserID, user.ID)
|
||||
c.Set(CtxUsername, user.Username)
|
||||
c.Set(CtxRole, user.Role)
|
||||
m.auth.TouchLastAccess(user.ID)
|
||||
}
|
||||
}
|
||||
sid := extractSessionID(c)
|
||||
if sid == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
user, sess, err := services.ResolveSession(sid)
|
||||
if err != nil || user == nil {
|
||||
ClearAuthCookie(c)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if user.Banned {
|
||||
services.RevokeUserSessions(user.ID)
|
||||
ClearAuthCookie(c)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.Set(CtxUserID, user.ID)
|
||||
c.Set(CtxUsername, user.Username)
|
||||
c.Set(CtxRole, user.Role)
|
||||
c.Set(CtxSessionID, sess.ID)
|
||||
m.auth.TouchLastAccess(user.ID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAuth 必须登录
|
||||
// RequireAuth 必须登录且未禁言
|
||||
func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := extractToken(c)
|
||||
if token == "" {
|
||||
sid := extractSessionID(c)
|
||||
if sid == "" {
|
||||
respondAuthRequired(c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
claims, err := m.auth.ParseToken(token)
|
||||
if err != nil {
|
||||
user, sess, err := services.ResolveSession(sid)
|
||||
if err != nil || user == nil {
|
||||
respondAuthExpired(c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
// 检查禁言
|
||||
var user models.User
|
||||
if err := models.DB.First(&user, claims.UserID).Error; err != nil || user.Banned {
|
||||
if user.Banned {
|
||||
services.RevokeUserSessions(user.ID)
|
||||
ClearAuthCookie(c)
|
||||
respondBanned(c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(CtxUserID, claims.UserID)
|
||||
c.Set(CtxUsername, claims.Username)
|
||||
c.Set(CtxRole, claims.Role)
|
||||
m.auth.TouchLastAccess(claims.UserID)
|
||||
c.Set(CtxUserID, user.ID)
|
||||
c.Set(CtxUsername, user.Username)
|
||||
c.Set(CtxRole, user.Role)
|
||||
c.Set(CtxSessionID, sess.ID)
|
||||
m.auth.TouchLastAccess(user.ID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin 必须管理员
|
||||
// RequireAdmin 必须管理员(依赖上游已跑 OptionalAuth/RequireAuth,role 来自 DB)
|
||||
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, ok := c.Get(CtxUserID)
|
||||
if !ok || uid == nil {
|
||||
respondAuthRequired(c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
role, exists := c.Get(CtxRole)
|
||||
if !exists || role != models.RoleAdmin {
|
||||
if isAPI(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||
} else {
|
||||
c.Redirect(http.StatusFound, "/admin/login")
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
c.Abort()
|
||||
return
|
||||
@@ -94,14 +110,13 @@ func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func extractToken(c *gin.Context) string {
|
||||
if auth := c.GetHeader("Authorization"); auth != "" {
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
return strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
func extractSessionID(c *gin.Context) string {
|
||||
if sid, err := c.Cookie(CookieName); err == nil && sid != "" {
|
||||
return sid
|
||||
}
|
||||
if token, err := c.Cookie(CookieName); err == nil {
|
||||
return token
|
||||
// 兼容清理旧 Cookie 名(一次性)
|
||||
if old, err := c.Cookie("jiang13_token"); err == nil && old != "" {
|
||||
ClearNamedCookie(c, "jiang13_token")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -112,7 +127,7 @@ func isAPI(c *gin.Context) bool {
|
||||
|
||||
func adminLoginPath(c *gin.Context) string {
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/admin") {
|
||||
return "/admin/login"
|
||||
return "/login?redirect=" + url.QueryEscape("/admin/dashboard")
|
||||
}
|
||||
return "/login"
|
||||
}
|
||||
@@ -122,11 +137,15 @@ func respondAuthRequired(c *gin.Context) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, adminLoginPath(c))
|
||||
redir := c.Request.URL.RequestURI()
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/admin") {
|
||||
redir = "/admin/dashboard"
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/login?redirect="+url.QueryEscape(redir))
|
||||
}
|
||||
|
||||
func respondAuthExpired(c *gin.Context) {
|
||||
c.SetCookie(CookieName, "", -1, "/", "", false, true)
|
||||
ClearAuthCookie(c)
|
||||
if isAPI(c) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期"})
|
||||
return
|
||||
@@ -139,10 +158,6 @@ func respondBanned(c *gin.Context) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁言"})
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/admin") {
|
||||
c.Redirect(http.StatusFound, "/admin/login?banned=1")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/login?banned=1")
|
||||
}
|
||||
|
||||
|
||||
43
modules/auth/cookie.go
Normal file
43
modules/auth/cookie.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ClearAuthCookie 清除登录会话 Cookie
|
||||
func ClearAuthCookie(c *gin.Context) {
|
||||
ClearNamedCookie(c, CookieName)
|
||||
ClearNamedCookie(c, "jiang13_token") // 清旧名
|
||||
}
|
||||
|
||||
// ClearNamedCookie 按名清除
|
||||
func ClearNamedCookie(c *gin.Context, name string) {
|
||||
secure := c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// SetSessionCookie 写入 opaque session id
|
||||
func SetSessionCookie(c *gin.Context, sessionID string) {
|
||||
secure := c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
MaxAge: services.SessionCookieMaxAge(),
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
181
modules/webctx/context.go
Normal file
181
modules/webctx/context.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package webctx
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/auth"
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webrender"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
csrfCookie = "jiang13_csrf"
|
||||
flashCookie = "jiang13_flash"
|
||||
)
|
||||
|
||||
// Context 浏览器请求上下文(对齐 Gitea context 的精简版)
|
||||
type Context struct {
|
||||
C *gin.Context
|
||||
Doer *models.User
|
||||
Secret string
|
||||
}
|
||||
|
||||
// New 从 Gin 构造;依赖 OptionalAuth / RequireAuth 已写入 user 信息时可再查库
|
||||
func New(c *gin.Context, secret string) *Context {
|
||||
ctx := &Context{C: c, Secret: secret}
|
||||
if id, ok := c.Get(auth.CtxUserID); ok {
|
||||
if uid, ok := id.(uint); ok && uid > 0 {
|
||||
var u models.User
|
||||
if err := models.DB.First(&u, uid).Error; err == nil {
|
||||
ctx.Doer = &u
|
||||
}
|
||||
}
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (ctx *Context) IsSigned() bool { return ctx.Doer != nil }
|
||||
func (ctx *Context) IsAdmin() bool {
|
||||
return ctx.Doer != nil && ctx.Doer.Role == models.RoleAdmin
|
||||
}
|
||||
func (ctx *Context) UserID() uint {
|
||||
if ctx.Doer == nil {
|
||||
return 0
|
||||
}
|
||||
return ctx.Doer.ID
|
||||
}
|
||||
|
||||
// SkipsModeration 管理员或认证用户免审
|
||||
func (ctx *Context) SkipsModeration() bool {
|
||||
return ctx.Doer != nil && ctx.Doer.SkipsModeration()
|
||||
}
|
||||
|
||||
// HTML 渲染命名模板
|
||||
func (ctx *Context) HTML(status int, name string, data any) {
|
||||
ctx.C.Header("Content-Type", "text/html; charset=utf-8")
|
||||
ctx.C.Status(status)
|
||||
if err := webrender.Execute(ctx.C.Writer, name, data); err != nil {
|
||||
ctx.C.String(http.StatusInternalServerError, "模板渲染失败")
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect 303 见其它 URI(PRG)
|
||||
func (ctx *Context) Redirect(url string) {
|
||||
ctx.C.Redirect(http.StatusSeeOther, url)
|
||||
}
|
||||
|
||||
// SetFlash 一次性提示(下一请求读取)
|
||||
func (ctx *Context) SetFlash(msg string) {
|
||||
v := base64.RawURLEncoding.EncodeToString([]byte(msg))
|
||||
ctx.writeCookie(flashCookie, v, 120, true)
|
||||
}
|
||||
|
||||
// TakeFlash 读取并清除
|
||||
func (ctx *Context) TakeFlash() string {
|
||||
v, err := ctx.C.Cookie(flashCookie)
|
||||
if err != nil || v == "" {
|
||||
return ""
|
||||
}
|
||||
ctx.writeCookie(flashCookie, "", -1, true)
|
||||
b, err := base64.RawURLEncoding.DecodeString(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// EnsureCSRF 保证 CSRF cookie,并返回表单 token
|
||||
func (ctx *Context) EnsureCSRF() string {
|
||||
if t, err := ctx.C.Cookie(csrfCookie); err == nil && t != "" && ctx.validCSRF(t) {
|
||||
return t
|
||||
}
|
||||
t := ctx.newCSRF()
|
||||
ctx.writeCookie(csrfCookie, t, int((12 * time.Hour).Seconds()), true)
|
||||
return t
|
||||
}
|
||||
|
||||
// CheckCSRF 校验表单 _csrf(或请求头 X-CSRF-Token,供上传 fetch)
|
||||
func (ctx *Context) CheckCSRF() bool {
|
||||
form := strings.TrimSpace(ctx.C.PostForm("_csrf"))
|
||||
if form == "" {
|
||||
form = strings.TrimSpace(ctx.C.GetHeader("X-CSRF-Token"))
|
||||
}
|
||||
cookie, _ := ctx.C.Cookie(csrfCookie)
|
||||
if form == "" || cookie == "" || form != cookie {
|
||||
return false
|
||||
}
|
||||
return ctx.validCSRF(form)
|
||||
}
|
||||
|
||||
func (ctx *Context) newCSRF() string {
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
mac := hmac.New(sha256.New, []byte(ctx.Secret))
|
||||
_, _ = mac.Write([]byte(ts))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))[:32]
|
||||
return ts + "." + sig
|
||||
}
|
||||
|
||||
func (ctx *Context) validCSRF(token string) bool {
|
||||
parts := strings.SplitN(token, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
ts, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if time.Since(time.Unix(ts, 0)) > 12*time.Hour {
|
||||
return false
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(ctx.Secret))
|
||||
_, _ = mac.Write([]byte(parts[0]))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))[:32]
|
||||
return hmac.Equal([]byte(sig), []byte(parts[1]))
|
||||
}
|
||||
|
||||
// SetLoginCookie 写入 opaque 会话 Cookie
|
||||
func (ctx *Context) SetLoginCookie(sessionID string) {
|
||||
auth.SetSessionCookie(ctx.C, sessionID)
|
||||
}
|
||||
|
||||
// ClearLoginCookie 退出并删 Cookie;若有 session id 则吊销
|
||||
func (ctx *Context) ClearLoginCookie() {
|
||||
if sid, err := ctx.C.Cookie(auth.CookieName); err == nil && sid != "" {
|
||||
services.DeleteSession(sid)
|
||||
}
|
||||
auth.ClearAuthCookie(ctx.C)
|
||||
}
|
||||
|
||||
func (ctx *Context) writeCookie(name, value string, maxAge int, httpOnly bool) {
|
||||
secure := requestIsHTTPS(ctx.C)
|
||||
http.SetCookie(ctx.C.Writer, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: httpOnly,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// requestIsHTTPS 直连 TLS 或反代 X-Forwarded-Proto
|
||||
func requestIsHTTPS(c *gin.Context) bool {
|
||||
if c.Request.TLS != nil {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
|
||||
// SafeHTML 供模板使用的类型别名说明(实际转换在 webrender FuncMap)
|
||||
type SafeHTML = template.HTML
|
||||
@@ -19,6 +19,7 @@ var (
|
||||
|
||||
func funcMap() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
|
||||
"sortURL": func(boardID uint, sort string) string {
|
||||
q := url.Values{}
|
||||
if sort != "" && sort != "latest" {
|
||||
@@ -50,13 +51,36 @@ func funcMap() template.FuncMap {
|
||||
}
|
||||
return path
|
||||
},
|
||||
"postURL": func(id uint) string {
|
||||
return fmt.Sprintf("/post/%d", id)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var parseGlobs = []string{
|
||||
"*.tmpl",
|
||||
"base/*.tmpl",
|
||||
"home/*.tmpl",
|
||||
"post/*.tmpl",
|
||||
"shared/*.tmpl",
|
||||
"status/*.tmpl",
|
||||
"auth/*.tmpl",
|
||||
"admin/*.tmpl",
|
||||
}
|
||||
|
||||
// Load 解析全部模板(进程内一次)
|
||||
func Load() (*template.Template, error) {
|
||||
loadOnce.Do(func() {
|
||||
tpl, loadErr = template.New("root").Funcs(funcMap()).ParseFS(apptemplates.FS, "*.tmpl")
|
||||
root := template.New("root").Funcs(funcMap())
|
||||
var err error
|
||||
for _, g := range parseGlobs {
|
||||
root, err = root.ParseFS(apptemplates.FS, g)
|
||||
if err != nil {
|
||||
loadErr = err
|
||||
return
|
||||
}
|
||||
}
|
||||
tpl = root
|
||||
})
|
||||
return tpl, loadErr
|
||||
}
|
||||
@@ -69,3 +93,10 @@ func Execute(w io.Writer, name string, data any) error {
|
||||
}
|
||||
return t.ExecuteTemplate(w, name, data)
|
||||
}
|
||||
|
||||
// ResetForTest 测试用重置
|
||||
func ResetForTest() {
|
||||
loadOnce = sync.Once{}
|
||||
tpl = nil
|
||||
loadErr = nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user