Files
openteam/server/internal/api/auth.go
T
SakurasanandClaude 9324a782d5 Passkey: 账户设置绑定 + 免密登录(WebAuthn)
- 引入 go-webauthn, Passkey 表存凭据, challenge 会话内存存储(带过期)
- API: /webauthn/register|login begin/complete, /webauthn/passkeys 列表/删除
- 配置 OT_WEBAUTHN_RP_ID/RP_ORIGIN/RP_NAME;登录成功发 JWT+refresh cookie
- 前端 lib/webauthn(编解码+凭据序列化+安全上下文检测), 账户设置绑定区, 登录页免密按钮
- 需 HTTPS 或 localhost(安全上下文)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 02:00:08 +08:00

246 lines
7.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"
"time"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/api/middleware"
"github.com/openteam/server/internal/app"
"github.com/openteam/server/internal/passkey"
"github.com/openteam/server/internal/pkg/resp"
"github.com/openteam/server/internal/proxy"
"github.com/openteam/server/internal/store"
)
// Handler 聚合所有管理 API。
type Handler struct {
a *app.App
gw *proxy.Gateway
passkeys *passkey.Service
}
func NewHandler(a *app.App, gw *proxy.Gateway, pk *passkey.Service) *Handler {
return &Handler{a: a, gw: gw, passkeys: pk}
}
// ---------------------------------------------------------------------------
// 认证
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})
}
type changePasswordReq struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8,max=72"`
}
// ChangePassword POST /api/v1/auth/password — 修改密码。
func (h *Handler) ChangePassword(c *gin.Context) {
u := sessionUser(c)
var req changePasswordReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error())
return
}
ok, err := h.a.Hasher.VerifyPassword(u.PasswordHash, req.OldPassword)
if err != nil || !ok {
resp.Fail(c, http.StatusBadRequest, "旧密码不正确")
return
}
hash, err := h.a.Hasher.HashPassword(req.NewPassword)
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to hash password")
return
}
if err := h.a.DB.Model(&store.User{}).Where("id = ?", u.ID).Update("password_hash", hash).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to update password")
return
}
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,
"allowed_models": u.AllowedModels,
"denied_models": u.DeniedModels,
"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
}