LineのOAuth認証を試してみた

にあえん

November 30, 2023

OAuth触るの5年ぶり。ナナオです。

今回はLINEのOAuth認証を、実際にサーバーを実装しながら試していきたいと思います。

LINEログイン v2.1 APIリファレンス | LINE Developers

そもそもOAuthとは?といったところは↓

一番分かりやすい OAuth の説明 #OAuth - Qiita

OAuthでログイン画面表示まで

まずLINE Developersコンソールからログインチャネルを作成します。

これはMessaging APIなどとは別で作成する必要があります。

以下のように設定しました。

続けて、「LINEログイン設定」からコールバックURLを設定します。

コールバックURLのリクエスト先はまだ実装していなくても大丈夫です。

今回はウェブアプリから使用する設定で実装します。

ここまで出来たらブラウザからログインの様子を確認することができます。

認可リクエストのドキュメントを確認し、URLを作ってブラウザからリクエストしてみましょう。

うまくいけば以下のようなLINEのログイン画面に遷移します。

コールバックURLを実装する

今のままだとリダイレクトURLの先には何もないので、自分で実装しましょう。

Cargoパッケージを作成します。

cargo new rust-line-oauth-playground

必要な依存関係も追加しておきます。

[dependencies]
axum = "0.7.1"
dotenv = "0.15.0"
reqwest = { version = "0.11.22", features = ["json"] }
serde = { version = "1.0.193", features = ["derive"] }
serde_json = "1.0.108"
tokio = { version = "1.34.0", features = ["macros", "rt", "rt-multi-thread"] }

では実際にリダイレクト先の実装をしてあげましょう。

これを実施すると、アクセストークンが取得できるようにになります。

以下のように行いました。

use axum::{extract::{Query, State}, routing::get, Router};
use serde::Deserialize;
use reqwest::Client;
use serde_json::{Value, json};

#[derive(Debug, Deserialize)]
struct LineCallbackQuery {
    code: String,
    state: String,
    friendship_status_changed: Option<bool>,
    liffClientId: Option<String>,
    liffRedirectUri: Option<String>,
}

#[derive(Debug, Deserialize)]
struct LineCallbackError {
    error: String,
    error_description: Option<String>,
    state: Option<String>,
}

#[derive(Debug, Deserialize)]
struct AccessTokenResponse {
    access_token: String,
    expires_in: i32,
    id_token: String,
    refresh_token: String,
    scope: String,
    token_type: String,
}

#[derive(Debug, Clone)]
struct BaseSetting {
    redirect_uri: String,
    client_id: String,
    client_secret: String,
}

#[derive(Debug, Clone)]
struct AppState {
    http_client: Client,
    setting: BaseSetting,
}

async fn line_callback(
    Query(query): Query<Value>,
    State(state): State<AppState>,
) {
    // エラーの場合
    // https://developers.line.biz/ja/docs/line-login/integrate-line-login/#receiving-an-error-response
    if query.get("error").is_some() {
        let query: LineCallbackError = serde_json::from_value(query).unwrap();
        eprintln!("リダイレクト時にエラーが発生しました: {:?}", query);
        return
    }

    // 正常な場合
    // https://developers.line.biz/ja/docs/line-login/integrate-line-login/#receiving-the-authorization-code
    let query: LineCallbackQuery = serde_json::from_value(query).unwrap();

    let params = json!({
        "grant_type": "authorization_code",
        "code": query.code,
        "redirect_uri": state.setting.redirect_uri,
        "client_id": state.setting.client_id,
        "client_secret": state.setting.client_secret,
    });

    // アクセストークンを取得する
    // https://developers.line.biz/ja/docs/line-login/integrate-line-login/#get-access-token
    let res = state.http_client.post("https://api.line.me/oauth2/v2.1/token")
        .form(&params)
        .send().await.unwrap();

    let res_json: AccessTokenResponse = res.json().await.unwrap();
    println!("res: {:?}", res_json);
}

#[tokio::main]
async fn main() {
    // 環境変数をdotenvで取得する
    dotenv::dotenv().ok();

    let setting = BaseSetting {
        redirect_uri: std::env::var("REDIRECT_URI")
            .expect("REDIRECT_URI must be set"),
        client_id: std::env::var("CLIENT_ID")
            .expect("CLIENT_ID must be set"),
        client_secret: std::env::var("CLIENT_SECRET")
            .expect("CLIENT_SECRET must be set"),
    };

    let http_client = reqwest::Client::new();

    let state = AppState {
        http_client,
        setting,
    };

    let app = Router::new()
        .route("/callback", get(line_callback))
        .with_state(state);
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();
    println!("listening on {}", listener.local_addr().unwrap());
    axum::serve(listener, app).await.unwrap();
}

あとはルートパッケージ直下に.envを配置します。

REDIRECT_URI="http://localhost:3000/callback"
CLIENT_ID="LINEのクライアントID"
CLIENT_SECRET="LINEのクライアントシークレット"

実装では、先ほどと同じ手順でログイン画面に遷移してログインしたらログにアクセストークンが出力されるようにしました。

起動して確認してみましょう。

listening on 127.0.0.1:3000
res: AccessTokenResponse { access_token: "アクセストークン", expires_in: 2592000, id_token: "ユーザー情報のJWT", refresh_token: "アクセストークンの再発行に使用するトークン", scope: "profile openid", token_type: "Bearer" }

出力されました!

stateの検証のために、LINEログインボタンを設置したページを実装する

現在の実装だと、リダイレクト時にstateの検証をしていません。

それはブラウザ側からどのようなstateが来るのか、アプリが判別できないためです。

なので、ログインページを設置してstateをバックエンドが生成するようにしましょう。

まずは依存関係を追加してあげます。

[dependencies]
tera = "1.19.1"
tower-http = { version = "0.5.0", features = ["fs"] }

続けてtemplatesディレクトリをルートパッケージ配下に作成し、shuttleのこのドキュメントとかを参考にして、ベースになるHTMLとログインページのHTMLを実装します。

mkdir templates
touch templates/base.html
touch templates/login.html

あとは別に凝らなくてもいいんですが、ここからLINEのログインボタンの画像をダウンロードして使いましょう。

publicディレクトリにダウンロードした画像を配置しておきます。

base.htmlは以下のように実装してあげました。

ヘッダーと本体部分だけブロックにしてあとから挿入可能なようにしておきます。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Title</title>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Karla:wght@500&display=swap" rel="stylesheet">
    <link href="/styles.css" rel="stylesheet">
    {% block header %}{% endblock header %}
</head>
<body>
    {% block content %}{% endblock content %}
</body>
</html>

login.htmlはバックエンドからstateなどの認可リクエストのパラメータを注入できるように、変数として定義してあげます。

{% extends "base.html" %}
{% block header %}
<script>
    document.getElementById("line_login").addEventListener()
</script>
{% endblock header %}
{% block content %}
<form action="https://access.line.me/oauth2/v2.1/authorize" method="get">
    <input type="hidden" name="response_type" value="code">
    <input type="hidden" name="client_id" value="{{ client_id }}">
    <input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
    <input type="hidden" name="state" value="{{ state }}">
    <input type="hidden" name="scope" value="{{ scope }}">
    <input type="hidden" name="nonce" value="{{ nonce }}">
    <button type="submit" id="line_login">
        <img src="/public/img/btn_login_base.png" alt="loginボタン">
    </button>
</form>
{% endblock content %}

Rust側の実装は以下のように行いました。

とりあえずstateの検証はせず、ログインページからログインができるところまで実装しました。

// ...中略...

/// ログインページの実装
async fn login_page(
    State(state): State<AppState>,
) -> Html<String> {
    let line = LineLogin {
        client_id: state.setting.client_id,
        redirect_uri: state.setting.redirect_uri,
        state: "12345abcde".to_string(),
        scope: "openid profile".to_string(),
        nonce: "09876xyz".to_string(),
    };

    let context = Context::from_serialize(&line).unwrap();
    let html = state.tera.render("login.html", &context).unwrap();
    Html(html)
}

// ...中略...

#[derive(Debug, Clone)]
struct AppState {
    tera: Tera,
    http_client: Client,
    setting: BaseSetting,
}

// ...中略...

#[tokio::main]
async fn main() {
    // ...中略...

    let http_client = reqwest::Client::new();

    // teraの初期化
    let tera = match Tera::new("templates/**/*.html") {
        Ok(t) => t,
        Err(e) => {
            println!("Parsing error(s): {}", e);
            ::std::process::exit(1);
        }
    };

    // AppStateの初期化
    let state = AppState {
        tera,
        http_client,
        setting,
    };

    let app = Router::new()
        .route("/callback", get(line_callback))
        .route("/login", get(login_page)) // ログインページ
        .nest_service("/public", ServeDir::new("public")) // 静的リソースの定義を行う
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();
    println!("listening on {}", listener.local_addr().unwrap());
    axum::serve(listener, app).await.unwrap();
}

起動して確認してみます。

ログインボタンが表示されました!

ログインボタンを押すとLINEのログイン画面にリダイレクトし、アクセストークンが返却されました。

では次に、stateをちゃんと管理するようにしましょう。

stateの文字列にはこの辺の記事を参考に適当に作ります。

(↑の方法で実装してみたんですが、なぜか全然ビルドできなかったので別の方法にしました)

stateの文字列(oauth2)[https://docs.rs/oauth2/latest/oauth2/]のCsrfTokenという構造体を使用します。

あとはstateの管理用にDBが必要なので、docker-composeで作成しておきます。

version: '3'

services:
  db:
    image: postgres:15
    ports:
        - 5432:5432
    environment:
        - POSTGRES_PASSWORD=example
        - POSTGRES_DB=oauth

必要な依存関係を追加しておきます。

sqlx = "0.7.3"
oauth2 = "4.4.2"

sqlx-cliを使用して、stateの管理用のテーブルを作成します。

sqlx migrate add init
create table "line_state" (
    "state" varchar(32) PRIMARY KEY,
    "expire_at" timestamp with time zone NOT NULL
);

実行時にマイグレーションするようにします。


#[tokio::main]
async fn main() {
    // 環境変数をdotenvで取得する
    dotenv::dotenv().ok();

    let database_url = std::env::var("DATABASE_URL")
        .expect("DATABASE_URL must be set");
    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect(database_url.as_str()).await
        .expect("poolの作成に失敗しました");

    sqlx::migrate!().run(&pool.clone()).await.expect("マイグレーションに失敗しました");

    // ...以下略...
}

続けて、作成したstateを設定したHTMLを返却するようにしましょう。

async fn login_page(
    State(state): State<AppState>,
) -> Html<String> {
    let csrf_token = CsrfToken::new_random().secret().clone();

    let line = LineLogin {
        client_id: state.setting.client_id,
        redirect_uri: state.setting.redirect_uri,
        state: "12345abcde".to_string(),
        scope: "openid profile".to_string(),
        nonce: "09876xyz".to_string(),
    };

    let context = Context::from_serialize(&line).unwrap();
    let html = state.tera.render("login.html", &context).unwrap();
    Html(html)
}

// ...中略...

async fn line_callback(
    Query(query): Query<Value>,
    State(state): State<AppState>,
) {
    // エラーの場合
    // https://developers.line.biz/ja/docs/line-login/integrate-line-login/#receiving-an-error-response
    if query.get("error").is_some() {
        let query: LineCallbackError = serde_json::from_value(query).unwrap();
        eprintln!("リダイレクト時にエラーが発生しました: {:?}", query);
        return
    }

    // 正常な場合
    // https://developers.line.biz/ja/docs/line-login/integrate-line-login/#receiving-the-authorization-code
    println!("query: {:?}", query);
    let query: LineCallbackQuery = serde_json::from_value(query).unwrap();

    let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM line_state WHERE state = $1")
        .bind(query.state)
        .fetch_one(&state.pool)
        .await
        .unwrap();

    // stateが存在しない場合はアクセストークンの取得は行わない
    if row.0 == 0 {
        return
    }

    // ...以下略...
}

ではこの状態でリクエストを行ってみます。

起動してブラウザ上からアクセストークンが取得できることを確認した結果、取得することができました。

CSRFの確認として、HTMLにあるstate文字列を意図的に変更してどうなるか確認してみます。

赤枠の部分を意図的に変更

この状態でリクエストしてもサーバー側に存在しないstateなため、アクセストークンの取得処理はサーバー側では実行されません。

ただ今回はチュートリアル記事として実装しているので、本来のCSRFの使い方としては間違っています。

(HTMLにstateがそのまま表示されてしまっていたらサーバー側のstateが分かってしまって意味がないです)

ちゃんとやらなきゃいけないところではちゃんとやりましょう。

まとめ

OAuthの実装ができるとサービス実装時にログイン周りに関しての考え方が少し楽になるなぁと感じました。

同じような認証方式としてOpenID Connectもありますが、同じようなプロトコルなのでこれができれば今後かなり役に立つはず…!!

今回の実装内容はこちらに上げています。

rust-line-oauth-playground

ではまた。