新增活跃会话管理与通行密钥、钱包登录
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
+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
}