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) {