- config: OS-specific paths, launcher.json load/save, DefaultSettings with MRIXSCRAFT_SERVER_URL env override - auth: Yggdrasil client (authenticate, refresh, validate), session persistence in session.json, EnsureValid flow - fetcher: HTTP download with SHA-1 verification, WorkerPool for concurrent downloads - java: JRE detection (IsInstalled/Find), platform-specific executable name - utils: SHA1File, SHA1Bytes, Unzip with zip-slip protection - cmd/launcher: wire config + auth into main, session restore on startup Co-Authored-By: OWL <noreply@anthropic.com>
257 lines
6.1 KiB
Go
257 lines
6.1 KiB
Go
// package auth handles Yggdrasil authentication with the backend.
|
|
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/config"
|
|
)
|
|
|
|
// Session represents an authenticated Yggdrasil session stored in session.json.
|
|
type Session struct {
|
|
AccessToken string `json:"accessToken"`
|
|
ClientToken string `json:"clientToken"`
|
|
UUID string `json:"uuid"`
|
|
Username string `json:"username"`
|
|
ExpiresAt time.Time `json:"expiresAt"`
|
|
}
|
|
|
|
// Yggdrasil client endpoints.
|
|
type Client struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// New creates a new Yggdrasil client bound to the given base URL.
|
|
func New(baseURL string) *Client {
|
|
return &Client{
|
|
baseURL: baseURL,
|
|
httpClient: &http.Client{
|
|
Timeout: 15 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// NewFromConfig creates a client using the server URL from launcher settings.
|
|
func NewFromConfig() (*Client, error) {
|
|
s, err := config.Load()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading config: %w", err)
|
|
}
|
|
return New(s.ServerURL), nil
|
|
}
|
|
|
|
// sessionPath returns the file path for session.json under the launcher root.
|
|
func sessionPath() (string, error) {
|
|
root, err := config.RootDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(root, "session.json"), nil
|
|
}
|
|
|
|
// generateClientToken creates a random hex token (16 bytes → 32 hex chars).
|
|
func generateClientToken() string {
|
|
b := make([]byte, 16)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// Load reads the session from disk.
|
|
func Load() (*Session, error) {
|
|
p, err := sessionPath()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := os.ReadFile(p)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("reading session: %w", err)
|
|
}
|
|
var s Session
|
|
if err := json.Unmarshal(data, &s); err != nil {
|
|
return nil, fmt.Errorf("parsing session: %w", err)
|
|
}
|
|
return &s, nil
|
|
}
|
|
|
|
// Save writes the session to disk with restricted permissions.
|
|
func (s *Session) Save() error {
|
|
p, err := sessionPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, err := json.MarshalIndent(s, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("serializing session: %w", err)
|
|
}
|
|
return os.WriteFile(p, data, 0o600)
|
|
}
|
|
|
|
// Delete removes the session file (logout).
|
|
func Delete() error {
|
|
p, err := sessionPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.Remove(p)
|
|
}
|
|
|
|
// Authenticate performs a Yggdrasil /authserver/authenticate request.
|
|
func (c *Client) Authenticate(username, password string) (*Session, error) {
|
|
body, err := json.Marshal(map[string]interface{}{
|
|
"username": username,
|
|
"password": password,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encoding request: %w", err)
|
|
}
|
|
|
|
resp, err := c.httpClient.Post(
|
|
c.baseURL+"/authserver/authenticate",
|
|
"application/json",
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sending request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
msg, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("authentication failed (%d): %s", resp.StatusCode, string(msg))
|
|
}
|
|
|
|
var result struct {
|
|
AccessToken string `json:"accessToken"`
|
|
ClientToken string `json:"clientToken"`
|
|
Profile struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
} `json:"selectedProfile"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("decoding response: %w", err)
|
|
}
|
|
|
|
clientToken := result.ClientToken
|
|
if clientToken == "" {
|
|
clientToken = generateClientToken()
|
|
}
|
|
|
|
return &Session{
|
|
AccessToken: result.AccessToken,
|
|
ClientToken: clientToken,
|
|
UUID: result.Profile.ID,
|
|
Username: result.Profile.Name,
|
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
|
}, nil
|
|
}
|
|
|
|
// Refresh performs a Yggdrasil /authserver/refresh request.
|
|
func (c *Client) Refresh(s *Session) (*Session, error) {
|
|
body, err := json.Marshal(map[string]string{
|
|
"accessToken": s.AccessToken,
|
|
"clientToken": s.ClientToken,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encoding request: %w", err)
|
|
}
|
|
|
|
resp, err := c.httpClient.Post(
|
|
c.baseURL+"/authserver/refresh",
|
|
"application/json",
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sending request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
msg, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("refresh failed (%d): %s", resp.StatusCode, string(msg))
|
|
}
|
|
|
|
var result struct {
|
|
AccessToken string `json:"accessToken"`
|
|
ClientToken string `json:"clientToken"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("decoding response: %w", err)
|
|
}
|
|
|
|
return &Session{
|
|
AccessToken: result.AccessToken,
|
|
ClientToken: result.ClientToken,
|
|
UUID: s.UUID,
|
|
Username: s.Username,
|
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
|
}, nil
|
|
}
|
|
|
|
// Validate checks if the current session token is still valid.
|
|
func (c *Client) Validate(s *Session) error {
|
|
body, err := json.Marshal(map[string]string{
|
|
"accessToken": s.AccessToken,
|
|
"clientToken": s.ClientToken,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("encoding request: %w", err)
|
|
}
|
|
|
|
resp, err := c.httpClient.Post(
|
|
c.baseURL+"/authserver/validate",
|
|
"application/json",
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("sending request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
|
msg, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("validation failed (%d): %s", resp.StatusCode, string(msg))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EnsureValid tries validate → refresh. Returns a valid session or an error.
|
|
func (c *Client) EnsureValid() (*Session, error) {
|
|
s, err := Load()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if s == nil {
|
|
return nil, nil // not logged in
|
|
}
|
|
|
|
if err := c.Validate(s); err == nil {
|
|
return s, nil // still valid
|
|
}
|
|
|
|
// Try refresh.
|
|
refreshed, err := c.Refresh(s)
|
|
if err != nil {
|
|
_ = Delete()
|
|
return nil, nil // need to re-login
|
|
}
|
|
|
|
if err := refreshed.Save(); err != nil {
|
|
return nil, fmt.Errorf("saving refreshed session: %w", err)
|
|
}
|
|
return refreshed, nil
|
|
}
|