oups
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user