feat: add config, auth, fetcher, java modules
- 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>
This commit is contained in:
@@ -1,2 +1,256 @@
|
||||
// package auth handles Yggdrasil authentication (login, refresh, validate).
|
||||
// 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user