Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cc7297f23 | |||
| 9dc85cfc2d | |||
| 6bc0cb5bc8 | |||
| 1fb97eeeeb | |||
| 1032ae4268 | |||
| af1c6178ef | |||
| 0f23219ee4 | |||
| bc475c91f6 | |||
| 25268d7dd7 |
@@ -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"`
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestFile(t *testing.T) {
|
||||
{
|
||||
name: "tags.py",
|
||||
code: "<>",
|
||||
want: lines(`<span class="o"><</span><span class="o">></span>`),
|
||||
want: lines(`<span class="o"><></span>`),
|
||||
lexerName: "Python",
|
||||
},
|
||||
{
|
||||
@@ -102,7 +102,7 @@ c=2
|
||||
<span class="n">def</span><span class="p">:</span>\n
|
||||
<span class="n">a</span><span class="o">=</span><span class="mi">1</span>\n
|
||||
\n
|
||||
<span class="n">b</span><span class="o">=</span><span class="sa"></span><span class="s1">'</span><span class="s1">'</span>\n
|
||||
<span class="n">b</span><span class="o">=</span><span class="s1">''</span>\n
|
||||
\n
|
||||
<span class="n">c</span><span class="o">=</span><span class="mi">2</span>`,
|
||||
),
|
||||
@@ -114,6 +114,18 @@ c=2
|
||||
want: []template.HTML{"<span class=\"c1\">--\n</span>", `<span class="k">SELECT</span>`},
|
||||
lexerName: "SQL",
|
||||
},
|
||||
{
|
||||
name: "test.http",
|
||||
code: `HTTP/1.0 400 Bad request
|
||||
Content-Type: text/html
|
||||
|
||||
<html></html>`,
|
||||
want: lines(`<span class="kr">HTTP</span><span class="o">/</span><span class="m">1.0</span> <span class="m">400</span> <span class="ne">Bad request</span>\n
|
||||
<span class="n">Content-Type</span><span class="o">:</span> <span class="l">text/html</span>\n
|
||||
\n
|
||||
<span class="p"><</span><span class="nt">html</span><span class="p">></</span><span class="nt">html</span><span class="p">></span>`),
|
||||
lexerName: "HTTP",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -288,24 +288,24 @@ func detectChromaLexerWithAnalyze(fileName, lang string, code []byte) chroma.Lex
|
||||
|
||||
// if lang is provided, and it matches a lexer, use it directly
|
||||
if byLang {
|
||||
return lexer
|
||||
return chroma.Coalesce(lexer)
|
||||
}
|
||||
|
||||
// if a lexer is detected and there is no conflict for the file extension, use it directly
|
||||
fileExt := path.Ext(fileName)
|
||||
_, hasConflicts := chromaLexers().conflictingExtLangMap[fileExt]
|
||||
if !hasConflicts && lexer != lexers.Fallback {
|
||||
return lexer
|
||||
return chroma.Coalesce(lexer)
|
||||
}
|
||||
|
||||
// try to detect language by content, for best guessing for the language
|
||||
// when using "code" to detect, analyze.GetCodeLanguage is slow, it iterates many rules to detect language from content
|
||||
analyzedLanguage := analyze.GetCodeLanguage(fileName, code)
|
||||
lexer = DetectChromaLexerByFileName(fileName, analyzedLanguage)
|
||||
lexer, _ = detectChromaLexerByFileName(fileName, analyzedLanguage)
|
||||
if lexer == lexers.Fallback {
|
||||
if analyzedLanguage != enry.OtherLanguage {
|
||||
log.Warn("No chroma lexer found for enry detected language: %s (file: %s), need to fix the language mapping between enry and chroma.", analyzedLanguage, fileName)
|
||||
}
|
||||
}
|
||||
return lexer
|
||||
return chroma.Coalesce(lexer)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,13 @@ var (
|
||||
Channel: "stable",
|
||||
}
|
||||
|
||||
// LoginNotification configuration for sign-in alerts
|
||||
LoginNotification = struct {
|
||||
Enabled bool
|
||||
}{
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
// IsInTesting indicates whether the testing is running (unit test or integration test). It can be used for:
|
||||
// * Skip nonsense error logs during testing caused by unreliable code (TODO: this is only a temporary solution, we should make the test code more reliable)
|
||||
// * Panic in dev or testing mode to make the problem more obvious and easier to debug
|
||||
@@ -171,6 +178,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
|
||||
loadOtherFrom(cfg)
|
||||
loadUpdateCheckerFrom(cfg)
|
||||
loadNtfyFrom(cfg)
|
||||
loadLoginNotificationFrom(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -181,6 +189,11 @@ func loadUpdateCheckerFrom(cfg ConfigProvider) {
|
||||
UpdateChecker.Channel = sec.Key("CHANNEL").MustString(UpdateChecker.Channel)
|
||||
}
|
||||
|
||||
func loadLoginNotificationFrom(cfg ConfigProvider) {
|
||||
sec := cfg.Section("login_notification")
|
||||
LoginNotification.Enabled = sec.Key("ENABLED").MustBool(true)
|
||||
}
|
||||
|
||||
func loadRunModeFrom(rootCfg ConfigProvider) {
|
||||
rootSec := rootCfg.Section("")
|
||||
RunUser = rootSec.Key("RUN_USER").MustString(user.CurrentUsername())
|
||||
|
||||
@@ -440,6 +440,9 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
|
||||
ctx.ServerError("UpdateUser", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send login notification (email + ntfy)
|
||||
go mailer.SendLoginNotification(u, ctx.RemoteAddr(), ctx.Req.UserAgent())
|
||||
}
|
||||
|
||||
// extractUserNameFromOAuth2 tries to extract a normalized username from the given OAuth2 user.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
user_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
||||
sender_service "git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/mailer/sender"
|
||||
)
|
||||
|
||||
// SendLoginNotification sends email and ntfy notifications when a user signs in.
|
||||
func SendLoginNotification(u *user_model.User, ip, userAgent string) {
|
||||
if !setting.LoginNotification.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := time.Now().UTC().Format("2006-01-02 15:04:05 UTC")
|
||||
subject := fmt.Sprintf("[%s] New sign-in: %s", setting.AppName, u.Name)
|
||||
|
||||
body := fmt.Sprintf(`New sign-in detected
|
||||
|
||||
Account: %s (%s)
|
||||
IP Address: %s
|
||||
Browser: %s
|
||||
Time: %s
|
||||
Instance: %s
|
||||
|
||||
If this wasn't you, change your password immediately and review your active sessions.
|
||||
|
||||
— %s`, u.Name, u.Email, ip, userAgent, timestamp, setting.AppURL, setting.AppName)
|
||||
|
||||
// Email notification
|
||||
if setting.MailService != nil && u.Email != "" {
|
||||
msg := sender_service.NewMessage(u.EmailTo(), subject, body)
|
||||
msg.Info = fmt.Sprintf("Login notification for %s", u.Name)
|
||||
SendAsync(msg)
|
||||
log.Debug("Login notification email sent to %s", u.Email)
|
||||
}
|
||||
|
||||
// ntfy push notification
|
||||
if setting.Ntfy.Enabled && setting.Ntfy.ServerURL != "" {
|
||||
go sendLoginNtfy(subject, u.Name, ip, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func sendLoginNtfy(title, username, ip, timestamp string) {
|
||||
body := fmt.Sprintf("User: %s\nIP: %s\nTime: %s", username, ip, timestamp)
|
||||
url := fmt.Sprintf("%s/%s", setting.Ntfy.ServerURL, setting.Ntfy.DefaultTopic)
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
log.Error("ntfy login: create request: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Title", title)
|
||||
req.Header.Set("Priority", "default")
|
||||
req.Header.Set("Tags", "key,login")
|
||||
req.Header.Set("Click", setting.AppURL+"-/admin")
|
||||
if setting.Ntfy.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+setting.Ntfy.Token)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Error("ntfy login: send: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
log.Error("ntfy login: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -6,6 +6,7 @@ package wiki
|
||||
import (
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
@@ -148,10 +149,24 @@ func WebPathFromRequest(s string) WebPath {
|
||||
return WebPath(s)
|
||||
}
|
||||
|
||||
var multiHyphenRe = regexp.MustCompile(`-{2,}`)
|
||||
var nonAlphanumRe = regexp.MustCompile(`[^a-zA-Z0-9\-]`)
|
||||
|
||||
// sanitizeWikiTitle converts a user-provided title into a clean, URL-friendly slug.
|
||||
// Spaces and special characters become hyphens, consecutive hyphens collapse to one.
|
||||
func sanitizeWikiTitle(title string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
title = strings.ReplaceAll(title, " ", "-")
|
||||
title = nonAlphanumRe.ReplaceAllString(title, "-")
|
||||
title = multiHyphenRe.ReplaceAllString(title, "-")
|
||||
title = strings.Trim(title, "-")
|
||||
return title
|
||||
}
|
||||
|
||||
func UserTitleToWebPath(base, title string) WebPath {
|
||||
// TODO: no support for subdirectory, because the old wiki code's behavior is always using %2F, instead of subdirectory.
|
||||
// So we do not add the support for writing slashes in title at the moment.
|
||||
title = strings.TrimSpace(title)
|
||||
title = sanitizeWikiTitle(title)
|
||||
title = util.PathJoinRelX(base, escapeSegToWeb(title, false))
|
||||
if title == "" || title == "." {
|
||||
title = "unnamed"
|
||||
|
||||
@@ -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