新增活跃会话管理与通行密钥、钱包登录
CI / test (push) Successful in 55s

This commit is contained in:
2026-07-30 12:23:05 +08:00
parent f1914880ac
commit 109c345e5e
49 changed files with 6468 additions and 336 deletions
+4 -3
View File
@@ -12,6 +12,7 @@ import (
"gorm.io/gorm"
"oci-portal/internal/model"
"oci-portal/internal/service"
)
// seedGatewayModel 直插启用渠道与模型缓存,绕过云同步。
@@ -29,7 +30,7 @@ func seedGatewayModel(t *testing.T, db *gorm.DB, name string) {
func TestAiGatewayKeyModelRestrict(t *testing.T) {
r, auth, _, db := newTestRouterDB(t)
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
token, _, err := auth.Login(context.Background(), "admin", "pass123", "", service.SessionMeta{ClientIP: "127.0.0.1"})
if err != nil {
t.Fatalf("login: %v", err)
}
@@ -125,7 +126,7 @@ func TestAiGatewayKeyModelRestrict(t *testing.T) {
func TestAiGatewayModelsFilteredByKey(t *testing.T) {
r, auth, _, db := newTestRouterDB(t)
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
token, _, err := auth.Login(context.Background(), "admin", "pass123", "", service.SessionMeta{ClientIP: "127.0.0.1"})
if err != nil {
t.Fatalf("login: %v", err)
}
@@ -172,7 +173,7 @@ func TestAiGatewayModelsFilteredByKey(t *testing.T) {
func TestAiKeyModelsUpdateRoundTrip(t *testing.T) {
r, auth, _, _ := newTestRouterDB(t)
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
token, _, err := auth.Login(context.Background(), "admin", "pass123", "", service.SessionMeta{ClientIP: "127.0.0.1"})
if err != nil {
t.Fatalf("login: %v", err)
}
+5 -5
View File
@@ -3,7 +3,6 @@ package api
import (
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -34,7 +33,9 @@ type loginRequest struct {
// @Param body body loginRequest true "登录凭据"
// @Success 200 {object} tokenResponse "token 与 expiresAt"
// @Failure 401 {object} errorResponse "凭据错误"
// @Failure 403 {object} errorResponse "密码登录已禁用"
// @Failure 428 {object} totpRequiredResponse "需要两步验证码(totpRequired=true)"
// @Failure 429 {object} errorResponse "登录守卫锁定(无 code);全局 IP 限流时 code=RateLimited"
// @Router /api/v1/auth/login [post]
func (h *authHandler) login(c *gin.Context) {
var req loginRequest
@@ -43,7 +44,7 @@ func (h *authHandler) login(c *gin.Context) {
return
}
start := time.Now()
token, expires, err := h.svc.Login(c.Request.Context(), req.Username, req.Password, requestIP(c), req.TotpCode)
token, expires, err := h.svc.Login(c.Request.Context(), req.Username, req.Password, req.TotpCode, sessionMetaOf(c))
if errors.Is(err, service.ErrTotpRequired) {
// 密码已通过,引导前端弹出二次验证输入;不算失败不留痕
c.JSON(http.StatusPreconditionRequired, gin.H{"error": err.Error(), "totpRequired": true})
@@ -80,9 +81,8 @@ func (h *authHandler) login(c *gin.Context) {
// @Security BearerAuth
// @Router /api/v1/auth/logout [post]
func (h *authHandler) logout(c *gin.Context) {
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
if ok && token != "" {
h.svc.Logout(token)
if token := bearerToken(c); token != "" {
h.svc.Logout(c.Request.Context(), token)
}
c.Status(http.StatusNoContent)
}
+51 -27
View File
@@ -16,9 +16,11 @@ import (
// authxHandler 处理两步验证与外部身份(OAuth)接口。
type authxHandler struct {
auth *service.AuthService
oauth *service.OAuthService
logs *service.SystemLogService
auth *service.AuthService
oauth *service.OAuthService
passkeys *service.PasskeyService
wallets *service.WalletService
logs *service.SystemLogService
}
// ---- TOTP(JWT 组内) ----
@@ -66,7 +68,7 @@ func (h *authxHandler) totpSetup(c *gin.Context) {
// @Tags 认证
// @Param body body object true "{code: 6 位验证码}"
// @Success 200 {object} tokenResponse "已启用,返回换发的新 token"
// @Success 204 "已启用但新 token 签发失败,需重新登录"
// @Failure 401 {object} errorResponse "请求处理期间会话已撤销"
// @Security BearerAuth
// @Router /api/v1/auth/totp/activate [post]
func (h *authxHandler) totpActivate(c *gin.Context) {
@@ -77,7 +79,9 @@ func (h *authxHandler) totpActivate(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.auth.ActivateTotp(c.Request.Context(), c.GetString(usernameKey), req.Code)
token, expires, err := h.auth.ActivateTotp(
c.Request.Context(), c.GetString(usernameKey), req.Code,
bearerToken(c), sessionMetaOf(c), tokenProofOf(c))
if err != nil {
if errors.Is(err, service.ErrTotpInvalid) || errors.Is(err, service.ErrTotpNotSetup) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -86,7 +90,7 @@ func (h *authxHandler) totpActivate(c *gin.Context) {
respondError(c, err)
return
}
h.respondFreshToken(c)
c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires})
}
// totpDisable 停用两步验证;需当前验证码或登录密码任一确认。
@@ -95,7 +99,7 @@ func (h *authxHandler) totpActivate(c *gin.Context) {
// @Tags 认证
// @Param body body object true "{password 或 code 任一确认}"
// @Success 200 {object} tokenResponse "已停用,返回换发的新 token"
// @Success 204 "已停用但新 token 签发失败,需重新登录"
// @Failure 401 {object} errorResponse "请求处理期间会话已撤销"
// @Security BearerAuth
// @Router /api/v1/auth/totp/disable [post]
func (h *authxHandler) totpDisable(c *gin.Context) {
@@ -107,7 +111,9 @@ func (h *authxHandler) totpDisable(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.auth.DisableTotp(c.Request.Context(), c.GetString(usernameKey), req.Password, req.Code)
token, expires, err := h.auth.DisableTotp(
c.Request.Context(), c.GetString(usernameKey), req.Password, req.Code,
bearerToken(c), sessionMetaOf(c), tokenProofOf(c))
if err != nil {
if errors.Is(err, service.ErrTotpConfirm) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -116,13 +122,14 @@ func (h *authxHandler) totpDisable(c *gin.Context) {
respondError(c, err)
return
}
h.respondFreshToken(c)
c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires})
}
// respondFreshToken 敏感变更后为操作者签发新令牌返回(版本已递增,
// 旧令牌全部失效);签发失败降级 204,前端按会话失效走重新登录
func (h *authxHandler) respondFreshToken(c *gin.Context) {
token, expires, err := h.auth.IssueToken(c.Request.Context(), c.GetString(usernameKey))
// 旧令牌全部失效);旧令牌的会话行接续到新令牌,列表中当前设备保持连续
// 签发失败降级 204,前端按会话失效走重新登录。
func respondFreshToken(c *gin.Context, auth *service.AuthService) {
token, expires, err := auth.RenewToken(c.Request.Context(), c.GetString(usernameKey), bearerToken(c), sessionMetaOf(c))
if err != nil {
c.Status(http.StatusNoContent)
return
@@ -138,7 +145,7 @@ func (h *authxHandler) respondFreshToken(c *gin.Context) {
// @Security BearerAuth
// @Router /api/v1/auth/revoke-sessions [post]
func (h *authxHandler) revokeSessions(c *gin.Context) {
token, expires, err := h.auth.RevokeSessions(c.Request.Context(), c.GetString(usernameKey))
token, expires, err := h.auth.RevokeSessions(c.Request.Context(), c.GetString(usernameKey), bearerToken(c), sessionMetaOf(c), tokenProofOf(c))
if err != nil {
respondError(c, err)
return
@@ -183,7 +190,7 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
finalName, err := h.auth.UpdateCredentials(c.Request.Context(), c.GetString(usernameKey), req)
finalName, err := h.auth.UpdateCredentials(c.Request.Context(), c.GetString(usernameKey), req, tokenProofOf(c))
if errors.Is(err, service.ErrCredentialConfirm) {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
@@ -197,7 +204,7 @@ func (h *authxHandler) updateCredentials(c *gin.Context) {
return
}
c.Set(usernameKey, finalName)
h.respondFreshToken(c)
respondFreshToken(c, h.auth)
}
// updatePasswordLogin 保存密码登录禁用开关;开启需至少绑定一个外部身份。
@@ -218,7 +225,7 @@ func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.auth.SetPasswordLoginDisabled(c.Request.Context(), c.GetString(usernameKey), *req.Disabled)
err := h.auth.SetPasswordLoginDisabled(c.Request.Context(), c.GetString(usernameKey), *req.Disabled, tokenProofOf(c))
if errors.Is(err, service.ErrNeedIdentity) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
@@ -227,26 +234,31 @@ func (h *authxHandler) updatePasswordLogin(c *gin.Context) {
respondError(c, err)
return
}
h.respondFreshToken(c)
respondFreshToken(c, h.auth)
}
// ---- OAuth ----
// oauthProviders 返回已配置的 provider 列表(公开,登录页展示按钮),
// 并附带密码登录禁用开关:开启且存在可用外部身份时,登录页隐藏密码表单;
// provider 清空时不下发禁用,保证界面始终留有登录入口(后端 Login 仍会拒绝)。
// 并附带密码登录禁用开关与通行密钥登录可用性:开关开启且存在可用外部身份时,
// 登录页隐藏密码表单;provider 清空时不下发禁用,保证界面始终留有登录入口
// (后端 Login 仍会拒绝)。
//
// @Summary 外部登录 provider 列表
// @Tags 认证
// @Success 200 {object} oauthProvidersResponse "providerspasswordLoginDisabled"
// @Success 200 {object} oauthProvidersResponse "providerspasswordLoginDisabled 与 passkeyLogin"
// @Router /api/v1/auth/oauth/providers [get]
func (h *authxHandler) oauthProviders(c *gin.Context) {
providers := h.oauth.Providers(c.Request.Context())
passkey := h.passkeys != nil && h.passkeys.HasAny(c.Request.Context())
wallet := h.wallets != nil && h.wallets.HasAny(c.Request.Context())
disabled := false
if off, err := h.auth.PasswordLoginDisabled(c.Request.Context()); err == nil && off && len(providers) > 0 {
// 有任一免密入口(OAuth/通行密钥/钱包)才如实下发禁用状态;全部缺席时隐藏,
// 保证登录页始终留有入口(后端 Login 仍会拒绝)
if off, err := h.auth.PasswordLoginDisabled(c.Request.Context()); err == nil && off && (len(providers) > 0 || passkey || wallet) {
disabled = true
}
c.JSON(http.StatusOK, gin.H{"providers": providers, "passwordLoginDisabled": disabled})
c.JSON(http.StatusOK, gin.H{"providers": providers, "passwordLoginDisabled": disabled, "passkeyLogin": passkey, "walletLogin": wallet})
}
// oauthAuthorize 返回授权跳转 URL;mode=bind 需有效 JWT(绑定到当前账号),login 公开。
@@ -256,6 +268,9 @@ func (h *authxHandler) oauthProviders(c *gin.Context) {
// @Param provider path string true "oidc / github"
// @Param mode query string false "bind=绑定当前账号(需 Bearer),缺省登录"
// @Success 200 {object} urlResponse "url"
// @Failure 401 {object} errorResponse "bind 模式未携带有效 Bearer"
// @Failure 404 {object} errorResponse "未知 provider"
// @Failure 409 {object} errorResponse "provider 未配置 / 面板地址缺失 / 已禁用"
// @Router /api/v1/auth/oauth/{provider}/authorize [get]
func (h *authxHandler) oauthAuthorize(c *gin.Context) {
provider := c.Param("provider")
@@ -272,7 +287,7 @@ func (h *authxHandler) oauthAuthorize(c *gin.Context) {
}
mode, username = "bind", name
}
authURL, err := h.oauth.AuthorizeURL(c.Request.Context(), provider, mode, username)
authURL, err := h.oauth.AuthorizeURL(c.Request.Context(), provider, mode, username, bearerToken(c))
if err != nil {
if errors.Is(err, service.ErrOAuthNotConfigured) || errors.Is(err, service.ErrOAuthNoAppURL) || errors.Is(err, service.ErrOAuthDisabled) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
@@ -310,7 +325,7 @@ func (h *authxHandler) oauthCallback(c *gin.Context) {
start := time.Now()
provider := c.Param("provider")
token, username, mode, err := h.oauth.HandleCallback(
c.Request.Context(), provider, c.Query("state"), c.Query("code"))
c.Request.Context(), provider, c.Query("state"), c.Query("code"), sessionMetaOf(c))
if err != nil {
h.recordOauth(c, username, http.StatusUnauthorized, oauthErrText(err), start)
target := "/login"
@@ -390,7 +405,7 @@ func (h *authxHandler) unbindIdentity(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.oauth.Unbind(c.Request.Context(), c.GetString(usernameKey), uint(id)); err != nil {
if err := h.oauth.Unbind(c.Request.Context(), c.GetString(usernameKey), uint(id), tokenProofOf(c)); err != nil {
if errors.Is(err, service.ErrLastIdentity) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
@@ -398,7 +413,7 @@ func (h *authxHandler) unbindIdentity(c *gin.Context) {
respondError(c, err)
return
}
h.respondFreshToken(c)
respondFreshToken(c, h.auth)
}
// ---- OAuth provider 设置(JWT 组内) ----
@@ -426,6 +441,9 @@ func (h *authxHandler) getOAuthSettings(c *gin.Context, settings *service.Settin
// @Tags 设置
// @Param body body service.UpdateOAuthInput true "出现的字段才会被更新"
// @Success 200 {object} service.OAuthProvidersView
// @Failure 400 {object} errorResponse "请求体非法"
// @Failure 401 {object} errorResponse "请求处理期间会话已撤销"
// @Failure 409 {object} errorResponse "密码登录禁用期间,该变更将移除最后可用登录方式"
// @Security BearerAuth
// @Router /api/v1/settings/oauth [patch]
func (h *authxHandler) updateOAuthSettings(c *gin.Context, settings *service.SettingService) {
@@ -434,7 +452,13 @@ func (h *authxHandler) updateOAuthSettings(c *gin.Context, settings *service.Set
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := settings.UpdateOAuth(c.Request.Context(), req); err != nil {
err := settings.UpdateOAuthAuthenticated(
c.Request.Context(), req, h.auth, c.GetString(usernameKey), tokenProofOf(c))
if err != nil {
if errors.Is(err, service.ErrProviderLastLogin) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
respondError(c, err)
return
}
+1 -1
View File
@@ -26,7 +26,7 @@ func newIconTestRouter(t *testing.T) (*gin.Engine, string, uint, *service.System
t.Helper()
router, auth, logs, db := newTestRouterDB(t)
id := seedIconConfig(t, db)
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
token, _, err := auth.Login(context.Background(), "admin", "pass123", "", service.SessionMeta{ClientIP: "127.0.0.1"})
if err != nil {
t.Fatalf("login: %v", err)
}
+33 -2
View File
@@ -2,6 +2,7 @@ package api
import (
"net/http"
"sync"
"github.com/gin-gonic/gin"
@@ -202,6 +203,7 @@ func (h *ociConfigHandler) updateInstance(c *gin.Context) {
// @Param id path int true "配置 ID"
// @Param instanceId path string true "instanceId"
// @Success 204 "无内容"
// @Failure 409 {object} errorResponse "该实例的生命周期操作正在处理中"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances/{instanceId} [delete]
func (h *ociConfigHandler) terminateInstance(c *gin.Context) {
@@ -209,20 +211,43 @@ func (h *ociConfigHandler) terminateInstance(c *gin.Context) {
if !ok {
return
}
instanceID := c.Param("instanceId")
if !beginInstanceLifecycleOperation(instanceID) {
respondInstanceLifecycleBusy(c)
return
}
defer endInstanceLifecycleOperation(instanceID)
preserve := c.Query("preserveBootVolume") == "true"
if err := h.svc.TerminateInstance(c.Request.Context(), id, c.Query("region"), c.Param("instanceId"), preserve); err != nil {
if err := h.svc.TerminateInstance(c.Request.Context(), id, c.Query("region"), instanceID, preserve); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// instanceLifecycleGuard 按实例 OCID 互斥 HTTP 请求飞行期内的终止与电源操作。
var instanceLifecycleGuard sync.Map
func beginInstanceLifecycleOperation(instanceID string) bool {
_, busy := instanceLifecycleGuard.LoadOrStore(instanceID, struct{}{})
return !busy
}
func endInstanceLifecycleOperation(instanceID string) {
instanceLifecycleGuard.Delete(instanceID)
}
func respondInstanceLifecycleBusy(c *gin.Context) {
c.JSON(http.StatusConflict, gin.H{"error": "该实例的生命周期操作正在处理中"})
}
// @Summary 实例电源操作(启停/重启)
// @Tags 计算
// @Param id path int true "配置 ID"
// @Param instanceId path string true "instanceId"
// @Param body body instanceActionRequest true "请求体"
// @Success 200 {object} oci.Instance
// @Failure 409 {object} errorResponse "该实例的生命周期操作正在处理中"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/instances/{instanceId}/action [post]
func (h *ociConfigHandler) instanceAction(c *gin.Context) {
@@ -235,7 +260,13 @@ func (h *ociConfigHandler) instanceAction(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
instance, err := h.svc.InstanceAction(c.Request.Context(), id, req.Region, c.Param("instanceId"), req.Action)
instanceID := c.Param("instanceId")
if !beginInstanceLifecycleOperation(instanceID) {
respondInstanceLifecycleBusy(c)
return
}
defer endInstanceLifecycleOperation(instanceID)
instance, err := h.svc.InstanceAction(c.Request.Context(), id, req.Region, instanceID, req.Action)
if err != nil {
respondError(c, err)
return
+78
View File
@@ -0,0 +1,78 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/gin-gonic/gin"
)
func countLifecycleWinners(instanceID string, workers int) int {
start := make(chan struct{})
results := make(chan bool, workers)
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
<-start
results <- beginInstanceLifecycleOperation(instanceID)
}()
}
close(start)
wg.Wait()
close(results)
winners := 0
for won := range results {
if won {
winners++
}
}
return winners
}
func TestBeginInstanceLifecycleOperationSingleFlight(t *testing.T) {
instanceID := "ocid1.instance.oc1..single-flight"
instanceLifecycleGuard.Delete(instanceID)
t.Cleanup(func() { instanceLifecycleGuard.Delete(instanceID) })
if got, want := countLifecycleWinners(instanceID, 16), 1; got != want {
t.Errorf("winners = %d, want %d", got, want)
}
}
func instanceOperationRouter() *gin.Engine {
h := &ociConfigHandler{}
r := gin.New()
r.DELETE("/oci-configs/:id/instances/:instanceId", h.terminateInstance)
r.POST("/oci-configs/:id/instances/:instanceId/action", h.instanceAction)
return r
}
func TestInstanceLifecycleOperationRejectsLaterRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
instanceID := "ocid1.instance.oc1..same-physical-instance"
instanceLifecycleGuard.Store(instanceID, struct{}{})
t.Cleanup(func() { instanceLifecycleGuard.Delete(instanceID) })
r := instanceOperationRouter()
tests := []struct {
name, method, path, body string
}{
{"action先占位时跨配置terminate被拒", http.MethodDelete, "/oci-configs/2/instances/" + instanceID, ""},
{"terminate先占位时action被拒", http.MethodPost, "/oci-configs/1/instances/" + instanceID + "/action", `{"action":"STOP"}`},
{"action先占位时第二个action被拒", http.MethodPost, "/oci-configs/3/instances/" + instanceID + "/action", `{"action":"RESET"}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if got, want := w.Code, http.StatusConflict; got != want {
t.Errorf("status = %d, want %d, body %s", got, want, w.Body.String())
}
})
}
}
+30 -8
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
@@ -12,11 +13,17 @@ import (
"oci-portal/internal/service"
)
// usernameKey 是鉴权通过后写入 gin.Context 的用户名键
const usernameKey = "username"
// usernameKey 是鉴权通过后写入 gin.Context 的用户名键;
// tokenVerKey/tokenJtiKey 是鉴权时观察到的令牌快照,敏感事务提交前复核。
const (
usernameKey = "username"
tokenVerKey = "tokenVer"
tokenJtiKey = "tokenJti"
)
// RequireAuth 校验 Authorization: Bearer 令牌(签名、有效期与令牌版本),
// 通过后把用户名放进上下文。
// 通过后把用户名与令牌快照(版本/jti)放进上下文——快照供敏感事务在
// 行锁下复核,防「请求挂起期间令牌被撤销,恢复后仍完成变更」的在途绕过。
func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
return func(c *gin.Context) {
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
@@ -24,16 +31,25 @@ func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
return
}
username, err := auth.ParseToken(c.Request.Context(), token)
username, proof, err := auth.ParseTokenProof(c.Request.Context(), token)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
return
}
c.Set(usernameKey, username)
c.Set(tokenVerKey, proof.Ver)
c.Set(tokenJtiKey, proof.Jti)
c.Next()
}
}
// tokenProofOf 取出鉴权时的令牌快照,交给敏感 service 事务复核。
func tokenProofOf(c *gin.Context) service.TokenProof {
v, _ := c.Get(tokenVerKey)
ver, _ := v.(uint)
return service.TokenProof{Ver: ver, Jti: c.GetString(tokenJtiKey)}
}
// bodyLimit 给请求体套上限(S-03):超限读取由 MaxBytesReader 截断报错,
// 绑定失败返回 400;webhook 入口另有独立 256KiB 自限,不经此中间件。
func bodyLimit(n int64) gin.HandlerFunc {
@@ -45,7 +61,7 @@ func bodyLimit(n int64) gin.HandlerFunc {
}
}
// systemLogMiddleware 把写请求(POST/PUT/DELETE)异步记入系统日志,只读请求跳过。
// systemLogMiddleware 把写请求(POST/PUT/PATCH/DELETE)异步记入系统日志,只读请求跳过。
// 只记方法、路径等元数据,绝不读请求体——请求体可能含私钥 / 口令等敏感数据。
func systemLogMiddleware(logs *service.SystemLogService) gin.HandlerFunc {
return func(c *gin.Context) {
@@ -101,17 +117,23 @@ func errMsgOf(status int, head []byte) string {
return truncateLogField(body.Error, 256)
}
// truncateLogField 按字节截断留痕字段,防超长 UA / 错误串撑爆列宽
// truncateLogField 按字节截断留痕字段,防超长 UA / 错误串撑爆列宽;
// 截断点回退到 rune 边界,避免产生非法 UTF-8(严格 MySQL/PG 会拒写整行)。
func truncateLogField(s string, max int) string {
if len(s) <= max {
return s
}
for max > 0 && !utf8.RuneStart(s[max]) {
max--
}
return s[:max]
}
// isWriteMethod 判断是否需要留痕的写方法
// isWriteMethod 判断是否需要留痕的写方法;PATCH 承载 OAuth / 安全设置等
// 敏感配置变更,必须与 POST/PUT/DELETE 一样进入审计。
func isWriteMethod(m string) bool {
return m == http.MethodPost || m == http.MethodPut || m == http.MethodDelete
return m == http.MethodPost || m == http.MethodPut ||
m == http.MethodPatch || m == http.MethodDelete
}
// requestPath 优先取注册的路由模板(含 :id 占位符,避免把敏感 query 记入日志),
+55
View File
@@ -0,0 +1,55 @@
package api
import (
"net/http"
"strings"
"testing"
"unicode/utf8"
)
// PATCH 承载 OAuth / 安全设置变更,必须纳入审计;只读方法不留痕。
func TestIsWriteMethod(t *testing.T) {
tests := []struct {
method string
want bool
}{
{http.MethodPost, true},
{http.MethodPut, true},
{http.MethodPatch, true},
{http.MethodDelete, true},
{http.MethodGet, false},
{http.MethodHead, false},
}
for _, tt := range tests {
if got := isWriteMethod(tt.method); got != tt.want {
t.Errorf("isWriteMethod(%s) = %v, want %v", tt.method, got, tt.want)
}
}
}
func TestTruncateLogField(t *testing.T) {
tests := []struct {
name string
in string
max int
want string
}{
{name: "短串原样", in: "abc", max: 10, want: "abc"},
{name: "ASCII 截断", in: "abcdef", max: 4, want: "abcd"},
{name: "中文边界回退", in: "中文日志", max: 4, want: "中"},
{name: "恰好落在边界", in: "中文", max: 3, want: "中"},
{name: "emoji 拦腰", in: "a😀b", max: 3, want: "a"},
{name: "超长 UA 不产非法序列", in: strings.Repeat("界", 200), max: 255, want: strings.Repeat("界", 85)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := truncateLogField(tt.in, tt.max)
if got != tt.want {
t.Errorf("truncateLogField(%q, %d) = %q, want %q", tt.in, tt.max, got, tt.want)
}
if !utf8.ValidString(got) {
t.Errorf("result %q is not valid UTF-8", got)
}
})
}
}
+6
View File
@@ -277,6 +277,12 @@ func respondError(c *gin.Context, err error) {
c.JSON(http.StatusNotFound, gin.H{"error": "资源不存在"})
return
}
// 请求处理期间令牌已失效(撤销/注销/并发敏感操作后到):按未认证处理,
// 前端「发送时令牌==当前令牌」快照决定是否登出
if errors.Is(err, service.ErrTokenStale) {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
id := newRequestID()
log.Printf("[ERR %s] %s %s: %v", id, c.Request.Method, requestPath(c), err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器内部错误", "requestId": id})
+196
View File
@@ -0,0 +1,196 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"oci-portal/internal/model"
"oci-portal/internal/service"
)
// passkeyHandler 处理通行密钥(WebAuthn)注册、登录与凭据管理接口。
type passkeyHandler struct {
svc *service.PasskeyService
auth *service.AuthService
logs *service.SystemLogService
}
// registerBegin 生成注册 options;sessionId 需原样带回 finish。
//
// @Summary 发起通行密钥注册
// @Tags 认证
// @Success 200 {object} passkeyOptionsResponse "sessionId 与 WebAuthn options"
// @Failure 409 {object} errorResponse "面板地址未设置或数量达上限"
// @Security BearerAuth
// @Router /api/v1/auth/passkey/register/begin [post]
func (h *passkeyHandler) registerBegin(c *gin.Context) {
sid, opts, err := h.svc.BeginRegister(c.Request.Context(), c.GetString(usernameKey))
if err != nil {
if errors.Is(err, service.ErrPasskeyNoAppURL) || errors.Is(err, service.ErrPasskeyLimit) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"sessionId": sid, "options": opts})
}
// passkeyFinishRequest 是注册/登录 finish 的请求体;credential 为浏览器
// navigator.credentials 返回的 WebAuthn JSON,原样透传后端解析。
type passkeyFinishRequest struct {
SessionId string `json:"sessionId" binding:"required,max=64"`
Name string `json:"name" binding:"max=64"`
Credential json.RawMessage `json:"credential" binding:"required"`
}
// registerFinish 校验注册响应并保存凭据;属敏感变更,换发新令牌。
//
// @Summary 完成通行密钥注册
// @Tags 认证
// @Param body body passkeyFinishRequest true "sessionId、名称与凭据响应"
// @Success 200 {object} tokenResponse "已注册,返回换发的新 token"
// @Success 204 "已注册但新 token 签发失败,需重新登录"
// @Failure 400 {object} errorResponse "请求体非法、会话过期或凭据校验失败"
// @Security BearerAuth
// @Router /api/v1/auth/passkey/register/finish [post]
func (h *passkeyHandler) registerFinish(c *gin.Context) {
var req passkeyFinishRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.svc.FinishRegister(c.Request.Context(), c.GetString(usernameKey),
req.SessionId, req.Name, bytes.NewReader(req.Credential), tokenProofOf(c))
if err != nil {
if errors.Is(err, service.ErrPasskeySession) || errors.Is(err, service.ErrPasskeyVerify) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
respondError(c, err)
return
}
respondFreshToken(c, h.auth)
}
// list 列出当前账号的通行密钥。
//
// @Summary 通行密钥列表
// @Tags 认证
// @Success 200 {object} itemsResponse[model.UserPasskey] "items"
// @Security BearerAuth
// @Router /api/v1/auth/passkeys [get]
func (h *passkeyHandler) list(c *gin.Context) {
items, err := h.svc.List(c.Request.Context(), c.GetString(usernameKey))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
// remove 删除通行密钥;属敏感变更,换发新令牌。
// 密码登录禁用期间,最后一个免密登录方式不可删除(防自锁)。
//
// @Summary 删除通行密钥
// @Tags 认证
// @Param id path int true "凭据 ID"
// @Success 200 {object} tokenResponse "已删除,返回换发的新 token"
// @Success 204 "已删除但新 token 签发失败,需重新登录"
// @Failure 409 {object} errorResponse "密码登录已禁用且这是最后一个免密登录方式"
// @Security BearerAuth
// @Router /api/v1/auth/passkeys/{id} [delete]
func (h *passkeyHandler) remove(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.svc.Remove(c.Request.Context(), c.GetString(usernameKey), uint(id), tokenProofOf(c)); err != nil {
if errors.Is(err, service.ErrLastIdentity) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
respondError(c, err)
return
}
respondFreshToken(c, h.auth)
}
// loginBegin 生成登录断言 options(公开;无凭据也正常下发,不泄露账号状态)。
//
// @Summary 发起通行密钥登录
// @Tags 认证
// @Success 200 {object} passkeyOptionsResponse "sessionId 与 WebAuthn options"
// @Failure 409 {object} errorResponse "面板地址未设置"
// @Router /api/v1/auth/passkey/login/begin [post]
func (h *passkeyHandler) loginBegin(c *gin.Context) {
sid, opts, err := h.svc.BeginLogin(c.Request.Context())
if err != nil {
if errors.Is(err, service.ErrPasskeyNoAppURL) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"sessionId": sid, "options": opts})
}
// loginFinish 校验断言并签发 JWT;凭 UV 豁免 TOTP。
//
// @Summary 完成通行密钥登录
// @Tags 认证
// @Param body body passkeyFinishRequest true "sessionId 与断言响应(name 忽略)"
// @Success 200 {object} tokenResponse "token 与 expiresAt"
// @Failure 400 {object} errorResponse "请求体非法"
// @Failure 401 {object} errorResponse "校验失败"
// @Failure 429 {object} errorResponse "连续失败已锁定"
// @Router /api/v1/auth/passkey/login/finish [post]
func (h *passkeyHandler) loginFinish(c *gin.Context) {
var req passkeyFinishRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
start := time.Now()
token, expires, username, err := h.svc.FinishLogin(
c.Request.Context(), req.SessionId, sessionMetaOf(c), bytes.NewReader(req.Credential))
if errors.Is(err, service.ErrLoginLocked) {
h.recordLogin(c, username, http.StatusTooManyRequests, "连续失败已锁定", start)
c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
return
}
if err != nil {
// 统一 401 文案,不区分会话过期/校验失败,防探测
h.recordLogin(c, username, http.StatusUnauthorized, service.ErrPasskeyVerify.Error(), start)
c.JSON(http.StatusUnauthorized, gin.H{"error": service.ErrPasskeyVerify.Error()})
return
}
h.recordLogin(c, username, http.StatusOK, "", start)
c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires})
}
// recordLogin 记录通行密钥登录成败——公开端点不经系统日志中间件,
// 登录失败是安全关键事件,必须留痕;失败时账号未知,username 为空。
func (h *passkeyHandler) recordLogin(c *gin.Context, username string, status int, errMsg string, start time.Time) {
if h.logs == nil {
return
}
h.logs.Record(model.SystemLog{
Username: username,
Method: http.MethodPost,
Path: requestPath(c),
Status: status,
DurationMs: time.Since(start).Milliseconds(),
ClientIP: requestIP(c),
UserAgent: truncateLogField(c.Request.UserAgent(), 256),
ErrMsg: errMsg,
})
}
+3 -1
View File
@@ -77,7 +77,9 @@ func IPRateMiddleware(settings *service.SettingService) gin.HandlerFunc {
return func(c *gin.Context) {
sec := settings.SecurityCached()
if !lm.get(requestIP(c), sec.IPRateRPS, sec.IPRateBurst).Allow() {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
// code 供前端区分全局 IP 限流与登录守卫锁定(后者不带 code,留在表单内提示)
c.AbortWithStatusJSON(http.StatusTooManyRequests,
gin.H{"error": "rate limit exceeded", "code": "RateLimited"})
return
}
c.Next()
+4 -4
View File
@@ -10,7 +10,7 @@ import (
// NewRouter 组装 HTTP 路由骨架:中间件链、公开/鉴权分组与各域注册;
// 具体路由按域拆在 routes_*.go。/auth/login 公开,业务路由要求 JWT。
func NewRouter(auth *service.AuthService, oauth *service.OAuthService, ociConfigs *service.OciConfigService, tasks *service.TaskService, console *service.ConsoleService, settings *service.SettingService, notifier *service.Notifier, systemLogs *service.SystemLogService, logEvents *service.LogEventService, proxies *service.ProxyService, aiGateway *service.AiGatewayService) *gin.Engine {
func NewRouter(auth *service.AuthService, oauth *service.OAuthService, passkeys *service.PasskeyService, wallets *service.WalletService, ociConfigs *service.OciConfigService, tasks *service.TaskService, console *service.ConsoleService, settings *service.SettingService, notifier *service.Notifier, systemLogs *service.SystemLogService, logEvents *service.LogEventService, proxies *service.ProxyService, aiGateway *service.AiGatewayService) *gin.Engine {
r := gin.New()
// 禁用 gin 内置代理信任:真实 IP 统一由 RealIPMiddleware 按安全设置解析
_ = r.SetTrustedProxies(nil)
@@ -23,15 +23,15 @@ func NewRouter(auth *service.AuthService, oauth *service.OAuthService, ociConfig
// 面板 API 请求体统一 1MB 上限(S-03);AI 网关按对话体量单独 10MB
v1 := r.Group("/api/v1", bodyLimit(1<<20))
registerAuthPublic(v1, auth, oauth, systemLogs)
registerAuthPublic(v1, auth, oauth, passkeys, wallets, systemLogs)
// AI 网关对外端点:独立密钥鉴权,挂在全局 Use 之后,仍受 IP 限速与 Recovery 保护
registerAiGateway(r, aiGateway)
// 系统日志中间件挂在鉴权之后,写请求自动留痕(webconsole ws 为 GET,天然跳过)
secured := v1.Group("", RequireAuth(auth), systemLogMiddleware(systemLogs))
registerAuthSecured(secured, auth, oauth, settings, systemLogs)
registerAuthSecured(secured, auth, oauth, passkeys, settings, systemLogs)
registerConsole(v1, secured, console, auth)
registerSettings(secured, settings, notifier, systemLogs, proxies)
registerSettings(secured, auth, settings, notifier, systemLogs, proxies)
registerTasksAndLogs(secured, tasks, logEvents)
registerOci(secured, ociConfigs)
// 上传类接口独立成组:文件本体上限 1MB,multipart 边界与字段头另占空间,
+6 -4
View File
@@ -86,7 +86,7 @@ func newTestRouterDB(t *testing.T) (*gin.Engine, *service.AuthService, *service.
t.Fatalf("db handle: %v", err)
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(&model.User{}, &model.OciConfig{}, &model.Task{}, &model.TaskLog{}, &model.Setting{}, &model.SystemLog{}, &model.LogEvent{}, &model.Proxy{}, &model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{}); err != nil {
if err := db.AutoMigrate(&model.User{}, &model.UserPasskey{}, &model.UserSession{}, &model.OciConfig{}, &model.Task{}, &model.TaskLog{}, &model.Setting{}, &model.SystemLog{}, &model.LogEvent{}, &model.Proxy{}, &model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
cipher, err := crypto.NewCipher("test-key")
@@ -104,7 +104,9 @@ func newTestRouterDB(t *testing.T) (*gin.Engine, *service.AuthService, *service.
systemLogs := service.NewSystemLogService(db)
logEvents := service.NewLogEventService(db)
oauth := service.NewOAuthService(db, settings, auth)
r := NewRouter(auth, oauth, ociConfigs, tasks, service.NewConsoleService(ociConfigs), settings, notifier, systemLogs, logEvents, service.NewProxyService(db, cipher), service.NewAiGatewayService(db, ociConfigs, nullClient{}))
passkeys := service.NewPasskeyService(db, settings, auth)
wallets := service.NewWalletService(db, settings, auth)
r := NewRouter(auth, oauth, passkeys, wallets, ociConfigs, tasks, service.NewConsoleService(ociConfigs), settings, notifier, systemLogs, logEvents, service.NewProxyService(db, cipher), service.NewAiGatewayService(db, ociConfigs, nullClient{}))
return r, auth, systemLogs, db
}
@@ -146,7 +148,7 @@ func TestLoginEndpoint(t *testing.T) {
func TestSecuredRoutesRequireToken(t *testing.T) {
r, auth, _ := newTestRouter(t)
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
token, _, err := auth.Login(context.Background(), "admin", "pass123", "", service.SessionMeta{ClientIP: "127.0.0.1"})
if err != nil {
t.Fatalf("login: %v", err)
}
@@ -171,7 +173,7 @@ func TestSecuredRoutesRequireToken(t *testing.T) {
func TestSystemLogsEndpoint(t *testing.T) {
r, auth, logs := newTestRouter(t)
token, _, err := auth.Login(context.Background(), "admin", "pass123", "127.0.0.1", "")
token, _, err := auth.Login(context.Background(), "admin", "pass123", "", service.SessionMeta{ClientIP: "127.0.0.1"})
if err != nil {
t.Fatalf("login: %v", err)
}
+26 -5
View File
@@ -6,23 +6,44 @@ import (
"oci-portal/internal/service"
)
// registerAuthPublic 公开登录/OAuth 路由(不经 JWT)。
func registerAuthPublic(v1 *gin.RouterGroup, auth *service.AuthService, oauth *service.OAuthService, systemLogs *service.SystemLogService) {
// registerAuthPublic 公开登录/OAuth/通行密钥/钱包路由(不经 JWT)。
func registerAuthPublic(v1 *gin.RouterGroup, auth *service.AuthService, oauth *service.OAuthService, passkeys *service.PasskeyService, wallets *service.WalletService, systemLogs *service.SystemLogService) {
ah := &authHandler{svc: auth, logs: systemLogs}
v1.POST("/auth/login", ah.login)
// 外部身份登录:provider 列表 / 授权跳转(bind 模式 handler 内校验 JWT)/ 回调
ax := &authxHandler{auth: auth, oauth: oauth, logs: systemLogs}
ax := &authxHandler{auth: auth, oauth: oauth, passkeys: passkeys, wallets: wallets, logs: systemLogs}
v1.GET("/auth/oauth/providers", ax.oauthProviders)
v1.GET("/auth/oauth/:provider/authorize", ax.oauthAuthorize)
v1.GET("/auth/oauth/:provider/callback", ax.oauthCallback)
// 通行密钥登录:断言 options 公开下发,finish 内部接入登录守卫
pk := &passkeyHandler{svc: passkeys, auth: auth, logs: systemLogs}
v1.POST("/auth/passkey/login/begin", pk.loginBegin)
v1.POST("/auth/passkey/login/finish", pk.loginFinish)
// 钱包登录/绑定:挑战-签名两段式(bind 模式 handler 内校验 JWT)
wl := &walletHandler{svc: wallets, auth: auth, logs: systemLogs}
v1.POST("/auth/wallet/challenge", wl.challenge)
v1.POST("/auth/wallet/verify", wl.verify)
}
// registerAuthSecured 登出、两步验证、外部身份管理(JWT 组内)。
func registerAuthSecured(secured *gin.RouterGroup, auth *service.AuthService, oauth *service.OAuthService, settings *service.SettingService, systemLogs *service.SystemLogService) {
// registerAuthSecured 登出、两步验证、外部身份与通行密钥管理(JWT 组内)。
func registerAuthSecured(secured *gin.RouterGroup, auth *service.AuthService, oauth *service.OAuthService, passkeys *service.PasskeyService, settings *service.SettingService, systemLogs *service.SystemLogService) {
ah := &authHandler{svc: auth, logs: systemLogs}
secured.POST("/auth/logout", ah.logout)
pk := &passkeyHandler{svc: passkeys, auth: auth}
secured.POST("/auth/passkey/register/begin", pk.registerBegin)
secured.POST("/auth/passkey/register/finish", pk.registerFinish)
secured.GET("/auth/passkeys", pk.list)
secured.DELETE("/auth/passkeys/:id", pk.remove)
// 活跃会话:查看与定点撤销
sh := &sessionHandler{auth: auth}
secured.GET("/auth/sessions", sh.list)
secured.DELETE("/auth/sessions/:id", sh.revoke)
ax := &authxHandler{auth: auth, oauth: oauth}
secured.GET("/auth/totp", ax.totpStatus)
secured.POST("/auth/totp/setup", ax.totpSetup)
+2 -2
View File
@@ -7,11 +7,11 @@ import (
)
// registerSettings 挂载 /settings/* 与 /about,以及系统日志与代理管理。
func registerSettings(secured *gin.RouterGroup, settings *service.SettingService, notifier *service.Notifier, systemLogs *service.SystemLogService, proxies *service.ProxyService) {
func registerSettings(secured *gin.RouterGroup, auth *service.AuthService, settings *service.SettingService, notifier *service.Notifier, systemLogs *service.SystemLogService, proxies *service.ProxyService) {
secured.GET("/about", about)
secured.GET("/system-logs", (&systemLogHandler{svc: systemLogs}).list)
st := &settingsHandler{svc: settings, notifier: notifier}
st := &settingsHandler{svc: settings, auth: auth, notifier: notifier}
secured.GET("/settings/telegram", st.getTelegram)
secured.PUT("/settings/telegram", st.updateTelegram)
secured.POST("/settings/telegram/test", st.testTelegram)
+85
View File
@@ -0,0 +1,85 @@
package api
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"oci-portal/internal/service"
)
// sessionHandler 处理活跃会话查看与定点撤销接口。
type sessionHandler struct {
auth *service.AuthService
}
// bearerToken 取 Authorization 头中的原始令牌串;不存在返回空串。
func bearerToken(c *gin.Context) string {
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
if !ok {
return ""
}
return token
}
// sessionMetaOf 采集会话登记所需的客户端上下文;登录方式由 service 层填写。
func sessionMetaOf(c *gin.Context) service.SessionMeta {
return service.SessionMeta{
ClientIP: requestIP(c),
UserAgent: truncateLogField(c.Request.UserAgent(), 256),
}
}
// list 列出当前账号的活跃会话,最近活跃在前,当前会话带 current 标记。
//
// @Summary 活跃会话列表
// @Tags 认证
// @Success 200 {object} itemsResponse[service.SessionInfo] "items"
// @Security BearerAuth
// @Router /api/v1/auth/sessions [get]
func (h *sessionHandler) list(c *gin.Context) {
items, err := h.auth.ListSessions(c.Request.Context(), c.GetString(usernameKey), bearerToken(c))
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
// revoke 定点撤销一个其他会话;该会话令牌随即失效,不影响其余会话。
//
// @Summary 撤销单个会话
// @Tags 认证
// @Param id path int true "会话 ID"
// @Success 204 "已撤销"
// @Failure 400 {object} errorResponse "id 非法"
// @Failure 404 {object} errorResponse "会话不存在"
// @Failure 409 {object} errorResponse "不能撤销当前会话"
// @Security BearerAuth
// @Router /api/v1/auth/sessions/{id} [delete]
func (h *sessionHandler) revoke(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
err = h.auth.RevokeSession(c.Request.Context(), c.GetString(usernameKey), bearerToken(c), uint(id))
if errors.Is(err, service.ErrSessionCurrent) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在或已失效"})
return
}
if err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
+11 -1
View File
@@ -12,6 +12,7 @@ import (
// settingsHandler 处理系统设置(Telegram 通知)相关请求。
type settingsHandler struct {
svc *service.SettingService
auth *service.AuthService
notifier *service.Notifier
}
@@ -323,6 +324,9 @@ func (h *settingsHandler) getSecurity(c *gin.Context) {
// @Tags 设置
// @Param body body service.SecurityPatch true "出现的字段才会被更新"
// @Success 200 {object} service.SecuritySettings
// @Failure 400 {object} errorResponse "字段越界或非法"
// @Failure 401 {object} errorResponse "请求处理期间会话已撤销"
// @Failure 409 {object} errorResponse "密码登录禁用期间,面板地址变更将移除最后可用登录方式"
// @Security BearerAuth
// @Router /api/v1/settings/security [patch]
func (h *settingsHandler) updateSecurity(c *gin.Context) {
@@ -331,11 +335,17 @@ func (h *settingsHandler) updateSecurity(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.svc.UpdateSecurity(c.Request.Context(), req); err != nil {
err := h.svc.UpdateSecurityAuthenticated(
c.Request.Context(), req, h.auth, c.GetString(usernameKey), tokenProofOf(c))
if err != nil {
if errors.Is(err, service.ErrInvalidSecurity) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if errors.Is(err, service.ErrProviderLastLogin) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
respondError(c, err)
return
}
+17
View File
@@ -15,6 +15,9 @@ import (
// errorResponse 是统一错误响应外壳。
type errorResponse struct {
Error string `json:"error"`
// Code 是机器可读错误码,多数错误不携带;当前仅全局 IP 限流的 429
// 返回 "RateLimited",前端据此与登录守卫的 429(无 code)区分
Code string `json:"code,omitempty"`
}
// itemsResponse 是 {"items": [...]} 列表外壳。
@@ -71,6 +74,20 @@ type credentialsResponse struct {
type oauthProvidersResponse struct {
Providers []service.ProviderInfo `json:"providers"`
PasswordLoginDisabled bool `json:"passwordLoginDisabled"`
PasskeyLogin bool `json:"passkeyLogin"`
WalletLogin bool `json:"walletLogin"`
}
// walletChallengeResponse 是钱包签名挑战响应;message 需原样 personal_sign。
type walletChallengeResponse struct {
Nonce string `json:"nonce"`
Message string `json:"message"`
}
// passkeyOptionsResponse 是 WebAuthn 仪式 options 响应;sessionId 需原样带回 finish。
type passkeyOptionsResponse struct {
SessionId string `json:"sessionId"`
Options json.RawMessage `json:"options"`
}
// urlResponse 是跳转地址响应。
+4 -3
View File
@@ -439,10 +439,10 @@ func (h *ociConfigHandler) deleteTenantUserApiKey(c *gin.Context) {
}
// @Summary 启用 API Key 为面板签名凭据
// @Description 将刚创建的 key 设为本配置签名凭据:验证可用后落库,不删除旧 key;私钥为创建时下发的那份回传。
// @Description 将刚创建的 key 设为本配置签名凭据:验证可用后落库,不删除旧 key;私钥为创建时下发的那份回传。userId 给定且异于当前签名用户时,一并把签名用户切换为该用户。
// @Tags 租户 IAM
// @Param id path int true "配置 ID"
// @Param body body object true "请求体:fingerprint 与 privateKey"
// @Param body body object true "请求体:fingerprint 与 privateKey,可选 userId(切换签名用户)"
// @Success 204 "无内容"
// @Security BearerAuth
// @Router /api/v1/oci-configs/{id}/activate-api-key [post]
@@ -452,6 +452,7 @@ func (h *ociConfigHandler) activateApiKey(c *gin.Context) {
return
}
var req struct {
UserID string `json:"userId"`
Fingerprint string `json:"fingerprint"`
PrivateKey string `json:"privateKey"`
}
@@ -459,7 +460,7 @@ func (h *ociConfigHandler) activateApiKey(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "fingerprint 与 privateKey 必填"})
return
}
if err := h.svc.ActivateApiKey(c.Request.Context(), id, req.Fingerprint, req.PrivateKey); err != nil {
if err := h.svc.ActivateApiKey(c.Request.Context(), id, req.UserID, req.Fingerprint, req.PrivateKey); err != nil {
respondError(c, err)
return
}
+161
View File
@@ -0,0 +1,161 @@
package api
import (
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"oci-portal/internal/model"
"oci-portal/internal/service"
)
// walletHandler 处理钱包(SIWE)签名挑战与校验接口。
type walletHandler struct {
svc *service.WalletService
auth *service.AuthService
logs *service.SystemLogService
}
// walletChallengeRequest 是签名挑战请求;mode=bind 需有效 Bearer(绑定到当前账号)。
type walletChallengeRequest struct {
Address string `json:"address" binding:"required,max=64"`
Mode string `json:"mode" binding:"omitempty,oneof=login bind"`
}
// challenge 下发 EIP-4361 消息与一次性 nonce;前端对消息原样 personal_sign。
//
// @Summary 发起钱包签名挑战
// @Tags 认证
// @Param body body walletChallengeRequest true "钱包地址与模式(bind 需 Bearer)"
// @Success 200 {object} walletChallengeResponse "nonce 与待签名消息"
// @Failure 400 {object} errorResponse "地址格式不正确"
// @Failure 401 {object} errorResponse "bind 模式未登录"
// @Failure 409 {object} errorResponse "面板地址未设置"
// @Router /api/v1/auth/wallet/challenge [post]
func (h *walletHandler) challenge(c *gin.Context) {
var req walletChallengeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
mode, username := "login", ""
if req.Mode == "bind" {
name, ok := (&authxHandler{auth: h.auth}).bearerUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "绑定需要先登录"})
return
}
mode, username = "bind", name
}
nonce, message, err := h.svc.Challenge(c.Request.Context(), req.Address, mode, username, bearerToken(c))
if err != nil {
h.respondChallengeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"nonce": nonce, "message": message})
}
// respondChallengeErr 映射挑战阶段错误。
func (h *walletHandler) respondChallengeErr(c *gin.Context, err error) {
if errors.Is(err, service.ErrWalletAddress) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if errors.Is(err, service.ErrWalletNoAppURL) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
respondError(c, err)
}
// walletVerifyRequest 是签名校验请求;signature 为 65 字节 r||s||v 的 hex。
type walletVerifyRequest struct {
Nonce string `json:"nonce" binding:"required,max=64"`
Signature string `json:"signature" binding:"required,max=200"`
}
// verify 校验签名:login 模式签发 JWT,bind 模式绑定后换发新令牌。
//
// @Summary 校验钱包签名
// @Tags 认证
// @Param body body walletVerifyRequest true "nonce 与签名"
// @Success 200 {object} tokenResponse "token 与 expiresAt(bind 模式为换发的新 token)"
// @Failure 400 {object} errorResponse "挑战无效已过期,或 bind 模式签名校验失败"
// @Failure 401 {object} errorResponse "签名校验失败(login 模式)"
// @Failure 403 {object} errorResponse "地址未绑定账号"
// @Failure 409 {object} errorResponse "地址已绑定过"
// @Failure 429 {object} errorResponse "连续失败已锁定"
// @Router /api/v1/auth/wallet/verify [post]
func (h *walletHandler) verify(c *gin.Context) {
var req walletVerifyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
start := time.Now()
res, err := h.svc.Verify(c.Request.Context(), req.Nonce, req.Signature, sessionMetaOf(c))
if err != nil {
status := walletErrStatus(err)
// bind 是已登录流程:验签失败不可回 401,否则前端全局拦截会误登出有效会话
if res.Mode == "bind" && status == http.StatusUnauthorized {
status = http.StatusBadRequest
}
h.recordWallet(c, res.Username, status, err, start)
c.JSON(status, gin.H{"error": walletErrText(err)})
return
}
h.recordWallet(c, res.Username, http.StatusOK, nil, start)
// login 与 bind 同构响应;bind 的 token 为版本递增后换发的新会话
c.JSON(http.StatusOK, gin.H{"token": res.Token, "expiresAt": res.ExpiresAt})
}
// walletErrStatus 映射校验阶段错误码。
func walletErrStatus(err error) int {
switch {
case errors.Is(err, service.ErrLoginLocked):
return http.StatusTooManyRequests
case errors.Is(err, service.ErrWalletChallenge):
return http.StatusBadRequest
case errors.Is(err, service.ErrWalletNotBound):
return http.StatusForbidden
case errors.Is(err, service.ErrWalletBound):
return http.StatusConflict
case errors.Is(err, service.ErrWalletSig):
return http.StatusUnauthorized
default:
return http.StatusInternalServerError
}
}
// walletErrText 把流程错误转为用户可读文案;内部错误不透出细节。
func walletErrText(err error) string {
for _, known := range []error{service.ErrLoginLocked, service.ErrWalletChallenge, service.ErrWalletNotBound, service.ErrWalletBound, service.ErrWalletSig} {
if errors.Is(err, known) {
return known.Error()
}
}
return "校验失败,请重试"
}
// recordWallet 记录钱包登录/绑定结果——公开端点不经系统日志中间件,须显式留痕。
func (h *walletHandler) recordWallet(c *gin.Context, username string, status int, err error, start time.Time) {
if h.logs == nil {
return
}
errMsg := ""
if err != nil {
errMsg = walletErrText(err)
}
h.logs.Record(model.SystemLog{
Username: username,
Method: http.MethodPost,
Path: requestPath(c),
Status: status,
DurationMs: time.Since(start).Milliseconds(),
ClientIP: requestIP(c),
UserAgent: truncateLogField(c.Request.UserAgent(), 256),
ErrMsg: errMsg,
})
}