mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-24 17:49:33 +08:00
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.
416 lines
12 KiB
Go
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")
|
|
}
|