package api import ( "errors" "net/http" "net/url" "strconv" "time" "github.com/gin-gonic/gin" "oci-portal/internal/model" "oci-portal/internal/service" ) // authxHandler 处理两步验证与外部身份(OAuth)接口。 type authxHandler struct { auth *service.AuthService oauth *service.OAuthService passkeys *service.PasskeyService wallets *service.WalletService logs *service.SystemLogService } // ---- TOTP(JWT 组内) ---- // totpStatus 返回当前账号两步验证启用状态。 // // @Summary 两步验证状态 // @Tags 认证 // @Success 200 {object} enabledResponse "enabled" // @Security BearerAuth // @Router /api/v1/auth/totp [get] func (h *authxHandler) totpStatus(c *gin.Context) { enabled, err := h.auth.TotpStatus(c.Request.Context(), c.GetString(usernameKey)) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"enabled": enabled}) } // totpSetup 生成待激活密钥,返回手动输入码与 otpauth URI(前端渲染二维码)。 // // @Summary 发起两步验证设置 // @Tags 认证 // @Success 200 {object} totpSetupResponse "secret 与 otpauthUri" // @Failure 409 {object} errorResponse "已启用" // @Security BearerAuth // @Router /api/v1/auth/totp/setup [post] func (h *authxHandler) totpSetup(c *gin.Context) { secret, uri, err := h.auth.SetupTotp(c.Request.Context(), c.GetString(usernameKey)) if err != nil { if errors.Is(err, service.ErrTotpAlreadyOn) { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"secret": secret, "otpauthUri": uri}) } // totpActivate 校验验证码并正式启用两步验证。 // // @Summary 激活两步验证 // @Tags 认证 // @Param body body object true "{code: 6 位验证码}" // @Success 200 {object} tokenResponse "已启用,返回换发的新 token" // @Failure 401 {object} errorResponse "请求处理期间会话已撤销" // @Security BearerAuth // @Router /api/v1/auth/totp/activate [post] func (h *authxHandler) totpActivate(c *gin.Context) { var req struct { Code string `json:"code" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } 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()}) return } respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires}) } // totpDisable 停用两步验证;需当前验证码或登录密码任一确认。 // // @Summary 停用两步验证 // @Tags 认证 // @Param body body object true "{password 或 code 任一确认}" // @Success 200 {object} tokenResponse "已停用,返回换发的新 token" // @Failure 401 {object} errorResponse "请求处理期间会话已撤销" // @Security BearerAuth // @Router /api/v1/auth/totp/disable [post] func (h *authxHandler) totpDisable(c *gin.Context) { var req struct { Password string `json:"password"` Code string `json:"code"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } 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()}) return } respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires}) } // respondFreshToken 敏感变更后为操作者签发新令牌返回(版本已递增, // 旧令牌全部失效);旧令牌的会话行接续到新令牌,列表中当前设备保持连续。 // 签发失败降级 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 } c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires}) } // revokeSessions 撤销全部会话(令牌版本递增),并为当前操作者重签新令牌。 // // @Summary 撤销全部会话 // @Tags 认证 // @Success 200 {object} tokenResponse "新 token 与 expiresAt" // @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), bearerToken(c), sessionMetaOf(c), tokenProofOf(c)) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"token": token, "expiresAt": expires}) } // ---- 登录凭据(JWT 组内) ---- // getCredentials 返回登录凭据摘要:当前用户名与密码登录禁用态。 // // @Summary 登录凭据摘要 // @Tags 认证 // @Success 200 {object} credentialsResponse "username 与 passwordLoginDisabled" // @Security BearerAuth // @Router /api/v1/auth/credentials [get] func (h *authxHandler) getCredentials(c *gin.Context) { disabled, err := h.auth.PasswordLoginDisabled(c.Request.Context()) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, gin.H{ "username": c.GetString(usernameKey), "passwordLoginDisabled": disabled, }) } // updateCredentials 修改用户名 / 密码;当前密码必验,改名后旧 JWT 随即失效(前端应重新登录)。 // // @Summary 修改登录凭据 // @Tags 认证 // @Param body body service.UpdateCredentialsInput true "新凭据(当前密码必验)" // @Success 200 {object} tokenResponse "已更新,返回换发的新 token" // @Success 204 "已更新但新 token 签发失败,需重新登录" // @Failure 401 {object} errorResponse "当前密码错误" // @Security BearerAuth // @Router /api/v1/auth/credentials [put] func (h *authxHandler) updateCredentials(c *gin.Context) { var req service.UpdateCredentialsInput if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } 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 } if errors.Is(err, service.ErrCredentialInvalid) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err != nil { respondError(c, err) return } c.Set(usernameKey, finalName) respondFreshToken(c, h.auth) } // updatePasswordLogin 保存密码登录禁用开关;开启需至少绑定一个外部身份。 // // @Summary 密码登录开关 // @Tags 认证 // @Param body body object true "{disabled: bool}" // @Success 200 {object} tokenResponse "已保存,返回换发的新 token" // @Success 204 "已保存但新 token 签发失败,需重新登录" // @Failure 409 {object} errorResponse "未绑定外部身份" // @Security BearerAuth // @Router /api/v1/auth/password-login [put] func (h *authxHandler) updatePasswordLogin(c *gin.Context) { var req struct { Disabled *bool `json:"disabled" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } 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 } if err != nil { respondError(c, err) return } respondFreshToken(c, h.auth) } // ---- OAuth ---- // oauthProviders 返回已配置的 provider 列表(公开,登录页展示按钮), // 并附带密码登录禁用开关与通行密钥登录可用性:开关开启且存在可用外部身份时, // 登录页隐藏密码表单;provider 清空时不下发禁用,保证界面始终留有登录入口 // (后端 Login 仍会拒绝)。 // // @Summary 外部登录 provider 列表 // @Tags 认证 // @Success 200 {object} oauthProvidersResponse "providers、passwordLoginDisabled 与 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 // 有任一免密入口(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, "passkeyLogin": passkey, "walletLogin": wallet}) } // oauthAuthorize 返回授权跳转 URL;mode=bind 需有效 JWT(绑定到当前账号),login 公开。 // // @Summary 外部登录授权跳转 // @Tags 认证 // @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") if provider != "oidc" && provider != "github" { c.JSON(http.StatusNotFound, gin.H{"error": "unknown provider"}) return } mode, username := "login", "" if c.Query("mode") == "bind" { name, ok := h.bearerUser(c) if !ok { c.JSON(http.StatusUnauthorized, gin.H{"error": "绑定需要先登录"}) return } mode, username = "bind", name } 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()}) return } respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"url": authURL}) } // bearerUser 从 Authorization 头解析用户名(authorize 在公开组,绑定模式手动校验)。 func (h *authxHandler) bearerUser(c *gin.Context) (string, bool) { header := c.GetHeader("Authorization") if len(header) < 8 || header[:7] != "Bearer " { return "", false } username, err := h.auth.ParseToken(c.Request.Context(), header[7:]) if err != nil { return "", false } return username, true } // oauthCallback 是 provider 回调落点(浏览器直达):完成登录或绑定后 302 回前端。 // // @Summary 外部登录回调 // @Tags 认证 // @Param provider path string true "oidc / github" // @Param state query string true "授权流程 state" // @Param code query string true "授权码" // @Success 302 "登录 token 经 fragment 回前端,绑定回设置页" // @Router /api/v1/auth/oauth/{provider}/callback [get] 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"), sessionMetaOf(c)) if err != nil { h.recordOauth(c, username, http.StatusUnauthorized, oauthErrText(err), start) target := "/login" if mode == "bind" { target = "/settings" } c.Redirect(http.StatusFound, target+"?oauthError="+url.QueryEscape(oauthErrText(err))) return } h.recordOauth(c, username, http.StatusOK, "", start) if mode == "bind" { // 绑定模式:版本已递增,新 token 经 fragment 带回设置页无感换发 c.Redirect(http.StatusFound, "/settings?oauth=bound#oauthToken="+url.QueryEscape(token)) return } // 登录模式:token 放 fragment,不进服务端日志与 Referer c.Redirect(http.StatusFound, "/login#oauthToken="+url.QueryEscape(token)) } // recordOauth 把外部登录 / 绑定结果记入系统日志——回调是 GET, // 不经写方法中间件;实际响应恒为 302,日志按语义记 200 / 401。 func (h *authxHandler) recordOauth(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: c.Request.Method, Path: requestPath(c), Status: status, DurationMs: time.Since(start).Milliseconds(), ClientIP: requestIP(c), UserAgent: truncateLogField(c.Request.UserAgent(), 256), ErrMsg: errMsg, }) } // oauthErrText 把流程错误转为用户可读文案;内部错误不透出细节。 func oauthErrText(err error) string { for _, known := range []error{service.ErrOAuthNotConfigured, service.ErrOAuthNoAppURL, service.ErrOAuthDisabled, service.ErrOAuthState, service.ErrOAuthNotBound, service.ErrOAuthBound} { if errors.Is(err, known) { return known.Error() } } return "登录失败,请重试" } // identities 列出当前账号绑定的外部身份。 // // @Summary 已绑定外部身份 // @Tags 认证 // @Success 200 {object} itemsResponse[model.UserIdentity] "items" // @Security BearerAuth // @Router /api/v1/auth/identities [get] func (h *authxHandler) identities(c *gin.Context) { items, err := h.oauth.Identities(c.Request.Context(), c.GetString(usernameKey)) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"items": items}) } // unbindIdentity 解绑外部身份;密码登录禁用期间不允许解绑最后一个身份。 // // @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/identities/{id} [delete] func (h *authxHandler) unbindIdentity(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.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 } respondError(c, err) return } respondFreshToken(c, h.auth) } // ---- OAuth provider 设置(JWT 组内) ---- // getOAuthSettings 返回 provider 配置(secret 只回设置态)。 // // @Summary OAuth provider 配置 // @Tags 设置 // @Success 200 {object} service.OAuthProvidersView // @Security BearerAuth // @Router /api/v1/settings/oauth [get] func (h *authxHandler) getOAuthSettings(c *gin.Context, settings *service.SettingService) { view, err := settings.OAuthView(c.Request.Context()) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, view) } // updateOAuthSettings 部分更新 provider 配置;缺省字段沿用现值, // secret 传空串清除、缺省沿用。 // // @Summary 部分更新 OAuth provider 配置 // @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) { var req service.UpdateOAuthInput if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } 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 } h.getOAuthSettings(c, settings) }