diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 627bc04dfa..e362332ca5 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -410,6 +410,7 @@ func prepareMigrationTasks() []*migration { newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel), newMigration(332, "Add org-level branch protection rulesets", v1_27.AddOrgProtectedBranchTable), + newMigration(333, "Add require_2fa to user table for org enforcement", v1_27.AddRequire2FAToUser), } return preparedMigrations } diff --git a/models/migrations/v1_27/v333.go b/models/migrations/v1_27/v333.go new file mode 100644 index 0000000000..e245b6d73d --- /dev/null +++ b/models/migrations/v1_27/v333.go @@ -0,0 +1,16 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package v1_27 + +import ( + "xorm.io/xorm" +) + +func AddRequire2FAToUser(x *xorm.Engine) error { + type User struct { + Require2FA bool `xorm:"NOT NULL DEFAULT false"` + } + _, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(User)) + return err +} diff --git a/models/user/user.go b/models/user/user.go index 2f259a3416..f7dcc407e8 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -117,6 +117,9 @@ type User struct { // Maximum repository creation limit, -1 means use global default MaxRepoCreation int `xorm:"NOT NULL DEFAULT -1"` + // Require2FA when true (and user is an org), all org members must have 2FA enabled + Require2FA bool `xorm:"NOT NULL DEFAULT false"` + // IsActive true: primary email is activated, user can access Web UI and Git SSH. // false: an inactive user can only log in Web UI for account operations (ex: activate the account by email), no other access. IsActive bool `xorm:"INDEX"` diff --git a/routers/web/org/require2fa.go b/routers/web/org/require2fa.go new file mode 100644 index 0000000000..445069c59d --- /dev/null +++ b/routers/web/org/require2fa.go @@ -0,0 +1,39 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package org + +import ( + "net/http" + + auth_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" +) + +// Check2FARequirement checks if the current org requires 2FA and if the user has it enabled. +// If the user doesn't have 2FA and the org requires it, redirect to 2FA setup page. +func Check2FARequirement(ctx *context.Context) { + if ctx.Org == nil || ctx.Org.Organization == nil || ctx.Doer == nil { + return + } + + if !ctx.Org.Organization.Require2FA { + return + } + + // Check if user has 2FA enabled + has, err := auth_model.HasTwoFactorOrWebAuthn(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("HasTwoFactorOrWebAuthn", err) + return + } + + if has { + return + } + + // User doesn't have 2FA — show warning and redirect to settings + ctx.Flash.Warning("This organization requires two-factor authentication. Please enable 2FA to continue.") + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go index e9153cd6c2..9ba6c03840 100644 --- a/routers/web/org/setting.go +++ b/routers/web/org/setting.go @@ -80,12 +80,14 @@ func SettingsPost(ctx *context.Context) { return } + require2FA := ctx.FormBool("require_2fa") opts := &user_service.UpdateOptions{ FullName: optional.FromPtr(form.FullName), Description: optional.FromPtr(form.Description), Website: optional.FromPtr(form.Website), Location: optional.FromPtr(form.Location), RepoAdminChangeTeamAccess: optional.FromPtr(form.RepoAdminChangeTeamAccess), + Require2FA: optional.Some(require2FA), } if ctx.Doer.IsAdmin { opts.MaxRepoCreation = optional.FromPtr(form.MaxRepoCreation) diff --git a/routers/web/web.go b/routers/web/web.go index 3a831b02c1..452ad77302 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -960,7 +960,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("/milestones/{team}", reqMilestonesDashboardPageEnabled, user.Milestones) m.Post("/members/action/{action}", org.MembersAction) m.Get("/teams", org.Teams) - }, context.OrgAssignment(context.OrgAssignmentOptions{RequireMember: true, RequireTeamMember: true})) + }, context.OrgAssignment(context.OrgAssignmentOptions{RequireMember: true, RequireTeamMember: true}), org.Check2FARequirement) m.Group("/{org}", func() { m.Get("/teams/{team}", org.TeamMembers) diff --git a/services/user/update.go b/services/user/update.go index 988e6dd2c8..64356a7b9a 100644 --- a/services/user/update.go +++ b/services/user/update.go @@ -56,6 +56,7 @@ type UpdateOptions struct { EmailNotificationsPreference optional.Option[string] SetLastLogin bool RepoAdminChangeTeamAccess optional.Option[bool] + Require2FA optional.Option[bool] } func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) error { @@ -169,6 +170,11 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er cols = append(cols, "repo_admin_change_team_access") } + if opts.Require2FA.Has() { + u.Require2FA = opts.Require2FA.Value() + + cols = append(cols, "require_2fa") + } if opts.EmailNotificationsPreference.Has() { u.EmailNotificationsPreference = opts.EmailNotificationsPreference.Value() diff --git a/templates/org/settings/options.tmpl b/templates/org/settings/options.tmpl index 614e861b09..5bd9bda4e4 100644 --- a/templates/org/settings/options.tmpl +++ b/templates/org/settings/options.tmpl @@ -48,6 +48,16 @@ {{end}} +
+ +
+
+ + +
+

When enabled, organization members without 2FA configured will be prompted to set it up before accessing organization resources.

+
+