397 lines
9.1 KiB
Go
397 lines
9.1 KiB
Go
|
|
package bkosmi
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"net/http"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/sirupsen/logrus"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
// Token expiry buffer - refresh if token expires within this window
|
||
|
|
tokenExpiryBuffer = 5 * time.Minute
|
||
|
|
)
|
||
|
|
|
||
|
|
// AuthManager handles authentication with Kosmi
|
||
|
|
type AuthManager struct {
|
||
|
|
email string
|
||
|
|
password string
|
||
|
|
token string
|
||
|
|
tokenExpiry time.Time
|
||
|
|
refreshToken string
|
||
|
|
userID string
|
||
|
|
mu sync.RWMutex
|
||
|
|
log *logrus.Entry
|
||
|
|
httpClient *http.Client
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewAuthManager creates a new authentication manager
|
||
|
|
func NewAuthManager(email, password string, log *logrus.Entry) *AuthManager {
|
||
|
|
return &AuthManager{
|
||
|
|
email: email,
|
||
|
|
password: password,
|
||
|
|
log: log,
|
||
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Login performs email/password authentication
|
||
|
|
//
|
||
|
|
// NOTE: The actual login API format needs to be verified through monitoring.
|
||
|
|
// This implementation is based on common GraphQL patterns and may need adjustment.
|
||
|
|
func (a *AuthManager) Login() error {
|
||
|
|
a.mu.Lock()
|
||
|
|
defer a.mu.Unlock()
|
||
|
|
|
||
|
|
a.log.Info("Logging in to Kosmi...")
|
||
|
|
|
||
|
|
// Prepare login mutation
|
||
|
|
// Based on common GraphQL patterns, likely something like:
|
||
|
|
// mutation { login(email: "...", password: "...") { token user { id displayName } } }
|
||
|
|
mutation := map[string]interface{}{
|
||
|
|
"query": `mutation Login($email: String!, $password: String!) {
|
||
|
|
login(email: $email, password: $password) {
|
||
|
|
token
|
||
|
|
refreshToken
|
||
|
|
expiresIn
|
||
|
|
user {
|
||
|
|
id
|
||
|
|
displayName
|
||
|
|
username
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}`,
|
||
|
|
"variables": map[string]interface{}{
|
||
|
|
"email": a.email,
|
||
|
|
"password": a.password,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
jsonBody, err := json.Marshal(mutation)
|
||
|
|
if err != nil {
|
||
|
|
return &AuthError{
|
||
|
|
Op: "login",
|
||
|
|
Reason: "failed to marshal request",
|
||
|
|
Err: err,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
req, err := http.NewRequest("POST", kosmiHTTPURL, bytes.NewReader(jsonBody))
|
||
|
|
if err != nil {
|
||
|
|
return &AuthError{
|
||
|
|
Op: "login",
|
||
|
|
Reason: "failed to create request",
|
||
|
|
Err: err,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
req.Header.Set("Content-Type", "application/json")
|
||
|
|
req.Header.Set("Referer", "https://app.kosmi.io/")
|
||
|
|
req.Header.Set("User-Agent", userAgent)
|
||
|
|
|
||
|
|
resp, err := a.httpClient.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return &AuthError{
|
||
|
|
Op: "login",
|
||
|
|
Reason: "request failed",
|
||
|
|
Err: err,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode == 401 {
|
||
|
|
return &AuthError{
|
||
|
|
Op: "login",
|
||
|
|
Reason: "invalid credentials",
|
||
|
|
Err: ErrAuthFailed,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if resp.StatusCode != 200 {
|
||
|
|
return &AuthError{
|
||
|
|
Op: "login",
|
||
|
|
Reason: fmt.Sprintf("HTTP %d", resp.StatusCode),
|
||
|
|
Err: ErrAuthFailed,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
var result map[string]interface{}
|
||
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
|
|
return &AuthError{
|
||
|
|
Op: "login",
|
||
|
|
Reason: "failed to parse response",
|
||
|
|
Err: err,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Extract token and user info
|
||
|
|
if data, ok := result["data"].(map[string]interface{}); ok {
|
||
|
|
if login, ok := data["login"].(map[string]interface{}); ok {
|
||
|
|
if token, ok := login["token"].(string); ok {
|
||
|
|
a.token = token
|
||
|
|
|
||
|
|
// Extract refresh token if present
|
||
|
|
if refreshToken, ok := login["refreshToken"].(string); ok {
|
||
|
|
a.refreshToken = refreshToken
|
||
|
|
}
|
||
|
|
|
||
|
|
// Calculate token expiry
|
||
|
|
if expiresIn, ok := login["expiresIn"].(float64); ok {
|
||
|
|
a.tokenExpiry = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||
|
|
} else {
|
||
|
|
// Default to 24 hours if not specified
|
||
|
|
a.tokenExpiry = time.Now().Add(24 * time.Hour)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Extract user ID
|
||
|
|
if user, ok := login["user"].(map[string]interface{}); ok {
|
||
|
|
if userID, ok := user["id"].(string); ok {
|
||
|
|
a.userID = userID
|
||
|
|
}
|
||
|
|
if displayName, ok := user["displayName"].(string); ok {
|
||
|
|
a.log.Infof("Logged in as: %s", displayName)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
a.log.Info("✅ Login successful")
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for GraphQL errors
|
||
|
|
if errors, ok := result["errors"].([]interface{}); ok && len(errors) > 0 {
|
||
|
|
if errObj, ok := errors[0].(map[string]interface{}); ok {
|
||
|
|
if message, ok := errObj["message"].(string); ok {
|
||
|
|
return &AuthError{
|
||
|
|
Op: "login",
|
||
|
|
Reason: message,
|
||
|
|
Err: ErrAuthFailed,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return &AuthError{
|
||
|
|
Op: "login",
|
||
|
|
Reason: "no token in response",
|
||
|
|
Err: ErrAuthFailed,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetToken returns a valid token, refreshing if necessary
|
||
|
|
func (a *AuthManager) GetToken() (string, error) {
|
||
|
|
a.mu.RLock()
|
||
|
|
|
||
|
|
// Check if token is still valid
|
||
|
|
if a.token != "" && time.Now().Before(a.tokenExpiry.Add(-tokenExpiryBuffer)) {
|
||
|
|
token := a.token
|
||
|
|
a.mu.RUnlock()
|
||
|
|
return token, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
a.mu.RUnlock()
|
||
|
|
|
||
|
|
// Token is expired or about to expire, refresh it
|
||
|
|
if a.refreshToken != "" {
|
||
|
|
if err := a.RefreshToken(); err != nil {
|
||
|
|
a.log.Warnf("Token refresh failed, attempting re-login: %v", err)
|
||
|
|
if err := a.Login(); err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// No refresh token, need to login again
|
||
|
|
if err := a.Login(); err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
a.mu.RLock()
|
||
|
|
defer a.mu.RUnlock()
|
||
|
|
return a.token, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// RefreshToken renews the access token using the refresh token
|
||
|
|
func (a *AuthManager) RefreshToken() error {
|
||
|
|
a.mu.Lock()
|
||
|
|
defer a.mu.Unlock()
|
||
|
|
|
||
|
|
if a.refreshToken == "" {
|
||
|
|
return &AuthError{
|
||
|
|
Op: "token_refresh",
|
||
|
|
Reason: "no refresh token available",
|
||
|
|
Err: ErrTokenExpired,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
a.log.Debug("Refreshing authentication token...")
|
||
|
|
|
||
|
|
// Prepare refresh mutation
|
||
|
|
mutation := map[string]interface{}{
|
||
|
|
"query": `mutation RefreshToken($refreshToken: String!) {
|
||
|
|
refreshToken(refreshToken: $refreshToken) {
|
||
|
|
token
|
||
|
|
refreshToken
|
||
|
|
expiresIn
|
||
|
|
}
|
||
|
|
}`,
|
||
|
|
"variables": map[string]interface{}{
|
||
|
|
"refreshToken": a.refreshToken,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
jsonBody, err := json.Marshal(mutation)
|
||
|
|
if err != nil {
|
||
|
|
return &AuthError{
|
||
|
|
Op: "token_refresh",
|
||
|
|
Reason: "failed to marshal request",
|
||
|
|
Err: err,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
req, err := http.NewRequest("POST", kosmiHTTPURL, bytes.NewReader(jsonBody))
|
||
|
|
if err != nil {
|
||
|
|
return &AuthError{
|
||
|
|
Op: "token_refresh",
|
||
|
|
Reason: "failed to create request",
|
||
|
|
Err: err,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
req.Header.Set("Content-Type", "application/json")
|
||
|
|
req.Header.Set("Referer", "https://app.kosmi.io/")
|
||
|
|
req.Header.Set("User-Agent", userAgent)
|
||
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.token))
|
||
|
|
|
||
|
|
resp, err := a.httpClient.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return &AuthError{
|
||
|
|
Op: "token_refresh",
|
||
|
|
Reason: "request failed",
|
||
|
|
Err: err,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != 200 {
|
||
|
|
return &AuthError{
|
||
|
|
Op: "token_refresh",
|
||
|
|
Reason: fmt.Sprintf("HTTP %d", resp.StatusCode),
|
||
|
|
Err: ErrTokenExpired,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
var result map[string]interface{}
|
||
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
|
|
return &AuthError{
|
||
|
|
Op: "token_refresh",
|
||
|
|
Reason: "failed to parse response",
|
||
|
|
Err: err,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Extract new token
|
||
|
|
if data, ok := result["data"].(map[string]interface{}); ok {
|
||
|
|
if refresh, ok := data["refreshToken"].(map[string]interface{}); ok {
|
||
|
|
if token, ok := refresh["token"].(string); ok {
|
||
|
|
a.token = token
|
||
|
|
|
||
|
|
// Update refresh token if provided
|
||
|
|
if newRefreshToken, ok := refresh["refreshToken"].(string); ok {
|
||
|
|
a.refreshToken = newRefreshToken
|
||
|
|
}
|
||
|
|
|
||
|
|
// Update expiry
|
||
|
|
if expiresIn, ok := refresh["expiresIn"].(float64); ok {
|
||
|
|
a.tokenExpiry = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||
|
|
} else {
|
||
|
|
a.tokenExpiry = time.Now().Add(24 * time.Hour)
|
||
|
|
}
|
||
|
|
|
||
|
|
a.log.Debug("✅ Token refreshed successfully")
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return &AuthError{
|
||
|
|
Op: "token_refresh",
|
||
|
|
Reason: "no token in response",
|
||
|
|
Err: ErrTokenExpired,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Logout invalidates the current session
|
||
|
|
func (a *AuthManager) Logout() error {
|
||
|
|
a.mu.Lock()
|
||
|
|
defer a.mu.Unlock()
|
||
|
|
|
||
|
|
if a.token == "" {
|
||
|
|
return nil // Already logged out
|
||
|
|
}
|
||
|
|
|
||
|
|
a.log.Info("Logging out from Kosmi...")
|
||
|
|
|
||
|
|
// Prepare logout mutation
|
||
|
|
mutation := map[string]interface{}{
|
||
|
|
"query": `mutation Logout {
|
||
|
|
logout {
|
||
|
|
ok
|
||
|
|
}
|
||
|
|
}`,
|
||
|
|
}
|
||
|
|
|
||
|
|
jsonBody, err := json.Marshal(mutation)
|
||
|
|
if err != nil {
|
||
|
|
a.log.Warnf("Failed to marshal logout request: %v", err)
|
||
|
|
// Continue with local cleanup
|
||
|
|
} else {
|
||
|
|
req, err := http.NewRequest("POST", kosmiHTTPURL, bytes.NewReader(jsonBody))
|
||
|
|
if err == nil {
|
||
|
|
req.Header.Set("Content-Type", "application/json")
|
||
|
|
req.Header.Set("Referer", "https://app.kosmi.io/")
|
||
|
|
req.Header.Set("User-Agent", userAgent)
|
||
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.token))
|
||
|
|
|
||
|
|
resp, err := a.httpClient.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
a.log.Warnf("Logout request failed: %v", err)
|
||
|
|
} else {
|
||
|
|
resp.Body.Close()
|
||
|
|
if resp.StatusCode != 200 {
|
||
|
|
a.log.Warnf("Logout returned HTTP %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Clear local state regardless of server response
|
||
|
|
a.token = ""
|
||
|
|
a.refreshToken = ""
|
||
|
|
a.tokenExpiry = time.Time{}
|
||
|
|
a.userID = ""
|
||
|
|
|
||
|
|
a.log.Info("✅ Logged out")
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// IsAuthenticated checks if we have a valid token
|
||
|
|
func (a *AuthManager) IsAuthenticated() bool {
|
||
|
|
a.mu.RLock()
|
||
|
|
defer a.mu.RUnlock()
|
||
|
|
return a.token != "" && time.Now().Before(a.tokenExpiry)
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetUserID returns the authenticated user's ID
|
||
|
|
func (a *AuthManager) GetUserID() string {
|
||
|
|
a.mu.RLock()
|
||
|
|
defer a.mu.RUnlock()
|
||
|
|
return a.userID
|
||
|
|
}
|
||
|
|
|