diff --git a/apps/api/internal/auth/service.go b/apps/api/internal/auth/service.go index 70adcaf7..7da68821 100644 --- a/apps/api/internal/auth/service.go +++ b/apps/api/internal/auth/service.go @@ -66,6 +66,7 @@ type Service struct { sessionStore *store.SessionStore resetTokenStore *store.PasswordResetTokenStore accountStore *store.AccountStore + apiTokenStore *store.ApiTokenStore } func NewService(userStore *store.UserStore, sessionStore *store.SessionStore, resetTokenStore *store.PasswordResetTokenStore) *Service { @@ -74,6 +75,8 @@ func NewService(userStore *store.UserStore, sessionStore *store.SessionStore, re func (s *Service) SetAccountStore(as *store.AccountStore) { s.accountStore = as } +func (s *Service) SetApiTokenStore(ts *store.ApiTokenStore) { s.apiTokenStore = ts } + type SignUpRequest struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=8"` @@ -253,6 +256,37 @@ func (s *Service) UserFromSession(ctx context.Context, sessionKey string) (*mode return user, nil } +// ActiveUserByID returns the user if they exist and are active, otherwise +// nil. Used by API-token authentication so a deactivated user's still-valid +// token is rejected the same way a deactivated user's session is. +func (s *Service) ActiveUserByID(ctx context.Context, id uuid.UUID) (*model.User, error) { + user, err := s.userStore.GetByID(ctx, id) + if err != nil || user == nil || !user.IsActive { + return nil, nil + } + return user, nil +} + +// UserFromAPIToken validates a bearer value as an API token (hashed + +// looked up, active/unexpired), returning the active user if valid, or nil +// if the value isn't a recognized token (not an error — callers should fall +// back to other bearer interpretations). +func (s *Service) UserFromAPIToken(ctx context.Context, plain string) (*model.User, error) { + if s.apiTokenStore == nil { + return nil, nil + } + tok, err := s.apiTokenStore.GetActiveByHash(ctx, store.HashToken(plain)) + if err != nil || tok == nil { + return nil, nil + } + user, err := s.ActiveUserByID(ctx, tok.UserID) + if err != nil || user == nil { + return nil, nil + } + _ = s.apiTokenStore.UpdateLastUsed(ctx, tok.ID) + return user, nil +} + func (s *Service) UpdateProfile(ctx context.Context, u *model.User) error { return s.userStore.Update(ctx, u) } diff --git a/apps/api/internal/handler/auth.go b/apps/api/internal/handler/auth.go index a89fd513..f5268493 100644 --- a/apps/api/internal/handler/auth.go +++ b/apps/api/internal/handler/auth.go @@ -32,6 +32,7 @@ type AuthHandler struct { Ws *store.WorkspaceStore NotifPrefs *store.UserNotificationPreferenceStore ApiTokens *store.ApiTokenStore + InstanceAdmins *store.InstanceAdminStore Queue *queue.Publisher Redis *redis.Client MagicCodeSecret string @@ -120,19 +121,35 @@ func (h *AuthHandler) SignIn(c *gin.Context) { return } } - sessionKey, user, err := h.Auth.SignIn(c.Request.Context(), auth.SignInRequest{Email: req.Email, Password: req.Password}) + ctx := c.Request.Context() + emailNorm := strings.ToLower(strings.TrimSpace(req.Email)) + signinFailKey := redis.PrefixRateLimit + "signinacctfail:" + emailNorm + if h.Redis != nil { + failCount, err := h.Redis.Count(ctx, signinFailKey) + if err == nil && failCount >= 10 { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "Too many failed sign-in attempts for this account, please try again later"}) + return + } + } + sessionKey, user, err := h.Auth.SignIn(ctx, auth.SignInRequest{Email: req.Email, Password: req.Password}) if err != nil { if errors.Is(err, auth.ErrUserDeactivated) { c.JSON(http.StatusForbidden, gin.H{"error": "Your account has been deactivated. Please contact the administrator.", "error_code": "USER_ACCOUNT_DEACTIVATED"}) return } if errors.Is(err, auth.ErrInvalidCredentials) { + if h.Redis != nil { + _, _ = h.Redis.Allow(ctx, signinFailKey, 10, 15*time.Minute) + } c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Sign in failed"}) return } + if h.Redis != nil { + _ = h.Redis.Delete(ctx, signinFailKey) + } setSessionCookie(c, sessionKey) c.JSON(http.StatusOK, userResponse(user)) } @@ -223,7 +240,12 @@ func (h *AuthHandler) Me(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) return } - c.JSON(http.StatusOK, userResponse(user)) + resp := userResponse(user) + if h.InstanceAdmins != nil { + isAdmin, _ := h.InstanceAdmins.IsAdmin(c.Request.Context(), user.ID) + resp["is_instance_admin"] = isAdmin + } + c.JSON(http.StatusOK, resp) } // UpdateMeRequest is the body for PATCH /api/users/me/ @@ -884,6 +906,19 @@ func (h *AuthHandler) MagicCodeVerify(c *gin.Context) { return } + // Failed-verify attempts are tracked on a key independent of the code's + // own TTL/Attempts, so requesting a new code does not reset this lockout. + failCount, err := h.Redis.MagicCodeVerifyFailCount(ctx, body.Email) + if err != nil { + h.log().Error("magic code verify-fail count", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Verification failed"}) + return + } + if failCount >= redis.MagicCodeVerifyFailMax { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "Too many incorrect attempts. Please request a new code later."}) + return + } + stored, err := h.Redis.GetMagicCodeLogin(ctx, body.Email) if err != nil { h.log().Error("magic code redis get", "error", err) @@ -898,6 +933,7 @@ func (h *AuthHandler) MagicCodeVerify(c *gin.Context) { tryMAC := auth.MagicCodeHMAC(h.MagicCodeSecret, body.Email, body.Code) if subtle.ConstantTimeCompare([]byte(stored.CodeMAC), []byte(tryMAC)) != 1 { _ = h.Redis.BumpMagicCodeLoginFailedAttempt(ctx, body.Email) + _ = h.Redis.BumpMagicCodeVerifyFail(ctx, body.Email) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired code"}) return } @@ -909,6 +945,7 @@ func (h *AuthHandler) MagicCodeVerify(c *gin.Context) { } _ = h.Redis.DeleteMagicCodeLogin(ctx, body.Email) + _ = h.Redis.ResetMagicCodeVerifyFail(ctx, body.Email) if stored.IsSignup { sessionKey, user, err := h.Auth.SignUpMagic(ctx, body.Email, body.FirstName, body.LastName) diff --git a/apps/api/internal/handler/auth_test.go b/apps/api/internal/handler/auth_test.go index 548ec681..a81d13c8 100644 --- a/apps/api/internal/handler/auth_test.go +++ b/apps/api/internal/handler/auth_test.go @@ -178,6 +178,21 @@ func TestAuth_Me_ReturnsUser(t *testing.T) { body := testutil.MustJSONMap(t, rr) assert.Equal(t, "me@test.local", body["email"]) assert.Equal(t, user.ID.String(), body["id"]) + assert.Equal(t, false, body["is_instance_admin"]) +} + +// TestAuth_Me_IsInstanceAdminTrueForAdmin proves /api/users/me/ reports +// is_instance_admin: true for an actual instance admin (#163's frontend fix +// gates the instance-admin UI on this field). +func TestAuth_Me_IsInstanceAdminTrueForAdmin(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + testutil.SeedInstanceAdmin(t, ts.DB, user) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.GET("/api/users/me/", session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + assert.Equal(t, true, testutil.MustJSONMap(t, rr)["is_instance_admin"]) } func TestAuth_UpdateMe_PatchProfile(t *testing.T) { @@ -303,6 +318,97 @@ func TestAuth_Tokens_RequiresAuth(t *testing.T) { require.Equal(t, http.StatusUnauthorized, rr.Code) } +// TestAuth_ApiToken_AuthenticatesRequests proves an API token created via +// the tokens endpoint actually authenticates requests (the bug in #162 was +// that tokens were issued but never accepted by RequireAuth). +func TestAuth_ApiToken_AuthenticatesRequests(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.POST("/api/users/me/tokens/", map[string]any{"label": "ci-token"}, session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + plainToken, _ := testutil.MustJSONMap(t, rr)["token"].(string) + require.NotEmpty(t, plainToken) + + rr2 := ts.DoWithHeaders(http.MethodGet, "/api/users/me/", nil, http.Header{ + "Authorization": []string{"Bearer " + plainToken}, + }) + require.Equal(t, http.StatusOK, rr2.Code, "body=%s", rr2.Body.String()) + assert.Equal(t, user.ID.String(), testutil.MustJSONMap(t, rr2)["id"]) +} + +// TestAuth_ApiToken_ValidEvenWithStaleCookiePresent proves a stale/invalid +// session cookie sent alongside a valid API token Bearer header doesn't mask +// the token (a bug caught in code review: an if/else-if between cookie and +// bearer meant any cookie, even an invalid one, skipped the bearer check). +func TestAuth_ApiToken_ValidEvenWithStaleCookiePresent(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.POST("/api/users/me/tokens/", map[string]any{"label": "ci-token"}, session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + plainToken, _ := testutil.MustJSONMap(t, rr)["token"].(string) + require.NotEmpty(t, plainToken) + + rr2 := ts.DoWithHeaders(http.MethodGet, "/api/users/me/", nil, http.Header{ + "Authorization": []string{"Bearer " + plainToken}, + "Cookie": []string{"session_id=this-is-not-a-real-session"}, + }) + require.Equal(t, http.StatusOK, rr2.Code, "body=%s", rr2.Body.String()) + assert.Equal(t, user.ID.String(), testutil.MustJSONMap(t, rr2)["id"]) +} + +// TestAuth_ApiToken_RevokedRejected proves a revoked token no longer +// authenticates. +func TestAuth_ApiToken_RevokedRejected(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.POST("/api/users/me/tokens/", map[string]any{"label": "ci-token"}, session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + createdBody := testutil.MustJSONMap(t, rr) + plainToken, _ := createdBody["token"].(string) + require.NotEmpty(t, plainToken) + + listRR := ts.GET("/api/users/me/tokens/", session) + tokens, _ := testutil.MustJSONMap(t, listRR)["tokens"].([]any) + require.Len(t, tokens, 1) + tokenID, _ := tokens[0].(map[string]any)["id"].(string) + require.NotEmpty(t, tokenID) + + revokeRR := ts.DELETE("/api/users/me/tokens/"+tokenID+"/", session) + require.Equal(t, http.StatusNoContent, revokeRR.Code) + + rr2 := ts.DoWithHeaders(http.MethodGet, "/api/users/me/", nil, http.Header{ + "Authorization": []string{"Bearer " + plainToken}, + }) + require.Equal(t, http.StatusUnauthorized, rr2.Code, "body=%s", rr2.Body.String()) +} + +// TestAuth_ApiToken_DeactivatedUserRejected proves a deactivated user's +// still-valid API token is rejected, mirroring the #155 protection for +// cookie sessions. +func TestAuth_ApiToken_DeactivatedUserRejected(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.POST("/api/users/me/tokens/", map[string]any{"label": "ci-token"}, session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + plainToken, _ := testutil.MustJSONMap(t, rr)["token"].(string) + require.NotEmpty(t, plainToken) + + require.NoError(t, ts.DB.Exec("UPDATE users SET is_active = false WHERE id = ?", user.ID).Error) + + rr2 := ts.DoWithHeaders(http.MethodGet, "/api/users/me/", nil, http.Header{ + "Authorization": []string{"Bearer " + plainToken}, + }) + require.Equal(t, http.StatusUnauthorized, rr2.Code, "body=%s", rr2.Body.String()) +} + func TestAuth_ForgotPassword_NoSMTPReturns503(t *testing.T) { ts := testutil.NewTestServer(t) testutil.CreateUser(t, ts.DB, testutil.WithUserEmail("forgot@test.local")) diff --git a/apps/api/internal/handler/join_test.go b/apps/api/internal/handler/join_test.go new file mode 100644 index 00000000..c0fa3a34 --- /dev/null +++ b/apps/api/internal/handler/join_test.go @@ -0,0 +1,147 @@ +package handler_test + +import ( + "context" + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/require" +) + +// TestWorkspace_JoinByToken_RejectsEmailMismatch proves a workspace invite +// token can't be redeemed by an account whose email doesn't match the +// invited email (a leaked/forwarded token must not grant membership). +func TestWorkspace_JoinByToken_RejectsEmailMismatch(t *testing.T) { + ts := testutil.NewTestServer(t) + owner := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, owner.ID) + inv := testutil.CreateWorkspaceInvite(t, ts.DB, w.ID, "invited@test.local", "tok-mismatch-111") + + stranger := testutil.CreateUser(t, ts.DB) // email is stranger-N@test.local, not invited@test.local + session := testutil.LoginAs(t, ts.DB, stranger) + + rr := ts.POST("/api/workspaces/join/", map[string]any{"token": inv.Token}, session) + require.Equal(t, http.StatusNotFound, rr.Code, "body=%s", rr.Body.String()) +} + +// TestWorkspace_JoinByToken_AcceptsEmailMatch proves the happy path still +// works when the joining account's email matches the invite. +func TestWorkspace_JoinByToken_AcceptsEmailMatch(t *testing.T) { + ts := testutil.NewTestServer(t) + owner := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, owner.ID) + + invitee := testutil.CreateUser(t, ts.DB) + email := invitee.Email + require.NotNil(t, email) + inv := testutil.CreateWorkspaceInvite(t, ts.DB, w.ID, *email, "tok-match-111") + session := testutil.LoginAs(t, ts.DB, invitee) + + rr := ts.POST("/api/workspaces/join/", map[string]any{"token": inv.Token}, session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} + +// TestWorkspace_JoinByToken_AllowsRejoinAfterRemoval proves a user who was +// previously removed from a workspace (leaving a soft-deleted membership +// row) can rejoin via a fresh invite, instead of hitting the +// UNIQUE(workspace_id, member_id) constraint against the old row. +func TestWorkspace_JoinByToken_AllowsRejoinAfterRemoval(t *testing.T) { + ts := testutil.NewTestServer(t) + owner := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, owner.ID) + + rejoiner := testutil.CreateUser(t, ts.DB) + testutil.AddWorkspaceMember(t, ts.DB, w.ID, rejoiner.ID, testutil.RoleMember) + require.NoError(t, ts.DB.WithContext(context.Background()). + Where("workspace_id = ? AND member_id = ?", w.ID, rejoiner.ID). + Delete(&model.WorkspaceMember{}).Error) + + email := rejoiner.Email + require.NotNil(t, email) + inv := testutil.CreateWorkspaceInvite(t, ts.DB, w.ID, *email, "tok-rejoin-111") + session := testutil.LoginAs(t, ts.DB, rejoiner) + + rr := ts.POST("/api/workspaces/join/", map[string]any{"token": inv.Token}, session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} + +func TestProject_JoinByToken_RejectsEmailMismatch(t *testing.T) { + ts := testutil.NewTestServer(t) + owner := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, owner.ID) + p := testutil.CreateProject(t, ts.DB, w.ID, owner.ID) + + inv := &model.ProjectMemberInvite{ + ProjectID: p.ID, + WorkspaceID: w.ID, + Email: "invited@test.local", + Token: "proj-tok-mismatch-111", + Role: testutil.RoleMember, + } + require.NoError(t, ts.DB.WithContext(context.Background()).Create(inv).Error) + + stranger := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, stranger) + + rr := ts.POST("/api/workspaces/"+w.Slug+"/projects/join/", map[string]any{"token": inv.Token}, session) + require.Equal(t, http.StatusNotFound, rr.Code, "body=%s", rr.Body.String()) +} + +func TestProject_JoinByToken_AcceptsEmailMatch(t *testing.T) { + ts := testutil.NewTestServer(t) + owner := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, owner.ID) + p := testutil.CreateProject(t, ts.DB, w.ID, owner.ID) + + invitee := testutil.CreateUser(t, ts.DB) + testutil.AddWorkspaceMember(t, ts.DB, w.ID, invitee.ID, testutil.RoleMember) + email := invitee.Email + require.NotNil(t, email) + + inv := &model.ProjectMemberInvite{ + ProjectID: p.ID, + WorkspaceID: w.ID, + Email: *email, + Token: "proj-tok-match-111", + Role: testutil.RoleMember, + } + require.NoError(t, ts.DB.WithContext(context.Background()).Create(inv).Error) + + session := testutil.LoginAs(t, ts.DB, invitee) + rr := ts.POST("/api/workspaces/"+w.Slug+"/projects/join/", map[string]any{"token": inv.Token}, session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} + +// TestProject_JoinByToken_AllowsRejoinAfterRemoval mirrors the workspace +// version: a soft-deleted project_members row for the same (project, member) +// must not block rejoining via a fresh invite. +func TestProject_JoinByToken_AllowsRejoinAfterRemoval(t *testing.T) { + ts := testutil.NewTestServer(t) + owner := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, owner.ID) + p := testutil.CreateProject(t, ts.DB, w.ID, owner.ID) + + rejoiner := testutil.CreateUser(t, ts.DB) + testutil.AddWorkspaceMember(t, ts.DB, w.ID, rejoiner.ID, testutil.RoleMember) + testutil.AddProjectMember(t, ts.DB, p.ID, w.ID, rejoiner.ID, testutil.RoleMember) + require.NoError(t, ts.DB.WithContext(context.Background()). + Where("project_id = ? AND member_id = ?", p.ID, rejoiner.ID). + Delete(&model.ProjectMember{}).Error) + + email := rejoiner.Email + require.NotNil(t, email) + inv := &model.ProjectMemberInvite{ + ProjectID: p.ID, + WorkspaceID: w.ID, + Email: *email, + Token: "proj-tok-rejoin-111", + Role: testutil.RoleMember, + } + require.NoError(t, ts.DB.WithContext(context.Background()).Create(inv).Error) + + session := testutil.LoginAs(t, ts.DB, rejoiner) + rr := ts.POST("/api/workspaces/"+w.Slug+"/projects/join/", map[string]any{"token": inv.Token}, session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} diff --git a/apps/api/internal/handler/label_test.go b/apps/api/internal/handler/label_test.go index d42eb14c..ac55cdaf 100644 --- a/apps/api/internal/handler/label_test.go +++ b/apps/api/internal/handler/label_test.go @@ -1,9 +1,11 @@ package handler_test import ( + "context" "net/http" "testing" + "github.com/Devlaner/devlane/api/internal/model" "github.com/Devlaner/devlane/api/internal/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -40,3 +42,29 @@ func TestLabel_CRUD(t *testing.T) { rr4 := ts.DELETE(base+id+"/", w.Session) require.Equal(t, http.StatusNoContent, rr4.Code) } + +// TestLabel_WorkspaceLevel_RejectsForeignWorkspace proves a workspace-level +// label (ProjectID == nil) from a foreign workspace can't be read, updated, +// or deleted just by supplying its UUID alongside any project in a workspace +// the caller does belong to. +func TestLabel_WorkspaceLevel_RejectsForeignWorkspace(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + base := "/api/workspaces/" + w.Workspace.Slug + "/projects/" + w.Project.ID.String() + "/issue-labels/" + + otherOwner := testutil.CreateUser(t, ts.DB) + otherWs := testutil.CreateWorkspace(t, ts.DB, otherOwner.ID) + foreignLabel := &model.Label{ + Name: "foreign workspace label", + Color: "#00ff00", + ProjectID: nil, + WorkspaceID: otherWs.ID, + } + require.NoError(t, ts.DB.WithContext(context.Background()).Create(foreignLabel).Error) + + rr := ts.PATCH(base+foreignLabel.ID.String()+"/", map[string]any{"name": "hijacked"}, w.Session) + require.Equal(t, http.StatusNotFound, rr.Code, "body=%s", rr.Body.String()) + + rr2 := ts.DELETE(base+foreignLabel.ID.String()+"/", w.Session) + require.Equal(t, http.StatusNotFound, rr2.Code, "body=%s", rr2.Body.String()) +} diff --git a/apps/api/internal/handler/upload.go b/apps/api/internal/handler/upload.go index 62ffbc2f..bec8a5fc 100644 --- a/apps/api/internal/handler/upload.go +++ b/apps/api/internal/handler/upload.go @@ -27,6 +27,10 @@ var allowedImageTypes = map[string]bool{ "image/webp": true, } +// maxUploadSize caps generic uploads (avatars/covers/logos) to a sane size +// for profile-type images; the larger issue-attachment flow has its own cap. +const maxUploadSize = 5 << 20 // 5 MiB + // Upload accepts a multipart file and uploads it to MinIO. // POST /api/upload // Form: file (required). Returns { "url": "/api/files/uploads/..." }. @@ -46,6 +50,10 @@ func (h *UploadHandler) Upload(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "No file provided", "detail": err.Error()}) return } + if file.Size > maxUploadSize { + c.JSON(http.StatusBadRequest, gin.H{"error": "File too large. Maximum size is 5MB."}) + return + } f, err := file.Open() if err != nil { @@ -117,5 +125,7 @@ func (h *UploadHandler) ServeFile(c *gin.Context) { } c.Header("Content-Type", info.ContentType) + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Content-Disposition", "inline") c.DataFromReader(http.StatusOK, info.Size, info.ContentType, obj, nil) } diff --git a/apps/api/internal/middleware/auth.go b/apps/api/internal/middleware/auth.go index cac97cb2..30d7f216 100644 --- a/apps/api/internal/middleware/auth.go +++ b/apps/api/internal/middleware/auth.go @@ -27,20 +27,43 @@ func SessionKeyFromCookieOrBearer(c *gin.Context) string { return sessionKey } -// RequireAuth loads the user from session and returns 401 if not authenticated. +// RequireAuth loads the user from a session cookie or Authorization: Bearer +// header, and returns 401 if not authenticated. A session cookie is tried +// first; if that's absent or doesn't resolve to a user (e.g. stale/expired), +// the Authorization header is tried next — first as an API token (hashed and +// looked up via the auth service), and if that doesn't match, as a raw +// session key — kept for the cross-origin OAuth SPA fragment flow (see +// SessionKeyFromCookieOrBearer). Both cookie and bearer are checked +// independently so a stale cookie can never mask a valid bearer token. func RequireAuth(authSvc *auth.Service, log *slog.Logger) gin.HandlerFunc { return func(c *gin.Context) { - sessionKey := SessionKeyFromCookieOrBearer(c) - user, err := authSvc.UserFromSession(c.Request.Context(), sessionKey) - if err != nil || user == nil { - if log != nil { - log.Debug("auth required", "error", err, "has_session_key", sessionKey != "") + ctx := c.Request.Context() + + if cookieKey, _ := c.Cookie(SessionCookieName); cookieKey != "" { + if user, err := authSvc.UserFromSession(ctx, cookieKey); err == nil && user != nil { + c.Set(UserContextKey, user) + c.Next() + return + } + } + if authHeader := c.GetHeader("Authorization"); len(authHeader) > 7 && strings.EqualFold(authHeader[:7], "bearer ") { + bearer := strings.TrimSpace(authHeader[7:]) + if user, err := authSvc.UserFromAPIToken(ctx, bearer); err == nil && user != nil { + c.Set(UserContextKey, user) + c.Next() + return } - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) - return + if user, err := authSvc.UserFromSession(ctx, bearer); err == nil && user != nil { + c.Set(UserContextKey, user) + c.Next() + return + } + } + + if log != nil { + log.Debug("auth required", "has_session_key", SessionKeyFromCookieOrBearer(c) != "") } - c.Set(UserContextKey, user) - c.Next() + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) } } diff --git a/apps/api/internal/middleware/ratelimit.go b/apps/api/internal/middleware/ratelimit.go new file mode 100644 index 00000000..81c9ad98 --- /dev/null +++ b/apps/api/internal/middleware/ratelimit.go @@ -0,0 +1,33 @@ +package middleware + +import ( + "net/http" + "time" + + "github.com/Devlaner/devlane/api/internal/redis" + "github.com/gin-gonic/gin" +) + +// RateLimit caps requests per client IP to limit uses per window, backed by +// Redis. Like other optional-infra integrations in this codebase, it fails +// open (allows the request) when rdb is nil or a Redis error occurs, rather +// than turning a Redis outage into an outage of the whole API. +func RateLimit(rdb *redis.Client, prefix string, limit int, window time.Duration) gin.HandlerFunc { + return func(c *gin.Context) { + if rdb == nil { + c.Next() + return + } + key := redis.PrefixRateLimit + prefix + ":" + c.ClientIP() + ok, err := rdb.Allow(c.Request.Context(), key, limit, window) + if err != nil { + c.Next() + return + } + if !ok { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "Too many requests, please try again later"}) + return + } + c.Next() + } +} diff --git a/apps/api/internal/redis/cache.go b/apps/api/internal/redis/cache.go index db573a8c..99a63515 100644 --- a/apps/api/internal/redis/cache.go +++ b/apps/api/internal/redis/cache.go @@ -2,6 +2,8 @@ package redis import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "strings" "time" @@ -16,6 +18,13 @@ const ( PrefixMagicCodeLogin = "logincode_" PrefixLock = "lock_" PrefixCache = "cache_" + PrefixRateLimit = "ratelimit_" + // PrefixMagicCodeVerifyFail tracks failed magic-code verification attempts + // independently of the code's own TTL/Attempts field, so requesting a new + // code does not reset the lockout counter. The email is hashed into the + // key (not concatenated raw) so it can never collide with a + // PrefixMagicCodeLogin key, regardless of email content. + PrefixMagicCodeVerifyFail = "mcvf_" ) // Default TTLs. @@ -25,6 +34,10 @@ const ( // MagicCodeMaxAttempts before the stored code is invalidated. MagicCodeMaxAttempts = 10 LockTTL = 300 * time.Second // 5 min + // MagicCodeVerifyFailWindow/Max bound repeated verify attempts against a + // single email regardless of how many new codes are requested meanwhile. + MagicCodeVerifyFailWindow = 15 * time.Minute + MagicCodeVerifyFailMax = 10 ) // Get gets a string value. Returns redis.Nil when key does not exist. @@ -229,3 +242,62 @@ func (c *Client) GetRequestOrigin(ctx context.Context, entityID string) (string, } return s, err } + +// --- Rate limiting (fixed window) --- + +// Allow increments the counter for key and reports whether it's still within +// limit for the current fixed window; the window starts (TTL is set) on the +// first increment. Used for both per-IP and per-account throttling. +func (c *Client) Allow(ctx context.Context, key string, limit int, window time.Duration) (bool, error) { + n, err := c.Client.Incr(ctx, key).Result() + if err != nil { + return false, err + } + if n == 1 { + if err := c.Client.Expire(ctx, key, window).Err(); err != nil { + return false, err + } + } else if ttl, err := c.Client.TTL(ctx, key).Result(); err == nil && ttl < 0 { + // Safety net: if the very first increment's Expire call failed + // transiently, the key would otherwise never expire and could lock + // out its subject permanently. Recover the TTL on the next call. + _ = c.Client.Expire(ctx, key, window).Err() + } + return n <= int64(limit), nil +} + +// Count returns the current counter value for key without incrementing it +// (0 if unset). Used to peek a counter before deciding whether to act. +func (c *Client) Count(ctx context.Context, key string) (int64, error) { + n, err := c.Client.Get(ctx, key).Int64() + if err == redis.Nil { + return 0, nil + } + return n, err +} + +// --- Magic-code verify-failure lockout (independent of the code's own TTL) --- + +func magicCodeVerifyFailKey(email string) string { + sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(email)))) + return PrefixMagicCodeVerifyFail + hex.EncodeToString(sum[:]) +} + +// MagicCodeVerifyFailCount peeks the current failed-verify count for email. +func (c *Client) MagicCodeVerifyFailCount(ctx context.Context, email string) (int64, error) { + return c.Count(ctx, magicCodeVerifyFailKey(email)) +} + +// BumpMagicCodeVerifyFail increments the failed-verify counter for email, +// independent of the per-code Attempts field, so requesting a new code does +// not reset how many times this email has failed verification recently. +func (c *Client) BumpMagicCodeVerifyFail(ctx context.Context, email string) error { + _, err := c.Allow(ctx, magicCodeVerifyFailKey(email), MagicCodeVerifyFailMax, MagicCodeVerifyFailWindow) + return err +} + +// ResetMagicCodeVerifyFail clears the failed-verify counter after a +// successful verification. +func (c *Client) ResetMagicCodeVerifyFail(ctx context.Context, email string) error { + return c.Delete(ctx, magicCodeVerifyFailKey(email)) +} diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go index 1a3e2fa1..b2ea45e1 100644 --- a/apps/api/internal/router/router.go +++ b/apps/api/internal/router/router.go @@ -3,6 +3,7 @@ package router import ( "context" "log/slog" + "time" "github.com/Devlaner/devlane/api/internal/auth" gh "github.com/Devlaner/devlane/api/internal/github" @@ -41,6 +42,11 @@ func New(cfg Config) *gin.Engine { gin.SetMode(gin.ReleaseMode) r := gin.New() + // Gin trusts all proxies by default, which lets any client spoof + // X-Forwarded-For/X-Real-IP and defeat c.ClientIP()-keyed rate limiting. + // Disable that trust so ClientIP() always returns the real remote + // address unless this is explicitly reconfigured for a known proxy/LB. + _ = r.SetTrustedProxies(nil) r.Use(middleware.Recovery(cfg.Log)) r.Use(middleware.Logger(cfg.Log)) @@ -95,6 +101,7 @@ func New(cfg Config) *gin.Engine { // Auth authSvc := auth.NewService(userStore, sessionStore, passwordResetTokenStore) authSvc.SetAccountStore(accountStore) + authSvc.SetApiTokenStore(apiTokenStore) appBaseURL := cfg.AppBaseURL if appBaseURL == "" { appBaseURL = cfg.CORSAllowOrigin @@ -107,6 +114,7 @@ func New(cfg Config) *gin.Engine { Ws: workspaceStore, NotifPrefs: userNotifPrefStore, ApiTokens: apiTokenStore, + InstanceAdmins: instanceAdminStore, Queue: cfg.Queue, Redis: cfg.Redis, MagicCodeSecret: cfg.MagicCodeSecret, @@ -494,13 +502,13 @@ func New(cfg Config) *gin.Engine { authGroup := r.Group("/auth") { authGroup.GET("/config/", authHandler.InstanceAuthConfig) - authGroup.POST("/email-check/", authHandler.EmailCheck) - authGroup.POST("/sign-in/", authHandler.SignIn) + authGroup.POST("/email-check/", middleware.RateLimit(cfg.Redis, "emailcheck", 30, 15*time.Minute), authHandler.EmailCheck) + authGroup.POST("/sign-in/", middleware.RateLimit(cfg.Redis, "signin", 20, 15*time.Minute), authHandler.SignIn) authGroup.POST("/sign-up/", authHandler.SignUp) authGroup.POST("/sign-out/", authHandler.SignOut) - authGroup.POST("/forgot-password/", authHandler.ForgotPassword) + authGroup.POST("/forgot-password/", middleware.RateLimit(cfg.Redis, "forgotpw", 10, 15*time.Minute), authHandler.ForgotPassword) authGroup.POST("/reset-password/", authHandler.ResetPassword) - authGroup.POST("/magic-code/request/", authHandler.MagicCodeRequest) + authGroup.POST("/magic-code/request/", middleware.RateLimit(cfg.Redis, "magiccode", 10, 15*time.Minute), authHandler.MagicCodeRequest) authGroup.POST("/magic-code/verify/", authHandler.MagicCodeVerify) authGroup.POST("/set-password/", middleware.RequireAuth(authSvc, cfg.Log), authHandler.SetPassword) } diff --git a/apps/api/internal/service/label.go b/apps/api/internal/service/label.go index 6d5379a6..9a3ec9a5 100644 --- a/apps/api/internal/service/label.go +++ b/apps/api/internal/service/label.go @@ -22,39 +22,39 @@ func NewLabelService(ls *store.LabelStore, ps *store.ProjectStore, ws *store.Wor return &LabelService{ls: ls, ps: ps, ws: ws} } -func (s *LabelService) ensureProjectAccess(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID) error { +func (s *LabelService) ensureProjectAccess(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID) (uuid.UUID, error) { wrk, err := s.ws.GetBySlug(ctx, workspaceSlug) if err != nil { - return ErrProjectForbidden + return uuid.Nil, ErrProjectForbidden } ok, _ := s.ws.IsMember(ctx, wrk.ID, userID) if !ok { - return ErrProjectForbidden + return uuid.Nil, ErrProjectForbidden } inWorkspace, _ := s.ps.IsInWorkspace(ctx, projectID, wrk.ID) if !inWorkspace { - return ErrProjectNotFound + return uuid.Nil, ErrProjectNotFound } - return nil + return wrk.ID, nil } func (s *LabelService) ListByProject(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID) ([]model.Label, error) { - if err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID); err != nil { + if _, err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID); err != nil { return nil, err } return s.ls.ListByProjectID(ctx, projectID) } func (s *LabelService) Create(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID, name, color string) (*model.Label, error) { - if err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID); err != nil { + workspaceID, err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID) + if err != nil { return nil, err } - wrk, _ := s.ws.GetBySlug(ctx, workspaceSlug) l := &model.Label{ Name: name, Color: color, ProjectID: &projectID, - WorkspaceID: wrk.ID, + WorkspaceID: workspaceID, } if err := s.ls.Create(ctx, l); err != nil { return nil, err @@ -63,13 +63,20 @@ func (s *LabelService) Create(ctx context.Context, workspaceSlug string, project } func (s *LabelService) GetByID(ctx context.Context, workspaceSlug string, projectID, labelID uuid.UUID, userID uuid.UUID) (*model.Label, error) { - if err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID); err != nil { + workspaceID, err := s.ensureProjectAccess(ctx, workspaceSlug, projectID, userID) + if err != nil { return nil, err } l, err := s.ls.GetByID(ctx, labelID) if err != nil { return nil, ErrLabelNotFound } + // A label's workspace must match the caller's resolved workspace — this is + // what actually protects workspace-level labels (ProjectID == nil), which + // the project-scoped check below can't see. + if l.WorkspaceID != workspaceID { + return nil, ErrLabelNotFound + } if l.ProjectID != nil && *l.ProjectID != projectID { return nil, ErrLabelNotFound } diff --git a/apps/api/internal/service/project.go b/apps/api/internal/service/project.go index b03f6559..604e139f 100644 --- a/apps/api/internal/service/project.go +++ b/apps/api/internal/service/project.go @@ -11,6 +11,7 @@ import ( "github.com/Devlaner/devlane/api/internal/model" "github.com/Devlaner/devlane/api/internal/store" "github.com/google/uuid" + "gorm.io/gorm" ) var ( @@ -353,6 +354,37 @@ func (s *ProjectService) DeleteInvite(ctx context.Context, workspaceSlug string, return s.pinv.Delete(ctx, inviteID) } +// requireInviteEmailMatch ensures the authenticated user's email matches the +// invite's email, so a leaked/forwarded token can't be redeemed by a +// different account. +func (s *ProjectService) requireInviteEmailMatch(ctx context.Context, inviteEmail string, userID uuid.UUID) error { + u, err := s.us.GetByID(ctx, userID) + if err != nil || u.Email == nil || !strings.EqualFold(*u.Email, inviteEmail) { + return ErrInviteNotFound + } + return nil +} + +// upsertProjectMember adds userID as a member of projectID, reviving a +// previously soft-deleted membership row (e.g. from a past removal/leave) +// instead of inserting a fresh one, since project_members has a plain +// UNIQUE(project_id, member_id) constraint that a stale soft-deleted row +// would otherwise violate. +func upsertProjectMember(tx *gorm.DB, projectID, workspaceID, userID uuid.UUID, role int16) error { + var existing model.ProjectMember + err := tx.Unscoped().Where("project_id = ? AND member_id = ?", projectID, userID).First(&existing).Error + if err == nil { + existing.DeletedAt = gorm.DeletedAt{} + existing.Role = role + return tx.Unscoped().Save(&existing).Error + } + if err != gorm.ErrRecordNotFound { + return err + } + m := &model.ProjectMember{ProjectID: projectID, WorkspaceID: workspaceID, MemberID: &userID, Role: role} + return tx.Create(m).Error +} + func (s *ProjectService) JoinByToken(ctx context.Context, token string, userID uuid.UUID) (*model.Project, error) { inv, err := s.pinv.GetByToken(ctx, token) if err != nil || inv == nil { @@ -362,10 +394,18 @@ func (s *ProjectService) JoinByToken(ctx context.Context, token string, userID u if err != nil { return nil, ErrProjectNotFound } + if err := s.requireInviteEmailMatch(ctx, inv.Email, userID); err != nil { + return nil, err + } inv.Accepted = true - _ = s.pinv.Update(ctx, inv) - m := &model.ProjectMember{ProjectID: p.ID, WorkspaceID: p.WorkspaceID, MemberID: &userID, Role: inv.Role} - _ = s.ps.AddProjectMember(ctx, m) + if err := s.ps.Transaction(ctx, func(tx *gorm.DB) error { + if err := tx.Save(inv).Error; err != nil { + return err + } + return upsertProjectMember(tx, p.ID, p.WorkspaceID, userID, inv.Role) + }); err != nil { + return nil, err + } return p, nil } @@ -378,10 +418,18 @@ func (s *ProjectService) JoinByInviteID(ctx context.Context, workspaceSlug strin if err != nil || inv.ProjectID != p.ID || inv.Accepted { return nil, ErrInviteNotFound } + if err := s.requireInviteEmailMatch(ctx, inv.Email, userID); err != nil { + return nil, err + } inv.Accepted = true - _ = s.pinv.Update(ctx, inv) - m := &model.ProjectMember{ProjectID: p.ID, WorkspaceID: p.WorkspaceID, MemberID: &userID, Role: inv.Role} - _ = s.ps.AddProjectMember(ctx, m) + if err := s.ps.Transaction(ctx, func(tx *gorm.DB) error { + if err := tx.Save(inv).Error; err != nil { + return err + } + return upsertProjectMember(tx, p.ID, p.WorkspaceID, userID, inv.Role) + }); err != nil { + return nil, err + } return p, nil } diff --git a/apps/api/internal/service/workspace.go b/apps/api/internal/service/workspace.go index 9de8f038..78fefc8b 100644 --- a/apps/api/internal/service/workspace.go +++ b/apps/api/internal/service/workspace.go @@ -11,6 +11,7 @@ import ( "github.com/Devlaner/devlane/api/internal/model" "github.com/Devlaner/devlane/api/internal/store" "github.com/google/uuid" + "gorm.io/gorm" ) var ( @@ -296,6 +297,37 @@ func (s *WorkspaceService) DeleteInvite(ctx context.Context, slug string, invite return s.winv.Delete(ctx, inviteID) } +// requireInviteEmailMatch ensures the authenticated user's email matches the +// invite's email, so a leaked/forwarded token can't be redeemed by a +// different account. +func (s *WorkspaceService) requireInviteEmailMatch(ctx context.Context, inviteEmail string, userID uuid.UUID) error { + u, err := s.us.GetByID(ctx, userID) + if err != nil || u.Email == nil || !strings.EqualFold(*u.Email, inviteEmail) { + return ErrInviteNotFound + } + return nil +} + +// upsertWorkspaceMember adds userID as a member of workspaceID, reviving a +// previously soft-deleted membership row (e.g. from a past removal/leave) +// instead of inserting a fresh one, since workspace_members has a plain +// UNIQUE(workspace_id, member_id) constraint that a stale soft-deleted row +// would otherwise violate. +func upsertWorkspaceMember(tx *gorm.DB, workspaceID, userID uuid.UUID, role int16) error { + var existing model.WorkspaceMember + err := tx.Unscoped().Where("workspace_id = ? AND member_id = ?", workspaceID, userID).First(&existing).Error + if err == nil { + existing.DeletedAt = gorm.DeletedAt{} + existing.Role = role + return tx.Unscoped().Save(&existing).Error + } + if err != gorm.ErrRecordNotFound { + return err + } + m := &model.WorkspaceMember{WorkspaceID: workspaceID, MemberID: userID, Role: role} + return tx.Create(m).Error +} + func (s *WorkspaceService) JoinByToken(ctx context.Context, token string, userID uuid.UUID) (*model.Workspace, error) { inv, err := s.winv.GetByToken(ctx, token) if err != nil || inv == nil { @@ -305,10 +337,18 @@ func (s *WorkspaceService) JoinByToken(ctx context.Context, token string, userID if err != nil { return nil, ErrWorkspaceNotFound } + if err := s.requireInviteEmailMatch(ctx, inv.Email, userID); err != nil { + return nil, err + } inv.Accepted = true - _ = s.winv.Update(ctx, inv) - m := &model.WorkspaceMember{WorkspaceID: w.ID, MemberID: userID, Role: inv.Role} - _ = s.ws.AddMember(ctx, m) + if err := s.ws.Transaction(ctx, func(tx *gorm.DB) error { + if err := tx.Save(inv).Error; err != nil { + return err + } + return upsertWorkspaceMember(tx, w.ID, userID, inv.Role) + }); err != nil { + return nil, err + } return w, nil } @@ -322,10 +362,18 @@ func (s *WorkspaceService) JoinByInviteID(ctx context.Context, slug string, invi if err != nil || inv.WorkspaceID != w.ID || inv.Accepted { return nil, ErrInviteNotFound } + if err := s.requireInviteEmailMatch(ctx, inv.Email, userID); err != nil { + return nil, err + } inv.Accepted = true - _ = s.winv.Update(ctx, inv) - m := &model.WorkspaceMember{WorkspaceID: w.ID, MemberID: userID, Role: inv.Role} - _ = s.ws.AddMember(ctx, m) + if err := s.ws.Transaction(ctx, func(tx *gorm.DB) error { + if err := tx.Save(inv).Error; err != nil { + return err + } + return upsertWorkspaceMember(tx, w.ID, userID, inv.Role) + }); err != nil { + return nil, err + } return w, nil } diff --git a/apps/api/internal/store/api_token.go b/apps/api/internal/store/api_token.go index 0c1ae4a5..522892f3 100644 --- a/apps/api/internal/store/api_token.go +++ b/apps/api/internal/store/api_token.go @@ -30,6 +30,10 @@ func hashToken(plain string) string { return hex.EncodeToString(h[:]) } +// HashToken hashes a plain-text API token for lookup (auth middleware never +// stores or compares the plain value). +func HashToken(plain string) string { return hashToken(plain) } + // Create generates a new token, stores its hash, and returns the plain token (caller must show it once). func (s *ApiTokenStore) Create(ctx context.Context, userID uuid.UUID, label, description string, expiredAt *time.Time) (plainToken string, err error) { plain, err := generateToken() @@ -75,3 +79,21 @@ func (s *ApiTokenStore) Delete(ctx context.Context, tokenID, userID uuid.UUID) e } return nil } + +// GetActiveByHash returns the token matching hash if it is active and not +// expired, for use by the auth middleware. +func (s *ApiTokenStore) GetActiveByHash(ctx context.Context, hash string) (*model.ApiToken, error) { + var t model.ApiToken + err := s.db.WithContext(ctx). + Where("token = ? AND is_active = true AND (expired_at IS NULL OR expired_at > ?)", hash, time.Now().UTC()). + First(&t).Error + if err != nil { + return nil, err + } + return &t, nil +} + +// UpdateLastUsed stamps the token's last_used time. +func (s *ApiTokenStore) UpdateLastUsed(ctx context.Context, id uuid.UUID) error { + return s.db.WithContext(ctx).Model(&model.ApiToken{}).Where("id = ?", id).Update("last_used", time.Now().UTC()).Error +} diff --git a/apps/api/internal/store/project.go b/apps/api/internal/store/project.go index ce7d274a..6b08433e 100644 --- a/apps/api/internal/store/project.go +++ b/apps/api/internal/store/project.go @@ -13,6 +13,11 @@ type ProjectStore struct{ db *gorm.DB } func NewProjectStore(db *gorm.DB) *ProjectStore { return &ProjectStore{db: db} } +// Transaction runs fn inside a DB transaction (same connection). +func (s *ProjectStore) Transaction(ctx context.Context, fn func(tx *gorm.DB) error) error { + return s.db.WithContext(ctx).Transaction(fn) +} + func (s *ProjectStore) Create(ctx context.Context, p *model.Project) error { return s.db.WithContext(ctx).Create(p).Error } diff --git a/apps/api/internal/store/workspace.go b/apps/api/internal/store/workspace.go index 611fc3db..d6dbad1b 100644 --- a/apps/api/internal/store/workspace.go +++ b/apps/api/internal/store/workspace.go @@ -13,6 +13,11 @@ type WorkspaceStore struct{ db *gorm.DB } func NewWorkspaceStore(db *gorm.DB) *WorkspaceStore { return &WorkspaceStore{db: db} } +// Transaction runs fn inside a DB transaction (same connection). +func (s *WorkspaceStore) Transaction(ctx context.Context, fn func(tx *gorm.DB) error) error { + return s.db.WithContext(ctx).Transaction(fn) +} + func (s *WorkspaceStore) Create(ctx context.Context, w *model.Workspace) error { return s.db.WithContext(ctx).Create(w).Error } diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 89a1a832..70b8e2f7 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -284,6 +284,7 @@ export interface UserApiResponse { is_active: boolean; is_onboarded: boolean; is_password_autoset?: boolean; + is_instance_admin?: boolean; date_joined: string; created_at: string; updated_at: string; diff --git a/apps/web/src/contexts/AuthContext.tsx b/apps/web/src/contexts/AuthContext.tsx index bbf9b821..66ac45cb 100644 --- a/apps/web/src/contexts/AuthContext.tsx +++ b/apps/web/src/contexts/AuthContext.tsx @@ -24,6 +24,7 @@ function mapApiUserToUser(api: UserApiResponse): User { name, avatarUrl: api.avatar ?? null, coverImageUrl: api.cover_image ?? null, + isInstanceAdmin: api.is_instance_admin ?? false, }; } @@ -34,6 +35,7 @@ interface AuthContextValue { login: (email: string, password: string) => Promise; logout: () => Promise; setUserFromApi: (api: UserApiResponse) => void; + refreshUser: () => Promise; } const AuthContext = createContext(null); @@ -69,12 +71,25 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(mapApiUserToUser(api)); }, []); - const login = useCallback(async (email: string, password: string): Promise => { - const api = await authService.signIn({ email, password }); - setUser(mapApiUserToUser(api)); - return true; + // Re-fetches the canonical user profile from /api/users/me/. Sign-in/ + // sign-up/magic-code responses don't carry every field (e.g. + // is_instance_admin is only computed on the Me endpoint), so callers that + // need the full, authoritative profile right after establishing a session + // should use this instead of setUserFromApi with the raw auth response. + const refreshUser = useCallback(async () => { + const api = await authService.getMe(); + if (api) setUser(mapApiUserToUser(api)); }, []); + const login = useCallback( + async (email: string, password: string): Promise => { + await authService.signIn({ email, password }); + await refreshUser(); + return true; + }, + [refreshUser], + ); + const logout = useCallback(async () => { try { await authService.signOut(); @@ -92,8 +107,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { login, logout, setUserFromApi, + refreshUser, }), - [user, isLoading, login, logout, setUserFromApi], + [user, isLoading, login, logout, setUserFromApi, refreshUser], ); return {children}; diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx index b47de4cb..6c0e6d6a 100644 --- a/apps/web/src/pages/LoginPage.tsx +++ b/apps/web/src/pages/LoginPage.tsx @@ -14,7 +14,7 @@ type AuthMode = 'sign-in' | 'sign-up'; export function LoginPage() { const navigate = useNavigate(); const location = useLocation(); - const { login, setUserFromApi } = useAuth(); + const { login, refreshUser } = useAuth(); const state = location.state as { from?: { pathname?: string; search?: string }; @@ -182,12 +182,12 @@ export function LoginPage() { } setIsSubmitting(true); try { - const user = await authService.verifyMagicCode({ + await authService.verifyMagicCode({ email, code, ...(inviteToken ? { invite_token: inviteToken } : {}), }); - setUserFromApi(user); + await refreshUser(); navigate(returnPath, { replace: true }); } catch (err: unknown) { setError(getApiErrorMessage(err) || 'Invalid or expired code.'); @@ -195,7 +195,7 @@ export function LoginPage() { setIsSubmitting(false); } }, - [magicCode, email, inviteToken, setUserFromApi, navigate, returnPath], + [magicCode, email, inviteToken, refreshUser, navigate, returnPath], ); const goBackToEmail = useCallback(() => { diff --git a/apps/web/src/pages/instance-admin/InstanceAdminLoginPage.tsx b/apps/web/src/pages/instance-admin/InstanceAdminLoginPage.tsx deleted file mode 100644 index edebc5c6..00000000 --- a/apps/web/src/pages/instance-admin/InstanceAdminLoginPage.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Button, IconEye, IconEyeOff, Input } from '../../components/ui'; - -const INSTANCE_ADMIN_KEY = 'devlane_instance_admin'; - -const IconGlobe = () => ( - - - - - -); -export function InstanceAdminLoginPage() { - const navigate = useNavigate(); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [showPassword, setShowPassword] = useState(false); - const [error, setError] = useState(''); - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setError(''); - // Mock: accept any non-empty email + password for instance admin - if (email.trim() && password) { - sessionStorage.setItem(INSTANCE_ADMIN_KEY, '1'); - navigate('/instance-admin/general', { replace: true }); - } else { - setError('Please enter your email and password.'); - } - } - - return ( -
- {/* Top-left branding */} -
-
- - - - Devlane -
-
- - {/* Centered form */} -
-
-

- Manage your Devlane instance -

-

- Configure instance-wide settings to secure your instance. -

-
- setEmail(e.target.value)} - placeholder="name@company.com" - required - autoComplete="email" - className="w-full" - /> -
- -
- setPassword(e.target.value)} - placeholder="Enter your password" - required - autoComplete="current-password" - className="h-9 w-full rounded-(--radius-md) border border-(--border-subtle) bg-(--bg-surface-1) py-2 pl-3 pr-10 text-sm text-(--txt-primary) placeholder:text-(--txt-placeholder) focus:outline-none" - /> - -
- {error && {error}} -
- -
-
-
-
- ); -} diff --git a/apps/web/src/pages/instance-admin/index.ts b/apps/web/src/pages/instance-admin/index.ts index 38b338a9..4ed4355e 100644 --- a/apps/web/src/pages/instance-admin/index.ts +++ b/apps/web/src/pages/instance-admin/index.ts @@ -9,6 +9,5 @@ export { InstanceAdminAuthGitHubPage } from './InstanceAdminAuthGitHubPage'; export { InstanceAdminAuthGitLabPage } from './InstanceAdminAuthGitLabPage'; export { InstanceAdminAIPage } from './InstanceAdminAIPage'; export { InstanceAdminImagePage } from './InstanceAdminImagePage'; -export { InstanceAdminLoginPage } from './InstanceAdminLoginPage'; export { InstanceAdminIntegrationsPage } from './InstanceAdminIntegrationsPage'; export { InstanceAdminIntegrationGitHubPage } from './InstanceAdminIntegrationGitHubPage'; diff --git a/apps/web/src/routes/InstanceAdminProtectedRoute.tsx b/apps/web/src/routes/InstanceAdminProtectedRoute.tsx index b2bd2318..03fad855 100644 --- a/apps/web/src/routes/InstanceAdminProtectedRoute.tsx +++ b/apps/web/src/routes/InstanceAdminProtectedRoute.tsx @@ -7,7 +7,7 @@ interface InstanceAdminProtectedRouteProps { export function InstanceAdminProtectedRoute({ children }: InstanceAdminProtectedRouteProps) { const location = useLocation(); - const { isAuthenticated, isLoading } = useAuth(); + const { user, isAuthenticated, isLoading } = useAuth(); if (isLoading) { return ( @@ -21,5 +21,9 @@ export function InstanceAdminProtectedRoute({ children }: InstanceAdminProtected return ; } + if (!user?.isInstanceAdmin) { + return ; + } + return <>{children}; } diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 68e6c750..904dbb80 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -183,8 +183,6 @@ const InstanceAdminIntegrationGitHubPage = lazy(() => page({ InstanceAdminIntegrationGitHubPage: m.InstanceAdminIntegrationGitHubPage }), ), ); -// Reserved for future instance-admin login route: -// const InstanceAdminLoginPage = lazy(() => import("../pages/instance-admin").then((m) => page({ InstanceAdminLoginPage: m.InstanceAdminLoginPage }))); const InstanceSetupWelcomePage = lazy(() => import('../pages/setup').then((m) => diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts index ea727ab0..1e70343e 100644 --- a/apps/web/src/types/index.ts +++ b/apps/web/src/types/index.ts @@ -10,6 +10,7 @@ export interface User { name: string; avatarUrl?: string | null; coverImageUrl?: string | null; + isInstanceAdmin?: boolean; } export interface Workspace { diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs index 7aae980b..ea01a2e5 100644 --- a/lint-staged.config.mjs +++ b/lint-staged.config.mjs @@ -6,9 +6,10 @@ export default { const args = files.map(q).join(' '); return [ `npm --prefix apps/web exec -- eslint --max-warnings=0 --fix --config apps/web/eslint.config.js ${args}`, - `npx prettier --write ${args}`, + `npm --prefix apps/web exec -- prettier --write ${args}`, ]; }, - 'apps/web/**/*.{css,json,md}': (files) => (files.length ? [`npx prettier --write ${files.join(' ')}`] : []), + 'apps/web/**/*.{css,json,md}': (files) => + files.length ? [`npm --prefix apps/web exec -- prettier --write ${files.join(' ')}`] : [], 'apps/api/**/*.go': (files) => (files.length ? [`gofmt -w ${files.join(' ')}`] : []), };