初始化项目:基础设施 + 用户认证 + 后台管理系统 + AGPL 3.0 许可
This commit is contained in:
62
internal/common/cookie.go
Normal file
62
internal/common/cookie.go
Normal file
@ -0,0 +1,62 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"metazone.cc/metalab/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Cookie 名称常量
|
||||
const (
|
||||
CookieName = "mlb_token"
|
||||
RefreshCookieName = "mlb_refresh"
|
||||
RMCookieName = "mlb_rm" // 记住我标记,JS 可读
|
||||
)
|
||||
|
||||
// SetAuthCookies 设置 access + refresh Cookie
|
||||
// rememberMe=true → Cookie 持久化(30天),关浏览器后仍保持登录
|
||||
// rememberMe=false → Cookie 用 session 模式(maxAge=0),关浏览器即清除,但页面开启期间自动刷新
|
||||
func SetAuthCookies(c *gin.Context, accessToken, refreshToken string, rememberMe bool, cfg *config.Config) {
|
||||
secure := cfg.Server.Mode != "debug"
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
|
||||
setCookie(c, CookieName, accessToken, cfg.JWT.AccessExpire*60, secure)
|
||||
|
||||
if rememberMe {
|
||||
setCookie(c, RefreshCookieName, refreshToken, int(cfg.JWT.RememberExpire*3600), secure)
|
||||
setPlainCookie(c, RMCookieName, "1", int(cfg.JWT.RememberExpire*3600), secure)
|
||||
} else {
|
||||
// session cookie:关浏览器即清除,但页面开启期间自动刷新生效
|
||||
setCookie(c, RefreshCookieName, refreshToken, 0, secure)
|
||||
setPlainCookie(c, RMCookieName, "1", 0, secure)
|
||||
}
|
||||
}
|
||||
|
||||
// SetAccessCookie 仅刷新 access Cookie(refresh 续期调用)
|
||||
func SetAccessCookie(c *gin.Context, accessToken string, cfg *config.Config) {
|
||||
secure := cfg.Server.Mode != "debug"
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
setCookie(c, CookieName, accessToken, cfg.JWT.AccessExpire*60, secure)
|
||||
}
|
||||
|
||||
// ClearAuthCookies 清除所有认证 Cookie(logout 调用)
|
||||
func ClearAuthCookies(c *gin.Context, cfg *config.Config) {
|
||||
secure := cfg.Server.Mode != "debug"
|
||||
c.SetCookie(CookieName, "", -1, "/", "", secure, true)
|
||||
c.SetCookie(RefreshCookieName, "", -1, "/", "", secure, true)
|
||||
c.SetCookie(RMCookieName, "", -1, "/", "", secure, false)
|
||||
}
|
||||
|
||||
// setCookie 写入一个 HttpOnly Cookie
|
||||
func setCookie(c *gin.Context, name, value string, maxAge int, secure bool) {
|
||||
log.Printf("[setCookie] name=%s maxAge=%ds secure=%v", name, maxAge, secure)
|
||||
c.SetCookie(name, value, maxAge, "/", "", secure, true)
|
||||
}
|
||||
|
||||
// setPlainCookie 写入非 HttpOnly Cookie(JS 可读)
|
||||
func setPlainCookie(c *gin.Context, name, value string, maxAge int, secure bool) {
|
||||
c.SetCookie(name, value, maxAge, "/", "", secure, false)
|
||||
}
|
||||
20
internal/common/crypto.go
Normal file
20
internal/common/crypto.go
Normal file
@ -0,0 +1,20 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// HashPassword 使用 bcrypt 哈希密码
|
||||
func HashPassword(password string, cost int) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), cost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// CheckPassword 验证密码
|
||||
func CheckPassword(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
22
internal/common/errors.go
Normal file
22
internal/common/errors.go
Normal file
@ -0,0 +1,22 @@
|
||||
package common
|
||||
|
||||
import "errors"
|
||||
|
||||
// 认证相关错误哨兵(统一存放,避免 service 和 middleware 交叉引用)
|
||||
var (
|
||||
ErrEmailExists = errors.New("该邮箱已注册")
|
||||
ErrUsernameTaken = errors.New("该用户名已被占用")
|
||||
ErrInvalidCred = errors.New("邮箱或密码错误")
|
||||
ErrUserNotFound = errors.New("用户不存在")
|
||||
ErrUserBanned = errors.New("该账号已被封禁")
|
||||
ErrUserDeleted = errors.New("该账号已申请注销,登录即自动恢复")
|
||||
ErrUserLocked = errors.New("邮箱或密码错误")
|
||||
ErrWeakPassword = errors.New("密码需至少 8 位,且包含字母和数字")
|
||||
ErrTokenExpired = errors.New("登录已过期,请重新登录")
|
||||
ErrTokenInvalid = errors.New("无效的认证凭据")
|
||||
ErrTokenRevoked = errors.New("登录凭证已失效,请重新登录")
|
||||
ErrRateLimitAccount = errors.New("该账号登录尝试过于频繁")
|
||||
ErrRateLimitIP = errors.New("请求过于频繁,请稍后重试")
|
||||
ErrRateLimitRegisterIP = errors.New("注册请求过于频繁,请稍后重试")
|
||||
ErrPermissionDenied = errors.New("权限不足")
|
||||
)
|
||||
44
internal/common/helper.go
Normal file
44
internal/common/helper.go
Normal file
@ -0,0 +1,44 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// BuildPageData 构建页面模板数据,自动注入登录状态与 CSRF token
|
||||
func BuildPageData(c *gin.Context, extra gin.H) gin.H {
|
||||
data := gin.H{}
|
||||
for k, v := range extra {
|
||||
data[k] = v
|
||||
}
|
||||
if username, exists := c.Get("username"); exists {
|
||||
data["IsLoggedIn"] = true
|
||||
data["Username"] = username
|
||||
}
|
||||
// 注入 CSRF token(由 CSRF 中间件设置到上下文中)
|
||||
if token, exists := c.Get("csrf_token"); exists {
|
||||
data["CSRFToken"] = token
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// BuildAdminPageData 构建管理后台模板数据
|
||||
// 额外注入 UID、Role、CanManageUsers、CurrentPath 等权限信息
|
||||
func BuildAdminPageData(c *gin.Context, extra gin.H) gin.H {
|
||||
data := BuildPageData(c, extra)
|
||||
data["IsAdmin"] = true
|
||||
data["CurrentPath"] = c.Request.URL.Path
|
||||
|
||||
if uid, exists := c.Get("uid"); exists {
|
||||
data["UID"] = uid
|
||||
}
|
||||
if role, exists := c.Get("role"); exists {
|
||||
roleStr := role.(string)
|
||||
data["Role"] = roleStr
|
||||
data["CanManageUsers"] = model.HasMinRole(roleStr, model.RoleAdmin)
|
||||
data["CanManageSettings"] = model.HasMinRole(roleStr, model.RoleOwner)
|
||||
data["IsOwner"] = model.HasMinRole(roleStr, model.RoleOwner)
|
||||
}
|
||||
return data
|
||||
}
|
||||
51
internal/common/pagination.go
Normal file
51
internal/common/pagination.go
Normal file
@ -0,0 +1,51 @@
|
||||
package common
|
||||
|
||||
import "time"
|
||||
|
||||
// Pagination 分页请求参数
|
||||
type Pagination struct {
|
||||
Page int `form:"page" json:"page" binding:"min=1"`
|
||||
PageSize int `form:"page_size" json:"page_size" binding:"min=1,max=100"`
|
||||
}
|
||||
|
||||
// PaginatedResult 分页响应
|
||||
type PaginatedResult struct {
|
||||
Items interface{} `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// DefaultPagination 默认分页(未传参时使用)
|
||||
func (p *Pagination) DefaultPagination() {
|
||||
if p.Page < 1 {
|
||||
p.Page = 1
|
||||
}
|
||||
if p.PageSize < 1 || p.PageSize > 100 {
|
||||
p.PageSize = 20
|
||||
}
|
||||
}
|
||||
|
||||
// Offset 计算 SQL offset
|
||||
func (p *Pagination) Offset() int {
|
||||
return (p.Page - 1) * p.PageSize
|
||||
}
|
||||
|
||||
// NewPaginatedResult 构建分页响应
|
||||
func NewPaginatedResult(items interface{}, total int64, page, pageSize int) *PaginatedResult {
|
||||
totalPages := int(total) / pageSize
|
||||
if int(total)%pageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
return &PaginatedResult{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
TotalPages: totalPages,
|
||||
}
|
||||
}
|
||||
|
||||
// 固定时间格式,前后端统一
|
||||
const TimeFormat = time.RFC3339
|
||||
20
internal/common/response.go
Normal file
20
internal/common/response.go
Normal file
@ -0,0 +1,20 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// 统一 JSON 响应
|
||||
|
||||
func Ok(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
|
||||
}
|
||||
|
||||
func OkMessage(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": message})
|
||||
}
|
||||
|
||||
func Error(c *gin.Context, code int, message string) {
|
||||
c.JSON(code, gin.H{"success": false, "message": message})
|
||||
}
|
||||
51
internal/common/username.go
Normal file
51
internal/common/username.go
Normal file
@ -0,0 +1,51 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// usernameChars 随机用户名字符集(小写字母 + 数字)
|
||||
const usernameChars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
const usernameLen = 10
|
||||
|
||||
// UsernameChecker 用户名查重接口(避免 common 反向依赖 repository)
|
||||
type UsernameChecker interface {
|
||||
ExistsByUsername(username string) (bool, error)
|
||||
}
|
||||
|
||||
// GenerateUsername 生成不重复的随机 10 位用户名(小写字母 + 数字)
|
||||
// checker 提供去重查询,maxRetry 次重试后仍冲突则返回错误
|
||||
func GenerateUsername(checker UsernameChecker, maxRetry int) (string, error) {
|
||||
if maxRetry <= 0 {
|
||||
maxRetry = 20
|
||||
}
|
||||
for i := 0; i < maxRetry; i++ {
|
||||
username, err := randomUsername(usernameLen)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
exists, err := checker.ExistsByUsername(username)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
return username, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("生成用户名失败,请重试")
|
||||
}
|
||||
|
||||
// randomUsername 通过 crypto/rand 生成安全的随机字符串
|
||||
func randomUsername(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(usernameChars))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b[i] = usernameChars[idx.Int64()]
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
Reference in New Issue
Block a user