package auth import ( "bytes" "context" "crypto/rand" "encoding/base64" "encoding/hex" "fmt" "image/png" "time" "github.com/pquerna/otp/totp" qrcode "github.com/skip2/go-qrcode" "golang.org/x/crypto/bcrypt" "github.com/leolionad58/ticketbot/internal/db" ) const bcryptCost = 12 // Service handles authentication operations: password, TOTP, sessions. type Service struct { admins *db.PanelAdminRepo sessions *db.PanelSessionRepo limiter *RateLimiter ttl time.Duration } func NewService(admins *db.PanelAdminRepo, sessions *db.PanelSessionRepo, ttlMinutes int) *Service { return &Service{ admins: admins, sessions: sessions, limiter: NewRateLimiter(), ttl: time.Duration(ttlMinutes) * time.Minute, } } // HashPassword returns a bcrypt hash of the password. func HashPassword(password string) (string, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) if err != nil { return "", err } return string(hash), nil } // VerifyPassword checks a plaintext password against a bcrypt hash. func VerifyPassword(hash, password string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil } // Allow reports whether the Discord ID is not currently rate-limited. func (s *Service) Allow(discordID string) bool { return s.limiter.Allow(discordID) } // RecordFailure records a failed login attempt and returns true if now locked. func (s *Service) RecordFailure(discordID string) bool { return s.limiter.Record(discordID) } // ResetLimit clears the rate limit for the Discord ID. func (s *Service) ResetLimit(discordID string) { s.limiter.Reset(discordID) } // VerifyCredentials checks password and TOTP code for the given admin. // Returns a unified error ("invalid credentials") regardless of which check fails // to prevent information leakage. func (s *Service) VerifyCredentials(admin *db.PanelAdmin, password, totpCode string) bool { if !VerifyPassword(admin.PasswordHash, password) { return false } if admin.TOTPEnabled { if !admin.TOTPSecret.Valid { return false } if !totp.Validate(totpCode, admin.TOTPSecret.String) { return false } } return true } // GenerateTOTP generates a new TOTP key for the admin and returns the QR code as a base64 PNG // and the raw secret for manual entry. func GenerateTOTP(issuer, accountName string) (qrBase64, secret string, err error) { key, err := totp.Generate(totp.GenerateOpts{ Issuer: issuer, AccountName: accountName, }) if err != nil { return "", "", fmt.Errorf("generate totp key: %w", err) } img, err := key.Image(256, 256) if err != nil { // Fallback: use skip2/go-qrcode var pngBytes []byte pngBytes, err = qrcode.Encode(key.URL(), qrcode.Medium, 256) if err != nil { return "", "", fmt.Errorf("generate qr code: %w", err) } return "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngBytes), key.Secret(), nil } var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { return "", "", fmt.Errorf("encode qr png: %w", err) } return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), key.Secret(), nil } // ValidateTOTPCode validates a TOTP code against a secret. func ValidateTOTPCode(code, secret string) bool { return totp.Validate(code, secret) } // ConfirmTOTP saves the TOTP secret to the admin record and enables TOTP. func (s *Service) ConfirmTOTP(ctx context.Context, adminID int64, secret, code string) error { if !totp.Validate(code, secret) { return fmt.Errorf("invalid totp code") } return s.admins.UpdateTOTP(ctx, adminID, secret, true) } // CreateSession generates a new session for the given admin. func (s *Service) CreateSession(ctx context.Context, adminID int64, ip, ua string) (*db.PanelSession, error) { token, err := randomHex(64) if err != nil { return nil, err } csrf, err := randomHex(32) if err != nil { return nil, err } now := time.Now().UTC() sess := &db.PanelSession{ Token: token, CSRFToken: csrf, AdminID: adminID, IPAddress: ip, UserAgent: ua, LastActivity: now, CreatedAt: now, } if err := s.sessions.Create(ctx, sess); err != nil { return nil, err } return sess, nil } // ValidateSession looks up a session by token and checks it hasn't timed out. // Returns nil if not found or expired (and deletes it in that case). func (s *Service) ValidateSession(ctx context.Context, token string) (*db.PanelSession, error) { sess, err := s.sessions.GetByToken(ctx, token) if err != nil || sess == nil { return nil, err } if time.Since(sess.LastActivity) > s.ttl { _ = s.sessions.DeleteByToken(ctx, token) return nil, nil } return sess, nil } // TouchSession updates the last_activity timestamp. func (s *Service) TouchSession(ctx context.Context, token string) error { return s.sessions.UpdateLastActivity(ctx, token, time.Now().UTC()) } // InvalidateSession deletes a session. func (s *Service) InvalidateSession(ctx context.Context, token string) error { return s.sessions.DeleteByToken(ctx, token) } // InvalidateAllExcept deletes all sessions for adminID except exceptToken. func (s *Service) InvalidateAllExcept(ctx context.Context, adminID int64, exceptToken string) error { return s.sessions.DeleteAllExcept(ctx, adminID, exceptToken) } func randomHex(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } // GenerateState generates a random state string for OAuth2 CSRF protection. func GenerateState() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil }