[server] Support for self-recovery on account deletion (#5712)

## Description

## Tests
This commit is contained in:
Neeraj 2025-04-25 13:39:49 +05:30 committed by GitHub
commit 39509813c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 118 additions and 30 deletions

View File

@ -519,6 +519,7 @@ func main() {
privateAPI.DELETE("/users/session", userHandler.TerminateSession) privateAPI.DELETE("/users/session", userHandler.TerminateSession)
privateAPI.GET("/users/delete-challenge", userHandler.GetDeleteChallenge) privateAPI.GET("/users/delete-challenge", userHandler.GetDeleteChallenge)
privateAPI.DELETE("/users/delete", userHandler.DeleteUser) privateAPI.DELETE("/users/delete", userHandler.DeleteUser)
publicAPI.GET("/users/recover-account", userHandler.SelfAccountRecovery)
accountsJwtAuthAPI := server.Group("/") accountsJwtAuthAPI := server.Group("/")
accountsJwtAuthAPI.Use(rateLimiter.GlobalRateLimiter(), authMiddleware.TokenAuthMiddleware(jwt.ACCOUNTS.Ptr()), rateLimiter.APIRateLimitForUserMiddleware(urlSanitizer)) accountsJwtAuthAPI.Use(rateLimiter.GlobalRateLimiter(), authMiddleware.TokenAuthMiddleware(jwt.ACCOUNTS.Ptr()), rateLimiter.APIRateLimitForUserMiddleware(urlSanitizer))

View File

@ -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 { func NewConflictError(message string) *ApiError {
return &ApiError{ return &ApiError{
Code: CONFLICT, Code: CONFLICT,

View File

@ -2,7 +2,6 @@ package jwt
import ( import (
"errors" "errors"
"github.com/ente-io/museum/pkg/utils/time" "github.com/ente-io/museum/pkg/utils/time"
) )
@ -13,6 +12,7 @@ const (
FAMILIES ClaimScope = "FAMILIES" FAMILIES ClaimScope = "FAMILIES"
ACCOUNTS ClaimScope = "ACCOUNTS" ACCOUNTS ClaimScope = "ACCOUNTS"
DELETE_ACCOUNT ClaimScope = "DELETE_ACCOUNT" DELETE_ACCOUNT ClaimScope = "DELETE_ACCOUNT"
RestoreAccount ClaimScope = "RestoreAccount"
) )
func (c ClaimScope) Ptr() *ClaimScope { func (c ClaimScope) Ptr() *ClaimScope {
@ -20,9 +20,10 @@ func (c ClaimScope) Ptr() *ClaimScope {
} }
type WebCommonJWTClaim struct { type WebCommonJWTClaim struct {
UserID int64 `json:"userID"` UserID int64 `json:"userID,omitempty"`
ExpiryTime int64 `json:"expiryTime"` ExpiryTime int64 `json:"expiryTime,omitempty"`
ClaimScope *ClaimScope `json:"claimScope"` Email string `json:"email,omitempty"`
ClaimScope *ClaimScope `json:"claimScope,omitempty"`
} }
func (w *WebCommonJWTClaim) GetScope() ClaimScope { func (w *WebCommonJWTClaim) GetScope() ClaimScope {

View File

@ -101,15 +101,17 @@
font-size: 16px; " > font-size: 16px; " >
<p>Hey,</p> <p>Hey,</p>
<p>As requested by you, we've deleted your Ente account and scheduled your <p>As requested, we've deleted your Ente account and scheduled your uploaded data for deletion.</p>
uploaded data for deletion.</p>
<p>If you accidentally deleted your account, please contact our support <p>If this was a mistake, please click here to
immediately to try and recover your uploaded data before the next <a href={{.AccountRecoveryLink}} style="color: #37C066; font-weight: bold;">recover your account</a>.</p>
scheduled deletion happens.</p>
<p> Thank you for checking out Ente, we wish you a great time ahead!</p> <p>After 7 days, your data will be permanently deleted. If you need help, contact our support team.</p>
<p>Thank you for trying Ente. We hope to see you again!</p>
</div> </div>
<br /> <br />
<div class="footer" style="text-align: center; font-size: 12px; color: <div class="footer" style="text-align: center; font-size: 12px; color:
rgb(136, 136, 136)" > rgb(136, 136, 136)" >

View File

@ -105,9 +105,10 @@
uploaded data for deletion. If you have an App Store subscription for uploaded data for deletion. If you have an App Store subscription for
Ente, please remember to cancel it too.</p> Ente, please remember to cancel it too.</p>
<p>If you accidentally deleted your account, please contact our support <p>If this was a mistake, please click here to
immediately to try and recover your uploaded data before the next <a href={{.AccountRecoveryLink}} style="color: #37C066; font-weight: bold;">recover your account</a>.</p>
scheduled deletion happens.</p>
<p>After 7 days, your data will be permanently deleted. If you need help, contact our support team.</p>
<p>Thank you for checking out Ente, we wish you a great time ahead!</p> <p>Thank you for checking out Ente, we wish you a great time ahead!</p>
</div> </div>

View File

@ -540,6 +540,22 @@ func (h *UserHandler) DeleteUser(c *gin.Context) {
c.JSON(http.StatusOK, response) 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 // GetSRPAttributes returns the SRP attributes for a user
func (h *UserHandler) GetSRPAttributes(c *gin.Context) { func (h *UserHandler) GetSRPAttributes(c *gin.Context) {
var request ente.GetSRPAttributesRequest var request ente.GetSRPAttributesRequest

View File

@ -2,7 +2,7 @@ package user
import ( import (
"fmt" "fmt"
"github.com/ente-io/museum/ente"
enteJWT "github.com/ente-io/museum/ente/jwt" enteJWT "github.com/ente-io/museum/ente/jwt"
"github.com/ente-io/museum/pkg/utils/time" "github.com/ente-io/museum/pkg/utils/time"
"github.com/ente-io/stacktrace" "github.com/ente-io/stacktrace"
@ -17,13 +17,18 @@ func (c *UserController) GetJWTToken(userID int64, scope enteJWT.ClaimScope) (st
if scope == enteJWT.ACCOUNTS { if scope == enteJWT.ACCOUNTS {
tokenExpiry = time.NMinFromNow(30) tokenExpiry = time.NMinFromNow(30)
} }
// Create a new token object, specifying signing method and the claims claim := enteJWT.WebCommonJWTClaim{
// you would like it to contain.
token := jwt.NewWithClaims(jwt.SigningMethodHS256, &enteJWT.WebCommonJWTClaim{
UserID: userID, UserID: userID,
ExpiryTime: tokenExpiry, ExpiryTime: tokenExpiry,
ClaimScope: &scope, 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 // Sign and get the complete encoded token as a string using the secret
tokenString, err := token.SignedString(c.JwtSecret) tokenString, err := token.SignedString(c.JwtSecret)
@ -33,7 +38,7 @@ func (c *UserController) GetJWTToken(userID int64, scope enteJWT.ClaimScope) (st
return tokenString, nil 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) { token, err := jwt.ParseWithClaims(jwtToken, &enteJWT.WebCommonJWTClaim{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, stacktrace.Propagate(fmt.Errorf("unexpected signing method: %v", token.Header["alg"]), "") 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 return c.JwtSecret, nil
}) })
if err != 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) claims, ok := token.Claims.(*enteJWT.WebCommonJWTClaim)
if ok && token.Valid { if ok && token.Valid {
if claims.GetScope() != scope { 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")
} }

View File

@ -1,10 +1,13 @@
package user package user
import ( import (
"database/sql"
"errors" "errors"
"fmt" "fmt"
enteJWT "github.com/ente-io/museum/ente/jwt"
"github.com/ente-io/museum/pkg/controller/collections" "github.com/ente-io/museum/pkg/controller/collections"
"github.com/ente-io/museum/pkg/repo/two_factor_recovery" "github.com/ente-io/museum/pkg/repo/two_factor_recovery"
"github.com/ente-io/museum/pkg/utils/time"
"strings" "strings"
cache2 "github.com/ente-io/museum/ente/cache" 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, "") return nil, stacktrace.Propagate(err, "")
} }
go c.NotifyAccountDeletion(email, isSubscriptionCancelled) go c.NotifyAccountDeletion(userID, email, isSubscriptionCancelled)
return &ente.DeleteAccountResponse{ return &ente.DeleteAccountResponse{
IsSubscriptionCancelled: isSubscriptionCancelled, 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 template := AccountDeletedEmailTemplate
if !isSubscriptionCancelled { if !isSubscriptionCancelled {
template = AccountDeletedWithActiveSubscriptionEmailTemplate 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", err := email.SendTemplatedEmail([]string{userEmail}, "ente", "team@ente.io",
AccountDeletedEmailSubject, template, nil, nil) AccountDeletedEmailSubject, template, templateData, nil)
if err != nil { if err != nil {
logrus.WithError(err).Errorf("Failed to send the account deletion email to %s", userEmail) 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 { 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) _, err := c.UserRepo.Get(req.UserID)
if err == nil { if err == nil {
return stacktrace.Propagate(ente.NewBadRequestError(&ente.ApiErrorParams{ 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) { 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 // check if the user keyAttributes are still available
if _, keyErr := c.UserRepo.GetKeyAttributes(req.UserID); keyErr != nil { 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") return stacktrace.Propagate(keyErr, "keyAttributes missing? Account can not be recovered")
} }
email := strings.ToLower(req.EmailID) email := strings.ToLower(req.EmailID)

View File

@ -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) { func (c *UserController) SelfDeleteAccount(ctx *gin.Context, req ente.DeleteAccountRequest) (*ente.DeleteAccountResponse, error) {
userID := auth.GetUserID(ctx.Request.Header) 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 { if err != nil {
return nil, stacktrace.Propagate(err, "failed to validate jwt token") 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") return nil, stacktrace.Propagate(ente.ErrPermissionDenied, "jwtToken belongs to different user")
} }
user, err := c.UserRepo.Get(userID) user, err := c.UserRepo.Get(userID)

View File

@ -48,7 +48,12 @@ func (m *AuthMiddleware) TokenAuthMiddleware(jwtClaimScope *jwt.ClaimScope) gin.
var err error var err error
if !found { if !found {
if isJWT { 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 { } else {
userID, err = m.UserAuthRepo.GetUserIDWithToken(token, app) userID, err = m.UserAuthRepo.GetUserIDWithToken(token, app)
if err != nil && !errors.Is(err, sql.ErrNoRows) { if err != nil && !errors.Is(err, sql.ErrNoRows) {