mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 16:46:01 +01:00
Compare commits
4 Commits
948e111cc6
...
68c81ebaed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68c81ebaed | ||
|
|
f122d29282 | ||
|
|
d6e987f66c | ||
|
|
ef85246806 |
243
README.md
243
README.md
@@ -1,110 +1,215 @@
|
|||||||
# GoWebMail
|
# GoWebMail
|
||||||
|
|
||||||
A self-hosted, encrypted web email client written entirely in Go. Supports Gmail and Outlook via OAuth2, plus any standard IMAP/SMTP provider.
|
A self-hosted, multi-user, encrypted web email client written entirely in Go. Supports Gmail and Outlook via OAuth2, plus any standard IMAP/SMTP provider (Fastmail, ProtonMail Bridge, iCloud, etc.).
|
||||||
|
|
||||||
# Notes:
|
> **Notes:**
|
||||||
- work still in progress ( gmail and hotmail email not tested yet, just prepared the app for it)
|
> - Work still in progress (Gmail and Outlook OAuth2 not yet fully tested in production)
|
||||||
- AI is involved in making this work, as I do not have the skill and time to do it on my own
|
> - AI-assisted development — suggestions and contributions very welcome!
|
||||||
- looking for any advice and suggestions to improve it!
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
### Email
|
||||||
- **Unified inbox** — view emails from all connected accounts in one stream
|
- **Unified inbox** — view emails from all connected accounts in one stream
|
||||||
- **Gmail & Outlook OAuth2** — modern, token-based auth (no storing raw passwords for these providers)
|
- **Gmail & Outlook OAuth2** — modern token-based auth (no raw passwords stored for these providers)
|
||||||
- **IMAP/SMTP** — connect any provider (ProtonMail Bridge, Fastmail, iCloud, etc.)
|
- **IMAP/SMTP** — connect any standard provider with username/password credentials
|
||||||
- **AES-256-GCM encryption** — all email content encrypted at rest in SQLite
|
- **Auto-detect mail settings** — MX lookup + common port patterns to pre-fill IMAP/SMTP config
|
||||||
|
- **Send / Reply / Forward / Draft** — full compose workflow with floating draggable compose window
|
||||||
|
- **Attachments** — view inline images, download individual files or all at once
|
||||||
|
- **Forward as attachment** — attach original `.eml` as `message/rfc822`
|
||||||
|
- **Folder navigation** — per-account folder/label browsing with right-click context menu
|
||||||
|
- **Full-text search** — across all accounts and folders locally (no server-side search required)
|
||||||
|
- **Message filtering** — unread only, starred, has attachment, from/to filters
|
||||||
|
- **Bulk operations** — multi-select with Ctrl+click / Shift+range; bulk mark read/delete
|
||||||
|
- **Drag-and-drop** — move messages to folders; attach files in compose
|
||||||
|
- **Starred messages** — virtual folder across all accounts
|
||||||
|
- **EML download** — download raw message as `.eml`
|
||||||
|
- **Raw headers view** — fetches full RFC 822 headers from IMAP on demand
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- **AES-256-GCM encryption** — all email content, credentials and OAuth tokens encrypted at rest in SQLite (field-level, not whole-DB encryption)
|
||||||
- **bcrypt password hashing** — GoWebMail account passwords hashed with cost=12
|
- **bcrypt password hashing** — GoWebMail account passwords hashed with cost=12
|
||||||
- **Send / Reply / Forward** — full compose workflow
|
- **TOTP MFA** — custom implementation, no external library; ±60s window for clock skew tolerance
|
||||||
- **Folder navigation** — per-account folder/label browsing
|
- **Brute-force IP blocking** — auto-blocks IPs after configurable failed login attempts (default: 5 attempts in 30 min → 12h ban); permanent blocks supported
|
||||||
- **Full-text search** — across all accounts locally
|
- **Geo-blocking** — deny or allow-only access by country via ip-api.com (no API key needed); 24h in-memory cache
|
||||||
- **Dark-themed web UI** — clean, keyboard-shortcut-friendly interface
|
- **Per-user IP access rules** — each user configures their own IP allow-list or brute-force bypass list independently of global rules
|
||||||
<img width="1213" height="848" alt="image" src="https://github.com/user-attachments/assets/955eda04-e358-4779-80e7-0a9b299ac110" />
|
- **Security alert emails** — notifies the targeted user when their account is brute-forced; supports STARTTLS, implicit TLS, and plain relay
|
||||||
<img width="1261" height="921" alt="image" src="https://github.com/user-attachments/assets/40ee58e8-6c4b-45c3-974d-98cc8ccc45a5" />
|
- **DNS rebinding protection** — `HostCheckMiddleware` rejects requests with unexpected `Host` headers
|
||||||
<img width="1153" height="907" alt="image" src="https://github.com/user-attachments/assets/ebc92335-f6b7-46ed-b9a2-84512f70e1b2" />
|
- **Security headers** — CSP, X-Frame-Options, Referrer-Policy, Permissions-Policy, X-XSS-Protection on all responses
|
||||||
<img width="551" height="669" alt="image" src="https://github.com/user-attachments/assets/412585c0-434a-4177-ab04-7db69da9d08a" />
|
- **Sandboxed HTML email rendering** — emails rendered in CSP-sandboxed `<iframe>`; external links require confirmation before opening
|
||||||
|
- **Remote image blocking** — images blocked by default with per-sender whitelist
|
||||||
|
- **Styled HTTP error pages** — 403/404/405 served as themed pages matching the app (not plain browser defaults)
|
||||||
|
|
||||||
|
### Admin Panel (`/admin`)
|
||||||
|
- **User management** — create, edit (role, active status, password reset), delete users
|
||||||
|
- **Audit log** — paginated, filterable event log for all security-relevant actions
|
||||||
|
- **Security dashboard** — live blocked IPs table with attacker country, login attempt history per IP, manual block/unblock controls
|
||||||
|
- **App settings** — all runtime configuration editable from the UI; changes are written back to `gowebmail.conf`
|
||||||
|
- **MFA disable** — admin can disable MFA for any locked-out user
|
||||||
|
- **Password reset** — admin can reset any user's password from the web UI
|
||||||
|
|
||||||
|
### User Settings
|
||||||
|
- **Profile** — change username and email address (password confirmation required)
|
||||||
|
- **Password change** — change own password
|
||||||
|
- **TOTP MFA setup** — enable/disable via QR code scan
|
||||||
|
- **Sync interval** — per-user background sync frequency
|
||||||
|
- **Compose popup mode** — toggle floating window vs. browser popup window
|
||||||
|
- **Per-user IP rules** — three modes: `disabled` (global rules apply), `brute_skip` (listed IPs bypass lockout counter), `allow_only` (only listed IPs may log in to this account)
|
||||||
|
|
||||||
|
### UI
|
||||||
|
- **Dark-themed SPA** — clean, responsive vanilla-JS single-page app; no JavaScript framework
|
||||||
|
- **OS / browser notifications** — permission requested once; slide-in toast + OS push notification on new mail
|
||||||
|
- **Folder context menu** — right-click: sync, enable/disable sync, hide, empty trash/spam, move contents, delete
|
||||||
|
- **Compose window** — draggable floating window or browser popup; tag-input for To/CC/BCC; auto-saves draft every 60s
|
||||||
|
|
||||||
|
<img width="1213" height="848" alt="Inbox view" src="https://github.com/user-attachments/assets/955eda04-e358-4779-80e7-0a9b299ac110" />
|
||||||
|
<img width="1261" height="921" alt="Compose" src="https://github.com/user-attachments/assets/40ee58e8-6c4b-45c3-974d-98cc8ccc45a5" />
|
||||||
|
<img width="1153" height="907" alt="Admin Security panel" src="https://github.com/user-attachments/assets/ebc92335-f6b7-46ed-b9a2-84512f70e1b2" />
|
||||||
|
<img width="551" height="669" alt="Settings" src="https://github.com/user-attachments/assets/412585c0-434a-4177-ab04-7db69da9d08a" />
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Option 1: Build executable
|
### Option 1: Build executable
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone / copy the project
|
|
||||||
git clone https://github.com/ghostersk/gowebmail && cd gowebmail
|
git clone https://github.com/ghostersk/gowebmail && cd gowebmail
|
||||||
go build -o gowebmail ./cmd/server
|
go build -o gowebmail ./cmd/server
|
||||||
# if you want smaller exe ( strip down debuginformation):
|
# Smaller binary (strip debug info):
|
||||||
go build -ldflags="-s -w" -o gowebmail ./cmd/server
|
go build -ldflags="-s -w" -o gowebmail ./cmd/server
|
||||||
./gowebmail
|
./gowebmail
|
||||||
```
|
```
|
||||||
|
|
||||||
Visit http://localhost:8080, default login admin/admin, register an account, then connect your email.
|
Visit `http://localhost:8080`. Default login: `admin` / `admin`.
|
||||||
|
|
||||||
### Option 2: Run directly
|
### Option 2: Run directly
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/ghostersk/gowebmail && cd gowebmail
|
git clone https://github.com/ghostersk/gowebmail && cd gowebmail
|
||||||
go run ./cmd/server/main.go
|
go run ./cmd/server/main.go
|
||||||
# check ./data/gowebmail.conf what gets generated on first run if not exists, update as needed.
|
# Check ./data/gowebmail.conf on first run — update as needed, then restart.
|
||||||
# then restart the app
|
|
||||||
```
|
```
|
||||||
### Reset Admin password, MFA
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin CLI Commands
|
||||||
|
|
||||||
|
All commands open the database directly without starting the HTTP server. They require the same environment variables or `data/gowebmail.conf` as the server.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List all admins with MFA status
|
# List all admin accounts with MFA status
|
||||||
./gowebmail --list-admin
|
./gowebmail --list-admin
|
||||||
|
|
||||||
# USERNAME EMAIL MFA
|
# USERNAME EMAIL MFA
|
||||||
# -------- ----- ---
|
# -------- ----- ---
|
||||||
# admin admin@example.com ON
|
# admin admin@example.com ON
|
||||||
|
|
||||||
# Reset an admin's password (min 8 chars)
|
# Reset an admin's password (minimum 8 characters)
|
||||||
./gowebmail --pw admin "NewSecurePass123"
|
./gowebmail --pw admin "NewSecurePass123"
|
||||||
|
|
||||||
# Disable MFA so a locked-out admin can log in again
|
# Disable MFA for a locked-out admin
|
||||||
./gowebmail --mfa-off admin
|
./gowebmail --mfa-off admin
|
||||||
|
|
||||||
|
# List all currently blocked IPs
|
||||||
|
# Shows: IP, username attempted, attempt count, blocked-at, expiry, time remaining
|
||||||
|
./gowebmail --blocklist
|
||||||
|
|
||||||
|
# IP USERNAME USED TRIES BLOCKED AT EXPIRES REMAINING
|
||||||
|
# -- ------------- ----- ---------- ------- ---------
|
||||||
|
# 1.2.3.4 bob 7 2026-03-08 14:22:01 2026-03-09 02:22:01 11h 34m
|
||||||
|
# 5.6.7.8 admin 12 2026-03-07 09:10:00 permanent ∞ (manual unblock)
|
||||||
|
|
||||||
|
# Remove a block immediately
|
||||||
|
./gowebmail --unblock 1.2.3.4
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Note:** `--list-admin`, `--pw`, and `--mfa-off` only work on admin accounts. Regular user management is done through the web UI at `/admin`. `--blocklist` and `--unblock` are particularly useful if you have locked yourself out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
On first run, `data/gowebmail.conf` is auto-generated with all defaults and inline comments. All keys can also be set via environment variables. The Admin → Settings UI can edit and save most values at runtime, writing changes back to `gowebmail.conf`.
|
||||||
|
|
||||||
|
### Core
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|-----|---------|-------------|
|
||||||
|
| `HOSTNAME` | `localhost` | Public hostname for BaseURL construction and Host header validation |
|
||||||
|
| `LISTEN_ADDR` | `:8080` | Bind address |
|
||||||
|
| `SECURE_COOKIE` | `false` | Set `true` when running behind HTTPS |
|
||||||
|
| `TRUSTED_PROXIES` | _(blank)_ | Comma-separated IPs/CIDRs allowed to set `X-Forwarded-For` |
|
||||||
|
| `ENCRYPTION_KEY` | _(auto-generated)_ | AES-256 key — **back this up immediately**; losing it makes the DB unreadable |
|
||||||
|
| `SESSION_MAX_AGE` | `604800` | Session lifetime in seconds (default: 7 days) |
|
||||||
|
|
||||||
|
### Brute Force Protection
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|-----|---------|-------------|
|
||||||
|
| `BRUTE_ENABLED` | `true` | Enable automatic IP blocking on failed logins |
|
||||||
|
| `BRUTE_MAX_ATTEMPTS` | `5` | Failed login attempts before ban triggers |
|
||||||
|
| `BRUTE_WINDOW_MINUTES` | `30` | Rolling window in minutes for counting failures |
|
||||||
|
| `BRUTE_BAN_HOURS` | `12` | Ban duration in hours; `0` = permanent block (manual unblock required) |
|
||||||
|
| `BRUTE_WHITELIST_IPS` | _(blank)_ | Comma-separated IPs never blocked — **add your own IP here** |
|
||||||
|
|
||||||
|
### Geo Blocking
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|-----|---------|-------------|
|
||||||
|
| `GEO_BLOCK_COUNTRIES` | _(blank)_ | ISO country codes to deny outright (e.g. `CN,RU,KP`). Evaluated first — takes priority over allow list. |
|
||||||
|
| `GEO_ALLOW_COUNTRIES` | _(blank)_ | ISO country codes to allow exclusively (e.g. `SK,CZ,DE`). All other countries are denied. |
|
||||||
|
|
||||||
|
Geo lookups use [ip-api.com](http://ip-api.com) (free tier, no API key, ~45 req/min limit). Results are cached in-memory for 24 hours. Private/loopback IPs always bypass geo checks.
|
||||||
|
|
||||||
|
### Security Notification Emails
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|-----|---------|-------------|
|
||||||
|
| `NOTIFY_ENABLED` | `true` | Send alert email to user when a brute-force attack targets their account |
|
||||||
|
| `NOTIFY_SMTP_HOST` | _(blank)_ | SMTP hostname for sending alert emails |
|
||||||
|
| `NOTIFY_SMTP_PORT` | `587` | `465` = implicit TLS · `587` = STARTTLS · `25` = plain relay (no auth) |
|
||||||
|
| `NOTIFY_FROM` | _(blank)_ | Sender address (e.g. `security@yourdomain.com`) |
|
||||||
|
| `NOTIFY_USER` | _(blank)_ | SMTP auth username — leave blank for unauthenticated relay |
|
||||||
|
| `NOTIFY_PASS` | _(blank)_ | SMTP auth password — leave blank for unauthenticated relay |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Setting up OAuth2
|
## Setting up OAuth2
|
||||||
|
|
||||||
### Gmail
|
### Gmail
|
||||||
|
|
||||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/) → New project
|
1. Go to [Google Cloud Console](https://console.cloud.google.com/) → New project
|
||||||
2. Enable **Gmail API**
|
2. Enable **Gmail API**
|
||||||
3. Create **OAuth 2.0 Client ID** (Web application)
|
3. Create **OAuth 2.0 Client ID** (Web application type)
|
||||||
4. Add Authorized redirect URI: `http://localhost:8080/auth/gmail/callback`
|
4. Add Authorized redirect URI: `<BASE_URL>/auth/gmail/callback`
|
||||||
5. Set env vars: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`
|
5. Add scope `https://mail.google.com/` (required for full IMAP access)
|
||||||
|
6. Add test users while the app is in "Testing" mode
|
||||||
> **Important:** In the Google Cloud Console, add the scope `https://mail.google.com/` to allow IMAP access. You'll also need to add test users while in "Testing" mode.
|
7. Set in config: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`
|
||||||
|
|
||||||
### Outlook / Microsoft 365
|
### Outlook / Microsoft 365
|
||||||
|
|
||||||
1. Go to [Azure portal](https://portal.azure.com/) → App registrations → New registration
|
1. Go to [Azure portal](https://portal.azure.com/) → App registrations → New registration
|
||||||
2. Set redirect URI: `http://localhost:8080/auth/outlook/callback`
|
2. Set redirect URI: `<BASE_URL>/auth/outlook/callback`
|
||||||
3. Under API permissions, add:
|
3. Under API permissions add:
|
||||||
- `https://outlook.office.com/IMAP.AccessAsUser.All`
|
- `https://outlook.office.com/IMAP.AccessAsUser.All`
|
||||||
- `https://outlook.office.com/SMTP.Send`
|
- `https://outlook.office.com/SMTP.Send`
|
||||||
- `offline_access`, `openid`, `profile`, `email`
|
- `offline_access`, `openid`, `profile`, `email`
|
||||||
4. Create a Client secret
|
4. Create a Client secret under Certificates & secrets
|
||||||
5. Set env vars: `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_TENANT_ID`
|
5. Set in config: `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_TENANT_ID`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Security Notes
|
## Security Notes
|
||||||
|
|
||||||
- **ENCRYPTION_KEY** is critical — back it up. Without it, the encrypted SQLite database is unreadable.
|
- **`ENCRYPTION_KEY` is critical** — back it up. Without it the encrypted SQLite database is permanently unreadable.
|
||||||
- Email content (subject, from, to, body) is encrypted at rest using AES-256-GCM.
|
- Email content (subject, from, to, body), IMAP/SMTP credentials, and OAuth tokens are all encrypted at rest with AES-256-GCM at the field level.
|
||||||
- OAuth2 tokens are stored encrypted in the database.
|
- GoWebMail user passwords are bcrypt hashed (cost=12). Session tokens are 32-byte `crypto/rand` hex strings.
|
||||||
- Passwords for GoWebMail accounts are bcrypt hashed (cost=12).
|
- All HTTP responses include security headers (CSP, X-Frame-Options, Referrer-Policy, etc.).
|
||||||
- All HTTP responses include security headers (CSP, X-Frame-Options, etc.).
|
- HTML emails render in a CSP-sandboxed `<iframe>` — external links trigger a confirmation dialog before opening in a new tab.
|
||||||
- In production, run behind HTTPS (nginx/Caddy) and set `SECURE_COOKIE=true`.
|
- In production, run behind a reverse proxy with HTTPS (nginx / Caddy) and set `SECURE_COOKIE=true`.
|
||||||
|
- Add your own IP to `BRUTE_WHITELIST_IPS` to avoid ever locking yourself out. If it does happen, use `./gowebmail --unblock <ip>` — no server restart needed.
|
||||||
|
|
||||||
## Dependencies
|
---
|
||||||
|
|
||||||
```
|
|
||||||
github.com/emersion/go-imap IMAP client
|
|
||||||
github.com/emersion/go-smtp SMTP client
|
|
||||||
github.com/emersion/go-message MIME parsing
|
|
||||||
github.com/gorilla/mux HTTP routing
|
|
||||||
github.com/mattn/go-sqlite3 SQLite driver (CGO)
|
|
||||||
golang.org/x/crypto bcrypt
|
|
||||||
golang.org/x/oauth2 OAuth2 + Google/Microsoft endpoints
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building for Production
|
## Building for Production
|
||||||
|
|
||||||
@@ -112,7 +217,41 @@ golang.org/x/oauth2 OAuth2 + Google/Microsoft endpoints
|
|||||||
CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o gowebmail ./cmd/server
|
CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o gowebmail ./cmd/server
|
||||||
```
|
```
|
||||||
|
|
||||||
CGO is required by `go-sqlite3`. Cross-compilation requires a C cross-compiler.
|
CGO is required by `mattn/go-sqlite3`. Cross-compilation for other platforms requires a C cross-compiler (or use `zig cc` as a drop-in).
|
||||||
|
|
||||||
|
### Docker (example)
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM golang:1.22-alpine AS builder
|
||||||
|
RUN apk add gcc musl-dev sqlite-dev
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=1 go build -ldflags="-s -w" -o gowebmail ./cmd/server
|
||||||
|
|
||||||
|
FROM alpine:latest
|
||||||
|
RUN apk add --no-cache sqlite-libs ca-certificates
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/gowebmail .
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["./gowebmail"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
| Package | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `github.com/emersion/go-imap v1.2.1` | IMAP client |
|
||||||
|
| `github.com/emersion/go-smtp` | SMTP client |
|
||||||
|
| `github.com/emersion/go-message` | MIME parsing |
|
||||||
|
| `github.com/gorilla/mux` | HTTP router |
|
||||||
|
| `github.com/mattn/go-sqlite3` | SQLite driver (CGO required) |
|
||||||
|
| `golang.org/x/crypto` | bcrypt |
|
||||||
|
| `golang.org/x/oauth2` | OAuth2 + Google/Microsoft endpoints |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the [GPL-3.0 license](LICENSE).
|
This project is licensed under the [GPL-3.0 license](LICENSE).
|
||||||
@@ -47,6 +47,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
runDisableMFA(args[1])
|
runDisableMFA(args[1])
|
||||||
return
|
return
|
||||||
|
case "--blocklist":
|
||||||
|
runBlockList()
|
||||||
|
return
|
||||||
|
case "--unblock":
|
||||||
|
if len(args) < 2 {
|
||||||
|
fmt.Fprintln(os.Stderr, "Usage: gowebmail --unblock <ip>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
runUnblock(args[1])
|
||||||
|
return
|
||||||
case "--help", "-h":
|
case "--help", "-h":
|
||||||
printHelp()
|
printHelp()
|
||||||
return
|
return
|
||||||
@@ -85,6 +95,14 @@ func main() {
|
|||||||
r.Use(middleware.CORS)
|
r.Use(middleware.CORS)
|
||||||
r.Use(cfg.HostCheckMiddleware)
|
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
|
// Static files
|
||||||
r.PathPrefix("/static/").Handler(
|
r.PathPrefix("/static/").Handler(
|
||||||
http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))),
|
http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))),
|
||||||
@@ -103,7 +121,7 @@ func main() {
|
|||||||
// Public auth routes
|
// Public auth routes
|
||||||
auth := r.PathPrefix("/auth").Subrouter()
|
auth := r.PathPrefix("/auth").Subrouter()
|
||||||
auth.HandleFunc("/login", h.Auth.ShowLogin).Methods("GET")
|
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")
|
auth.HandleFunc("/logout", h.Auth.Logout).Methods("POST")
|
||||||
|
|
||||||
// MFA (session exists but mfa_verified=0)
|
// MFA (session exists but mfa_verified=0)
|
||||||
@@ -133,6 +151,7 @@ func main() {
|
|||||||
adminUI.HandleFunc("/", h.Admin.ShowAdmin).Methods("GET")
|
adminUI.HandleFunc("/", h.Admin.ShowAdmin).Methods("GET")
|
||||||
adminUI.HandleFunc("/settings", h.Admin.ShowAdmin).Methods("GET")
|
adminUI.HandleFunc("/settings", h.Admin.ShowAdmin).Methods("GET")
|
||||||
adminUI.HandleFunc("/audit", h.Admin.ShowAdmin).Methods("GET")
|
adminUI.HandleFunc("/audit", h.Admin.ShowAdmin).Methods("GET")
|
||||||
|
adminUI.HandleFunc("/security", h.Admin.ShowAdmin).Methods("GET")
|
||||||
|
|
||||||
// API
|
// API
|
||||||
api := r.PathPrefix("/api").Subrouter()
|
api := r.PathPrefix("/api").Subrouter()
|
||||||
@@ -141,10 +160,13 @@ func main() {
|
|||||||
|
|
||||||
// Profile / auth
|
// Profile / auth
|
||||||
api.HandleFunc("/me", h.Auth.Me).Methods("GET")
|
api.HandleFunc("/me", h.Auth.Me).Methods("GET")
|
||||||
|
api.HandleFunc("/profile", h.Auth.UpdateProfile).Methods("PUT")
|
||||||
api.HandleFunc("/change-password", h.Auth.ChangePassword).Methods("POST")
|
api.HandleFunc("/change-password", h.Auth.ChangePassword).Methods("POST")
|
||||||
api.HandleFunc("/mfa/setup", h.Auth.MFASetupBegin).Methods("POST")
|
api.HandleFunc("/mfa/setup", h.Auth.MFASetupBegin).Methods("POST")
|
||||||
api.HandleFunc("/mfa/confirm", h.Auth.MFASetupConfirm).Methods("POST")
|
api.HandleFunc("/mfa/confirm", h.Auth.MFASetupConfirm).Methods("POST")
|
||||||
api.HandleFunc("/mfa/disable", h.Auth.MFADisable).Methods("POST")
|
api.HandleFunc("/mfa/disable", h.Auth.MFADisable).Methods("POST")
|
||||||
|
api.HandleFunc("/ip-rules", h.Auth.GetUserIPRule).Methods("GET")
|
||||||
|
api.HandleFunc("/ip-rules", h.Auth.SetUserIPRule).Methods("PUT")
|
||||||
|
|
||||||
// Providers (which OAuth providers are configured)
|
// Providers (which OAuth providers are configured)
|
||||||
api.HandleFunc("/providers", h.API.GetProviders).Methods("GET")
|
api.HandleFunc("/providers", h.API.GetProviders).Methods("GET")
|
||||||
@@ -218,6 +240,19 @@ func main() {
|
|||||||
adminAPI.HandleFunc("/audit", h.Admin.ListAuditLogs).Methods("GET")
|
adminAPI.HandleFunc("/audit", h.Admin.ListAuditLogs).Methods("GET")
|
||||||
adminAPI.HandleFunc("/settings", h.Admin.GetSettings).Methods("GET")
|
adminAPI.HandleFunc("/settings", h.Admin.GetSettings).Methods("GET")
|
||||||
adminAPI.HandleFunc("/settings", h.Admin.SetSettings).Methods("PUT")
|
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{
|
srv := &http.Server{
|
||||||
Addr: cfg.ListenAddr,
|
Addr: cfg.ListenAddr,
|
||||||
@@ -310,6 +345,69 @@ func runDisableMFA(username string) {
|
|||||||
fmt.Printf("MFA disabled for admin '%s'. They can now log in with password only.\n", username)
|
fmt.Printf("MFA disabled for admin '%s'. They can now log in with password only.\n", username)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runBlockList() {
|
||||||
|
database, close := openDB()
|
||||||
|
defer close()
|
||||||
|
|
||||||
|
blocks, err := database.ListIPBlocksWithUsername()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if len(blocks) == 0 {
|
||||||
|
fmt.Println("No blocked IPs.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%-18s %-20s %-5s %-22s %-22s %s\n",
|
||||||
|
"IP", "USERNAME USED", "TRIES", "BLOCKED AT", "EXPIRES", "REMAINING")
|
||||||
|
fmt.Printf("%-18s %-20s %-5s %-22s %-22s %s\n",
|
||||||
|
"--", "-------------", "-----", "----------", "-------", "---------")
|
||||||
|
for _, b := range blocks {
|
||||||
|
blockedAt := b.BlockedAt.UTC().Format("2006-01-02 15:04:05")
|
||||||
|
var expires, remaining string
|
||||||
|
if b.IsPermanent || b.ExpiresAt == nil {
|
||||||
|
expires = "permanent"
|
||||||
|
remaining = "∞ (manual unblock)"
|
||||||
|
} else {
|
||||||
|
expires = b.ExpiresAt.UTC().Format("2006-01-02 15:04:05")
|
||||||
|
left := time.Until(*b.ExpiresAt)
|
||||||
|
if left <= 0 {
|
||||||
|
remaining = "expired (purge pending)"
|
||||||
|
} else {
|
||||||
|
h := int(left.Hours())
|
||||||
|
m := int(left.Minutes()) % 60
|
||||||
|
s := int(left.Seconds()) % 60
|
||||||
|
if h > 0 {
|
||||||
|
remaining = fmt.Sprintf("%dh %dm", h, m)
|
||||||
|
} else if m > 0 {
|
||||||
|
remaining = fmt.Sprintf("%dm %ds", m, s)
|
||||||
|
} else {
|
||||||
|
remaining = fmt.Sprintf("%ds", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
username := b.LastUsername
|
||||||
|
if username == "" {
|
||||||
|
username = "(unknown)"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-18s %-20s %-5d %-22s %-22s %s\n",
|
||||||
|
b.IP, username, b.Attempts, blockedAt, expires, remaining)
|
||||||
|
}
|
||||||
|
fmt.Printf("\nTotal: %d blocked IP(s)\n", len(blocks))
|
||||||
|
}
|
||||||
|
|
||||||
|
func runUnblock(ip string) {
|
||||||
|
database, close := openDB()
|
||||||
|
defer close()
|
||||||
|
|
||||||
|
if err := database.UnblockIP(ip); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error unblocking %s: %v\n", ip, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("IP %s has been unblocked.\n", ip)
|
||||||
|
}
|
||||||
|
|
||||||
func printHelp() {
|
func printHelp() {
|
||||||
fmt.Print(`GoWebMail — Admin CLI
|
fmt.Print(`GoWebMail — Admin CLI
|
||||||
|
|
||||||
@@ -318,13 +416,17 @@ Usage:
|
|||||||
gowebmail --list-admin List all admin accounts (username, email, MFA status)
|
gowebmail --list-admin List all admin accounts (username, email, MFA status)
|
||||||
gowebmail --pw <username> <pass> Reset password for an admin account
|
gowebmail --pw <username> <pass> Reset password for an admin account
|
||||||
gowebmail --mfa-off <username> Disable MFA for an admin account
|
gowebmail --mfa-off <username> Disable MFA for an admin account
|
||||||
|
gowebmail --blocklist List all currently blocked IP addresses
|
||||||
|
gowebmail --unblock <ip> Remove block for a specific IP address
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
./gowebmail --list-admin
|
./gowebmail --list-admin
|
||||||
./gowebmail --pw admin "NewSecurePass123"
|
./gowebmail --pw admin "NewSecurePass123"
|
||||||
./gowebmail --mfa-off admin
|
./gowebmail --mfa-off admin
|
||||||
|
./gowebmail --blocklist
|
||||||
|
./gowebmail --unblock 1.2.3.4
|
||||||
|
|
||||||
Note: These commands only work on admin accounts.
|
Note: --list-admin, --pw, and --mfa-off only work on admin accounts.
|
||||||
Regular user management is done through the web UI.
|
Regular user management is done through the web UI.
|
||||||
Requires the same environment variables as the server (DB_PATH, ENCRYPTION_KEY, etc).
|
Requires the same environment variables as the server (DB_PATH, ENCRYPTION_KEY, etc).
|
||||||
`)
|
`)
|
||||||
|
|||||||
195
config/config.go
195
config/config.go
@@ -28,6 +28,23 @@ type Config struct {
|
|||||||
SessionMaxAge int
|
SessionMaxAge int
|
||||||
TrustedProxies []net.IPNet // CIDR ranges allowed to set X-Forwarded-For/Proto headers
|
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
|
// Storage
|
||||||
DBPath string
|
DBPath string
|
||||||
|
|
||||||
@@ -118,6 +135,108 @@ var allFields = []configField{
|
|||||||
" NOTE: Do not add untrusted IPs — clients could spoof their source address.",
|
" 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",
|
key: "DB_PATH",
|
||||||
defVal: "./data/gowebmail.db",
|
defVal: "./data/gowebmail.db",
|
||||||
@@ -313,6 +432,21 @@ func Load() (*Config, error) {
|
|||||||
SessionMaxAge: atoi(get("SESSION_MAX_AGE"), 604800),
|
SessionMaxAge: atoi(get("SESSION_MAX_AGE"), 604800),
|
||||||
TrustedProxies: trustedProxies,
|
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"),
|
GoogleClientID: get("GOOGLE_CLIENT_ID"),
|
||||||
GoogleClientSecret: get("GOOGLE_CLIENT_SECRET"),
|
GoogleClientSecret: get("GOOGLE_CLIENT_SECRET"),
|
||||||
GoogleRedirectURL: googleRedirect,
|
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.
|
// 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).
|
// Accepts exact match, hostname:port, or any value if hostname is "localhost" (dev mode).
|
||||||
func (c *Config) IsAllowedHost(requestHost string) bool {
|
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 {
|
func mustHex(n int) string {
|
||||||
b := make([]byte, n)
|
b := make([]byte, n)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
|||||||
@@ -199,6 +199,52 @@ func (d *DB) Migrate() error {
|
|||||||
return fmt.Errorf("create pending_imap_ops: %w", err)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-user IP access rules.
|
||||||
|
// mode: "brute_skip" = skip brute force check for this user from listed IPs
|
||||||
|
// "allow_only" = only allow login from listed IPs (all others get 403)
|
||||||
|
if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS user_ip_rules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
mode TEXT NOT NULL DEFAULT 'brute_skip',
|
||||||
|
ip_list TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT (datetime('now')),
|
||||||
|
updated_at DATETIME DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(user_id)
|
||||||
|
)`); err != nil {
|
||||||
|
return fmt.Errorf("create user_ip_rules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Bootstrap admin account if no users exist
|
// Bootstrap admin account if no users exist
|
||||||
return d.bootstrapAdmin()
|
return d.bootstrapAdmin()
|
||||||
}
|
}
|
||||||
@@ -1693,3 +1739,300 @@ func (d *DB) AdminDisableMFAByID(targetUserID int64) error {
|
|||||||
WHERE id=?`, targetUserID)
|
WHERE id=?`, targetUserID)
|
||||||
return err
|
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 expiresAt sql.NullTime
|
||||||
|
rows.Scan(&b.ID, &b.IP, &b.Reason, &b.Country, &b.CountryCode,
|
||||||
|
&b.Attempts, &b.BlockedAt, &expiresAt, &b.IsPermanent)
|
||||||
|
if expiresAt.Valid {
|
||||||
|
t := expiresAt.Time
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Profile Updates ----
|
||||||
|
|
||||||
|
// UpdateUserEmail changes a user's email address. Returns error if already taken.
|
||||||
|
func (d *DB) UpdateUserEmail(userID int64, newEmail string) error {
|
||||||
|
_, err := d.sql.Exec(
|
||||||
|
`UPDATE users SET email=?, updated_at=datetime('now') WHERE id=?`,
|
||||||
|
newEmail, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserUsername changes a user's display username. Returns error if already taken.
|
||||||
|
func (d *DB) UpdateUserUsername(userID int64, newUsername string) error {
|
||||||
|
_, err := d.sql.Exec(
|
||||||
|
`UPDATE users SET username=?, updated_at=datetime('now') WHERE id=?`,
|
||||||
|
newUsername, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Per-User IP Rules ----
|
||||||
|
|
||||||
|
// UserIPRule holds per-user IP access settings.
|
||||||
|
type UserIPRule struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Mode string `json:"mode"` // "brute_skip" | "allow_only" | "disabled"
|
||||||
|
IPList string `json:"ip_list"` // comma-separated IPs
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserIPRule returns the IP rule for a user, or nil if none set.
|
||||||
|
func (d *DB) GetUserIPRule(userID int64) (*UserIPRule, error) {
|
||||||
|
row := d.sql.QueryRow(`SELECT user_id, mode, ip_list FROM user_ip_rules WHERE user_id=?`, userID)
|
||||||
|
r := &UserIPRule{}
|
||||||
|
if err := row.Scan(&r.UserID, &r.Mode, &r.IPList); err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserIPRule upserts the IP rule for a user.
|
||||||
|
func (d *DB) SetUserIPRule(userID int64, mode, ipList string) error {
|
||||||
|
_, err := d.sql.Exec(`
|
||||||
|
INSERT INTO user_ip_rules (user_id, mode, ip_list, updated_at)
|
||||||
|
VALUES (?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
mode=excluded.mode,
|
||||||
|
ip_list=excluded.ip_list,
|
||||||
|
updated_at=datetime('now')`,
|
||||||
|
userID, mode, ipList)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUserIPRule removes IP rules for a user (disables the feature).
|
||||||
|
func (d *DB) DeleteUserIPRule(userID int64) error {
|
||||||
|
_, err := d.sql.Exec(`DELETE FROM user_ip_rules WHERE user_id=?`, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckUserIPAccess evaluates per-user IP rules against a connecting IP.
|
||||||
|
// Returns:
|
||||||
|
// "allow" — rule says allow (brute_skip match or allow_only match)
|
||||||
|
// "deny" — allow_only mode and IP is not in list
|
||||||
|
// "skip_brute" — brute_skip mode and IP is in list (skip brute force check)
|
||||||
|
// "default" — no rule exists, fall through to global rules
|
||||||
|
func (d *DB) CheckUserIPAccess(userID int64, ip string) string {
|
||||||
|
rule, err := d.GetUserIPRule(userID)
|
||||||
|
if err != nil || rule == nil || rule.Mode == "disabled" || rule.IPList == "" {
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
for _, listed := range splitIPs(rule.IPList) {
|
||||||
|
if listed == ip {
|
||||||
|
if rule.Mode == "allow_only" {
|
||||||
|
return "allow"
|
||||||
|
}
|
||||||
|
return "skip_brute"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// IP not in list
|
||||||
|
if rule.Mode == "allow_only" {
|
||||||
|
return "deny"
|
||||||
|
}
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitIPList splits a comma-separated IP string into trimmed, non-empty entries.
|
||||||
|
func SplitIPList(s string) []string {
|
||||||
|
return splitIPs(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitIPs(s string) []string {
|
||||||
|
var result []string
|
||||||
|
for _, p := range strings.Split(s, ",") {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
result = append(result, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPBlockWithUsername extends IPBlock with the last username attempted from that IP.
|
||||||
|
type IPBlockWithUsername struct {
|
||||||
|
IPBlock
|
||||||
|
LastUsername string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIPBlocksWithUsername returns active blocks enriched with the most recent
|
||||||
|
// username that was attempted from each IP (from login_attempts history).
|
||||||
|
func (d *DB) ListIPBlocksWithUsername() ([]IPBlockWithUsername, error) {
|
||||||
|
rows, err := d.sql.Query(`
|
||||||
|
SELECT
|
||||||
|
b.id, b.ip, b.reason, b.country, b.country_code,
|
||||||
|
b.attempts, b.blocked_at, b.expires_at, b.is_permanent,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT username FROM login_attempts
|
||||||
|
WHERE ip=b.ip AND username != ''
|
||||||
|
ORDER BY created_at DESC LIMIT 1),
|
||||||
|
''
|
||||||
|
) AS last_username
|
||||||
|
FROM ip_blocks b
|
||||||
|
WHERE b.is_permanent=1 OR b.expires_at IS NULL OR b.expires_at > datetime('now')
|
||||||
|
ORDER BY b.blocked_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []IPBlockWithUsername
|
||||||
|
for rows.Next() {
|
||||||
|
var b IPBlockWithUsername
|
||||||
|
var expiresAt sql.NullTime
|
||||||
|
err := rows.Scan(
|
||||||
|
&b.ID, &b.IP, &b.Reason, &b.Country, &b.CountryCode,
|
||||||
|
&b.Attempts, &b.BlockedAt, &expiresAt, &b.IsPermanent,
|
||||||
|
&b.LastUsername,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if expiresAt.Valid {
|
||||||
|
t := expiresAt.Time
|
||||||
|
b.ExpiresAt = &t
|
||||||
|
}
|
||||||
|
result = append(result, b)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|||||||
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/config"
|
||||||
"github.com/ghostersk/gowebmail/internal/db"
|
"github.com/ghostersk/gowebmail/internal/db"
|
||||||
|
"github.com/ghostersk/gowebmail/internal/geo"
|
||||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||||
"github.com/ghostersk/gowebmail/internal/models"
|
"github.com/ghostersk/gowebmail/internal/models"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
@@ -225,3 +226,68 @@ func (h *AdminHandler) SetSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"changed": changed,
|
"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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -53,6 +54,17 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-user IP access check — evaluated before password to avoid timing leaks
|
||||||
|
switch h.db.CheckUserIPAccess(user.ID, ip) {
|
||||||
|
case "deny":
|
||||||
|
h.db.WriteAudit(&user.ID, models.AuditLoginFail, "IP not in allow-list: "+ip, ip, ua)
|
||||||
|
http.Redirect(w, r, "/auth/login?error=location_not_authorized", http.StatusFound)
|
||||||
|
return
|
||||||
|
case "skip_brute":
|
||||||
|
// Signal the BruteForceProtect middleware to skip failure counting for this user/IP
|
||||||
|
w.Header().Set("X-Skip-Brute", "1")
|
||||||
|
}
|
||||||
|
|
||||||
if err := crypto.CheckPassword(password, user.PasswordHash); err != nil {
|
if err := crypto.CheckPassword(password, user.PasswordHash); err != nil {
|
||||||
uid := user.ID
|
uid := user.ID
|
||||||
h.db.WriteAudit(&uid, models.AuditLoginFail, "bad password for: "+username, ip, ua)
|
h.db.WriteAudit(&uid, models.AuditLoginFail, "bad password for: "+username, ip, ua)
|
||||||
@@ -403,3 +415,119 @@ func writeJSONError(w http.ResponseWriter, status int, msg string) {
|
|||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Profile Updates ----
|
||||||
|
|
||||||
|
func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
user, err := h.db.GetUserByID(userID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Field string `json:"field"` // "email" | "username"
|
||||||
|
Value string `json:"value"`
|
||||||
|
Password string `json:"password"` // current password required for confirmation
|
||||||
|
}
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
if req.Value == "" {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "value required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Password == "" {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "current password required to confirm profile changes")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := crypto.CheckPassword(req.Password, user.PasswordHash); err != nil {
|
||||||
|
writeJSONError(w, http.StatusForbidden, "incorrect password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.Field {
|
||||||
|
case "email":
|
||||||
|
// Check uniqueness
|
||||||
|
existing, _ := h.db.GetUserByEmail(req.Value)
|
||||||
|
if existing != nil && existing.ID != userID {
|
||||||
|
writeJSONError(w, http.StatusConflict, "email already in use")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.UpdateUserEmail(userID, req.Value); err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "failed to update email")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "username":
|
||||||
|
existing, _ := h.db.GetUserByUsername(req.Value)
|
||||||
|
if existing != nil && existing.ID != userID {
|
||||||
|
writeJSONError(w, http.StatusConflict, "username already in use")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.UpdateUserUsername(userID, req.Value); err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "failed to update username")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "field must be 'email' or 'username'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := middleware.ClientIP(r)
|
||||||
|
h.db.WriteAudit(&userID, models.AuditUserUpdate, "profile update: "+req.Field, ip, r.UserAgent())
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Per-User IP Rules ----
|
||||||
|
|
||||||
|
func (h *AuthHandler) GetUserIPRule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
rule, err := h.db.GetUserIPRule(userID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rule == nil {
|
||||||
|
rule = &db.UserIPRule{UserID: userID, Mode: "disabled", IPList: ""}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) SetUserIPRule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r)
|
||||||
|
var req struct {
|
||||||
|
Mode string `json:"mode"` // "disabled" | "brute_skip" | "allow_only"
|
||||||
|
IPList string `json:"ip_list"` // comma-separated
|
||||||
|
}
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
validModes := map[string]bool{"disabled": true, "brute_skip": true, "allow_only": true}
|
||||||
|
if !validModes[req.Mode] {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "mode must be disabled, brute_skip, or allow_only")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate IPs
|
||||||
|
for _, rawIP := range db.SplitIPList(req.IPList) {
|
||||||
|
if net.ParseIP(rawIP) == nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "invalid IP address: "+rawIP)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Mode == "disabled" {
|
||||||
|
h.db.DeleteUserIPRule(userID)
|
||||||
|
} else {
|
||||||
|
if err := h.db.SetUserIPRule(userID, req.Mode, req.IPList); err != nil {
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "failed to save rule")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := middleware.ClientIP(r)
|
||||||
|
h.db.WriteAudit(&userID, models.AuditUserUpdate, "IP rule updated: "+req.Mode, ip, r.UserAgent())
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,7 +13,9 @@ import (
|
|||||||
|
|
||||||
"github.com/ghostersk/gowebmail/config"
|
"github.com/ghostersk/gowebmail/config"
|
||||||
"github.com/ghostersk/gowebmail/internal/db"
|
"github.com/ghostersk/gowebmail/internal/db"
|
||||||
|
"github.com/ghostersk/gowebmail/internal/geo"
|
||||||
"github.com/ghostersk/gowebmail/internal/models"
|
"github.com/ghostersk/gowebmail/internal/models"
|
||||||
|
"github.com/ghostersk/gowebmail/internal/notify"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
@@ -117,9 +121,13 @@ func RequireAdmin(next http.Handler) http.Handler {
|
|||||||
role, _ := r.Context().Value(UserRoleKey).(models.UserRole)
|
role, _ := r.Context().Value(UserRoleKey).(models.UserRole)
|
||||||
if role != models.RoleAdmin {
|
if role != models.RoleAdmin {
|
||||||
if isAPIPath(r) {
|
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 {
|
} 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
|
return
|
||||||
}
|
}
|
||||||
@@ -169,3 +177,216 @@ func ClientIP(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
return r.RemoteAddr
|
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 && !rw.skipBrute {
|
||||||
|
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 and skip-brute signal from the login handler.
|
||||||
|
type loginResponseCapture struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
location string
|
||||||
|
skipBrute bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lrc *loginResponseCapture) WriteHeader(code int) {
|
||||||
|
lrc.statusCode = code
|
||||||
|
lrc.location = lrc.ResponseWriter.Header().Get("Location")
|
||||||
|
if lrc.Header().Get("X-Skip-Brute") == "1" {
|
||||||
|
lrc.skipBrute = true
|
||||||
|
lrc.Header().Del("X-Skip-Brute") // strip before sending to client
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ const adminRoutes = {
|
|||||||
'/admin': renderUsers,
|
'/admin': renderUsers,
|
||||||
'/admin/settings': renderSettings,
|
'/admin/settings': renderSettings,
|
||||||
'/admin/audit': renderAudit,
|
'/admin/audit': renderAudit,
|
||||||
|
'/admin/security': renderSecurity,
|
||||||
};
|
};
|
||||||
|
|
||||||
function navigate(path) {
|
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' },
|
{ 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() {
|
async function renderSettings() {
|
||||||
@@ -329,3 +358,134 @@ function eventBadge(evt) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
// ============================================================
|
||||||
|
// 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,'"');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1379,6 +1379,28 @@ async function openSettings() {
|
|||||||
openModal('settings-modal');
|
openModal('settings-modal');
|
||||||
loadSyncInterval();
|
loadSyncInterval();
|
||||||
renderMFAPanel();
|
renderMFAPanel();
|
||||||
|
loadIPRules();
|
||||||
|
// Pre-fill profile fields with current values
|
||||||
|
const me = await api('GET', '/me');
|
||||||
|
if (me) {
|
||||||
|
document.getElementById('profile-username').placeholder = me.username || 'New username';
|
||||||
|
document.getElementById('profile-email').placeholder = me.email || 'New email';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProfile(field) {
|
||||||
|
const value = document.getElementById('profile-' + field).value.trim();
|
||||||
|
const password = document.getElementById('profile-confirm-pw').value;
|
||||||
|
if (!value) { toast('Please enter a new ' + field, 'error'); return; }
|
||||||
|
if (!password) { toast('Current password required to confirm changes', 'error'); return; }
|
||||||
|
const r = await api('PUT', '/profile', { field, value, password });
|
||||||
|
if (r?.ok) {
|
||||||
|
toast(field.charAt(0).toUpperCase() + field.slice(1) + ' updated', 'success');
|
||||||
|
document.getElementById('profile-' + field).value = '';
|
||||||
|
document.getElementById('profile-confirm-pw').value = '';
|
||||||
|
} else {
|
||||||
|
toast(r?.error || 'Update failed', 'error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSyncInterval() {
|
async function loadSyncInterval() {
|
||||||
@@ -1432,6 +1454,39 @@ async function disableMFA() {
|
|||||||
if(r?.ok){toast('MFA disabled','success');renderMFAPanel();}else toast(r?.error||'Invalid code','error');
|
if(r?.ok){toast('MFA disabled','success');renderMFAPanel();}else toast(r?.error||'Invalid code','error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadIPRules() {
|
||||||
|
const r = await api('GET', '/ip-rules');
|
||||||
|
if (!r) return;
|
||||||
|
document.getElementById('ip-rule-mode').value = r.mode || 'disabled';
|
||||||
|
document.getElementById('ip-rule-list').value = r.ip_list || '';
|
||||||
|
toggleIPRuleHelp();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleIPRuleHelp() {
|
||||||
|
const mode = document.getElementById('ip-rule-mode').value;
|
||||||
|
const helpEl = document.getElementById('ip-rule-help');
|
||||||
|
const listField = document.getElementById('ip-rule-list-field');
|
||||||
|
const helps = {
|
||||||
|
disabled: '',
|
||||||
|
brute_skip: 'IPs in the list below will never be locked out of your account, even after many failed attempts. All other IPs are subject to global brute-force protection.',
|
||||||
|
allow_only: '⚠ Only IPs in the list below will be able to log into your account. All other IPs will see an "Access not authorized" error. Make sure to include your current IP before saving.',
|
||||||
|
};
|
||||||
|
helpEl.textContent = helps[mode] || '';
|
||||||
|
helpEl.style.display = mode !== 'disabled' ? 'block' : 'none';
|
||||||
|
listField.style.display = mode !== 'disabled' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveIPRules() {
|
||||||
|
const mode = document.getElementById('ip-rule-mode').value;
|
||||||
|
const ip_list = document.getElementById('ip-rule-list').value.trim();
|
||||||
|
if (mode !== 'disabled' && !ip_list) {
|
||||||
|
toast('Please enter at least one IP address', 'error'); return;
|
||||||
|
}
|
||||||
|
const r = await api('PUT', '/ip-rules', { mode, ip_list });
|
||||||
|
if (r?.ok) toast('IP rules saved', 'success');
|
||||||
|
else toast(r?.error || 'Save failed', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
async function doLogout() { await fetch('/auth/logout',{method:'POST'}); location.href='/auth/login'; }
|
async function doLogout() { await fetch('/auth/logout',{method:'POST'}); location.href='/auth/login'; }
|
||||||
|
|
||||||
// ── Context menu helper ────────────────────────────────────────────────────
|
// ── Context menu helper ────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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>
|
<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
|
Audit Log
|
||||||
</a>
|
</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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -35,5 +39,5 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
<script src="/static/js/admin.js?v=16"></script>
|
<script src="/static/js/admin.js?v=23"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -264,12 +264,34 @@
|
|||||||
|
|
||||||
<!-- ── Settings Modal ─────────────────────────────────────────────────────── -->
|
<!-- ── Settings Modal ─────────────────────────────────────────────────────── -->
|
||||||
<div class="modal-overlay" id="settings-modal">
|
<div class="modal-overlay" id="settings-modal">
|
||||||
<div class="modal" style="width:520px">
|
<div class="modal" style="width:540px;max-height:90vh;overflow-y:auto">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:22px">
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:22px">
|
||||||
<h2 style="margin-bottom:0">Settings</h2>
|
<h2 style="margin-bottom:0">Settings</h2>
|
||||||
<button onclick="closeModal('settings-modal')" class="icon-btn"><svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
|
<button onclick="closeModal('settings-modal')" class="icon-btn"><svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Profile</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>Username</label>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<input type="text" id="profile-username" placeholder="New username" style="flex:1">
|
||||||
|
<button class="btn-primary" onclick="updateProfile('username')">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>Email Address</label>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<input type="email" id="profile-email" placeholder="New email address" style="flex:1">
|
||||||
|
<button class="btn-primary" onclick="updateProfile('email')">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>Current Password <span style="color:var(--muted);font-size:11px">(required to confirm changes)</span></label>
|
||||||
|
<input type="password" id="profile-confirm-pw" placeholder="Enter your current password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<div class="settings-group-title">Email Sync</div>
|
<div class="settings-group-title">Email Sync</div>
|
||||||
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">How often to automatically check all your accounts for new mail.</div>
|
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">How often to automatically check all your accounts for new mail.</div>
|
||||||
@@ -300,6 +322,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="mfa-panel">Loading...</div>
|
<div id="mfa-panel">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">IP Access Rules</div>
|
||||||
|
<div style="font-size:13px;color:var(--muted);margin-bottom:14px">
|
||||||
|
Control which IP addresses can access your account. This overrides global brute-force settings for your account only.
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>Mode</label>
|
||||||
|
<select id="ip-rule-mode" onchange="toggleIPRuleHelp()" style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none">
|
||||||
|
<option value="disabled">Disabled — use global settings</option>
|
||||||
|
<option value="brute_skip">Skip brute-force check — listed IPs bypass lockout</option>
|
||||||
|
<option value="allow_only">Allow only — only listed IPs can log in</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="ip-rule-help" style="font-size:12px;color:var(--muted);margin-bottom:10px;display:none"></div>
|
||||||
|
<div class="modal-field" id="ip-rule-list-field">
|
||||||
|
<label>Allowed IPs <span style="color:var(--muted);font-size:11px">(comma-separated)</span></label>
|
||||||
|
<input type="text" id="ip-rule-list" placeholder="e.g. 192.168.1.10, 10.0.0.5">
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" onclick="saveIPRules()">Save IP Rules</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -309,5 +352,5 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
<script src="/static/js/app.js?v=16"></script>
|
<script src="/static/js/app.js?v=23"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{block "title" .}}GoWebMail{{end}}</title>
|
<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 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=23">
|
||||||
{{block "head_extra" .}}{{end}}
|
{{block "head_extra" .}}{{end}}
|
||||||
</head>
|
</head>
|
||||||
<body class="{{block "body_class" .}}{{end}}">
|
<body class="{{block "body_class" .}}{{end}}">
|
||||||
{{block "body" .}}{{end}}
|
{{block "body" .}}{{end}}
|
||||||
<script src="/static/js/gowebmail.js?v=16"></script>
|
<script src="/static/js/gowebmail.js?v=23"></script>
|
||||||
{{block "scripts" .}}{{end}}
|
{{block "scripts" .}}{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
<script>
|
<script>
|
||||||
const msgs={invalid_credentials:'Invalid username or password.',missing_fields:'Please fill in all fields.'};
|
const msgs={invalid_credentials:'Invalid username or password.',missing_fields:'Please fill in all fields.',location_not_authorized:'Access from your current location is not permitted for this account.'};
|
||||||
const k=new URLSearchParams(location.search).get('error');
|
const k=new URLSearchParams(location.search).get('error');
|
||||||
if(k){const b=document.getElementById('err');b.textContent=msgs[k]||'An error occurred.';b.style.display='block';}
|
if(k){const b=document.getElementById('err');b.textContent=msgs[k]||'An error occurred.';b.style.display='block';}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user