リモートワークで固定IPするために: VPNの認証のためにRADIUSサーバーをつくる

こんにちは。 ピリカ開発チームの伊藤です。

ピリカでは開発環境へのアクセスを保護するための一環としてIP制限をしていますが、ピリカという会社は元々リモートで仕事をしている人が多く、またオフィスのIPアドレスは動的IPの時代が続いていました。

そこで、各自のIPアドレスを固定化するためにVPNやプロキシサーバーを構築していますが、その認証の管理をできるだけGoogle Workspaceの権限を元にやりたいと考えました。

この記事では、VPNやプロキシの認証をするための前段として、Google Workspaceの情報と連動した独自の認証基盤をどのように作ったかを紹介します。

全体の設計

このVPN/認証プロキシシステムの設計はこのようになっています。

f:id:iseebi:20210922102253p:plain

Google Workspaceの生のパスワードをシステムが受け取って認証するのは、APIが存在しないし、セキュリティ的にも良くないので、事前にIdentity-Aware Proxyで保護されたGoogle App Engineのページを作って、このシステム専用のパスワードを登録しておいてもらいます。 更に、メールアドレスとパスワード、Googleグループのアドレスを受け取り、パスワード認証した上で、そのユーザーが指定されたGoogleグループにいることを判定するAPIもここに用意します。

そして、この認証APIVPNの認証に使えるようにします。VPNはxl2tpdとstrongSwanを使用したL2TP/IPsecを構築しますが、この認証のためにはRADIUSが必要なので、認証APIRADIUSとして見せられるようにGoで変換サーバーを実装します。

プロキシにはDeleGateを使用しますが、これはコマンドライン引数で与えられたパラメータで認証して結果を標準出力で返すような仕組みを作ることで認証ができます。 プロキシの設定を各自にやってもらうのは手間なので、pacスクリプトGoogle Workspaceから各自のChromeに配信して設定フリーにしています。

パスワードを登録させる

パスワードを登録する画面はGoogle App Engineで実装しています。

f:id:iseebi:20210922102402p:plain
まずはシンプルなHTMLだけで構成したページを作りました。

Identity-Aware Proxy(IAP)で認証させ、そのユーザーがスタッフであることを確認してからこのシステムに有効なパスワードを登録してもらいます。

認証APIを作る

認証APIもパスワード登録と同じApp Engine上にAPIとして実装しています。

IDとしてメールアドレス、パスワードを受け取るほかに、Googleグループをパラメータとして受け取ります。 こうすることで、Google Workspaceの管理画面で対応するグループのメンバー追加・削除でVPNやプロキシのアクセス権を管理することができるようになります。

APIも登録画面と同じくIAPで保護されているため、APIを使う場合も認証が必要です。 APIを使うサーバーは、サービスアカウントとして認証させています。 VPN/プロキシを動作しているサーバーはGCP外に存在していて、キーのJSONを発行するしかないため、このIAPを通過するためだけの専用のサービスアカウントを作成しました。

RADIUSを実装する

それでは、このAPIを使用して認証できるRADIUSを実装します。

RADIUSの実装をするためには、安定性とサーバーライブラリの存在からGoを選択しました。 また、DeleGateでの認証も同じバイナリでできるよう、RADIUSサーバーモードで起動しない場合はコマンドライン引数で与えられたユーザーの認証をして、DeleGateが求める認証結果を出力するようにしました。

package main

import (
    "context"
    "encoding/json"
    "flag"
    "fmt"
    "google.golang.org/api/idtoken"
    "google.golang.org/api/option"
    "io/ioutil"
    "layeh.com/radius"
    "layeh.com/radius/rfc2865"
    "log"
    "net/http"
    "net/url"
    "os"
    "strings"
)

type AuthenticateResult struct {
    Group    string `json:"group"`
    User     string `json:"user"`
    Verified bool   `json:"verified"`
}

func readFile(fileName string) ([]byte, error) {
    bytes, err := ioutil.ReadFile(fileName)
    if err != nil {
        return []byte{}, err
    }
    return bytes, nil
}

func validateUser(saJson *[]byte, clientId string, apiUrl string, group string, user string, password string) (bool, error) {
    ctx := context.Background()
    // 認証APIにあわせて実装を変更する
    reqUrl := fmt.Sprintf("%s/%s", apiUrl, url.QueryEscape(group))
    val := url.Values{}
    val.Add("user", user)
    val.Add("password", password)

    client, err := idtoken.NewClient(ctx, clientId, option.WithCredentialsJSON(*saJson))
    if err != nil {
        return false, err
    }
    req, err := http.NewRequest("POST", reqUrl, strings.NewReader(val.Encode()))
    if err != nil {
        return false, err
    }
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

    res, err := client.Do(req)
    if err != nil {
        return false, err
    }
    body, err := ioutil.ReadAll(res.Body)
    if err != nil {
        return false, err
    }
    var result AuthenticateResult
    if err := json.Unmarshal(body, &result); err != nil {
        return false, err
    }
    return result.Verified, nil
}

func radiusServer(saJson *[]byte, clientId string, apiUrl string, group string) {
    handler := func(w radius.ResponseWriter, r *radius.Request) {
        username := rfc2865.UserName_GetString(r.Packet)
        password := rfc2865.UserPassword_GetString(r.Packet)

        var code radius.Code
        result, err := validateUser(saJson, clientId, apiUrl, group, username, password)
        if err != nil {
            log.Printf("Error in validation %v", err)
            code = radius.CodeAccessReject
        } else if result {
            code = radius.CodeAccessAccept
        } else {
            code = radius.CodeAccessReject
        }
        log.Printf("Writing %v to %v for %s", code, r.RemoteAddr, username)
        err = w.Write(r.Response(code))
        if err != nil {
            log.Printf("Write Failed %v to %v for %s", code, r.RemoteAddr, username)
        }
    }

    server := radius.PacketServer{
        Handler:      radius.HandlerFunc(handler),
        SecretSource: radius.StaticSecretSource([]byte(`secret`)),
    }

    log.Printf("Starting server on :1812")
    if err := server.ListenAndServe(); err != nil {
        log.Fatal(err)
    }
}

func printResult(result bool) {
    if result {
        fmt.Print("230 Success\n\n")
    } else {
        fmt.Print("530 Failure\n\n")
    }
}

func main() {
    saJsonName := flag.String("sa", "", "Service Account JSON")
    clientId := flag.String("client", "*****.apps.googleusercontent.com", "Client ID")
    group := flag.String("group", "", "Group Email Address")
    requestRadius := flag.Bool("radius", false, "Become RADIUS daemon")
    user := flag.String("user", "", "User Email Address")
    apiUrl := flag.String("api", "https://******/", "API URL")
    flag.Parse()
    if *saJsonName == "" {
        printResult(false)
        log.Fatalf("Must provide service accouunt json file by -sa option")
    }
    if *clientId == "" {
        printResult(false)
        log.Fatalf("Must provide Client ID by -client option")
    }
    if *group == "" {
        printResult(false)
        log.Fatalf("Must provide group email address by -group option")
    }

    saJson, err := readFile(*saJsonName)
    if err != nil {
        printResult(false)
        log.Fatalf("Service Account JSON read failed%v", err)
    }

    if *requestRadius {
        radiusServer(&saJson, *clientId, *apiUrl, *group)
    } else {
        password := os.Getenv("RADIUS_USER_PASSWORD")
        if *user == "" {
            printResult(false)
            log.Fatalf("Must provide user email address by -user option")
        }
        result, err := validateUser(&saJson, *clientId, *apiUrl, *group, *user, password)
        if err != nil {
            printResult(false)
            log.Fatalf("Verify Failed\n%v", err)
        }
        printResult(result)
    }
}

RADIUSとしてのポイントはfunc radiusServerです。handlerでリクエスト内に入っているユーザー名とパスワードを得て、それを認証APIに送り、認証できればradius.CodeAccessAcceptできなければradius.CodeAccessRejectをレスポンスとしてWriteするだけです。*1

できたバイナリを以下のようにして実行することで、実装したRADIUSサーバーが1812番ポートで起動してきます。

$ auth_radius -sa service_account.json -group group.vpn-users@example.com -client *** -api https://***/ -radius

また、このような形でコマンドラインで直接認証することも可能です。こちらはDeleGateで使用します。

export RADIUS_USER_PASSWORD=[パスワード]
$ auth_radius -sa service_account.json -group group.vpn-users@example.com -client *** -api https://***/ -user member@example.com
230 Success

長くなりましたので、実際のサーバーの設定はまた今度ご紹介できればと思います。

*1:これはPAPという方式ですが、可能であればよりセキュアなCHAPを使用した方が良いでしょう