Files
mailgoserver/internal/db/crud_mailbox_app_passwords.go
T

125 lines
4.0 KiB
Go

package db
import (
"crypto/rand"
"database/sql"
"math/big"
"time"
)
const appPasswordChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
// GenerateAppPassword returns a random secret for IMAP/SMTP client login — the only
// credential those protocols ever see, since AUTH has no interactive MFA step (see
// esrv_mailbox_app_passwords in schema.go). Floors at 25 chars regardless of minLen.
func GenerateAppPassword(minLen int) string {
if minLen < 25 {
minLen = 25
}
b := make([]byte, minLen)
max := big.NewInt(int64(len(appPasswordChars)))
for i := range b {
n, _ := rand.Int(rand.Reader, max)
b[i] = appPasswordChars[n.Int64()]
}
return string(b)
}
func (d *DB) ListAppPasswordsForMailbox(mailboxID int64) ([]MailboxAppPassword, error) {
rows, err := d.Query(`SELECT id, mailbox_id, label, password_hash, is_active, created_at, last_used_at, expires_at
FROM esrv_mailbox_app_passwords WHERE mailbox_id = ? ORDER BY created_at`, mailboxID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []MailboxAppPassword
for rows.Next() {
var p MailboxAppPassword
var createdAt string
var lastUsedAt, expiresAt sql.NullString
if err := rows.Scan(&p.ID, &p.MailboxID, &p.Label, &p.PasswordHash, &p.IsActive, &createdAt, &lastUsedAt, &expiresAt); err != nil {
return nil, err
}
p.CreatedAt, _ = parseTime(createdAt)
if lastUsedAt.Valid {
t, _ := parseTime(lastUsedAt.String)
p.LastUsedAt = &t
}
if expiresAt.Valid {
t, _ := parseTime(expiresAt.String)
p.ExpiresAt = &t
}
out = append(out, p)
}
return out, rows.Err()
}
// CreateAppPassword inserts a new app password. expiresAt is nil for one that never
// expires (the default).
func (d *DB) CreateAppPassword(mailboxID int64, label, passwordHash string, expiresAt *time.Time) (int64, error) {
res, err := d.Exec(`INSERT INTO esrv_mailbox_app_passwords (mailbox_id, label, password_hash, expires_at) VALUES (?, ?, ?, ?)`,
mailboxID, label, passwordHash, expiresAt)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// VerifyMailboxAppPassword resolves the mailbox by its primary email (never an alias)
// and bcrypt-checks it against every active app password. A mailbox's app-password
// list is small, so a linear scan needs no index. Returns (nil, nil) on no match.
func (d *DB) VerifyMailboxAppPassword(email, password string) (*Mailbox, error) {
mbox, err := d.GetMailboxByEmail(email)
if err != nil || mbox == nil {
return nil, err
}
rows, err := d.Query(`SELECT id, password_hash FROM esrv_mailbox_app_passwords
WHERE mailbox_id = ? AND is_active = 1 AND (expires_at IS NULL OR expires_at > ?)`, mbox.ID, time.Now())
if err != nil {
return nil, err
}
var matchedID int64
found := false
for rows.Next() {
var id int64
var hash string
if err := rows.Scan(&id, &hash); err != nil {
rows.Close()
return nil, err
}
if CheckPassword(password, hash) {
matchedID = id
found = true
break
}
}
rowsErr := rows.Err()
// Must close before the UPDATE below: the connection pool is capped to one
// connection (see schema.go's Open), so an Exec while these rows are still open
// would deadlock waiting for a connection that rows itself is holding.
rows.Close()
if rowsErr != nil {
return nil, rowsErr
}
if !found {
return nil, nil
}
if _, err := d.Exec(`UPDATE esrv_mailbox_app_passwords SET last_used_at = ? WHERE id = ?`, time.Now(), matchedID); err != nil {
return nil, err
}
return mbox, nil
}
func (d *DB) SetAppPasswordActive(id int64, active bool) error {
_, err := d.Exec(`UPDATE esrv_mailbox_app_passwords SET is_active = ? WHERE id = ?`, active, id)
return err
}
// RemoveAppPassword deletes an app password, scoped to mailboxID so a caller can't
// remove one belonging to a different mailbox by guessing/manipulating its id —
// mirrors DeleteWebAuthnCredential's (id, ownerID) pattern.
func (d *DB) RemoveAppPassword(id, mailboxID int64) error {
_, err := d.Exec(`DELETE FROM esrv_mailbox_app_passwords WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
return err
}