feat: add internal/auth package for credential-based login
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user