Files
eve-signaler/esi_sso.go

783 lines
20 KiB
Go

package main
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
const (
issuerAuthorizeURL = "https://login.eveonline.com/v2/oauth/authorize"
issuerTokenURL = "https://login.eveonline.com/v2/oauth/token"
esiBase = "https://esi.evetech.net"
)
type ESISSO struct {
clientID string
redirectURI string
scopes []string
state string
codeVerifier string
codeChallenge string
mu sync.Mutex
accessToken string
refreshToken string
expiresAt time.Time
characterID int64
characterName string
server *http.Server
db *gorm.DB
nameCacheOnce sync.Once
nameToID map[string]int64 // lowercased name -> id
}
type SolarSystem struct {
SolarSystemID int64 `gorm:"column:solarSystemID;primaryKey"`
SolarSystemName string `gorm:"column:solarSystemName"`
}
func (SolarSystem) TableName() string { return "mapSolarSystems" }
type ESIToken struct {
ID uint `gorm:"primaryKey"`
CharacterID int64 `gorm:"index"`
CharacterName string
AccessToken string
RefreshToken string
ExpiresAt time.Time
WaypointEnabled bool `gorm:"default:true"`
UpdatedAt time.Time
CreatedAt time.Time
}
type CharacterInfo struct {
CharacterID int64 `json:"character_id"`
CharacterName string `json:"character_name"`
WaypointEnabled bool `json:"waypoint_enabled"`
}
// CharacterLocation represents a character's current location
type CharacterLocation struct {
CharacterID int64 `json:"character_id"`
CharacterName string `json:"character_name"`
SolarSystemID int64 `json:"solar_system_id"`
SolarSystemName string `json:"solar_system_name"`
RetrievedAt time.Time `json:"retrieved_at"`
}
type esiCharacterLocationResponse struct {
SolarSystemID int64 `json:"solar_system_id"`
StationID int64 `json:"station_id"`
StructureID int64 `json:"structure_id"`
}
func NewESISSO(clientID string, redirectURI string, scopes []string) *ESISSO {
s := &ESISSO{
clientID: clientID,
redirectURI: redirectURI,
scopes: scopes,
}
_ = s.initDB()
return s
}
func (s *ESISSO) initDB() error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
dbPath := filepath.Join(home, ".industrializer", "sqlite-latest.sqlite")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
return err
}
if err := db.AutoMigrate(&ESIToken{}); err != nil {
return err
}
s.db = db
return nil
}
// resolveSystemNameByID returns the system name for an ID from the local DB, or empty if not found
func (s *ESISSO) resolveSystemNameByID(id int64) string {
if s.db == nil || id == 0 {
return ""
}
var ss SolarSystem
if err := s.db.Select("solarSystemName").First(&ss, "solarSystemID = ?", id).Error; err != nil {
return ""
}
return ss.SolarSystemName
}
// GetCharacterLocations returns current locations for all stored characters
func (s *ESISSO) GetCharacterLocations(ctx context.Context) ([]CharacterLocation, error) {
if s.db == nil {
return nil, errors.New("db not initialised")
}
var tokens []ESIToken
if err := s.db.Find(&tokens).Error; err != nil {
return nil, err
}
out := make([]CharacterLocation, 0, len(tokens))
client := &http.Client{Timeout: 5 * time.Second}
for i := range tokens {
t := &tokens[i]
tok, err := s.ensureAccessTokenFor(ctx, t)
if err != nil {
continue
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, esiBase+"/v2/characters/"+strconv.FormatInt(t.CharacterID, 10)+"/location", nil)
if err != nil {
continue
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+tok)
resp, err := client.Do(req)
if err != nil {
continue
}
func() {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return
}
var lr esiCharacterLocationResponse
if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil {
return
}
name := s.resolveSystemNameByID(lr.SolarSystemID)
out = append(out, CharacterLocation{CharacterID: t.CharacterID, CharacterName: t.CharacterName, SolarSystemID: lr.SolarSystemID, SolarSystemName: name, RetrievedAt: time.Now()})
}()
}
return out, nil
}
func (s *ESISSO) saveToken() {
if s.db == nil || s.characterID == 0 {
return
}
t := ESIToken{}
s.db.Where("character_id = ?", s.characterID).First(&t)
t.CharacterID = s.characterID
t.CharacterName = s.characterName
t.AccessToken = s.accessToken
t.RefreshToken = s.refreshToken
t.ExpiresAt = s.expiresAt
s.db.Save(&t)
}
func (s *ESISSO) loadToken() {
if s.db == nil || s.characterID == 0 {
return
}
t := ESIToken{}
if err := s.db.Where("character_id = ?", s.characterID).First(&t).Error; err == nil {
s.accessToken = t.AccessToken
s.refreshToken = t.RefreshToken
s.expiresAt = t.ExpiresAt
}
}
func (s *ESISSO) BuildAuthorizeURL() (string, error) {
if s.clientID == "" {
return "", errors.New("EVE_SSO_CLIENT_ID not set")
}
verifier, challenge, err := generatePKCE()
if err != nil {
return "", err
}
s.codeVerifier = verifier
s.codeChallenge = challenge
s.state = randString(24)
q := url.Values{}
q.Set("response_type", "code")
q.Set("client_id", s.clientID)
q.Set("redirect_uri", s.redirectURI)
if len(s.scopes) > 0 {
q.Set("scope", strings.Join(s.scopes, " "))
}
q.Set("state", s.state)
q.Set("code_challenge", s.codeChallenge)
q.Set("code_challenge_method", "S256")
return issuerAuthorizeURL + "?" + q.Encode(), nil
}
func (s *ESISSO) StartCallbackServerAsync() error {
u, err := url.Parse(s.redirectURI)
if err != nil {
return err
}
if u.Scheme != "http" && u.Scheme != "https" {
return errors.New("redirect URI must be http(s)")
}
hostPort := u.Host
if !strings.Contains(hostPort, ":") {
if u.Scheme == "https" {
hostPort += ":443"
} else {
hostPort += ":80"
}
}
mux := http.NewServeMux()
mux.HandleFunc(u.Path, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
q := r.URL.Query()
code := q.Get("code")
st := q.Get("state")
if code == "" || st == "" || st != s.state {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("Invalid SSO response"))
return
}
if err := s.exchangeToken(r.Context(), code); err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("Token exchange failed: " + err.Error()))
return
}
_, _ = io.WriteString(w, "Login successful. You can close this window.")
go func() {
time.Sleep(200 * time.Millisecond)
_ = s.server.Shutdown(context.Background())
}()
})
ln, err := net.Listen("tcp", hostPort)
if err != nil {
return err
}
s.server = &http.Server{Handler: mux}
go func() { _ = s.server.Serve(ln) }()
return nil
}
func (s *ESISSO) StartCallbackServer() error {
u, err := url.Parse(s.redirectURI)
if err != nil {
return err
}
if u.Scheme != "http" && u.Scheme != "https" {
return errors.New("redirect URI must be http(s)")
}
hostPort := u.Host
if !strings.Contains(hostPort, ":") {
if u.Scheme == "https" {
hostPort += ":443"
} else {
hostPort += ":80"
}
}
mux := http.NewServeMux()
mux.HandleFunc(u.Path, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
q := r.URL.Query()
code := q.Get("code")
st := q.Get("state")
if code == "" || st == "" || st != s.state {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("Invalid SSO response"))
return
}
if err := s.exchangeToken(r.Context(), code); err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("Token exchange failed: " + err.Error()))
return
}
_, _ = io.WriteString(w, "Login successful. You can close this window.")
go func() {
time.Sleep(200 * time.Millisecond)
_ = s.server.Shutdown(context.Background())
}()
})
ln, err := net.Listen("tcp", hostPort)
if err != nil {
return err
}
s.server = &http.Server{Handler: mux}
return s.server.Serve(ln)
}
func (s *ESISSO) exchangeToken(ctx context.Context, code string) error {
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("client_id", s.clientID)
form.Set("code_verifier", s.codeVerifier)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, issuerTokenURL, strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("token exchange failed: %s: %s", resp.Status, string(b))
}
var tr tokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
return err
}
s.mu.Lock()
if tr.AccessToken != "" {
s.accessToken = tr.AccessToken
}
if tr.RefreshToken != "" {
s.refreshToken = tr.RefreshToken
}
if tr.ExpiresIn > 0 {
s.expiresAt = time.Now().Add(time.Duration(tr.ExpiresIn-30) * time.Second)
}
s.mu.Unlock()
name, cid := parseTokenCharacter(tr.AccessToken)
s.characterName = name
s.characterID = cid
s.saveToken()
return nil
}
func (s *ESISSO) refresh(ctx context.Context) error {
s.mu.Lock()
rt := s.refreshToken
s.mu.Unlock()
if rt == "" {
return errors.New("no refresh token")
}
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", rt)
form.Set("client_id", s.clientID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, issuerTokenURL, strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("token refresh failed: %s: %s", resp.Status, string(b))
}
var tr tokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
return err
}
s.mu.Lock()
s.accessToken = tr.AccessToken
if tr.RefreshToken != "" {
s.refreshToken = tr.RefreshToken
}
if tr.ExpiresIn > 0 {
s.expiresAt = time.Now().Add(time.Duration(tr.ExpiresIn-30) * time.Second)
}
s.mu.Unlock()
name, cid := parseTokenCharacter(tr.AccessToken)
s.characterName = name
s.characterID = cid
s.saveToken()
return nil
}
func (s *ESISSO) refreshForToken(ctx context.Context, t *ESIToken) (*ESIToken, error) {
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", t.RefreshToken)
form.Set("client_id", s.clientID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, issuerTokenURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("token refresh failed: %s: %s", resp.Status, string(b))
}
var tr tokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
return nil, err
}
t.AccessToken = tr.AccessToken
if tr.RefreshToken != "" {
t.RefreshToken = tr.RefreshToken
}
if tr.ExpiresIn > 0 {
t.ExpiresAt = time.Now().Add(time.Duration(tr.ExpiresIn-30) * time.Second)
}
if s.db != nil {
_ = s.db.Save(t).Error
}
return t, nil
}
func (s *ESISSO) ensureAccessToken(ctx context.Context) (string, error) {
s.mu.Lock()
if s.accessToken == "" && s.db != nil && s.characterID != 0 {
s.mu.Unlock()
s.loadToken()
s.mu.Lock()
}
tok := s.accessToken
exp := s.expiresAt
s.mu.Unlock()
if tok == "" {
return "", errors.New("not logged in")
}
if time.Now().After(exp.Add(-60 * time.Second)) {
if err := s.refresh(ctx); err != nil {
return "", err
}
s.mu.Lock()
tok = s.accessToken
s.mu.Unlock()
}
return tok, nil
}
func (s *ESISSO) ensureAccessTokenFor(ctx context.Context, t *ESIToken) (string, error) {
if t.AccessToken == "" || time.Now().After(t.ExpiresAt.Add(-60*time.Second)) {
nt, err := s.refreshForToken(ctx, t)
if err != nil {
return "", err
}
return nt.AccessToken, nil
}
return t.AccessToken, nil
}
func (s *ESISSO) PostWaypoint(destinationID int64, clearOthers bool, addToBeginning bool) error {
tok, err := s.ensureAccessToken(context.Background())
if err != nil {
return err
}
return s.postWaypointWithToken(tok, destinationID, clearOthers, addToBeginning)
}
func (s *ESISSO) postWaypointWithToken(tok string, destinationID int64, clearOthers bool, addToBeginning bool) error {
q := url.Values{}
q.Set("destination_id", strconv.FormatInt(destinationID, 10))
q.Set("add_to_beginning", strconv.FormatBool(addToBeginning))
q.Set("clear_other_waypoints", strconv.FormatBool(clearOthers))
q.Set("datasource", "tranquility")
endpoint := esiBase + "/v2/ui/autopilot/waypoint?" + q.Encode()
req, err := http.NewRequest(http.MethodPost, endpoint, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Accept", "application/json")
req.Header.Set("X-User-Agent", "signalerr/1.0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
return nil
}
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("waypoint failed: %s: %s", resp.Status, string(b))
}
func (s *ESISSO) PostWaypointForAll(destinationID int64, clearOthers bool, addToBeginning bool) error {
if s.db == nil {
return errors.New("db not initialised")
}
var tokens []ESIToken
if err := s.db.Find(&tokens).Error; err != nil {
return err
}
var firstErr error
for i := range tokens {
if !tokens[i].WaypointEnabled {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
tok, err := s.ensureAccessTokenFor(ctx, &tokens[i])
cancel()
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
if err := s.postWaypointWithToken(tok, destinationID, clearOthers, addToBeginning); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// ToggleCharacterWaypointEnabled toggles the waypoint enabled status for a character
func (s *ESISSO) ToggleCharacterWaypointEnabled(characterID int64) error {
if s.db == nil {
return errors.New("db not initialised")
}
var token ESIToken
if err := s.db.Where("character_id = ?", characterID).First(&token).Error; err != nil {
return err
}
token.WaypointEnabled = !token.WaypointEnabled
return s.db.Save(&token).Error
}
func (s *ESISSO) Status() SSOStatus {
s.mu.Lock()
defer s.mu.Unlock()
return SSOStatus{
LoggedIn: s.accessToken != "",
CharacterID: s.characterID,
CharacterName: s.characterName,
ExpiresAt: s.expiresAt,
}
}
type SSOStatus struct {
LoggedIn bool
CharacterID int64
CharacterName string
ExpiresAt time.Time
}
type tokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
func generatePKCE() (verifier string, challenge string, err error) {
buf := make([]byte, 32)
if _, err = rand.Read(buf); err != nil {
return
}
v := base64.RawURLEncoding.EncodeToString(buf)
h := sha256.Sum256([]byte(v))
c := base64.RawURLEncoding.EncodeToString(h[:])
return v, c, nil
}
func randString(n int) string {
buf := make([]byte, n)
_, _ = rand.Read(buf)
return base64.RawURLEncoding.EncodeToString(buf)
}
func parseTokenCharacter(jwt string) (name string, id int64) {
parts := strings.Split(jwt, ".")
if len(parts) != 3 {
return "", 0
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", 0
}
var m map[string]any
if err := json.Unmarshal(payload, &m); err != nil {
return "", 0
}
if v, ok := m["name"].(string); ok {
name = v
}
if v, ok := m["sub"].(string); ok {
if idx := strings.LastIndexByte(v, ':'); idx > -1 {
if idv, err := strconv.ParseInt(v[idx+1:], 10, 64); err == nil {
id = idv
}
}
}
return
}
// ensureNameCache loads a lowercase name->id map from the local DB once
func (s *ESISSO) ensureNameCache() error {
var err error
s.nameCacheOnce.Do(func() {
cache := make(map[string]int64, 50000)
if s.db != nil {
var rows []SolarSystem
// Only select required columns
if e := s.db.Select("solarSystemID, solarSystemName").Find(&rows).Error; e != nil {
err = e
return
}
for _, r := range rows {
cache[strings.ToLower(r.SolarSystemName)] = r.SolarSystemID
}
}
s.nameToID = cache
})
return err
}
// ResolveSystemIDByName resolves using ONLY the local DB cache (case-insensitive)
func (s *ESISSO) ResolveSystemIDByName(ctx context.Context, name string) (int64, error) {
if err := s.ensureNameCache(); err != nil {
return 0, err
}
key := strings.ToLower(strings.TrimSpace(name))
if id, ok := s.nameToID[key]; ok {
return id, nil
}
return 0, fmt.Errorf("system not found in local DB: %s", name)
}
// ResolveSystemIDsByNames returns IDs in the same order as names using ONLY the local DB cache
func (s *ESISSO) ResolveSystemIDsByNames(ctx context.Context, names []string) ([]int64, error) {
if err := s.ensureNameCache(); err != nil {
return nil, err
}
out := make([]int64, len(names))
missing := []string{}
for i, n := range names {
key := strings.ToLower(strings.TrimSpace(n))
if id, ok := s.nameToID[key]; ok {
out[i] = id
} else {
missing = append(missing, n)
}
}
if len(missing) > 0 {
return nil, fmt.Errorf("systems not found in local DB: %s", strings.Join(missing, ", "))
}
return out, nil
}
// PostRouteForAll clears route and posts vias then destination last
func (s *ESISSO) PostRouteForAll(destID int64, viaIDs []int64) error {
if s.db == nil {
return errors.New("db not initialised")
}
var tokens []ESIToken
if err := s.db.Find(&tokens).Error; err != nil {
return err
}
// Deduplicate by CharacterID and filter enabled characters
uniq := make(map[int64]ESIToken, len(tokens))
for _, t := range tokens {
if t.WaypointEnabled {
uniq[t.CharacterID] = t
}
}
uniqueTokens := make([]ESIToken, 0, len(uniq))
for _, t := range uniq {
uniqueTokens = append(uniqueTokens, t)
}
var mu sync.Mutex
var firstErr error
var wg sync.WaitGroup
// Run per-character in parallel
for i := range uniqueTokens {
wg.Add(1)
go func(t ESIToken) {
defer wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
tok, err := s.ensureAccessTokenFor(ctx, &t)
cancel()
if err != nil {
mu.Lock()
if firstErr == nil {
firstErr = err
}
mu.Unlock()
return
}
// Post sequence for this character
if len(viaIDs) > 0 {
if err := s.postWaypointWithToken(tok, viaIDs[0], true, false); err != nil {
mu.Lock()
if firstErr == nil {
firstErr = err
}
mu.Unlock()
return
}
for _, id := range viaIDs[1:] {
if err := s.postWaypointWithToken(tok, id, false, false); err != nil {
mu.Lock()
if firstErr == nil {
firstErr = err
}
mu.Unlock()
return
}
}
if err := s.postWaypointWithToken(tok, destID, false, false); err != nil {
mu.Lock()
if firstErr == nil {
firstErr = err
}
mu.Unlock()
return
}
} else {
if err := s.postWaypointWithToken(tok, destID, true, false); err != nil {
mu.Lock()
if firstErr == nil {
firstErr = err
}
mu.Unlock()
return
}
}
}(uniqueTokens[i])
}
wg.Wait()
return firstErr
}