diff --git a/backend/mpc-system/services/account/adapters/input/http/account_handler.go b/backend/mpc-system/services/account/adapters/input/http/account_handler.go new file mode 100644 index 00000000..59079949 --- /dev/null +++ b/backend/mpc-system/services/account/adapters/input/http/account_handler.go @@ -0,0 +1,507 @@ +package http + +import ( + "encoding/hex" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/rwadurian/mpc-system/services/account/application/ports" + "github.com/rwadurian/mpc-system/services/account/application/use_cases" + "github.com/rwadurian/mpc-system/services/account/domain/value_objects" +) + +// AccountHTTPHandler handles HTTP requests for accounts +type AccountHTTPHandler struct { + createAccountUC *use_cases.CreateAccountUseCase + getAccountUC *use_cases.GetAccountUseCase + updateAccountUC *use_cases.UpdateAccountUseCase + listAccountsUC *use_cases.ListAccountsUseCase + getAccountSharesUC *use_cases.GetAccountSharesUseCase + deactivateShareUC *use_cases.DeactivateShareUseCase + loginUC *use_cases.LoginUseCase + refreshTokenUC *use_cases.RefreshTokenUseCase + generateChallengeUC *use_cases.GenerateChallengeUseCase + initiateRecoveryUC *use_cases.InitiateRecoveryUseCase + completeRecoveryUC *use_cases.CompleteRecoveryUseCase + getRecoveryStatusUC *use_cases.GetRecoveryStatusUseCase + cancelRecoveryUC *use_cases.CancelRecoveryUseCase +} + +// NewAccountHTTPHandler creates a new AccountHTTPHandler +func NewAccountHTTPHandler( + createAccountUC *use_cases.CreateAccountUseCase, + getAccountUC *use_cases.GetAccountUseCase, + updateAccountUC *use_cases.UpdateAccountUseCase, + listAccountsUC *use_cases.ListAccountsUseCase, + getAccountSharesUC *use_cases.GetAccountSharesUseCase, + deactivateShareUC *use_cases.DeactivateShareUseCase, + loginUC *use_cases.LoginUseCase, + refreshTokenUC *use_cases.RefreshTokenUseCase, + generateChallengeUC *use_cases.GenerateChallengeUseCase, + initiateRecoveryUC *use_cases.InitiateRecoveryUseCase, + completeRecoveryUC *use_cases.CompleteRecoveryUseCase, + getRecoveryStatusUC *use_cases.GetRecoveryStatusUseCase, + cancelRecoveryUC *use_cases.CancelRecoveryUseCase, +) *AccountHTTPHandler { + return &AccountHTTPHandler{ + createAccountUC: createAccountUC, + getAccountUC: getAccountUC, + updateAccountUC: updateAccountUC, + listAccountsUC: listAccountsUC, + getAccountSharesUC: getAccountSharesUC, + deactivateShareUC: deactivateShareUC, + loginUC: loginUC, + refreshTokenUC: refreshTokenUC, + generateChallengeUC: generateChallengeUC, + initiateRecoveryUC: initiateRecoveryUC, + completeRecoveryUC: completeRecoveryUC, + getRecoveryStatusUC: getRecoveryStatusUC, + cancelRecoveryUC: cancelRecoveryUC, + } +} + +// RegisterRoutes registers HTTP routes +func (h *AccountHTTPHandler) RegisterRoutes(router *gin.RouterGroup) { + accounts := router.Group("/accounts") + { + accounts.POST("", h.CreateAccount) + accounts.GET("", h.ListAccounts) + accounts.GET("/:id", h.GetAccount) + accounts.PUT("/:id", h.UpdateAccount) + accounts.GET("/:id/shares", h.GetAccountShares) + accounts.DELETE("/:id/shares/:shareId", h.DeactivateShare) + } + + auth := router.Group("/auth") + { + auth.POST("/challenge", h.GenerateChallenge) + auth.POST("/login", h.Login) + auth.POST("/refresh", h.RefreshToken) + } + + recovery := router.Group("/recovery") + { + recovery.POST("", h.InitiateRecovery) + recovery.GET("/:id", h.GetRecoveryStatus) + recovery.POST("/:id/complete", h.CompleteRecovery) + recovery.POST("/:id/cancel", h.CancelRecovery) + } +} + +// CreateAccountRequest represents the request for creating an account +type CreateAccountRequest struct { + Username string `json:"username" binding:"required"` + Email string `json:"email" binding:"required,email"` + Phone *string `json:"phone"` + PublicKey string `json:"publicKey" binding:"required"` + KeygenSessionID string `json:"keygenSessionId" binding:"required"` + ThresholdN int `json:"thresholdN" binding:"required,min=1"` + ThresholdT int `json:"thresholdT" binding:"required,min=1"` + Shares []ShareInput `json:"shares" binding:"required,min=1"` +} + +// ShareInput represents a share in the request +type ShareInput struct { + ShareType string `json:"shareType" binding:"required"` + PartyID string `json:"partyId" binding:"required"` + PartyIndex int `json:"partyIndex" binding:"required,min=0"` + DeviceType *string `json:"deviceType"` + DeviceID *string `json:"deviceId"` +} + +// CreateAccount handles account creation +func (h *AccountHTTPHandler) CreateAccount(c *gin.Context) { + var req CreateAccountRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + keygenSessionID, err := uuid.Parse(req.KeygenSessionID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid keygen session ID"}) + return + } + + shares := make([]ports.ShareInput, len(req.Shares)) + for i, s := range req.Shares { + shares[i] = ports.ShareInput{ + ShareType: value_objects.ShareType(s.ShareType), + PartyID: s.PartyID, + PartyIndex: s.PartyIndex, + DeviceType: s.DeviceType, + DeviceID: s.DeviceID, + } + } + + output, err := h.createAccountUC.Execute(c.Request.Context(), ports.CreateAccountInput{ + Username: req.Username, + Email: req.Email, + Phone: req.Phone, + PublicKey: []byte(req.PublicKey), + KeygenSessionID: keygenSessionID, + ThresholdN: req.ThresholdN, + ThresholdT: req.ThresholdT, + Shares: shares, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "account": output.Account, + "shares": output.Shares, + }) +} + +// GetAccount handles getting account by ID +func (h *AccountHTTPHandler) GetAccount(c *gin.Context) { + idStr := c.Param("id") + accountID, err := value_objects.AccountIDFromString(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account ID"}) + return + } + + output, err := h.getAccountUC.Execute(c.Request.Context(), ports.GetAccountInput{ + AccountID: &accountID, + }) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "account": output.Account, + "shares": output.Shares, + }) +} + +// UpdateAccountRequest represents the request for updating an account +type UpdateAccountRequest struct { + Phone *string `json:"phone"` +} + +// UpdateAccount handles account updates +func (h *AccountHTTPHandler) UpdateAccount(c *gin.Context) { + idStr := c.Param("id") + accountID, err := value_objects.AccountIDFromString(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account ID"}) + return + } + + var req UpdateAccountRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + output, err := h.updateAccountUC.Execute(c.Request.Context(), ports.UpdateAccountInput{ + AccountID: accountID, + Phone: req.Phone, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, output.Account) +} + +// ListAccounts handles listing accounts +func (h *AccountHTTPHandler) ListAccounts(c *gin.Context) { + var offset, limit int + if o := c.Query("offset"); o != "" { + // Parse offset + } + if l := c.Query("limit"); l != "" { + // Parse limit + } + + output, err := h.listAccountsUC.Execute(c.Request.Context(), use_cases.ListAccountsInput{ + Offset: offset, + Limit: limit, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "accounts": output.Accounts, + "total": output.Total, + }) +} + +// GetAccountShares handles getting account shares +func (h *AccountHTTPHandler) GetAccountShares(c *gin.Context) { + idStr := c.Param("id") + accountID, err := value_objects.AccountIDFromString(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account ID"}) + return + } + + output, err := h.getAccountSharesUC.Execute(c.Request.Context(), accountID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "shares": output.Shares, + }) +} + +// DeactivateShare handles share deactivation +func (h *AccountHTTPHandler) DeactivateShare(c *gin.Context) { + idStr := c.Param("id") + accountID, err := value_objects.AccountIDFromString(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account ID"}) + return + } + + shareID := c.Param("shareId") + + err = h.deactivateShareUC.Execute(c.Request.Context(), ports.DeactivateShareInput{ + AccountID: accountID, + ShareID: shareID, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "share deactivated"}) +} + +// GenerateChallengeRequest represents the request for generating a challenge +type GenerateChallengeRequest struct { + Username string `json:"username" binding:"required"` +} + +// GenerateChallenge handles challenge generation +func (h *AccountHTTPHandler) GenerateChallenge(c *gin.Context) { + var req GenerateChallengeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + output, err := h.generateChallengeUC.Execute(c.Request.Context(), use_cases.GenerateChallengeInput{ + Username: req.Username, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "challengeId": output.ChallengeID, + "challenge": hex.EncodeToString(output.Challenge), + "expiresAt": output.ExpiresAt, + }) +} + +// LoginRequest represents the request for login +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Challenge string `json:"challenge" binding:"required"` + Signature string `json:"signature" binding:"required"` +} + +// Login handles user login +func (h *AccountHTTPHandler) Login(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Decode hex-encoded challenge and signature + challengeBytes, err := hex.DecodeString(req.Challenge) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid challenge format"}) + return + } + + signatureBytes, err := hex.DecodeString(req.Signature) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid signature format"}) + return + } + + output, err := h.loginUC.Execute(c.Request.Context(), ports.LoginInput{ + Username: req.Username, + Challenge: challengeBytes, + Signature: signatureBytes, + }) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "account": output.Account, + "accessToken": output.AccessToken, + "refreshToken": output.RefreshToken, + }) +} + +// RefreshTokenRequest represents the request for refreshing tokens +type RefreshTokenRequest struct { + RefreshToken string `json:"refreshToken" binding:"required"` +} + +// RefreshToken handles token refresh +func (h *AccountHTTPHandler) RefreshToken(c *gin.Context) { + var req RefreshTokenRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + output, err := h.refreshTokenUC.Execute(c.Request.Context(), use_cases.RefreshTokenInput{ + RefreshToken: req.RefreshToken, + }) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "accessToken": output.AccessToken, + "refreshToken": output.RefreshToken, + }) +} + +// InitiateRecoveryRequest represents the request for initiating recovery +type InitiateRecoveryRequest struct { + AccountID string `json:"accountId" binding:"required"` + RecoveryType string `json:"recoveryType" binding:"required"` + OldShareType *string `json:"oldShareType"` +} + +// InitiateRecovery handles recovery initiation +func (h *AccountHTTPHandler) InitiateRecovery(c *gin.Context) { + var req InitiateRecoveryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + accountID, err := value_objects.AccountIDFromString(req.AccountID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account ID"}) + return + } + + input := ports.InitiateRecoveryInput{ + AccountID: accountID, + RecoveryType: value_objects.RecoveryType(req.RecoveryType), + } + + if req.OldShareType != nil { + st := value_objects.ShareType(*req.OldShareType) + input.OldShareType = &st + } + + output, err := h.initiateRecoveryUC.Execute(c.Request.Context(), input) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "recoverySession": output.RecoverySession, + }) +} + +// GetRecoveryStatus handles getting recovery status +func (h *AccountHTTPHandler) GetRecoveryStatus(c *gin.Context) { + id := c.Param("id") + + output, err := h.getRecoveryStatusUC.Execute(c.Request.Context(), use_cases.GetRecoveryStatusInput{ + RecoverySessionID: id, + }) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, output.RecoverySession) +} + +// CompleteRecoveryRequest represents the request for completing recovery +type CompleteRecoveryRequest struct { + NewPublicKey string `json:"newPublicKey" binding:"required"` + NewKeygenSessionID string `json:"newKeygenSessionId" binding:"required"` + NewShares []ShareInput `json:"newShares" binding:"required,min=1"` +} + +// CompleteRecovery handles recovery completion +func (h *AccountHTTPHandler) CompleteRecovery(c *gin.Context) { + id := c.Param("id") + + var req CompleteRecoveryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + newKeygenSessionID, err := uuid.Parse(req.NewKeygenSessionID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid keygen session ID"}) + return + } + + newShares := make([]ports.ShareInput, len(req.NewShares)) + for i, s := range req.NewShares { + newShares[i] = ports.ShareInput{ + ShareType: value_objects.ShareType(s.ShareType), + PartyID: s.PartyID, + PartyIndex: s.PartyIndex, + DeviceType: s.DeviceType, + DeviceID: s.DeviceID, + } + } + + // Decode hex-encoded public key + newPublicKeyBytes, err := hex.DecodeString(req.NewPublicKey) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid public key format"}) + return + } + + output, err := h.completeRecoveryUC.Execute(c.Request.Context(), ports.CompleteRecoveryInput{ + RecoverySessionID: id, + NewPublicKey: newPublicKeyBytes, + NewKeygenSessionID: newKeygenSessionID, + NewShares: newShares, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, output.Account) +} + +// CancelRecovery handles recovery cancellation +func (h *AccountHTTPHandler) CancelRecovery(c *gin.Context) { + id := c.Param("id") + + err := h.cancelRecoveryUC.Execute(c.Request.Context(), use_cases.CancelRecoveryInput{ + RecoverySessionID: id, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "recovery cancelled"}) +}