gitlink-cli/internal/auth/login.go

153 lines
4.0 KiB
Go

package auth
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/config"
)
type LoginResult struct {
Username string `json:"username"`
Login string `json:"login"`
UserID int `json:"user_id"`
Token string `json:"token"`
// Error fields (only present on failure)
Status int `json:"status"`
Message string `json:"message"`
}
// Login authenticates with username/password and stores the session cookie.
func Login(username, password string) (*LoginResult, error) {
cfg, err := config.Load()
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
body := map[string]string{
"login": username,
"password": password,
}
bodyJSON, _ := json.Marshal(body)
loginURL := cfg.BaseURL + "/accounts/login.json"
req, err := http.NewRequest("POST", loginURL, bytes.NewReader(bodyJSON))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "*/*")
// Use a client that captures cookies across redirects
jar, _ := cookiejar.New(nil)
client := &http.Client{Jar: jar}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var result LoginResult
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Check for error response (has negative status)
if result.Status < 0 {
return nil, fmt.Errorf("%s", result.Message)
}
// Collect auth cookies from response (GitLink uses autologin_trustie for session persistence)
var authCookies []string
for _, cookie := range resp.Cookies() {
if cookie.Name == "autologin_trustie" || cookie.Name == "_educoder_session" {
authCookies = append(authCookies, cookie.Name+"="+cookie.Value)
}
}
// Also check cookie jar (captures cookies from redirect hops)
if len(authCookies) == 0 {
if u, err := url.Parse(loginURL); err == nil {
for _, cookie := range jar.Cookies(u) {
if cookie.Name == "autologin_trustie" || cookie.Name == "_educoder_session" {
authCookies = append(authCookies, cookie.Name+"="+cookie.Value)
}
}
}
}
if len(authCookies) == 0 {
return nil, fmt.Errorf("login succeeded but no auth cookies received")
}
// Store as "cookie:<name>=<value>; <name>=<value>" format
// Transport will send these as Cookie header
tokenValue := "cookie:" + strings.Join(authCookies, "; ")
if err := StoreToken(tokenValue); err != nil {
return nil, fmt.Errorf("failed to store credentials: %w", err)
}
// Verify the stored credentials actually work
if _, verifyErr := GetCurrentUser(); verifyErr != nil {
// Clean up the bad token
_ = DeleteToken()
return nil, fmt.Errorf("login failed: credentials not accepted by API (%w)", verifyErr)
}
return &result, nil
}
// GetCurrentUser fetches the authenticated user info.
// Returns error if not authenticated or token is invalid.
func GetCurrentUser() (map[string]interface{}, error) {
cfg, err := config.Load()
if err != nil {
return nil, err
}
client := NewHTTPClient()
resp, err := client.Get(cfg.BaseURL + "/users/me.json")
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
// GitLink returns {"status": -1, "message": "..."} for auth errors with HTTP 200
if status, ok := result["status"].(float64); ok && status < 0 {
msg, _ := result["message"].(string)
return nil, fmt.Errorf("%s", msg)
}
// Verify we got actual user data
if _, ok := result["login"]; !ok {
return nil, fmt.Errorf("invalid response: missing login field")
}
return result, nil
}