mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 08:36:01 +01:00
added IP Block and notification for failed logins
This commit is contained in:
@@ -85,6 +85,14 @@ func main() {
|
||||
r.Use(middleware.CORS)
|
||||
r.Use(cfg.HostCheckMiddleware)
|
||||
|
||||
// Custom error handlers for non-API paths
|
||||
r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
middleware.ServeErrorPage(w, req, http.StatusNotFound, "Page Not Found", "The page you're looking for doesn't exist or has been moved.")
|
||||
})
|
||||
r.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
middleware.ServeErrorPage(w, req, http.StatusMethodNotAllowed, "Method Not Allowed", "This request method is not supported for this URL.")
|
||||
})
|
||||
|
||||
// Static files
|
||||
r.PathPrefix("/static/").Handler(
|
||||
http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))),
|
||||
@@ -103,7 +111,7 @@ func main() {
|
||||
// Public auth routes
|
||||
auth := r.PathPrefix("/auth").Subrouter()
|
||||
auth.HandleFunc("/login", h.Auth.ShowLogin).Methods("GET")
|
||||
auth.HandleFunc("/login", h.Auth.Login).Methods("POST")
|
||||
auth.Handle("/login", middleware.BruteForceProtect(database, cfg, http.HandlerFunc(h.Auth.Login))).Methods("POST")
|
||||
auth.HandleFunc("/logout", h.Auth.Logout).Methods("POST")
|
||||
|
||||
// MFA (session exists but mfa_verified=0)
|
||||
@@ -133,6 +141,7 @@ func main() {
|
||||
adminUI.HandleFunc("/", h.Admin.ShowAdmin).Methods("GET")
|
||||
adminUI.HandleFunc("/settings", h.Admin.ShowAdmin).Methods("GET")
|
||||
adminUI.HandleFunc("/audit", h.Admin.ShowAdmin).Methods("GET")
|
||||
adminUI.HandleFunc("/security", h.Admin.ShowAdmin).Methods("GET")
|
||||
|
||||
// API
|
||||
api := r.PathPrefix("/api").Subrouter()
|
||||
@@ -218,6 +227,19 @@ func main() {
|
||||
adminAPI.HandleFunc("/audit", h.Admin.ListAuditLogs).Methods("GET")
|
||||
adminAPI.HandleFunc("/settings", h.Admin.GetSettings).Methods("GET")
|
||||
adminAPI.HandleFunc("/settings", h.Admin.SetSettings).Methods("PUT")
|
||||
adminAPI.HandleFunc("/ip-blocks", h.Admin.ListIPBlocks).Methods("GET")
|
||||
adminAPI.HandleFunc("/ip-blocks", h.Admin.AddIPBlock).Methods("POST")
|
||||
adminAPI.HandleFunc("/ip-blocks/{ip}", h.Admin.RemoveIPBlock).Methods("DELETE")
|
||||
adminAPI.HandleFunc("/login-attempts", h.Admin.ListLoginAttempts).Methods("GET")
|
||||
|
||||
// Periodically purge expired IP blocks
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
database.PurgeExpiredBlocks()
|
||||
}
|
||||
}()
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
|
||||
195
config/config.go
195
config/config.go
@@ -28,6 +28,23 @@ type Config struct {
|
||||
SessionMaxAge int
|
||||
TrustedProxies []net.IPNet // CIDR ranges allowed to set X-Forwarded-For/Proto headers
|
||||
|
||||
// Notification SMTP (outbound alerts — separate from user mail accounts)
|
||||
NotifyEnabled bool
|
||||
NotifySMTPHost string
|
||||
NotifySMTPPort int
|
||||
NotifyFrom string
|
||||
NotifyUser string // optional — leave blank for unauthenticated relay
|
||||
NotifyPass string // optional
|
||||
|
||||
// Brute force protection
|
||||
BruteEnabled bool
|
||||
BruteMaxAttempts int
|
||||
BruteWindowMins int
|
||||
BruteBanHours int
|
||||
BruteWhitelist []net.IP // IPs exempt from blocking
|
||||
GeoBlockCountries []string // 2-letter codes to deny (deny-list mode)
|
||||
GeoAllowCountries []string // 2-letter codes to allow (allow-list mode, empty=allow all)
|
||||
|
||||
// Storage
|
||||
DBPath string
|
||||
|
||||
@@ -118,6 +135,108 @@ var allFields = []configField{
|
||||
" NOTE: Do not add untrusted IPs — clients could spoof their source address.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "NOTIFY_ENABLED",
|
||||
defVal: "true",
|
||||
comments: []string{
|
||||
"--- Security Notifications ---",
|
||||
"Send email alerts to users when their account is targeted by brute-force attacks.",
|
||||
"Set to false to disable all security notification emails.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "NOTIFY_SMTP_HOST",
|
||||
defVal: "",
|
||||
comments: []string{
|
||||
"SMTP server hostname for sending security notification emails.",
|
||||
"Example: smtp.example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "NOTIFY_SMTP_PORT",
|
||||
defVal: "587",
|
||||
comments: []string{
|
||||
"SMTP server port. Common values: 587 (STARTTLS), 465 (TLS), 25 (relay, no auth).",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "NOTIFY_FROM",
|
||||
defVal: "",
|
||||
comments: []string{
|
||||
"Sender address for security notification emails. Example: security@example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "NOTIFY_USER",
|
||||
defVal: "",
|
||||
comments: []string{
|
||||
"SMTP username for authenticated relay. Leave blank for unauthenticated relay.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "NOTIFY_PASS",
|
||||
defVal: "",
|
||||
comments: []string{
|
||||
"SMTP password for authenticated relay. Leave blank for unauthenticated relay.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "BRUTE_ENABLED",
|
||||
defVal: "true",
|
||||
comments: []string{
|
||||
"--- Brute Force Protection ---",
|
||||
"Enable automatic IP blocking after repeated failed logins.",
|
||||
"Set to false to disable entirely.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "BRUTE_MAX_ATTEMPTS",
|
||||
defVal: "5",
|
||||
comments: []string{
|
||||
"Number of failed login attempts within BRUTE_WINDOW_MINUTES that triggers a ban.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "BRUTE_WINDOW_MINUTES",
|
||||
defVal: "30",
|
||||
comments: []string{
|
||||
"Time window in minutes for counting failed login attempts.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "BRUTE_BAN_HOURS",
|
||||
defVal: "12",
|
||||
comments: []string{
|
||||
"How many hours to ban an offending IP. Set to 0 for permanent ban (admin must unban manually).",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "BRUTE_WHITELIST_IPS",
|
||||
defVal: "",
|
||||
comments: []string{
|
||||
"Comma-separated IPv4/IPv6 addresses that are never blocked by brute force protection.",
|
||||
"Example: 192.168.1.1,10.0.0.1",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "GEO_BLOCK_COUNTRIES",
|
||||
defVal: "",
|
||||
comments: []string{
|
||||
"--- Geo Blocking (uses ip-api.com, requires internet access) ---",
|
||||
"Comma-separated 2-letter ISO country codes to DENY access from.",
|
||||
"Example: CN,RU,KP",
|
||||
"Leave blank to disable deny-list. Takes precedence over GEO_ALLOW_COUNTRIES.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "GEO_ALLOW_COUNTRIES",
|
||||
defVal: "",
|
||||
comments: []string{
|
||||
"Comma-separated 2-letter ISO country codes to ALLOW (all others are denied).",
|
||||
"Example: SK,CZ,DE",
|
||||
"Leave blank to allow all countries. Only active if GEO_BLOCK_COUNTRIES is also blank.",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "DB_PATH",
|
||||
defVal: "./data/gowebmail.db",
|
||||
@@ -313,6 +432,21 @@ func Load() (*Config, error) {
|
||||
SessionMaxAge: atoi(get("SESSION_MAX_AGE"), 604800),
|
||||
TrustedProxies: trustedProxies,
|
||||
|
||||
BruteEnabled: atobool(get("BRUTE_ENABLED"), true),
|
||||
BruteMaxAttempts: atoi(get("BRUTE_MAX_ATTEMPTS"), 5),
|
||||
BruteWindowMins: atoi(get("BRUTE_WINDOW_MINUTES"), 30),
|
||||
BruteBanHours: atoi(get("BRUTE_BAN_HOURS"), 12),
|
||||
BruteWhitelist: parseIPList(get("BRUTE_WHITELIST_IPS")),
|
||||
GeoBlockCountries: parseCountryList(get("GEO_BLOCK_COUNTRIES")),
|
||||
GeoAllowCountries: parseCountryList(get("GEO_ALLOW_COUNTRIES")),
|
||||
|
||||
NotifyEnabled: atobool(get("NOTIFY_ENABLED"), true),
|
||||
NotifySMTPHost: get("NOTIFY_SMTP_HOST"),
|
||||
NotifySMTPPort: atoi(get("NOTIFY_SMTP_PORT"), 587),
|
||||
NotifyFrom: get("NOTIFY_FROM"),
|
||||
NotifyUser: get("NOTIFY_USER"),
|
||||
NotifyPass: get("NOTIFY_PASS"),
|
||||
|
||||
GoogleClientID: get("GOOGLE_CLIENT_ID"),
|
||||
GoogleClientSecret: get("GOOGLE_CLIENT_SECRET"),
|
||||
GoogleRedirectURL: googleRedirect,
|
||||
@@ -345,6 +479,42 @@ func buildBaseURL(hostname, port string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// IsIPWhitelisted returns true if the IP is in the brute force whitelist.
|
||||
func (c *Config) IsIPWhitelisted(ipStr string) bool {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, w := range c.BruteWhitelist {
|
||||
if w.Equal(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsCountryAllowed returns true if traffic from the given 2-letter country code is permitted.
|
||||
// Logic: deny-list takes precedence; then allow-list if non-empty; otherwise allow all.
|
||||
func (c *Config) IsCountryAllowed(code string) bool {
|
||||
code = strings.ToUpper(code)
|
||||
if len(c.GeoBlockCountries) > 0 {
|
||||
for _, bc := range c.GeoBlockCountries {
|
||||
if bc == code {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(c.GeoAllowCountries) > 0 {
|
||||
for _, ac := range c.GeoAllowCountries {
|
||||
if ac == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsAllowedHost returns true if the request Host header matches our expected hostname.
|
||||
// Accepts exact match, hostname:port, or any value if hostname is "localhost" (dev mode).
|
||||
func (c *Config) IsAllowedHost(requestHost string) bool {
|
||||
@@ -589,6 +759,31 @@ func logStartupInfo(cfg *Config) {
|
||||
}
|
||||
}
|
||||
|
||||
func parseIPList(s string) []net.IP {
|
||||
var ips []net.IP
|
||||
for _, raw := range strings.Split(s, ",") {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
if ip := net.ParseIP(raw); ip != nil {
|
||||
ips = append(ips, ip)
|
||||
}
|
||||
}
|
||||
return ips
|
||||
}
|
||||
|
||||
func parseCountryList(s string) []string {
|
||||
var codes []string
|
||||
for _, raw := range strings.Split(s, ",") {
|
||||
raw = strings.TrimSpace(strings.ToUpper(raw))
|
||||
if len(raw) == 2 {
|
||||
codes = append(codes, raw)
|
||||
}
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
func mustHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
|
||||
@@ -199,6 +199,37 @@ func (d *DB) Migrate() error {
|
||||
return fmt.Errorf("create pending_imap_ops: %w", err)
|
||||
}
|
||||
|
||||
// Login attempt tracking for brute-force protection.
|
||||
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT NOT NULL,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
success INTEGER NOT NULL DEFAULT 0,
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
country_code TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now'))
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create login_attempts: %w", err)
|
||||
}
|
||||
if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_login_attempts_ip_time ON login_attempts(ip, created_at)`); err != nil {
|
||||
return fmt.Errorf("create login_attempts index: %w", err)
|
||||
}
|
||||
|
||||
// IP block list — manually added or auto-created by brute force protection.
|
||||
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS ip_blocks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT NOT NULL UNIQUE,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
country_code TEXT NOT NULL DEFAULT '',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
blocked_at DATETIME DEFAULT (datetime('now')),
|
||||
expires_at DATETIME,
|
||||
is_permanent INTEGER NOT NULL DEFAULT 0
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create ip_blocks: %w", err)
|
||||
}
|
||||
|
||||
// Bootstrap admin account if no users exist
|
||||
return d.bootstrapAdmin()
|
||||
}
|
||||
@@ -1693,3 +1724,152 @@ func (d *DB) AdminDisableMFAByID(targetUserID int64) error {
|
||||
WHERE id=?`, targetUserID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- Brute Force / IP Block ----
|
||||
|
||||
// IPBlock represents a blocked IP entry.
|
||||
type IPBlock struct {
|
||||
ID int64 `json:"id"`
|
||||
IP string `json:"ip"`
|
||||
Reason string `json:"reason"`
|
||||
Country string `json:"country"`
|
||||
CountryCode string `json:"country_code"`
|
||||
Attempts int `json:"attempts"`
|
||||
BlockedAt time.Time `json:"blocked_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
IsPermanent bool `json:"is_permanent"`
|
||||
}
|
||||
|
||||
// LoginAttemptStat is used for summary display.
|
||||
type LoginAttemptStat struct {
|
||||
IP string `json:"ip"`
|
||||
Country string `json:"country"`
|
||||
CountryCode string `json:"country_code"`
|
||||
Total int `json:"total"`
|
||||
Failures int `json:"failures"`
|
||||
LastSeen string `json:"last_seen"`
|
||||
}
|
||||
|
||||
// RecordLoginAttempt saves a login attempt for an IP.
|
||||
func (d *DB) RecordLoginAttempt(ip, username, country, countryCode string, success bool) {
|
||||
suc := 0
|
||||
if success {
|
||||
suc = 1
|
||||
}
|
||||
d.sql.Exec(`INSERT INTO login_attempts (ip, username, success, country, country_code) VALUES (?,?,?,?,?)`,
|
||||
ip, username, suc, country, countryCode)
|
||||
}
|
||||
|
||||
// CountRecentFailures returns the number of failed logins from an IP in the last windowMinutes.
|
||||
func (d *DB) CountRecentFailures(ip string, windowMinutes int) int {
|
||||
var count int
|
||||
d.sql.QueryRow(`
|
||||
SELECT COUNT(*) FROM login_attempts
|
||||
WHERE ip=? AND success=0 AND created_at >= datetime('now', ? || ' minutes')`,
|
||||
ip, fmt.Sprintf("-%d", windowMinutes),
|
||||
).Scan(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// IsIPBlocked returns true if the IP is currently blocked (non-expired entry).
|
||||
func (d *DB) IsIPBlocked(ip string) bool {
|
||||
var count int
|
||||
d.sql.QueryRow(`
|
||||
SELECT COUNT(*) FROM ip_blocks
|
||||
WHERE ip=? AND (is_permanent=1 OR expires_at IS NULL OR expires_at > datetime('now'))`,
|
||||
ip,
|
||||
).Scan(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// BlockIP adds or updates a block entry for an IP.
|
||||
// banHours=0 means permanent block (admin must remove manually).
|
||||
func (d *DB) BlockIP(ip, reason, country, countryCode string, attempts int, banHours int) {
|
||||
isPermanent := 0
|
||||
var expiresExpr string
|
||||
if banHours == 0 {
|
||||
isPermanent = 1
|
||||
expiresExpr = "NULL"
|
||||
} else {
|
||||
expiresExpr = fmt.Sprintf("datetime('now', '+%d hours')", banHours)
|
||||
}
|
||||
d.sql.Exec(fmt.Sprintf(`
|
||||
INSERT INTO ip_blocks (ip, reason, country, country_code, attempts, is_permanent, expires_at)
|
||||
VALUES (?,?,?,?,?,%d,%s)
|
||||
ON CONFLICT(ip) DO UPDATE SET
|
||||
reason=excluded.reason, attempts=excluded.attempts,
|
||||
blocked_at=datetime('now'), is_permanent=%d, expires_at=%s`,
|
||||
isPermanent, expiresExpr, isPermanent, expiresExpr,
|
||||
), ip, reason, country, countryCode, attempts)
|
||||
}
|
||||
|
||||
// UnblockIP removes a block entry.
|
||||
func (d *DB) UnblockIP(ip string) error {
|
||||
_, err := d.sql.Exec(`DELETE FROM ip_blocks WHERE ip=?`, ip)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListIPBlocks returns all current (non-expired or permanent) blocked IPs.
|
||||
func (d *DB) ListIPBlocks() ([]IPBlock, error) {
|
||||
rows, err := d.sql.Query(`
|
||||
SELECT id, ip, reason, country, country_code, attempts, blocked_at, expires_at, is_permanent
|
||||
FROM ip_blocks
|
||||
WHERE is_permanent=1 OR expires_at IS NULL OR expires_at > datetime('now')
|
||||
ORDER BY blocked_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []IPBlock
|
||||
for rows.Next() {
|
||||
var b IPBlock
|
||||
var expiresStr sql.NullString
|
||||
rows.Scan(&b.ID, &b.IP, &b.Reason, &b.Country, &b.CountryCode,
|
||||
&b.Attempts, &b.BlockedAt, &expiresStr, &b.IsPermanent)
|
||||
if expiresStr.Valid {
|
||||
t, _ := time.Parse("2006-01-02 15:04:05", expiresStr.String)
|
||||
b.ExpiresAt = &t
|
||||
}
|
||||
result = append(result, b)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ListLoginAttemptStats returns per-IP attempt summaries for display.
|
||||
func (d *DB) ListLoginAttemptStats(limitHours int) ([]LoginAttemptStat, error) {
|
||||
rows, err := d.sql.Query(`
|
||||
SELECT ip, country, country_code,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN success=0 THEN 1 ELSE 0 END) as failures,
|
||||
MAX(created_at) as last_seen
|
||||
FROM login_attempts
|
||||
WHERE created_at >= datetime('now', ? || ' hours')
|
||||
GROUP BY ip ORDER BY failures DESC LIMIT 100`,
|
||||
fmt.Sprintf("-%d", limitHours),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []LoginAttemptStat
|
||||
for rows.Next() {
|
||||
var s LoginAttemptStat
|
||||
rows.Scan(&s.IP, &s.Country, &s.CountryCode, &s.Total, &s.Failures, &s.LastSeen)
|
||||
result = append(result, s)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// PurgeExpiredBlocks removes expired (non-permanent) blocks from the table.
|
||||
func (d *DB) PurgeExpiredBlocks() {
|
||||
d.sql.Exec(`DELETE FROM ip_blocks WHERE is_permanent=0 AND expires_at IS NOT NULL AND expires_at <= datetime('now')`)
|
||||
}
|
||||
|
||||
// LookupIPCountry returns cached country info for an IP from recent login_attempts.
|
||||
func (d *DB) LookupCachedCountry(ip string) (country, countryCode string) {
|
||||
d.sql.QueryRow(`
|
||||
SELECT country, country_code FROM login_attempts
|
||||
WHERE ip=? AND country != '' ORDER BY created_at DESC LIMIT 1`, ip,
|
||||
).Scan(&country, &countryCode)
|
||||
return
|
||||
}
|
||||
|
||||
97
internal/geo/geo.go
Normal file
97
internal/geo/geo.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// Package geo provides IP geolocation lookup using the free ip-api.com service.
|
||||
// No API key is required. Rate limit: 45 requests/minute on the free tier.
|
||||
// Results are cached in memory to reduce API calls.
|
||||
package geo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GeoResult struct {
|
||||
CountryCode string
|
||||
Country string
|
||||
Cached bool
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
result GeoResult
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
cache = make(map[string]*cacheEntry)
|
||||
)
|
||||
|
||||
const cacheTTL = 24 * time.Hour
|
||||
|
||||
// Lookup returns the country for an IP address.
|
||||
// Returns empty strings on failure (private IPs, rate limit, etc.).
|
||||
func Lookup(ip string) GeoResult {
|
||||
// Skip private / loopback
|
||||
parsed := net.ParseIP(ip)
|
||||
if parsed == nil || isPrivate(parsed) {
|
||||
return GeoResult{}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
if e, ok := cache[ip]; ok && time.Since(e.fetchedAt) < cacheTTL {
|
||||
mu.Unlock()
|
||||
r := e.result
|
||||
r.Cached = true
|
||||
return r
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
result := fetchFromAPI(ip)
|
||||
|
||||
mu.Lock()
|
||||
cache[ip] = &cacheEntry{result: result, fetchedAt: time.Now()}
|
||||
mu.Unlock()
|
||||
return result
|
||||
}
|
||||
|
||||
func fetchFromAPI(ip string) GeoResult {
|
||||
url := fmt.Sprintf("http://ip-api.com/json/%s?fields=status,country,countryCode", ip)
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
log.Printf("geo lookup failed for %s: %v", ip, err)
|
||||
return GeoResult{}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var data struct {
|
||||
Status string `json:"status"`
|
||||
Country string `json:"country"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil || data.Status != "success" {
|
||||
return GeoResult{}
|
||||
}
|
||||
return GeoResult{
|
||||
CountryCode: strings.ToUpper(data.CountryCode),
|
||||
Country: data.Country,
|
||||
}
|
||||
}
|
||||
|
||||
func isPrivate(ip net.IP) bool {
|
||||
privateRanges := []string{
|
||||
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
||||
"127.0.0.0/8", "::1/128", "fc00::/7",
|
||||
}
|
||||
for _, cidr := range privateRanges {
|
||||
_, network, _ := net.ParseCIDR(cidr)
|
||||
if network != nil && network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/geo"
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
"github.com/gorilla/mux"
|
||||
@@ -225,3 +226,68 @@ func (h *AdminHandler) SetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
"changed": changed,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- IP Blocks ----
|
||||
|
||||
func (h *AdminHandler) ListIPBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
blocks, err := h.db.ListIPBlocks()
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list blocks")
|
||||
return
|
||||
}
|
||||
if blocks == nil {
|
||||
blocks = []db.IPBlock{}
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"blocks": blocks})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) AddIPBlock(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
IP string `json:"ip"`
|
||||
Reason string `json:"reason"`
|
||||
BanHours int `json:"ban_hours"` // 0 = permanent
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.IP == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "ip required")
|
||||
return
|
||||
}
|
||||
// Try geo lookup for the IP being manually blocked
|
||||
g := geo.Lookup(req.IP)
|
||||
if req.Reason == "" {
|
||||
req.Reason = "Manual admin block"
|
||||
}
|
||||
h.db.BlockIP(req.IP, req.Reason, g.Country, g.CountryCode, 0, req.BanHours)
|
||||
adminID := middleware.GetUserID(r)
|
||||
h.db.WriteAudit(&adminID, models.AuditConfigChange, "manual IP block: "+req.IP, middleware.ClientIP(r), r.UserAgent())
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) RemoveIPBlock(w http.ResponseWriter, r *http.Request) {
|
||||
ip := mux.Vars(r)["ip"]
|
||||
if ip == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "ip required")
|
||||
return
|
||||
}
|
||||
if err := h.db.UnblockIP(ip); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "unblock failed")
|
||||
return
|
||||
}
|
||||
adminID := middleware.GetUserID(r)
|
||||
h.db.WriteAudit(&adminID, models.AuditConfigChange, "unblocked IP: "+ip, middleware.ClientIP(r), r.UserAgent())
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ---- Login Attempts ----
|
||||
|
||||
func (h *AdminHandler) ListLoginAttempts(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := h.db.ListLoginAttemptStats(72) // last 72 hours
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to query attempts")
|
||||
return
|
||||
}
|
||||
if stats == nil {
|
||||
stats = []db.LoginAttemptStat{}
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"attempts": stats})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -11,7 +13,9 @@ import (
|
||||
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/geo"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
"github.com/ghostersk/gowebmail/internal/notify"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
@@ -117,9 +121,13 @@ func RequireAdmin(next http.Handler) http.Handler {
|
||||
role, _ := r.Context().Value(UserRoleKey).(models.UserRole)
|
||||
if role != models.RoleAdmin {
|
||||
if isAPIPath(r) {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
fmt.Fprint(w, `{"error":"forbidden"}`)
|
||||
} else {
|
||||
http.Error(w, "403 Forbidden", http.StatusForbidden)
|
||||
renderErrorPage(w, r, http.StatusForbidden,
|
||||
"Access Denied",
|
||||
"You don't have permission to access this page. Admin privileges are required.")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -169,3 +177,211 @@ func ClientIP(r *http.Request) string {
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// BruteForceProtect wraps the login POST handler with rate-limiting and geo-blocking.
|
||||
// It must be called with the raw handler so it can intercept BEFORE auth.
|
||||
func BruteForceProtect(database *db.DB, cfg *config.Config, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := cfg.RealIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
|
||||
|
||||
// Whitelist check runs FIRST — whitelisted IPs bypass all blocking entirely.
|
||||
if cfg.IsIPWhitelisted(ip) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve country for geo-block and attempt recording.
|
||||
// Only do a live lookup for non-GET to save API quota; GET uses cache only.
|
||||
geoResult := geo.Lookup(ip)
|
||||
|
||||
// --- Geo block (apply to all requests) ---
|
||||
if geoResult.CountryCode != "" {
|
||||
if !cfg.IsCountryAllowed(geoResult.CountryCode) {
|
||||
log.Printf("geo-block: %s (%s %s)", ip, geoResult.CountryCode, geoResult.Country)
|
||||
renderErrorPage(w, r, http.StatusForbidden,
|
||||
"Access Denied",
|
||||
"Access from your country is not permitted.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !cfg.BruteEnabled || r.Method != http.MethodPost {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already blocked
|
||||
if database.IsIPBlocked(ip) {
|
||||
renderErrorPage(w, r, http.StatusForbidden,
|
||||
"IP Address Blocked",
|
||||
"Your IP address has been temporarily blocked due to too many failed login attempts. Please contact the administrator.")
|
||||
return
|
||||
}
|
||||
|
||||
// Wrap the response writer to detect a failed login (redirect to error vs success)
|
||||
rw := &loginResponseCapture{ResponseWriter: w, statusCode: 200}
|
||||
next.ServeHTTP(rw, r)
|
||||
|
||||
// Determine success: a redirect away from login = success
|
||||
success := rw.statusCode == http.StatusFound && !strings.Contains(rw.location, "error=")
|
||||
username := r.FormValue("username")
|
||||
database.RecordLoginAttempt(ip, username, geoResult.Country, geoResult.CountryCode, success)
|
||||
|
||||
if !success {
|
||||
failures := database.CountRecentFailures(ip, cfg.BruteWindowMins)
|
||||
if failures >= cfg.BruteMaxAttempts {
|
||||
reason := "Too many failed logins"
|
||||
database.BlockIP(ip, reason, geoResult.Country, geoResult.CountryCode, failures, cfg.BruteBanHours)
|
||||
log.Printf("brute-force block: %s (%d failures in %d min, ban %d hrs)",
|
||||
ip, failures, cfg.BruteWindowMins, cfg.BruteBanHours)
|
||||
|
||||
// Send security notification to the targeted user (non-blocking goroutine)
|
||||
go func(targetUsername string) {
|
||||
user, _ := database.GetUserByUsername(targetUsername)
|
||||
if user == nil {
|
||||
user, _ = database.GetUserByEmail(targetUsername)
|
||||
}
|
||||
if user != nil && user.Email != "" {
|
||||
notify.SendBruteForceAlert(cfg, notify.BruteForceAlert{
|
||||
Username: user.Username,
|
||||
ToEmail: user.Email,
|
||||
AttackerIP: ip,
|
||||
Country: geoResult.Country,
|
||||
CountryCode: geoResult.CountryCode,
|
||||
Attempts: failures,
|
||||
BlockedAt: time.Now().UTC(),
|
||||
BanHours: cfg.BruteBanHours,
|
||||
Hostname: cfg.Hostname,
|
||||
})
|
||||
}
|
||||
}(username)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// loginResponseCapture captures the redirect location from the login handler.
|
||||
type loginResponseCapture struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
location string
|
||||
}
|
||||
|
||||
func (lrc *loginResponseCapture) WriteHeader(code int) {
|
||||
lrc.statusCode = code
|
||||
lrc.location = lrc.ResponseWriter.Header().Get("Location")
|
||||
lrc.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// ServeErrorPage is the public wrapper used by main.go for 404/405 handlers.
|
||||
func ServeErrorPage(w http.ResponseWriter, r *http.Request, status int, title, message string) {
|
||||
renderErrorPage(w, r, status, title, message)
|
||||
}
|
||||
|
||||
// renderErrorPage writes a themed HTML error page for browser requests,
|
||||
// or a JSON error for API paths.
|
||||
func renderErrorPage(w http.ResponseWriter, r *http.Request, status int, title, message string) {
|
||||
if isAPIPath(r) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprintf(w, `{"error":%q}`, message)
|
||||
return
|
||||
}
|
||||
// Decide back-button destination: if the user has a session cookie they're
|
||||
// likely logged in, so send them home. Otherwise send to login.
|
||||
backHref := "/auth/login"
|
||||
backLabel := "← Back to Login"
|
||||
if _, err := r.Cookie("gomail_session"); err == nil {
|
||||
backHref = "/"
|
||||
backLabel = "← Go to Home"
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Status int
|
||||
Title string
|
||||
Message string
|
||||
BackHref string
|
||||
BackLabel string
|
||||
}{status, title, message, backHref, backLabel}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if err := errorPageTmpl.Execute(w, data); err != nil {
|
||||
// Last-resort plain text fallback
|
||||
fmt.Fprintf(w, "%d %s: %s", status, title, message)
|
||||
}
|
||||
}
|
||||
|
||||
var errorPageTmpl = template.Must(template.New("error").Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Status}} – {{.Title}}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/gowebmail.css">
|
||||
<style>
|
||||
html, body { height: 100%; margin: 0; }
|
||||
.error-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg, #18191b);
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
}
|
||||
.error-card {
|
||||
background: var(--surface, #232428);
|
||||
border: 1px solid var(--border, #2e2f34);
|
||||
border-radius: 16px;
|
||||
padding: 48px 56px;
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
width: 90%;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.4);
|
||||
}
|
||||
.error-code {
|
||||
font-size: 64px;
|
||||
font-weight: 700;
|
||||
color: var(--accent, #6b8afd);
|
||||
line-height: 1;
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
.error-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #e8e9ed);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.error-message {
|
||||
font-size: 14px;
|
||||
color: var(--muted, #8b8d97);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 32px;
|
||||
}
|
||||
.error-back {
|
||||
display: inline-block;
|
||||
padding: 10px 24px;
|
||||
background: var(--accent, #6b8afd);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.error-back:hover { opacity: .85; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-page">
|
||||
<div class="error-card">
|
||||
<div class="error-code">{{.Status}}</div>
|
||||
<h1 class="error-title">{{.Title}}</h1>
|
||||
<p class="error-message">{{.Message}}</p>
|
||||
<a href="{{.BackHref}}" class="error-back">{{.BackLabel}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
201
internal/notify/notify.go
Normal file
201
internal/notify/notify.go
Normal file
@@ -0,0 +1,201 @@
|
||||
// Package notify sends security alert emails using a configurable SMTP relay.
|
||||
// It supports both authenticated and unauthenticated (relay-only) SMTP servers.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
)
|
||||
|
||||
// BruteForceAlert holds the data for the brute-force notification email.
|
||||
type BruteForceAlert struct {
|
||||
Username string
|
||||
ToEmail string
|
||||
AttackerIP string
|
||||
Country string
|
||||
CountryCode string
|
||||
Attempts int
|
||||
BlockedAt time.Time
|
||||
BanHours int // 0 = permanent
|
||||
AppName string
|
||||
Hostname string
|
||||
}
|
||||
|
||||
var bruteForceTemplate = template.Must(template.New("brute").Parse(`From: {{.AppName}} Security <{{.From}}>
|
||||
To: {{.ToEmail}}
|
||||
Subject: Security Alert: Failed login attempts on your account
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Hello {{.Username}},
|
||||
|
||||
This is an automated security alert from {{.AppName}} ({{.Hostname}}).
|
||||
|
||||
We detected multiple failed login attempts on your account and have
|
||||
automatically blocked the source IP address.
|
||||
|
||||
Account targeted : {{.Username}}
|
||||
Source IP : {{.AttackerIP}}
|
||||
{{- if .Country}}
|
||||
Country : {{.Country}} ({{.CountryCode}})
|
||||
{{- end}}
|
||||
Failed attempts : {{.Attempts}}
|
||||
Detected at : {{.BlockedAt.Format "2006-01-02 15:04:05 UTC"}}
|
||||
{{- if eq .BanHours 0}}
|
||||
Block duration : Permanent (administrator action required to unblock)
|
||||
{{- else}}
|
||||
Block duration : {{.BanHours}} hours
|
||||
{{- end}}
|
||||
|
||||
If this was you, you may have mistyped your password. The block will
|
||||
{{- if eq .BanHours 0}} remain until removed by an administrator.
|
||||
{{- else}} expire automatically after {{.BanHours}} hours.{{end}}
|
||||
|
||||
If you did not attempt to log in, your account credentials may be at
|
||||
risk. We recommend changing your password as soon as possible.
|
||||
|
||||
This is an automated message. Please do not reply.
|
||||
|
||||
--
|
||||
{{.AppName}} Security
|
||||
{{.Hostname}}
|
||||
`))
|
||||
|
||||
type templateData struct {
|
||||
BruteForceAlert
|
||||
From string
|
||||
}
|
||||
|
||||
// SendBruteForceAlert sends a security notification email to the targeted user.
|
||||
// It runs in a goroutine — errors are logged but not returned.
|
||||
func SendBruteForceAlert(cfg *config.Config, alert BruteForceAlert) {
|
||||
if !cfg.NotifyEnabled || cfg.NotifySMTPHost == "" || cfg.NotifyFrom == "" {
|
||||
return
|
||||
}
|
||||
if alert.ToEmail == "" {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if err := sendAlert(cfg, alert); err != nil {
|
||||
log.Printf("notify: failed to send brute-force alert to %s: %v", alert.ToEmail, err)
|
||||
} else {
|
||||
log.Printf("notify: sent brute-force alert to %s (attacker: %s)", alert.ToEmail, alert.AttackerIP)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func sendAlert(cfg *config.Config, alert BruteForceAlert) error {
|
||||
if alert.AppName == "" {
|
||||
alert.AppName = "GoWebMail"
|
||||
}
|
||||
if alert.Hostname == "" {
|
||||
alert.Hostname = cfg.Hostname
|
||||
}
|
||||
|
||||
data := templateData{BruteForceAlert: alert, From: cfg.NotifyFrom}
|
||||
var buf bytes.Buffer
|
||||
if err := bruteForceTemplate.Execute(&buf, data); err != nil {
|
||||
return fmt.Errorf("template execute: %w", err)
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", cfg.NotifySMTPHost, cfg.NotifySMTPPort)
|
||||
|
||||
// Choose auth method
|
||||
var auth smtp.Auth
|
||||
if cfg.NotifyUser != "" && cfg.NotifyPass != "" {
|
||||
auth = smtp.PlainAuth("", cfg.NotifyUser, cfg.NotifyPass, cfg.NotifySMTPHost)
|
||||
}
|
||||
|
||||
// Try STARTTLS first (port 587), fall back to plain, support TLS on 465
|
||||
if cfg.NotifySMTPPort == 465 {
|
||||
return sendTLS(addr, cfg.NotifySMTPHost, auth, cfg.NotifyFrom, alert.ToEmail, buf.Bytes())
|
||||
}
|
||||
return sendSTARTTLS(addr, cfg.NotifySMTPHost, auth, cfg.NotifyFrom, alert.ToEmail, buf.Bytes())
|
||||
}
|
||||
|
||||
// sendSTARTTLS sends via plain SMTP with optional STARTTLS upgrade (ports 25, 587).
|
||||
func sendSTARTTLS(addr, host string, auth smtp.Auth, from, to string, msg []byte) error {
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s: %w", addr, err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// Try STARTTLS — not all servers require it (plain relay servers often skip it)
|
||||
if ok, _ := c.Extension("STARTTLS"); ok {
|
||||
tlsCfg := &tls.Config{ServerName: host}
|
||||
if err := c.StartTLS(tlsCfg); err != nil {
|
||||
// Log but continue — some relays advertise STARTTLS but don't enforce it
|
||||
log.Printf("notify: STARTTLS failed for %s, continuing unencrypted: %v", host, err)
|
||||
}
|
||||
}
|
||||
|
||||
if auth != nil {
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp auth: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return sendMessage(c, from, to, msg)
|
||||
}
|
||||
|
||||
// sendTLS sends via direct TLS connection (port 465).
|
||||
func sendTLS(addr, host string, auth smtp.Auth, from, to string, msg []byte) error {
|
||||
tlsCfg := &tls.Config{ServerName: host}
|
||||
conn, err := tls.Dial("tcp", addr, tlsCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tls dial %s: %w", addr, err)
|
||||
}
|
||||
|
||||
// Resolve host for the smtp.NewClient call
|
||||
bareHost, _, _ := net.SplitHostPort(addr)
|
||||
if bareHost == "" {
|
||||
bareHost = host
|
||||
}
|
||||
|
||||
c, err := smtp.NewClient(conn, bareHost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp client: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if auth != nil {
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp auth: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return sendMessage(c, from, to, msg)
|
||||
}
|
||||
|
||||
func sendMessage(c *smtp.Client, from, to string, msg []byte) error {
|
||||
if err := c.Mail(from); err != nil {
|
||||
return fmt.Errorf("MAIL FROM: %w", err)
|
||||
}
|
||||
if err := c.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("RCPT TO: %w", err)
|
||||
}
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("DATA: %w", err)
|
||||
}
|
||||
// Normalise line endings to CRLF
|
||||
normalized := strings.ReplaceAll(string(msg), "\r\n", "\n")
|
||||
normalized = strings.ReplaceAll(normalized, "\n", "\r\n")
|
||||
if _, err := w.Write([]byte(normalized)); err != nil {
|
||||
return fmt.Errorf("write body: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("close data: %w", err)
|
||||
}
|
||||
return c.Quit()
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
// GoWebMail Admin SPA
|
||||
|
||||
const adminRoutes = {
|
||||
'/admin': renderUsers,
|
||||
'/admin/settings': renderSettings,
|
||||
'/admin/audit': renderAudit,
|
||||
'/admin': renderUsers,
|
||||
'/admin/settings': renderSettings,
|
||||
'/admin/audit': renderAudit,
|
||||
'/admin/security': renderSecurity,
|
||||
};
|
||||
|
||||
function navigate(path) {
|
||||
@@ -202,6 +203,34 @@ const SETTINGS_META = [
|
||||
{ key: 'DB_PATH', label: 'Database Path', desc: 'Path to SQLite file, relative to working directory', type: 'text' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Security Notifications',
|
||||
fields: [
|
||||
{ key: 'NOTIFY_ENABLED', label: 'Enabled', desc: 'Send email to users when brute-force attack is detected on their account', type: 'select', options: ['true','false'] },
|
||||
{ key: 'NOTIFY_SMTP_HOST', label: 'SMTP Host', desc: 'SMTP server for sending alerts. Example: smtp.example.com', type: 'text' },
|
||||
{ key: 'NOTIFY_SMTP_PORT', label: 'SMTP Port', desc: '587 = STARTTLS, 465 = TLS, 25 = plain relay', type: 'number' },
|
||||
{ key: 'NOTIFY_FROM', label: 'From Address', desc: 'Sender email. Example: security@example.com', type: 'text' },
|
||||
{ key: 'NOTIFY_USER', label: 'SMTP Username', desc: 'Leave blank for unauthenticated relay', type: 'text' },
|
||||
{ key: 'NOTIFY_PASS', label: 'SMTP Password', desc: 'Leave blank for unauthenticated relay', type: 'password' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Brute Force Protection',
|
||||
fields: [
|
||||
{ key: 'BRUTE_ENABLED', label: 'Enabled', desc: 'Auto-block IPs after repeated failed logins', type: 'select', options: ['true','false'] },
|
||||
{ key: 'BRUTE_MAX_ATTEMPTS', label: 'Max Attempts', desc: 'Failed logins before ban', type: 'number' },
|
||||
{ key: 'BRUTE_WINDOW_MINUTES', label: 'Window (minutes)',desc: 'Time window for counting failures', type: 'number' },
|
||||
{ key: 'BRUTE_BAN_HOURS', label: 'Ban Duration (hours)', desc: '0 = permanent ban (admin must unban)', type: 'number' },
|
||||
{ key: 'BRUTE_WHITELIST_IPS', label: 'Whitelist IPs', desc: 'Comma-separated IPs that are never blocked', type: 'text' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Geo Blocking',
|
||||
fields: [
|
||||
{ key: 'GEO_BLOCK_COUNTRIES', label: 'Block Countries', desc: 'Comma-separated ISO codes to DENY (e.g. CN,RU,KP). Takes precedence over Allow list.', type: 'text' },
|
||||
{ key: 'GEO_ALLOW_COUNTRIES', label: 'Allow Countries', desc: 'Comma-separated ISO codes to ALLOW exclusively (e.g. SK,CZ,DE). Leave blank to allow all.', type: 'text' },
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
async function renderSettings() {
|
||||
@@ -328,4 +357,135 @@ function eventBadge(evt) {
|
||||
navigate(a.getAttribute('href'));
|
||||
});
|
||||
});
|
||||
})();
|
||||
})();
|
||||
// ============================================================
|
||||
// Security — IP Blocks & Login Attempts
|
||||
// ============================================================
|
||||
async function renderSecurity() {
|
||||
const el = document.getElementById('admin-content');
|
||||
el.innerHTML = `
|
||||
<div class="admin-page-header">
|
||||
<h1>Security</h1>
|
||||
<p>Monitor login attempts, manage IP blocks, and control access by country.</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-card" style="margin-bottom:24px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h2 style="margin:0;font-size:16px">Blocked IPs</h2>
|
||||
<button class="btn-primary" onclick="openAddBlock()">+ Block IP</button>
|
||||
</div>
|
||||
<div id="blocks-table"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h2 style="margin:0;font-size:16px">Login Attempts (last 72h)</h2>
|
||||
<button class="btn-secondary" onclick="loadLoginAttempts()">↻ Refresh</button>
|
||||
</div>
|
||||
<div id="attempts-table"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="add-block-modal">
|
||||
<div class="modal" style="max-width:420px">
|
||||
<h2>Block IP Address</h2>
|
||||
<div class="modal-field"><label>IP Address</label><input type="text" id="block-ip" placeholder="e.g. 192.168.1.100"></div>
|
||||
<div class="modal-field"><label>Reason</label><input type="text" id="block-reason" placeholder="Manual admin block"></div>
|
||||
<div class="modal-field"><label>Ban Hours (0 = permanent)</label><input type="number" id="block-hours" value="24" min="0"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-secondary" onclick="closeModal('add-block-modal')">Cancel</button>
|
||||
<button class="btn-primary" onclick="submitAddBlock()">Block IP</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
loadIPBlocks();
|
||||
loadLoginAttempts();
|
||||
}
|
||||
|
||||
async function loadIPBlocks() {
|
||||
const el = document.getElementById('blocks-table');
|
||||
if (!el) return;
|
||||
const r = await api('GET', '/admin/ip-blocks');
|
||||
const blocks = r?.blocks || [];
|
||||
if (!blocks.length) {
|
||||
el.innerHTML = '<p style="color:var(--muted);padding:8px 0">No blocked IPs.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `<table class="admin-table" style="width:100%">
|
||||
<thead><tr>
|
||||
<th>IP</th><th>Country</th><th>Reason</th><th>Attempts</th><th>Blocked At</th><th>Expires</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${blocks.map(b => `<tr>
|
||||
<td><code>${esc(b.ip)}</code></td>
|
||||
<td>${b.country_code ? `<span title="${esc(b.country)}">${esc(b.country_code)}</span>` : '—'}</td>
|
||||
<td>${esc(b.reason)}</td>
|
||||
<td>${b.attempts||0}</td>
|
||||
<td style="font-size:11px">${fmtDate(b.blocked_at)}</td>
|
||||
<td style="font-size:11px;color:var(--muted)">${b.is_permanent ? '♾ Permanent' : b.expires_at ? fmtDate(b.expires_at) : '—'}</td>
|
||||
<td><button class="action-btn danger" onclick="unblockIP('${esc(b.ip)}')">Unblock</button></td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
async function loadLoginAttempts() {
|
||||
const el = document.getElementById('attempts-table');
|
||||
if (!el) return;
|
||||
const r = await api('GET', '/admin/login-attempts');
|
||||
const attempts = r?.attempts || [];
|
||||
if (!attempts.length) {
|
||||
el.innerHTML = '<p style="color:var(--muted);padding:8px 0">No login attempts recorded in the last 72 hours.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `<table class="admin-table" style="width:100%">
|
||||
<thead><tr>
|
||||
<th>IP</th><th>Country</th><th>Total</th><th>Failures</th><th>Last Seen</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${attempts.map(a => `<tr ${a.failures>3?'style="background:rgba(255,80,80,.07)"':''}>
|
||||
<td><code>${esc(a.ip)}</code></td>
|
||||
<td>${a.country_code ? `<span title="${esc(a.country)}">${esc(a.country_code)} ${esc(a.country)}</span>` : '—'}</td>
|
||||
<td>${a.total}</td>
|
||||
<td style="${a.failures>3?'color:#f87;font-weight:600':''}">${a.failures}</td>
|
||||
<td style="font-size:11px">${a.last_seen||'—'}</td>
|
||||
<td><button class="action-btn danger" onclick="blockFromAttempt('${esc(a.ip)}')">Block</button></td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function openAddBlock() { openModal('add-block-modal'); }
|
||||
|
||||
async function submitAddBlock() {
|
||||
const ip = document.getElementById('block-ip').value.trim();
|
||||
const reason = document.getElementById('block-reason').value.trim() || 'Manual admin block';
|
||||
const hours = parseInt(document.getElementById('block-hours').value) || 0;
|
||||
if (!ip) { toast('IP address required', 'error'); return; }
|
||||
const r = await api('POST', '/admin/ip-blocks', { ip, reason, ban_hours: hours });
|
||||
if (r?.ok) { toast('IP blocked', 'success'); closeModal('add-block-modal'); loadIPBlocks(); }
|
||||
else toast(r?.error || 'Failed', 'error');
|
||||
}
|
||||
|
||||
async function unblockIP(ip) {
|
||||
const r = await fetch('/api/admin/ip-blocks/' + encodeURIComponent(ip), { method: 'DELETE' });
|
||||
const data = await r.json();
|
||||
if (data?.ok) { toast('IP unblocked', 'success'); loadIPBlocks(); }
|
||||
else toast(data?.error || 'Failed', 'error');
|
||||
}
|
||||
|
||||
function blockFromAttempt(ip) {
|
||||
document.getElementById('block-ip').value = ip;
|
||||
document.getElementById('block-reason').value = 'Manual block from login attempts';
|
||||
openModal('add-block-modal');
|
||||
}
|
||||
|
||||
function fmtDate(s) {
|
||||
if (!s) return '—';
|
||||
try { return new Date(s).toLocaleString(); } catch(e) { return s; }
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
<svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
|
||||
Audit Log
|
||||
</a>
|
||||
<a href="/admin/security" id="nav-security">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>
|
||||
Security
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -35,5 +39,5 @@
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/js/admin.js?v=16"></script>
|
||||
<script src="/static/js/admin.js?v=20"></script>
|
||||
{{end}}
|
||||
@@ -309,5 +309,5 @@
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/js/app.js?v=16"></script>
|
||||
<script src="/static/js/app.js?v=20"></script>
|
||||
{{end}}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{block "title" .}}GoWebMail{{end}}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/gowebmail.css?v=16">
|
||||
<link rel="stylesheet" href="/static/css/gowebmail.css?v=20">
|
||||
{{block "head_extra" .}}{{end}}
|
||||
</head>
|
||||
<body class="{{block "body_class" .}}{{end}}">
|
||||
{{block "body" .}}{{end}}
|
||||
<script src="/static/js/gowebmail.js?v=16"></script>
|
||||
<script src="/static/js/gowebmail.js?v=20"></script>
|
||||
{{block "scripts" .}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user