From 56b0f404d63bb1e874534d085a7918814903fcb6 Mon Sep 17 00:00:00 2001 From: Mathis Maquenne <124215603+mathismqn@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:21:52 +0200 Subject: [PATCH] feat: add internal/auth package for credential-based login --- internal/auth/auth.go | 48 +++++++ internal/auth/mobile.go | 266 +++++++++++++++++++++++++++++++++++++++ internal/auth/prompt.go | 28 +++++ internal/auth/storage.go | 61 +++++++++ 4 files changed, 403 insertions(+) create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/mobile.go create mode 100644 internal/auth/prompt.go create mode 100644 internal/auth/storage.go diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..88ac6e2 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,48 @@ +package auth + +import ( + "context" + "fmt" +) + +func Resolve(ctx context.Context, validate func(ctx context.Context, arl string) error) (string, error) { + creds, err := Load() + if err != nil { + return "", err + } + + if creds != nil && creds.ARL != "" { + if verr := validate(ctx, creds.ARL); verr == nil { + return creds.ARL, nil + } + } + + if creds != nil && creds.Email != "" && creds.Password != "" { + arl, _, err := Login(ctx, creds.Email, creds.Password) + if err != nil { + return "", fmt.Errorf("stored session expired and could not be renewed automatically: %w", err) + } + + return arl, nil + } + + return "", fmt.Errorf("run 'godeez login' or export DEEZER_ARL environment variable") +} + +func Login(ctx context.Context, email, password string) (string, string, error) { + client, err := newMobileClient() + if err != nil { + return "", "", err + } + + creds, username, err := client.Login(ctx, email, password) + if err != nil { + return "", "", err + } + + if err := Save(creds); err != nil { + return "", "", err + } + + return creds.ARL, username, nil +} diff --git a/internal/auth/mobile.go b/internal/auth/mobile.go new file mode 100644 index 0000000..0e86f81 --- /dev/null +++ b/internal/auth/mobile.go @@ -0,0 +1,266 @@ +package auth + +import ( + "bytes" + "context" + "crypto/aes" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math/rand/v2" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/mathismqn/godeez/internal/crypto" +) + +const ( + gatewayBaseURL = "https://api.deezer.com/1.0/gateway.php" + gatewayUserAgent = "Deezer/6.1.22.49 (Android; 9; Tablet; us) innotek GmbH VirtualBox" + nonceAlphabet = "012345689abdef" +) + +const ( + deviceOS = "Android" + deviceName = "VirtualBox" + deviceType = "tablet" + deviceModel = "VirtualBox" + devicePlatform = "innotek GmbH_x86_64_9" + deviceSerial = "" +) + +type mobileClient struct { + httpClient *http.Client + apiKey string + gwKey []byte + sid string +} + +func CheckGatewayEnv() error { + _, _, err := gatewayEnv() + return err +} + +func gatewayEnv() (string, string, error) { + apiKey := os.Getenv("DEEZER_MOBILE_API_KEY") + gwKey := os.Getenv("DEEZER_MOBILE_GW_KEY") + if apiKey == "" || gwKey == "" { + return "", "", fmt.Errorf("DEEZER_MOBILE_API_KEY and DEEZER_MOBILE_GW_KEY must be set to use email/password login") + } + + if len(gwKey) != aes.BlockSize { + return "", "", fmt.Errorf("DEEZER_MOBILE_GW_KEY must be exactly %d bytes long", aes.BlockSize) + } + + return apiKey, gwKey, nil +} + +func newMobileClient() (*mobileClient, error) { + apiKey, gwKey, err := gatewayEnv() + if err != nil { + return nil, err + } + + return &mobileClient{ + httpClient: &http.Client{Timeout: 20 * time.Second}, + apiKey: apiKey, + gwKey: []byte(gwKey), + }, nil +} + +func (m *mobileClient) Login(ctx context.Context, email, password string) (*Credentials, string, error) { + token, tokenKey, userKey, err := m.authenticate(ctx) + if err != nil { + return nil, "", err + } + + if err := m.checkToken(ctx, token, tokenKey); err != nil { + return nil, "", err + } + + arl, username, err := m.userAuth(ctx, email, password, userKey) + if err != nil { + return nil, "", err + } + + return &Credentials{Email: email, Password: password, ARL: arl}, username, nil +} + +func (m *mobileClient) authenticate(ctx context.Context) (string, string, string, error) { + body, err := m.gatewayRequest(ctx, "mobile_auth", "GET", "uniq_id", genUniqID(), nil) + if err != nil { + return "", "", "", err + } + + var res struct { + Results struct { + Token string `json:"TOKEN"` + } `json:"results"` + } + if err := json.Unmarshal(body, &res); err != nil { + return "", "", "", err + } + + if strings.Contains(string(body), "Undefined or invalid API key") { + return "", "", "", fmt.Errorf("DEEZER_MOBILE_API_KEY is invalid") + } + if strings.Contains(string(body), "GATEWAY_ERROR") || res.Results.Token == "" { + return "", "", "", fmt.Errorf("unexpected response from gateway") + } + + encrypted, err := hex.DecodeString(res.Results.Token) + if err != nil { + return "", "", "", err + } + + decrypted, err := crypto.DecryptECB(m.gwKey, encrypted) + if err != nil { + return "", "", "", err + } + + token := string(decrypted[0:64]) + tokenKey := string(decrypted[64:80]) + userKey := string(decrypted[80:96]) + + return token, tokenKey, userKey, nil +} + +func (m *mobileClient) checkToken(ctx context.Context, token, tokenKey string) error { + encrypted, err := crypto.EncryptECB([]byte(tokenKey), []byte(token)) + if err != nil { + return err + } + authToken := hex.EncodeToString(encrypted) + + body, err := m.gatewayRequest(ctx, "api_checkToken", "GET", "auth_token", authToken, nil) + if err != nil { + return err + } + + var res struct { + Results string `json:"results"` + } + if err := json.Unmarshal(body, &res); err != nil { + return err + } + if res.Results == "" { + return fmt.Errorf("unexpected response from gateway") + } + m.sid = res.Results + + return nil +} + +func (m *mobileClient) userAuth(ctx context.Context, email, password, userKey string) (string, string, error) { + encryptedPassword, err := crypto.EncryptECB([]byte(userKey), crypto.ZeroPad([]byte(password))) + if err != nil { + return "", "", err + } + + payload := map[string]string{ + "mail": email, + "password": hex.EncodeToString(encryptedPassword), + "device_serial": deviceSerial, + "platform": devicePlatform, + "custo_version_id": "", + "custo_partner": "", + "model": deviceModel, + "device_name": deviceName, + "device_os": deviceOS, + "device_type": deviceType, + "google_play_services_availability": "1", + "consent_string": "", + } + + jsonBody, err := json.Marshal(payload) + if err != nil { + return "", "", err + } + + body, err := m.gatewayRequest(ctx, "mobile_userAuth", "POST", "", "", jsonBody) + if err != nil { + return "", "", err + } + + if strings.Contains(string(body), "USER_AUTH_ERROR") { + return "", "", fmt.Errorf("invalid email or password") + } + + var res struct { + Results struct { + ARL string `json:"ARL"` + UserID int `json:"USER_ID"` + BlogName string `json:"BLOG_NAME"` + } `json:"results"` + } + if err := json.Unmarshal(body, &res); err != nil { + return "", "", err + } + + if res.Results.ARL == "" || res.Results.UserID == 0 { + return "", "", fmt.Errorf("unexpected response from gateway") + } + + return res.Results.ARL, res.Results.BlogName, nil +} + +func (m *mobileClient) gatewayRequest(ctx context.Context, method, httpMethod, paramKey, paramValue string, jsonBody []byte) ([]byte, error) { + u, err := url.Parse(gatewayBaseURL) + if err != nil { + return nil, err + } + + q := u.Query() + q.Set("method", method) + q.Set("api_key", m.apiKey) + q.Set("output", "3") + if httpMethod == "POST" { + q.Set("input", "3") + } + if m.sid != "" { + q.Set("sid", m.sid) + } + if paramKey != "" { + q.Set(paramKey, paramValue) + } + u.RawQuery = q.Encode() + + var reqBody io.Reader + if jsonBody != nil { + reqBody = bytes.NewReader(jsonBody) + } + + req, err := http.NewRequestWithContext(ctx, httpMethod, u.String(), reqBody) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", gatewayUserAgent) + if jsonBody != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := m.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} + +func genUniqID() string { + b := make([]byte, 32) + for i := range b { + b[i] = nonceAlphabet[rand.IntN(len(nonceAlphabet))] + } + + return string(b) +} diff --git a/internal/auth/prompt.go b/internal/auth/prompt.go new file mode 100644 index 0000000..72e4dc1 --- /dev/null +++ b/internal/auth/prompt.go @@ -0,0 +1,28 @@ +package auth + +import ( + "bufio" + "fmt" + "os" + "strings" + + "golang.org/x/term" +) + +func PromptCredentials() (string, string, error) { + fmt.Print("Email: ") + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return "", "", err + } + email := strings.TrimSpace(line) + + fmt.Print("Password: ") + passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return "", "", err + } + + return email, string(passwordBytes), nil +} diff --git a/internal/auth/storage.go b/internal/auth/storage.go new file mode 100644 index 0000000..627d7b2 --- /dev/null +++ b/internal/auth/storage.go @@ -0,0 +1,61 @@ +package auth + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/zalando/go-keyring" +) + +const ( + keyringService = "godeez" + keyringUser = "default" +) + +type Credentials struct { + Email string `json:"email"` + Password string `json:"password"` + ARL string `json:"arl,omitempty"` +} + +func Load() (*Credentials, error) { + secret, err := keyring.Get(keyringService, keyringUser) + if err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return nil, nil + } + return nil, fmt.Errorf("system keyring is unavailable: %v", err) + } + + var creds Credentials + if err := json.Unmarshal([]byte(secret), &creds); err != nil { + return nil, err + } + + return &creds, nil +} + +func Save(creds *Credentials) error { + data, err := json.Marshal(creds) + if err != nil { + return err + } + + if err := keyring.Set(keyringService, keyringUser, string(data)); err != nil { + return fmt.Errorf("system keyring is unavailable: %v", err) + } + + return nil +} + +func Clear() error { + if err := keyring.Delete(keyringService, keyringUser); err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return nil + } + return fmt.Errorf("system keyring is unavailable: %v", err) + } + + return nil +}