diff --git a/server/cmd/museum/main.go b/server/cmd/museum/main.go index 4cbea32a14..5b1671619e 100644 --- a/server/cmd/museum/main.go +++ b/server/cmd/museum/main.go @@ -519,6 +519,7 @@ func main() { privateAPI.DELETE("/users/session", userHandler.TerminateSession) privateAPI.GET("/users/delete-challenge", userHandler.GetDeleteChallenge) privateAPI.DELETE("/users/delete", userHandler.DeleteUser) + publicAPI.GET("/users/recover-account", userHandler.SelfAccountRecovery) accountsJwtAuthAPI := server.Group("/") accountsJwtAuthAPI.Use(rateLimiter.GlobalRateLimiter(), authMiddleware.TokenAuthMiddleware(jwt.ACCOUNTS.Ptr()), rateLimiter.APIRateLimitForUserMiddleware(urlSanitizer)) diff --git a/server/ente/errors.go b/server/ente/errors.go index 56d341571a..4a5a02fb47 100644 --- a/server/ente/errors.go +++ b/server/ente/errors.go @@ -279,6 +279,14 @@ func NewBadRequestWithMessage(message string) *ApiError { } } +func NewPermissionDeniedError(message string) *ApiError { + return &ApiError{ + Code: "PERMISSION_DENIED", + HttpStatusCode: http.StatusForbidden, + Message: message, + } +} + func NewConflictError(message string) *ApiError { return &ApiError{ Code: CONFLICT, diff --git a/server/ente/jwt/jwt.go b/server/ente/jwt/jwt.go index 34511f5e3c..94cfa995f2 100644 --- a/server/ente/jwt/jwt.go +++ b/server/ente/jwt/jwt.go @@ -2,7 +2,6 @@ package jwt import ( "errors" - "github.com/ente-io/museum/pkg/utils/time" ) @@ -13,6 +12,7 @@ const ( FAMILIES ClaimScope = "FAMILIES" ACCOUNTS ClaimScope = "ACCOUNTS" DELETE_ACCOUNT ClaimScope = "DELETE_ACCOUNT" + RestoreAccount ClaimScope = "RestoreAccount" ) func (c ClaimScope) Ptr() *ClaimScope { @@ -20,9 +20,10 @@ func (c ClaimScope) Ptr() *ClaimScope { } type WebCommonJWTClaim struct { - UserID int64 `json:"userID"` - ExpiryTime int64 `json:"expiryTime"` - ClaimScope *ClaimScope `json:"claimScope"` + UserID int64 `json:"userID,omitempty"` + ExpiryTime int64 `json:"expiryTime,omitempty"` + Email string `json:"email,omitempty"` + ClaimScope *ClaimScope `json:"claimScope,omitempty"` } func (w *WebCommonJWTClaim) GetScope() ClaimScope { diff --git a/server/mail-templates/account_deleted.html b/server/mail-templates/account_deleted.html index e9963746f9..830e41a6d0 100644 --- a/server/mail-templates/account_deleted.html +++ b/server/mail-templates/account_deleted.html @@ -101,15 +101,17 @@ font-size: 16px; " >

Hey,

-

As requested by you, we've deleted your Ente account and scheduled your - uploaded data for deletion.

+

As requested, we've deleted your Ente account and scheduled your uploaded data for deletion.

-

If you accidentally deleted your account, please contact our support - immediately to try and recover your uploaded data before the next - scheduled deletion happens.

+

If this was a mistake, please click here to + recover your account.

-

Thank you for checking out Ente, we wish you a great time ahead!

+

After 7 days, your data will be permanently deleted. If you need help, contact our support team.

+ +

Thank you for trying Ente. We hope to see you again!

+ +
diff --git a/server/pkg/api/user.go b/server/pkg/api/user.go index c62613ccb7..a3cb4ec16f 100644 --- a/server/pkg/api/user.go +++ b/server/pkg/api/user.go @@ -540,6 +540,22 @@ func (h *UserHandler) DeleteUser(c *gin.Context) { c.JSON(http.StatusOK, response) } +func (h *UserHandler) SelfAccountRecovery(c *gin.Context) { + token := c.Query("token") + if token == "" { + handler.Error(c, stacktrace.Propagate(ente.NewBadRequestWithMessage("token missing"), "token is required")) + return + } + err := h.UserController.HandleSelfAccountRecovery(c, token) + if err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "Account recovery successful", + }) +} + // GetSRPAttributes returns the SRP attributes for a user func (h *UserHandler) GetSRPAttributes(c *gin.Context) { var request ente.GetSRPAttributesRequest diff --git a/server/pkg/controller/user/jwt.go b/server/pkg/controller/user/jwt.go index d804f4cef3..2e19805050 100644 --- a/server/pkg/controller/user/jwt.go +++ b/server/pkg/controller/user/jwt.go @@ -2,7 +2,7 @@ package user import ( "fmt" - + "github.com/ente-io/museum/ente" enteJWT "github.com/ente-io/museum/ente/jwt" "github.com/ente-io/museum/pkg/utils/time" "github.com/ente-io/stacktrace" @@ -17,13 +17,18 @@ func (c *UserController) GetJWTToken(userID int64, scope enteJWT.ClaimScope) (st if scope == enteJWT.ACCOUNTS { tokenExpiry = time.NMinFromNow(30) } - // Create a new token object, specifying signing method and the claims - // you would like it to contain. - token := jwt.NewWithClaims(jwt.SigningMethodHS256, &enteJWT.WebCommonJWTClaim{ + claim := enteJWT.WebCommonJWTClaim{ UserID: userID, ExpiryTime: tokenExpiry, ClaimScope: &scope, - }) + } + return c.GetJWTTokenForClaim(&claim) +} + +func (c *UserController) GetJWTTokenForClaim(claim *enteJWT.WebCommonJWTClaim) (string, error) { + // Create a new token object, specifying signing method and the claims + // you would like it to contain. + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claim) // Sign and get the complete encoded token as a string using the secret tokenString, err := token.SignedString(c.JwtSecret) @@ -33,7 +38,7 @@ func (c *UserController) GetJWTToken(userID int64, scope enteJWT.ClaimScope) (st return tokenString, nil } -func (c *UserController) ValidateJWTToken(jwtToken string, scope enteJWT.ClaimScope) (int64, error) { +func (c *UserController) ValidateJWTToken(jwtToken string, scope enteJWT.ClaimScope) (*enteJWT.WebCommonJWTClaim, error) { token, err := jwt.ParseWithClaims(jwtToken, &enteJWT.WebCommonJWTClaim{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, stacktrace.Propagate(fmt.Errorf("unexpected signing method: %v", token.Header["alg"]), "") @@ -41,14 +46,17 @@ func (c *UserController) ValidateJWTToken(jwtToken string, scope enteJWT.ClaimSc return c.JwtSecret, nil }) if err != nil { - return -1, stacktrace.Propagate(err, "JWT parsed failed") + if ve, ok := err.(*jwt.ValidationError); ok && ve.Error() == "token expired" { + return nil, stacktrace.Propagate(ente.NewBadRequestWithMessage("token expired"), "") + } + return nil, stacktrace.Propagate(err, "JWT parsed failed") } claims, ok := token.Claims.(*enteJWT.WebCommonJWTClaim) if ok && token.Valid { if claims.GetScope() != scope { - return -1, stacktrace.Propagate(fmt.Errorf("recived claimScope %s is different than expected scope: %s", claims.GetScope(), scope), "") + return nil, stacktrace.Propagate(fmt.Errorf("recived claimScope %s is different than expected scope: %s", claims.GetScope(), scope), "") } - return claims.UserID, nil + return claims, nil } - return -1, stacktrace.Propagate(err, "JWT claim failed") + return nil, stacktrace.Propagate(err, "JWT claim failed") } diff --git a/server/pkg/controller/user/user.go b/server/pkg/controller/user/user.go index c72d50546b..a86fc633a9 100644 --- a/server/pkg/controller/user/user.go +++ b/server/pkg/controller/user/user.go @@ -1,10 +1,13 @@ package user import ( + "database/sql" "errors" "fmt" + enteJWT "github.com/ente-io/museum/ente/jwt" "github.com/ente-io/museum/pkg/controller/collections" "github.com/ente-io/museum/pkg/repo/two_factor_recovery" + "github.com/ente-io/museum/pkg/utils/time" "strings" cache2 "github.com/ente-io/museum/ente/cache" @@ -271,7 +274,7 @@ func (c *UserController) HandleAccountDeletion(ctx *gin.Context, userID int64, l return nil, stacktrace.Propagate(err, "") } - go c.NotifyAccountDeletion(email, isSubscriptionCancelled) + go c.NotifyAccountDeletion(userID, email, isSubscriptionCancelled) return &ente.DeleteAccountResponse{ IsSubscriptionCancelled: isSubscriptionCancelled, @@ -280,23 +283,63 @@ func (c *UserController) HandleAccountDeletion(ctx *gin.Context, userID int64, l } -func (c *UserController) NotifyAccountDeletion(userEmail string, isSubscriptionCancelled bool) { +func (c *UserController) NotifyAccountDeletion(userID int64, userEmail string, isSubscriptionCancelled bool) { template := AccountDeletedEmailTemplate if !isSubscriptionCancelled { template = AccountDeletedWithActiveSubscriptionEmailTemplate } + recoverToken, err2 := c.GetJWTTokenForClaim(&enteJWT.WebCommonJWTClaim{ + UserID: userID, + ExpiryTime: time.Microseconds(), + ClaimScope: enteJWT.RestoreAccount.Ptr(), + Email: userEmail, + }) + if err2 != nil { + logrus.WithError(err2).Error("failed to generate recover token") + return + } + + templateData := make(map[string]interface{}) + templateData["AccountRecoveryLink"] = fmt.Sprintf("%s/users/recover-account?token=%s", "https://api.ente.io", recoverToken) err := email.SendTemplatedEmail([]string{userEmail}, "ente", "team@ente.io", - AccountDeletedEmailSubject, template, nil, nil) + AccountDeletedEmailSubject, template, templateData, nil) if err != nil { logrus.WithError(err).Errorf("Failed to send the account deletion email to %s", userEmail) } } +func (c *UserController) HandleSelfAccountRecovery(ctx *gin.Context, token string) error { + jwtToken, err := c.ValidateJWTToken(token, enteJWT.RestoreAccount) + if err != nil { + return stacktrace.Propagate(ente.NewPermissionDeniedError("invalid token"), fmt.Sprintf("failed to validate jwt token: %s", err.Error())) + } + if jwtToken.UserID == 0 || jwtToken.Email == "" { + return stacktrace.Propagate(ente.NewBadRequestError(&ente.ApiErrorParams{ + Message: "Invalid token", + }), "userID or email is empty") + } + if jwtToken.ExpiryTime < time.Microseconds() { + return stacktrace.Propagate(ente.NewBadRequestError(&ente.ApiErrorParams{ + Message: "Token expired", + }), "") + } + return c.HandleAccountRecovery(ctx, ente.RecoverAccountRequest{ + UserID: jwtToken.UserID, + EmailID: jwtToken.Email, + }) +} func (c *UserController) HandleAccountRecovery(ctx *gin.Context, req ente.RecoverAccountRequest) error { + logger := logrus.WithFields(logrus.Fields{ + "req_id": ctx.GetString("req_id"), + "req_ctx": "account_recovery", + "email": req.EmailID, + "userID": req.UserID, + }) + logger.Info("initiating account recovery") _, err := c.UserRepo.Get(req.UserID) if err == nil { return stacktrace.Propagate(ente.NewBadRequestError(&ente.ApiErrorParams{ - Message: "User ID is linked to undeleted account", + Message: "account is already recovered or userID is linked to another active account", }), "") } if !errors.Is(err, ente.ErrUserDeleted) { @@ -304,6 +347,9 @@ func (c *UserController) HandleAccountRecovery(ctx *gin.Context, req ente.Recove } // check if the user keyAttributes are still available if _, keyErr := c.UserRepo.GetKeyAttributes(req.UserID); keyErr != nil { + if errors.Is(keyErr, sql.ErrNoRows) { + return stacktrace.Propagate(ente.NewBadRequestWithMessage("account can not be recovered now"), "") + } return stacktrace.Propagate(keyErr, "keyAttributes missing? Account can not be recovered") } email := strings.ToLower(req.EmailID) diff --git a/server/pkg/controller/user/user_delete.go b/server/pkg/controller/user/user_delete.go index 8dddec5727..a7257174a3 100644 --- a/server/pkg/controller/user/user_delete.go +++ b/server/pkg/controller/user/user_delete.go @@ -63,11 +63,11 @@ func (c *UserController) GetDeleteChallengeToken(ctx *gin.Context) (*ente.Delete func (c *UserController) SelfDeleteAccount(ctx *gin.Context, req ente.DeleteAccountRequest) (*ente.DeleteAccountResponse, error) { userID := auth.GetUserID(ctx.Request.Header) - tokenUserID, err := c.ValidateJWTToken(req.Challenge, enteJWT.DELETE_ACCOUNT) + claim, err := c.ValidateJWTToken(req.Challenge, enteJWT.DELETE_ACCOUNT) if err != nil { return nil, stacktrace.Propagate(err, "failed to validate jwt token") } - if tokenUserID != userID { + if claim.UserID != userID { return nil, stacktrace.Propagate(ente.ErrPermissionDenied, "jwtToken belongs to different user") } user, err := c.UserRepo.Get(userID) diff --git a/server/pkg/middleware/auth.go b/server/pkg/middleware/auth.go index 3113043a58..cd3c387fce 100644 --- a/server/pkg/middleware/auth.go +++ b/server/pkg/middleware/auth.go @@ -48,7 +48,12 @@ func (m *AuthMiddleware) TokenAuthMiddleware(jwtClaimScope *jwt.ClaimScope) gin. var err error if !found { if isJWT { - userID, err = m.UserController.ValidateJWTToken(token, *jwtClaimScope) + claim, claimErr := m.UserController.ValidateJWTToken(token, *jwtClaimScope) + if claimErr != nil { + err = claimErr + } else { + userID = claim.UserID + } } else { userID, err = m.UserAuthRepo.GetUserIDWithToken(token, app) if err != nil && !errors.Is(err, sql.ErrNoRows) {