diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 2fb301156d..287ff923c6 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -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()) diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 9cb0e566fb..0eb4e55107 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -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. diff --git a/services/mailer/mail_login.go b/services/mailer/mail_login.go new file mode 100644 index 0000000000..62b956514c --- /dev/null +++ b/services/mailer/mail_login.go @@ -0,0 +1,84 @@ +// Copyright 2026 Moko Consulting +// 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) + } +}