feat: change email, export my data, delete account (with data anonymization)
This commit is contained in:
1 parent
bb191c389a
commit
10a66cd979
123 files changed
+10789
-76
No files matched your search
@@ -98,6 +98,10 @@ type UseCases struct {
|
||||
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
|
||||
ResetPasswordUseCase *useCases.ResetPasswordUseCase
|
||||
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
|
||||
RequestEmailChangeUseCase *useCases.RequestEmailChangeUseCase
|
||||
ResendEmailChangeUseCase *useCases.ResendEmailChangeUseCase
|
||||
CancelEmailChangeUseCase *useCases.CancelEmailChangeUseCase
|
||||
ConfirmEmailChangeUseCase *useCases.ConfirmEmailChangeUseCase
|
||||
ListUsersUseCase *useCases.ListUsersUseCase
|
||||
CreateInviteUseCase *useCases.CreateInviteUseCase
|
||||
CancelInviteUseCase *useCases.CancelInviteUseCase
|
||||
@@ -105,6 +109,8 @@ type UseCases struct {
|
||||
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
|
||||
ChangeUserRoleUseCase *useCases.ChangeUserRoleUseCase
|
||||
RemoveUserUseCase *useCases.RemoveUserUseCase
|
||||
ExportUserDataUseCase *useCases.ExportUserDataUseCase
|
||||
DeleteOwnAccountUseCase *useCases.DeleteOwnAccountUseCase
|
||||
TransferOwnershipUseCase *useCases.TransferOwnershipUseCase
|
||||
GetInviteUseCase *useCases.GetInviteUseCase
|
||||
AcceptInviteUseCase *useCases.AcceptInviteUseCase
|
||||
@@ -174,6 +180,7 @@ type Repositories struct {
|
||||
EntryRepo domainEntry.Repository
|
||||
AuthorRepo domainAuthor.Repository
|
||||
UserRepo domainUser.Repository
|
||||
UserEraser domainUser.Eraser
|
||||
RoleRepo domainRole.Repository
|
||||
ImageRepo domainImage.Repository
|
||||
Transactor domainTransaction.Transactor
|
||||
@@ -208,6 +215,8 @@ const (
|
||||
updateHelperFileName = "update-helper.json"
|
||||
updatesDirName = "updates"
|
||||
backupsDirName = "backups"
|
||||
|
||||
maxConcurrentDataExports = 2
|
||||
)
|
||||
|
||||
func imageStorageConfig(settings config.Settings) storage.Config {
|
||||
@@ -319,6 +328,7 @@ func getRepositories(connection *database.Connection, settings config.Settings)
|
||||
EntryRepo: persistence.NewEntryRepository(db),
|
||||
AuthorRepo: persistence.NewAuthorRepository(db),
|
||||
UserRepo: persistence.NewUserRepository(db),
|
||||
UserEraser: persistence.NewAccountEraser(db),
|
||||
RoleRepo: persistence.NewRoleRepository(db),
|
||||
ImageRepo: persistence.NewImageRepository(db),
|
||||
Transactor: persistence.NewTransactor(db),
|
||||
@@ -350,9 +360,12 @@ func getRepositories(connection *database.Connection, settings config.Settings)
|
||||
|
||||
func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings config.Settings) *UseCases {
|
||||
hasher := hashing.NewBcryptHasher()
|
||||
mailDispatcher := mailer.NewAsyncDispatcher()
|
||||
sessionRevocation := useCases.NewSessionRevocationUseCase(repos.SessionRepo, time.Now)
|
||||
renderer := markdown.NewConverter()
|
||||
keyGenerator := hashing.NewSHA256KeyGenerator()
|
||||
recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default())
|
||||
mediaUsage := useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo)
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, repos.VariantFileRepo, recorder, repos.ContentTypeRepo, repos.EntryRepo, repos.Transactor, log.Default())
|
||||
|
||||
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder, repos.SiteRepo, repos.AuthorRepo)
|
||||
@@ -366,7 +379,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
|
||||
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
|
||||
RetentionScheduler: scheduler.NewSystemRetentionRunner(useCases.NewEnforceRetentionUseCase(repos.UserRepo, repos.InviteRepo, repos.SessionRepo), log.Default()),
|
||||
Token: authtoken.New([]byte(settings.JWTSecret), authtoken.WithRevocationStore(useCases.NewSessionRevocationUseCase(repos.SessionRepo, time.Now))),
|
||||
Token: authtoken.New([]byte(settings.JWTSecret), authtoken.WithRevocationStore(sessionRevocation)),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
|
||||
@@ -416,6 +429,10 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
|
||||
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder),
|
||||
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder),
|
||||
RequestEmailChangeUseCase: useCases.NewRequestEmailChangeUseCase(repos.UserRepo, hasher, repos.MailRepo, mailDispatcher, settings.PublicURL, time.Now, recorder),
|
||||
ResendEmailChangeUseCase: useCases.NewResendEmailChangeUseCase(repos.UserRepo, hasher, repos.MailRepo, mailDispatcher, settings.PublicURL, time.Now, recorder),
|
||||
CancelEmailChangeUseCase: useCases.NewCancelEmailChangeUseCase(repos.UserRepo, recorder),
|
||||
ConfirmEmailChangeUseCase: useCases.NewConfirmEmailChangeUseCase(repos.UserRepo, hasher, time.Now, recorder),
|
||||
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
|
||||
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, repos.ContentTypeRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
|
||||
CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder),
|
||||
@@ -423,6 +440,8 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo),
|
||||
ChangeUserRoleUseCase: useCases.NewChangeUserRoleUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder),
|
||||
RemoveUserUseCase: useCases.NewRemoveUserUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder),
|
||||
ExportUserDataUseCase: useCases.NewExportUserDataUseCase(repos.UserRepo, repos.RoleRepo, repos.AuthorRepo, repos.APIKeyRepo, repos.ArticleRepo, repos.EntryRepo, repos.ContentTypeRepo, repos.ImageRepo, repos.AuditRepo, time.Now, recorder, maxConcurrentDataExports),
|
||||
DeleteOwnAccountUseCase: useCases.NewDeleteOwnAccountUseCase(repos.UserRepo, repos.RoleRepo, hasher, repos.UserEraser, sessionRevocation, repos.MailRepo, mailDispatcher, settings.PublicURL, recorder, repos.AuthorRepo, repos.ImageRepo, mediaUsage, imageStorage, repos.VariantFileRepo),
|
||||
TransferOwnershipUseCase: useCases.NewTransferOwnershipUseCase(repos.UserRepo, repos.RoleRepo, recorder),
|
||||
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
|
||||
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, hasher, recorder),
|
||||
@@ -433,7 +452,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ArticleRepo),
|
||||
MergeMediaUseCase: mergeMedia,
|
||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo),
|
||||
GetMediaUsageUseCase: mediaUsage,
|
||||
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
|
||||
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
|
||||
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo),
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type EmailChangeHandler struct {
|
||||
RequestUseCase *useCases.RequestEmailChangeUseCase
|
||||
ResendUseCase *useCases.ResendEmailChangeUseCase
|
||||
CancelUseCase *useCases.CancelEmailChangeUseCase
|
||||
ConfirmUseCase *useCases.ConfirmEmailChangeUseCase
|
||||
}
|
||||
|
||||
type RequestEmailChangeCredentials struct {
|
||||
NewEmail string `json:"new_email" validate:"required,max=255,inviteemail"`
|
||||
CurrentPassword string `json:"current_password" validate:"required"`
|
||||
}
|
||||
|
||||
type ConfirmEmailChangeCredentials struct {
|
||||
Code string `json:"code" validate:"required"`
|
||||
}
|
||||
|
||||
func NewEmailChangeHandler(request *useCases.RequestEmailChangeUseCase, resend *useCases.ResendEmailChangeUseCase, cancel *useCases.CancelEmailChangeUseCase, confirm *useCases.ConfirmEmailChangeUseCase) *EmailChangeHandler {
|
||||
return &EmailChangeHandler{
|
||||
RequestUseCase: request,
|
||||
ResendUseCase: resend,
|
||||
CancelUseCase: cancel,
|
||||
ConfirmUseCase: confirm,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *EmailChangeHandler) Request(w http.ResponseWriter, r *http.Request) {
|
||||
var credentials RequestEmailChangeCredentials
|
||||
if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validator.Validate.Struct(credentials); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
pending, err := h.RequestUseCase.RequestEmailChange(r.Context(), useCases.RequestEmailChangeCommand{
|
||||
UserID: userId,
|
||||
NewEmail: credentials.NewEmail,
|
||||
CurrentPassword: credentials.CurrentPassword,
|
||||
})
|
||||
if err != nil {
|
||||
writeEmailChangeError(w, err)
|
||||
return
|
||||
}
|
||||
writePendingEmail(w, pending)
|
||||
}
|
||||
|
||||
func (h *EmailChangeHandler) Resend(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
pending, err := h.ResendUseCase.ResendEmailChange(r.Context(), userId)
|
||||
if err != nil {
|
||||
writeEmailChangeError(w, err)
|
||||
return
|
||||
}
|
||||
writePendingEmail(w, pending)
|
||||
}
|
||||
|
||||
func (h *EmailChangeHandler) Cancel(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.CancelUseCase.CancelEmailChange(r.Context(), userId); err != nil {
|
||||
writeEmailChangeError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *EmailChangeHandler) Confirm(w http.ResponseWriter, r *http.Request) {
|
||||
var credentials ConfirmEmailChangeCredentials
|
||||
if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validator.Validate.Struct(credentials); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
email, err := h.ConfirmUseCase.ConfirmEmailChange(r.Context(), userId, credentials.Code)
|
||||
if err != nil {
|
||||
writeEmailChangeError(w, err)
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(map[string]string{"email": email})
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func writePendingEmail(w http.ResponseWriter, pending string) {
|
||||
body, err := json.Marshal(map[string]string{"pending_email": pending})
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func writeEmailChangeError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, user.ErrUserNotFound):
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
case errors.Is(err, user.ErrWrongPassword):
|
||||
http.Error(w, apperr.WrongPasswordErrorCode, http.StatusForbidden)
|
||||
case errors.Is(err, user.ErrSameEmail):
|
||||
http.Error(w, apperr.SameEmailErrorCode, http.StatusBadRequest)
|
||||
case errors.Is(err, user.ErrNoPendingEmailChange):
|
||||
http.Error(w, apperr.NoPendingEmailChangeErrorCode, http.StatusNotFound)
|
||||
case errors.Is(err, user.ErrInvalidEmailChangeCode):
|
||||
http.Error(w, apperr.InvalidEmailChangeCodeErrorCode, http.StatusForbidden)
|
||||
case errors.Is(err, user.ErrEmailUnavailable):
|
||||
http.Error(w, apperr.EmailUnavailableErrorCode, http.StatusConflict)
|
||||
default:
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -40,16 +40,7 @@ func NewLoginHandler(token *authtoken.Auth, create *useCases.CreateUserUseCase,
|
||||
}
|
||||
|
||||
func removeJwtCookie(w http.ResponseWriter, isProduction bool) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: authtoken.CookieName,
|
||||
Value: "",
|
||||
Expires: time.Now(),
|
||||
MaxAge: -1,
|
||||
Secure: isProduction,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
})
|
||||
http.SetCookie(w, authtoken.ExpiredCookie(isProduction))
|
||||
}
|
||||
|
||||
func setJwtCookie(w http.ResponseWriter, token *authtoken.Auth, isProduction bool, u user.User) error {
|
||||
|
||||
@@ -41,8 +41,9 @@ type meAccess struct {
|
||||
|
||||
type meResponse struct {
|
||||
publicuser.PublicUser
|
||||
Author *meAuthor `json:"author"`
|
||||
Access meAccess `json:"access"`
|
||||
PendingEmail *string `json:"pending_email"`
|
||||
Author *meAuthor `json:"author"`
|
||||
Access meAccess `json:"access"`
|
||||
}
|
||||
|
||||
func toMeAccess(access useCases.Access) meAccess {
|
||||
@@ -60,6 +61,13 @@ func toMeAuthor(found *author.Author) *meAuthor {
|
||||
return &meAuthor{Byline: found.Byline(), ArticleCount: found.ArticleCount}
|
||||
}
|
||||
|
||||
func pendingEmailOf(account user.User) *string {
|
||||
if account.PendingEmail == "" {
|
||||
return nil
|
||||
}
|
||||
return &account.PendingEmail
|
||||
}
|
||||
|
||||
func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
@@ -89,7 +97,7 @@ func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
body, err := json.Marshal(meResponse{PublicUser: publicuser.New(loadedUser), Author: toMeAuthor(linked), Access: toMeAccess(access)})
|
||||
body, err := json.Marshal(meResponse{PublicUser: publicuser.New(loadedUser), PendingEmail: pendingEmailOf(loadedUser), Author: toMeAuthor(linked), Access: toMeAccess(access)})
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
domain "RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DeleteAccountHandler struct {
|
||||
DeleteUseCase *useCases.DeleteOwnAccountUseCase
|
||||
IsProduction bool
|
||||
}
|
||||
|
||||
type DeleteAccountRequest struct {
|
||||
CurrentPassword string `json:"current_password" validate:"required"`
|
||||
Confirmation string `json:"confirmation" validate:"required,eq=DELETE"`
|
||||
}
|
||||
|
||||
func NewDeleteAccountHandler(deleteAccount *useCases.DeleteOwnAccountUseCase, isProduction bool) *DeleteAccountHandler {
|
||||
return &DeleteAccountHandler{DeleteUseCase: deleteAccount, IsProduction: isProduction}
|
||||
}
|
||||
|
||||
func (h *DeleteAccountHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
var request DeleteAccountRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validator.Validate.Struct(request); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
command := useCases.DeleteOwnAccountCommand{UserID: userId, CurrentPassword: request.CurrentPassword}
|
||||
command.SessionID, command.SessionExpiresAt = sessionOf(r)
|
||||
if err := h.DeleteUseCase.Delete(r.Context(), command); err != nil {
|
||||
writeDeleteAccountError(w, err)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, authtoken.ExpiredCookie(h.IsProduction))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func sessionOf(r *http.Request) (string, time.Time) {
|
||||
tokenClaims, err := authtoken.FromContext(r.Context())
|
||||
if err != nil {
|
||||
return "", time.Time{}
|
||||
}
|
||||
id, _ := tokenClaims["jti"].(string)
|
||||
expiry, _ := tokenClaims["exp"].(float64)
|
||||
return id, time.Unix(int64(expiry), 0)
|
||||
}
|
||||
|
||||
func writeDeleteAccountError(w http.ResponseWriter, err error) {
|
||||
var lastAdmin domain.LastAdminError
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrUserNotFound):
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
case errors.Is(err, domain.ErrWrongPassword):
|
||||
http.Error(w, apperr.WrongPasswordErrorCode, http.StatusForbidden)
|
||||
case errors.As(err, &lastAdmin):
|
||||
w.Header().Set(apperr.LastRoleHeader, lastAdmin.Role)
|
||||
http.Error(w, apperr.LastAdminErrorCode, http.StatusConflict)
|
||||
case errors.Is(err, domain.ErrAccountNotDeletable):
|
||||
http.Error(w, apperr.AccountNotDeletableErrorCode, http.StatusConflict)
|
||||
default:
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type ExportHandler struct {
|
||||
ExportUseCase *useCases.ExportUserDataUseCase
|
||||
}
|
||||
|
||||
func NewExportHandler(export *useCases.ExportUserDataUseCase) *ExportHandler {
|
||||
return &ExportHandler{ExportUseCase: export}
|
||||
}
|
||||
|
||||
func (h *ExportHandler) Export(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
archive, err := h.ExportUseCase.Export(r.Context(), userId)
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
http.Error(w, "Service unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+archive.Filename+"\"")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(archive.Content)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(archive.Content)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type successSlots struct {
|
||||
mu sync.Mutex
|
||||
window time.Duration
|
||||
now func() time.Time
|
||||
taken map[string]time.Time
|
||||
}
|
||||
|
||||
func NewUserSuccessLimiter(window time.Duration) func(http.Handler) http.Handler {
|
||||
return NewUserSuccessLimiterAt(window, time.Now)
|
||||
}
|
||||
|
||||
func NewUserSuccessLimiterAt(window time.Duration, now func() time.Time) func(http.Handler) http.Handler {
|
||||
slots := &successSlots{window: window, now: now, taken: make(map[string]time.Time)}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := userRateLimitKey(r)
|
||||
claimed, retryAfter, granted := slots.claim(key)
|
||||
if !granted {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
||||
http.Error(w, apperr.TooManyRequestsErrorMessage, http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
succeeded := false
|
||||
defer func() {
|
||||
if !succeeded {
|
||||
slots.release(key, claimed)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(recorder, r)
|
||||
succeeded = recorder.status < http.StatusBadRequest
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *successSlots) claim(key string) (time.Time, int, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := s.now()
|
||||
s.sweepExpired(now)
|
||||
if claimedAt, held := s.taken[key]; held {
|
||||
return time.Time{}, retryAfterSeconds(s.window - now.Sub(claimedAt)), false
|
||||
}
|
||||
s.taken[key] = now
|
||||
return now, 0, true
|
||||
}
|
||||
|
||||
func (s *successSlots) release(key string, claimedAt time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if current, held := s.taken[key]; held && current.Equal(claimedAt) {
|
||||
delete(s.taken, key)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *successSlots) sweepExpired(now time.Time) {
|
||||
for key, claimedAt := range s.taken {
|
||||
if now.Sub(claimedAt) >= s.window {
|
||||
delete(s.taken, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (s *statusRecorder) WriteHeader(status int) {
|
||||
s.status = status
|
||||
s.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (s *statusRecorder) Unwrap() http.ResponseWriter {
|
||||
return s.ResponseWriter
|
||||
}
|
||||
@@ -127,3 +127,12 @@ const InvalidRoleErrorMessage = "invalid_role"
|
||||
const UserRoleChangedErrorMessage = "user_role_changed"
|
||||
const InvalidTransferErrorMessage = "invalid_transfer"
|
||||
const ConfirmationMismatchErrorMessage = "confirmation_mismatch"
|
||||
const WrongPasswordErrorCode = "wrong_password"
|
||||
const SameEmailErrorCode = "same_email"
|
||||
const NoPendingEmailChangeErrorCode = "no_pending_email_change"
|
||||
const InvalidEmailChangeCodeErrorCode = "invalid_email_change_code"
|
||||
const EmailUnavailableErrorCode = "email_unavailable"
|
||||
|
||||
const LastAdminErrorCode = "last_admin"
|
||||
const AccountNotDeletableErrorCode = "account_not_deletable"
|
||||
const LastRoleHeader = "X-Last-Role"
|
||||
@@ -0,0 +1,19 @@
|
||||
package authtoken
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ExpiredCookie(secure bool) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: "",
|
||||
Expires: time.Now(),
|
||||
MaxAge: -1,
|
||||
Secure: secure,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
}
|
||||
}
|
||||
+50
-2
@@ -52,6 +52,14 @@ func GetHelloWorld(w http.ResponseWriter, _ *http.Request) {
|
||||
const (
|
||||
apiKeyMaxRequests = 120
|
||||
apiKeyWindow = time.Minute
|
||||
|
||||
emailChangeMaxRequests = 5
|
||||
emailChangeWindow = 15 * time.Minute
|
||||
|
||||
dataExportWindow = 15 * time.Minute
|
||||
|
||||
accountDeleteMaxRequests = 5
|
||||
accountDeleteWindow = 15 * time.Minute
|
||||
)
|
||||
|
||||
func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
|
||||
@@ -119,6 +127,7 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
||||
verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction)
|
||||
meHandler := auth.NewMeHandler(container.GetUserUseCase, container.GetMyAuthorUseCase, container.GetMyAccessUseCase)
|
||||
passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase)
|
||||
emailChangeHandler := auth.NewEmailChangeHandler(container.RequestEmailChangeUseCase, container.ResendEmailChangeUseCase, container.CancelEmailChangeUseCase, container.ConfirmEmailChangeUseCase)
|
||||
acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction)
|
||||
|
||||
r.Post("/register", loginHandler.Register)
|
||||
@@ -149,6 +158,22 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
||||
r.Post("/verify", verificationHandler.Verify)
|
||||
r.Delete("/register", verificationHandler.CancelRegistration)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Delete("/email/change", emailChangeHandler.Cancel)
|
||||
r.Post("/email/confirm", emailChangeHandler.Confirm)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.NewUserRateLimiter(emailChangeMaxRequests, emailChangeWindow))
|
||||
r.Post("/email/change", emailChangeHandler.Request)
|
||||
r.Post("/email/resend", emailChangeHandler.Resend)
|
||||
})
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(container.apiKeyAuth())
|
||||
@@ -159,7 +184,7 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
||||
return r
|
||||
}
|
||||
|
||||
func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
|
||||
func NewUserRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
userHandler := user.NewHandler(container.UpdateUserProfileUseCase)
|
||||
@@ -167,6 +192,8 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
|
||||
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
|
||||
roleHandler := user.NewRoleHandler(container.ChangeUserRoleUseCase)
|
||||
removeHandler := user.NewRemoveHandler(container.RemoveUserUseCase)
|
||||
exportHandler := user.NewExportHandler(container.ExportUserDataUseCase)
|
||||
deleteAccountHandler := user.NewDeleteAccountHandler(container.DeleteOwnAccountUseCase, isProduction)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
@@ -187,6 +214,27 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
|
||||
r.Post("/invite/{id}/renew", inviteHandler.RenewInvite)
|
||||
})
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.With(middleware.NewUserSuccessLimiter(dataExportWindow)).Get("/me/export", exportHandler.Export)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(authtoken.Authenticator)
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(middleware.NewUserRateLimiter(accountDeleteMaxRequests, accountDeleteWindow))
|
||||
r.Delete("/me", deleteAccountHandler.Delete)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
@@ -535,7 +583,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||
r.Use(authtoken.Verifier(container.Token))
|
||||
r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies))
|
||||
})
|
||||
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
|
||||
r.Mount("/user", NewUserRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
||||
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/role", NewRoleRouter(container, settings.TrustedProxies))
|
||||
|
||||
Reference in new issue
Block a user