Files
bot-pontos/backend/internal/app/webauth.go
2026-10-30 23:25:38 -03:00

320 lines
9.9 KiB
Go

package app
import (
"context"
"errors"
"net/netip"
"regexp"
"strings"
"time"
"botpontos/internal/adapters/crypto"
"botpontos/internal/adapters/postgres"
"botpontos/internal/domain"
)
type LoginResult struct {
Token string
CSRFToken string
ExpiresAt time.Time
Account domain.WebAccount
}
// Login valida usuário e senha (Argon2id), aplica o bloqueio por tentativas e cria a sessão.
func (s *Service) Login(ctx context.Context, username, password, ip, userAgent string) (LoginResult, error) {
now := s.now()
username = strings.TrimSpace(username)
account, err := s.store.GetWebAccountByUsername(ctx, username)
if errors.Is(err, domain.ErrNotFound) {
crypto.DummyVerify(password)
s.audit(ctx, Actor{Source: domain.SourceWeb}, nil, domain.EventWebLoginFailed, false,
map[string]any{"username": username, "reason": "usuário inexistente", "ip": ip})
return LoginResult{}, domain.ErrInvalidLogin
}
if err != nil {
return LoginResult{}, err
}
actor := WebActor(account.ID)
if account.IsDisabled() {
crypto.DummyVerify(password)
s.audit(ctx, actor, nil, domain.EventWebLoginFailed, false, map[string]any{"reason": "conta desativada", "ip": ip})
return LoginResult{}, domain.ErrInvalidLogin
}
if account.IsLocked(now) {
s.audit(ctx, actor, nil, domain.EventWebLoginFailed, false, map[string]any{"reason": "conta bloqueada", "ip": ip})
return LoginResult{}, domain.ErrAccountLocked
}
if !crypto.VerifyPassword(account.PasswordHash, password) {
locked, err := s.store.RecordLoginFailure(ctx, account.ID, s.cfg.Web.LoginMaxFailures, now.Add(s.cfg.Web.LoginLock))
if err != nil {
return LoginResult{}, err
}
s.audit(ctx, actor, nil, domain.EventWebLoginFailed, false, map[string]any{"reason": "senha incorreta", "locked": locked, "ip": ip})
if locked {
return LoginResult{}, domain.ErrAccountLocked
}
return LoginResult{}, domain.ErrInvalidLogin
}
if err := s.store.RecordLoginSuccess(ctx, account.ID, now); err != nil {
return LoginResult{}, err
}
token, tokenHash, err := crypto.NewToken()
if err != nil {
return LoginResult{}, err
}
csrf, csrfHash, err := crypto.NewToken()
if err != nil {
return LoginResult{}, err
}
expires := now.Add(s.cfg.Web.SessionDuration)
if err := s.store.CreateSession(ctx, postgres.NewSession{
AccountID: account.ID,
TokenHash: tokenHash,
CSRFTokenHash: csrfHash,
ExpiresAt: expires,
UserAgent: limitPtr(userAgent, 300),
IP: ipPtr(ip),
}); err != nil {
return LoginResult{}, err
}
s.audit(ctx, actor, nil, domain.EventWebLogin, true, map[string]any{"ip": ip})
account.PasswordHash = ""
return LoginResult{Token: token, CSRFToken: csrf, ExpiresAt: expires, Account: account}, nil
}
// Authenticate valida o token de sessão: revogação, expiração absoluta e inatividade.
func (s *Service) Authenticate(ctx context.Context, token string) (domain.WebSession, error) {
if !crypto.ValidTokenFormat(token) {
return domain.WebSession{}, domain.ErrUnauthenticated
}
session, err := s.store.GetSessionByTokenHash(ctx, crypto.HashToken(token))
if errors.Is(err, domain.ErrNotFound) {
return session, domain.ErrUnauthenticated
}
if err != nil {
return session, err
}
now := s.now()
switch {
case session.RevokedAt != nil, now.After(session.ExpiresAt), session.Account.IsDisabled():
return session, domain.ErrUnauthenticated
case now.Sub(session.LastSeenAt) > s.cfg.Web.SessionIdle:
_ = s.store.RevokeSession(ctx, session.ID)
return session, domain.ErrUnauthenticated
}
if now.Sub(session.LastSeenAt) > time.Minute {
if err := s.store.TouchSession(ctx, session.ID, now); err != nil {
s.log.Warn("falha ao atualizar sessão", "error", err)
}
}
session.Account.PasswordHash = ""
return session, nil
}
func (s *Service) CheckCSRF(session domain.WebSession, token string) bool {
return token != "" && crypto.TokenMatches(token, session.CSRFTokenHash)
}
func (s *Service) Logout(ctx context.Context, session domain.WebSession) error {
if err := s.store.RevokeSession(ctx, session.ID); err != nil {
return err
}
s.audit(ctx, WebActor(session.AccountID), nil, domain.EventWebLogout, true, nil)
return nil
}
var usernamePattern = regexp.MustCompile(`^[a-zA-Z0-9._-]{3,50}$`)
type AccountInput struct {
Username string
Password string
Role domain.Role
UserID *int64
}
func (s *Service) CreateAccount(ctx context.Context, actor Actor, in AccountInput) (domain.WebAccount, error) {
var errs domain.ValidationErrors
in.Username = strings.TrimSpace(in.Username)
if !usernamePattern.MatchString(in.Username) {
errs.Add("username", "use de 3 a 50 caracteres: letras, números, ponto, hífen ou sublinhado")
}
if !in.Role.Valid() {
errs.Add("role", "use owner, admin ou viewer")
}
if err := crypto.ValidatePassword(in.Password); err != nil {
errs.Add("password", err.Error())
}
if in.UserID != nil {
if err := s.checkAccountUser(ctx, 0, *in.UserID); err != nil {
errs.Add("user_id", err.Error())
}
}
if len(errs) > 0 {
return domain.WebAccount{}, errs
}
hash, err := crypto.HashPassword(in.Password)
if err != nil {
return domain.WebAccount{}, err
}
account, err := s.store.CreateWebAccount(ctx, in.Username, hash, in.Role, in.UserID)
if errors.Is(err, domain.ErrConflict) {
return account, domain.ValidationErrors{{Field: "username", Message: "já existe uma conta com esse nome"}}
}
if err != nil {
return account, err
}
s.audit(ctx, actor, in.UserID, domain.EventWebAccountCreated, true,
map[string]any{"account_id": account.ID, "username": account.Username, "role": string(account.Role)})
account.PasswordHash = ""
return account, nil
}
// checkAccountUser confere que o usuário do Telegram existe e não está ligado a
// outra conta do painel (accountID é a conta que está sendo alterada, 0 se nova).
func (s *Service) checkAccountUser(ctx context.Context, accountID, userID int64) error {
if _, err := s.store.GetUser(ctx, userID); err != nil {
return errors.New("usuário do Telegram não encontrado")
}
accounts, err := s.store.ListWebAccounts(ctx)
if err != nil {
return err
}
for _, a := range accounts {
if a.ID != accountID && a.UserID != nil && *a.UserID == userID {
return errors.New("esse usuário já está ligado à conta " + a.Username)
}
}
return nil
}
// AccountUserLink pede para trocar o vínculo da conta; UserID nil remove o vínculo.
type AccountUserLink struct {
UserID *int64
}
// UpdateAccount altera papel, situação e/ou vínculo com o usuário do Telegram,
// mantendo sempre um owner ativo (9.6).
func (s *Service) UpdateAccount(ctx context.Context, actor Actor, id int64, role *domain.Role, disabled *bool, link *AccountUserLink) error {
if role != nil && !role.Valid() {
return domain.ValidationErrors{{Field: "role", Message: "use owner, admin ou viewer"}}
}
if link != nil && link.UserID != nil {
if err := s.checkAccountUser(ctx, id, *link.UserID); err != nil {
return domain.ValidationErrors{{Field: "user_id", Message: err.Error()}}
}
}
account, err := s.store.GetWebAccount(ctx, id)
if err != nil {
return err
}
losesOwner := account.Role == domain.RoleOwner && !account.IsDisabled() &&
((role != nil && *role != domain.RoleOwner) || (disabled != nil && *disabled))
if losesOwner {
owners, err := s.store.CountActiveOwners(ctx)
if err != nil {
return err
}
if owners <= 1 {
return domain.ErrLastOwner
}
}
var userID *int64
if link != nil {
userID = link.UserID
}
if err := s.store.UpdateWebAccount(ctx, id, role, disabled, link != nil, userID); err != nil {
return err
}
if disabled != nil && *disabled {
if err := s.store.RevokeAccountSessions(ctx, id); err != nil {
return err
}
}
details := map[string]any{"account_id": id}
if role != nil {
details["role"] = string(*role)
}
if disabled != nil {
details["disabled"] = *disabled
}
if link != nil {
details["user_id"] = link.UserID
}
s.audit(ctx, actor, userID, domain.EventWebAccountChanged, true, details)
return nil
}
// DeleteAccount exclui a conta do painel, mantendo sempre um owner ativo (9.6).
func (s *Service) DeleteAccount(ctx context.Context, actor Actor, id int64) error {
account, err := s.store.GetWebAccount(ctx, id)
if err != nil {
return err
}
if account.Role == domain.RoleOwner && !account.IsDisabled() {
owners, err := s.store.CountActiveOwners(ctx)
if err != nil {
return err
}
if owners <= 1 {
return domain.ErrLastOwner
}
}
if err := s.store.DeleteWebAccount(ctx, id); err != nil {
return err
}
s.audit(ctx, actor, account.UserID, domain.EventWebAccountDeleted, true,
map[string]any{"account_id": id, "username": account.Username, "role": string(account.Role)})
return nil
}
// ResetPassword troca a senha e encerra todas as sessões da conta.
func (s *Service) ResetPassword(ctx context.Context, actor Actor, id int64, password string) error {
if err := crypto.ValidatePassword(password); err != nil {
return domain.ValidationErrors{{Field: "password", Message: err.Error()}}
}
if _, err := s.store.GetWebAccount(ctx, id); err != nil {
return err
}
hash, err := crypto.HashPassword(password)
if err != nil {
return err
}
if err := s.store.SetWebAccountPassword(ctx, id, hash); err != nil {
return err
}
if err := s.store.RevokeAccountSessions(ctx, id); err != nil {
return err
}
s.audit(ctx, actor, nil, domain.EventWebPasswordReset, true, map[string]any{"account_id": id})
return nil
}
func (s *Service) ResetPasswordByUsername(ctx context.Context, actor Actor, username, password string) error {
account, err := s.store.GetWebAccountByUsername(ctx, username)
if err != nil {
return err
}
return s.ResetPassword(ctx, actor, account.ID, password)
}
func limitPtr(v string, max int) *string {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
v = truncate(v, max)
return &v
}
// ipPtr só aceita endereços válidos (a coluna é inet).
func ipPtr(ip string) *string {
addr, err := netip.ParseAddr(ip)
if err != nil {
return nil
}
v := addr.String()
return &v
}