[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.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))

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 {
return &ApiError{
Code: CONFLICT,

View File

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

View File

@ -101,15 +101,17 @@
font-size: 16px; " >
<p>Hey,</p>
<p>As requested by you, we've deleted your Ente account and scheduled your
uploaded data for deletion.</p>
<p>As requested, we've deleted your Ente account and scheduled your uploaded data for deletion.</p>
<p>If you accidentally deleted your account, please contact our support
immediately to try and recover your uploaded data before the next
scheduled deletion happens.</p>
<p>If this was a mistake, please click here to
<a href={{.AccountRecoveryLink}} style="color: #37C066; font-weight: bold;">recover your account</a>.</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>
<br />
<div class="footer" style="text-align: center; font-size: 12px; color:
rgb(136, 136, 136)" >

View File

@ -105,9 +105,10 @@
uploaded data for deletion. If you have an App Store subscription for
Ente, please remember to cancel it too.</p>
<p>If you accidentally deleted your account, please contact our support
immediately to try and recover your uploaded data before the next
scheduled deletion happens.</p>
<p>If this was a mistake, please click here to
<a href={{.AccountRecoveryLink}} style="color: #37C066; font-weight: bold;">recover your account</a>.</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>
</div>

View File

@ -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

View File

@ -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")
}

View File

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

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

View File

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