Smail/backend/controllers/webauthn.go
smail 6de4a1cae3
Some checks are pending
Security Checks / security (push) Waiting to run
统一修复未知收件人超管限定与全部控制器Unicode转义
2026-09-10 22:27:27 +08:00

363 lines
14 KiB
Go

package controllers
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/models"
webauthnservice "git.pcux.cn/xuans/Smail/services/webauthn"
"git.pcux.cn/xuans/Smail/utils/context"
)
type webAuthnFinishRequest struct {
SessionID string `json:"session_id"`
ChallengeID string `json:"challenge_id"`
Identifier string `json:"identifier"`
Account string `json:"account"`
DisplayName string `json:"display_name"`
Credential json.RawMessage `json:"credential"`
}
type webAuthnLoginBeginRequest struct {
ChallengeID string `json:"challenge_id"`
Account string `json:"account"`
}
type webAuthnCredentialRequest struct {
ID int `json:"id"`
DisplayName string `json:"display_name"`
}
type webAuthnRecoveryBeginRequest struct {
Identifier string `json:"identifier"`
}
type webAuthnRecoveryVerifyRequest struct {
Identifier string `json:"identifier"`
SessionID string `json:"session_id"`
Credential json.RawMessage `json:"credential"`
}
const (
publicWebAuthnBeginLimit = 30
publicWebAuthnFailureLimit = 20
publicRecoveryBeginLimit = 30
publicRecoveryFailureLimit = 20
)
var (
webAuthnLoginBeginIPAttempts = newLoginLimiter(publicWebAuthnBeginLimit)
webAuthnLoginFailureIPAttempts = newLoginLimiter(publicWebAuthnFailureLimit)
webAuthnRecoveryBeginIPAttempts = newLoginLimiter(publicRecoveryBeginLimit)
webAuthnRecoveryFailureIPAttempts = newLoginLimiter(publicRecoveryFailureLimit)
emailRecoveryBeginIPAttempts = newLoginLimiter(publicRecoveryBeginLimit)
emailRecoveryFailureIPAttempts = newLoginLimiter(publicRecoveryFailureLimit)
)
func webAuthnRuntime() *webauthnservice.Service {
return currentSecurityRuntime().webauthn
}
// BeginWebAuthnRegistration 开始当前用户的安全密钥注册流程。
func BeginWebAuthnRegistration(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if ctx == nil || ctx.UserID <= 0 {
writeSecurityError(w, response.NeedLogin, "请先登录")
return
}
service := webAuthnRuntime()
if service == nil {
writeSecurityError(w, response.ParamsError, "安全密钥功能未启用")
return
}
options, sessionID, err := service.BeginRegistration(ctx.UserID)
if err != nil {
writeSecurityError(w, response.ParamsError, "无法开始安全密钥注册")
return
}
response.NewSuccessResponse(map[string]any{"session_id": sessionID, "options": options}).FPrint(w)
}
// FinishWebAuthnRegistration 完成注册并只返回凭据索引信息。
func FinishWebAuthnRegistration(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if ctx == nil || ctx.UserID <= 0 {
writeSecurityError(w, response.NeedLogin, "请先登录")
return
}
service := webAuthnRuntime()
if service == nil {
writeSecurityError(w, response.ParamsError, "安全密钥功能未启用")
return
}
var input webAuthnFinishRequest
if err := decodeJSONRequest(w, req, &input); err != nil || strings.TrimSpace(input.SessionID) == "" || len(input.Credential) == 0 || !json.Valid(input.Credential) {
writeSecurityError(w, response.ParamsError, "安全密钥注册请求无效")
return
}
credentialRequest := cloneWebAuthnRequest(req, input.Credential)
item, err := service.FinishRegistration(ctx.UserID, input.SessionID, credentialRequest, input.DisplayName)
if err != nil {
writeSecurityError(w, response.ParamsError, "安全密钥注册失败")
return
}
response.NewSuccessResponse(webAuthnCredentialView(item)).FPrint(w)
}
// ListWebAuthnCredentials 返回当前用户的安全密钥索引,不返回公钥材料。
func ListWebAuthnCredentials(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if ctx == nil || ctx.UserID <= 0 {
writeSecurityError(w, response.NeedLogin, "请先登录")
return
}
service := webAuthnRuntime()
if service == nil {
writeSecurityError(w, response.ParamsError, "安全密钥功能未启用")
return
}
items, err := service.ListCredentials(ctx.UserID)
if err != nil {
writeSecurityError(w, response.ServerError, "读取安全密钥失败")
return
}
result := make([]map[string]any, 0, len(items))
for index := range items {
result = append(result, webAuthnCredentialView(&items[index]))
}
response.NewSuccessResponse(result).FPrint(w)
}
// RenameWebAuthnCredential 修改当前用户的安全密钥名称。
func RenameWebAuthnCredential(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if ctx == nil || ctx.UserID <= 0 {
writeSecurityError(w, response.NeedLogin, "请先登录")
return
}
service := webAuthnRuntime()
if service == nil {
writeSecurityError(w, response.ParamsError, "安全密钥功能未启用")
return
}
var input webAuthnCredentialRequest
if err := decodeJSONRequest(w, req, &input); err != nil || input.ID <= 0 {
writeSecurityError(w, response.ParamsError, "安全密钥请求无效")
return
}
if err := service.RenameCredential(ctx.UserID, input.ID, input.DisplayName); err != nil {
writeSecurityError(w, response.ParamsError, "安全密钥名称修改失败")
return
}
response.NewSuccessResponse(map[string]any{"updated": true}).FPrint(w)
}
// RevokeWebAuthnCredential 撤销当前用户的安全密钥。
func RevokeWebAuthnCredential(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if ctx == nil || ctx.UserID <= 0 {
writeSecurityError(w, response.NeedLogin, "请先登录")
return
}
service := webAuthnRuntime()
if service == nil {
writeSecurityError(w, response.ParamsError, "安全密钥功能未启用")
return
}
var input webAuthnCredentialRequest
if err := decodeJSONRequest(w, req, &input); err != nil || input.ID <= 0 {
writeSecurityError(w, response.ParamsError, "安全密钥请求无效")
return
}
if err := service.RevokeCredential(ctx.UserID, input.ID); err != nil {
writeSecurityError(w, response.ParamsError, "安全密钥撤销失败")
return
}
response.NewSuccessResponse(map[string]any{"revoked": true}).FPrint(w)
}
// BeginWebAuthnLogin 开始公开的安全密钥登录流程。
func BeginWebAuthnLogin(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
service := webAuthnRuntime()
if service == nil {
writeSecurityError(w, response.ParamsError, "安全密钥登录不可用")
return
}
beginKey := webAuthnLoginBeginIPAttempts.ipKey(req)
failureKey := webAuthnLoginFailureIPAttempts.ipKey(req)
if webAuthnLoginBeginIPAttempts.blocked(beginKey) || webAuthnLoginFailureIPAttempts.blocked(failureKey) || loginIPAttempts.blocked(loginIPAttempts.ipKey(req)) {
writeSecurityError(w, response.ParamsError, "安全密钥登录失败")
return
}
var input webAuthnLoginBeginRequest
if err := decodeJSONRequest(w, req, &input); err != nil || strings.TrimSpace(input.ChallengeID) == "" || strings.TrimSpace(input.Account) == "" {
webAuthnLoginFailureIPAttempts.recordFailure(failureKey)
writeSecurityError(w, response.ParamsError, "安全密钥登录请求无效")
return
}
item, ok := pendingLoginVerification(input.ChallengeID, input.Account)
if !ok {
webAuthnLoginFailureIPAttempts.recordFailure(failureKey)
writeSecurityError(w, response.ParamsError, "安全密钥登录请求无效")
return
}
options, sessionID, err := service.BeginLogin(item.UserID)
if err != nil {
webAuthnLoginFailureIPAttempts.recordFailure(failureKey)
writeSecurityError(w, response.ParamsError, "安全密钥登录失败")
return
}
response.NewSuccessResponse(map[string]any{
"challenge_id": input.ChallengeID,
"account": item.Account,
"expires_at": item.ExpiresAt.UTC(),
"session_id": sessionID,
"options": options,
}).FPrint(w)
}
// FinishWebAuthnLogin 完成安全密钥登录并创建普通业务会话。
func FinishWebAuthnLogin(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
runtime := currentSecurityRuntime()
service := runtime.webauthn
if service == nil {
writeSecurityError(w, response.ParamsError, "安全密钥登录不可用")
return
}
failureKey := webAuthnLoginFailureIPAttempts.ipKey(req)
if webAuthnLoginFailureIPAttempts.blocked(failureKey) || loginIPAttempts.blocked(loginIPAttempts.ipKey(req)) {
writeSecurityError(w, response.ParamsError, "安全密钥登录失败")
return
}
var input webAuthnFinishRequest
if err := decodeJSONRequest(w, req, &input); err != nil || strings.TrimSpace(input.ChallengeID) == "" || strings.TrimSpace(input.SessionID) == "" || len(input.Credential) == 0 || !json.Valid(input.Credential) {
webAuthnLoginFailureIPAttempts.recordFailure(failureKey)
loginIPAttempts.recordFailure(loginIPAttempts.ipKey(req))
writeSecurityError(w, response.ParamsError, "安全密钥登录请求无效")
return
}
account := strings.TrimSpace(input.Account)
if account == "" {
account = strings.TrimSpace(input.Identifier)
}
item, ok := pendingLoginVerification(input.ChallengeID, account)
if !ok {
webAuthnLoginFailureIPAttempts.recordFailure(failureKey)
loginIPAttempts.recordFailure(loginIPAttempts.ipKey(req))
writeSecurityError(w, response.ParamsError, "登录验证已失效,请重新登录")
return
}
credentialRequest := cloneWebAuthnRequest(req, input.Credential)
if _, err := service.FinishLogin(item.UserID, input.SessionID, credentialRequest); err != nil {
webAuthnLoginFailureIPAttempts.recordFailure(failureKey)
loginIPAttempts.recordFailure(loginIPAttempts.ipKey(req))
writeSecurityError(w, response.ParamsError, "安全密钥登录失败")
return
}
var user models.User
has, err := db.Instance.ID(item.UserID).Get(&user)
if err != nil || !has || user.Disabled != 0 || user.Account != item.Account || user.SessionVersion != item.SessionVersion {
writeSecurityError(w, response.ParamsError, "登录验证已失效,请重新登录")
return
}
if runtime.verification != nil && runtime.verification.Store != nil {
runtime.verification.Store.InvalidateChallenge(input.ChallengeID)
}
consumePendingLoginVerification(input.ChallengeID, item)
completeLoginSession(ctx, w, req, &user)
}
func BeginWebAuthnRecovery(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if rejectUnavailableRecovery(w) {
return
}
service := currentSecurityRuntime().recovery
if service == nil {
writeSecurityError(w, response.ParamsError, "安全密钥恢复不可用")
return
}
beginKey := webAuthnRecoveryBeginIPAttempts.ipKey(req)
failureKey := webAuthnRecoveryFailureIPAttempts.ipKey(req)
if webAuthnRecoveryBeginIPAttempts.blocked(beginKey) || webAuthnRecoveryFailureIPAttempts.blocked(failureKey) {
writeSecurityError(w, response.ParamsError, "安全密钥验证失败")
return
}
var input webAuthnRecoveryBeginRequest
if err := decodeJSONRequest(w, req, &input); err != nil || strings.TrimSpace(input.Identifier) == "" {
webAuthnRecoveryFailureIPAttempts.recordFailure(failureKey)
writeSecurityError(w, response.ParamsError, "安全密钥恢复请求无效")
return
}
if _, ok := recoveryBrowserContext(req, w, true); !ok {
writeSecurityError(w, response.ParamsError, "安全密钥恢复请求无效")
return
}
options, sessionID, err := service.BeginWebAuthn(input.Identifier)
if err != nil {
writeSecurityError(w, response.ParamsError, "安全密钥验证失败")
return
}
webAuthnRecoveryBeginIPAttempts.recordFailure(beginKey)
response.NewSuccessResponse(map[string]any{"session_id": sessionID, "options": options}).FPrint(w)
}
func VerifyWebAuthnRecovery(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if rejectUnavailableRecovery(w) {
return
}
service := currentSecurityRuntime().recovery
if service == nil {
writeSecurityError(w, response.ParamsError, "安全密钥恢复不可用")
return
}
failureKey := webAuthnRecoveryFailureIPAttempts.ipKey(req)
if webAuthnRecoveryFailureIPAttempts.blocked(failureKey) {
writeSecurityError(w, response.ParamsError, "安全密钥验证失败")
return
}
var input webAuthnRecoveryVerifyRequest
if err := decodeJSONRequest(w, req, &input); err != nil || strings.TrimSpace(input.Identifier) == "" || strings.TrimSpace(input.SessionID) == "" || len(input.Credential) == 0 || !json.Valid(input.Credential) {
webAuthnRecoveryFailureIPAttempts.recordFailure(failureKey)
writeSecurityError(w, response.ParamsError, "安全密钥恢复请求无效")
return
}
browserContext, ok := recoveryBrowserContext(req, w, false)
if !ok {
webAuthnRecoveryFailureIPAttempts.recordFailure(failureKey)
writeSecurityError(w, response.ParamsError, "安全密钥验证失败")
return
}
credentialRequest := cloneWebAuthnRequest(req, input.Credential)
ticket, err := service.VerifyWebAuthnWithContext(input.Identifier, input.SessionID, credentialRequest, browserContext)
if err != nil {
webAuthnRecoveryFailureIPAttempts.recordFailure(failureKey)
writeSecurityError(w, response.ParamsError, "安全密钥验证失败")
return
}
response.NewSuccessResponse(map[string]any{"ticket": ticket}).FPrint(w)
}
func cloneWebAuthnRequest(source *http.Request, payload []byte) *http.Request {
clone := source.Clone(source.Context())
clone.Body = io.NopCloser(bytes.NewReader(payload))
clone.ContentLength = int64(len(payload))
clone.Header = source.Header.Clone()
clone.Header.Set("Content-Type", "application/json")
return clone
}
func webAuthnCredentialView(item *models.WebAuthnCredential) map[string]any {
if item == nil {
return map[string]any{}
}
return map[string]any{
"id": item.ID,
"display_name": item.DisplayName,
"discoverable": item.Discoverable,
"requires_re_registration": !item.Discoverable && item.RevokedAt == nil,
"created_at": item.CreatedAt,
"last_used_at": item.LastUsedAt,
"revoked_at": item.RevokedAt,
}
}