diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4ece26..f970ad5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,10 +22,16 @@ jobs: - name: Set APP_VERSION env run: echo APP_VERSION=$(echo ${GITHUB_REF} | rev | cut -d'/' -f 1 | rev ) >> ${GITHUB_ENV} - - name: Set up Node.js - uses: actions/setup-node@v3 + - uses: pnpm/action-setup@v2 + with: + version: 8 + + - uses: actions/setup-node@v3 with: node-version: 18.x + cache: 'pnpm' + cache-dependency-path: | + internal/web/pnpm-lock.yaml - name: Prepare Vue App run: | diff --git a/Dockerfile b/Dockerfile index e654c14..3cf4d54 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,10 +9,10 @@ WORKDIR /go/src/app COPY . . RUN go mod download -RUN CGO_ENABLED=0 go build -o /go/bin/quiz-app -ldflags="-s -w -X 'github.com/michaelcoll/quiz-app/cmd.version=$VERSION'" +RUN go build -o /go/bin/quiz-app -ldflags="-s -w -X 'github.com/michaelcoll/quiz-app/cmd.version=$VERSION'" # Now copy it into our base image. -FROM gcr.io/distroless/static-debian11:nonroot +FROM gcr.io/distroless/base-debian11:nonroot COPY --from=build-go /go/bin/quiz-app /bin/quiz-app diff --git a/Makefile b/Makefile index 5409454..f6a62dc 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ build-web: && pnpm run build build-docker: - docker build . -t web --pull --build-arg VERSION=v0.0.1 + docker build . -t michaelcoll/quiz-app:latest --pull --build-arg VERSION=v0.0.1 .PHONY: test test: @@ -43,7 +43,9 @@ vue-lint: run-docker: docker run -ti --rm -p 8080:8080 web:latest -.PHONY: sqlc -sqlc: - sqlc generate \ +.PHONY: generate +generate: + go generate internal/back/domain/repositories.go \ + && go generate internal/back/domain/callers.go \ + && sqlc generate \ && sqlc-addon generate --quiet diff --git a/cmd/banner.go b/cmd/banner.go index 118b16d..0916543 100644 --- a/cmd/banner.go +++ b/cmd/banner.go @@ -54,7 +54,7 @@ const ( Serve Mode = 0 ) -func Print(version string, mode Mode) { +func printBanner(version string, mode Mode) { var modeStr string switch mode { diff --git a/cmd/serve.go b/cmd/serve.go index 836cbf6..11f93ee 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -39,7 +39,7 @@ Starts the server`, } func serve(_ *cobra.Command, _ []string) { - Print(version, Serve) + printBanner(version, Serve) module := back.New() @@ -52,10 +52,12 @@ func serve(_ *cobra.Command, _ []string) { } func init() { - serveCmd.Flags().StringP("repository-url", "r", "", "The url of the repository containing the quizzes") - serveCmd.Flags().StringP("token", "t", "", "The P.A.T. used to access the repository") - serveCmd.Flags().String("auth0-audience", "", "The Auth0 audience used in the clientId") - serveCmd.Flags().String("restrict-email-domain", "", "New users will have to be in this domain to be created") + serveCmd.Flags().StringP("repository-url", "r", "", "The url of the repository containing the quizzes.") + serveCmd.Flags().StringP("token", "t", "", "The P.A.T. used to access the repository.") + serveCmd.Flags().String("auth0-audience", "", "The Auth0 audience used in the clientId.") + serveCmd.Flags().String("restrict-email-domain", "", "New users will have to be in this domain to be created.") + serveCmd.Flags().String("default-admin-email", "", + "The default admin email. If specified when the user with the given email registers, it will be created with admin role automatically.") _ = viper.BindPFlag("repository-url", serveCmd.Flags().Lookup("repository-url")) _ = viper.BindPFlag("token", serveCmd.Flags().Lookup("token")) diff --git a/db/migrations/v1_init.sql b/db/migrations/v1_init.sql index 1f0d241..e142df5 100644 --- a/db/migrations/v1_init.sql +++ b/db/migrations/v1_init.sql @@ -5,7 +5,7 @@ CREATE TABLE quiz filename TEXT NOT NULL, version INTEGER NOT NULL DEFAULT 1, active INTEGER NOT NULL DEFAULT 1, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TEXT NOT NULL, CONSTRAINT filename_fk FOREIGN KEY (filename) REFERENCES quiz (filename), CONSTRAINT quiz_version_unique UNIQUE (filename, version) diff --git a/db/migrations/v2_auth.sql b/db/migrations/v2_auth.sql index 3a13300..6f1bfa2 100644 --- a/db/migrations/v2_auth.sql +++ b/db/migrations/v2_auth.sql @@ -17,25 +17,8 @@ CREATE TABLE user email TEXT NOT NULL, firstname TEXT NOT NULL, lastname TEXT NOT NULL, - active INTEGER NOT NULL DEFAULT 1 -); - -CREATE TABLE user_role -( - user_id TEXT, - role_id INTEGER, + active INTEGER NOT NULL DEFAULT 1, + role_id INTEGER NOT NULL, - CONSTRAINT pk PRIMARY KEY (user_id, role_id), - CONSTRAINT user_fk FOREIGN KEY (user_id) REFERENCES user (id), CONSTRAINT role_fk FOREIGN KEY (role_id) REFERENCES role (id) ); - -CREATE TABLE token -( - opaque_token TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - expires TIMESTAMP NOT NULL, - aud TEXT NOT NULL, - - CONSTRAINT user_fk FOREIGN KEY (user_id) REFERENCES user (id) -); \ No newline at end of file diff --git a/db/queries/auth.sql b/db/queries/auth.sql index 3a2aaf0..6aec0e6 100644 --- a/db/queries/auth.sql +++ b/db/queries/auth.sql @@ -1,26 +1,22 @@ --- name: FindUserById :many -SELECT u.*, ur.role_id -FROM user u LEFT JOIN user_role ur ON u.id = ur.user_id +-- name: FindUserById :one +SELECT * +FROM user WHERE id = ?; --- name: CreateOrReplaceUser :exec -REPLACE INTO user (id, email, firstname, lastname) -VALUES (?, ?, ?, ?); - --- name: FindTokenByTokenStr :one -SELECT t.*, u.email -FROM token t JOIN user u ON u.id = t.user_id -WHERE opaque_token = ?; - --- name: CreateOrReplaceToken :exec -REPLACE INTO token (opaque_token, user_id, expires, aud) -VALUES (?, ?, ?, ?); +-- name: FindAllUser :many +SELECT * +FROM user; --- name: AddRoleToUser :exec -REPLACE INTO user_role (user_id, role_id) -VALUES (?, ?); +-- name: CreateOrReplaceUser :exec +REPLACE INTO user (id, email, firstname, lastname, role_id) +VALUES (?, ?, ?, ?, ?); --- name: RemoveAllRoleFromUser :exec -DELETE FROM user_role -WHERE user_id = ? +-- name: UpdateUserRole :exec +UPDATE user +SET role_id = ? +WHERE id = ?; +-- name: UpdateUserActive :exec +UPDATE user +SET active = ? +WHERE id = ?; diff --git a/db/queries/quiz.sql b/db/queries/quiz.sql index 5659c4e..01e8269 100644 --- a/db/queries/quiz.sql +++ b/db/queries/quiz.sql @@ -1,6 +1,6 @@ -- name: CreateOrReplaceQuiz :exec -REPLACE INTO quiz (sha1, name, filename, version) -VALUES (?, ?, ?, ?); +REPLACE INTO quiz (sha1, name, filename, version, created_at) +VALUES (?, ?, ?, ?, ?); -- name: CreateOrReplaceQuestion :exec REPLACE INTO quiz_question (sha1, content) diff --git a/go.mod b/go.mod index c39c27e..e02f6b1 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/go-git/go-git/v5 v5.6.1 github.com/joeig/gin-cachecontrol v1.1.1 github.com/mattn/go-sqlite3 v1.14.16 + github.com/patrickmn/go-cache v2.1.0+incompatible github.com/spf13/cobra v1.7.0 github.com/spf13/viper v1.15.0 github.com/stretchr/testify v1.8.3 diff --git a/go.sum b/go.sum index a349c7a..90b4561 100644 --- a/go.sum +++ b/go.sum @@ -241,6 +241,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo= github.com/pelletier/go-toml/v2 v2.0.7 h1:muncTPStnKRos5dpVKULv2FVd4bMOhNePj9CjgDb8Us= github.com/pelletier/go-toml/v2 v2.0.7/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= diff --git a/internal/back/domain/authService.go b/internal/back/domain/authService.go index 6c4a130..7771d9b 100644 --- a/internal/back/domain/authService.go +++ b/internal/back/domain/authService.go @@ -18,9 +18,11 @@ package domain import ( "context" + "fmt" "strings" "time" + "github.com/fatih/color" "github.com/spf13/viper" ) @@ -30,44 +32,52 @@ type AuthService struct { } func NewAuthService(r AuthRepository, c AccessTokenCaller) AuthService { + adminEmail := viper.GetString("default-admin-email") + + if len(adminEmail) > 0 { + fmt.Printf("%s Default admin email set to %s\n", color.GreenString("✓"), color.BlueString(adminEmail)) + } + return AuthService{r: r, c: c} } -func (s *AuthService) Register(ctx context.Context, user *User, accessToken string) error { +func (s *AuthService) Register(ctx context.Context, user *User, accessToken string) (*User, error) { token, err := s.validateToken(ctx, accessToken) if err != nil { - return err + return nil, err } if token.Email != user.Email { - return Errorf(UnAuthorized, "token email and user email mismatch (%s != %s)", token.Email, user.Email) + return nil, Errorf(UnAuthorized, "token email and user email mismatch (%s != %s)", token.Email, user.Email) } if token.Email != user.Email { - return Errorf(UnAuthorized, "token email and user email mismatch (%s != %s)", token.Email, user.Email) + return nil, Errorf(UnAuthorized, "token email and user email mismatch (%s != %s)", token.Email, user.Email) } if token.Sub != user.Id { - return Errorf(UnAuthorized, "token sub and user id mismatch (%s != %s)", token.Sub, user.Id) + return nil, Errorf(UnAuthorized, "token sub and user id mismatch (%s != %s)", token.Sub, user.Id) } emailDomain := strings.Split(user.Email, "@")[1] restrictedDomainName := viper.GetString("restrict-email-domain") if len(restrictedDomainName) > 0 && emailDomain != restrictedDomainName { - return Errorf(UnAuthorized, "user is not in a valid domain (%s not in domain %s)", user.Email, restrictedDomainName) + return nil, Errorf(UnAuthorized, "user is not in a valid domain (%s not in domain %s)", user.Email, restrictedDomainName) } - err = s.r.CreateUser(ctx, user) - if err != nil { - return err + adminEmail := viper.GetString("default-admin-email") + if user.Email == adminEmail { + user.Role = Admin + } else { + user.Role = Student } - err = s.r.AddRoleToUser(ctx, user.Id, Student) + err = s.r.CreateUser(ctx, user) if err != nil { - return err + return nil, err } - return nil + return user, nil } func (s *AuthService) validateToken(ctx context.Context, tokenStr string) (token *AccessToken, err error) { @@ -114,7 +124,7 @@ func (s *AuthService) ValidateTokenAndGetUser(ctx context.Context, accessToken s } if token.Provenance == Api { - err := s.r.CreateToken(ctx, token) + err := s.r.CacheToken(ctx, token) if err != nil { return nil, err } @@ -135,3 +145,32 @@ func (s *AuthService) FindUserById(ctx context.Context, id string) (*User, error return user, nil } + +func (role Role) CanAccess(other Role) bool { + + if role == other { + return true + } + + if role == Admin && (other == Teacher || other == Student) { + return true + } + + if role == Teacher && other == Student { + return true + } + + return false +} + +func (s *AuthService) FindAllUser(ctx context.Context) ([]*User, error) { + return s.r.FindAllUser(ctx) +} + +func (s *AuthService) DeactivateUser(ctx context.Context, id string) error { + return s.r.UpdateUserActive(ctx, id, false) +} + +func (s *AuthService) ActivateUser(ctx context.Context, id string) error { + return s.r.UpdateUserActive(ctx, id, true) +} diff --git a/internal/back/domain/authService_test.go b/internal/back/domain/authService_test.go index 5e27d86..d5cd764 100644 --- a/internal/back/domain/authService_test.go +++ b/internal/back/domain/authService_test.go @@ -39,3 +39,13 @@ func TestAuthService_FindUserById(t *testing.T) { } } } + +func TestRole_canAccess(t *testing.T) { + + assert.True(t, Admin.CanAccess(Teacher)) + assert.True(t, Admin.CanAccess(Student)) + assert.True(t, Teacher.CanAccess(Student)) + assert.False(t, Teacher.CanAccess(Admin)) + assert.False(t, Student.CanAccess(Admin)) + +} diff --git a/internal/back/domain/mock_AuthRepository_test.go b/internal/back/domain/mock_AuthRepository_test.go index 53c16c7..835cf9c 100644 --- a/internal/back/domain/mock_AuthRepository_test.go +++ b/internal/back/domain/mock_AuthRepository_test.go @@ -21,13 +21,13 @@ func (_m *MockAuthRepository) EXPECT() *MockAuthRepository_Expecter { return &MockAuthRepository_Expecter{mock: &_m.Mock} } -// AddRoleToUser provides a mock function with given fields: ctx, userId, role -func (_m *MockAuthRepository) AddRoleToUser(ctx context.Context, userId string, role Role) error { - ret := _m.Called(ctx, userId, role) +// CacheToken provides a mock function with given fields: ctx, token +func (_m *MockAuthRepository) CacheToken(ctx context.Context, token *AccessToken) error { + ret := _m.Called(ctx, token) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, string, Role) error); ok { - r0 = rf(ctx, userId, role) + if rf, ok := ret.Get(0).(func(context.Context, *AccessToken) error); ok { + r0 = rf(ctx, token) } else { r0 = ret.Error(0) } @@ -35,43 +35,42 @@ func (_m *MockAuthRepository) AddRoleToUser(ctx context.Context, userId string, return r0 } -// MockAuthRepository_AddRoleToUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddRoleToUser' -type MockAuthRepository_AddRoleToUser_Call struct { +// MockAuthRepository_CacheToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CacheToken' +type MockAuthRepository_CacheToken_Call struct { *mock.Call } -// AddRoleToUser is a helper method to define mock.On call +// CacheToken is a helper method to define mock.On call // - ctx context.Context -// - userId string -// - role Role -func (_e *MockAuthRepository_Expecter) AddRoleToUser(ctx interface{}, userId interface{}, role interface{}) *MockAuthRepository_AddRoleToUser_Call { - return &MockAuthRepository_AddRoleToUser_Call{Call: _e.mock.On("AddRoleToUser", ctx, userId, role)} +// - token *AccessToken +func (_e *MockAuthRepository_Expecter) CacheToken(ctx interface{}, token interface{}) *MockAuthRepository_CacheToken_Call { + return &MockAuthRepository_CacheToken_Call{Call: _e.mock.On("CacheToken", ctx, token)} } -func (_c *MockAuthRepository_AddRoleToUser_Call) Run(run func(ctx context.Context, userId string, role Role)) *MockAuthRepository_AddRoleToUser_Call { +func (_c *MockAuthRepository_CacheToken_Call) Run(run func(ctx context.Context, token *AccessToken)) *MockAuthRepository_CacheToken_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(string), args[2].(Role)) + run(args[0].(context.Context), args[1].(*AccessToken)) }) return _c } -func (_c *MockAuthRepository_AddRoleToUser_Call) Return(_a0 error) *MockAuthRepository_AddRoleToUser_Call { +func (_c *MockAuthRepository_CacheToken_Call) Return(_a0 error) *MockAuthRepository_CacheToken_Call { _c.Call.Return(_a0) return _c } -func (_c *MockAuthRepository_AddRoleToUser_Call) RunAndReturn(run func(context.Context, string, Role) error) *MockAuthRepository_AddRoleToUser_Call { +func (_c *MockAuthRepository_CacheToken_Call) RunAndReturn(run func(context.Context, *AccessToken) error) *MockAuthRepository_CacheToken_Call { _c.Call.Return(run) return _c } -// CreateToken provides a mock function with given fields: ctx, token -func (_m *MockAuthRepository) CreateToken(ctx context.Context, token *AccessToken) error { - ret := _m.Called(ctx, token) +// CreateUser provides a mock function with given fields: ctx, user +func (_m *MockAuthRepository) CreateUser(ctx context.Context, user *User) error { + ret := _m.Called(ctx, user) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *AccessToken) error); ok { - r0 = rf(ctx, token) + if rf, ok := ret.Get(0).(func(context.Context, *User) error); ok { + r0 = rf(ctx, user) } else { r0 = ret.Error(0) } @@ -79,74 +78,85 @@ func (_m *MockAuthRepository) CreateToken(ctx context.Context, token *AccessToke return r0 } -// MockAuthRepository_CreateToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateToken' -type MockAuthRepository_CreateToken_Call struct { +// MockAuthRepository_CreateUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateUser' +type MockAuthRepository_CreateUser_Call struct { *mock.Call } -// CreateToken is a helper method to define mock.On call +// CreateUser is a helper method to define mock.On call // - ctx context.Context -// - token *AccessToken -func (_e *MockAuthRepository_Expecter) CreateToken(ctx interface{}, token interface{}) *MockAuthRepository_CreateToken_Call { - return &MockAuthRepository_CreateToken_Call{Call: _e.mock.On("CreateToken", ctx, token)} +// - user *User +func (_e *MockAuthRepository_Expecter) CreateUser(ctx interface{}, user interface{}) *MockAuthRepository_CreateUser_Call { + return &MockAuthRepository_CreateUser_Call{Call: _e.mock.On("CreateUser", ctx, user)} } -func (_c *MockAuthRepository_CreateToken_Call) Run(run func(ctx context.Context, token *AccessToken)) *MockAuthRepository_CreateToken_Call { +func (_c *MockAuthRepository_CreateUser_Call) Run(run func(ctx context.Context, user *User)) *MockAuthRepository_CreateUser_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(*AccessToken)) + run(args[0].(context.Context), args[1].(*User)) }) return _c } -func (_c *MockAuthRepository_CreateToken_Call) Return(_a0 error) *MockAuthRepository_CreateToken_Call { +func (_c *MockAuthRepository_CreateUser_Call) Return(_a0 error) *MockAuthRepository_CreateUser_Call { _c.Call.Return(_a0) return _c } -func (_c *MockAuthRepository_CreateToken_Call) RunAndReturn(run func(context.Context, *AccessToken) error) *MockAuthRepository_CreateToken_Call { +func (_c *MockAuthRepository_CreateUser_Call) RunAndReturn(run func(context.Context, *User) error) *MockAuthRepository_CreateUser_Call { _c.Call.Return(run) return _c } -// CreateUser provides a mock function with given fields: ctx, user -func (_m *MockAuthRepository) CreateUser(ctx context.Context, user *User) error { - ret := _m.Called(ctx, user) +// FindAllUser provides a mock function with given fields: ctx +func (_m *MockAuthRepository) FindAllUser(ctx context.Context) ([]*User, error) { + ret := _m.Called(ctx) - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *User) error); ok { - r0 = rf(ctx, user) + var r0 []*User + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) ([]*User, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) []*User); ok { + r0 = rf(ctx) } else { - r0 = ret.Error(0) + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*User) + } } - return r0 + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } -// MockAuthRepository_CreateUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateUser' -type MockAuthRepository_CreateUser_Call struct { +// MockAuthRepository_FindAllUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindAllUser' +type MockAuthRepository_FindAllUser_Call struct { *mock.Call } -// CreateUser is a helper method to define mock.On call +// FindAllUser is a helper method to define mock.On call // - ctx context.Context -// - user *User -func (_e *MockAuthRepository_Expecter) CreateUser(ctx interface{}, user interface{}) *MockAuthRepository_CreateUser_Call { - return &MockAuthRepository_CreateUser_Call{Call: _e.mock.On("CreateUser", ctx, user)} +func (_e *MockAuthRepository_Expecter) FindAllUser(ctx interface{}) *MockAuthRepository_FindAllUser_Call { + return &MockAuthRepository_FindAllUser_Call{Call: _e.mock.On("FindAllUser", ctx)} } -func (_c *MockAuthRepository_CreateUser_Call) Run(run func(ctx context.Context, user *User)) *MockAuthRepository_CreateUser_Call { +func (_c *MockAuthRepository_FindAllUser_Call) Run(run func(ctx context.Context)) *MockAuthRepository_FindAllUser_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(*User)) + run(args[0].(context.Context)) }) return _c } -func (_c *MockAuthRepository_CreateUser_Call) Return(_a0 error) *MockAuthRepository_CreateUser_Call { - _c.Call.Return(_a0) +func (_c *MockAuthRepository_FindAllUser_Call) Return(_a0 []*User, _a1 error) *MockAuthRepository_FindAllUser_Call { + _c.Call.Return(_a0, _a1) return _c } -func (_c *MockAuthRepository_CreateUser_Call) RunAndReturn(run func(context.Context, *User) error) *MockAuthRepository_CreateUser_Call { +func (_c *MockAuthRepository_FindAllUser_Call) RunAndReturn(run func(context.Context) ([]*User, error)) *MockAuthRepository_FindAllUser_Call { _c.Call.Return(run) return _c } @@ -261,13 +271,13 @@ func (_c *MockAuthRepository_FindUserById_Call) RunAndReturn(run func(context.Co return _c } -// RemoveAllRoleFromUser provides a mock function with given fields: ctx, userId -func (_m *MockAuthRepository) RemoveAllRoleFromUser(ctx context.Context, userId string) error { - ret := _m.Called(ctx, userId) +// UpdateUserActive provides a mock function with given fields: ctx, id, active +func (_m *MockAuthRepository) UpdateUserActive(ctx context.Context, id string, active bool) error { + ret := _m.Called(ctx, id, active) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { - r0 = rf(ctx, userId) + if rf, ok := ret.Get(0).(func(context.Context, string, bool) error); ok { + r0 = rf(ctx, id, active) } else { r0 = ret.Error(0) } @@ -275,31 +285,76 @@ func (_m *MockAuthRepository) RemoveAllRoleFromUser(ctx context.Context, userId return r0 } -// MockAuthRepository_RemoveAllRoleFromUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveAllRoleFromUser' -type MockAuthRepository_RemoveAllRoleFromUser_Call struct { +// MockAuthRepository_UpdateUserActive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateUserActive' +type MockAuthRepository_UpdateUserActive_Call struct { *mock.Call } -// RemoveAllRoleFromUser is a helper method to define mock.On call +// UpdateUserActive is a helper method to define mock.On call +// - ctx context.Context +// - id string +// - active bool +func (_e *MockAuthRepository_Expecter) UpdateUserActive(ctx interface{}, id interface{}, active interface{}) *MockAuthRepository_UpdateUserActive_Call { + return &MockAuthRepository_UpdateUserActive_Call{Call: _e.mock.On("UpdateUserActive", ctx, id, active)} +} + +func (_c *MockAuthRepository_UpdateUserActive_Call) Run(run func(ctx context.Context, id string, active bool)) *MockAuthRepository_UpdateUserActive_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(bool)) + }) + return _c +} + +func (_c *MockAuthRepository_UpdateUserActive_Call) Return(_a0 error) *MockAuthRepository_UpdateUserActive_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockAuthRepository_UpdateUserActive_Call) RunAndReturn(run func(context.Context, string, bool) error) *MockAuthRepository_UpdateUserActive_Call { + _c.Call.Return(run) + return _c +} + +// UpdateUserRole provides a mock function with given fields: ctx, userId, role +func (_m *MockAuthRepository) UpdateUserRole(ctx context.Context, userId string, role Role) error { + ret := _m.Called(ctx, userId, role) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, Role) error); ok { + r0 = rf(ctx, userId, role) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockAuthRepository_UpdateUserRole_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateUserRole' +type MockAuthRepository_UpdateUserRole_Call struct { + *mock.Call +} + +// UpdateUserRole is a helper method to define mock.On call // - ctx context.Context // - userId string -func (_e *MockAuthRepository_Expecter) RemoveAllRoleFromUser(ctx interface{}, userId interface{}) *MockAuthRepository_RemoveAllRoleFromUser_Call { - return &MockAuthRepository_RemoveAllRoleFromUser_Call{Call: _e.mock.On("RemoveAllRoleFromUser", ctx, userId)} +// - role Role +func (_e *MockAuthRepository_Expecter) UpdateUserRole(ctx interface{}, userId interface{}, role interface{}) *MockAuthRepository_UpdateUserRole_Call { + return &MockAuthRepository_UpdateUserRole_Call{Call: _e.mock.On("UpdateUserRole", ctx, userId, role)} } -func (_c *MockAuthRepository_RemoveAllRoleFromUser_Call) Run(run func(ctx context.Context, userId string)) *MockAuthRepository_RemoveAllRoleFromUser_Call { +func (_c *MockAuthRepository_UpdateUserRole_Call) Run(run func(ctx context.Context, userId string, role Role)) *MockAuthRepository_UpdateUserRole_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(string)) + run(args[0].(context.Context), args[1].(string), args[2].(Role)) }) return _c } -func (_c *MockAuthRepository_RemoveAllRoleFromUser_Call) Return(_a0 error) *MockAuthRepository_RemoveAllRoleFromUser_Call { +func (_c *MockAuthRepository_UpdateUserRole_Call) Return(_a0 error) *MockAuthRepository_UpdateUserRole_Call { _c.Call.Return(_a0) return _c } -func (_c *MockAuthRepository_RemoveAllRoleFromUser_Call) RunAndReturn(run func(context.Context, string) error) *MockAuthRepository_RemoveAllRoleFromUser_Call { +func (_c *MockAuthRepository_UpdateUserRole_Call) RunAndReturn(run func(context.Context, string, Role) error) *MockAuthRepository_UpdateUserRole_Call { _c.Call.Return(run) return _c } diff --git a/internal/back/domain/model.go b/internal/back/domain/model.go index 979886c..e929b97 100644 --- a/internal/back/domain/model.go +++ b/internal/back/domain/model.go @@ -24,7 +24,7 @@ type Quiz struct { Filename string Name string Version int - CreatedAt time.Time + CreatedAt string Active bool Questions map[string]QuizQuestion } @@ -62,7 +62,7 @@ type User struct { Firstname string Lastname string Active bool - Roles []Role + Role Role } type TokenProvenance int8 @@ -76,6 +76,7 @@ type AccessToken struct { Aud string Sub string Exp time.Time + ExpiresIn int Email string Provenance TokenProvenance OpaqueToken string diff --git a/internal/back/domain/quizParseService.go b/internal/back/domain/quizParseService.go index b3d30c0..96e0d0f 100644 --- a/internal/back/domain/quizParseService.go +++ b/internal/back/domain/quizParseService.go @@ -22,6 +22,7 @@ import ( "fmt" "regexp" "strings" + "time" ) // Parse parse the content of a quiz file @@ -41,6 +42,7 @@ func (s *QuizService) Parse(filename string, content string) (*Quiz, error) { Sha1: getSha1(content), Name: name, Filename: filename, + CreatedAt: time.Now().Format(time.RFC3339), Version: 1, Questions: questions, }, nil diff --git a/internal/back/domain/repositories.go b/internal/back/domain/repositories.go index 64249aa..4f1977b 100644 --- a/internal/back/domain/repositories.go +++ b/internal/back/domain/repositories.go @@ -34,9 +34,10 @@ type QuizRepository interface { //go:generate mockery --name AuthRepository type AuthRepository interface { FindUserById(ctx context.Context, id string) (*User, error) + FindAllUser(ctx context.Context) ([]*User, error) CreateUser(ctx context.Context, user *User) error - AddRoleToUser(ctx context.Context, userId string, role Role) error - RemoveAllRoleFromUser(ctx context.Context, userId string) error - CreateToken(ctx context.Context, token *AccessToken) error + UpdateUserActive(ctx context.Context, id string, active bool) error + UpdateUserRole(ctx context.Context, userId string, role Role) error + CacheToken(ctx context.Context, token *AccessToken) error FindTokenByTokenStr(ctx context.Context, tokenStr string) (*AccessToken, error) } diff --git a/internal/back/infrastructure/authRepositoryImpl.go b/internal/back/infrastructure/authRepositoryImpl.go index 8d2e785..d48ff39 100644 --- a/internal/back/infrastructure/authRepositoryImpl.go +++ b/internal/back/infrastructure/authRepositoryImpl.go @@ -20,6 +20,9 @@ import ( "context" "database/sql" "errors" + "time" + + "github.com/patrickmn/go-cache" "github.com/michaelcoll/quiz-app/internal/back/domain" "github.com/michaelcoll/quiz-app/internal/back/infrastructure/sqlc" @@ -28,24 +31,35 @@ import ( type AuthDBRepository struct { domain.AuthRepository - q *sqlc.Queries + q *sqlc.Queries + uc *cache.Cache + tc *cache.Cache } func NewAuthRepository(c *sql.DB) *AuthDBRepository { - return &AuthDBRepository{q: sqlc.New(c)} + userCache := cache.New(30*time.Minute, 10*time.Minute) + tokenCache := cache.New(1*time.Hour, 1*time.Second) + return &AuthDBRepository{q: sqlc.New(c), uc: userCache, tc: tokenCache} } func (r *AuthDBRepository) FindUserById(ctx context.Context, id string) (*domain.User, error) { - rows, err := r.q.FindUserById(ctx, id) - if err != nil { - return nil, err + + if user, found := r.uc.Get(id); found { + return user.(*domain.User), nil } - if len(rows) == 0 { + entity, err := r.q.FindUserById(ctx, id) + if err != nil && errors.Is(err, sql.ErrNoRows) { return nil, nil + } else if err != nil { + return nil, err } - return r.toUser(rows), nil + user := r.toUser(entity) + + r.uc.Set(id, user, cache.DefaultExpiration) + + return user, nil } func (r *AuthDBRepository) CreateUser(ctx context.Context, user *domain.User) error { @@ -54,6 +68,7 @@ func (r *AuthDBRepository) CreateUser(ctx context.Context, user *domain.User) er Email: user.Email, Firstname: user.Firstname, Lastname: user.Lastname, + RoleID: int64(user.Role), }) if err != nil { return err @@ -62,88 +77,74 @@ func (r *AuthDBRepository) CreateUser(ctx context.Context, user *domain.User) er return nil } -func (r *AuthDBRepository) AddRoleToUser(ctx context.Context, userId string, role domain.Role) error { - err := r.q.AddRoleToUser(ctx, sqlc.AddRoleToUserParams{ - UserID: sql.NullString{ - String: userId, - Valid: true, - }, - RoleID: sql.NullInt64{ - Int64: int64(role), - Valid: true, - }, +func (r *AuthDBRepository) UpdateUserRole(ctx context.Context, userId string, role domain.Role) error { + err := r.q.UpdateUserRole(ctx, sqlc.UpdateUserRoleParams{ + ID: userId, + RoleID: int64(role), }) if err != nil { return err } + r.uc.Delete(userId) + return nil } -func (r *AuthDBRepository) RemoveAllRoleFromUser(ctx context.Context, userId string) error { - err := r.q.RemoveAllRoleFromUser(ctx, sql.NullString{ - String: userId, - Valid: true, - }) - if err != nil { - return err - } +func (r *AuthDBRepository) CacheToken(_ context.Context, token *domain.AccessToken) error { + + r.tc.Set(token.OpaqueToken, token, time.Duration(token.ExpiresIn)*time.Second) return nil } -func (r *AuthDBRepository) CreateToken(ctx context.Context, token *domain.AccessToken) error { - err := r.q.CreateOrReplaceToken(ctx, sqlc.CreateOrReplaceTokenParams{ - OpaqueToken: token.OpaqueToken, - UserID: token.Sub, - Expires: token.Exp, - Aud: token.Aud, - }) - if err != nil { - return err +func (r *AuthDBRepository) FindTokenByTokenStr(_ context.Context, tokenStr string) (*domain.AccessToken, error) { + + if t, found := r.tc.Get(tokenStr); found { + token := t.(*domain.AccessToken) + token.Provenance = domain.Cache + + return token, nil } - return nil + return nil, nil } -func (r *AuthDBRepository) FindTokenByTokenStr(ctx context.Context, tokenStr string) (*domain.AccessToken, error) { - token, err := r.q.FindTokenByTokenStr(ctx, tokenStr) - if err != nil && errors.Is(err, sql.ErrNoRows) { - return nil, nil - } else if err != nil { +func (r *AuthDBRepository) FindAllUser(ctx context.Context) ([]*domain.User, error) { + entities, err := r.q.FindAllUser(ctx) + if err != nil { return nil, err } - return r.toAccessToken(token), nil -} + users := make([]*domain.User, len(entities)) -func (r *AuthDBRepository) toUser(entity []sqlc.FindUserByIdRow) *domain.User { - - user := domain.User{ - Roles: []domain.Role{}, + for i, entity := range entities { + users[i] = r.toUser(entity) } - for _, row := range entity { - user.Id = row.ID - user.Email = row.Email - user.Firstname = row.Firstname - user.Lastname = row.Lastname - user.Active = intToBool(row.Active) - role := r.toRole(row.RoleID) - if role > 0 { - user.Roles = append(user.Roles, role) - } - } + return users, nil +} - return &user +func (r *AuthDBRepository) UpdateUserActive(ctx context.Context, id string, active bool) error { + return r.q.UpdateUserActive(ctx, sqlc.UpdateUserActiveParams{ + Active: boolToInt(active), + ID: id, + }) } -func (r *AuthDBRepository) toRole(entity sql.NullInt64) domain.Role { - if !entity.Valid { - return 0 +func (r *AuthDBRepository) toUser(entity sqlc.User) *domain.User { + return &domain.User{ + Id: entity.ID, + Email: entity.Email, + Firstname: entity.Firstname, + Lastname: entity.Lastname, + Active: intToBool(entity.Active), + Role: r.toRole(entity.RoleID), } +} - switch entity.Int64 { +func (r *AuthDBRepository) toRole(entity int64) domain.Role { + switch entity { case int64(domain.Admin): return domain.Admin case int64(domain.Teacher): @@ -154,14 +155,3 @@ func (r *AuthDBRepository) toRole(entity sql.NullInt64) domain.Role { return 0 } - -func (r *AuthDBRepository) toAccessToken(entity sqlc.FindTokenByTokenStrRow) *domain.AccessToken { - return &domain.AccessToken{ - Aud: entity.Aud, - Sub: entity.UserID, - Exp: entity.Expires, - Email: entity.Email, - Provenance: domain.Cache, - OpaqueToken: entity.OpaqueToken, - } -} diff --git a/internal/back/infrastructure/authRepositoryImpl_test.go b/internal/back/infrastructure/authRepositoryImpl_test.go index ca29759..bcd9e54 100644 --- a/internal/back/infrastructure/authRepositoryImpl_test.go +++ b/internal/back/infrastructure/authRepositoryImpl_test.go @@ -58,6 +58,7 @@ func TestAuthDBRepository_FindUserById(t *testing.T) { Email: email, Firstname: firstName, Lastname: lastName, + Role: domain.Admin, }) if err != nil { assert.Failf(t, "Fail to create user", "%v", err) @@ -73,12 +74,6 @@ func TestAuthDBRepository_FindUserById(t *testing.T) { assert.Equal(t, firstName, user.Firstname) assert.Equal(t, lastName, user.Lastname) assert.True(t, user.Active) - assert.Len(t, user.Roles, 0) - - err = r.AddRoleToUser(context.Background(), sub, domain.Admin) - if err != nil { - assert.Failf(t, "Fail to add role", "%v", err) - } user, err = r.FindUserById(context.Background(), sub) if err != nil { @@ -90,6 +85,5 @@ func TestAuthDBRepository_FindUserById(t *testing.T) { assert.Equal(t, firstName, user.Firstname) assert.Equal(t, lastName, user.Lastname) assert.True(t, user.Active) - assert.Len(t, user.Roles, 1) - assert.Equal(t, user.Roles[0], domain.Admin) + assert.Equal(t, user.Role, domain.Admin) } diff --git a/internal/back/infrastructure/commons_test.go b/internal/back/infrastructure/commons_test.go index 67010c6..abeea60 100644 --- a/internal/back/infrastructure/commons_test.go +++ b/internal/back/infrastructure/commons_test.go @@ -30,6 +30,8 @@ const ( aud = "aud" sub = "103275817862301231842" expStr = "1684494062" + expInStr = "3591" + expIn = 3591 exp = 1684494062 firstName = "Cordell" lastName = "Walker" diff --git a/internal/back/infrastructure/db/migration.go b/internal/back/infrastructure/db/migration.go index 107ee65..1f53e53 100644 --- a/internal/back/infrastructure/db/migration.go +++ b/internal/back/infrastructure/db/migration.go @@ -39,7 +39,7 @@ CREATE TABLE quiz filename TEXT NOT NULL, version INTEGER NOT NULL DEFAULT 1, active INTEGER NOT NULL DEFAULT 1, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TEXT NOT NULL, CONSTRAINT filename_fk FOREIGN KEY (filename) REFERENCES quiz (filename), CONSTRAINT quiz_version_unique UNIQUE (filename, version) @@ -98,28 +98,11 @@ CREATE TABLE user email TEXT NOT NULL, firstname TEXT NOT NULL, lastname TEXT NOT NULL, - active INTEGER NOT NULL DEFAULT 1 -); - -CREATE TABLE user_role -( - user_id TEXT, - role_id INTEGER, + active INTEGER NOT NULL DEFAULT 1, + role_id INTEGER NOT NULL, - CONSTRAINT pk PRIMARY KEY (user_id, role_id), - CONSTRAINT user_fk FOREIGN KEY (user_id) REFERENCES user (id), CONSTRAINT role_fk FOREIGN KEY (role_id) REFERENCES role (id) ); - -CREATE TABLE token -( - opaque_token TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - expires TIMESTAMP NOT NULL, - aud TEXT NOT NULL, - - CONSTRAINT user_fk FOREIGN KEY (user_id) REFERENCES user (id) -); ` var migrations = map[int]string{ diff --git a/internal/back/infrastructure/googleCallerImpl.go b/internal/back/infrastructure/googleCallerImpl.go index 96d3058..d63ba5d 100644 --- a/internal/back/infrastructure/googleCallerImpl.go +++ b/internal/back/infrastructure/googleCallerImpl.go @@ -39,6 +39,7 @@ type successResponse struct { Aud string `json:"aud"` Sub string `json:"sub"` Exp string `json:"exp"` + ExpiresIn string `json:"expires_in"` Email string `json:"email"` EmailVerified string `json:"email_verified"` } @@ -102,13 +103,19 @@ func (c *GoogleAccessTokenCaller) toAccessToken(res *successResponse, token stri expUnix, err := strconv.ParseInt(res.Exp, 10, 64) if err != nil { - return nil, domain.Errorf(domain.UnexpectedError, "can't parse expStr %s (%v)", res.Exp, err) + return nil, domain.Errorf(domain.UnexpectedError, "can't parse token exp %s (%v)", res.Exp, err) + } + + expIn, err := strconv.ParseInt(res.ExpiresIn, 10, 64) + if err != nil { + return nil, domain.Errorf(domain.UnexpectedError, "can't parse token expires_in %s (%v)", res.Exp, err) } return &domain.AccessToken{ Aud: res.Aud, Sub: res.Sub, Exp: time.Unix(expUnix, 0), + ExpiresIn: int(expIn), Email: res.Email, Provenance: domain.Api, OpaqueToken: token, diff --git a/internal/back/infrastructure/googleCallerImpl_test.go b/internal/back/infrastructure/googleCallerImpl_test.go index 8c693bf..91c453f 100644 --- a/internal/back/infrastructure/googleCallerImpl_test.go +++ b/internal/back/infrastructure/googleCallerImpl_test.go @@ -39,6 +39,7 @@ func TestGoogleAccessTokenCaller_Get(t *testing.T) { Aud: aud, Sub: sub, Exp: expStr, + ExpiresIn: expInStr, Email: email, EmailVerified: emailVerified, } @@ -64,6 +65,7 @@ func TestGoogleAccessTokenCaller_Get(t *testing.T) { Aud: aud, Sub: sub, Exp: time.Unix(exp, 0), + ExpiresIn: expIn, Email: email, OpaqueToken: accessToken, Provenance: domain.Api, diff --git a/internal/back/infrastructure/quizRepositoryImpl.go b/internal/back/infrastructure/quizRepositoryImpl.go index 732efaf..4da1475 100644 --- a/internal/back/infrastructure/quizRepositoryImpl.go +++ b/internal/back/infrastructure/quizRepositoryImpl.go @@ -65,6 +65,7 @@ func (r *QuizDBRepository) FindFullBySha1(ctx context.Context, sha1 string) (*do quiz.Name = entity.QuizName quiz.Active = intToBool(entity.QuizActive) quiz.Version = int(entity.QuizVersion) + quiz.CreatedAt = entity.QuizCreatedAt quiz.Questions = map[string]domain.QuizQuestion{} } @@ -123,10 +124,11 @@ func (r *QuizDBRepository) FindLatestVersionByFilename(ctx context.Context, file func (r *QuizDBRepository) Create(ctx context.Context, quiz *domain.Quiz) error { err := r.q.CreateOrReplaceQuiz(ctx, sqlc.CreateOrReplaceQuizParams{ - Sha1: quiz.Sha1, - Name: quiz.Name, - Filename: quiz.Filename, - Version: int64(quiz.Version), + Sha1: quiz.Sha1, + Name: quiz.Name, + Filename: quiz.Filename, + Version: int64(quiz.Version), + CreatedAt: quiz.CreatedAt, }) if err != nil { return err diff --git a/internal/back/infrastructure/sqlc/auth.sql.go b/internal/back/infrastructure/sqlc/auth.sql.go index 1c34e01..b66acdd 100644 --- a/internal/back/infrastructure/sqlc/auth.sql.go +++ b/internal/back/infrastructure/sqlc/auth.sql.go @@ -7,50 +7,11 @@ package sqlc import ( "context" - "database/sql" - "time" ) -const addRoleToUser = `-- name: AddRoleToUser :exec -REPLACE INTO user_role (user_id, role_id) -VALUES (?, ?) -` - -type AddRoleToUserParams struct { - UserID sql.NullString `db:"user_id"` - RoleID sql.NullInt64 `db:"role_id"` -} - -func (q *Queries) AddRoleToUser(ctx context.Context, arg AddRoleToUserParams) error { - _, err := q.db.ExecContext(ctx, addRoleToUser, arg.UserID, arg.RoleID) - return err -} - -const createOrReplaceToken = `-- name: CreateOrReplaceToken :exec -REPLACE INTO token (opaque_token, user_id, expires, aud) -VALUES (?, ?, ?, ?) -` - -type CreateOrReplaceTokenParams struct { - OpaqueToken string `db:"opaque_token"` - UserID string `db:"user_id"` - Expires time.Time `db:"expires"` - Aud string `db:"aud"` -} - -func (q *Queries) CreateOrReplaceToken(ctx context.Context, arg CreateOrReplaceTokenParams) error { - _, err := q.db.ExecContext(ctx, createOrReplaceToken, - arg.OpaqueToken, - arg.UserID, - arg.Expires, - arg.Aud, - ) - return err -} - const createOrReplaceUser = `-- name: CreateOrReplaceUser :exec -REPLACE INTO user (id, email, firstname, lastname) -VALUES (?, ?, ?, ?) +REPLACE INTO user (id, email, firstname, lastname, role_id) +VALUES (?, ?, ?, ?, ?) ` type CreateOrReplaceUserParams struct { @@ -58,6 +19,7 @@ type CreateOrReplaceUserParams struct { Email string `db:"email"` Firstname string `db:"firstname"` Lastname string `db:"lastname"` + RoleID int64 `db:"role_id"` } func (q *Queries) CreateOrReplaceUser(ctx context.Context, arg CreateOrReplaceUserParams) error { @@ -66,61 +28,25 @@ func (q *Queries) CreateOrReplaceUser(ctx context.Context, arg CreateOrReplaceUs arg.Email, arg.Firstname, arg.Lastname, + arg.RoleID, ) return err } -const findTokenByTokenStr = `-- name: FindTokenByTokenStr :one -SELECT t.opaque_token, t.user_id, t.expires, t.aud, u.email -FROM token t JOIN user u ON u.id = t.user_id -WHERE opaque_token = ? -` - -type FindTokenByTokenStrRow struct { - OpaqueToken string `db:"opaque_token"` - UserID string `db:"user_id"` - Expires time.Time `db:"expires"` - Aud string `db:"aud"` - Email string `db:"email"` -} - -func (q *Queries) FindTokenByTokenStr(ctx context.Context, opaqueToken string) (FindTokenByTokenStrRow, error) { - row := q.db.QueryRowContext(ctx, findTokenByTokenStr, opaqueToken) - var i FindTokenByTokenStrRow - err := row.Scan( - &i.OpaqueToken, - &i.UserID, - &i.Expires, - &i.Aud, - &i.Email, - ) - return i, err -} - -const findUserById = `-- name: FindUserById :many -SELECT u.id, u.email, u.firstname, u.lastname, u.active, ur.role_id -FROM user u LEFT JOIN user_role ur ON u.id = ur.user_id -WHERE id = ? +const findAllUser = `-- name: FindAllUser :many +SELECT id, email, firstname, lastname, active, role_id +FROM user ` -type FindUserByIdRow struct { - ID string `db:"id"` - Email string `db:"email"` - Firstname string `db:"firstname"` - Lastname string `db:"lastname"` - Active int64 `db:"active"` - RoleID sql.NullInt64 `db:"role_id"` -} - -func (q *Queries) FindUserById(ctx context.Context, id string) ([]FindUserByIdRow, error) { - rows, err := q.db.QueryContext(ctx, findUserById, id) +func (q *Queries) FindAllUser(ctx context.Context) ([]User, error) { + rows, err := q.db.QueryContext(ctx, findAllUser) if err != nil { return nil, err } defer rows.Close() - items := []FindUserByIdRow{} + items := []User{} for rows.Next() { - var i FindUserByIdRow + var i User if err := rows.Scan( &i.ID, &i.Email, @@ -142,12 +68,54 @@ func (q *Queries) FindUserById(ctx context.Context, id string) ([]FindUserByIdRo return items, nil } -const removeAllRoleFromUser = `-- name: RemoveAllRoleFromUser :exec -DELETE FROM user_role -WHERE user_id = ? +const findUserById = `-- name: FindUserById :one +SELECT id, email, firstname, lastname, active, role_id +FROM user +WHERE id = ? +` + +func (q *Queries) FindUserById(ctx context.Context, id string) (User, error) { + row := q.db.QueryRowContext(ctx, findUserById, id) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.Firstname, + &i.Lastname, + &i.Active, + &i.RoleID, + ) + return i, err +} + +const updateUserActive = `-- name: UpdateUserActive :exec +UPDATE user +SET active = ? +WHERE id = ? +` + +type UpdateUserActiveParams struct { + Active int64 `db:"active"` + ID string `db:"id"` +} + +func (q *Queries) UpdateUserActive(ctx context.Context, arg UpdateUserActiveParams) error { + _, err := q.db.ExecContext(ctx, updateUserActive, arg.Active, arg.ID) + return err +} + +const updateUserRole = `-- name: UpdateUserRole :exec +UPDATE user +SET role_id = ? +WHERE id = ? ` -func (q *Queries) RemoveAllRoleFromUser(ctx context.Context, userID sql.NullString) error { - _, err := q.db.ExecContext(ctx, removeAllRoleFromUser, userID) +type UpdateUserRoleParams struct { + RoleID int64 `db:"role_id"` + ID string `db:"id"` +} + +func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) error { + _, err := q.db.ExecContext(ctx, updateUserRole, arg.RoleID, arg.ID) return err } diff --git a/internal/back/infrastructure/sqlc/models.go b/internal/back/infrastructure/sqlc/models.go index bdaf2fe..54eb773 100644 --- a/internal/back/infrastructure/sqlc/models.go +++ b/internal/back/infrastructure/sqlc/models.go @@ -4,18 +4,15 @@ package sqlc -import ( - "database/sql" - "time" -) +import () type Quiz struct { - Sha1 string `db:"sha1"` - Name string `db:"name"` - Filename string `db:"filename"` - Version int64 `db:"version"` - Active int64 `db:"active"` - CreatedAt time.Time `db:"created_at"` + Sha1 string `db:"sha1"` + Name string `db:"name"` + Filename string `db:"filename"` + Version int64 `db:"version"` + Active int64 `db:"active"` + CreatedAt string `db:"created_at"` } type QuizAnswer struct { @@ -44,22 +41,11 @@ type Role struct { Name string `db:"name"` } -type Token struct { - OpaqueToken string `db:"opaque_token"` - UserID string `db:"user_id"` - Expires time.Time `db:"expires"` - Aud string `db:"aud"` -} - type User struct { ID string `db:"id"` Email string `db:"email"` Firstname string `db:"firstname"` Lastname string `db:"lastname"` Active int64 `db:"active"` -} - -type UserRole struct { - UserID sql.NullString `db:"user_id"` - RoleID sql.NullInt64 `db:"role_id"` + RoleID int64 `db:"role_id"` } diff --git a/internal/back/infrastructure/sqlc/quiz.sql.go b/internal/back/infrastructure/sqlc/quiz.sql.go index bdf7b3e..3533a32 100644 --- a/internal/back/infrastructure/sqlc/quiz.sql.go +++ b/internal/back/infrastructure/sqlc/quiz.sql.go @@ -7,7 +7,6 @@ package sqlc import ( "context" - "time" ) const activateOnlyVersion = `-- name: ActivateOnlyVersion :exec @@ -72,15 +71,16 @@ func (q *Queries) CreateOrReplaceQuestion(ctx context.Context, arg CreateOrRepla } const createOrReplaceQuiz = `-- name: CreateOrReplaceQuiz :exec -REPLACE INTO quiz (sha1, name, filename, version) -VALUES (?, ?, ?, ?) +REPLACE INTO quiz (sha1, name, filename, version, created_at) +VALUES (?, ?, ?, ?, ?) ` type CreateOrReplaceQuizParams struct { - Sha1 string `db:"sha1"` - Name string `db:"name"` - Filename string `db:"filename"` - Version int64 `db:"version"` + Sha1 string `db:"sha1"` + Name string `db:"name"` + Filename string `db:"filename"` + Version int64 `db:"version"` + CreatedAt string `db:"created_at"` } func (q *Queries) CreateOrReplaceQuiz(ctx context.Context, arg CreateOrReplaceQuizParams) error { @@ -89,6 +89,7 @@ func (q *Queries) CreateOrReplaceQuiz(ctx context.Context, arg CreateOrReplaceQu arg.Name, arg.Filename, arg.Version, + arg.CreatedAt, ) return err } @@ -199,17 +200,17 @@ WHERE q.sha1 = ? ` type FindQuizFullBySha1Row struct { - QuizSha1 string `db:"quiz_sha1"` - QuizFilename string `db:"quiz_filename"` - QuizName string `db:"quiz_name"` - QuizVersion int64 `db:"quiz_version"` - QuizCreatedAt time.Time `db:"quiz_created_at"` - QuizActive int64 `db:"quiz_active"` - QuestionSha1 string `db:"question_sha1"` - QuestionContent string `db:"question_content"` - AnswerSha1 string `db:"answer_sha1"` - AnswerContent string `db:"answer_content"` - AnswerValid int64 `db:"answer_valid"` + QuizSha1 string `db:"quiz_sha1"` + QuizFilename string `db:"quiz_filename"` + QuizName string `db:"quiz_name"` + QuizVersion int64 `db:"quiz_version"` + QuizCreatedAt string `db:"quiz_created_at"` + QuizActive int64 `db:"quiz_active"` + QuestionSha1 string `db:"question_sha1"` + QuestionContent string `db:"question_content"` + AnswerSha1 string `db:"answer_sha1"` + AnswerContent string `db:"answer_content"` + AnswerValid int64 `db:"answer_valid"` } func (q *Queries) FindQuizFullBySha1(ctx context.Context, sha1 string) ([]FindQuizFullBySha1Row, error) { diff --git a/internal/back/presentation/authController.go b/internal/back/presentation/authController.go index 76e3c1a..8e0b509 100644 --- a/internal/back/presentation/authController.go +++ b/internal/back/presentation/authController.go @@ -35,9 +35,10 @@ func (c *ApiController) register(ctx *gin.Context) { token, exists := ctx.Get("token") if !exists { handleHttpError(ctx, http.StatusUnauthorized, "no token found in headers") + return } - err := c.authService.Register(ctx, &domain.User{ + user, err := c.authService.Register(ctx, &domain.User{ Id: request.Id, Email: request.Email, Firstname: request.Firstname, @@ -45,5 +46,50 @@ func (c *ApiController) register(ctx *gin.Context) { }, token.(string)) if err != nil { handleError(ctx, err) + return } + + dto := User{} + ctx.JSON(http.StatusCreated, dto.fromDomain(user)) +} + +func (c *ApiController) userList(ctx *gin.Context) { + users, err := c.authService.FindAllUser(ctx) + if err != nil { + handleError(ctx, err) + return + } + + dtos := make([]*User, len(users)) + + for i, user := range users { + dto := User{} + dtos[i] = dto.fromDomain(user) + } + + ctx.JSON(http.StatusOK, dtos) +} + +func (c *ApiController) deactivateUser(ctx *gin.Context) { + id := ctx.Param("id") + + err := c.authService.DeactivateUser(ctx, id) + if err != nil { + handleError(ctx, err) + return + } + + ctx.JSON(http.StatusNoContent, gin.H{"message": "user deactivated"}) +} + +func (c *ApiController) activateUser(ctx *gin.Context) { + id := ctx.Param("id") + + err := c.authService.ActivateUser(ctx, id) + if err != nil { + handleError(ctx, err) + return + } + + ctx.JSON(http.StatusNoContent, gin.H{"message": "user activated"}) } diff --git a/internal/back/presentation/authModel.go b/internal/back/presentation/authModel.go deleted file mode 100644 index e4b31e1..0000000 --- a/internal/back/presentation/authModel.go +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2023 Michaël COLL. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package presentation - -type RegisterRequestBody struct { - Id string `json:"id" binding:"required"` - Email string `json:"email" binding:"required"` - Firstname string `json:"firstname" binding:"required"` - Lastname string `json:"lastname" binding:"required"` -} diff --git a/internal/back/presentation/baseApiController.go b/internal/back/presentation/baseApiController.go index 7cbefb3..81b06ac 100644 --- a/internal/back/presentation/baseApiController.go +++ b/internal/back/presentation/baseApiController.go @@ -19,6 +19,7 @@ package presentation import ( "fmt" "log" + "regexp" "github.com/fatih/color" "github.com/gin-gonic/gin" @@ -37,6 +38,29 @@ func NewApiController(quizService *domain.QuizService, authService *domain.AuthS return ApiController{quizService: quizService, authService: authService} } +var pathRoleMapping = map[*endPointDef]domain.Role{ + &endPointDef{ + regex: regexp.MustCompile(`^/api/v1/quiz`), + method: "GET", + }: domain.Student, + &endPointDef{ + regex: regexp.MustCompile(`^/api/v1/quiz/[^/]+`), + method: "GET", + }: domain.Student, + &endPointDef{ + regex: regexp.MustCompile(`^/api/v1/user`), + method: "GET", + }: domain.Admin, + &endPointDef{ + regex: regexp.MustCompile(`^/api/v1/user/[^/]+`), + method: "DELETE", + }: domain.Admin, + &endPointDef{ + regex: regexp.MustCompile(`^/api/v1/user/[^/]+/activate`), + method: "POST", + }: domain.Admin, +} + func (c *ApiController) Serve() { gin.SetMode(gin.ReleaseMode) @@ -51,18 +75,16 @@ func (c *ApiController) Serve() { private := router.Group("/api/v1") private.Use(validateAuthHeaderAndGetUser(c.authService)) - - //private.GET("/daemon", c.daemonList) - //private.GET("/daemon/:id", c.daemonById) - //private.GET("/daemon/:id/media", c.mediaList) + private.Use(enforceRoles) public.POST("/register", c.register) private.GET("/quiz", c.quizList) private.GET("/quiz/:sha1", c.quizBySha1) - //mediaGroup.GET("/daemon/:id/media/:hash", c.contentByHash) - //mediaGroup.GET("/daemon/:id/thumbnail/:hash", c.thumbnailByHash) + private.GET("/user", c.userList) + private.DELETE("/user/:id", c.deactivateUser) + private.POST("/user/:id/activate", c.activateUser) // Listen and serve on 0.0.0.0:8080 fmt.Printf("%s Listening API on http://0.0.0.0%s\n", color.GreenString("✓"), color.GreenString(apiPort)) diff --git a/internal/back/presentation/errors.go b/internal/back/presentation/errors.go index 38db06f..35f0abd 100644 --- a/internal/back/presentation/errors.go +++ b/internal/back/presentation/errors.go @@ -16,7 +16,21 @@ package presentation -import "fmt" +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/michaelcoll/quiz-app/internal/back/domain" +) + +var statusMapping = map[domain.ErrorCode]int{ + domain.NotFound: http.StatusNotFound, + domain.InvalidArgument: http.StatusBadRequest, + domain.UnAuthorized: http.StatusUnauthorized, + domain.UnexpectedError: http.StatusInternalServerError, +} type HttpStatusError struct { status int @@ -44,3 +58,22 @@ func GetCodeFromError(err error) (int, bool) { return 0, false } + +func handleError(ctx *gin.Context, err error) { + status := http.StatusInternalServerError + if code, match := domain.GetCodeFromError(err); match { + if st, match := statusMapping[code]; match { + status = st + } + } + + if st, match := GetCodeFromError(err); match { + status = st + } + + handleHttpError(ctx, status, err.Error()) +} + +func handleHttpError(ctx *gin.Context, st int, message string) { + ctx.AbortWithStatusJSON(st, gin.H{"message": message}) +} diff --git a/internal/back/presentation/middleware.go b/internal/back/presentation/middleware.go index 10fb9bf..d4bf42a 100644 --- a/internal/back/presentation/middleware.go +++ b/internal/back/presentation/middleware.go @@ -17,6 +17,7 @@ package presentation import ( + "fmt" "net/http" "strings" "time" @@ -61,11 +62,12 @@ func validateAuthHeaderAndGetUser(s *domain.AuthService) gin.HandlerFunc { } ctx.Set("user", user) + ctx.Set("role", user.Role) } } func injectTokenIfPresent(ctx *gin.Context) { - if token, err := getBearerToken(ctx); err != nil { + if token, err := getBearerToken(ctx); err == nil { ctx.Set("token", token) } } @@ -84,3 +86,36 @@ func getBearerToken(ctx *gin.Context) (string, error) { return token, nil } + +func enforceRoles(ctx *gin.Context) { + + role := findRoleMatchingEndpointDef(ctx) + if role == 0 { + handleHttpError(ctx, http.StatusForbidden, "forbidden access (path undefined)") + return + } + + if r, found := ctx.Get("role"); found { + userRole := r.(domain.Role) + if !userRole.CanAccess(role) { + handleHttpError(ctx, + http.StatusForbidden, + fmt.Sprintf("forbidden access (userRole %d, required role %d)", userRole, role)) + return + } + + return + } + + handleHttpError(ctx, http.StatusForbidden, "forbidden access (no role in context)") +} + +func findRoleMatchingEndpointDef(ctx *gin.Context) domain.Role { + for def, role := range pathRoleMapping { + if def.match(ctx.Request) { + return role + } + } + + return 0 +} diff --git a/internal/back/presentation/quizModel.go b/internal/back/presentation/model.go similarity index 60% rename from internal/back/presentation/quizModel.go rename to internal/back/presentation/model.go index e37531a..48d7fc8 100644 --- a/internal/back/presentation/quizModel.go +++ b/internal/back/presentation/model.go @@ -17,7 +17,8 @@ package presentation import ( - "time" + "net/http" + "regexp" "github.com/michaelcoll/quiz-app/internal/back/domain" ) @@ -27,7 +28,7 @@ type Quiz struct { Filename string `json:"filename"` Name string `json:"name"` Version int `json:"version"` - CreatedAt time.Time `json:"createdAt"` + CreatedAt string `json:"createdAt"` Active bool `json:"active"` Questions []QuizQuestion `json:"questions,omitempty"` } @@ -43,7 +44,7 @@ type QuizQuestionAnswer struct { Content string `json:"content"` } -func fromDomain(domain *domain.Quiz) *Quiz { +func (q *Quiz) fromDomain(domain *domain.Quiz) *Quiz { quiz := Quiz{ Sha1: domain.Sha1, Filename: domain.Filename, @@ -75,15 +76,62 @@ func fromDomain(domain *domain.Quiz) *Quiz { i++ } - return &quiz + return q } -func fromDomains(domains []*domain.Quiz) []*Quiz { +func toQuizDtos(domains []*domain.Quiz) []*Quiz { dtos := make([]*Quiz, len(domains)) for i, d := range domains { - dtos[i] = fromDomain(d) + dto := Quiz{} + dtos[i] = dto.fromDomain(d) } return dtos } + +type endPointDef struct { + regex *regexp.Regexp + method string +} + +func (e *endPointDef) match(request *http.Request) bool { + path := request.URL.Path + method := request.Method + + return e.regex.MatchString(path) && e.method == method +} + +type RegisterRequestBody struct { + Id string `json:"id" binding:"required"` + Email string `json:"email" binding:"required"` + Firstname string `json:"firstname" binding:"required"` + Lastname string `json:"lastname" binding:"required"` +} + +type User struct { + Id string `json:"id"` + Email string `json:"email"` + Firstname string `json:"firstname"` + Lastname string `json:"lastname"` + Active bool `json:"active"` + Role string `json:"role,omitempty"` +} + +func (u *User) fromDomain(d *domain.User) *User { + u.Id = d.Id + u.Email = d.Email + u.Firstname = d.Firstname + u.Lastname = d.Lastname + u.Active = d.Active + switch d.Role { + case domain.Admin: + u.Role = "ADMIN" + case domain.Teacher: + u.Role = "TEACHER" + case domain.Student: + u.Role = "STUDENT" + } + + return u +} diff --git a/internal/back/presentation/quizApiController.go b/internal/back/presentation/quizController.go similarity index 95% rename from internal/back/presentation/quizApiController.go rename to internal/back/presentation/quizController.go index 2c7d173..c6748a4 100644 --- a/internal/back/presentation/quizApiController.go +++ b/internal/back/presentation/quizController.go @@ -42,7 +42,7 @@ func (c *ApiController) quizList(ctx *gin.Context) { } ctx.Header("Content-Range", fmt.Sprintf("%s %d-%d/%d", "quiz", start, int(start)+len(quizzes), total)) - ctx.JSON(http.StatusOK, fromDomains(quizzes)) + ctx.JSON(http.StatusOK, toQuizDtos(quizzes)) } func extractRangeHeader(rangeHeader string) (uint16, uint16, error) { @@ -88,5 +88,6 @@ func (c *ApiController) quizBySha1(ctx *gin.Context) { return } - ctx.JSON(http.StatusOK, fromDomain(quiz)) + dto := Quiz{} + ctx.JSON(http.StatusOK, dto.fromDomain(quiz)) } diff --git a/internal/back/presentation/status.go b/internal/back/presentation/status.go deleted file mode 100644 index eee7868..0000000 --- a/internal/back/presentation/status.go +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2022-2023 Michaël COLL. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package presentation - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/michaelcoll/quiz-app/internal/back/domain" -) - -var statusMapping = map[domain.ErrorCode]int{ - domain.NotFound: http.StatusNotFound, - domain.InvalidArgument: http.StatusBadRequest, - domain.UnAuthorized: http.StatusUnauthorized, - domain.UnexpectedError: http.StatusInternalServerError, -} - -func handleError(ctx *gin.Context, err error) { - status := http.StatusInternalServerError - if code, match := domain.GetCodeFromError(err); match { - if st, match := statusMapping[code]; match { - status = st - } - } - - if st, match := GetCodeFromError(err); match { - status = st - } - - handleHttpError(ctx, status, err.Error()) -} - -func handleHttpError(ctx *gin.Context, st int, message string) { - ctx.AbortWithStatusJSON(st, gin.H{"message": message}) -}