QR コードをスキャンしてログインする原理を紹介する前に、まず次のことについて話しましょう。サーバー側の ID 認証メカニズム。通常の「アカウント パスワード」によるログイン方法を例に挙げると、サーバーはユーザーのログイン要求を受信した後、まずアカウントとパスワードの正当性を検証します。検証に合格すると、サーバーはユーザーにトークンを割り当てます。トークンはユーザーの ID 情報に関連付けられ、ユーザーのログイン資格情報として使用できます。その後、PC が再度リクエストを送信するときは、リクエストのヘッダーまたはクエリ パラメーターにトークンを含める必要があり、サーバーはトークンに基づいて現在のユーザーを識別できます。トークンの利点は、アカウントとパスワードが乗っ取られるリスクが軽減され、ユーザーがアカウントとパスワードを繰り返し入力する必要がなくなり、より便利で安全であることです。 PC でアカウントとパスワードを使用してログインするプロセスは次のとおりです:
スキャン コード ログインは本質的に本人認証方法です アカウント パスワード ログインとスキャン コード ログインの違い前者はPCのアカウントとパスワードを使用してPC用のトークンを申請し、後者は携帯電話のトークンデバイス情報を使用してPC用のトークンを申請します。これら 2 つのログイン方法の目的は同じで、どちらも PC がサーバーから「承認」を取得することです。PC のトークンを申請する前に、どちらも自分の身元をサーバーに証明する必要があります。つまり、サーバーは次のことを知る必要があります。サーバーがそのユーザーの PC トークンを生成できるようにするための現在のユーザーは誰ですか? QR コードをスキャンする前に携帯電話にログインする必要があるため、携帯電話自体にトークンが保存されており、これをサーバー側の識別に使用できます。では、なぜ携帯電話は本人確認時にデバイス情報を必要とするのでしょうか?実際、携帯電話での ID 認証は PC での認証とは少し異なります。
携帯電話でも、ログインする前にアカウント番号とパスワードを入力する必要がありますが、ログインはリクエストには、アカウントのパスワードに加えて、デバイスの種類、デバイス ID などの情報も含まれます。
ログイン要求を受信したサーバーは、アカウントとパスワードを検証し、検証に合格した後、ユーザー情報をデバイス情報に関連付けます。データ構造。 。
サーバーは携帯電話用のトークンを生成し、そのトークンをユーザー情報およびデバイス情報に関連付けます。つまり、トークンをキーとして使用し、構造を値として使用します。 -value ペアは永続化されます。ローカルに保存してから、トークンを携帯電話に返します。
携帯電話はトークンとデバイス情報を含むリクエストを送信し、サーバーはトークンに基づいて構造をクエリし、構造内のデバイス情報がデバイス情報と同じであるかどうかを検証します。携帯電話でユーザーの有効性を判断します。
PC にログインに成功すると、しばらくは通常どおり Web を閲覧できますが、Web サイトにアクセスするときに再度ログインする必要があります。トークンの有効期限は比較的短く、有効期間が長いとトークンのハイジャックのリスクが高まります。ただし、携帯電話ではこの問題が発生することはほとんどないようで、たとえば、WeChat に正常にログインした後は、WeChat を終了したり、携帯電話を再起動したりしても、いつでも WeChat を使用できます。これは、デバイス情報が一意であるためであり、たとえトークンが乗っ取られたとしても、デバイス情報が異なるため攻撃者はサーバーに対して身元を証明することができず、セキュリティ性が大幅に向上し、トークンを長期間使用することが可能となる。携帯電話のアカウントとパスワードを使用してログインするプロセスは次のとおりです:
携帯電話の本人認証の仕組みを理解した後、サーバー、コードログインのプロセス全体のスキャンについて話しましょう。ウェブ版 WeChat を例に挙げると、PC で QR コードをクリックしてログインすると、ブラウザ ページに QR コードの画像が表示されます。このとき、携帯電話で WeChat を開いて QR コードをスキャンします。 . PC には「スキャン中」と表示され、携帯電話には「ログインを確認するためにクリックすると、PC には「ログイン成功」と表示されます。
上記の処理では、携帯電話側の操作に応じてサーバーがPC側に応答することができますが、サーバー側はこの両者をどのように関連付けるのでしょうか。答えは「QRコード」を通して、厳密に言えばQRコードの内容を通してです。 QR コード デコーダを使用して、Web バージョンの WeChat で QR コードをスキャンすると、次のコンテンツを取得できます。
上の図から、QRコードには実際には URL が含まれており、携帯電話が QR コードをスキャンした後、URL に基づいてサーバーにリクエストを送信します。次に、PC ブラウザの開発者ツールを開きます:
QR コードが表示された後、PC 側は決して「アイドル」状態ではなく、モバイル側の操作の結果を知るためにポーリングを通じてサーバーにリクエストを送信し続けていることがわかります。ここで、PC から送信された URL に、値「Adv-NP1FYw==」を持つパラメーター uuid が存在することがわかります。この uuid は、QR コードに含まれる URL にも存在します。このことから、サーバーは QR コードを生成する前に QR コード ID を生成すると推測できます。QR コード ID は、QR コードのステータス、有効期限、その他の情報にバインドされ、サーバー上に一緒に保存されます。携帯端末はQRコードIDに基づいてサーバー側でQRコードの状態を操作することができ、PC側はQRコードIDに基づいてサーバー側にQRコードの状態を問い合わせることができる。
QR コードは、最初は「スキャン中」状態ですが、携帯電話がコードをスキャンした後、サーバーはステータスを「確認中」状態に変更します。このとき、からのポーリング リクエストは、 PC が到着すると、サーバーは「確認待ち」応答を返します。携帯電話がログインを確認すると、QRコードが「確認済み」ステータスに変わり、サーバーはPCの本人認証用のトークンを生成し、PCが再度要求すると、このトークンを取得できます。スキャン コード ログイン プロセス全体を次の図に示します。
#PC が「スキャン コード ログイン」リクエストを送信し、サーバーがQRコードIDとQRコードの有効期限やステータスなどの情報を保存します。
PC側はQRコードを取得して表示します。
PC は QR コードのステータスを確認するためにポーリングを開始します。QR コードは最初は「スキャン対象」状態です。
携帯電話で QR コードをスキャンして、QR コード ID を取得します。
携帯電話は「スキャン コード」リクエストをサーバーに送信します。リクエストには QR コード ID、携帯電話トークン、およびデバイス情報が含まれます。
サーバーはモバイル ユーザーの正当性を検証し、検証に合格した後、QR コードのステータスを「確認中」に設定し、ユーザー情報を QR コードに関連付けます。携帯電話用のワンタイム トークン。ログインを確認するための資格情報として使用されます。
PC側がポーリングすると、QRコードのステータスが「確認中」であることが検出されます。
携帯電話は、QR コード ID、ワンタイム トークン、デバイス情報を含む「ログイン確認」リクエストをサーバーに送信します。
サーバーはワンタイム トークンを検証し、検証に合格すると、QR コードのステータスが「確認済み」に設定され、PC 用の PC トークンが生成されます。
PC側がポーリングを行ったところ、QRコードのステータスが「確認済み」であることを検出し、PC側トークンを取得した後、PC側はポーリングを停止しました。
PC は PC トークンを介してサーバーにアクセスします。
上記のプロセス中に、携帯電話でコードをスキャンした後、サーバーがワンタイム トークンを返すことに気付きました。このトークンは ID 資格情報でもありますが、一度だけ使用してください。ワンタイムトークンの機能は、「スキャンコード要求」と「ログイン確認」要求が同じ携帯電話から発行されることを保証することであり、言い換えれば、携帯電話ユーザーは「他のユーザーのログイン確認」を行うことができません。
ワンタイム トークンについてはよくわかりませんが、サーバーのキャッシュ内で、ワンタイム トークンによってマッピングされた値には、渡された QR コード情報とデバイスが含まれているはずだと推測できます。 「スキャンコード」リクエスト情報とユーザー情報。
JDK 1.8: プロジェクトは Java 言語で書かれています。
Maven: 依存関係の管理。
Redis: Redis は、ユーザー ID 情報を保存するデータベースとして機能するだけでなく (操作を簡素化するために MySQL は使用されません)、QR コード情報やトークン情報を保存するキャッシュとしても機能します。 、など。
SpringBoot (プロジェクトの基本環境) に依存します。
Hutool: QrCodeUtil を使用して QR コード画像を生成できるオープン ソース ツール クラス。
Thymeleaf: ページ レンダリング用のテンプレート エンジン。
QR コードの生成と QR コード ステータスの保存ロジックは次のとおりです:
@RequestMapping(path = "/getQrCodeImg", method = RequestMethod.GET) public String createQrCodeImg(Model model) { String uuid = loginService.createQrImg(); String qrCode = Base64.encodeBase64String(QrCodeUtil.generatePng("http://127.0.0.1:8080/login/uuid=" + uuid, 300, 300)); model.addAttribute("uuid", uuid); model.addAttribute("QrCode", qrCode); return "login"; }
PC アクセス" 「ログイン」リクエストを行う場合、サーバーは createQrImg メソッドを呼び出して、uuid と LoginTicket オブジェクトを生成します。LoginTicket オブジェクトは、ユーザーの userId と QR コードのステータスをカプセル化します。次に、サーバーは、uuid をキーとして、LoginTicket オブジェクトを値として Redis サーバーに保存し、有効時間を 5 分 (QR コードの有効時間) に設定します。createQrImg メソッドのロジックは次のとおりです。 ##
public String createQrImg() { // uuid String uuid = CommonUtil.generateUUID(); LoginTicket loginTicket = new LoginTicket(); // 二维码最初为 WAITING 状态 loginTicket.setStatus(QrCodeStatusEnum.WAITING.getStatus()); // 存入 redis String ticketKey = CommonUtil.buildTicketKey(uuid); cacheStore.put(ticketKey, loginTicket, LoginConstant.WAIT_EXPIRED_SECONDS, TimeUnit.SECONDS); return uuid; }
我们在前一节中提到,手机端的操作主要影响二维码的状态,PC 端轮询时也是查看二维码的状态,那么为什么还要在 LoginTicket 对象中封装 userId 呢?这样做是为了将二维码与用户进行关联,想象一下我们登录网页版微信的场景,手机端扫码后,PC 端就会显示用户的头像,虽然手机端并未确认登录,但 PC 端轮询时已经获取到了当前扫码的用户(仅头像信息)。因此手机端扫码后,需要将二维码与用户绑定在一起,使用 LoginTicket 对象只是一种实现方式。二维码生成后,我们将其状态置为 "待扫描" 状态,userId 不做处理,默认为 null。
手机端发送 "扫码" 请求时,Query 参数中携带着 uuid,服务端接收到请求后,调用 scanQrCodeImg 方法,根据 uuid 查询出二维码并将其状态置为 "待确认" 状态,操作完成后服务端向手机端返回 "扫码成功" 或 "二维码已失效" 的信息:
@RequestMapping(path = "/scan", method = RequestMethod.POST) @ResponseBody public Response scanQrCodeImg(@RequestParam String uuid) { JSONObject data = loginService.scanQrCodeImg(uuid); if (data.getBoolean("valid")) { return Response.createResponse("扫码成功", data); } return Response.createErrorResponse("二维码已失效"); }
scanQrCodeImg 方法的主要逻辑如下:
public JSONObject scanQrCodeImg(String uuid) { // 避免多个移动端同时扫描同一个二维码 lock.lock(); JSONObject data = new JSONObject(); try { String ticketKey = CommonUtil.buildTicketKey(uuid); LoginTicket loginTicket = (LoginTicket) cacheStore.get(ticketKey); // redis 中 key 过期后也可能不会立即删除 Long expired = cacheStore.getExpireForSeconds(ticketKey); boolean valid = loginTicket != null && QrCodeStatusEnum.parse(loginTicket.getStatus()) == QrCodeStatusEnum.WAITING && expired != null && expired >= 0; if (valid) { User user = hostHolder.getUser(); if (user == null) { throw new RuntimeException("用户未登录"); } // 修改扫码状态 loginTicket.setStatus(QrCodeStatusEnum.SCANNED.getStatus()); Condition condition = CONDITION_CONTAINER.get(uuid); if (condition != null) { condition.signal(); CONDITION_CONTAINER.remove(uuid); } // 将二维码与用户进行关联 loginTicket.setUserId(user.getUserId()); cacheStore.put(ticketKey, loginTicket, expired, TimeUnit.SECONDS); // 生成一次性 token, 用于之后的确认请求 String onceToken = CommonUtil.generateUUID(); cacheStore.put(CommonUtil.buildOnceTokenKey(onceToken), uuid, LoginConstant.ONCE_TOKEN_EXPIRE_TIME, TimeUnit.SECONDS); data.put("once_token", onceToken); } data.put("valid", valid); return data; } finally { lock.unlock(); } }
1.首先根据 uuid 查询 Redis 中存储的 LoginTicket 对象,然后检查二维码的状态是否为 "待扫描" 状态,如果是,那么将二维码的状态改为 "待确认" 状态。如果不是,那么该二维码已被扫描过,服务端提示用户 "二维码已失效"。我们规定,只允许第一个手机端能够扫描成功,加锁的目的是为了保证 查询 + 修改 操作的原子性,避免两个手机端同时扫码,且同时检测到二维码的状态为 "待扫描"。
2.上一步操作成功后,服务端将 LoginTicket 对象中的 userId 置为当前用户(扫码用户)的 userId,也就是将二维码与用户信息绑定在一起。由于扫码请求是由手机端发送的,因此该请求一定来自于一个有效的用户,我们在项目中配置一个拦截器(也可以是过滤器),当拦截到 "扫码" 请求后,根据请求中的 token(手机端发送请求时一定会携带 token)查询出用户信息,并将其存储到 ThreadLocal 容器(hostHolder)中,之后绑定信息时就可以从 ThreadLocal 容器将用户信息提取出来。注意,这里的 token 指的手机端 token,实际中应该还有设备信息,但为了简化操作,我们忽略掉设备信息。
3.用户信息与二维码信息关联在一起后,服务端为手机端生成一个一次性 token,并存储到 Redis 服务器,其中 key 为一次性 token 的值,value 为 uuid。一次性 token 会返回给手机端,作为 "确认登录" 请求的凭证。
上述代码中,当二维码的状态被修改后,我们唤醒了在 condition 中阻塞的线程,这一步的目的是为了实现长轮询操作,下文中会介绍长轮询的设计思路。
手机端发送 "确认登录" 请求时,Query 参数中携带着 uuid,且 Header 中携带着一次性 token,服务端接收到请求后,首先验证一次性 token 的有效性,即检查一次性 token 对应的 uuid 与 Query 参数中的 uuid 是否相同,以确保扫码操作和确认操作来自于同一个手机端,该验证过程可在拦截器中配置。验证通过后,服务端调用 confirmLogin 方法,将二维码的状态置为 "已确认":
@RequestMapping(path = "/confirm", method = RequestMethod.POST) @ResponseBody public Response confirmLogin(@RequestParam String uuid) { boolean logged = loginService.confirmLogin(uuid); String msg = logged ? "登录成功!" : "二维码已失效!"; return Response.createResponse(msg, logged); }
confirmLogin 方法的主要逻辑如下:
public boolean confirmLogin(String uuid) { String ticketKey = CommonUtil.buildTicketKey(uuid); LoginTicket loginTicket = (LoginTicket) cacheStore.get(ticketKey); boolean logged = true; Long expired = cacheStore.getExpireForSeconds(ticketKey); if (loginTicket == null || expired == null || expired == 0) { logged = false; } else { lock.lock(); try { loginTicket.setStatus(QrCodeStatusEnum.CONFIRMED.getStatus()); Condition condition = CONDITION_CONTAINER.get(uuid); if (condition != null) { condition.signal(); CONDITION_CONTAINER.remove(uuid); } cacheStore.put(ticketKey, loginTicket, expired, TimeUnit.SECONDS); } finally { lock.unlock(); } } return logged; }
该方法会根据 uuid 查询二维码是否已经过期,如果未过期,那么就修改二维码的状态。
轮询操作指的是前端重复多次向后端发送相同的请求,以获知数据的变化。轮询分为长轮询和短轮询:
长轮询:服务端收到请求后,如果有数据,那么就立即返回,否则线程进入等待状态,直到有数据到达或超时,浏览器收到响应后立即重新发送相同的请求。
短轮询:服务端收到请求后无论是否有数据都立即返回,浏览器收到响应后间隔一段时间后重新发送相同的请求。
由于长轮询相比短轮询能够得到实时的响应,且更加节约资源,因此项目中我们考虑使用 ReentrantLock 来实现长轮询。轮询的目的是为了查看二维码状态的变化:
@RequestMapping(path = "/getQrCodeStatus", method = RequestMethod.GET) @ResponseBody public Response getQrCodeStatus(@RequestParam String uuid, @RequestParam int currentStatus) throws InterruptedException { JSONObject data = loginService.getQrCodeStatus(uuid, currentStatus); return Response.createResponse(null, data); }
getQrCodeStatus 方法的主要逻辑如下:
public JSONObject getQrCodeStatus(String uuid, int currentStatus) throws InterruptedException { lock.lock(); try { JSONObject data = new JSONObject(); String ticketKey = CommonUtil.buildTicketKey(uuid); LoginTicket loginTicket = (LoginTicket) cacheStore.get(ticketKey); QrCodeStatusEnum statusEnum = loginTicket == null || QrCodeStatusEnum.parse(loginTicket.getStatus()) == QrCodeStatusEnum.INVALID ? QrCodeStatusEnum.INVALID : QrCodeStatusEnum.parse(loginTicket.getStatus()); if (currentStatus == statusEnum.getStatus()) { Condition condition = CONDITION_CONTAINER.get(uuid); if (condition == null) { condition = lock.newCondition(); CONDITION_CONTAINER.put(uuid, condition); } condition.await(LoginConstant.POLL_WAIT_TIME, TimeUnit.SECONDS); } // 用户扫码后向 PC 端返回头像信息 if (statusEnum == QrCodeStatusEnum.SCANNED) { User user = userService.getCurrentUser(loginTicket.getUserId()); data.put("avatar", user.getAvatar()); } // 用户确认后为 PC 端生成 access_token if (statusEnum == QrCodeStatusEnum.CONFIRMED) { String accessToken = CommonUtil.generateUUID(); cacheStore.put(CommonUtil.buildAccessTokenKey(accessToken), loginTicket.getUserId(), LoginConstant.ACCESS_TOKEN_EXPIRE_TIME, TimeUnit.SECONDS); data.put("access_token", accessToken); } data.put("status", statusEnum.getStatus()); data.put("message", statusEnum.getMessage()); return data; } finally { lock.unlock(); } }
该方法接收两个参数,即 uuid 和 currentStatus,其中 uuid 用于查询二维码,currentStatus 用于确认二维码状态是否发生了变化,如果是,那么需要立即向 PC 端反馈。我们规定 PC 端在轮询时,请求的参数中需要携带二维码当前的状态。
1.首先根据 uuid 查询出二维码的最新状态,并比较其是否与 currentStatus 相同。如果相同,那么当前线程进入阻塞状态,直到被唤醒或者超时。
2.如果二维码状态为 "待确认",那么服务端向 PC 端返回扫码用户的头像信息(处于 "待确认" 状态时,二维码已与用户信息绑定在一起,因此可以查询出用户的头像)。
3.如果二维码状态为 "已确认",那么服务端为 PC 端生成一个 token,在之后的请求中,PC 端可通过该 token 表明自己的身份。
上述代码中的加锁操作是为了能够令当前处理请求的线程进入阻塞状态,当二维码的状态发生变化时,我们再将其唤醒,因此上文中的扫码操作和确认登录操作完成后,还会有一个唤醒线程的过程。
实际上,加锁操作设计得不太合理,因为我们只设置了一把锁。因此对不同二维码的查询或修改操作都会抢占同一把锁。按理来说,不同二维码的操作之间应该是相互独立的,即使加锁,也应该是为每个二维码均配一把锁,但这样做代码会更加复杂,或许有其它更好的实现长轮询的方式?或者干脆直接短轮询。当然,也可以使用 WebSocket 实现长连接。
项目中配置了两个拦截器,一个用于确认用户的身份,即验证 token 是否有效:
@Component public class LoginInterceptor implements HandlerInterceptor { @Autowired private HostHolder hostHolder; @Autowired private CacheStore cacheStore; @Autowired private UserService userService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String accessToken = request.getHeader("access_token"); // access_token 存在 if (StringUtils.isNotEmpty(accessToken)) { String userId = (String) cacheStore.get(CommonUtil.buildAccessTokenKey(accessToken)); User user = userService.getCurrentUser(userId); hostHolder.setUser(user); } return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { hostHolder.clear(); } }
如果 token 有效,那么服务端根据 token 获取用户的信息,并将用户信息存储到 ThreadLocal 容器。手机端和 PC 端的请求都由该拦截器处理,如 PC 端的 "查询用户信息" 请求,手机端的 "扫码" 请求。由于我们忽略了手机端验证时所需要的的设备信息,因此 PC 端和手机端 token 可以使用同一套验证逻辑。
另一个拦截器用于拦截 "确认登录" 请求,即验证一次性 token 是否有效:
@Component public class ConfirmInterceptor implements HandlerInterceptor { @Autowired private CacheStore cacheStore; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String onceToken = request.getHeader("once_token"); if (StringUtils.isEmpty(onceToken)) { return false; } if (StringUtils.isNoneEmpty(onceToken)) { String onceTokenKey = CommonUtil.buildOnceTokenKey(onceToken); String uuidFromCache = (String) cacheStore.get(onceTokenKey); String uuidFromRequest = request.getParameter("uuid"); if (!StringUtils.equals(uuidFromCache, uuidFromRequest)) { throw new RuntimeException("非法的一次性 token"); } // 一次性 token 检查完成后将其删除 cacheStore.delete(onceTokenKey); } return true; } }
该拦截器主要拦截 "确认登录" 请求,需要注意的是,一次性 token 验证通过后要立即将其删除。
编码过程中,我们简化了许多操作,例如:1. 忽略掉了手机端的设备信息;2. 手机端确认登录后并没有直接为用户生成 PC 端 token,而是在轮询时生成。
浏览器:PC 端操作
Postman:模仿手机端操作。
由于我们没有实现真实的手机端扫码的功能,因此使用 Postman 模仿手机端向服务端发送请求。首先我们需要确保服务端存储着用户的信息,即在 Test 类中执行如下代码:
@Test void insertUser() { User user = new User(); user.setUserId("1"); user.setUserName("John同学"); user.setAvatar("/avatar.jpg"); cacheStore.put("user:1", user); }
手机端发送请求时需要携带手机端 token,这里我们为 useId 为 "1" 的用户生成一个 token(手机端 token):
@Test void loginByPhone() { String accessToken = CommonUtil.generateUUID(); System.out.println(accessToken); cacheStore.put(CommonUtil.buildAccessTokenKey(accessToken), "1"); }
手机端 token(accessToken)为 "aae466837d0246d486f644a3bcfaa9e1"(随机值),之后发送 "扫码" 请求时需要携带这个 token。
启动项目,访问 localhost:8080/index
:
点击登录,并在开发者工具中找到二维码 id(uuid):
打开 Postman,发送localhost:8080/login/scan
请求,Query 参数中携带 uuid,Header 中携带手机端 token:
上述请求返回 "扫码成功" 的响应,同时还返回了一次性 token。此时 PC 端显示出扫码用户的头像:
在 Postman 中发送 localhost:8080/login/confirm
请求,Query 参数中携带 uuid,Header 中携带一次性 token:
"确认登录" 请求发送完成后,PC 端随即获取到 PC 端 token,并成功查询用户信息:
以上がJava に基づいてコード スキャン ログインを実装する方法の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。