463 lines
12 KiB
Go
463 lines
12 KiB
Go
|
|
package bkosmi
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/chromedp/chromedp"
|
||
|
|
"github.com/sirupsen/logrus"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
// Check token expiry 7 days before it expires
|
||
|
|
tokenExpiryCheckBuffer = 7 * 24 * time.Hour
|
||
|
|
)
|
||
|
|
|
||
|
|
// BrowserAuthManager handles automated browser-based authentication
|
||
|
|
type BrowserAuthManager struct {
|
||
|
|
email string
|
||
|
|
password string
|
||
|
|
token string
|
||
|
|
tokenExpiry time.Time
|
||
|
|
log *logrus.Entry
|
||
|
|
lastCheckTime time.Time
|
||
|
|
checkInterval time.Duration
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewBrowserAuthManager creates a new browser-based authentication manager
|
||
|
|
func NewBrowserAuthManager(email, password string, log *logrus.Entry) *BrowserAuthManager {
|
||
|
|
return &BrowserAuthManager{
|
||
|
|
email: email,
|
||
|
|
password: password,
|
||
|
|
log: log,
|
||
|
|
checkInterval: 24 * time.Hour, // Check daily for token expiry
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetToken returns a valid token, obtaining a new one via browser if needed
|
||
|
|
func (b *BrowserAuthManager) GetToken() (string, error) {
|
||
|
|
// Check if we need to obtain or refresh the token
|
||
|
|
if b.token == "" || b.shouldRefreshToken() {
|
||
|
|
b.log.Info("Obtaining authentication token via browser automation...")
|
||
|
|
if err := b.loginViaBrowser(); err != nil {
|
||
|
|
return "", &AuthError{
|
||
|
|
Op: "browser_login",
|
||
|
|
Reason: "failed to obtain token via browser",
|
||
|
|
Err: err,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return b.token, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// shouldRefreshToken checks if the token needs to be refreshed
|
||
|
|
func (b *BrowserAuthManager) shouldRefreshToken() bool {
|
||
|
|
// No token yet
|
||
|
|
if b.token == "" {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
// Token expired or about to expire
|
||
|
|
if time.Now().After(b.tokenExpiry.Add(-tokenExpiryCheckBuffer)) {
|
||
|
|
b.log.Info("Token expired or expiring soon, will refresh")
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
// Periodic check (daily) to verify token is still valid
|
||
|
|
if time.Since(b.lastCheckTime) > b.checkInterval {
|
||
|
|
b.log.Debug("Performing periodic token validity check")
|
||
|
|
b.lastCheckTime = time.Now()
|
||
|
|
// For now, we trust the expiry time. Could add a validation check here.
|
||
|
|
}
|
||
|
|
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// loginViaBrowser uses chromedp to automate login and extract token
|
||
|
|
func (b *BrowserAuthManager) loginViaBrowser() error {
|
||
|
|
// Set up Chrome options
|
||
|
|
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||
|
|
chromedp.Flag("headless", true),
|
||
|
|
chromedp.Flag("disable-gpu", true),
|
||
|
|
chromedp.Flag("no-sandbox", true),
|
||
|
|
chromedp.Flag("disable-dev-shm-usage", true),
|
||
|
|
)
|
||
|
|
|
||
|
|
// Create allocator context
|
||
|
|
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
// Create context with timeout
|
||
|
|
ctx, cancel := chromedp.NewContext(allocCtx)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
// Set a reasonable timeout for the entire login process
|
||
|
|
ctx, cancel = context.WithTimeout(ctx, 90*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
var token string
|
||
|
|
|
||
|
|
// Run the browser automation tasks
|
||
|
|
err := chromedp.Run(ctx,
|
||
|
|
// Navigate to Kosmi
|
||
|
|
chromedp.Navigate("https://app.kosmi.io"),
|
||
|
|
|
||
|
|
// Wait for page to load completely
|
||
|
|
chromedp.WaitReady("body"),
|
||
|
|
chromedp.Sleep(2*time.Second),
|
||
|
|
|
||
|
|
// Click Login button (find by text content using JS with error handling)
|
||
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||
|
|
// First, log what buttons we can see
|
||
|
|
var buttonTexts []string
|
||
|
|
chromedp.Evaluate(`
|
||
|
|
Array.from(document.querySelectorAll('button')).map(el => el.textContent.trim())
|
||
|
|
`, &buttonTexts).Do(ctx)
|
||
|
|
b.log.Debugf("Found buttons: %v", buttonTexts)
|
||
|
|
|
||
|
|
var found bool
|
||
|
|
if err := chromedp.Evaluate(`
|
||
|
|
(() => {
|
||
|
|
const buttons = Array.from(document.querySelectorAll('button'));
|
||
|
|
// Try both "Login" and "Log in"
|
||
|
|
const btn = buttons.find(el => {
|
||
|
|
const text = el.textContent.trim();
|
||
|
|
return text === 'Login' || text === 'Log in';
|
||
|
|
});
|
||
|
|
if (btn) {
|
||
|
|
btn.click();
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
})()
|
||
|
|
`, &found).Do(ctx); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if !found {
|
||
|
|
return fmt.Errorf("Login button not found (found buttons: %v)", buttonTexts)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}),
|
||
|
|
|
||
|
|
// Wait for login modal
|
||
|
|
chromedp.Sleep(3*time.Second),
|
||
|
|
|
||
|
|
// Click "Login with Email" button
|
||
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||
|
|
// Log what buttons we can see now
|
||
|
|
var buttonTexts []string
|
||
|
|
chromedp.Evaluate(`
|
||
|
|
Array.from(document.querySelectorAll('button')).map(el => el.textContent.trim())
|
||
|
|
`, &buttonTexts).Do(ctx)
|
||
|
|
b.log.Debugf("After clicking Log in, found buttons: %v", buttonTexts)
|
||
|
|
|
||
|
|
var found bool
|
||
|
|
if err := chromedp.Evaluate(`
|
||
|
|
(() => {
|
||
|
|
const btn = Array.from(document.querySelectorAll('button')).find(el => el.textContent.includes('Email'));
|
||
|
|
if (btn) {
|
||
|
|
btn.click();
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
})()
|
||
|
|
`, &found).Do(ctx); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if !found {
|
||
|
|
return fmt.Errorf("Login with Email button not found (found buttons: %v)", buttonTexts)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}),
|
||
|
|
|
||
|
|
// Wait for email form
|
||
|
|
chromedp.Sleep(3*time.Second),
|
||
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||
|
|
b.log.Debug("Waiting for password input...")
|
||
|
|
return nil
|
||
|
|
}),
|
||
|
|
chromedp.WaitVisible(`input[type="password"]`, chromedp.ByQuery),
|
||
|
|
|
||
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||
|
|
b.log.Debug("Password input found, preparing to fill form...")
|
||
|
|
return nil
|
||
|
|
}),
|
||
|
|
|
||
|
|
// Click on the email input to focus it
|
||
|
|
chromedp.Click(`input[placeholder*="Email"], input[placeholder*="Username"]`, chromedp.ByQuery),
|
||
|
|
chromedp.Sleep(200*time.Millisecond),
|
||
|
|
|
||
|
|
// Type email character by character
|
||
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||
|
|
b.log.Debugf("Typing email: %s", b.email)
|
||
|
|
return chromedp.SendKeys(`input[placeholder*="Email"], input[placeholder*="Username"]`, b.email, chromedp.ByQuery).Do(ctx)
|
||
|
|
}),
|
||
|
|
|
||
|
|
chromedp.Sleep(500*time.Millisecond),
|
||
|
|
|
||
|
|
// Click on the password input to focus it
|
||
|
|
chromedp.Click(`input[type="password"]`, chromedp.ByQuery),
|
||
|
|
chromedp.Sleep(200*time.Millisecond),
|
||
|
|
|
||
|
|
// Type password character by character
|
||
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||
|
|
b.log.Debugf("Typing password (length: %d)", len(b.password))
|
||
|
|
return chromedp.SendKeys(`input[type="password"]`, b.password, chromedp.ByQuery).Do(ctx)
|
||
|
|
}),
|
||
|
|
|
||
|
|
// Verify password was filled correctly
|
||
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||
|
|
var actualLength int
|
||
|
|
chromedp.Evaluate(`
|
||
|
|
(() => {
|
||
|
|
const passwordInput = document.querySelector('input[type="password"]');
|
||
|
|
return passwordInput ? passwordInput.value.length : 0;
|
||
|
|
})()
|
||
|
|
`, &actualLength).Do(ctx)
|
||
|
|
|
||
|
|
b.log.Debugf("Password filled (actual length: %d, expected: %d)", actualLength, len(b.password))
|
||
|
|
|
||
|
|
if actualLength != len(b.password) {
|
||
|
|
return fmt.Errorf("password length mismatch: got %d, expected %d", actualLength, len(b.password))
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}),
|
||
|
|
|
||
|
|
chromedp.Sleep(500*time.Millisecond),
|
||
|
|
|
||
|
|
// Wait a moment for form validation
|
||
|
|
chromedp.Sleep(1*time.Second),
|
||
|
|
|
||
|
|
// Click the login submit button (be very specific)
|
||
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||
|
|
b.log.Debug("Attempting to click submit button...")
|
||
|
|
var result string
|
||
|
|
if err := chromedp.Evaluate(`
|
||
|
|
(() => {
|
||
|
|
const buttons = Array.from(document.querySelectorAll('button'));
|
||
|
|
|
||
|
|
// Find the submit button in the login form
|
||
|
|
// It should be visible, enabled, and contain "Login" but not be the main nav button
|
||
|
|
const submitBtn = buttons.find(el => {
|
||
|
|
const text = el.textContent.trim();
|
||
|
|
const isLoginBtn = text === 'Login' || text.startsWith('Login');
|
||
|
|
const isEnabled = !el.disabled;
|
||
|
|
const isVisible = el.offsetParent !== null;
|
||
|
|
const isInForm = el.closest('form') !== null || el.closest('[role="dialog"]') !== null;
|
||
|
|
|
||
|
|
return isLoginBtn && isEnabled && isVisible && isInForm;
|
||
|
|
});
|
||
|
|
|
||
|
|
if (submitBtn) {
|
||
|
|
submitBtn.click();
|
||
|
|
return 'CLICKED: ' + submitBtn.textContent.trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
return 'NOT_FOUND';
|
||
|
|
})()
|
||
|
|
`, &result).Do(ctx); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
b.log.Debugf("Submit button result: %s", result)
|
||
|
|
|
||
|
|
if result == "NOT_FOUND" {
|
||
|
|
return fmt.Errorf("Login submit button not found or not clickable")
|
||
|
|
}
|
||
|
|
|
||
|
|
b.log.Debug("Submit button clicked")
|
||
|
|
return nil
|
||
|
|
}),
|
||
|
|
|
||
|
|
// Wait for login to complete (page will reload/redirect)
|
||
|
|
chromedp.Sleep(5*time.Second),
|
||
|
|
|
||
|
|
// Check if login succeeded by looking for error messages
|
||
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||
|
|
var errorText string
|
||
|
|
chromedp.Evaluate(`
|
||
|
|
(() => {
|
||
|
|
const errorEl = document.querySelector('[role="alert"], .error, .alert-error');
|
||
|
|
return errorEl ? errorEl.textContent : '';
|
||
|
|
})()
|
||
|
|
`, &errorText).Do(ctx)
|
||
|
|
|
||
|
|
if errorText != "" {
|
||
|
|
return fmt.Errorf("login failed with error: %s", errorText)
|
||
|
|
}
|
||
|
|
|
||
|
|
b.log.Debug("No error messages found, checking token...")
|
||
|
|
return nil
|
||
|
|
}),
|
||
|
|
|
||
|
|
// Extract token from localStorage
|
||
|
|
chromedp.Evaluate(`localStorage.getItem('token')`, &token),
|
||
|
|
|
||
|
|
// Verify the token is not anonymous by checking if user info exists
|
||
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||
|
|
var userInfo string
|
||
|
|
chromedp.Evaluate(`
|
||
|
|
(() => {
|
||
|
|
try {
|
||
|
|
const token = localStorage.getItem('token');
|
||
|
|
if (!token) return 'NO_TOKEN';
|
||
|
|
|
||
|
|
// Decode JWT payload (middle part)
|
||
|
|
const parts = token.split('.');
|
||
|
|
if (parts.length !== 3) return 'INVALID_TOKEN';
|
||
|
|
|
||
|
|
const payload = JSON.parse(atob(parts[1]));
|
||
|
|
return JSON.stringify({
|
||
|
|
sub: payload.sub,
|
||
|
|
typ: payload.typ,
|
||
|
|
isAnon: payload.sub ? false : true
|
||
|
|
});
|
||
|
|
} catch (e) {
|
||
|
|
return 'ERROR: ' + e.message;
|
||
|
|
}
|
||
|
|
})()
|
||
|
|
`, &userInfo).Do(ctx)
|
||
|
|
|
||
|
|
b.log.Debugf("Token info from browser: %s", userInfo)
|
||
|
|
return nil
|
||
|
|
}),
|
||
|
|
)
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("browser automation failed: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if token == "" {
|
||
|
|
return fmt.Errorf("no token found in localStorage after login")
|
||
|
|
}
|
||
|
|
|
||
|
|
b.token = token
|
||
|
|
b.log.Infof("✅ Successfully obtained token via browser automation")
|
||
|
|
b.log.Infof(" Email used: %s", b.email)
|
||
|
|
b.log.Infof(" Token (first 50 chars): %s...", token[:min(50, len(token))])
|
||
|
|
b.log.Infof(" Token (last 50 chars): ...%s", token[max(0, len(token)-50):])
|
||
|
|
|
||
|
|
// Parse token to get expiry
|
||
|
|
if err := b.parseTokenExpiry(); err != nil {
|
||
|
|
b.log.Warnf("Failed to parse token expiry: %v", err)
|
||
|
|
// Default to 1 year if we can't parse
|
||
|
|
b.tokenExpiry = time.Now().Add(365 * 24 * time.Hour)
|
||
|
|
}
|
||
|
|
|
||
|
|
b.lastCheckTime = time.Now()
|
||
|
|
|
||
|
|
expiresIn := time.Until(b.tokenExpiry)
|
||
|
|
b.log.Infof("Token expires in: %v", expiresIn.Round(24*time.Hour))
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// parseTokenExpiry extracts the expiry time from the JWT token
|
||
|
|
func (b *BrowserAuthManager) parseTokenExpiry() error {
|
||
|
|
// JWT format: header.payload.signature
|
||
|
|
parts := strings.Split(b.token, ".")
|
||
|
|
if len(parts) != 3 {
|
||
|
|
return fmt.Errorf("invalid JWT format")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Decode the payload (base64url without padding)
|
||
|
|
payload := parts[1]
|
||
|
|
// Add padding if needed
|
||
|
|
switch len(payload) % 4 {
|
||
|
|
case 2:
|
||
|
|
payload += "=="
|
||
|
|
case 3:
|
||
|
|
payload += "="
|
||
|
|
}
|
||
|
|
|
||
|
|
// Replace URL-safe characters
|
||
|
|
payload = strings.ReplaceAll(payload, "-", "+")
|
||
|
|
payload = strings.ReplaceAll(payload, "_", "/")
|
||
|
|
|
||
|
|
decoded, err := base64.StdEncoding.DecodeString(payload)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to decode JWT payload: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parse JSON
|
||
|
|
var claims struct {
|
||
|
|
Exp int64 `json:"exp"`
|
||
|
|
Sub string `json:"sub"`
|
||
|
|
Typ string `json:"typ"`
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||
|
|
return fmt.Errorf("failed to parse JWT claims: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if claims.Exp == 0 {
|
||
|
|
return fmt.Errorf("no expiry in token")
|
||
|
|
}
|
||
|
|
|
||
|
|
b.tokenExpiry = time.Unix(claims.Exp, 0)
|
||
|
|
b.log.Infof(" Token user ID (sub): %s", claims.Sub)
|
||
|
|
b.log.Infof(" Token type (typ): %s", claims.Typ)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func min(a, b int) int {
|
||
|
|
if a < b {
|
||
|
|
return a
|
||
|
|
}
|
||
|
|
return b
|
||
|
|
}
|
||
|
|
|
||
|
|
func max(a, b int) int {
|
||
|
|
if a > b {
|
||
|
|
return a
|
||
|
|
}
|
||
|
|
return b
|
||
|
|
}
|
||
|
|
|
||
|
|
// IsAuthenticated checks if we have a valid token
|
||
|
|
func (b *BrowserAuthManager) IsAuthenticated() bool {
|
||
|
|
return b.token != "" && time.Now().Before(b.tokenExpiry)
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetUserID returns the user ID from the token (if available)
|
||
|
|
func (b *BrowserAuthManager) GetUserID() string {
|
||
|
|
if b.token == "" {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
parts := strings.Split(b.token, ".")
|
||
|
|
if len(parts) != 3 {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
payload := parts[1]
|
||
|
|
switch len(payload) % 4 {
|
||
|
|
case 2:
|
||
|
|
payload += "=="
|
||
|
|
case 3:
|
||
|
|
payload += "="
|
||
|
|
}
|
||
|
|
|
||
|
|
payload = strings.ReplaceAll(payload, "-", "+")
|
||
|
|
payload = strings.ReplaceAll(payload, "_", "/")
|
||
|
|
|
||
|
|
decoded, err := base64.StdEncoding.DecodeString(payload)
|
||
|
|
if err != nil {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
var claims struct {
|
||
|
|
Sub string `json:"sub"`
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
return claims.Sub
|
||
|
|
}
|
||
|
|
|