diff --git a/server/cmd/museum/main.go b/server/cmd/museum/main.go index 6f660b217..34dbfbfc2 100644 --- a/server/cmd/museum/main.go +++ b/server/cmd/museum/main.go @@ -433,9 +433,9 @@ func main() { publicAPI.POST("/users/two-factor/passkeys/begin", userHandler.BeginPasskeyAuthenticationCeremony) publicAPI.POST("/users/two-factor/passkeys/finish", userHandler.FinishPasskeyAuthenticationCeremony) privateAPI.GET("/users/two-factor/account-recovery-status", userHandler.GetAccountRecoveryStatus) - privateAPI.POST("/users/two-factor/passkeys/set-reset-challenge", userHandler.ConfigurePassKeyRecovery) - publicAPI.GET("/users/two-factor/passkeys/reset-challenge", userHandler.GetPasskeyResetChallenge) - publicAPI.POST("/users/two-factor/passkeys/reset", userHandler.ResetPasskey) + privateAPI.POST("/users/two-factor/passkeys/set-skip-challenge", userHandler.ConfigurePassKeySkipChallenge) + publicAPI.GET("/users/two-factor/passkeys/skip-challenge", userHandler.GetPasskeySkipChallenge) + publicAPI.POST("/users/two-factor/passkeys/skip", userHandler.SkipPassKey) privateAPI.GET("/users/two-factor/status", userHandler.GetTwoFactorStatus) privateAPI.POST("/users/two-factor/setup", userHandler.SetupTwoFactor) privateAPI.POST("/users/two-factor/enable", userHandler.EnableTwoFactor) diff --git a/server/ente/passkey.go b/server/ente/passkey.go index a975b5221..3edcf52b9 100644 --- a/server/ente/passkey.go +++ b/server/ente/passkey.go @@ -13,25 +13,23 @@ type Passkey struct { var MaxPasskeys = 10 -type EnablePassKeyRecovery struct { - UserID int64 `json:"userID" binding:"required"` - ResetKey string `json:"resetKey" binding:"required"` - EncResetKey EncData `json:"encResetKey" binding:"required"` +type ConfigurePassKeySkipRequest struct { + PassKeySkipSecret string `json:"passKeySkipSecret" binding:"required"` + EncPassKeySkipSecret EncData `json:"encPassKeySkipSecret" binding:"required"` } type AccountRecoveryStatus struct { // AllowAdminReset is a boolean that determines if the admin can reset the user's MFA. // If true, in the event that the user loses their MFA device, the admin can reset the user's MFA. - AllowAdminReset bool `json:"allowAdminReset" binding:"required"` - IsPassKeyResetEnabled bool `json:"isPassKeyResetEnabled" binding:"required"` + AllowAdminReset bool `json:"allowAdminReset" binding:"required"` + IsPassKeySkipEnabled bool `json:"isPassKeySkipEnabled" binding:"required"` } -type PasseKeyResetChallengeResponse struct { - EncResetKey string `json:"encResetKey" binding:"required"` - EncResetKeyNonce string `json:"encResetKeyNonce" binding:"required"` +type PasseKeySkipChallengeResponse struct { + EncData } -type ResetPassKey struct { - SessionID string `json:"sessionID" binding:"required"` - RestKey string `json:"resetKey" binding:"required"` +type SkipPassKeyRequest struct { + SessionID string `json:"sessionID" binding:"required"` + PassKeySkipSecret string `json:"passKeySkipSecret" binding:"required"` } diff --git a/server/pkg/api/user.go b/server/pkg/api/user.go index 5461c69af..d8a73291b 100644 --- a/server/pkg/api/user.go +++ b/server/pkg/api/user.go @@ -253,16 +253,48 @@ func (h *UserHandler) GetAccountRecoveryStatus(c *gin.Context) { c.JSON(http.StatusOK, res) } -func (h *UserHandler) ConfigurePassKeyRecovery(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"message": "Not implemented"}) +// ConfigurePassKeySkipChallenge configures the passkey skip challenge for a user. In case the user does not +// have access to passkey, the user can bypass the passkey by providing the recovery key +func (h *UserHandler) ConfigurePassKeySkipChallenge(c *gin.Context) { + var request ente.ConfigurePassKeySkipRequest + if err := c.ShouldBindJSON(&request); err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + err := h.UserController.ConfigurePassKeySkip(c, &request) + if err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + c.JSON(http.StatusOK, gin.H{}) } -func (h *UserHandler) GetPasskeyResetChallenge(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"message": "Not implemented"}) +func (h *UserHandler) GetPasskeySkipChallenge(c *gin.Context) { + passKeySessionID := c.Query("passKeySessionID") + if passKeySessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"message": "passKeySessionID is required"}) + return + } + resp, err := h.UserController.GetPasskeySkipChallenge(c, passKeySessionID) + if err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + c.JSON(http.StatusOK, resp) } -func (h *UserHandler) ResetPasskey(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"message": "Not implemented"}) +func (h *UserHandler) SkipPassKey(c *gin.Context) { + var req ente.SkipPassKeyRequest + if err := c.ShouldBindJSON(&req); err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + resp, err := h.UserController.SkipPassKey(c, &req) + if err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + c.JSON(http.StatusOK, resp) } // SetupTwoFactor generates a two factor secret and sends it to user to setup his authenticator app with diff --git a/server/pkg/controller/user/passkey.go b/server/pkg/controller/user/passkey.go index 075fa056d..058d20192 100644 --- a/server/pkg/controller/user/passkey.go +++ b/server/pkg/controller/user/passkey.go @@ -3,6 +3,7 @@ package user import ( "github.com/ente-io/museum/ente" "github.com/ente-io/museum/pkg/utils/auth" + "github.com/ente-io/stacktrace" "github.com/gin-gonic/gin" ) @@ -11,3 +12,50 @@ func (c *UserController) GetAccountRecoveryStatus(ctx *gin.Context) (*ente.Accou userID := auth.GetUserID(ctx.Request.Header) return c.AccountRecoveryRepo.GetAccountRecoveryStatus(userID) } + +func (c *UserController) ConfigurePassKeySkip(ctx *gin.Context, req *ente.ConfigurePassKeySkipRequest) error { + userID := auth.GetUserID(ctx.Request.Header) + return c.AccountRecoveryRepo.ConfigurePassKeyRecovery(ctx, userID, req) +} + +func (c *UserController) GetPasskeySkipChallenge(ctx *gin.Context, passKeySessionID string) (*ente.EncData, error) { + userID, err := c.PasskeyRepo.GetUserIDWithPasskeyTwoFactorSession(passKeySessionID) + if err != nil { + return nil, err + } + recoveryStatus, err := c.AccountRecoveryRepo.GetAccountRecoveryStatus(userID) + if err != nil { + return nil, err + } + if !recoveryStatus.IsPassKeySkipEnabled { + return nil, ente.NewBadRequestWithMessage("Passkey reset is not configured") + } + + result, err := c.AccountRecoveryRepo.GetPasskeyResetChallenge(ctx, userID) + if err != nil { + return nil, err + } + if result == nil { + return nil, ente.NewBadRequestWithMessage("Passkey reset is not configured") + } + return result, nil +} + +func (c *UserController) SkipPassKey(context *gin.Context, req *ente.SkipPassKeyRequest) (*ente.TwoFactorAuthorizationResponse, error) { + userID, err := c.PasskeyRepo.GetUserIDWithPasskeyTwoFactorSession(req.SessionID) + if err != nil { + return nil, stacktrace.Propagate(err, "") + } + exists, err := c.AccountRecoveryRepo.VerifyRecoveryKeyForPassKey(userID, req.PassKeySkipSecret) + if err != nil { + return nil, stacktrace.Propagate(err, "") + } + if !exists { + return nil, stacktrace.Propagate(ente.ErrPermissionDenied, "") + } + response, err := c.GetKeyAttributeAndToken(context, userID) + if err != nil { + return nil, stacktrace.Propagate(err, "") + } + return &response, nil +} diff --git a/server/pkg/repo/accountrecovery/repository.go b/server/pkg/repo/accountrecovery/repository.go index b04909f28..8b81e67f5 100644 --- a/server/pkg/repo/accountrecovery/repository.go +++ b/server/pkg/repo/accountrecovery/repository.go @@ -1,8 +1,10 @@ package accountrecovery import ( + "context" "database/sql" "github.com/ente-io/museum/ente" + "github.com/ente-io/stacktrace" ) type Repository struct { @@ -19,11 +21,38 @@ func (r *Repository) GetAccountRecoveryStatus(userID int64) (*ente.AccountRecove if err == sql.ErrNoRows { // by default, admin return &ente.AccountRecoveryStatus{ - AllowAdminReset: true, - IsPassKeyResetEnabled: false, + AllowAdminReset: true, + IsPassKeySkipEnabled: false, }, nil } return nil, err } - return &ente.AccountRecoveryStatus{AllowAdminReset: isAdminResetEnabled, IsPassKeyResetEnabled: resetKey.Valid}, nil + return &ente.AccountRecoveryStatus{AllowAdminReset: isAdminResetEnabled, IsPassKeySkipEnabled: resetKey.Valid}, nil +} + +func (r *Repository) ConfigurePassKeyRecovery(ctx context.Context, userID int64, req *ente.ConfigurePassKeySkipRequest) error { + _, err := r.Db.ExecContext(ctx, `INSERT INTO account_recovery (user_id, pass_key_reset_key, pass_key_reset_enc_data) + VALUES ($1, $2,$3) ON CONFLICT (user_id) + DO UPDATE SET pass_key_reset_key = $2, pass_key_reset_enc_data = $3`, userID, req.PassKeySkipKey, req.EncPassKeySkipSecret) + return err +} + +func (r *Repository) GetPasskeyResetChallenge(ctx context.Context, userID int64) (*ente.EncData, error) { + var encData *ente.EncData + err := r.Db.QueryRowContext(ctx, "SELECT pass_key_reset_enc_data FROM account_recovery WHERE user_id= $1", userID).Scan(encData) + if err != nil { + return nil, err + } + return encData, nil +} + +// VerifyRecoveryKeyForPassKey checks if the passkey reset key is valid for a user +func (r *Repository) VerifyRecoveryKeyForPassKey(userID int64, passKeyResetKey string) (bool, error) { + var exists bool + row := r.Db.QueryRow(`SELECT EXISTS( SELECT 1 FROM account_recovery WHERE user_id = $1 AND pass_key_reset_key = $2)`, userID, passKeyResetKey) + err := row.Scan(&exists) + if err != nil { + return false, stacktrace.Propagate(err, "") + } + return exists, nil }