From 2b66ce8eafd138cdc9193a4adddc8f8303fe9d26 Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Sat, 24 Aug 2024 20:37:02 +0100 Subject: [PATCH 1/5] implement middleware to block out unauthorized requrests --- internal/datastore/postgres/user.go | 5 +++ internal/datastore/postgres/user_test.go | 40 +++++++++++++++++++ server/middleware.go | 50 +++++++++++++++++------- server/workspace.go | 44 +++++++++++++++++++++ 4 files changed, 125 insertions(+), 14 deletions(-) create mode 100644 server/workspace.go diff --git a/internal/datastore/postgres/user.go b/internal/datastore/postgres/user.go index 51eb26fe..36fe8f75 100644 --- a/internal/datastore/postgres/user.go +++ b/internal/datastore/postgres/user.go @@ -8,6 +8,7 @@ import ( "github.com/ayinke-llc/malak" "github.com/ayinke-llc/malak/internal/pkg/util" + "github.com/google/uuid" "github.com/uptrace/bun" ) @@ -45,6 +46,10 @@ func (u *userRepo) Get(ctx context.Context, opts *malak.FindUserOptions) (*malak sel = sel.Where("email = ?", opts.Email.String()) } + if opts.ID != uuid.Nil { + sel = sel.Where("id = ?", opts.ID) + } + err := sel.Scan(ctx) if errors.Is(err, sql.ErrNoRows) { err = malak.ErrUserNotFound diff --git a/internal/datastore/postgres/user_test.go b/internal/datastore/postgres/user_test.go index 52b2ee2b..15591265 100644 --- a/internal/datastore/postgres/user_test.go +++ b/internal/datastore/postgres/user_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/ayinke-llc/malak" + "github.com/google/uuid" "github.com/stretchr/testify/require" ) @@ -75,6 +76,45 @@ func TestUser_Create(t *testing.T) { } } +func TestUser_GetUserID(t *testing.T) { + client, teardownFunc := setupDatabase(t) + defer teardownFunc() + + userRepo := NewUserRepository(client) + + tt := []struct { + id string + name string + hasError bool + }{ + { + id: "f0271acc-981e-4229-9c94-4e2b208618f5", + name: "User 1 from fixtures", + }, + { + id: "fe76e7a4-9e9b-4cb6-934f-79e528b7c016", + name: "user does not exists", + hasError: true, + }, + } + + for _, v := range tt { + t.Run(v.name, func(t *testing.T) { + user, err := userRepo.Get(context.Background(), &malak.FindUserOptions{ + ID: uuid.MustParse(v.id), + }) + if v.hasError { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.NotEmpty(t, user.FullName) + require.Equal(t, v.id, user.ID.String()) + }) + } +} + func TestUser_Get(t *testing.T) { client, teardownFunc := setupDatabase(t) defer teardownFunc() diff --git a/server/middleware.go b/server/middleware.go index 5f1fe7ea..261a6ff8 100644 --- a/server/middleware.go +++ b/server/middleware.go @@ -1,29 +1,24 @@ package server import ( - "errors" "net/http" - "strings" + "github.com/ayinke-llc/malak" + "github.com/ayinke-llc/malak/config" + "github.com/ayinke-llc/malak/internal/pkg/jwttoken" "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/render" "github.com/sirupsen/logrus" ) func tokenFromRequest(r *http.Request) (string, error) { - val := r.Header.Get("Authorization") - splitted := strings.Split(val, " ") - var t string - - if len(splitted) != 2 { - return t, errors.New("invalid header structure") - } - - if strings.ToUpper(splitted[0]) != "BEARER" { - return t, errors.New("invalid header structure") + c, err := r.Cookie(CookieNameUser.String()) + if err != nil { + return "", err } - return splitted[1], nil + return c.Value, nil } type contextKey string @@ -33,11 +28,38 @@ const ( orgCtx contextKey = "org" ) -func requireAuthentication(logger *logrus.Entry, +func requireAuthentication( + logger *logrus.Entry, + jwtManager jwttoken.JWTokenManager, + cfg config.Config, + userRepo malak.UserRepository, ) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, span, rid := getTracer(r.Context(), r, "middleware.requireAuthentication", cfg.Otel.IsEnabled) + defer span.End() + + logger = logger.WithField("request_id", rid) + + token, err := tokenFromRequest(r) + if err != nil { + logger.WithError(err).Error("token not found in cookie") + _ = render.Render(w, r, newAPIStatus(http.StatusUnauthorized, "session expired")) + return + } + + data, err := jwtManager.ParseJWToken(token) + if err != nil { + logger.WithError(err).Error("could not parse JWT token") + _ = render.Render(w, r, newAPIStatus(http.StatusUnauthorized, "could not validate JWT token")) + return + } + + user, err := userRepo.Get(ctx, &malak.FindUserOptions{ + ID: data.UserID, + }) + }) } } diff --git a/server/workspace.go b/server/workspace.go new file mode 100644 index 00000000..3709a51b --- /dev/null +++ b/server/workspace.go @@ -0,0 +1,44 @@ +package server + +import ( + "context" + "net/http" + + "github.com/ayinke-llc/malak" + "github.com/ayinke-llc/malak/config" + "github.com/go-chi/render" + "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/trace" +) + +type workspaceHandler struct { + logger *logrus.Entry + cfg config.Config + userRepo malak.UserRepository + workspaceRepo malak.WorkspaceRepository +} + +type createWorkspaceRequest struct { +} + +// @Summary Create a new workspace +// @Tags workspace +// @Accept json +// @Produce json +// @Param message body createWorkspaceRequest true "auth exchange data" +// @Success 200 {object} createdUserResponse +// @Failure 400 {object} APIStatus +// @Failure 401 {object} APIStatus +// @Failure 404 {object} APIStatus +// @Failure 500 {object} APIStatus +// @Router /auth/connect/{provider} [post] +// @Param provider path string true "oauth2 provider" +func (wo *workspaceHandler) createWorkspace( + ctx context.Context, + span trace.Span, + logger *logrus.Entry, + w http.ResponseWriter, + r *http.Request) (render.Renderer, Status) { + + return nil, StatusFailed +} From 7490571b9b1b4baa03027238deafbcb7520ed6a5 Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Sat, 31 Aug 2024 14:53:10 +0100 Subject: [PATCH 2/5] use regular JWTs because of future extensibility instead of enforcing cookies --- docs/docs.go | 151 ++++++++++++++++++++-- docs/swagger.json | 151 ++++++++++++++++++++-- docs/swagger.yaml | 106 ++++++++++++++- generate.go | 2 +- server/auth.go | 51 +++++--- server/http.go | 16 ++- server/middleware.go | 31 ++++- server/response.go | 3 +- server/workspace.go | 7 +- swagger.go | 6 +- user.go | 12 +- web/ui/app/login/page.tsx | 4 + web/ui/app/page.tsx | 8 ++ web/ui/bun.lockb | Bin 208860 -> 209719 bytes web/ui/components/providers/providers.tsx | 10 +- web/ui/components/providers/user.tsx | 8 ++ web/ui/hooks/user.ts | 22 ++++ web/ui/lib/client.ts | 2 +- web/ui/package.json | 3 +- web/ui/store/auth.ts | 34 +++++ 20 files changed, 558 insertions(+), 69 deletions(-) create mode 100644 web/ui/components/providers/user.tsx create mode 100644 web/ui/hooks/user.ts create mode 100644 web/ui/store/auth.ts diff --git a/docs/docs.go b/docs/docs.go index 1c9b15ec..50713be5 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -81,6 +81,109 @@ const docTemplate = `{ } } } + }, + "/user": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Fetch current user. This api should also double as a token validation api", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.createdUserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + } + } + } + }, + "/workspaces": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspace" + ], + "summary": "Create a new workspace", + "parameters": [ + { + "description": "request body to create a workspace", + "name": "message", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.createWorkspaceRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.createdUserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + } + } + } } }, "definitions": { @@ -99,6 +202,15 @@ const docTemplate = `{ }, "malak.User": { "type": "object", + "required": [ + "created_at", + "email", + "full_name", + "id", + "metadata", + "roles", + "updated_at" + ], "properties": { "created_at": { "type": "string" @@ -128,6 +240,9 @@ const docTemplate = `{ }, "malak.UserMetadata": { "type": "object", + "required": [ + "current_workspace" + ], "properties": { "current_workspace": { "description": "Used to keep track of the last used workspace\nIn the instance of multiple workspaces\nSo when next the user logs in, we remember and take them to the\nright place rather than always a list of all their workspaces and they\nhave to select one", @@ -137,6 +252,14 @@ const docTemplate = `{ }, "malak.UserRole": { "type": "object", + "required": [ + "created_at", + "id", + "role", + "updated_at", + "user_id", + "workspace_id" + ], "properties": { "created_at": { "type": "string" @@ -160,6 +283,9 @@ const docTemplate = `{ }, "server.APIStatus": { "type": "object", + "required": [ + "message" + ], "properties": { "message": { "description": "Generic message that tells you the status of the operation", @@ -169,40 +295,47 @@ const docTemplate = `{ }, "server.authenticateUserRequest": { "type": "object", + "required": [ + "code" + ], "properties": { "code": { "type": "string" } } }, + "server.createWorkspaceRequest": { + "type": "object" + }, "server.createdUserResponse": { "type": "object", + "required": [ + "message", + "token", + "user" + ], "properties": { "message": { "description": "Generic message that tells you the status of the operation", "type": "string" }, + "token": { + "type": "string" + }, "user": { "$ref": "#/definitions/malak.User" } } } - }, - "securityDefinitions": { - "ApiKeyAuth": { - "type": "apiKey", - "name": "Authorization", - "in": "header" - } } }` // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ Version: "0.1.0", - Host: "d0f6-102-88-37-85.ngrok-free.app", + Host: "localhost:5300", BasePath: "/v1", - Schemes: []string{"https"}, + Schemes: []string{"http"}, Title: "Malak's API documentation", Description: "", InfoInstanceName: "swagger", diff --git a/docs/swagger.json b/docs/swagger.json index ce3acb38..fcf5ca46 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1,6 +1,6 @@ { "schemes": [ - "https" + "http" ], "swagger": "2.0", "info": { @@ -11,7 +11,7 @@ }, "version": "0.1.0" }, - "host": "d0f6-102-88-37-85.ngrok-free.app", + "host": "localhost:5300", "basePath": "/v1", "paths": { "/auth/connect/{provider}": { @@ -77,6 +77,109 @@ } } } + }, + "/user": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Fetch current user. This api should also double as a token validation api", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.createdUserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + } + } + } + }, + "/workspaces": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspace" + ], + "summary": "Create a new workspace", + "parameters": [ + { + "description": "request body to create a workspace", + "name": "message", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.createWorkspaceRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.createdUserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.APIStatus" + } + } + } + } } }, "definitions": { @@ -95,6 +198,15 @@ }, "malak.User": { "type": "object", + "required": [ + "created_at", + "email", + "full_name", + "id", + "metadata", + "roles", + "updated_at" + ], "properties": { "created_at": { "type": "string" @@ -124,6 +236,9 @@ }, "malak.UserMetadata": { "type": "object", + "required": [ + "current_workspace" + ], "properties": { "current_workspace": { "description": "Used to keep track of the last used workspace\nIn the instance of multiple workspaces\nSo when next the user logs in, we remember and take them to the\nright place rather than always a list of all their workspaces and they\nhave to select one", @@ -133,6 +248,14 @@ }, "malak.UserRole": { "type": "object", + "required": [ + "created_at", + "id", + "role", + "updated_at", + "user_id", + "workspace_id" + ], "properties": { "created_at": { "type": "string" @@ -156,6 +279,9 @@ }, "server.APIStatus": { "type": "object", + "required": [ + "message" + ], "properties": { "message": { "description": "Generic message that tells you the status of the operation", @@ -165,30 +291,37 @@ }, "server.authenticateUserRequest": { "type": "object", + "required": [ + "code" + ], "properties": { "code": { "type": "string" } } }, + "server.createWorkspaceRequest": { + "type": "object" + }, "server.createdUserResponse": { "type": "object", + "required": [ + "message", + "token", + "user" + ], "properties": { "message": { "description": "Generic message that tells you the status of the operation", "type": "string" }, + "token": { + "type": "string" + }, "user": { "$ref": "#/definitions/malak.User" } } } - }, - "securityDefinitions": { - "ApiKeyAuth": { - "type": "apiKey", - "name": "Authorization", - "in": "header" - } } } \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 58d8abb5..fa365d65 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -28,6 +28,14 @@ definitions: type: array updated_at: type: string + required: + - created_at + - email + - full_name + - id + - metadata + - roles + - updated_at type: object malak.UserMetadata: properties: @@ -39,6 +47,8 @@ definitions: right place rather than always a list of all their workspaces and they have to select one type: string + required: + - current_workspace type: object malak.UserRole: properties: @@ -54,27 +64,46 @@ definitions: type: string workspace_id: type: string + required: + - created_at + - id + - role + - updated_at + - user_id + - workspace_id type: object server.APIStatus: properties: message: description: Generic message that tells you the status of the operation type: string + required: + - message type: object server.authenticateUserRequest: properties: code: type: string + required: + - code + type: object + server.createWorkspaceRequest: type: object server.createdUserResponse: properties: message: description: Generic message that tells you the status of the operation type: string + token: + type: string user: $ref: '#/definitions/malak.User' + required: + - message + - token + - user type: object -host: d0f6-102-88-37-85.ngrok-free.app +host: localhost:5300 info: contact: email: lanre@ayinke.ventures @@ -124,11 +153,74 @@ paths: summary: Sign in with a social login provider tags: - auth + /user: + get: + consumes: + - application/json + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.createdUserResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/server.APIStatus' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/server.APIStatus' + "404": + description: Not Found + schema: + $ref: '#/definitions/server.APIStatus' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.APIStatus' + summary: Fetch current user. This api should also double as a token validation + api + tags: + - user + /workspaces: + post: + consumes: + - application/json + parameters: + - description: request body to create a workspace + in: body + name: message + required: true + schema: + $ref: '#/definitions/server.createWorkspaceRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.createdUserResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/server.APIStatus' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/server.APIStatus' + "404": + description: Not Found + schema: + $ref: '#/definitions/server.APIStatus' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.APIStatus' + summary: Create a new workspace + tags: + - workspace schemes: -- https -securityDefinitions: - ApiKeyAuth: - in: header - name: Authorization - type: apiKey +- http swagger: "2.0" diff --git a/generate.go b/generate.go index 7c0ba17c..538276fd 100644 --- a/generate.go +++ b/generate.go @@ -2,7 +2,7 @@ package malak // Swagger generation // -//go:generate swag init -g swagger.go +//go:generate swag init -g swagger.go --requiredByDefault // // // Mocks generation diff --git a/server/auth.go b/server/auth.go index 13dc2eb6..4953077b 100644 --- a/server/auth.go +++ b/server/auth.go @@ -4,7 +4,6 @@ import ( "context" "errors" "net/http" - "time" "github.com/ayinke-llc/malak" "github.com/ayinke-llc/malak/config" @@ -44,20 +43,6 @@ func (a *authenticateUserRequest) Validate() error { return nil } -func writeCookie(w http.ResponseWriter, token jwttoken.JWTokenData) { - - cookie := &http.Cookie{ - Name: CookieNameUser.String(), - Value: token.Token, - Secure: true, - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - Path: "/", - MaxAge: -int(time.Since(token.ExpiresAt).Seconds()), - } - http.SetCookie(w, cookie) -} - // @Summary Sign in with a social login provider // @Tags auth // @Accept json @@ -140,8 +125,12 @@ func (a *authHandler) Login( return newAPIStatus(http.StatusInternalServerError, "an error occurred while generating jwt token"), StatusFailed } - writeCookie(w, token) - return newAPIStatus(http.StatusOK, "logged in successfully"), StatusSuccess + resp := createdUserResponse{ + User: user, + APIStatus: newAPIStatus(http.StatusOK, "Logged in Successfully"), + Token: token.Token, + } + return resp, StatusSuccess } if err != nil { @@ -157,11 +146,35 @@ func (a *authHandler) Login( return newAPIStatus(http.StatusInternalServerError, "an error occurred while generating jwt token"), StatusFailed } - writeCookie(w, authToken) - resp := createdUserResponse{ User: user, APIStatus: newAPIStatus(http.StatusOK, "user Successfully created"), + Token: authToken.Token, } return resp, StatusSuccess } + +// @Summary Fetch current user. This api should also double as a token validation api +// @Tags user +// @Accept json +// @Produce json +// @Success 200 {object} createdUserResponse +// @Failure 400 {object} APIStatus +// @Failure 401 {object} APIStatus +// @Failure 404 {object} APIStatus +// @Failure 500 {object} APIStatus +// @Router /user [get] +func (a *authHandler) fetchCurrentUser( + ctx context.Context, + span trace.Span, + logger *logrus.Entry, + w http.ResponseWriter, + r *http.Request) (render.Renderer, Status) { + + logger.Debug("Fetching user profile") + + return createdUserResponse{ + User: getUserFromContext(r.Context()), + APIStatus: newAPIStatus(http.StatusOK, "user data successfully retrieved"), + }, StatusFailed +} diff --git a/server/http.go b/server/http.go index 149189bc..040d8d98 100644 --- a/server/http.go +++ b/server/http.go @@ -44,7 +44,8 @@ func (rw *responseWriter) WriteHeader(code int) { rw.ResponseWriter.WriteHeader(code) } -func buildRoutes(logger *logrus.Entry, +func buildRoutes( + logger *logrus.Entry, cfg config.Config, jwtTokenManager jwttoken.JWTokenManager, userRepo malak.UserRepository, @@ -60,17 +61,28 @@ func buildRoutes(logger *logrus.Entry, router.Use(jsonResponse) auth := &authHandler{ - logger: logger, userRepo: userRepo, workspaceRepo: workspaceRepo, googleCfg: googleAuthProvider, tokenManager: jwtTokenManager, } + workspaceHandler := &workspaceHandler{} + router.Route("/v1", func(r chi.Router) { r.Route("/auth", func(r chi.Router) { r.Post("/connect/{provider}", WrapMalakHTTPHandler(auth.Login, cfg, "Auth.Login")) }) + + r.Route("/user", func(r chi.Router) { + r.Use(requireAuthentication(logger, jwtTokenManager, cfg, userRepo)) + r.Get("/", WrapMalakHTTPHandler(auth.fetchCurrentUser, cfg, "Auth.fetchCurrentUser")) + }) + + r.Route("/workspaces", func(r chi.Router) { + r.Use(requireAuthentication(logger, jwtTokenManager, cfg, userRepo)) + r.Post("/", WrapMalakHTTPHandler(workspaceHandler.createWorkspace, cfg, "workspaces.new")) + }) }) return cors.AllowAll(). diff --git a/server/middleware.go b/server/middleware.go index 261a6ff8..5032bf74 100644 --- a/server/middleware.go +++ b/server/middleware.go @@ -1,6 +1,9 @@ package server import ( + "context" + "errors" + "fmt" "net/http" "github.com/ayinke-llc/malak" @@ -13,6 +16,7 @@ import ( func tokenFromRequest(r *http.Request) (string, error) { + fmt.Println(r.Cookies()) c, err := r.Cookie(CookieNameUser.String()) if err != nil { return "", err @@ -24,8 +28,8 @@ func tokenFromRequest(r *http.Request) (string, error) { type contextKey string const ( - userCtx contextKey = "user" - orgCtx contextKey = "org" + userCtx contextKey = "user" + workspaceCtx = "workspace" ) func requireAuthentication( @@ -59,20 +63,41 @@ func requireAuthentication( user, err := userRepo.Get(ctx, &malak.FindUserOptions{ ID: data.UserID, }) + if errors.Is(err, malak.ErrUserNotFound) { + _ = render.Render(w, r, newAPIStatus(http.StatusForbidden, "user does not exists. JWT is invalid")) + return + } + + if err != nil { + logger.WithError(err).Error("could not fetch user from database") + _ = render.Render(w, r, newAPIStatus(http.StatusInternalServerError, "an error occurred while checking user")) + return + } + r = r.WithContext(writeUserToCtx(ctx, user)) + + next.ServeHTTP(w, r) }) } } func writeRequestIDHeader(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Request-ID", r.Context().Value(middleware.RequestIDKey).(string)) + w.Header().Set("X-Request-ID", retrieveRequestID(r)) next.ServeHTTP(w, r) }) } func retrieveRequestID(r *http.Request) string { return middleware.GetReqID(r.Context()) } +func writeUserToCtx(ctx context.Context, user *malak.User) context.Context { + return context.WithValue(ctx, userCtx, user) +} + +func getUserFromContext(ctx context.Context) *malak.User { + return ctx.Value(userCtx).(*malak.User) +} + func jsonResponse(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/server/response.go b/server/response.go index 64af3e30..1f3bf192 100644 --- a/server/response.go +++ b/server/response.go @@ -44,6 +44,7 @@ func newAPIStatus(code int, s string) APIStatus { } type createdUserResponse struct { - User *malak.User `json:"user,omitempty"` + User *malak.User `json:"user"` + Token string `json:"token"` APIStatus } diff --git a/server/workspace.go b/server/workspace.go index 3709a51b..39da7c97 100644 --- a/server/workspace.go +++ b/server/workspace.go @@ -2,6 +2,7 @@ package server import ( "context" + "fmt" "net/http" "github.com/ayinke-llc/malak" @@ -25,14 +26,13 @@ type createWorkspaceRequest struct { // @Tags workspace // @Accept json // @Produce json -// @Param message body createWorkspaceRequest true "auth exchange data" +// @Param message body createWorkspaceRequest true "request body to create a workspace" // @Success 200 {object} createdUserResponse // @Failure 400 {object} APIStatus // @Failure 401 {object} APIStatus // @Failure 404 {object} APIStatus // @Failure 500 {object} APIStatus -// @Router /auth/connect/{provider} [post] -// @Param provider path string true "oauth2 provider" +// @Router /workspaces [post] func (wo *workspaceHandler) createWorkspace( ctx context.Context, span trace.Span, @@ -40,5 +40,6 @@ func (wo *workspaceHandler) createWorkspace( w http.ResponseWriter, r *http.Request) (render.Renderer, Status) { + fmt.Println(getUserFromContext(r.Context())) return nil, StatusFailed } diff --git a/swagger.go b/swagger.go index 3cc7ab92..70d7ba4e 100644 --- a/swagger.go +++ b/swagger.go @@ -4,10 +4,10 @@ // @contact.name Ayinke Ventures // @contact.email lanre@ayinke.ventures -// @host d0f6-102-88-37-85.ngrok-free.app +// @host localhost:5300 // @BasePath /v1 -// @schemes https - +// @schemes http +// // @securityDefinitions.apikey ApiKeyAuth // @in header // @name Authorization diff --git a/user.go b/user.go index 39303556..217b67d0 100644 --- a/user.go +++ b/user.go @@ -52,16 +52,16 @@ type UserMetadata struct { } type User struct { - ID uuid.UUID `bun:"type:uuid,default:uuid_generate_v4(),pk" json:"id,omitempty"` - Email Email `json:"email,omitempty"` + ID uuid.UUID `bun:"type:uuid,default:uuid_generate_v4(),pk" json:"id"` + Email Email `json:"email"` - FullName string `json:"full_name,omitempty"` - Metadata *UserMetadata `json:"metadata,omitempty" ` + FullName string `json:"full_name"` + Metadata *UserMetadata `json:"metadata" ` Roles UserRoles `json:"roles" bun:"rel:has-many,join:id=user_id"` - CreatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp" json:"created_at,omitempty" ` - UpdatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp" json:"updated_at,omitempty" ` + CreatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp" json:"created_at" ` + UpdatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp" json:"updated_at" ` DeletedAt *time.Time `bun:",soft_delete,nullzero" json:"-,omitempty" ` bun.BaseModel `bun:"table:users" json:"-"` diff --git a/web/ui/app/login/page.tsx b/web/ui/app/login/page.tsx index 83d5fb9e..5dd9bb11 100644 --- a/web/ui/app/login/page.tsx +++ b/web/ui/app/login/page.tsx @@ -10,11 +10,13 @@ import { useMutation } from "@tanstack/react-query" import { useToast } from "@/components/ui/use-toast" import { HttpResponse, ServerAPIStatus, ServerCreatedUserResponse } from "@/client/Api" import { useRouter } from "next/navigation" +import useAuthStore from "@/store/auth" export default function Login() { const { toast } = useToast(); const router = useRouter() + const { setUser, setToken } = useAuthStore() const mutation = useMutation({ mutationFn: ({ code }: { code: string }) => { @@ -30,6 +32,8 @@ export default function Login() { }) }, onSuccess: (resp: HttpResponse) => { + setUser(resp.data.user) + setToken(resp.data.token) router.push("/") } }) diff --git a/web/ui/app/page.tsx b/web/ui/app/page.tsx index 0e18e190..917656c4 100644 --- a/web/ui/app/page.tsx +++ b/web/ui/app/page.tsx @@ -1,6 +1,14 @@ +"use client" + import { Button } from "@/components/ui/button" +import useAuthStore from "@/store/auth" export default function Home() { + + const { user } = useAuthStore() + + console.log(user) + return (

diff --git a/web/ui/bun.lockb b/web/ui/bun.lockb index 242bdbcec277735c816cf69c0ea8b766ade97e2b..dced6f6dca4f2a210931058b2a5f550e20d88bf3 100755 GIT binary patch delta 41037 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 delta 40191 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 diff --git a/web/ui/components/providers/providers.tsx b/web/ui/components/providers/providers.tsx index c51c1723..c868be22 100644 --- a/web/ui/components/providers/providers.tsx +++ b/web/ui/components/providers/providers.tsx @@ -11,13 +11,13 @@ import { TooltipProvider } from '../ui/tooltip'; import { ThemeProvider } from "@/components/providers/theme"; import { GoogleOAuthProvider } from '@react-oauth/google'; import { GOOGLE_CLIENT_ID } from "@/lib/config"; +import UserProvider from './user'; export default function Providers({ children }: { children: React.ReactNode }) { const queryClient = new QueryClient() return ( - - - - {children} + + + + {children} + diff --git a/web/ui/components/providers/user.tsx b/web/ui/components/providers/user.tsx new file mode 100644 index 00000000..0058a642 --- /dev/null +++ b/web/ui/components/providers/user.tsx @@ -0,0 +1,8 @@ +"use client" + +import { useInitializeUserData } from "@/hooks/user"; + +export default function UserProvider({ children }: { children: React.ReactNode }) { + useInitializeUserData() + return children; +} diff --git a/web/ui/hooks/user.ts b/web/ui/hooks/user.ts new file mode 100644 index 00000000..a7abe0a1 --- /dev/null +++ b/web/ui/hooks/user.ts @@ -0,0 +1,22 @@ +import client from "@/lib/client"; +import useAuthStore from "@/store/auth"; +import { useQuery } from "@tanstack/react-query"; + +export const useInitializeUserData = () => { + + const { setUser } = useAuthStore() + + const { data, isError } = useQuery({ + queryKey: ['userData'], + queryFn: () => client.user.userList(), + staleTime: Infinity, // Prevent auto-refetching + retry: false, // Disable retries + }); + + // Properly handle this case + if (data?.data === undefined || isError) { + return + } + + setUser(data.data.user) +}; diff --git a/web/ui/lib/client.ts b/web/ui/lib/client.ts index 94361c71..4a99080b 100644 --- a/web/ui/lib/client.ts +++ b/web/ui/lib/client.ts @@ -1,5 +1,5 @@ import { Api } from "@/client/Api"; -const client = new Api(); +const client = new Api({}); export default client; diff --git a/web/ui/package.json b/web/ui/package.json index 45d93c74..ee4f6924 100644 --- a/web/ui/package.json +++ b/web/ui/package.json @@ -38,7 +38,8 @@ "swagger-typescript-api": "^13.0.21", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", - "vaul": "^0.9.1" + "vaul": "^0.9.1", + "zustand": "^4.5.5" }, "devDependencies": { "@tanstack/eslint-plugin-query": "^5.52.0", diff --git a/web/ui/store/auth.ts b/web/ui/store/auth.ts new file mode 100644 index 00000000..3b96aa45 --- /dev/null +++ b/web/ui/store/auth.ts @@ -0,0 +1,34 @@ +import { MalakUser } from '@/client/Api'; +import create from 'zustand'; +import { persist } from 'zustand/middleware'; + +type UserState = { + token: string | null + user: MalakUser | null +} + +type Actions = { + isAuthenticated: () => boolean + setUser: (user: MalakUser) => void + setToken: (token: string) => void + logout: () => void +} + +const useAuthStore = create( + persist( + (set, get) => ({ + user: null, + token: null, + isAuthenticated: (): boolean => { + const { user, token } = get() + return user !== null && token !== null + }, + setUser: (user: MalakUser) => set({ user }), + setToken: (token: string) => set({ token }), + logout: (): void => set({ user: null }) + }), { + "name": "auth", + }) +) + +export default useAuthStore; From 610b39265893f1a17042620e55da8392722e4da9 Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Tue, 3 Sep 2024 22:24:38 +0100 Subject: [PATCH 3/5] log user out everytime they are not authenticated --- internal/datastore/postgres/user.go | 4 +- server/auth.go | 2 + server/middleware.go | 11 +- web/ui/app/page.tsx | 2 - web/ui/components/providers/user.tsx | 15 +++ web/ui/components/ui/dialog.tsx | 122 ++++++++++++++++++ .../ui/workspace/creation-modal.tsx | 48 +++++++ 7 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 web/ui/components/ui/dialog.tsx create mode 100644 web/ui/components/ui/workspace/creation-modal.tsx diff --git a/internal/datastore/postgres/user.go b/internal/datastore/postgres/user.go index 36fe8f75..b5869145 100644 --- a/internal/datastore/postgres/user.go +++ b/internal/datastore/postgres/user.go @@ -38,7 +38,9 @@ func (u *userRepo) Get(ctx context.Context, opts *malak.FindUserOptions) (*malak ctx, cancelFn := withContext(ctx) defer cancelFn() - user := new(malak.User) + user := &malak.User{ + Roles: malak.UserRoles{}, + } sel := u.inner.NewSelect().Model(user).Relation("Roles") diff --git a/server/auth.go b/server/auth.go index 4953077b..774b65aa 100644 --- a/server/auth.go +++ b/server/auth.go @@ -3,6 +3,7 @@ package server import ( "context" "errors" + "net/http" "github.com/ayinke-llc/malak" @@ -102,6 +103,7 @@ func (a *authHandler) Login( Email: malak.Email(u.Email), FullName: u.Name, Metadata: &malak.UserMetadata{}, + Roles: malak.UserRoles{}, } err = a.userRepo.Create(ctx, user) diff --git a/server/middleware.go b/server/middleware.go index 5032bf74..3703675a 100644 --- a/server/middleware.go +++ b/server/middleware.go @@ -3,8 +3,8 @@ package server import ( "context" "errors" - "fmt" "net/http" + "strings" "github.com/ayinke-llc/malak" "github.com/ayinke-llc/malak/config" @@ -16,13 +16,12 @@ import ( func tokenFromRequest(r *http.Request) (string, error) { - fmt.Println(r.Cookies()) - c, err := r.Cookie(CookieNameUser.String()) - if err != nil { - return "", err + ss := strings.Split(r.Header.Get("Authorization"), "Bearer") + if len(ss) != 2 { + return "", errors.New("bearer token not found") } - return c.Value, nil + return ss[1], nil } type contextKey string diff --git a/web/ui/app/page.tsx b/web/ui/app/page.tsx index 917656c4..6cf463b9 100644 --- a/web/ui/app/page.tsx +++ b/web/ui/app/page.tsx @@ -7,8 +7,6 @@ export default function Home() { const { user } = useAuthStore() - console.log(user) - return (

diff --git a/web/ui/components/providers/user.tsx b/web/ui/components/providers/user.tsx index 0058a642..09c562ac 100644 --- a/web/ui/components/providers/user.tsx +++ b/web/ui/components/providers/user.tsx @@ -1,8 +1,23 @@ "use client" import { useInitializeUserData } from "@/hooks/user"; +import useAuthStore from "@/store/auth"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect } from "react"; export default function UserProvider({ children }: { children: React.ReactNode }) { useInitializeUserData() + const path = usePathname() + const { isAuthenticated, user } = useAuthStore() + const router = useRouter() + + useEffect(() => { + console.log(isAuthenticated(), "HERE") + if (!isAuthenticated() && path !== "/login") { + router.push("/login") + return + } + }, [user]) + return children; } diff --git a/web/ui/components/ui/dialog.tsx b/web/ui/components/ui/dialog.tsx new file mode 100644 index 00000000..95b0d38a --- /dev/null +++ b/web/ui/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { Cross2Icon } from "@radix-ui/react-icons" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/web/ui/components/ui/workspace/creation-modal.tsx b/web/ui/components/ui/workspace/creation-modal.tsx new file mode 100644 index 00000000..93327f3b --- /dev/null +++ b/web/ui/components/ui/workspace/creation-modal.tsx @@ -0,0 +1,48 @@ +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +export function RequireWorkspaceComponent() { + return ( + + + + + + + Edit profile + + Make changes to your profile here. Click save when you're done. + + +
+
+ + +
+
+ + +
+
+ + + +
+
+ ) +} + From 217afa924a7c3d820281de56b8cd1598545a23ee Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Wed, 4 Sep 2024 00:19:55 +0100 Subject: [PATCH 4/5] use jwt authentication --- docs/docs.go | 7 ++ docs/swagger.json | 7 ++ docs/swagger.yaml | 150 ++++++++++++++------------- internal/pkg/jwttoken/jwt_test.go | 1 - server/middleware.go | 5 +- web/ui/components/providers/user.tsx | 47 +++++++-- web/ui/package.json | 2 +- web/ui/store/auth.ts | 2 +- 8 files changed, 138 insertions(+), 83 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 50713be5..83e92c8f 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -327,6 +327,13 @@ const docTemplate = `{ } } } + }, + "securityDefinitions": { + "ApiKeyAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } } }` diff --git a/docs/swagger.json b/docs/swagger.json index fcf5ca46..b10b1546 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -323,5 +323,12 @@ } } } + }, + "securityDefinitions": { + "ApiKeyAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } } } \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml index fa365d65..76d6c709 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2,14 +2,14 @@ basePath: /v1 definitions: malak.Role: enum: - - admin - - member - - billing + - admin + - member + - billing type: string x-enum-varnames: - - RoleAdmin - - RoleMember - - RoleBilling + - RoleAdmin + - RoleMember + - RoleBilling malak.User: properties: created_at: @@ -21,21 +21,21 @@ definitions: id: type: string metadata: - $ref: '#/definitions/malak.UserMetadata' + $ref: "#/definitions/malak.UserMetadata" roles: items: - $ref: '#/definitions/malak.UserRole' + $ref: "#/definitions/malak.UserRole" type: array updated_at: type: string required: - - created_at - - email - - full_name - - id - - metadata - - roles - - updated_at + - created_at + - email + - full_name + - id + - metadata + - roles + - updated_at type: object malak.UserMetadata: properties: @@ -48,7 +48,7 @@ definitions: have to select one type: string required: - - current_workspace + - current_workspace type: object malak.UserRole: properties: @@ -57,7 +57,7 @@ definitions: id: type: string role: - $ref: '#/definitions/malak.Role' + $ref: "#/definitions/malak.Role" updated_at: type: string user_id: @@ -65,12 +65,12 @@ definitions: workspace_id: type: string required: - - created_at - - id - - role - - updated_at - - user_id - - workspace_id + - created_at + - id + - role + - updated_at + - user_id + - workspace_id type: object server.APIStatus: properties: @@ -78,14 +78,14 @@ definitions: description: Generic message that tells you the status of the operation type: string required: - - message + - message type: object server.authenticateUserRequest: properties: code: type: string required: - - code + - code type: object server.createWorkspaceRequest: type: object @@ -97,11 +97,11 @@ definitions: token: type: string user: - $ref: '#/definitions/malak.User' + $ref: "#/definitions/malak.User" required: - - message - - token - - user + - message + - token + - user type: object host: localhost:5300 info: @@ -114,113 +114,119 @@ paths: /auth/connect/{provider}: post: consumes: - - application/json + - application/json parameters: - - description: auth exchange data - in: body - name: message - required: true - schema: - $ref: '#/definitions/server.authenticateUserRequest' - - description: oauth2 provider - in: path - name: provider - required: true - type: string + - description: auth exchange data + in: body + name: message + required: true + schema: + $ref: "#/definitions/server.authenticateUserRequest" + - description: oauth2 provider + in: path + name: provider + required: true + type: string produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/server.createdUserResponse' + $ref: "#/definitions/server.createdUserResponse" "400": description: Bad Request schema: - $ref: '#/definitions/server.APIStatus' + $ref: "#/definitions/server.APIStatus" "401": description: Unauthorized schema: - $ref: '#/definitions/server.APIStatus' + $ref: "#/definitions/server.APIStatus" "404": description: Not Found schema: - $ref: '#/definitions/server.APIStatus' + $ref: "#/definitions/server.APIStatus" "500": description: Internal Server Error schema: - $ref: '#/definitions/server.APIStatus' + $ref: "#/definitions/server.APIStatus" summary: Sign in with a social login provider tags: - - auth + - auth /user: get: consumes: - - application/json + - application/json produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/server.createdUserResponse' + $ref: "#/definitions/server.createdUserResponse" "400": description: Bad Request schema: - $ref: '#/definitions/server.APIStatus' + $ref: "#/definitions/server.APIStatus" "401": description: Unauthorized schema: - $ref: '#/definitions/server.APIStatus' + $ref: "#/definitions/server.APIStatus" "404": description: Not Found schema: - $ref: '#/definitions/server.APIStatus' + $ref: "#/definitions/server.APIStatus" "500": description: Internal Server Error schema: - $ref: '#/definitions/server.APIStatus' - summary: Fetch current user. This api should also double as a token validation + $ref: "#/definitions/server.APIStatus" + summary: + Fetch current user. This api should also double as a token validation api tags: - - user + - user /workspaces: post: consumes: - - application/json + - application/json parameters: - - description: request body to create a workspace - in: body - name: message - required: true - schema: - $ref: '#/definitions/server.createWorkspaceRequest' + - description: request body to create a workspace + in: body + name: message + required: true + schema: + $ref: "#/definitions/server.createWorkspaceRequest" produces: - - application/json + - application/json responses: "200": description: OK schema: - $ref: '#/definitions/server.createdUserResponse' + $ref: "#/definitions/server.createdUserResponse" "400": description: Bad Request schema: - $ref: '#/definitions/server.APIStatus' + $ref: "#/definitions/server.APIStatus" "401": description: Unauthorized schema: - $ref: '#/definitions/server.APIStatus' + $ref: "#/definitions/server.APIStatus" "404": description: Not Found schema: - $ref: '#/definitions/server.APIStatus' + $ref: "#/definitions/server.APIStatus" "500": description: Internal Server Error schema: - $ref: '#/definitions/server.APIStatus' + $ref: "#/definitions/server.APIStatus" summary: Create a new workspace tags: - - workspace + - workspace schemes: -- http + - http +securityDefinitions: + ApiKeyAuth: + in: header + name: Authorization + type: apiKey swagger: "2.0" diff --git a/internal/pkg/jwttoken/jwt_test.go b/internal/pkg/jwttoken/jwt_test.go index 3de7b042..e1b5b08f 100644 --- a/internal/pkg/jwttoken/jwt_test.go +++ b/internal/pkg/jwttoken/jwt_test.go @@ -47,7 +47,6 @@ func TestJWT_Parse(t *testing.T) { parsedToken, err := manager.ParseJWToken(token.Token) require.NoError(t, err) - t.Log(parsedToken.ExpiresAt) require.Equal(t, userID, parsedToken.UserID) } diff --git a/server/middleware.go b/server/middleware.go index 3703675a..67ada4ad 100644 --- a/server/middleware.go +++ b/server/middleware.go @@ -16,9 +16,10 @@ import ( func tokenFromRequest(r *http.Request) (string, error) { - ss := strings.Split(r.Header.Get("Authorization"), "Bearer") + ss := strings.Split(r.Header.Get("Authorization"), " ") + if len(ss) != 2 { - return "", errors.New("bearer token not found") + return "", errors.New("invalid bearer token structure") } return ss[1], nil diff --git a/web/ui/components/providers/user.tsx b/web/ui/components/providers/user.tsx index 09c562ac..83270448 100644 --- a/web/ui/components/providers/user.tsx +++ b/web/ui/components/providers/user.tsx @@ -1,23 +1,58 @@ "use client" -import { useInitializeUserData } from "@/hooks/user"; +import client from "@/lib/client"; import useAuthStore from "@/store/auth"; import { usePathname, useRouter } from "next/navigation"; import { useEffect } from "react"; export default function UserProvider({ children }: { children: React.ReactNode }) { - useInitializeUserData() const path = usePathname() - const { isAuthenticated, user } = useAuthStore() + const { setUser, isAuthenticated, user, logout } = useAuthStore() const router = useRouter() + // for some reason, it is not always set except you do it like this + // TODO: resolve this hack + const token = useAuthStore.getState().token; + + client.instance.interceptors.request.use( + async (config) => { + if (isAuthenticated()) { + config.headers['Authorization'] = `Bearer ${token}`; + } + return config; + }, + (error) => Promise.reject(error) + ); + + client.instance.interceptors.response.use( + (response) => response, + (error) => { + if (error.response && error.response.status === 401) { + logout() + router.push("/login") + } + + return Promise.reject(error); + } + ); + useEffect(() => { - console.log(isAuthenticated(), "HERE") - if (!isAuthenticated() && path !== "/login") { + if (!isAuthenticated()) { + logout() router.push("/login") return } - }, [user]) + }, [token]) + + useEffect(() => { + if (isAuthenticated()) { + client.user.userList().then(res => { + setUser(res.data.user) + }).catch((err) => { + console.log(err, "authenticate user") + }) + } + }, [token]) return children; } diff --git a/web/ui/package.json b/web/ui/package.json index ee4f6924..e1344f5d 100644 --- a/web/ui/package.json +++ b/web/ui/package.json @@ -7,7 +7,7 @@ "build": "next build", "start": "next start", "lint": "next lint", - "swagger": "bun swagger-typescript-api -p ../../docs/swagger.yaml -o ./client" + "swagger": "bun swagger-typescript-api --axios -p ../../docs/swagger.yaml -o ./client" }, "dependencies": { "@radix-ui/react-accordion": "^1.2.0", diff --git a/web/ui/store/auth.ts b/web/ui/store/auth.ts index 3b96aa45..81880407 100644 --- a/web/ui/store/auth.ts +++ b/web/ui/store/auth.ts @@ -25,7 +25,7 @@ const useAuthStore = create( }, setUser: (user: MalakUser) => set({ user }), setToken: (token: string) => set({ token }), - logout: (): void => set({ user: null }) + logout: (): void => set({ user: null, token: null }) }), { "name": "auth", }) From 8c04e2375bc1f0d830e4ae5f82a0dd75fa3102dd Mon Sep 17 00:00:00 2001 From: Lanre Adelowo Date: Wed, 4 Sep 2024 00:38:00 +0100 Subject: [PATCH 5/5] fix tests --- server/auth_test.go | 28 ++++++++++++------- ...uplicate_email._user_gets_logged_in.golden | 2 +- .../user_was_succesfully_created.golden | 2 +- web/ui/app/login/page.tsx | 9 +++--- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/server/auth_test.go b/server/auth_test.go index 292ddc30..c5465084 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -13,6 +13,7 @@ import ( "github.com/ayinke-llc/malak" "github.com/ayinke-llc/malak/config" "github.com/ayinke-llc/malak/internal/pkg/jwttoken" + mock_jwttoken "github.com/ayinke-llc/malak/internal/pkg/jwttoken/mocks" "github.com/ayinke-llc/malak/internal/pkg/socialauth" socialauth_mocks "github.com/ayinke-llc/malak/internal/pkg/socialauth/mocks" malak_mocks "github.com/ayinke-llc/malak/mocks" @@ -87,6 +88,8 @@ func TestAuthHandler_Login(t *testing.T) { googleCfg := socialauth_mocks.NewMockSocialAuthProvider(controller) userRepo := malak_mocks.NewMockUserRepository(controller) + jwtMock := mock_jwttoken.NewMockJWTokenManager(controller) + v.mockFn(googleCfg, userRepo) a := &authHandler{ @@ -94,7 +97,7 @@ func TestAuthHandler_Login(t *testing.T) { cfg: getConfig(), googleCfg: googleCfg, userRepo: userRepo, - tokenManager: jwttoken.New(getConfig()), + tokenManager: jwtMock, } var b = bytes.NewBuffer(nil) @@ -109,19 +112,21 @@ func TestAuthHandler_Login(t *testing.T) { req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, ctx)) req.Header.Add("Content-Type", "application/json") + if v.expectedStatusCode == http.StatusOK { + jwtMock.EXPECT(). + GenerateJWToken(gomock.Any()). + Times(1). + Return(jwttoken.JWTokenData{ + Token: "b622268d-4512-4e3c-98da-88097753d4b9", + UserID: uuid.MustParse("7e6ad0c8-7a96-4add-a270-52615bd808e6"), + }, nil) + } + WrapMalakHTTPHandler(a.Login, getConfig(), "Auth.Login"). ServeHTTP(rr, req) require.Equal(t, v.expectedStatusCode, rr.Code) verifyMatch(t, rr) - - if rr.Code == http.StatusOK { - for _, v := range rr.Result().Cookies() { - if v.Name == CookieNameUser.String() { - require.NotEmpty(t, v.String()) - } - } - } }) } } @@ -133,6 +138,9 @@ func generateLoginTestTable() []struct { req authenticateUserRequest provider string } { + + var reusedID = uuid.MustParse("37f41afb-afff-45cc-bcc0-71249d95df90") + return []struct { name string mockFn func(googleMock *socialauth_mocks.MockSocialAuthProvider, userRepo *malak_mocks.MockUserRepository) @@ -269,7 +277,7 @@ func generateLoginTestTable() []struct { userRepo.EXPECT().Get(gomock.Any(), gomock.Any()). Times(1). Return(&malak.User{ - ID: uuid.New(), + ID: reusedID, }, nil) }, expectedStatusCode: http.StatusOK, diff --git a/server/testdata/TestAuthHandler_Login/duplicate_email._user_gets_logged_in.golden b/server/testdata/TestAuthHandler_Login/duplicate_email._user_gets_logged_in.golden index c0121e47..7e0523fc 100644 --- a/server/testdata/TestAuthHandler_Login/duplicate_email._user_gets_logged_in.golden +++ b/server/testdata/TestAuthHandler_Login/duplicate_email._user_gets_logged_in.golden @@ -1 +1 @@ -{"message":"logged in successfully"} +{"user":{"id":"37f41afb-afff-45cc-bcc0-71249d95df90","email":"","full_name":"","metadata":null,"roles":null,"created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z"},"token":"b622268d-4512-4e3c-98da-88097753d4b9","message":"Logged in Successfully"} diff --git a/server/testdata/TestAuthHandler_Login/user_was_succesfully_created.golden b/server/testdata/TestAuthHandler_Login/user_was_succesfully_created.golden index b92dd542..b505a45a 100644 --- a/server/testdata/TestAuthHandler_Login/user_was_succesfully_created.golden +++ b/server/testdata/TestAuthHandler_Login/user_was_succesfully_created.golden @@ -1 +1 @@ -{"user":{"id":"00000000-0000-0000-0000-000000000000","email":"test@test.com","full_name":"TEST TEST","metadata":{"current_workspace":"00000000-0000-0000-0000-000000000000"},"roles":null,"created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z"},"message":"user Successfully created"} +{"user":{"id":"00000000-0000-0000-0000-000000000000","email":"test@test.com","full_name":"TEST TEST","metadata":{"current_workspace":"00000000-0000-0000-0000-000000000000"},"roles":[],"created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z"},"token":"b622268d-4512-4e3c-98da-88097753d4b9","message":"user Successfully created"} diff --git a/web/ui/app/login/page.tsx b/web/ui/app/login/page.tsx index 5dd9bb11..421b3fe5 100644 --- a/web/ui/app/login/page.tsx +++ b/web/ui/app/login/page.tsx @@ -8,9 +8,10 @@ import { MALAK_PRIVACY_POLICY_LINK, MALAK_TERMS_CONDITION_LINK } from "@/lib/con import client from "@/lib/client" import { useMutation } from "@tanstack/react-query" import { useToast } from "@/components/ui/use-toast" -import { HttpResponse, ServerAPIStatus, ServerCreatedUserResponse } from "@/client/Api" +import { ServerCreatedUserResponse } from "@/client/Api" import { useRouter } from "next/navigation" import useAuthStore from "@/store/auth" +import { AxiosResponse } from "axios" export default function Login() { @@ -25,13 +26,13 @@ export default function Login() { }) }, gcTime: 0, - onError: (err: HttpResponse): void => { + onError: (err: AxiosResponse): void => { toast({ variant: "destructive", - title: err.error.message, + title: err.data.message, }) }, - onSuccess: (resp: HttpResponse) => { + onSuccess: (resp: AxiosResponse) => { setUser(resp.data.user) setToken(resp.data.token) router.push("/")