From 74072b952d11a6a7f83a72cec44845cc7ec7e0b3 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 18 Mar 2025 11:43:45 +0530 Subject: [PATCH 01/12] Add JWT model for account recovery --- server/ente/jwt/jwt.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/server/ente/jwt/jwt.go b/server/ente/jwt/jwt.go index 34511f5e3c..b7d7b9d20a 100644 --- a/server/ente/jwt/jwt.go +++ b/server/ente/jwt/jwt.go @@ -13,6 +13,7 @@ const ( FAMILIES ClaimScope = "FAMILIES" ACCOUNTS ClaimScope = "ACCOUNTS" DELETE_ACCOUNT ClaimScope = "DELETE_ACCOUNT" + RestoreAccount ClaimScope = "RestoreAccount" ) func (c ClaimScope) Ptr() *ClaimScope { @@ -51,3 +52,9 @@ func (c PublicAlbumPasswordClaim) Valid() error { } return nil } + +type AccountRecoveryParams struct { + Email string `json:"email"` + ExpiryTime int64 `json:"expiryTime"` + UserID int64 `json:"userID"` +} From a1dbdfd6ba6afd42ff794d7d1f37f254ea935678 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 18 Mar 2025 14:22:15 +0530 Subject: [PATCH 02/12] Reuse existing claim for recovery --- server/ente/jwt/jwt.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/server/ente/jwt/jwt.go b/server/ente/jwt/jwt.go index b7d7b9d20a..567ff6bfb2 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" ) @@ -23,6 +22,7 @@ func (c ClaimScope) Ptr() *ClaimScope { type WebCommonJWTClaim struct { UserID int64 `json:"userID"` ExpiryTime int64 `json:"expiryTime"` + Email string `json:"email"` ClaimScope *ClaimScope `json:"claimScope"` } @@ -52,9 +52,3 @@ func (c PublicAlbumPasswordClaim) Valid() error { } return nil } - -type AccountRecoveryParams struct { - Email string `json:"email"` - ExpiryTime int64 `json:"expiryTime"` - UserID int64 `json:"userID"` -} From 162ce32b8ed8537672e40a25c372fdd0097e5c43 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 18 Mar 2025 14:23:54 +0530 Subject: [PATCH 03/12] omitEmpty field from claim json --- server/ente/jwt/jwt.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/ente/jwt/jwt.go b/server/ente/jwt/jwt.go index 567ff6bfb2..94cfa995f2 100644 --- a/server/ente/jwt/jwt.go +++ b/server/ente/jwt/jwt.go @@ -20,10 +20,10 @@ func (c ClaimScope) Ptr() *ClaimScope { } type WebCommonJWTClaim struct { - UserID int64 `json:"userID"` - ExpiryTime int64 `json:"expiryTime"` - Email string `json:"email"` - 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 { From 47f0c88ed8985a873c95ff191ace9966c1344aad Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 18 Mar 2025 14:27:39 +0530 Subject: [PATCH 04/12] Extract method --- server/pkg/controller/user/jwt.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/server/pkg/controller/user/jwt.go b/server/pkg/controller/user/jwt.go index d804f4cef3..9cfe61972a 100644 --- a/server/pkg/controller/user/jwt.go +++ b/server/pkg/controller/user/jwt.go @@ -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) From 2e3ac8b4855c3d6d30ce885f62a69c0960712a8c Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 18 Mar 2025 14:30:24 +0530 Subject: [PATCH 05/12] Return complete claim instead of userID --- server/pkg/controller/user/jwt.go | 10 +++++----- server/pkg/controller/user/user_delete.go | 4 ++-- server/pkg/middleware/auth.go | 7 ++++++- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/server/pkg/controller/user/jwt.go b/server/pkg/controller/user/jwt.go index 9cfe61972a..c52ba61cc7 100644 --- a/server/pkg/controller/user/jwt.go +++ b/server/pkg/controller/user/jwt.go @@ -38,7 +38,7 @@ func (c *UserController) GetJWTTokenForClaim(claim *enteJWT.WebCommonJWTClaim) ( 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"]), "") @@ -46,14 +46,14 @@ func (c *UserController) ValidateJWTToken(jwtToken string, scope enteJWT.ClaimSc return c.JwtSecret, nil }) if err != nil { - return -1, stacktrace.Propagate(err, "JWT parsed failed") + 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_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) { From c6b4cba8b49d88a20c49aa7c53b42a67befabe64 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Sat, 12 Apr 2025 13:47:46 +0530 Subject: [PATCH 06/12] [server] Auto recovery post deletion 1/x --- server/cmd/museum/main.go | 1 + server/pkg/api/user.go | 14 ++++++++++++ server/pkg/controller/user/user.go | 34 ++++++++++++++++++++++++++---- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/server/cmd/museum/main.go b/server/cmd/museum/main.go index f3a92cf210..940f34459e 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.RecoveryAccount) accountsJwtAuthAPI := server.Group("/") accountsJwtAuthAPI.Use(rateLimiter.GlobalRateLimiter(), authMiddleware.TokenAuthMiddleware(jwt.ACCOUNTS.Ptr()), rateLimiter.APIRateLimitForUserMiddleware(urlSanitizer)) diff --git a/server/pkg/api/user.go b/server/pkg/api/user.go index c62613ccb7..f1a11a6b82 100644 --- a/server/pkg/api/user.go +++ b/server/pkg/api/user.go @@ -540,6 +540,20 @@ func (h *UserHandler) DeleteUser(c *gin.Context) { c.JSON(http.StatusOK, response) } +func (h *UserHandler) RecoveryAccount(c *gin.Context) { + token := c.Query("token") + if token == "" { + handler.Error(c, stacktrace.Propagate(ente.NewBadRequestWithMessage("token missing"), "token is required")) + return + } + response, err := h.UserController.SelfDeleteAccount(c, request) + if err != nil { + handler.Error(c, stacktrace.Propagate(err, "")) + return + } + c.JSON(http.StatusOK, response) +} + // 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/user.go b/server/pkg/controller/user/user.go index c72d50546b..4917b29a0b 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,43 @@ 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.NDaysFromNow(7), + 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["RecoverLink"] = fmt.Sprintf("%s/user/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) 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("recover account request") _, 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: "User ID is linked to an active account account", }), "") } if !errors.Is(err, ente.ErrUserDeleted) { @@ -304,6 +327,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) From 7732f9eee9cea6b49f3b28f4f080dd03af257785 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Fri, 25 Apr 2025 10:03:03 +0530 Subject: [PATCH 07/12] Fix case --- .../IconOG.appiconset/IconOGTinted.png | Bin 0 -> 13854 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 mobile/ios/Runner/Assets.xcassets/IconOG.appiconset/IconOGTinted.png diff --git a/mobile/ios/Runner/Assets.xcassets/IconOG.appiconset/IconOGTinted.png b/mobile/ios/Runner/Assets.xcassets/IconOG.appiconset/IconOGTinted.png new file mode 100644 index 0000000000000000000000000000000000000000..9bc5fb67214201887f1cd2e77b9e4a46b200f2d9 GIT binary patch literal 13854 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 Date: Fri, 25 Apr 2025 11:37:25 +0530 Subject: [PATCH 08/12] Add link in delete email to auto-recover account --- server/cmd/museum/main.go | 2 +- server/mail-templates/account_deleted.html | 14 ++++++----- .../account_deleted_active_sub.html | 7 +++--- server/pkg/api/user.go | 8 +++--- server/pkg/controller/user/user.go | 25 +++++++++++++++++-- 5 files changed, 41 insertions(+), 15 deletions(-) diff --git a/server/cmd/museum/main.go b/server/cmd/museum/main.go index 940f34459e..aa117c6763 100644 --- a/server/cmd/museum/main.go +++ b/server/cmd/museum/main.go @@ -519,7 +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.RecoveryAccount) + publicAPI.GET("/users/recover-account", userHandler.SelfRecoverAccount) accountsJwtAuthAPI := server.Group("/") accountsJwtAuthAPI.Use(rateLimiter.GlobalRateLimiter(), authMiddleware.TokenAuthMiddleware(jwt.ACCOUNTS.Ptr()), rateLimiter.APIRateLimitForUserMiddleware(urlSanitizer)) 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 f1a11a6b82..119357072e 100644 --- a/server/pkg/api/user.go +++ b/server/pkg/api/user.go @@ -540,18 +540,20 @@ func (h *UserHandler) DeleteUser(c *gin.Context) { c.JSON(http.StatusOK, response) } -func (h *UserHandler) RecoveryAccount(c *gin.Context) { +func (h *UserHandler) SelfRecoverAccount(c *gin.Context) { token := c.Query("token") if token == "" { handler.Error(c, stacktrace.Propagate(ente.NewBadRequestWithMessage("token missing"), "token is required")) return } - response, err := h.UserController.SelfDeleteAccount(c, request) + err := h.UserController.HandleSelfAccountRecovery(c, token) if err != nil { handler.Error(c, stacktrace.Propagate(err, "")) return } - c.JSON(http.StatusOK, response) + c.JSON(http.StatusOK, gin.H{ + "message": "Account recovery successful", + }) } // GetSRPAttributes returns the SRP attributes for a user diff --git a/server/pkg/controller/user/user.go b/server/pkg/controller/user/user.go index 4917b29a0b..f0bd13f1ce 100644 --- a/server/pkg/controller/user/user.go +++ b/server/pkg/controller/user/user.go @@ -300,13 +300,34 @@ func (c *UserController) NotifyAccountDeletion(userID int64, userEmail string, i } templateData := make(map[string]interface{}) - templateData["RecoverLink"] = fmt.Sprintf("%s/user/recover-account?token=%s", "https://api.ente.io", recoverToken) + templateData["AccountRecoveryLink"] = fmt.Sprintf("%s/users/recover-account?token=%s", "https://api.ente.io", recoverToken) + logrus.Info("account recovery link: ", templateData["AccountRecoveryLink"]) err := email.SendTemplatedEmail([]string{userEmail}, "ente", "team@ente.io", 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 err + } + 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{ @@ -319,7 +340,7 @@ func (c *UserController) HandleAccountRecovery(ctx *gin.Context, req ente.Recove _, err := c.UserRepo.Get(req.UserID) if err == nil { return stacktrace.Propagate(ente.NewBadRequestError(&ente.ApiErrorParams{ - Message: "User ID is linked to an active account account", + Message: "Either account already recovered or User ID is linked to an active account account", }), "") } if !errors.Is(err, ente.ErrUserDeleted) { From 6ae9003585dd66ebd656a7d53621cd84868b8764 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Fri, 25 Apr 2025 11:39:36 +0530 Subject: [PATCH 09/12] rename --- server/cmd/museum/main.go | 2 +- server/pkg/api/user.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/cmd/museum/main.go b/server/cmd/museum/main.go index aa117c6763..ca75f3adda 100644 --- a/server/cmd/museum/main.go +++ b/server/cmd/museum/main.go @@ -519,7 +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.SelfRecoverAccount) + 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/pkg/api/user.go b/server/pkg/api/user.go index 119357072e..a3cb4ec16f 100644 --- a/server/pkg/api/user.go +++ b/server/pkg/api/user.go @@ -540,7 +540,7 @@ func (h *UserHandler) DeleteUser(c *gin.Context) { c.JSON(http.StatusOK, response) } -func (h *UserHandler) SelfRecoverAccount(c *gin.Context) { +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")) From c32e4be8be7e5cf4e0e4ac9044e2a7b4cd429f97 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Fri, 25 Apr 2025 11:57:23 +0530 Subject: [PATCH 10/12] copy change --- server/pkg/controller/user/user.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/server/pkg/controller/user/user.go b/server/pkg/controller/user/user.go index f0bd13f1ce..167e3e5a32 100644 --- a/server/pkg/controller/user/user.go +++ b/server/pkg/controller/user/user.go @@ -301,7 +301,6 @@ func (c *UserController) NotifyAccountDeletion(userID int64, userEmail string, i templateData := make(map[string]interface{}) templateData["AccountRecoveryLink"] = fmt.Sprintf("%s/users/recover-account?token=%s", "https://api.ente.io", recoverToken) - logrus.Info("account recovery link: ", templateData["AccountRecoveryLink"]) err := email.SendTemplatedEmail([]string{userEmail}, "ente", "team@ente.io", AccountDeletedEmailSubject, template, templateData, nil) if err != nil { @@ -336,11 +335,11 @@ func (c *UserController) HandleAccountRecovery(ctx *gin.Context, req ente.Recove "email": req.EmailID, "userID": req.UserID, }) - logger.Info("recover account request") + logger.Info("initiating account recovery") _, err := c.UserRepo.Get(req.UserID) if err == nil { return stacktrace.Propagate(ente.NewBadRequestError(&ente.ApiErrorParams{ - Message: "Either account already recovered or User ID is linked to an active account account", + Message: "account is already recovered or userID is linked to another active account", }), "") } if !errors.Is(err, ente.ErrUserDeleted) { From 31f6671626594b2980cf9ea95256035e93ed795f Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Fri, 25 Apr 2025 12:11:43 +0530 Subject: [PATCH 11/12] Gracefully handle bad or expired tokens --- server/ente/errors.go | 8 ++++++++ server/pkg/controller/user/jwt.go | 5 ++++- server/pkg/controller/user/user.go | 5 +++-- 3 files changed, 15 insertions(+), 3 deletions(-) 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/pkg/controller/user/jwt.go b/server/pkg/controller/user/jwt.go index c52ba61cc7..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" @@ -46,6 +46,9 @@ func (c *UserController) ValidateJWTToken(jwtToken string, scope enteJWT.ClaimSc return c.JwtSecret, nil }) if err != nil { + 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) diff --git a/server/pkg/controller/user/user.go b/server/pkg/controller/user/user.go index 167e3e5a32..6f49e6ed12 100644 --- a/server/pkg/controller/user/user.go +++ b/server/pkg/controller/user/user.go @@ -290,7 +290,7 @@ func (c *UserController) NotifyAccountDeletion(userID int64, userEmail string, i } recoverToken, err2 := c.GetJWTTokenForClaim(&enteJWT.WebCommonJWTClaim{ UserID: userID, - ExpiryTime: time.NDaysFromNow(7), + ExpiryTime: time.Microseconds(), ClaimScope: enteJWT.RestoreAccount.Ptr(), Email: userEmail, }) @@ -301,6 +301,7 @@ func (c *UserController) NotifyAccountDeletion(userID int64, userEmail string, i templateData := make(map[string]interface{}) templateData["AccountRecoveryLink"] = fmt.Sprintf("%s/users/recover-account?token=%s", "https://api.ente.io", recoverToken) + logrus.Infof("Account recovery link: %s", templateData["AccountRecoveryLink"]) err := email.SendTemplatedEmail([]string{userEmail}, "ente", "team@ente.io", AccountDeletedEmailSubject, template, templateData, nil) if err != nil { @@ -310,7 +311,7 @@ func (c *UserController) NotifyAccountDeletion(userID int64, userEmail string, i func (c *UserController) HandleSelfAccountRecovery(ctx *gin.Context, token string) error { jwtToken, err := c.ValidateJWTToken(token, enteJWT.RestoreAccount) if err != nil { - return err + 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{ From 9b15ab2f2fe3ecbb43a574b6bbb2b8f1880f86b2 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Fri, 25 Apr 2025 12:12:26 +0530 Subject: [PATCH 12/12] Remove log --- server/pkg/controller/user/user.go | 1 - 1 file changed, 1 deletion(-) diff --git a/server/pkg/controller/user/user.go b/server/pkg/controller/user/user.go index 6f49e6ed12..a86fc633a9 100644 --- a/server/pkg/controller/user/user.go +++ b/server/pkg/controller/user/user.go @@ -301,7 +301,6 @@ func (c *UserController) NotifyAccountDeletion(userID int64, userEmail string, i templateData := make(map[string]interface{}) templateData["AccountRecoveryLink"] = fmt.Sprintf("%s/users/recover-account?token=%s", "https://api.ente.io", recoverToken) - logrus.Infof("Account recovery link: %s", templateData["AccountRecoveryLink"]) err := email.SendTemplatedEmail([]string{userEmail}, "ente", "team@ente.io", AccountDeletedEmailSubject, template, templateData, nil) if err != nil {