实现带验证码的登录

This commit is contained in:
czp 2018-02-02 14:39:33 +08:00
parent ef04b65984
commit 0219a0c8fe
11 changed files with 380 additions and 21 deletions

View File

@ -50,6 +50,8 @@ IOException 在网络故障时抛出
LoginException 在用户名密码不匹配时抛出
CaptchaMismatchException 在验证码不正确时抛出, 见下文 [验证码问题](#验证码问题) 一节
login 方法的返回值为 LoginResponseEntity 类型, 使用
.login(...).toBilibiliAccount()
@ -79,6 +81,66 @@ IOException 在网络故障时抛出
LoginException 在 accessToken 错误或过期时抛出
### 验证码问题
当对一个账户在短时间内(时长不明确)尝试多次错误的登录(密码错误)后, 再尝试登录该账号, 会被要求验证码.
此时登录操作会抛出 CaptchaMismatchException 异常, 表示必须调用另一个接口
public LoginResponseEntity login(String username,
String password,
String captcha,
String cookie) throws IOException, LoginException, CaptchaMismatchException
这个接口将带 captcha 参数地去登录, 注意这里还有一个 cookie 参数.
下面先给出一段正确使用该接口的代码, 随后会解释其步骤
String username = "yourUsername";
String password = "yourPassword";
BilibiliAPI bilibiliAPI = new BilibiliAPI();
try {
bilibiliAPI.login(username, password);
} catch (CaptchaMismatchException e) { //如果该账号现在需要验证码来进行登录, 就会抛出异常
final cookie = "sid=123456"; //自己造一个 cookie 或者从服务器取得
Response response = bilibiliAPI.getPassportService()
.getCaptcha(cookie)
.execute();
InputStream inputStream = response.body().byteStream();
String captcha = letUserInputCaptcha(inputStream); //让用户根据图片输入验证码
bilibiliAPI.login(
username,
password,
captcha,
cookie
);
}
验证码是通过访问 https://passport.bilibili.com/captcha 这个地址获得的.
访问这个地址需要带有一个 cookie, cookie 里面要有 "sid=xxx", 然后服务端会记录下对应关系, 也就是 sid xxx 对应验证码 yyy, 然后就可以验证了.
我们会发现, 访问任何 passport.bilibili.com 下面的地址, 都会被分发一个 cookie, 里面带有 sid 的值. 我们访问 /captcha 也会被分发一个 cookie, 但是这个通过访问 captcha 而被分发得到的 cookie 和访问得到的验证码图片, 没有对应关系. 推测是因为 cookie 的发放在请求进入甚至模块运行完毕后才进行.
所以我们如果不带 cookie 去访问 /captcha, 我们这样拿到的由 /captcha 返回的 cookie 和 验证码, 是不匹配的.
所以我们要先从其他地方获取一个 cookie.
我们可以用 /api/oauth2/getKey(获取加密密码用的 hash 和公钥) 来获取一个 cookie
String cookie = bilibiliAPI.getPassportService()
.getKey()
.execute()
.headers()
.get("Set-cookie");
/captcha 不验证 cookie 正确性, 我们可以直接使用假的 cookie (比如 123456)对其发起验证码请求, 它会记录下这个假的 cookie 和 验证码 的对应关系, 一样能验证成功. 但是不推荐这么做.
简单地说, 只要我们是带 cookie 访问 /captcha 的, 那么我们得到的验证码, 是和这个 cookie 绑定的. 我们接下去用这个 cookie 和 这个验证码的值 去进行带验证码的登录, 就可以成功登陆.
至于验证码怎么处理, 可以显示给最终用户, 让用户来输入, 或者用一些预训练模型自动识别验证码.
这个带验证码的登录接口也会继续抛出 CaptchaMismatchException, 如果验证码输入错误的话.
### API 调用示例
打印一个直播间的历史弹幕

View File

@ -7,6 +7,7 @@ import com.hiczp.bilibili.api.passport.PassportService;
import com.hiczp.bilibili.api.passport.entity.LoginResponseEntity;
import com.hiczp.bilibili.api.passport.entity.LogoutResponseEntity;
import com.hiczp.bilibili.api.passport.entity.RefreshTokenResponseEntity;
import com.hiczp.bilibili.api.passport.exception.CaptchaMismatchException;
import okhttp3.OkHttpClient;
import okhttp3.logging.HttpLoggingInterceptor;
import org.slf4j.Logger;
@ -20,7 +21,6 @@ import java.text.SimpleDateFormat;
import java.time.Instant;
import java.util.Date;
//TODO 尚未实现自动 refreshToken 的拦截器
public class BilibiliAPI implements BilibiliServiceProvider, LiveClientProvider {
private static final Logger LOGGER = LoggerFactory.getLogger(BilibiliAPI.class);
@ -66,7 +66,7 @@ public class BilibiliAPI implements BilibiliServiceProvider, LiveClientProvider
))
.addInterceptor(new AddAppKeyInterceptor(bilibiliClientProperties))
.addInterceptor(new SortParamsAndSignInterceptor(bilibiliClientProperties))
.addInterceptor(new ErrorResponseConverterInterceptor())
.addInterceptor(new ErrorResponseBodyConverterInterceptor())
.addNetworkInterceptor(new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BASIC))
.build();
@ -107,7 +107,7 @@ public class BilibiliAPI implements BilibiliServiceProvider, LiveClientProvider
.addInterceptor(new AddAccessKeyInterceptor(bilibiliAccount))
.addInterceptor(new AddAppKeyInterceptor(bilibiliClientProperties))
.addInterceptor(new SortParamsAndSignInterceptor(bilibiliClientProperties))
.addInterceptor(new ErrorResponseConverterInterceptor())
.addInterceptor(new ErrorResponseBodyConverterInterceptor())
.addNetworkInterceptor(new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BASIC))
.build();
@ -121,12 +121,21 @@ public class BilibiliAPI implements BilibiliServiceProvider, LiveClientProvider
return liveService;
}
public LoginResponseEntity login(String username, String password) throws IOException, LoginException {
public LoginResponseEntity login(String username, String password) throws IOException, LoginException, CaptchaMismatchException {
return login(username, password, null, null);
}
public LoginResponseEntity login(String username,
String password,
String captcha,
String cookie) throws IOException, LoginException, CaptchaMismatchException {
LOGGER.info("Login attempting with username '{}'", username);
LoginResponseEntity loginResponseEntity = BilibiliSecurityHelper.login(
this,
username,
password
password,
captcha,
cookie
);
//判断返回值
switch (loginResponseEntity.getCode()) {
@ -141,7 +150,7 @@ public class BilibiliAPI implements BilibiliServiceProvider, LiveClientProvider
throw new LoginException("password error or hash expired");
}
case ServerErrorCode.Passport.CAPTCHA_NOT_MATCH: {
throw new LoginException(loginResponseEntity.getMessage());
throw new CaptchaMismatchException(loginResponseEntity.getMessage());
}
default: {
throw new IOException(loginResponseEntity.getMessage());

View File

@ -1,6 +1,5 @@
package com.hiczp.bilibili.api;
import com.hiczp.bilibili.api.passport.PassportService;
import com.hiczp.bilibili.api.passport.entity.KeyEntity;
import com.hiczp.bilibili.api.passport.entity.LoginResponseEntity;
import com.hiczp.bilibili.api.passport.entity.LogoutResponseEntity;
@ -22,11 +21,9 @@ import java.util.Base64;
import java.util.stream.Collectors;
public class BilibiliSecurityHelper {
public static LoginResponseEntity login(BilibiliServiceProvider bilibiliServiceProvider,
String username,
String password) throws IOException {
PassportService passportService = bilibiliServiceProvider.getPassportService();
KeyEntity keyEntity = passportService.getKey().execute().body();
private static String cipherPassword(BilibiliServiceProvider bilibiliServiceProvider,
String password) throws IOException {
KeyEntity keyEntity = bilibiliServiceProvider.getPassportService().getKey().execute().body();
//服务器返回异常错误码
if (keyEntity.getCode() != 0) {
throw new IOException(keyEntity.getMessage());
@ -60,9 +57,27 @@ public class BilibiliSecurityHelper {
} catch (InvalidKeyException e) {
throw new IOException("get broken RSA public key");
}
//发起登录请求
return passportService.login(username, cipheredPassword)
.execute()
return cipheredPassword;
}
public static LoginResponseEntity login(BilibiliServiceProvider bilibiliServiceProvider,
String username,
String password) throws IOException {
return login(bilibiliServiceProvider, username, password, null, null);
}
public static LoginResponseEntity login(BilibiliServiceProvider bilibiliServiceProvider,
String username,
String password,
String captcha,
String cookie) throws IOException {
return bilibiliServiceProvider.getPassportService()
.login(
username,
cipherPassword(bilibiliServiceProvider, password),
captcha,
cookie
).execute()
.body();
}

View File

@ -15,8 +15,8 @@ import java.nio.charset.StandardCharsets;
//由于服务器返回错误时的 data 字段类型不固定, 会导致 json 反序列化出错.
//该拦截器将在返回的 code 不为 0 , response 转换为包含一个空 data json 字符串.
public class ErrorResponseConverterInterceptor implements Interceptor {
private static final Logger LOGGER = LoggerFactory.getLogger(ErrorResponseConverterInterceptor.class);
public class ErrorResponseBodyConverterInterceptor implements Interceptor {
private static final Logger LOGGER = LoggerFactory.getLogger(ErrorResponseBodyConverterInterceptor.class);
private static final JsonParser JSON_PARSER = new JsonParser();
private static final Gson GSON = new Gson();

View File

@ -0,0 +1,15 @@
package com.hiczp.bilibili.api.interceptor;
import okhttp3.Interceptor;
import okhttp3.Response;
import java.io.IOException;
//当返回的数据中的 code 表示未登录时, response HttpStatus 改为 401, 以供 authenticator 使用
//TODO 未实现
public class ErrorResponseStatusConverterInterceptor implements Interceptor {
@Override
public Response intercept(Chain chain) throws IOException {
throw new UnsupportedOperationException();
}
}

View File

@ -1,12 +1,30 @@
package com.hiczp.bilibili.api.passport;
import com.hiczp.bilibili.api.BaseUrlDefinition;
import com.hiczp.bilibili.api.passport.entity.*;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.logging.HttpLoggingInterceptor;
import retrofit2.Call;
import retrofit2.http.GET;
import retrofit2.http.Header;
import retrofit2.http.POST;
import retrofit2.http.Query;
public interface PassportService {
//获取验证码
default okhttp3.Call getCaptcha(String cookies) {
return new OkHttpClient.Builder()
.addInterceptor(new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BASIC))
.build()
.newCall(
new Request.Builder()
.url(BaseUrlDefinition.PASSPORT + "captcha")
.header("Cookie", cookies)
.build()
);
}
@POST("api/oauth2/getKey")
Call<KeyEntity> getKey();
@ -14,10 +32,8 @@ public interface PassportService {
Call<LoginResponseEntity> login(@Query("username") String username, @Query("password") String password);
//在一段时间内进行多次错误的登录, 将被要求输入验证码
//TODO 尚不明确 captcha 是如何工作的
@Deprecated
@POST("api/oauth2/login")
Call<LoginResponseEntity> loginWithCaptcha(@Query("username") String username, @Query("password") String password, @Query("captcha") String captcha);
Call<LoginResponseEntity> login(@Query("username") String username, @Query("password") String password, @Query("captcha") String captcha, @Header("Cookie") String cookies);
@GET("api/oauth2/info")
Call<InfoEntity> getInfo(@Query("access_token") String accessToken);

View File

@ -0,0 +1,11 @@
package com.hiczp.bilibili.api.passport.exception;
public class CaptchaMismatchException extends RuntimeException {
public CaptchaMismatchException() {
}
public CaptchaMismatchException(String message) {
super(message);
}
}

View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<form xmlns="http://www.intellij.com/uidesigner/form/" version="1" bind-to-class="com.hiczp.bilibili.api.test.CaptchaInputDialog">
<grid id="cbd77" binding="contentPane" layout-manager="GridBagLayout">
<constraints>
<xy x="48" y="54" width="436" height="297"/>
</constraints>
<properties/>
<border type="empty">
<size top="2" left="2" bottom="2" right="2"/>
</border>
<children>
<grid id="94766" layout-manager="GridBagLayout">
<constraints>
<grid row="1" column="0" row-span="1" col-span="1" vsize-policy="1" hsize-policy="3" anchor="0" fill="3" indent="0" use-parent-layout="false"/>
<gridbag weightx="1.0" weighty="0.0"/>
</constraints>
<properties>
<preferredSize width="200" height="50"/>
</properties>
<border type="none"/>
<children>
<component id="e7465" class="javax.swing.JButton" binding="buttonOK">
<constraints>
<grid row="0" column="1" row-span="1" col-span="1" vsize-policy="0" hsize-policy="3" anchor="0" fill="1" indent="0" use-parent-layout="false"/>
<gridbag weightx="0.0" weighty="1.0"/>
</constraints>
<properties>
<text value="OK"/>
</properties>
</component>
<component id="9335c" class="javax.swing.JTextField" binding="textField">
<constraints>
<grid row="0" column="0" row-span="1" col-span="1" vsize-policy="0" hsize-policy="6" anchor="8" fill="1" indent="0" use-parent-layout="false">
<preferred-size width="150" height="-1"/>
</grid>
<gridbag weightx="1.0" weighty="1.0"/>
</constraints>
<properties/>
</component>
</children>
</grid>
<grid id="e3588" layout-manager="GridBagLayout">
<constraints>
<grid row="0" column="0" row-span="1" col-span="1" vsize-policy="3" hsize-policy="3" anchor="0" fill="3" indent="0" use-parent-layout="false"/>
<gridbag weightx="1.0" weighty="1.0"/>
</constraints>
<properties>
<preferredSize width="200" height="50"/>
</properties>
<border type="none"/>
<children>
<component id="85fac" class="javax.swing.JLabel" binding="label" custom-create="true">
<constraints>
<grid row="0" column="0" row-span="1" col-span="1" vsize-policy="0" hsize-policy="0" anchor="8" fill="0" indent="0" use-parent-layout="false"/>
<gridbag weightx="1.0" weighty="1.0"/>
</constraints>
<properties>
<text value="Label"/>
</properties>
</component>
</children>
</grid>
</children>
</grid>
</form>

View File

@ -0,0 +1,131 @@
package com.hiczp.bilibili.api.test;
import com.hiczp.bilibili.api.BilibiliAPI;
import okhttp3.Response;
import javax.imageio.ImageIO;
import javax.swing.*;
import java.awt.*;
import java.io.IOException;
public class CaptchaInputDialog extends JDialog {
private String cookie;
private String captcha;
private JPanel contentPane;
private JButton buttonOK;
private JTextField textField;
private JLabel label;
public CaptchaInputDialog() {
$$$setupUI$$$();
setContentPane(contentPane);
setModal(true);
getRootPane().setDefaultButton(buttonOK);
buttonOK.addActionListener(e -> {
captcha = textField.getText();
dispose();
});
}
public static CaptchaInputDialog create() {
CaptchaInputDialog dialog = new CaptchaInputDialog();
dialog.setTitle("Please input captcha");
dialog.pack();
dialog.setModal(true);
dialog.setVisible(true);
return dialog;
}
public String getCookie() {
return cookie;
}
public String getCaptcha() {
return captcha;
}
private void createUIComponents() {
try {
cookie = new BilibiliAPI().getPassportService().getKey()
.execute()
.headers()
.get("Set-cookie");
Response response = Config.getBilibiliAPI().getPassportService()
.getCaptcha(cookie)
.execute();
if (response.code() != 200) {
throw new IOException(response.message());
}
label = new JLabel(new ImageIcon(ImageIO.read(response.body().byteStream())));
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* Method generated by IntelliJ IDEA GUI Designer
* >>> IMPORTANT!! <<<
* DO NOT edit this method OR call it in your code!
*
* @noinspection ALL
*/
private void $$$setupUI$$$() {
createUIComponents();
contentPane = new JPanel();
contentPane.setLayout(new GridBagLayout());
contentPane.setBorder(BorderFactory.createTitledBorder(BorderFactory.createEmptyBorder(2, 2, 2, 2), null));
final JPanel panel1 = new JPanel();
panel1.setLayout(new GridBagLayout());
panel1.setPreferredSize(new Dimension(200, 50));
GridBagConstraints gbc;
gbc = new GridBagConstraints();
gbc.gridx = 0;
gbc.gridy = 1;
gbc.weightx = 1.0;
gbc.fill = GridBagConstraints.BOTH;
contentPane.add(panel1, gbc);
buttonOK = new JButton();
buttonOK.setText("OK");
gbc = new GridBagConstraints();
gbc.gridx = 1;
gbc.gridy = 0;
gbc.weighty = 1.0;
gbc.fill = GridBagConstraints.HORIZONTAL;
panel1.add(buttonOK, gbc);
textField = new JTextField();
gbc = new GridBagConstraints();
gbc.gridx = 0;
gbc.gridy = 0;
gbc.weightx = 1.0;
gbc.weighty = 1.0;
gbc.anchor = GridBagConstraints.WEST;
gbc.fill = GridBagConstraints.HORIZONTAL;
panel1.add(textField, gbc);
final JPanel panel2 = new JPanel();
panel2.setLayout(new GridBagLayout());
panel2.setPreferredSize(new Dimension(200, 50));
gbc = new GridBagConstraints();
gbc.gridx = 0;
gbc.gridy = 0;
gbc.weightx = 1.0;
gbc.weighty = 1.0;
gbc.fill = GridBagConstraints.BOTH;
contentPane.add(panel2, gbc);
label.setText("Label");
gbc = new GridBagConstraints();
gbc.gridx = 0;
gbc.gridy = 0;
gbc.weightx = 1.0;
gbc.weighty = 1.0;
gbc.anchor = GridBagConstraints.WEST;
panel2.add(label, gbc);
}
/**
* @noinspection ALL
*/
public JComponent $$$getRootComponent$$$() {
return contentPane;
}
}

View File

@ -1,12 +1,34 @@
package com.hiczp.bilibili.api.test;
import com.hiczp.bilibili.api.passport.exception.CaptchaMismatchException;
import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.*;
public class LoginTest {
private static final Logger LOGGER = LoggerFactory.getLogger(LoginTest.class);
private static final Config CONFIG = Config.getInstance();
@Test
public void login() throws Exception {
Config.getBilibiliAPI().login(CONFIG.getUsername(), CONFIG.getPassword());
try {
Config.getBilibiliAPI().login(CONFIG.getUsername(), CONFIG.getPassword());
} catch (CaptchaMismatchException e) {
LOGGER.info("Need captcha");
if (GraphicsEnvironment.isHeadless()) {
LOGGER.error("Need graphics support to display captcha, login failed");
throw new UnsupportedOperationException(e);
} else {
CaptchaInputDialog captchaInputDialog = CaptchaInputDialog.create();
Config.getBilibiliAPI().login(
CONFIG.getUsername(),
CONFIG.getPassword(),
captchaInputDialog.getCaptcha(),
captchaInputDialog.getCookie()
);
}
}
}
}

View File

@ -4,6 +4,7 @@ import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.hiczp.bilibili.api.BilibiliAPI;
import com.hiczp.bilibili.api.BilibiliSecurityHelper;
import com.hiczp.bilibili.api.ServerErrorCode;
import com.hiczp.bilibili.api.passport.entity.LoginResponseEntity;
import com.hiczp.bilibili.api.passport.entity.RefreshTokenResponseEntity;
import org.junit.Test;
@ -22,6 +23,10 @@ public class SecurityHelperTest {
CONFIG.getUsername(),
CONFIG.getPassword()
);
if (loginResponseEntity.getCode() == ServerErrorCode.Passport.CAPTCHA_NOT_MATCH) {
LOGGER.error("This account need captcha to login, ignore test");
return;
}
LOGGER.info("{}", GSON.toJson(loginResponseEntity));
BilibiliSecurityHelper.logout(new BilibiliAPI(), loginResponseEntity.getData().getAccessToken());
}
@ -43,6 +48,10 @@ public class SecurityHelperTest {
CONFIG.getUsername(),
CONFIG.getPassword()
);
if (loginResponseEntity.getCode() == ServerErrorCode.Passport.CAPTCHA_NOT_MATCH) {
LOGGER.error("This account need captcha to login, ignore test");
return;
}
RefreshTokenResponseEntity refreshTokenResponseEntity = BilibiliSecurityHelper.refreshToken(
new BilibiliAPI(),
loginResponseEntity.getData().getAccessToken(),
@ -69,6 +78,10 @@ public class SecurityHelperTest {
CONFIG.getUsername(),
CONFIG.getPassword()
);
if (loginResponseEntity.getCode() == ServerErrorCode.Passport.CAPTCHA_NOT_MATCH) {
LOGGER.error("This account need captcha to login, ignore test");
return;
}
String accessToken = loginResponseEntity.getData().getAccessToken();
RefreshTokenResponseEntity refreshTokenResponseEntity = BilibiliSecurityHelper.refreshToken(
new BilibiliAPI(),