diff --git a/application/auth/banned.go b/application/auth/banned.go new file mode 100644 index 00000000..47cf803d --- /dev/null +++ b/application/auth/banned.go @@ -0,0 +1,25 @@ +package auth + +// BannedCode marks a 403 as "this account is banned" rather than "you lack the +// permission for this". Both are 403s, and a client has to tell them apart: a +// permission denial is a dead end for one action, a ban ends the session. +const BannedCode = "user_banned" + +// BannedTranslationKey names the message shown to a banned user. +const BannedTranslationKey = "user_is_banned" + +// BannedResponse is the body of every "you are banned" 403 — the refused login, +// the refused token refresh, and every authenticated request turned away by the +// Authenticate middleware. Message is already translated, since only the server +// knows which language the user reads. +type BannedResponse struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func NewBannedResponse(message string) *BannedResponse { + return &BannedResponse{ + Code: BannedCode, + Message: message, + } +} diff --git a/application/auth/login/response.go b/application/auth/login/response.go index 323b31ee..3be88a61 100644 --- a/application/auth/login/response.go +++ b/application/auth/login/response.go @@ -5,6 +5,12 @@ import "github.com/khanzadimahdi/testproject/domain" type Response struct { ValidationErrors domain.ValidationErrors `json:"errors,omitempty"` + // Set when the account is banned: the handler answers 403 with this instead + // of tokens. Shaped like auth.BannedResponse, which every other refusal of a + // banned user returns. + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + AccessToken string `json:"access_token,omitempty"` RefreshToken string `json:"refresh_token,omitempty"` } diff --git a/application/auth/login/usecase.go b/application/auth/login/usecase.go index 55e576db..b51e6545 100644 --- a/application/auth/login/usecase.go +++ b/application/auth/login/usecase.go @@ -60,6 +60,15 @@ func (uc *UseCase) Execute(ctx context.Context, request *Request) (*Response, er }, nil } + // Checked after the password, so a wrong password can't be used to find out + // which accounts are banned. No tokens are issued either way. + if u.IsBanned() { + return &Response{ + Code: auth.BannedCode, + Message: uc.translator.Translate(auth.BannedTranslationKey), + }, nil + } + accessToken, err := uc.authTokenGenerator.GenerateAccessToken(ctx, &u) if err != nil { return nil, err diff --git a/application/auth/login/usecase_test.go b/application/auth/login/usecase_test.go index 50b2e7a3..37c645c4 100644 --- a/application/auth/login/usecase_test.go +++ b/application/auth/login/usecase_test.go @@ -5,6 +5,7 @@ import ( "errors" "reflect" "testing" + "time" jwtv5 "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" @@ -255,4 +256,65 @@ func TestUseCase_Execute(t *testing.T) { assert.NotNil(t, response) assert.Equal(t, &expectedResponse, response) }) + + t.Run("banned user gets no tokens", func(t *testing.T) { + t.Parallel() + + var ( + userRepository users.MockUsersRepository + roleRepository roles.MockRolesRepository + hasher mock.MockCrypto + validator validator.MockValidator + translator translator.TranslatorMock + + request = Request{ + Identity: "test-identity", + Password: "test-password", + } + + u = user.User{ + UUID: request.Identity, + PasswordHash: password.Hash{ + Value: []byte("hashed-value"), + Salt: []byte("salt-value"), + }, + BannedAt: time.Now(), + } + + expectedResponse = Response{ + Code: auth.BannedCode, + Message: "your account has been suspended", + } + ) + + validator.On("Validate", &request).Once().Return(nil) + defer validator.AssertExpectations(t) + + userRepository.On("GetOneByIdentity", mock2.Anything, request.Identity).Once().Return(u, nil) + defer userRepository.AssertExpectations(t) + + // The password is still checked first, so a wrong one can't be used to + // find out which accounts are banned. + hasher.On("Equal", mock2.Anything, []byte(request.Password), u.PasswordHash.Value, u.PasswordHash.Salt).Once().Return(true) + defer hasher.AssertExpectations(t) + + translator.On( + "Translate", + auth.BannedTranslationKey, + mock2.AnythingOfType(translatorOptionsType), + ).Once().Return(expectedResponse.Message) + defer translator.AssertExpectations(t) + + authTokenGenerator := auth.NewTokenGenerator(j, &roleRepository) + + response, err := NewUseCase(&userRepository, authTokenGenerator, &hasher, &translator, &validator).Execute(context.Background(), &request) + + roleRepository.AssertNotCalled(t, "GetByUserUUID") + + assert.NoError(t, err) + assert.NotNil(t, response) + assert.Equal(t, &expectedResponse, response) + assert.Empty(t, response.AccessToken) + assert.Empty(t, response.RefreshToken) + }) } diff --git a/application/auth/refresh/response.go b/application/auth/refresh/response.go index fa484c59..79b18985 100644 --- a/application/auth/refresh/response.go +++ b/application/auth/refresh/response.go @@ -5,6 +5,11 @@ import "github.com/khanzadimahdi/testproject/domain" type Response struct { ValidationErrors domain.ValidationErrors `json:"errors,omitempty"` + // Set when the account was banned after the refresh token was issued: the + // handler answers 403 with this instead of renewing the session. + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + AccessToken string `json:"access_token,omitempty"` RefreshToken string `json:"refresh_token,omitempty"` } diff --git a/application/auth/refresh/usecase.go b/application/auth/refresh/usecase.go index 2f39d25e..417da860 100644 --- a/application/auth/refresh/usecase.go +++ b/application/auth/refresh/usecase.go @@ -78,6 +78,15 @@ func (uc *UseCase) Execute(ctx context.Context, request *Request) (*Response, er return nil, err } + // The refresh token outlives the access token by days, so a ban applied in + // between has to be caught here too — otherwise the session renews itself. + if u.IsBanned() { + return &Response{ + Code: auth.BannedCode, + Message: uc.translator.Translate(auth.BannedTranslationKey), + }, nil + } + accessToken, err := uc.authTokenGenerator.GenerateAccessToken(ctx, &u) if err != nil { return nil, err diff --git a/application/auth/refresh/usecase_test.go b/application/auth/refresh/usecase_test.go index 7ab3395e..1e021e8b 100644 --- a/application/auth/refresh/usecase_test.go +++ b/application/auth/refresh/usecase_test.go @@ -101,6 +101,52 @@ func TestUseCase_Execute(t *testing.T) { assert.Equal(t, "refresh", audience[0]) }) + t.Run("banned user's session is not renewed", func(t *testing.T) { + t.Parallel() + + var ( + userRepository users.MockUsersRepository + roleRepository roles.MockRolesRepository + validator validator.MockValidator + translator translator.TranslatorMock + + u = user.User{UUID: "test-uuid", BannedAt: time.Now()} + r = Request{ + Token: generateRefreshToken(t, j, u, time.Now().Add(15*time.Second), auth.RefreshToken), + } + + expectedResponse = Response{ + Code: auth.BannedCode, + Message: "your account has been suspended", + } + ) + + validator.On("Validate", &r).Once().Return(nil) + defer validator.AssertExpectations(t) + + userRepository.On("GetOne", mock.Anything, u.UUID).Once().Return(u, nil) + defer userRepository.AssertExpectations(t) + + translator.On( + "Translate", + auth.BannedTranslationKey, + mock.Anything, + ).Once().Return(expectedResponse.Message) + defer translator.AssertExpectations(t) + + authTokenGenerator := auth.NewTokenGenerator(j, &roleRepository) + + response, err := NewUseCase(&userRepository, j, authTokenGenerator, &translator, &validator).Execute(context.Background(), &r) + + roleRepository.AssertNotCalled(t, "GetByUserUUID") + + assert.NoError(t, err) + assert.NotNil(t, response) + assert.Equal(t, &expectedResponse, response) + assert.Empty(t, response.AccessToken) + assert.Empty(t, response.RefreshToken) + }) + t.Run("validation fails", func(t *testing.T) { t.Parallel() diff --git a/application/dashboard/user/getUser/response.go b/application/dashboard/user/getUser/response.go index ce15ca8f..e8cf227e 100644 --- a/application/dashboard/user/getUser/response.go +++ b/application/dashboard/user/getUser/response.go @@ -1,5 +1,7 @@ package getuser +import "time" + type Response struct { UUID string `json:"uuid,omitempty"` Name string `json:"name,omitempty"` @@ -7,4 +9,9 @@ type Response struct { Email string `json:"email,omitempty"` Username string `json:"username,omitempty"` LanguageCode string `json:"language_code,omitempty"` + // Not omitempty: "not banned" is a meaningful false, not an absent value. + // BannedAt rides along so the dashboard can show since when; it is the zero + // time for an account that was never banned. + Banned bool `json:"banned"` + BannedAt time.Time `json:"banned_at"` } diff --git a/application/dashboard/user/getUser/useCase.go b/application/dashboard/user/getUser/useCase.go index 42df59af..6fa94668 100644 --- a/application/dashboard/user/getUser/useCase.go +++ b/application/dashboard/user/getUser/useCase.go @@ -29,5 +29,7 @@ func (uc *UseCase) Execute(ctx context.Context, UUID string) (*Response, error) Email: u.Email, Username: u.Username, LanguageCode: u.LanguageCode, + Banned: u.IsBanned(), + BannedAt: u.BannedAt, }, err } diff --git a/application/dashboard/user/updateUser/request.go b/application/dashboard/user/updateUser/request.go index 133d4979..e4b2c17f 100644 --- a/application/dashboard/user/updateUser/request.go +++ b/application/dashboard/user/updateUser/request.go @@ -12,6 +12,9 @@ type Request struct { Avatar string `json:"avatar"` Username string `json:"username"` LanguageCode string `json:"language_code"` + // Banned carries the intent only; the moment of the ban is the server's to + // decide, so it is never taken from the client. + Banned bool `json:"banned"` } var _ domain.Validatable = &Request{} diff --git a/application/dashboard/user/updateUser/usecase.go b/application/dashboard/user/updateUser/usecase.go index cd633714..dd916058 100644 --- a/application/dashboard/user/updateUser/usecase.go +++ b/application/dashboard/user/updateUser/usecase.go @@ -80,6 +80,7 @@ func (uc *UseCase) Execute(ctx context.Context, request *Request) (*Response, er u.Email = request.Email u.Username = request.Username u.LanguageCode = request.LanguageCode + u.SetBanned(request.Banned) _, err = uc.userRepository.Save(ctx, &u) diff --git a/application/dashboard/user/updateUser/usecase_test.go b/application/dashboard/user/updateUser/usecase_test.go index 9a00e202..65a026d6 100644 --- a/application/dashboard/user/updateUser/usecase_test.go +++ b/application/dashboard/user/updateUser/usecase_test.go @@ -5,6 +5,7 @@ import ( "errors" "reflect" "testing" + "time" "github.com/stretchr/testify/assert" mock2 "github.com/stretchr/testify/mock" @@ -69,6 +70,101 @@ func TestUseCase_Execute(t *testing.T) { assert.Nil(t, response) }) + t.Run("banning a user stores the moment of the ban", func(t *testing.T) { + t.Parallel() + + var ( + userRepository users.MockUsersRepository + languageResolver resolver.MockResolver + validator validator.MockValidator + translator translator.TranslatorMock + + r = Request{ + UserUUID: "test-user-uuid", + Name: "test name", + Email: "test@test.com", + Username: "test-username", + LanguageCode: "en", + Banned: true, + } + + u = user.User{ + UUID: r.UserUUID, + Name: r.Name, + Email: r.Email, + Username: r.Username, + LanguageCode: r.LanguageCode, + } + ) + + validator.On("Validate", &r).Once().Return(nil) + defer validator.AssertExpectations(t) + + languageResolver.On("Verify", mock2.Anything, r.LanguageCode).Once().Return(true) + defer languageResolver.AssertExpectations(t) + + userRepository.On("GetOneByIdentity", mock2.Anything, r.Email).Once().Return(user.User{}, domain.ErrNotExists) + userRepository.On("GetOneByIdentity", mock2.Anything, r.Username).Once().Return(user.User{}, domain.ErrNotExists) + userRepository.On("GetOne", mock2.Anything, r.UserUUID).Once().Return(u, nil) + userRepository.On("Save", mock2.Anything, mock2.MatchedBy(func(saved *user.User) bool { + return saved.IsBanned() + })).Once().Return(r.UserUUID, nil) + defer userRepository.AssertExpectations(t) + + response, err := NewUseCase(&userRepository, &languageResolver, &validator, &translator).Execute(context.Background(), &r) + + assert.NoError(t, err) + assert.Nil(t, response) + }) + + t.Run("lifting a ban clears it", func(t *testing.T) { + t.Parallel() + + var ( + userRepository users.MockUsersRepository + languageResolver resolver.MockResolver + validator validator.MockValidator + translator translator.TranslatorMock + + r = Request{ + UserUUID: "test-user-uuid", + Name: "test name", + Email: "test@test.com", + Username: "test-username", + LanguageCode: "en", + Banned: false, + } + + u = user.User{ + UUID: r.UserUUID, + Name: r.Name, + Email: r.Email, + Username: r.Username, + LanguageCode: r.LanguageCode, + BannedAt: time.Now(), + } + ) + + validator.On("Validate", &r).Once().Return(nil) + defer validator.AssertExpectations(t) + + languageResolver.On("Verify", mock2.Anything, r.LanguageCode).Once().Return(true) + defer languageResolver.AssertExpectations(t) + + userRepository.On("GetOneByIdentity", mock2.Anything, r.Email).Once().Return(user.User{}, domain.ErrNotExists) + userRepository.On("GetOneByIdentity", mock2.Anything, r.Username).Once().Return(user.User{}, domain.ErrNotExists) + userRepository.On("GetOne", mock2.Anything, r.UserUUID).Once().Return(u, nil) + userRepository.On("Save", mock2.Anything, mock2.MatchedBy(func(saved *user.User) bool { + return !saved.IsBanned() + })).Once().Return(r.UserUUID, nil) + defer userRepository.AssertExpectations(t) + + response, err := NewUseCase(&userRepository, &languageResolver, &validator, &translator).Execute(context.Background(), &r) + + assert.NoError(t, err) + assert.Nil(t, response) + }) + t.Run("invalid request", func(t *testing.T) { t.Parallel() diff --git a/domain/user/user.go b/domain/user/user.go index b7926276..713f35ac 100644 --- a/domain/user/user.go +++ b/domain/user/user.go @@ -17,6 +17,26 @@ type User struct { LanguageCode string PasswordHash password.Hash CreatedAt time.Time + // BannedAt is the moment an administrator banned the user. The zero value + // means the account is in good standing. + BannedAt time.Time +} + +// IsBanned reports whether the user is banned and must be kept out of the +// application. +func (u User) IsBanned() bool { + return !u.BannedAt.IsZero() +} + +// SetBanned bans or lifts the ban on the user. Banning keeps the moment the ban +// started, so saving an already banned user doesn't move the date. +func (u *User) SetBanned(banned bool) { + switch { + case !banned: + u.BannedAt = time.Time{} + case !u.IsBanned(): + u.BannedAt = time.Now() + } } type Repository interface { diff --git a/domain/user/user_test.go b/domain/user/user_test.go index 9252a29c..0585a65b 100644 --- a/domain/user/user_test.go +++ b/domain/user/user_test.go @@ -1,6 +1,9 @@ package user -import "testing" +import ( + "testing" + "time" +) func TestIsValidEmail(t *testing.T) { tests := []struct { @@ -70,3 +73,46 @@ func TestIsValidUsername(t *testing.T) { }) } } + +func TestSetBanned(t *testing.T) { + earlier := time.Date(2024, 3, 1, 12, 0, 0, 0, time.UTC) + + t.Run("banning marks the moment", func(t *testing.T) { + u := User{} + u.SetBanned(true) + + if !u.IsBanned() { + t.Fatal("expected the user to be banned") + } + if u.BannedAt.IsZero() { + t.Error("expected BannedAt to be set") + } + }) + + t.Run("banning again keeps the original moment", func(t *testing.T) { + u := User{BannedAt: earlier} + u.SetBanned(true) + + if !u.BannedAt.Equal(earlier) { + t.Errorf("expected BannedAt to stay %v, got %v", earlier, u.BannedAt) + } + }) + + t.Run("lifting the ban clears the moment", func(t *testing.T) { + u := User{BannedAt: earlier} + u.SetBanned(false) + + if u.IsBanned() { + t.Fatal("expected the user not to be banned") + } + if !u.BannedAt.IsZero() { + t.Errorf("expected BannedAt to be cleared, got %v", u.BannedAt) + } + }) + + t.Run("a new user is not banned", func(t *testing.T) { + if (User{}).IsBanned() { + t.Error("expected a new user not to be banned") + } + }) +} diff --git a/infrastructure/ioc/providers/blog.go b/infrastructure/ioc/providers/blog.go index b51ac2a5..505c5c87 100644 --- a/infrastructure/ioc/providers/blog.go +++ b/infrastructure/ioc/providers/blog.go @@ -440,7 +440,7 @@ func blog( // comments mux.Handle("POST /api/comments", middleware.NewAuthenticateMiddleware(scoped(func(c provider.Container) http.Handler { return commentAPI.NewCreateHandler(createComment.NewUseCase(commentsRepository, va(c))) - }), jwt, userRepository)) + }), jwt, userRepository, translator)) mux.Handle("GET /api/comments", scoped(func(c provider.Container) http.Handler { return commentAPI.NewIndexHandler(getComments.NewUseCase(commentsRepository, userRepository, va(c))) })) @@ -448,10 +448,10 @@ func blog( // bookmark mux.Handle("POST /api/bookmarks/exists", middleware.NewAuthenticateMiddleware(scoped(func(c provider.Container) http.Handler { return bookmarkAPI.NewExistsHandler(bookmarkExists.NewUseCase(bookmarkRepository, va(c))) - }), jwt, userRepository)) + }), jwt, userRepository, translator)) mux.Handle("PUT /api/bookmarks", middleware.NewAuthenticateMiddleware(scoped(func(c provider.Container) http.Handler { return bookmarkAPI.NewUpdateHandler(updateBookmark.NewUseCase(bookmarkRepository, va(c))) - }), jwt, userRepository)) + }), jwt, userRepository, translator)) // languages mux.Handle("GET /api/languages", middleware.NewCacheMiddleware(languageAPI.NewIndexHandler(getLanguagesUseCase), httpCache)) @@ -472,120 +472,120 @@ func blog( // ---- dashboard HTTP API ---- // profile - mux.Handle("GET /api/dashboard/profile", middleware.NewAuthenticateMiddleware(profile.NewGetProfileHandler(getProfileUseCase), jwt, userRepository)) + mux.Handle("GET /api/dashboard/profile", middleware.NewAuthenticateMiddleware(profile.NewGetProfileHandler(getProfileUseCase), jwt, userRepository, translator)) mux.Handle("PUT /api/dashboard/profile", middleware.NewAuthenticateMiddleware(scoped(func(c provider.Container) http.Handler { return profile.NewUpdateProfileHandler(updateprofile.NewUseCase(userRepository, languageResolver, va(c), tr(c))) - }), jwt, userRepository)) + }), jwt, userRepository, translator)) mux.Handle("PUT /api/dashboard/password", middleware.NewAuthenticateMiddleware(scoped(func(c provider.Container) http.Handler { return profile.NewChangePasswordHandler(changepassword.NewUseCase(userRepository, hasher, va(c), tr(c))) - }), jwt, userRepository)) - mux.Handle("GET /api/dashboard/profile/roles", middleware.NewAuthenticateMiddleware(profile.NewGetRolesHandler(dashboardProfileGetRolesUseCase), jwt, userRepository)) + }), jwt, userRepository, translator)) + mux.Handle("GET /api/dashboard/profile/roles", middleware.NewAuthenticateMiddleware(profile.NewGetRolesHandler(dashboardProfileGetRolesUseCase), jwt, userRepository, translator)) // user mux.Handle("POST /api/dashboard/users", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { return dashboardUserAPI.NewCreateHandler(createuser.NewUseCase(userRepository, languageResolver, hasher, va(c), tr(c))) - }), authorizer, permission.UsersCreate), jwt, userRepository)) - mux.Handle("DELETE /api/dashboard/users/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardUserAPI.NewDeleteHandler(dashboardDeleteUserUsecase), authorizer, permission.UsersDelete), jwt, userRepository)) - mux.Handle("GET /api/dashboard/users", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardUserAPI.NewIndexHandler(dashboardGetUsersUsecase), authorizer, permission.UsersIndex), jwt, userRepository)) - mux.Handle("GET /api/dashboard/users/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardUserAPI.NewShowHandler(dashboardGetUserUsecase), authorizer, permission.UsersShow), jwt, userRepository)) + }), authorizer, permission.UsersCreate), jwt, userRepository, translator)) + mux.Handle("DELETE /api/dashboard/users/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardUserAPI.NewDeleteHandler(dashboardDeleteUserUsecase), authorizer, permission.UsersDelete), jwt, userRepository, translator)) + mux.Handle("GET /api/dashboard/users", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardUserAPI.NewIndexHandler(dashboardGetUsersUsecase), authorizer, permission.UsersIndex), jwt, userRepository, translator)) + mux.Handle("GET /api/dashboard/users/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardUserAPI.NewShowHandler(dashboardGetUserUsecase), authorizer, permission.UsersShow), jwt, userRepository, translator)) mux.Handle("PUT /api/dashboard/users", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { return dashboardUserAPI.NewUpdateHandler(updateuser.NewUseCase(userRepository, languageResolver, va(c), tr(c))) - }), authorizer, permission.UsersUpdate), jwt, userRepository)) + }), authorizer, permission.UsersUpdate), jwt, userRepository, translator)) mux.Handle("PUT /api/dashboard/users/password", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { return dashboardUserAPI.NewChangePasswordHandler(userchangepassword.NewUseCase(userRepository, hasher, va(c))) - }), authorizer, permission.UsersPasswordUpdate), jwt, userRepository)) + }), authorizer, permission.UsersPasswordUpdate), jwt, userRepository, translator)) // permissions - mux.Handle("GET /api/dashboard/permissions", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardPermissionAPI.NewIndexHandler(dashboardGetPermissionsUseCase), authorizer, permission.PermissionsIndex), jwt, userRepository)) + mux.Handle("GET /api/dashboard/permissions", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardPermissionAPI.NewIndexHandler(dashboardGetPermissionsUseCase), authorizer, permission.PermissionsIndex), jwt, userRepository, translator)) // roles mux.Handle("POST /api/dashboard/roles", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { return dashboardRoleAPI.NewCreateHandler(dashboardCreateRole.NewUseCase(rolesRepository, permissionRepository, va(c), tr(c))) - }), authorizer, permission.RolesCreate), jwt, userRepository)) - mux.Handle("DELETE /api/dashboard/roles/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardRoleAPI.NewDeleteHandler(dashboardDeleteRoleUsecase), authorizer, permission.RolesDelete), jwt, userRepository)) - mux.Handle("GET /api/dashboard/roles", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardRoleAPI.NewIndexHandler(dashboardGetRolesUsecase), authorizer, permission.RolesIndex), jwt, userRepository)) - mux.Handle("GET /api/dashboard/roles/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardRoleAPI.NewShowHandler(dashboardGetRoleUsecase), authorizer, permission.RolesShow), jwt, userRepository)) + }), authorizer, permission.RolesCreate), jwt, userRepository, translator)) + mux.Handle("DELETE /api/dashboard/roles/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardRoleAPI.NewDeleteHandler(dashboardDeleteRoleUsecase), authorizer, permission.RolesDelete), jwt, userRepository, translator)) + mux.Handle("GET /api/dashboard/roles", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardRoleAPI.NewIndexHandler(dashboardGetRolesUsecase), authorizer, permission.RolesIndex), jwt, userRepository, translator)) + mux.Handle("GET /api/dashboard/roles/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardRoleAPI.NewShowHandler(dashboardGetRoleUsecase), authorizer, permission.RolesShow), jwt, userRepository, translator)) mux.Handle("PUT /api/dashboard/roles", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { return dashboardRoleAPI.NewUpdateHandler(dashboardUpdateRole.NewUseCase(rolesRepository, permissionRepository, va(c), tr(c))) - }), authorizer, permission.RolesUpdate), jwt, userRepository)) + }), authorizer, permission.RolesUpdate), jwt, userRepository, translator)) // languages mux.Handle("POST /api/dashboard/languages", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { return dashboardLanguageAPI.NewCreateHandler(dashboardCreateLanguage.NewUseCase(languageRepository, va(c), tr(c))) - }), authorizer, permission.LanguagesCreate), jwt, userRepository)) - mux.Handle("DELETE /api/dashboard/languages/{code}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardLanguageAPI.NewDeleteHandler(dashboardDeleteLanguageUsecase), authorizer, permission.LanguagesDelete), jwt, userRepository)) - mux.Handle("GET /api/dashboard/languages", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardLanguageAPI.NewIndexHandler(dashboardGetLanguagesUsecase), authorizer, permission.LanguagesIndex), jwt, userRepository)) - mux.Handle("GET /api/dashboard/languages/{code}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardLanguageAPI.NewShowHandler(dashboardGetLanguageUsecase), authorizer, permission.LanguagesShow), jwt, userRepository)) + }), authorizer, permission.LanguagesCreate), jwt, userRepository, translator)) + mux.Handle("DELETE /api/dashboard/languages/{code}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardLanguageAPI.NewDeleteHandler(dashboardDeleteLanguageUsecase), authorizer, permission.LanguagesDelete), jwt, userRepository, translator)) + mux.Handle("GET /api/dashboard/languages", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardLanguageAPI.NewIndexHandler(dashboardGetLanguagesUsecase), authorizer, permission.LanguagesIndex), jwt, userRepository, translator)) + mux.Handle("GET /api/dashboard/languages/{code}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardLanguageAPI.NewShowHandler(dashboardGetLanguageUsecase), authorizer, permission.LanguagesShow), jwt, userRepository, translator)) mux.Handle("PUT /api/dashboard/languages", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { return dashboardLanguageAPI.NewUpdateHandler(dashboardUpdateLanguage.NewUseCase(languageRepository, va(c))) - }), authorizer, permission.LanguagesUpdate), jwt, userRepository)) + }), authorizer, permission.LanguagesUpdate), jwt, userRepository, translator)) // articles mux.Handle("POST /api/dashboard/articles", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { return dashboardArticleAPI.NewCreateHandler(dashboardCreateArticle.NewUseCase(articlesRepository, languageRepository, va(c), tr(c))) - }), authorizer, permission.ArticlesCreate), jwt, userRepository)) - mux.Handle("DELETE /api/dashboard/articles/{correlationUUID}/{language_code}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardArticleAPI.NewDeleteHandler(dashboardDeleteArticleUsecase), authorizer, permission.ArticlesDelete), jwt, userRepository)) - mux.Handle("GET /api/dashboard/articles", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardArticleAPI.NewIndexHandler(dashboardGetArticlesUsecase), authorizer, permission.ArticlesIndex), jwt, userRepository)) - mux.Handle("GET /api/dashboard/articles/{correlationUUID}/{language_code}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardArticleAPI.NewShowHandler(dashboardGetArticleUsecase), authorizer, permission.ArticlesShow), jwt, userRepository)) + }), authorizer, permission.ArticlesCreate), jwt, userRepository, translator)) + mux.Handle("DELETE /api/dashboard/articles/{correlationUUID}/{language_code}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardArticleAPI.NewDeleteHandler(dashboardDeleteArticleUsecase), authorizer, permission.ArticlesDelete), jwt, userRepository, translator)) + mux.Handle("GET /api/dashboard/articles", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardArticleAPI.NewIndexHandler(dashboardGetArticlesUsecase), authorizer, permission.ArticlesIndex), jwt, userRepository, translator)) + mux.Handle("GET /api/dashboard/articles/{correlationUUID}/{language_code}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardArticleAPI.NewShowHandler(dashboardGetArticleUsecase), authorizer, permission.ArticlesShow), jwt, userRepository, translator)) mux.Handle("PUT /api/dashboard/articles", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { return dashboardArticleAPI.NewUpdateHandler(dashboardUpdateArticle.NewUseCase(articlesRepository, languageRepository, va(c), tr(c))) - }), authorizer, permission.ArticlesUpdate), jwt, userRepository)) + }), authorizer, permission.ArticlesUpdate), jwt, userRepository, translator)) // comments mux.Handle("POST /api/dashboard/comments", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { return dashboardCommentAPI.NewCreateHandler(dashboardCreateComment.NewUseCase(commentsRepository, va(c))) - }), authorizer, permission.CommentsCreate), jwt, userRepository)) - mux.Handle("DELETE /api/dashboard/comments/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardCommentAPI.NewDeleteHandler(dashboardDeleteCommentUsecase), authorizer, permission.CommentsDelete), jwt, userRepository)) - mux.Handle("GET /api/dashboard/comments", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardCommentAPI.NewIndexHandler(dashboardGetCommentsUsecase), authorizer, permission.CommentsIndex), jwt, userRepository)) - mux.Handle("GET /api/dashboard/comments/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardCommentAPI.NewShowHandler(dashboardGetCommentUsecase), authorizer, permission.CommentsShow), jwt, userRepository)) + }), authorizer, permission.CommentsCreate), jwt, userRepository, translator)) + mux.Handle("DELETE /api/dashboard/comments/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardCommentAPI.NewDeleteHandler(dashboardDeleteCommentUsecase), authorizer, permission.CommentsDelete), jwt, userRepository, translator)) + mux.Handle("GET /api/dashboard/comments", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardCommentAPI.NewIndexHandler(dashboardGetCommentsUsecase), authorizer, permission.CommentsIndex), jwt, userRepository, translator)) + mux.Handle("GET /api/dashboard/comments/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardCommentAPI.NewShowHandler(dashboardGetCommentUsecase), authorizer, permission.CommentsShow), jwt, userRepository, translator)) mux.Handle("PUT /api/dashboard/comments", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { return dashboardCommentAPI.NewUpdateHandler(dashboardUpdateComment.NewUseCase(commentsRepository, va(c))) - }), authorizer, permission.CommentsUpdate), jwt, userRepository)) + }), authorizer, permission.CommentsUpdate), jwt, userRepository, translator)) // self comments - mux.Handle("DELETE /api/dashboard/my/comments/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardCommentAPI.NewDeleteUserCommentHandler(dashboardDeleteUserCommentUsecase), authorizer, permission.SelfCommentsDelete), jwt, userRepository)) - mux.Handle("GET /api/dashboard/my/comments", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardCommentAPI.NewIndexUserCommentsHandler(dashboardGetUserCommentsUsecase), authorizer, permission.SelfCommentsIndex), jwt, userRepository)) - mux.Handle("GET /api/dashboard/my/comments/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardCommentAPI.NewShowUserCommentHandler(dashboardGetUserCommentUsecase), authorizer, permission.SelfCommentsShow), jwt, userRepository)) + mux.Handle("DELETE /api/dashboard/my/comments/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardCommentAPI.NewDeleteUserCommentHandler(dashboardDeleteUserCommentUsecase), authorizer, permission.SelfCommentsDelete), jwt, userRepository, translator)) + mux.Handle("GET /api/dashboard/my/comments", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardCommentAPI.NewIndexUserCommentsHandler(dashboardGetUserCommentsUsecase), authorizer, permission.SelfCommentsIndex), jwt, userRepository, translator)) + mux.Handle("GET /api/dashboard/my/comments/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardCommentAPI.NewShowUserCommentHandler(dashboardGetUserCommentUsecase), authorizer, permission.SelfCommentsShow), jwt, userRepository, translator)) mux.Handle("PUT /api/dashboard/my/comments", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { return dashboardCommentAPI.NewUpdateUserCommentHandler(dashboardUpdateUserComment.NewUseCase(commentsRepository, va(c))) - }), authorizer, permission.SelfCommentsUpdate), jwt, userRepository)) + }), authorizer, permission.SelfCommentsUpdate), jwt, userRepository, translator)) // self bookmarks mux.Handle("DELETE /api/dashboard/my/bookmarks", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { return dashboardBookmarkAPI.NewDeleteUserBookmarkHandler(dashboardDeleteUserBookmark.NewUseCase(bookmarkRepository, va(c))) - }), authorizer, permission.SelfBookmarksDelete), jwt, userRepository)) + }), authorizer, permission.SelfBookmarksDelete), jwt, userRepository, translator)) mux.Handle("GET /api/dashboard/my/bookmarks", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { return dashboardBookmarkAPI.NewIndexUserBookmarksHandler(dashboardGetUserBookmarks.NewUseCase(bookmarkRepository, va(c))) - }), authorizer, permission.SelfBookmarksIndex), jwt, userRepository)) + }), authorizer, permission.SelfBookmarksIndex), jwt, userRepository, translator)) // files mux.Handle("POST /api/dashboard/files", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { return dashboardFileAPI.NewUploadHandler(dashboardUploadFile.NewUseCase(filesRepository, fileStorage, va(c))) - }), authorizer, permission.FilesCreate), jwt, userRepository)) - mux.Handle("DELETE /api/dashboard/files/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardFileAPI.NewDeleteHandler(dashboardDeleteFileUseCase), authorizer, permission.FilesDelete), jwt, userRepository)) - mux.Handle("GET /api/dashboard/files", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardFileAPI.NewIndexHandler(dashboardGetFilesUseCase), authorizer, permission.FilesIndex), jwt, userRepository)) - mux.Handle("GET /dashboard/files/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardFileAPI.NewShowHandler(dashboardGetFileUseCase), authorizer, permission.FilesShow), jwt, userRepository)) + }), authorizer, permission.FilesCreate), jwt, userRepository, translator)) + mux.Handle("DELETE /api/dashboard/files/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardFileAPI.NewDeleteHandler(dashboardDeleteFileUseCase), authorizer, permission.FilesDelete), jwt, userRepository, translator)) + mux.Handle("GET /api/dashboard/files", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardFileAPI.NewIndexHandler(dashboardGetFilesUseCase), authorizer, permission.FilesIndex), jwt, userRepository, translator)) + mux.Handle("GET /dashboard/files/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardFileAPI.NewShowHandler(dashboardGetFileUseCase), authorizer, permission.FilesShow), jwt, userRepository, translator)) // self files - mux.Handle("DELETE /api/dashboard/my/files/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardFileAPI.NewDeleteUserHandler(dashboardDeleteUserFileUseCase), authorizer, permission.SelfFilesDelete), jwt, userRepository)) - mux.Handle("GET /api/dashboard/my/files", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardFileAPI.NewIndexUserHandler(dashboardGetUserFilesUseCase), authorizer, permission.SelfFilesIndex), jwt, userRepository)) + mux.Handle("DELETE /api/dashboard/my/files/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardFileAPI.NewDeleteUserHandler(dashboardDeleteUserFileUseCase), authorizer, permission.SelfFilesDelete), jwt, userRepository, translator)) + mux.Handle("GET /api/dashboard/my/files", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardFileAPI.NewIndexUserHandler(dashboardGetUserFilesUseCase), authorizer, permission.SelfFilesIndex), jwt, userRepository, translator)) // elements mux.Handle("POST /api/dashboard/elements", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { return dashboardElementAPI.NewCreateHandler(dashboardCreateElement.NewUseCase(elementsRepository, va(c))) - }), authorizer, permission.ElementsCreate), jwt, userRepository)) - mux.Handle("DELETE /api/dashboard/elements/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardElementAPI.NewDeleteHandler(dashboardDeleteElementUsecase), authorizer, permission.ElementsDelete), jwt, userRepository)) - mux.Handle("GET /api/dashboard/elements", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardElementAPI.NewIndexHandler(dashboardGetElementsUsecase), authorizer, permission.ElementsIndex), jwt, userRepository)) - mux.Handle("GET /api/dashboard/elements/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardElementAPI.NewShowHandler(dashboardGetElementUsecase), authorizer, permission.ElementsShow), jwt, userRepository)) + }), authorizer, permission.ElementsCreate), jwt, userRepository, translator)) + mux.Handle("DELETE /api/dashboard/elements/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardElementAPI.NewDeleteHandler(dashboardDeleteElementUsecase), authorizer, permission.ElementsDelete), jwt, userRepository, translator)) + mux.Handle("GET /api/dashboard/elements", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardElementAPI.NewIndexHandler(dashboardGetElementsUsecase), authorizer, permission.ElementsIndex), jwt, userRepository, translator)) + mux.Handle("GET /api/dashboard/elements/{uuid}", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardElementAPI.NewShowHandler(dashboardGetElementUsecase), authorizer, permission.ElementsShow), jwt, userRepository, translator)) mux.Handle("PUT /api/dashboard/elements", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { return dashboardElementAPI.NewUpdateHandler(dashboardUpdateElement.NewUseCase(elementsRepository, va(c))) - }), authorizer, permission.ElementsUpdate), jwt, userRepository)) + }), authorizer, permission.ElementsUpdate), jwt, userRepository, translator)) // config - mux.Handle("GET /api/dashboard/config", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardConfigAPI.NewShowHandler(dashboardGetConfigUsecase), authorizer, permission.ConfigShow), jwt, userRepository)) + mux.Handle("GET /api/dashboard/config", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(dashboardConfigAPI.NewShowHandler(dashboardGetConfigUsecase), authorizer, permission.ConfigShow), jwt, userRepository, translator)) mux.Handle("PUT /api/dashboard/config", middleware.NewAuthenticateMiddleware(middleware.NewAuthorizeMiddleware(scoped(func(c provider.Container) http.Handler { return dashboardConfigAPI.NewUpdateHandler(dashboardUpdateConfig.NewUseCase(configRepository, languageRepository, va(c), tr(c))) - }), authorizer, permission.ConfigUpdate), jwt, userRepository)) + }), authorizer, permission.ConfigUpdate), jwt, userRepository, translator)) rateLimited, err := middleware.NewRateLimitMiddleware(mux, 600, 1*time.Minute) if err != nil { diff --git a/infrastructure/repository/mongodb/users/model.go b/infrastructure/repository/mongodb/users/model.go index 6eb5aabb..bbedf8e9 100644 --- a/infrastructure/repository/mongodb/users/model.go +++ b/infrastructure/repository/mongodb/users/model.go @@ -13,6 +13,9 @@ type UserBson struct { LanguageCode string `bson:"language_code"` PasswordHash PasswordHashBson `bson:"hash,omitempty"` CreatedAt time.Time `bson:"created_at,omitempty"` + // Nullable rather than a zero time, so lifting a ban writes an explicit + // null instead of leaving the previous date behind. + BannedAt *time.Time `bson:"banned_at"` } type PasswordHashBson struct { diff --git a/infrastructure/repository/mongodb/users/repository.go b/infrastructure/repository/mongodb/users/repository.go index 0cd6f295..129cda51 100644 --- a/infrastructure/repository/mongodb/users/repository.go +++ b/infrastructure/repository/mongodb/users/repository.go @@ -36,6 +36,28 @@ func NewRepository(database *mongo.Database) *UsersRepository { } } +func toDomain(a UserBson) user.User { + var bannedAt time.Time + if a.BannedAt != nil { + bannedAt = *a.BannedAt + } + + return user.User{ + UUID: a.UUID, + Name: a.Name, + Avatar: a.Avatar, + Email: a.Email, + Username: a.Username, + LanguageCode: a.LanguageCode, + PasswordHash: password.Hash{ + Value: a.PasswordHash.Value, + Salt: a.PasswordHash.Salt, + }, + CreatedAt: a.CreatedAt, + BannedAt: bannedAt, + } +} + func (r *UsersRepository) GetAll(ctx context.Context, offset uint, limit uint) ([]user.User, error) { ctx, cancel := context.WithTimeout(ctx, queryTimeout) defer cancel() @@ -57,19 +79,7 @@ func (r *UsersRepository) GetAll(ctx context.Context, offset uint, limit uint) ( if err := cur.Decode(&a); err != nil { return nil, err } - items = append(items, user.User{ - UUID: a.UUID, - Name: a.Name, - Avatar: a.Avatar, - Email: a.Email, - Username: a.Username, - LanguageCode: a.LanguageCode, - PasswordHash: password.Hash{ - Value: a.PasswordHash.Value, - Salt: a.PasswordHash.Salt, - }, - CreatedAt: a.CreatedAt, - }) + items = append(items, toDomain(a)) } if err := cur.Err(); err != nil { @@ -98,19 +108,7 @@ func (r *UsersRepository) GetByUUIDs(ctx context.Context, UUIDs []string) ([]use if err := cur.Decode(&a); err != nil { return nil, err } - items = append(items, user.User{ - UUID: a.UUID, - Name: a.Name, - Avatar: a.Avatar, - Email: a.Email, - Username: a.Username, - LanguageCode: a.LanguageCode, - PasswordHash: password.Hash{ - Value: a.PasswordHash.Value, - Salt: a.PasswordHash.Salt, - }, - CreatedAt: a.CreatedAt, - }) + items = append(items, toDomain(a)) } if err := cur.Err(); err != nil { @@ -132,19 +130,7 @@ func (r *UsersRepository) GetOne(ctx context.Context, UUID string) (user.User, e return user.User{}, err } - return user.User{ - UUID: a.UUID, - Name: a.Name, - Avatar: a.Avatar, - Email: a.Email, - Username: a.Username, - LanguageCode: a.LanguageCode, - PasswordHash: password.Hash{ - Value: a.PasswordHash.Value, - Salt: a.PasswordHash.Salt, - }, - CreatedAt: a.CreatedAt, - }, nil + return toDomain(a), nil } // GetOneByIdentity returns a user which its email or username matches given identity @@ -171,19 +157,7 @@ func (r *UsersRepository) GetOneByIdentity(ctx context.Context, identity string) return user.User{}, err } - return user.User{ - UUID: a.UUID, - Name: a.Name, - Avatar: a.Avatar, - Email: a.Email, - Username: a.Username, - LanguageCode: a.LanguageCode, - PasswordHash: password.Hash{ - Value: a.PasswordHash.Value, - Salt: a.PasswordHash.Salt, - }, - CreatedAt: a.CreatedAt, - }, nil + return toDomain(a), nil } func (r *UsersRepository) Save(ctx context.Context, a *user.User) (string, error) { @@ -198,6 +172,11 @@ func (r *UsersRepository) Save(ctx context.Context, a *user.User) (string, error a.UUID = UUID.String() } + var bannedAt *time.Time + if a.IsBanned() { + bannedAt = &a.BannedAt + } + update := UserBson{ UUID: a.UUID, Name: a.Name, @@ -210,6 +189,7 @@ func (r *UsersRepository) Save(ctx context.Context, a *user.User) (string, error Salt: a.PasswordHash.Salt, }, CreatedAt: time.Now(), + BannedAt: bannedAt, } _, err := r.collection.UpdateOne( diff --git a/presentation/http/blog/api/auth/login.go b/presentation/http/blog/api/auth/login.go index b6bdf6ec..564ec985 100644 --- a/presentation/http/blog/api/auth/login.go +++ b/presentation/http/blog/api/auth/login.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" + applicationAuth "github.com/khanzadimahdi/testproject/application/auth" "github.com/khanzadimahdi/testproject/application/auth/login" infraTrace "github.com/khanzadimahdi/testproject/infrastructure/telemetry/trace" "go.opentelemetry.io/otel/trace" @@ -27,6 +28,7 @@ func NewLoginHandler(useCase *login.UseCase) *loginHandler { // @Param body body login.Request true "Credentials" // @Success 200 {object} login.Response // @Failure 400 {object} map[string]interface{} +// @Failure 403 {object} auth.BannedResponse // @Failure 500 {object} map[string]interface{} // @Router /auth/login [post] func (h *loginHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { @@ -45,6 +47,10 @@ func (h *loginHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { rw.Header().Add("Content-Type", "application/json") rw.WriteHeader(http.StatusBadRequest) json.NewEncoder(rw).Encode(response) + case response != nil && response.Code == applicationAuth.BannedCode: + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusForbidden) + json.NewEncoder(rw).Encode(response) default: rw.Header().Add("Content-Type", "application/json") rw.WriteHeader(http.StatusOK) diff --git a/presentation/http/blog/api/auth/refresh.go b/presentation/http/blog/api/auth/refresh.go index 4569bcf1..c32a4b1f 100644 --- a/presentation/http/blog/api/auth/refresh.go +++ b/presentation/http/blog/api/auth/refresh.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" + applicationAuth "github.com/khanzadimahdi/testproject/application/auth" "github.com/khanzadimahdi/testproject/application/auth/refresh" infraTrace "github.com/khanzadimahdi/testproject/infrastructure/telemetry/trace" "go.opentelemetry.io/otel/trace" @@ -27,6 +28,7 @@ func NewRefreshHandler(useCase *refresh.UseCase) *refreshHandler { // @Param body body refresh.Request true "Refresh request" // @Success 200 {object} refresh.Response // @Failure 400 {object} map[string]interface{} +// @Failure 403 {object} auth.BannedResponse // @Failure 500 {object} map[string]interface{} // @Router /auth/token/refresh [post] func (h *refreshHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { @@ -45,6 +47,10 @@ func (h *refreshHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { rw.Header().Add("Content-Type", "application/json") rw.WriteHeader(http.StatusBadRequest) json.NewEncoder(rw).Encode(response) + case response != nil && response.Code == applicationAuth.BannedCode: + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusForbidden) + json.NewEncoder(rw).Encode(response) default: rw.Header().Add("Content-Type", "application/json") rw.WriteHeader(http.StatusOK) diff --git a/presentation/http/blog/api/dashboard/user/testdata/show-a-user-response.json b/presentation/http/blog/api/dashboard/user/testdata/show-a-user-response.json index 6c23e562..a869ee6e 100644 --- a/presentation/http/blog/api/dashboard/user/testdata/show-a-user-response.json +++ b/presentation/http/blog/api/dashboard/user/testdata/show-a-user-response.json @@ -1 +1 @@ -{"uuid":"user-uuid"} +{"uuid":"user-uuid","banned":false,"banned_at":"0001-01-01T00:00:00Z"} diff --git a/presentation/http/middleware/authenticate.go b/presentation/http/middleware/authenticate.go index a6c62bcf..65c6f64d 100644 --- a/presentation/http/middleware/authenticate.go +++ b/presentation/http/middleware/authenticate.go @@ -1,9 +1,11 @@ package middleware import ( + "encoding/json" "net/http" "github.com/khanzadimahdi/testproject/application/auth" + "github.com/khanzadimahdi/testproject/domain/translator" "github.com/khanzadimahdi/testproject/domain/user" "github.com/khanzadimahdi/testproject/infrastructure/jwt" ) @@ -17,14 +19,16 @@ type Authenticate struct { next http.Handler j *jwt.JWT userRepository user.Repository + translator translator.Translator } var _ http.Handler = &Authenticate{} -func NewAuthenticateMiddleware(next http.Handler, j *jwt.JWT, userRepository user.Repository) *Authenticate { +func NewAuthenticateMiddleware(next http.Handler, j *jwt.JWT, userRepository user.Repository, translator translator.Translator) *Authenticate { return &Authenticate{ j: j, userRepository: userRepository, + translator: translator, next: next, } } @@ -54,9 +58,37 @@ func (a *Authenticate) ServeHTTP(rw http.ResponseWriter, r *http.Request) { return } + // A ban takes effect immediately, including for tokens handed out before it, + // so every authenticated request is checked. 403 rather than 401: the + // credentials are fine, the account is not, and retrying with a fresh token + // would change nothing. + if user.IsBanned() { + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusForbidden) + json.NewEncoder(rw).Encode(auth.NewBannedResponse(a.bannedMessage(r, &user))) + + return + } + a.next.ServeHTTP(rw, r.WithContext(auth.ToContext(r.Context(), &user))) } +// bannedMessage translates the refusal the same way the Localize middleware +// would, except that it runs before it: an explicitly requested language wins, +// otherwise the user's own, otherwise the translator's default. +func (a *Authenticate) bannedMessage(r *http.Request, u *user.User) string { + locale := r.Header.Get(languageCodeHeader) + if len(locale) == 0 { + locale = u.LanguageCode + } + + if len(locale) == 0 { + return a.translator.Translate(auth.BannedTranslationKey) + } + + return a.translator.Translate(auth.BannedTranslationKey, translator.WithLocale(locale)) +} + func (a *Authenticate) bearerToken(r *http.Request) string { offset := len(authenticationHeaderPrefix) h := r.Header.Get(authenticationHeaderName) diff --git a/presentation/http/middleware/authenticate_test.go b/presentation/http/middleware/authenticate_test.go index f082e446..285c5289 100644 --- a/presentation/http/middleware/authenticate_test.go +++ b/presentation/http/middleware/authenticate_test.go @@ -16,6 +16,7 @@ import ( "github.com/khanzadimahdi/testproject/infrastructure/crypto/ecdsa" "github.com/khanzadimahdi/testproject/infrastructure/jwt" "github.com/khanzadimahdi/testproject/infrastructure/repository/mocks/users" + "github.com/khanzadimahdi/testproject/infrastructure/translator" ) func TestAuthenticateMiddleware(t *testing.T) { @@ -27,6 +28,7 @@ func TestAuthenticateMiddleware(t *testing.T) { t.Run("authenticate and run next handler", func(t *testing.T) { var ( userRepository users.MockUsersRepository + translatorMock translator.TranslatorMock u = user.User{ UUID: "user-test-uuid", @@ -48,7 +50,7 @@ func TestAuthenticateMiddleware(t *testing.T) { assert.NoError(t, err) }) - middleware := NewAuthenticateMiddleware(next, j, &userRepository) + middleware := NewAuthenticateMiddleware(next, j, &userRepository, &translatorMock) request := httptest.NewRequest(http.MethodPost, "/", nil) request.Header.Set(authenticationHeaderName, authenticationHeaderPrefix+token) @@ -60,9 +62,53 @@ func TestAuthenticateMiddleware(t *testing.T) { assert.Equal(t, http.StatusOK, response.Code) }) + t.Run("banned user is refused", func(t *testing.T) { + var ( + userRepository users.MockUsersRepository + translatorMock translator.TranslatorMock + + u = user.User{ + UUID: "user-test-uuid", + LanguageCode: "fa", + BannedAt: time.Now(), + } + + bannedMessage = "your account has been suspended" + + token = generateToken(t, j, u, time.Now().Add(10*time.Second), auth.AccessToken) + ) + + userRepository.On("GetOne", mock.Anything, u.UUID).Once().Return(u, nil) + defer userRepository.AssertExpectations(t) + + translatorMock.On("Translate", auth.BannedTranslationKey, mock.Anything).Once().Return(bannedMessage) + defer translatorMock.AssertExpectations(t) + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("the next handler must not run for a banned user") + }) + + middleware := NewAuthenticateMiddleware(next, j, &userRepository, &translatorMock) + + request := httptest.NewRequest(http.MethodPost, "/", nil) + request.Header.Set(authenticationHeaderName, authenticationHeaderPrefix+token) + response := httptest.NewRecorder() + + middleware.ServeHTTP(response, request) + + assert.Equal(t, http.StatusForbidden, response.Code) + assert.Equal(t, "application/json", response.Header().Get("content-type")) + assert.JSONEq( + t, + `{"code":"`+auth.BannedCode+`","message":"`+bannedMessage+`"}`, + response.Body.String(), + ) + }) + t.Run("authentication fails", func(t *testing.T) { var ( userRepository users.MockUsersRepository + translatorMock translator.TranslatorMock u = user.User{ UUID: "user-test-uuid", @@ -81,7 +127,7 @@ func TestAuthenticateMiddleware(t *testing.T) { assert.NoError(t, err) }) - middleware := NewAuthenticateMiddleware(next, j, &userRepository) + middleware := NewAuthenticateMiddleware(next, j, &userRepository, &translatorMock) request := httptest.NewRequest(http.MethodPost, "/", nil) request.Header.Set(authenticationHeaderName, authenticationHeaderPrefix+token) diff --git a/resources/docs/blog/openapi/docs.go b/resources/docs/blog/openapi/docs.go index 2fcc82ec..e859c075 100644 --- a/resources/docs/blog/openapi/docs.go +++ b/resources/docs/blog/openapi/docs.go @@ -143,6 +143,12 @@ const docTemplate = `{ "additionalProperties": true } }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/auth.BannedResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -338,6 +344,12 @@ const docTemplate = `{ "additionalProperties": true } }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/auth.BannedResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -3324,6 +3336,17 @@ const docTemplate = `{ } }, "definitions": { + "auth.BannedResponse": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, "bookmarkExists.Request": { "type": "object", "properties": { @@ -3598,6 +3621,9 @@ const docTemplate = `{ "body": {}, "type": { "type": "string" + }, + "uuid": { + "type": "string" } } }, @@ -4547,6 +4573,13 @@ const docTemplate = `{ "avatar": { "type": "string" }, + "banned": { + "description": "Not omitempty: \"not banned\" is a meaningful false, not an absent value.\nBannedAt rides along so the dashboard can show since when; it is the zero\ntime for an account that was never banned.", + "type": "boolean" + }, + "banned_at": { + "type": "string" + }, "email": { "type": "string" }, @@ -5508,9 +5541,16 @@ const docTemplate = `{ "access_token": { "type": "string" }, + "code": { + "description": "Set when the account is banned: the handler answers 403 with this instead\nof tokens. Shaped like auth.BannedResponse, which every other refusal of a\nbanned user returns.", + "type": "string" + }, "errors": { "$ref": "#/definitions/domain.ValidationErrors" }, + "message": { + "type": "string" + }, "refresh_token": { "type": "string" } @@ -5530,9 +5570,16 @@ const docTemplate = `{ "access_token": { "type": "string" }, + "code": { + "description": "Set when the account was banned after the refresh token was issued: the\nhandler answers 403 with this instead of renewing the session.", + "type": "string" + }, "errors": { "$ref": "#/definitions/domain.ValidationErrors" }, + "message": { + "type": "string" + }, "refresh_token": { "type": "string" } @@ -5729,6 +5776,10 @@ const docTemplate = `{ "avatar": { "type": "string" }, + "banned": { + "description": "Banned carries the intent only; the moment of the ban is the server's to\ndecide, so it is never taken from the client.", + "type": "boolean" + }, "email": { "type": "string" }, diff --git a/resources/docs/blog/openapi/swagger.json b/resources/docs/blog/openapi/swagger.json index f8cc3243..6e19c787 100644 --- a/resources/docs/blog/openapi/swagger.json +++ b/resources/docs/blog/openapi/swagger.json @@ -140,6 +140,12 @@ "additionalProperties": true } }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/auth.BannedResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -335,6 +341,12 @@ "additionalProperties": true } }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/auth.BannedResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -3321,6 +3333,17 @@ } }, "definitions": { + "auth.BannedResponse": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, "bookmarkExists.Request": { "type": "object", "properties": { @@ -3595,6 +3618,9 @@ "body": {}, "type": { "type": "string" + }, + "uuid": { + "type": "string" } } }, @@ -4544,6 +4570,13 @@ "avatar": { "type": "string" }, + "banned": { + "description": "Not omitempty: \"not banned\" is a meaningful false, not an absent value.\nBannedAt rides along so the dashboard can show since when; it is the zero\ntime for an account that was never banned.", + "type": "boolean" + }, + "banned_at": { + "type": "string" + }, "email": { "type": "string" }, @@ -5505,9 +5538,16 @@ "access_token": { "type": "string" }, + "code": { + "description": "Set when the account is banned: the handler answers 403 with this instead\nof tokens. Shaped like auth.BannedResponse, which every other refusal of a\nbanned user returns.", + "type": "string" + }, "errors": { "$ref": "#/definitions/domain.ValidationErrors" }, + "message": { + "type": "string" + }, "refresh_token": { "type": "string" } @@ -5527,9 +5567,16 @@ "access_token": { "type": "string" }, + "code": { + "description": "Set when the account was banned after the refresh token was issued: the\nhandler answers 403 with this instead of renewing the session.", + "type": "string" + }, "errors": { "$ref": "#/definitions/domain.ValidationErrors" }, + "message": { + "type": "string" + }, "refresh_token": { "type": "string" } @@ -5726,6 +5773,10 @@ "avatar": { "type": "string" }, + "banned": { + "description": "Banned carries the intent only; the moment of the ban is the server's to\ndecide, so it is never taken from the client.", + "type": "boolean" + }, "email": { "type": "string" }, diff --git a/resources/docs/blog/openapi/swagger.yaml b/resources/docs/blog/openapi/swagger.yaml index 9b0392f4..eca6e6e0 100644 --- a/resources/docs/blog/openapi/swagger.yaml +++ b/resources/docs/blog/openapi/swagger.yaml @@ -1,5 +1,12 @@ basePath: /api definitions: + auth.BannedResponse: + properties: + code: + type: string + message: + type: string + type: object bookmarkExists.Request: properties: language_code: @@ -178,6 +185,8 @@ definitions: body: {} type: type: string + uuid: + type: string type: object forgetpassword.Request: properties: @@ -793,6 +802,14 @@ definitions: properties: avatar: type: string + banned: + description: |- + Not omitempty: "not banned" is a meaningful false, not an absent value. + BannedAt rides along so the dashboard can show since when; it is the zero + time for an account that was never banned. + type: boolean + banned_at: + type: string email: type: string language_code: @@ -1418,8 +1435,16 @@ definitions: properties: access_token: type: string + code: + description: |- + Set when the account is banned: the handler answers 403 with this instead + of tokens. Shaped like auth.BannedResponse, which every other refusal of a + banned user returns. + type: string errors: $ref: '#/definitions/domain.ValidationErrors' + message: + type: string refresh_token: type: string type: object @@ -1432,8 +1457,15 @@ definitions: properties: access_token: type: string + code: + description: |- + Set when the account was banned after the refresh token was issued: the + handler answers 403 with this instead of renewing the session. + type: string errors: $ref: '#/definitions/domain.ValidationErrors' + message: + type: string refresh_token: type: string type: object @@ -1561,6 +1593,11 @@ definitions: properties: avatar: type: string + banned: + description: |- + Banned carries the intent only; the moment of the ban is the server's to + decide, so it is never taken from the client. + type: boolean email: type: string language_code: @@ -1685,6 +1722,10 @@ paths: schema: additionalProperties: true type: object + "403": + description: Forbidden + schema: + $ref: '#/definitions/auth.BannedResponse' "500": description: Internal Server Error schema: @@ -1816,6 +1857,10 @@ paths: schema: additionalProperties: true type: object + "403": + description: Forbidden + schema: + $ref: '#/definitions/auth.BannedResponse' "500": description: Internal Server Error schema: diff --git a/resources/translation/en.go b/resources/translation/en.go index 0d3cd5f0..9fcf07af 100644 --- a/resources/translation/en.go +++ b/resources/translation/en.go @@ -16,6 +16,7 @@ var english = map[string]string{ "user_already_exists": "user already exists", "identity_not_exists": "identity (email/username) not exists", "invalid_identity_or_password": "identity (email/username) or password is wrong", + "user_is_banned": "your account has been suspended, please contact support if you believe this is a mistake", "one_or_more_permissions_not_exist": "one or more of permissions not exist", "invalid_state_transition": "invalid state transition", "registration_email_subject": "Registration", diff --git a/resources/translation/fa.go b/resources/translation/fa.go index cfed625f..618a2900 100644 --- a/resources/translation/fa.go +++ b/resources/translation/fa.go @@ -16,6 +16,7 @@ var farsi = map[string]string{ "user_already_exists": "کاربر از قبل وجود دارد", "identity_not_exists": "هویت (ایمیل/نام کاربری) وجود ندارد", "invalid_identity_or_password": "هویت (ایمیل/نام کاربری) یا رمز عبور اشتباه است", + "user_is_banned": "حساب کاربری شما مسدود شده است، اگر فکر می‌کنید اشتباهی رخ داده با پشتیبانی تماس بگیرید", "one_or_more_permissions_not_exist": "یک یا چند مجوز وجود ندارد", "invalid_state_transition": "تغییر وضعیت غیر ممکن است", "registration_email_subject": "ثبت نام",