mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-06-15 15:09:39 +01:00
update MFA, add parameters to reset admin pw,mfa if locked out
This commit is contained in:
@@ -44,7 +44,22 @@ go run ./cmd/server/main.go
|
|||||||
# check ./data/gomail.conf what gets generated on first run if not exists, update as needed.
|
# check ./data/gomail.conf what gets generated on first run if not exists, update as needed.
|
||||||
# then restart the app
|
# then restart the app
|
||||||
```
|
```
|
||||||
|
### Reset Admin password, MFA
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all admins with MFA status
|
||||||
|
./gowebmail --list-admin
|
||||||
|
|
||||||
|
# USERNAME EMAIL MFA
|
||||||
|
# -------- ----- ---
|
||||||
|
# admin admin@example.com ON
|
||||||
|
|
||||||
|
# Reset an admin's password (min 8 chars)
|
||||||
|
./gowebmail --pw admin "NewSecurePass123"
|
||||||
|
|
||||||
|
# Disable MFA so a locked-out admin can log in again
|
||||||
|
./gowebmail --mfa-off admin
|
||||||
|
```
|
||||||
## Setting up OAuth2
|
## Setting up OAuth2
|
||||||
|
|
||||||
### Gmail
|
### Gmail
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -19,6 +20,38 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// ── CLI admin commands (run without starting the HTTP server) ──────────
|
||||||
|
// Usage:
|
||||||
|
// ./gomail --list-admin list all admin usernames
|
||||||
|
// ./gomail --pw <username> <pass> reset an admin's password
|
||||||
|
// ./gomail --mfa-off <username> disable MFA for an admin
|
||||||
|
args := os.Args[1:]
|
||||||
|
if len(args) > 0 {
|
||||||
|
switch args[0] {
|
||||||
|
case "--list-admin":
|
||||||
|
runListAdmins()
|
||||||
|
return
|
||||||
|
case "--pw":
|
||||||
|
if len(args) < 3 {
|
||||||
|
fmt.Fprintln(os.Stderr, "Usage: gomail --pw <username> \"<password>\"")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
runResetPassword(args[1], args[2])
|
||||||
|
return
|
||||||
|
case "--mfa-off":
|
||||||
|
if len(args) < 2 {
|
||||||
|
fmt.Fprintln(os.Stderr, "Usage: gomail --mfa-off <username>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
runDisableMFA(args[1])
|
||||||
|
return
|
||||||
|
case "--help", "-h":
|
||||||
|
printHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Normal server startup ──────────────────────────────────────────────
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("config load: %v", err)
|
log.Fatalf("config load: %v", err)
|
||||||
@@ -185,3 +218,90 @@ func main() {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
srv.Shutdown(ctx)
|
srv.Shutdown(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CLI helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func openDB() (*db.DB, func()) {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
database, err := db.New(cfg.DBPath, cfg.EncryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error opening database: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return database, func() { database.Close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func runListAdmins() {
|
||||||
|
database, close := openDB()
|
||||||
|
defer close()
|
||||||
|
|
||||||
|
admins, err := database.AdminListAdmins()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if len(admins) == 0 {
|
||||||
|
fmt.Println("No admin accounts found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("%-24s %-36s %s\n", "USERNAME", "EMAIL", "MFA")
|
||||||
|
fmt.Printf("%-24s %-36s %s\n", "--------", "-----", "---")
|
||||||
|
for _, a := range admins {
|
||||||
|
mfaStatus := "off"
|
||||||
|
if a.MFAEnabled {
|
||||||
|
mfaStatus = "ON"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-24s %-36s %s\n", a.Username, a.Email, mfaStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runResetPassword(username, password string) {
|
||||||
|
if len(password) < 8 {
|
||||||
|
fmt.Fprintln(os.Stderr, "Error: password must be at least 8 characters")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
database, close := openDB()
|
||||||
|
defer close()
|
||||||
|
|
||||||
|
if err := database.AdminResetPassword(username, password); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Password updated for admin '%s'.\n", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDisableMFA(username string) {
|
||||||
|
database, close := openDB()
|
||||||
|
defer close()
|
||||||
|
|
||||||
|
if err := database.AdminDisableMFA(username); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("MFA disabled for admin '%s'. They can now log in with password only.\n", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printHelp() {
|
||||||
|
fmt.Print(`GoMail — Admin CLI
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
gomail Start the mail server
|
||||||
|
gomail --list-admin List all admin accounts (username, email, MFA status)
|
||||||
|
gomail --pw <username> <pass> Reset password for an admin account
|
||||||
|
gomail --mfa-off <username> Disable MFA for an admin account
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
./gomail --list-admin
|
||||||
|
./gomail --pw admin "NewSecurePass123"
|
||||||
|
./gomail --mfa-off admin
|
||||||
|
|
||||||
|
Note: These commands only work on admin accounts.
|
||||||
|
Regular user management is done through the web UI.
|
||||||
|
Requires the same environment variables as the server (DB_PATH, ENCRYPTION_KEY, etc).
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -308,6 +308,63 @@ func (d *DB) UpdateUserPassword(userID int64, newPassword string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminListAdmins returns (username, email, mfa_enabled) for all admin-role users.
|
||||||
|
func (d *DB) AdminListAdmins() ([]struct {
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
MFAEnabled bool
|
||||||
|
}, error) {
|
||||||
|
rows, err := d.sql.Query(`SELECT username, email, mfa_enabled FROM users WHERE role='admin' ORDER BY username`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []struct {
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
MFAEnabled bool
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var r struct {
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
MFAEnabled bool
|
||||||
|
}
|
||||||
|
rows.Scan(&r.Username, &r.Email, &r.MFAEnabled)
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminResetPassword sets a new password for an admin user by username (admin-only check).
|
||||||
|
func (d *DB) AdminResetPassword(username, newPassword string) error {
|
||||||
|
// Verify user exists and is admin
|
||||||
|
var id int64
|
||||||
|
var role string
|
||||||
|
err := d.sql.QueryRow(`SELECT id, role FROM users WHERE username=?`, username).Scan(&id, &role)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return fmt.Errorf("user %q not found", username)
|
||||||
|
}
|
||||||
|
if role != "admin" {
|
||||||
|
return fmt.Errorf("user %q is not an admin (use the web UI for regular users)", username)
|
||||||
|
}
|
||||||
|
return d.UpdateUserPassword(id, newPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDisableMFA disables MFA for an admin user by username (admin-only check).
|
||||||
|
func (d *DB) AdminDisableMFA(username string) error {
|
||||||
|
var id int64
|
||||||
|
var role string
|
||||||
|
err := d.sql.QueryRow(`SELECT id, role FROM users WHERE username=?`, username).Scan(&id, &role)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return fmt.Errorf("user %q not found", username)
|
||||||
|
}
|
||||||
|
if role != "admin" {
|
||||||
|
return fmt.Errorf("user %q is not an admin (use the web UI for regular users)", username)
|
||||||
|
}
|
||||||
|
return d.DisableMFA(id)
|
||||||
|
}
|
||||||
|
|
||||||
func (d *DB) SetUserActive(userID int64, active bool) error {
|
func (d *DB) SetUserActive(userID int64, active bool) error {
|
||||||
v := 0
|
v := 0
|
||||||
if active {
|
if active {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"encoding/base32"
|
"encoding/base32"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -19,7 +20,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
totpDigits = 6
|
totpDigits = 6
|
||||||
totpPeriod = 30 // seconds
|
totpPeriod = 30 // seconds
|
||||||
totpWindow = 1 // accept ±1 period to allow for clock skew
|
totpWindow = 2 // accept ±2 periods (±60s) to handle clock skew and slow input
|
||||||
)
|
)
|
||||||
|
|
||||||
// GenerateSecret creates a new random 20-byte (160-bit) TOTP secret,
|
// GenerateSecret creates a new random 20-byte (160-bit) TOTP secret,
|
||||||
@@ -58,15 +59,19 @@ func QRCodeURL(issuer, accountName, secret string) string {
|
|||||||
|
|
||||||
// Validate checks whether code is a valid TOTP code for secret at the current time.
|
// Validate checks whether code is a valid TOTP code for secret at the current time.
|
||||||
// It accepts codes from [now-window*period, now+window*period] to handle clock skew.
|
// It accepts codes from [now-window*period, now+window*period] to handle clock skew.
|
||||||
|
// Handles both padded and unpadded base32 secrets.
|
||||||
func Validate(secret, code string) bool {
|
func Validate(secret, code string) bool {
|
||||||
code = strings.TrimSpace(code)
|
code = strings.TrimSpace(code)
|
||||||
if len(code) != totpDigits {
|
if len(code) != totpDigits {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
keyBytes, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(
|
// Normalise: uppercase, strip spaces and padding, then re-decode.
|
||||||
strings.ToUpper(secret),
|
// Accept both padded (JBSWY3DP====) and unpadded (JBSWY3DP) base32.
|
||||||
)
|
cleaned := strings.ToUpper(strings.ReplaceAll(secret, " ", ""))
|
||||||
|
cleaned = strings.TrimRight(cleaned, "=")
|
||||||
|
keyBytes, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(cleaned)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("mfa: base32 decode error (secret len=%d): %v", len(secret), err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
|
|||||||
Reference in New Issue
Block a user