oups
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/leolionad58/ticketbot/internal/db"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
ctxAdmin ctxKey = iota
|
||||
ctxSession ctxKey = iota
|
||||
)
|
||||
|
||||
// WithAdmin injects an admin into the request context.
|
||||
func WithAdmin(r *http.Request, admin *db.PanelAdmin) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), ctxAdmin, admin))
|
||||
}
|
||||
|
||||
// AdminFromContext retrieves the admin from the request context (nil if not set).
|
||||
func AdminFromContext(ctx context.Context) *db.PanelAdmin {
|
||||
v, _ := ctx.Value(ctxAdmin).(*db.PanelAdmin)
|
||||
return v
|
||||
}
|
||||
|
||||
// SessionFromContext retrieves the session from the request context.
|
||||
func SessionFromContext(ctx context.Context) *db.PanelSession {
|
||||
v, _ := ctx.Value(ctxSession).(*db.PanelSession)
|
||||
return v
|
||||
}
|
||||
|
||||
// Middleware wires authentication and CSRF checking onto protected routes.
|
||||
type Middleware struct {
|
||||
svc *Service
|
||||
admins *db.PanelAdminRepo
|
||||
}
|
||||
|
||||
func NewMiddleware(svc *Service, admins *db.PanelAdminRepo) *Middleware {
|
||||
return &Middleware{svc: svc, admins: admins}
|
||||
}
|
||||
|
||||
// RequireAuth is an HTTP middleware that validates the session cookie and injects
|
||||
// the admin into the request context. Redirects to /login if not authenticated.
|
||||
func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("panel_session")
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
sess, err := m.svc.ValidateSession(ctx, cookie.Value)
|
||||
if err != nil || sess == nil {
|
||||
clearSessionCookie(w, r)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// CSRF check for state-changing methods
|
||||
if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodDelete {
|
||||
csrfHeader := r.Header.Get("X-CSRF-Token")
|
||||
if csrfHeader == "" {
|
||||
// Also accept form field for non-HTMX posts
|
||||
csrfHeader = r.FormValue("csrf_token")
|
||||
}
|
||||
if csrfHeader != sess.CSRFToken {
|
||||
http.Error(w, "CSRF token invalid", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Touch session (update last_activity)
|
||||
_ = m.svc.TouchSession(ctx, cookie.Value)
|
||||
|
||||
// Load admin
|
||||
admin, err := m.admins.GetByID(ctx, sess.AdminID)
|
||||
if err != nil || admin == nil {
|
||||
clearSessionCookie(w, r)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, ctxAdmin, admin)
|
||||
ctx = context.WithValue(ctx, ctxSession, sess)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// RequireSuperadmin wraps RequireAuth and additionally checks is_superadmin.
|
||||
func (m *Middleware) RequireSuperadmin(next http.Handler) http.Handler {
|
||||
return m.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
admin := AdminFromContext(r.Context())
|
||||
if admin == nil || !admin.IsSuperadmin {
|
||||
http.Error(w, "Accès refusé", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
}))
|
||||
}
|
||||
|
||||
// isSecure returns true if the request arrived over HTTPS (direct TLS or behind a proxy).
|
||||
func isSecure(r *http.Request) bool {
|
||||
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
||||
}
|
||||
|
||||
// SetSessionCookie writes the main session cookie.
|
||||
func SetSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "panel_session",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: isSecure(r),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
// SetPendingAuthCookie stores the Discord ID between OAuth callback and password verification.
|
||||
func SetPendingAuthCookie(w http.ResponseWriter, r *http.Request, discordID string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "panel_pending_auth",
|
||||
Value: discordID,
|
||||
Path: "/auth",
|
||||
HttpOnly: true,
|
||||
Secure: isSecure(r),
|
||||
SameSite: http.SameSiteLaxMode, // Lax: survives the redirect from /oauth/callback to /auth/*
|
||||
MaxAge: 300, // 5 minutes
|
||||
})
|
||||
}
|
||||
|
||||
// PendingAuthDiscordID reads the pending auth cookie.
|
||||
func PendingAuthDiscordID(r *http.Request) string {
|
||||
c, err := r.Cookie("panel_pending_auth")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "panel_session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: isSecure(r),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: -1,
|
||||
Expires: time.Unix(0, 0),
|
||||
})
|
||||
}
|
||||
|
||||
func clearPendingAuthCookie(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "panel_pending_auth",
|
||||
Value: "",
|
||||
Path: "/auth",
|
||||
HttpOnly: true,
|
||||
Secure: isSecure(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
Expires: time.Unix(0, 0),
|
||||
})
|
||||
}
|
||||
|
||||
// ClearPendingAuthCookie is exported for use by handlers.
|
||||
func ClearPendingAuthCookie(w http.ResponseWriter, r *http.Request) {
|
||||
clearPendingAuthCookie(w, r)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// DiscordEndpoint is the Discord OAuth2 endpoint.
|
||||
var DiscordEndpoint = oauth2.Endpoint{
|
||||
AuthURL: "https://discord.com/api/oauth2/authorize",
|
||||
TokenURL: "https://discord.com/api/oauth2/token",
|
||||
}
|
||||
|
||||
// DiscordUser holds the data returned by /users/@me.
|
||||
type DiscordUser struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
// AvatarURL returns the full Discord CDN URL for the user's avatar.
|
||||
func (u *DiscordUser) AvatarURL() string {
|
||||
if u.Avatar == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("https://cdn.discordapp.com/avatars/%s/%s.png?size=128", u.ID, u.Avatar)
|
||||
}
|
||||
|
||||
// NewOAuthConfig builds an oauth2.Config for Discord.
|
||||
func NewOAuthConfig(clientID, clientSecret, callbackURL string) *oauth2.Config {
|
||||
return &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: callbackURL,
|
||||
Scopes: []string{"identify"},
|
||||
Endpoint: DiscordEndpoint,
|
||||
}
|
||||
}
|
||||
|
||||
// AuthURL returns the Discord authorization URL with the given state.
|
||||
func AuthURL(cfg *oauth2.Config, state string) string {
|
||||
return cfg.AuthCodeURL(state, oauth2.AccessTypeOnline)
|
||||
}
|
||||
|
||||
// FetchDiscordUser exchanges the authorization code for a token, then calls /users/@me.
|
||||
func FetchDiscordUser(ctx context.Context, cfg *oauth2.Config, code string) (*DiscordUser, error) {
|
||||
token, err := cfg.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("exchange oauth code: %w", err)
|
||||
}
|
||||
|
||||
client := cfg.Client(ctx, token)
|
||||
resp, err := client.Get("https://discord.com/api/v10/users/@me")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch discord user: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("discord api %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var user DiscordUser
|
||||
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
||||
return nil, fmt.Errorf("decode discord user: %w", err)
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxAttempts = 5
|
||||
windowMinutes = 15
|
||||
lockMinutes = 15
|
||||
)
|
||||
|
||||
type entry struct {
|
||||
count int
|
||||
firstAt time.Time
|
||||
lockedUntil time.Time
|
||||
}
|
||||
|
||||
// RateLimiter tracks failed login attempts per Discord ID in memory.
|
||||
// Resets on bot restart (by design — avoids DB writes on every auth failure).
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*entry
|
||||
}
|
||||
|
||||
func NewRateLimiter() *RateLimiter {
|
||||
return &RateLimiter{entries: make(map[string]*entry)}
|
||||
}
|
||||
|
||||
// Allow returns true if the given ID is not currently rate-limited.
|
||||
func (r *RateLimiter) Allow(id string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
e, ok := r.entries[id]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if !e.lockedUntil.IsZero() {
|
||||
if time.Now().Before(e.lockedUntil) {
|
||||
return false
|
||||
}
|
||||
// Lock expired — reset
|
||||
delete(r.entries, id)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Record records a failed attempt. Returns true if the account is now locked.
|
||||
func (r *RateLimiter) Record(id string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
e, ok := r.entries[id]
|
||||
if !ok {
|
||||
e = &entry{firstAt: time.Now()}
|
||||
r.entries[id] = e
|
||||
}
|
||||
// Reset window if outside window duration
|
||||
if time.Since(e.firstAt) > windowMinutes*time.Minute {
|
||||
e.count = 0
|
||||
e.firstAt = time.Now()
|
||||
e.lockedUntil = time.Time{}
|
||||
}
|
||||
e.count++
|
||||
if e.count >= maxAttempts {
|
||||
e.lockedUntil = time.Now().Add(lockMinutes * time.Minute)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Reset clears the rate limit entry for a Discord ID (call on successful login).
|
||||
func (r *RateLimiter) Reset(id string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.entries, id)
|
||||
}
|
||||
|
||||
// RemainingLock returns the remaining lock duration (zero if not locked).
|
||||
func (r *RateLimiter) RemainingLock(id string) time.Duration {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
e, ok := r.entries[id]
|
||||
if !ok || e.lockedUntil.IsZero() {
|
||||
return 0
|
||||
}
|
||||
remaining := time.Until(e.lockedUntil)
|
||||
if remaining < 0 {
|
||||
return 0
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRateLimiterAllow(t *testing.T) {
|
||||
r := NewRateLimiter()
|
||||
if !r.Allow("user1") {
|
||||
t.Error("fresh user should be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterLockAfterMaxAttempts(t *testing.T) {
|
||||
r := NewRateLimiter()
|
||||
id := "user2"
|
||||
for i := 0; i < maxAttempts-1; i++ {
|
||||
locked := r.Record(id)
|
||||
if locked {
|
||||
t.Fatalf("should not be locked before max attempts (attempt %d)", i+1)
|
||||
}
|
||||
if !r.Allow(id) {
|
||||
t.Fatalf("should be allowed before lock (attempt %d)", i+1)
|
||||
}
|
||||
}
|
||||
locked := r.Record(id)
|
||||
if !locked {
|
||||
t.Error("should be locked after max attempts")
|
||||
}
|
||||
if r.Allow(id) {
|
||||
t.Error("locked user should not be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterReset(t *testing.T) {
|
||||
r := NewRateLimiter()
|
||||
id := "user3"
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
r.Record(id) //nolint
|
||||
}
|
||||
if r.Allow(id) {
|
||||
t.Error("should be locked before reset")
|
||||
}
|
||||
r.Reset(id)
|
||||
if !r.Allow(id) {
|
||||
t.Error("should be allowed after reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterWindowReset(t *testing.T) {
|
||||
r := NewRateLimiter()
|
||||
id := "user4"
|
||||
// Simulate 4 attempts within window then manually expire the window
|
||||
for i := 0; i < maxAttempts-1; i++ {
|
||||
r.Record(id) //nolint
|
||||
}
|
||||
// Manually expire the window
|
||||
r.mu.Lock()
|
||||
r.entries[id].firstAt = time.Now().Add(-windowMinutes*time.Minute - time.Second)
|
||||
r.mu.Unlock()
|
||||
|
||||
// Next attempt should reset the window, not lock
|
||||
locked := r.Record(id)
|
||||
if locked {
|
||||
t.Error("should not lock after window expired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterRemainingLock(t *testing.T) {
|
||||
r := NewRateLimiter()
|
||||
id := "user5"
|
||||
if r.RemainingLock(id) != 0 {
|
||||
t.Error("no lock expected for fresh user")
|
||||
}
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
r.Record(id) //nolint
|
||||
}
|
||||
if r.RemainingLock(id) == 0 {
|
||||
t.Error("lock expected after max attempts")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
func TestHashAndVerifyPassword(t *testing.T) {
|
||||
hash, err := HashPassword("correct-horse-battery-staple")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
if !VerifyPassword(hash, "correct-horse-battery-staple") {
|
||||
t.Error("VerifyPassword should return true for correct password")
|
||||
}
|
||||
if VerifyPassword(hash, "wrong-password") {
|
||||
t.Error("VerifyPassword should return false for wrong password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTOTP(t *testing.T) {
|
||||
qr, secret, err := GenerateTOTP("TestApp", "user@test")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateTOTP: %v", err)
|
||||
}
|
||||
if secret == "" {
|
||||
t.Error("secret should not be empty")
|
||||
}
|
||||
if qr == "" {
|
||||
t.Error("qr should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTOTPCode(t *testing.T) {
|
||||
_, secret, err := GenerateTOTP("TestApp", "user@test")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateTOTP: %v", err)
|
||||
}
|
||||
|
||||
// Generate a valid code
|
||||
code, err := totp.GenerateCode(secret, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode: %v", err)
|
||||
}
|
||||
if !ValidateTOTPCode(code, secret) {
|
||||
t.Error("ValidateTOTPCode should return true for valid code")
|
||||
}
|
||||
if ValidateTOTPCode("000000", secret) {
|
||||
t.Error("ValidateTOTPCode should return false for invalid code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateState(t *testing.T) {
|
||||
s1, err := GenerateState()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateState: %v", err)
|
||||
}
|
||||
s2, err := GenerateState()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateState: %v", err)
|
||||
}
|
||||
if s1 == s2 {
|
||||
t.Error("GenerateState should return unique values")
|
||||
}
|
||||
if len(s1) != 32 { // 16 bytes → 32 hex chars
|
||||
t.Errorf("GenerateState length = %d, want 32", len(s1))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user