Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions application/auth/banned.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
6 changes: 6 additions & 0 deletions application/auth/login/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
9 changes: 9 additions & 0 deletions application/auth/login/usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions application/auth/login/usecase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"reflect"
"testing"
"time"

jwtv5 "github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -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)
})
}
5 changes: 5 additions & 0 deletions application/auth/refresh/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
9 changes: 9 additions & 0 deletions application/auth/refresh/usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions application/auth/refresh/usecase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
7 changes: 7 additions & 0 deletions application/dashboard/user/getUser/response.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
package getuser

import "time"

type Response struct {
UUID string `json:"uuid,omitempty"`
Name string `json:"name,omitempty"`
Avatar string `json:"avatar,omitempty"`
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"`
}
2 changes: 2 additions & 0 deletions application/dashboard/user/getUser/useCase.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
3 changes: 3 additions & 0 deletions application/dashboard/user/updateUser/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down
1 change: 1 addition & 0 deletions application/dashboard/user/updateUser/usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
96 changes: 96 additions & 0 deletions application/dashboard/user/updateUser/usecase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"reflect"
"testing"
"time"

"github.com/stretchr/testify/assert"
mock2 "github.com/stretchr/testify/mock"
Expand Down Expand Up @@ -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()

Expand Down
20 changes: 20 additions & 0 deletions domain/user/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading