Files
openteam/server/internal/api/auth.go
T
SakurasanandClaude 4f92d7e0a4 渠道: 新增主页字段 + 表格展示 favicon
- Channel 新增 homepage 字段(渠道主页)
- 后端 /admin/channels/:id/favicon 代理抓取 {homepage}/favicon.ico, 内存缓存 1h;
  服务端抓取可解析 localhost/内网主页, 避免浏览器跨域
- 前端渠道表单加"渠道主页"输入; 表格新增主页列(favicon + 域名), 加载失败回退灰色地球图标
- mock 上游提供 /favicon.ico 演示

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 17:01:45 +08:00

220 lines
6.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package api 管理 API(/api/v1):认证、用户、密钥、用量、管理后台。
package api
import (
"encoding/json"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/app"
"github.com/openteam/server/internal/api/middleware"
"github.com/openteam/server/internal/pkg/resp"
"github.com/openteam/server/internal/store"
)
// Handler 聚合所有管理 API。
type Handler struct {
a *app.App
favMu sync.Mutex
favCache map[uint64]favEntry // 渠道 favicon 内存缓存
}
type favEntry struct {
data []byte
ct string
at time.Time
}
func NewHandler(a *app.App) *Handler {
return &Handler{a: a, favCache: map[uint64]favEntry{}}
}
// ---------------------------------------------------------------------------
// 认证
type registerReq struct {
Username string `json:"username" binding:"required,min=3,max=32"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8,max=72"`
InviteCode string `json:"invite_code"`
}
// Register POST /api/v1/auth/register
func (h *Handler) Register(c *gin.Context) {
var req registerReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error())
return
}
// 注册模式优先读系统配置(管理后台可改),缺省用环境配置
mode := h.a.Cfg.Auth.RegistrationMode
var modeCfgRaw string
h.a.DB.Model(&store.SystemConfig{}).Where("key = ?", "registration_mode").Pluck("value", &modeCfgRaw)
var modeCfg string
_ = json.Unmarshal([]byte(modeCfgRaw), &modeCfg)
if modeCfg == "open" || modeCfg == "invite" {
mode = modeCfg
}
if mode == "invite" {
var icRaw string
h.a.DB.Model(&store.SystemConfig{}).Where("key = ?", "invite_codes").Pluck("value", &icRaw)
var ic string
_ = json.Unmarshal([]byte(icRaw), &ic)
if req.InviteCode == "" || !strings.Contains(ic, req.InviteCode) {
resp.Fail(c, http.StatusForbidden, "valid invite code required")
return
}
}
var count int64
h.a.DB.Model(&store.User{}).Where("username = ? OR email = ?", req.Username, req.Email).Count(&count)
if count > 0 {
resp.Fail(c, http.StatusConflict, "username or email already exists")
return
}
hash, err := h.a.Hasher.HashPassword(req.Password)
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to hash password")
return
}
u := store.User{
Username: req.Username,
Email: strings.ToLower(req.Email),
PasswordHash: hash,
Role: store.RoleUser,
Balance: 5.0, // 新用户赠送体验余额(可通过 admin 调整)
Status: store.UserStatusActive,
}
if req.InviteCode != "" {
ic := req.InviteCode
u.InviteCode = &ic
}
if err := h.a.DB.Create(&u).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to create user")
return
}
resp.Created(c, gin.H{"id": u.ID, "username": u.Username})
}
type loginReq struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// Login POST /api/v1/auth/login — 返回 access token,refresh token 写入 HttpOnly Cookie。
func (h *Handler) Login(c *gin.Context) {
var req loginReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input")
return
}
var u store.User
err := h.a.DB.Where("username = ? OR email = ?", req.Username, strings.ToLower(req.Username)).First(&u).Error
if err != nil || u.Status != store.UserStatusActive {
resp.Fail(c, http.StatusUnauthorized, "invalid credentials")
return
}
ok, err := h.a.Hasher.VerifyPassword(u.PasswordHash, req.Password)
if err != nil || !ok {
resp.Fail(c, http.StatusUnauthorized, "invalid credentials")
return
}
now := time.Now()
h.a.DB.Model(&u).Update("last_login_at", now)
access, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "access")
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to issue token")
return
}
refresh, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "refresh")
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to issue token")
return
}
h.setRefreshCookie(c, refresh)
resp.OK(c, gin.H{"access_token": access, "expires_in": int(h.a.JWT.AccessTTL().Seconds()), "user": h.publicUser(&u)})
}
// Refresh POST /api/v1/auth/refresh — 用 refresh cookie 换新 access token。
func (h *Handler) Refresh(c *gin.Context) {
tok, err := c.Cookie(h.a.Cfg.JWT.CookieName)
if err != nil || tok == "" {
resp.Fail(c, http.StatusUnauthorized, "refresh token missing")
return
}
claims, err := h.a.JWT.Parse(tok)
if err != nil || claims.Subject != "refresh" {
resp.Fail(c, http.StatusUnauthorized, "invalid refresh token")
return
}
var u store.User
if err := h.a.DB.First(&u, claims.UserID).Error; err != nil || u.Status != store.UserStatusActive {
resp.Fail(c, http.StatusUnauthorized, "user not found or disabled")
return
}
access, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "access")
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to issue token")
return
}
resp.OK(c, gin.H{"access_token": access, "expires_in": int(h.a.JWT.AccessTTL().Seconds())})
}
// Logout POST /api/v1/auth/logout — 清除 refresh cookie。
func (h *Handler) Logout(c *gin.Context) {
h.setRefreshCookie(c, "")
resp.OK(c, gin.H{"ok": true})
}
// Me GET /api/v1/auth/me
func (h *Handler) Me(c *gin.Context) {
u := sessionUser(c)
resp.OK(c, gin.H{"user": h.publicUser(u)})
}
func (h *Handler) setRefreshCookie(c *gin.Context, value string) {
maxAge := int(h.a.JWT.RefreshTTL().Seconds())
if value == "" {
maxAge = -1
}
http.SetCookie(c.Writer, &http.Cookie{
Name: h.a.Cfg.JWT.CookieName,
Value: value,
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
Secure: h.a.Cfg.JWT.CookieSecure,
SameSite: http.SameSiteLaxMode,
Domain: h.a.Cfg.JWT.CookieDomain,
})
}
func (h *Handler) publicUser(u *store.User) gin.H {
return gin.H{
"id": u.ID,
"username": u.Username,
"email": u.Email,
"role": u.Role,
"balance": u.Balance,
"status": u.Status,
"created_at": u.CreatedAt,
}
}
func sessionUser(c *gin.Context) *store.User {
u, _ := c.Get(middleware.CtxSessionUser)
return u.(*store.User)
}
func userFromContext(c *gin.Context) (*store.User, bool) {
u, ok := c.Get(middleware.CtxSessionUser)
if !ok {
return nil, false
}
return u.(*store.User), true
}