Files
BackupX/server/internal/http/auth_handler.go
Wu Qing 5af5f97efb feat: add complete MFA support
Add complete MFA support with TOTP, recovery codes, WebAuthn, trusted-device cookie flow, and email/SMS OTP delivery via notification channels. Security follow-up: trusted device tokens are stored in HttpOnly cookies, and SMS OTP reuses the existing Webhook notifier to avoid introducing a new dynamic URL sink.
2026-04-25 22:14:50 +08:00

416 lines
12 KiB
Go

package http
import (
"net"
stdhttp "net/http"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
const (
trustedDeviceCookieName = "backupx_trusted_device"
trustedDeviceCookiePath = "/api/auth"
trustedDeviceCookieMaxAge = int((30 * 24 * time.Hour) / time.Second)
)
type AuthHandler struct {
authService *service.AuthService
}
func NewAuthHandler(authService *service.AuthService) *AuthHandler {
return &AuthHandler{authService: authService}
}
func (h *AuthHandler) SetupStatus(c *gin.Context) {
initialized, err := h.authService.SetupStatus(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"initialized": initialized})
}
func (h *AuthHandler) Setup(c *gin.Context) {
var input service.SetupInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_SETUP_INVALID", "初始化参数不合法", err))
return
}
payload, err := h.authService.Setup(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
func (h *AuthHandler) Login(c *gin.Context) {
var input service.LoginInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_LOGIN_INVALID", "登录参数不合法", err))
return
}
if strings.TrimSpace(input.TrustedDeviceToken) == "" {
input.TrustedDeviceToken = trustedDeviceCookieValue(c)
}
payload, err := h.authService.Login(c.Request.Context(), input, ClientKey(c))
if err != nil {
response.Error(c, err)
return
}
if payload.TrustedDeviceToken != "" {
setTrustedDeviceCookie(c, payload.TrustedDeviceToken)
payload.TrustedDeviceToken = ""
}
response.Success(c, payload)
}
func (h *AuthHandler) Profile(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
user, err := h.authService.GetCurrentUser(c.Request.Context(), subject)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, user)
}
func (h *AuthHandler) ChangePassword(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.ChangePasswordInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_PASSWORD_INVALID", "参数不合法", err))
return
}
if err := h.authService.ChangePassword(c.Request.Context(), subject, input); err != nil {
response.Error(c, err)
return
}
clearTrustedDeviceCookie(c)
response.Success(c, gin.H{"changed": true})
}
func (h *AuthHandler) PrepareTwoFactor(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.TwoFactorSetupInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
return
}
payload, err := h.authService.PrepareTwoFactor(c.Request.Context(), subject, input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
func (h *AuthHandler) EnableTwoFactor(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.EnableTwoFactorInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
return
}
user, err := h.authService.EnableTwoFactor(c.Request.Context(), subject, input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, user)
}
func (h *AuthHandler) DisableTwoFactor(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.DisableTwoFactorInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
return
}
user, err := h.authService.DisableTwoFactor(c.Request.Context(), subject, input)
if err != nil {
response.Error(c, err)
return
}
if !user.MFAEnabled {
clearTrustedDeviceCookie(c)
}
response.Success(c, user)
}
func (h *AuthHandler) RegenerateRecoveryCodes(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.RegenerateRecoveryCodesInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
return
}
payload, err := h.authService.RegenerateRecoveryCodes(c.Request.Context(), subject, input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
func (h *AuthHandler) ConfigureOTP(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.OTPConfigInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_OTP_INVALID", "参数不合法", err))
return
}
user, err := h.authService.ConfigureOutOfBandOTP(c.Request.Context(), subject, input)
if err != nil {
response.Error(c, err)
return
}
if !user.MFAEnabled {
clearTrustedDeviceCookie(c)
}
response.Success(c, user)
}
func (h *AuthHandler) SendLoginOTP(c *gin.Context) {
var input service.LoginOTPInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_OTP_INVALID", "参数不合法", err))
return
}
if err := h.authService.SendLoginOTP(c.Request.Context(), input, ClientKey(c)); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"sent": true})
}
func (h *AuthHandler) BeginWebAuthnRegistration(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.WebAuthnRegistrationOptionsInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
return
}
options, err := h.authService.BeginWebAuthnRegistration(c.Request.Context(), subject, input, webAuthnRequestContext(c))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, options)
}
func (h *AuthHandler) FinishWebAuthnRegistration(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.WebAuthnRegistrationFinishInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
return
}
user, err := h.authService.FinishWebAuthnRegistration(c.Request.Context(), subject, input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, user)
}
func (h *AuthHandler) BeginWebAuthnLogin(c *gin.Context) {
var input service.WebAuthnLoginOptionsInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
return
}
options, err := h.authService.BeginWebAuthnLogin(c.Request.Context(), input, webAuthnRequestContext(c), ClientKey(c))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, options)
}
func (h *AuthHandler) ListWebAuthnCredentials(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
items, err := h.authService.ListWebAuthnCredentials(c.Request.Context(), subject)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *AuthHandler) DeleteWebAuthnCredential(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.WebAuthnCredentialDeleteInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
return
}
user, err := h.authService.DeleteWebAuthnCredential(c.Request.Context(), subject, c.Param("id"), input)
if err != nil {
response.Error(c, err)
return
}
if !user.MFAEnabled {
clearTrustedDeviceCookie(c)
}
response.Success(c, user)
}
func (h *AuthHandler) ListTrustedDevices(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
items, err := h.authService.ListTrustedDevices(c.Request.Context(), subject)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, items)
}
func (h *AuthHandler) RevokeTrustedDevice(c *gin.Context) {
subjectValue, _ := c.Get(contextUserSubjectKey)
subject, err := service.SubjectFromContextValue(subjectValue)
if err != nil {
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
return
}
var input service.TrustedDeviceRevokeInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("AUTH_TRUSTED_DEVICE_INVALID", "参数不合法", err))
return
}
if err := h.authService.RevokeTrustedDevice(c.Request.Context(), subject, c.Param("id"), input); err != nil {
response.Error(c, err)
return
}
clearTrustedDeviceCookie(c)
response.Success(c, gin.H{"deleted": true})
}
func (h *AuthHandler) Logout(c *gin.Context) {
response.Success(c, gin.H{"loggedOut": true})
}
func webAuthnRequestContext(c *gin.Context) service.WebAuthnRequestContext {
host := firstForwardedValue(c.Request.Host)
if forwardedHost := firstForwardedValue(c.GetHeader("X-Forwarded-Host")); forwardedHost != "" {
host = forwardedHost
}
rpID := host
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
rpID = parsedHost
}
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
if forwardedProto := firstForwardedValue(c.GetHeader("X-Forwarded-Proto")); forwardedProto != "" {
scheme = forwardedProto
}
origin := strings.TrimSpace(c.GetHeader("Origin"))
if origin == "" {
origin = scheme + "://" + host
}
return service.WebAuthnRequestContext{RPID: rpID, Origin: origin}
}
func firstForwardedValue(value string) string {
parts := strings.Split(value, ",")
if len(parts) == 0 {
return ""
}
return strings.TrimSpace(parts[0])
}
func trustedDeviceCookieValue(c *gin.Context) string {
token, err := c.Cookie(trustedDeviceCookieName)
if err != nil {
return ""
}
return strings.TrimSpace(token)
}
func setTrustedDeviceCookie(c *gin.Context, token string) {
writeTrustedDeviceCookie(c, strings.TrimSpace(token), trustedDeviceCookieMaxAge)
}
func clearTrustedDeviceCookie(c *gin.Context) {
writeTrustedDeviceCookie(c, "", -1)
}
func writeTrustedDeviceCookie(c *gin.Context, value string, maxAge int) {
c.SetSameSite(stdhttp.SameSiteLaxMode)
c.SetCookie(trustedDeviceCookieName, value, maxAge, trustedDeviceCookiePath, "", requestIsSecure(c), true)
}
func requestIsSecure(c *gin.Context) bool {
if c.Request.TLS != nil {
return true
}
return strings.EqualFold(firstForwardedValue(c.GetHeader("X-Forwarded-Proto")), "https")
}