163 lines
6.2 KiB
Go
163 lines
6.2 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"botpontos/internal/domain"
|
|
)
|
|
|
|
func accountCols(p string) string {
|
|
return p + "id, " + p + "username::text, " + cols(p, "password_hash", "role", "user_id", "failed_login_count",
|
|
"locked_until", "disabled_at", "last_login_at", "created_at")
|
|
}
|
|
|
|
type accountScan struct {
|
|
a domain.WebAccount
|
|
role string
|
|
}
|
|
|
|
func (s *accountScan) dest() []any {
|
|
return []any{&s.a.ID, &s.a.Username, &s.a.PasswordHash, &s.role, &s.a.UserID, &s.a.FailedLoginCount,
|
|
&s.a.LockedUntil, &s.a.DisabledAt, &s.a.LastLoginAt, &s.a.CreatedAt}
|
|
}
|
|
|
|
func (s *accountScan) result() domain.WebAccount {
|
|
a := s.a
|
|
a.Role = domain.Role(s.role)
|
|
return a
|
|
}
|
|
|
|
func readAccount(row scanner) (domain.WebAccount, error) {
|
|
var as accountScan
|
|
if err := row.Scan(as.dest()...); err != nil {
|
|
return domain.WebAccount{}, mapErr(err)
|
|
}
|
|
return as.result(), nil
|
|
}
|
|
|
|
func (s *Store) CreateWebAccount(ctx context.Context, username, passwordHash string, role domain.Role, userID *int64) (domain.WebAccount, error) {
|
|
return readAccount(s.pool.QueryRow(ctx, `
|
|
INSERT INTO web_accounts (username, password_hash, role, user_id)
|
|
VALUES ($1::text::citext, $2, $3::text::web_role, $4)
|
|
RETURNING `+accountCols(""), username, passwordHash, string(role), userID))
|
|
}
|
|
|
|
func (s *Store) GetWebAccount(ctx context.Context, id int64) (domain.WebAccount, error) {
|
|
return readAccount(s.pool.QueryRow(ctx, `SELECT `+accountCols("")+` FROM web_accounts WHERE id = $1`, id))
|
|
}
|
|
|
|
func (s *Store) GetWebAccountByUsername(ctx context.Context, username string) (domain.WebAccount, error) {
|
|
return readAccount(s.pool.QueryRow(ctx, `SELECT `+accountCols("")+` FROM web_accounts WHERE username = $1::text::citext`, username))
|
|
}
|
|
|
|
func (s *Store) ListWebAccounts(ctx context.Context) ([]domain.WebAccount, error) {
|
|
rows, err := s.pool.Query(ctx, `SELECT `+accountCols("")+` FROM web_accounts ORDER BY username`)
|
|
return collect(rows, err, readAccount)
|
|
}
|
|
|
|
// UpdateWebAccount altera papel, situação e/ou vínculo com o usuário do Telegram;
|
|
// nil mantém o valor atual. Em setUser, userID nil remove o vínculo.
|
|
func (s *Store) UpdateWebAccount(ctx context.Context, id int64, role *domain.Role, disabled *bool, setUser bool, userID *int64) error {
|
|
var roleText *string
|
|
if role != nil {
|
|
v := string(*role)
|
|
roleText = &v
|
|
}
|
|
return expectOne(s.pool.Exec(ctx, `
|
|
UPDATE web_accounts SET
|
|
role = COALESCE($2::text::web_role, role),
|
|
disabled_at = CASE
|
|
WHEN $3::boolean IS NULL THEN disabled_at
|
|
WHEN $3::boolean THEN COALESCE(disabled_at, now())
|
|
ELSE NULL
|
|
END,
|
|
user_id = CASE WHEN $4::boolean THEN $5::bigint ELSE user_id END
|
|
WHERE id = $1`, id, roleText, disabled, setUser, userID))
|
|
}
|
|
|
|
func (s *Store) SetWebAccountPassword(ctx context.Context, id int64, passwordHash string) error {
|
|
return expectOne(s.pool.Exec(ctx, `
|
|
UPDATE web_accounts SET password_hash = $2, failed_login_count = 0, locked_until = NULL
|
|
WHERE id = $1`, id, passwordHash))
|
|
}
|
|
|
|
// DeleteWebAccount apaga a conta; as sessões vão junto e a auditoria mantém o nome (migration 00003).
|
|
func (s *Store) DeleteWebAccount(ctx context.Context, id int64) error {
|
|
return expectOne(s.pool.Exec(ctx, `DELETE FROM web_accounts WHERE id = $1`, id))
|
|
}
|
|
|
|
func (s *Store) CountActiveOwners(ctx context.Context) (int, error) {
|
|
var n int
|
|
err := s.pool.QueryRow(ctx, `SELECT count(*) FROM web_accounts WHERE role = 'owner' AND disabled_at IS NULL`).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
// RecordLoginFailure soma uma falha; ao atingir o limite, bloqueia até lockUntil e zera o contador.
|
|
func (s *Store) RecordLoginFailure(ctx context.Context, id int64, maxFailures int, lockUntil time.Time) (locked bool, err error) {
|
|
err = s.pool.QueryRow(ctx, `
|
|
UPDATE web_accounts SET
|
|
failed_login_count = CASE WHEN failed_login_count + 1 >= $2 THEN 0 ELSE failed_login_count + 1 END,
|
|
locked_until = CASE WHEN failed_login_count + 1 >= $2 THEN $3 ELSE locked_until END
|
|
WHERE id = $1
|
|
RETURNING locked_until IS NOT NULL AND locked_until > now()`, id, maxFailures, lockUntil).Scan(&locked)
|
|
return locked, mapErr(err)
|
|
}
|
|
|
|
func (s *Store) RecordLoginSuccess(ctx context.Context, id int64, at time.Time) error {
|
|
return expectOne(s.pool.Exec(ctx, `
|
|
UPDATE web_accounts SET failed_login_count = 0, locked_until = NULL, last_login_at = $2 WHERE id = $1`, id, at))
|
|
}
|
|
|
|
type NewSession struct {
|
|
AccountID int64
|
|
TokenHash []byte
|
|
CSRFTokenHash []byte
|
|
ExpiresAt time.Time
|
|
UserAgent *string
|
|
IP *string
|
|
}
|
|
|
|
func (s *Store) CreateSession(ctx context.Context, n NewSession) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO web_sessions (account_id, token_hash, csrf_token_hash, expires_at, last_seen_at, user_agent, ip)
|
|
VALUES ($1, $2, $3, $4, now(), $5, $6::text::inet)`,
|
|
n.AccountID, n.TokenHash, n.CSRFTokenHash, n.ExpiresAt, n.UserAgent, n.IP)
|
|
return mapErr(err)
|
|
}
|
|
|
|
func (s *Store) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (domain.WebSession, error) {
|
|
var ws domain.WebSession
|
|
var as accountScan
|
|
dest := append([]any{&ws.ID, &ws.AccountID, &ws.CSRFTokenHash, &ws.ExpiresAt, &ws.LastSeenAt, &ws.RevokedAt}, as.dest()...)
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT s.id, s.account_id, s.csrf_token_hash, s.expires_at, s.last_seen_at, s.revoked_at, `+accountCols("a.")+`
|
|
FROM web_sessions s JOIN web_accounts a ON a.id = s.account_id
|
|
WHERE s.token_hash = $1`, tokenHash).Scan(dest...)
|
|
if err != nil {
|
|
return ws, mapErr(err)
|
|
}
|
|
ws.Account = as.result()
|
|
return ws, nil
|
|
}
|
|
|
|
func (s *Store) TouchSession(ctx context.Context, id int64, at time.Time) error {
|
|
_, err := s.pool.Exec(ctx, `UPDATE web_sessions SET last_seen_at = $2 WHERE id = $1`, id, at)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) RevokeSession(ctx context.Context, id int64) error {
|
|
_, err := s.pool.Exec(ctx, `UPDATE web_sessions SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`, id)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) RevokeAccountSessions(ctx context.Context, accountID int64) error {
|
|
_, err := s.pool.Exec(ctx, `UPDATE web_sessions SET revoked_at = now() WHERE account_id = $1 AND revoked_at IS NULL`, accountID)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) DeleteSessionsEndedBefore(ctx context.Context, before time.Time) (int64, error) {
|
|
tag, err := s.pool.Exec(ctx, `DELETE FROM web_sessions WHERE COALESCE(revoked_at, expires_at) < $1`, before)
|
|
return tag.RowsAffected(), err
|
|
}
|