mirror of
https://github.com/ente-io/ente.git
synced 2025-04-30 03:25:52 +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.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))
|
||||||
|
@ -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,
|
||||||
|
@ -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 {
|
||||||
|
@ -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)" >
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
@ -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")
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user