mirror of
https://github.com/ente-io/ente.git
synced 2025-04-29 19:15:33 +00:00
[server] Support for self-recovery on account deletion (#5712)
## Description ## Tests
This commit is contained in:
commit
39509813c6
@ -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))
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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)" >
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user