125 lines
4.0 KiB
Go
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
|
|
}
|