Skip to content

Commit 446db17

Browse files
committed
implement totp to generate access token
1 parent 941cfa2 commit 446db17

File tree

6 files changed

+223
-4
lines changed

6 files changed

+223
-4
lines changed

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ plugins {
44
}
55

66
group 'de.labystudio'
7-
version '1.2.0'
7+
version '1.2.1'
88

99
compileJava {
1010
sourceCompatibility = '1.8'

src/main/java/de/labystudio/spotifyapi/open/OpenSpotifyAPI.java

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
package de.labystudio.spotifyapi.open;
22

33
import com.google.gson.Gson;
4+
import com.google.gson.JsonObject;
45
import com.google.gson.stream.JsonReader;
56
import de.labystudio.spotifyapi.model.Track;
67
import de.labystudio.spotifyapi.open.model.AccessTokenResponse;
78
import de.labystudio.spotifyapi.open.model.track.OpenTrack;
9+
import de.labystudio.spotifyapi.open.util.TOTP;
810

911
import javax.imageio.ImageIO;
1012
import javax.net.ssl.HttpsURLConnection;
1113
import java.awt.image.BufferedImage;
14+
import java.io.BufferedReader;
1215
import java.io.IOException;
16+
import java.io.InputStream;
1317
import java.io.InputStreamReader;
18+
import java.net.HttpURLConnection;
1419
import java.net.URL;
1520
import java.nio.charset.StandardCharsets;
1621
import java.util.concurrent.Executor;
@@ -27,10 +32,13 @@ public class OpenSpotifyAPI {
2732

2833
private static final Gson GSON = new Gson();
2934

30-
private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/71.0.3578.98";
35+
private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/71.0.3578." + (int) (Math.random() * 90);
3136

32-
private static final String URL_API_GEN_ACCESS_TOKEN = "https://open.spotify.com/get_access_token?reason=transport&productType=web_player";
37+
private static final String URL_API_GEN_ACCESS_TOKEN = "https://open.spotify.com/get_access_token?reason=%s&productType=web-player&totp=%s&totpVer=5";
3338
private static final String URL_API_TRACKS = "https://api.spotify.com/v1/tracks/%s";
39+
private static final String URL_API_SERVER_TIME = "https://open.spotify.com/server-time";
40+
41+
public static final int[] TOTP_SECRET = {12, 56, 76, 33, 88, 44, 88, 33, 78, 78, 11, 66, 22, 22, 55, 69, 54};
3442

3543
private final Executor executor = Executors.newSingleThreadExecutor();
3644

@@ -53,16 +61,86 @@ private void generateAccessTokenAsync(Consumer<AccessTokenResponse> callback) {
5361
});
5462
}
5563

64+
public long requestServerTime() throws IOException {
65+
// Get server time
66+
URL url = new URL(URL_API_SERVER_TIME);
67+
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
68+
conn.setRequestMethod("GET");
69+
conn.setRequestProperty("Host", "open.spotify.com");
70+
conn.setRequestProperty("User-Agent", "Mozilla/5.0");
71+
conn.setRequestProperty("accept", "*/*");
72+
73+
BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream()));
74+
String response = reader.readLine();
75+
reader.close();
76+
77+
JsonObject obj = GSON.fromJson(response, JsonObject.class);
78+
return obj.get("serverTime").getAsLong();
79+
}
80+
81+
public String generateTotp(long serverTime) {
82+
StringBuilder xorResults = new StringBuilder();
83+
for (int i = 0; i < TOTP_SECRET.length; i++) {
84+
int result = TOTP_SECRET[i] ^ (i % 33 + 9);
85+
xorResults.append(result);
86+
}
87+
StringBuilder hexResult = new StringBuilder();
88+
for (int i = 0; i < xorResults.length(); i++) {
89+
hexResult.append(String.format("%02x", (int) xorResults.charAt(i)));
90+
}
91+
byte[] byteArray = new byte[hexResult.length() / 2];
92+
for (int i = 0; i < hexResult.length(); i += 2) {
93+
int byteValue = Integer.parseInt(hexResult.substring(i, i + 2), 16);
94+
byteArray[i / 2] = (byte) byteValue;
95+
}
96+
return TOTP.generateOtp(byteArray, serverTime);
97+
}
98+
5699
/**
57100
* Generate an access token for the open spotify api
58101
*/
59102
private AccessTokenResponse generateAccessToken() throws IOException {
103+
long serverTime = this.requestServerTime();
104+
String totp = this.generateTotp(serverTime);
105+
106+
AccessTokenResponse response = this.getToken("transport", totp);
107+
108+
if (!this.hasValidAccessToken(response)) {
109+
response = this.getToken("init", totp);
110+
}
111+
112+
if (!this.hasValidAccessToken(response)) {
113+
throw new IOException("Could not generate access token");
114+
}
115+
116+
return response;
117+
}
118+
119+
private boolean hasValidAccessToken(AccessTokenResponse response) {
120+
return response != null && response.accessToken != null && !response.accessToken.isEmpty();
121+
}
122+
123+
/**
124+
* Retrieve access token using totp
125+
*/
126+
private AccessTokenResponse getToken(String mode, String totp) throws IOException {
60127
// Open connection
61-
HttpsURLConnection connection = (HttpsURLConnection) new URL(URL_API_GEN_ACCESS_TOKEN).openConnection();
128+
String url = String.format(URL_API_GEN_ACCESS_TOKEN, mode, totp);
129+
HttpsURLConnection connection = (HttpsURLConnection) new URL(url).openConnection();
62130
connection.addRequestProperty("User-Agent", USER_AGENT);
63131
connection.addRequestProperty("referer", "https://open.spotify.com/");
64132
connection.addRequestProperty("app-platform", "WebPlayer");
65133

134+
int code = connection.getResponseCode();
135+
if (code != HttpURLConnection.HTTP_OK) {
136+
InputStream errorStream = connection.getErrorStream();
137+
if (errorStream != null) {
138+
JsonReader reader = new JsonReader(new InputStreamReader(errorStream));
139+
JsonObject response = GSON.fromJson(reader, JsonObject.class);
140+
throw new IOException("Could not retrieve access token: " + response.toString());
141+
}
142+
}
143+
66144
// Read response
67145
JsonReader reader = new JsonReader(new InputStreamReader(connection.getInputStream()));
68146
return GSON.fromJson(reader, AccessTokenResponse.class);
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package de.labystudio.spotifyapi.open.util;
2+
3+
import java.util.Arrays;
4+
5+
class Base32 {
6+
7+
private static final String ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
8+
private static final int[] DECODE_MAP = new int[256];
9+
10+
static {
11+
Arrays.fill(DECODE_MAP, -1);
12+
for (int i = 0; i < ALPHABET.length(); i++) {
13+
DECODE_MAP[ALPHABET.charAt(i)] = i;
14+
}
15+
}
16+
17+
public static String encode(byte[] data) {
18+
StringBuilder encoded = new StringBuilder();
19+
int index = 0, digit = 0, currByte, nextByte;
20+
int dataLength = data.length;
21+
for (int i = 0; i < dataLength; i++) {
22+
currByte = data[i] & 0xFF;
23+
index = (index + 8) % 5;
24+
digit = currByte >> index;
25+
encoded.append(ALPHABET.charAt(digit));
26+
if (index == 0 && i + 1 < dataLength) {
27+
nextByte = data[i + 1] & 0xFF;
28+
digit = ((currByte & ((1 << index) - 1)) << (5 - index)) | (nextByte >> (index + 3));
29+
encoded.append(ALPHABET.charAt(digit));
30+
}
31+
}
32+
return encoded.toString();
33+
}
34+
35+
public static byte[] decode(String encoded) {
36+
// Remove any padding characters ('=')
37+
encoded = encoded.replace("=", "");
38+
39+
int length = encoded.length();
40+
int byteLength = (length * 5) / 8;
41+
byte[] decoded = new byte[byteLength];
42+
int buffer = 0;
43+
int bitsLeft = 0;
44+
int decodedIndex = 0;
45+
46+
for (int i = 0; i < length; i++) {
47+
char c = encoded.charAt(i);
48+
int value = DECODE_MAP[c];
49+
if (value == -1) {
50+
throw new IllegalArgumentException("Invalid Base32 character: " + c);
51+
}
52+
53+
buffer = (buffer << 5) | value;
54+
bitsLeft += 5;
55+
56+
if (bitsLeft >= 8) {
57+
bitsLeft -= 8;
58+
decoded[decodedIndex++] = (byte) (buffer >> bitsLeft);
59+
buffer &= (1 << bitsLeft) - 1;
60+
}
61+
}
62+
63+
return decoded;
64+
}
65+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package de.labystudio.spotifyapi.open.util;
2+
3+
import javax.crypto.Mac;
4+
import javax.crypto.spec.SecretKeySpec;
5+
import java.nio.ByteBuffer;
6+
import java.nio.ByteOrder;
7+
import java.security.InvalidKeyException;
8+
import java.security.NoSuchAlgorithmException;
9+
10+
public class TOTP {
11+
12+
private static final String DEFAULT_ALGORITHM = "HmacSHA1";
13+
14+
private static final int DEFAULT_DIGITS = 6;
15+
private static final int DEFAULT_PERIOD = 30;
16+
17+
public static String generateOtp(byte[] secret, long time) {
18+
long counter = time / DEFAULT_PERIOD;
19+
20+
21+
// Convert counter to byte array (Big Endian)
22+
ByteBuffer buffer = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN);
23+
buffer.putLong(counter);
24+
byte[] counterBytes = buffer.array();
25+
26+
try {
27+
Mac mac = Mac.getInstance(DEFAULT_ALGORITHM);
28+
mac.init(new SecretKeySpec(secret, DEFAULT_ALGORITHM));
29+
byte[] hmac = mac.doFinal(counterBytes);
30+
31+
// Extract dynamic offset
32+
int offset = hmac[hmac.length - 1] & 0x0F;
33+
34+
// Compute binary value
35+
int binary = ((hmac[offset] & 0x7F) << 24) |
36+
((hmac[offset + 1] & 0xFF) << 16) |
37+
((hmac[offset + 2] & 0xFF) << 8) |
38+
(hmac[offset + 3] & 0xFF);
39+
40+
// Compute OTP
41+
int otp = binary % ((int) Math.pow(10, DEFAULT_DIGITS));
42+
43+
// Return zero-padded OTP
44+
return String.format("%0" + DEFAULT_DIGITS + "d", otp);
45+
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
46+
throw new IllegalStateException("Failed to generate TOTP", e);
47+
}
48+
}
49+
50+
51+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import de.labystudio.spotifyapi.open.OpenSpotifyAPI;
2+
import de.labystudio.spotifyapi.open.model.track.OpenTrack;
3+
4+
public class OpenSpotifyApiTest {
5+
6+
public static void main(String[] args) throws Exception {
7+
OpenSpotifyAPI openSpotifyAPI = new OpenSpotifyAPI();
8+
OpenTrack openTrack = openSpotifyAPI.requestOpenTrack("38T0tPVZHcPZyhtOcCP7pF");
9+
System.out.println(openTrack.name);
10+
}
11+
}

src/test/java/TOTPTest.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import de.labystudio.spotifyapi.open.OpenSpotifyAPI;
2+
3+
public class TOTPTest {
4+
5+
public static void main(String[] args) throws Exception {
6+
OpenSpotifyAPI openSpotifyAPI = new OpenSpotifyAPI();
7+
String totp = openSpotifyAPI.generateTotp(0);
8+
if (!totp.equals("371625")) {
9+
throw new Exception("Invalid TOTP: " + totp);
10+
}
11+
System.out.println(totp);
12+
}
13+
14+
}

0 commit comments

Comments
 (0)