297 lines
9.2 KiB
Go
297 lines
9.2 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
|
|
}
|
|
|
|
// 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
|
|
}
|