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 }