feat: org-level 2FA requirement (#208) #214
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// 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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// 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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -48,6 +48,16 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="require_2fa" {{if .Org.Require2FA}}checked{{end}}>
|
||||
<label>{{svg "octicon-shield-lock" 16}} Require two-factor authentication for all members</label>
|
||||
</div>
|
||||
<p class="help">When enabled, organization members without 2FA configured will be prompted to set it up before accessing organization resources.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "org.settings.update_settings"}}</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user