LoginSignup
4
0

More than 1 year has passed since last update.

GoとUnityとSQLでちょっとしたソシャゲとか用のユーザ認証とセッションを作りたい

Last updated at Posted at 2022-12-21

この記事はゲーム制作者の発表の場アドベントカレンダーに参加中です。

https://adventar.org/calendars/8046
ゲーム制作にかかわる人が自由な記事を作る Advent Calendar 2022 22日目担当、Fuseです。
サーバサイドに興味を持ったので、サーバサイドの記事を書きました。
今回は色々使えそうなユーザ認証とセッションを作っていきます。

まえがき

  • 著者はGo言語初学者です。なのでお見苦しいところがあるかもしれませんがご了承ください。
  • 巷ではやりのGo言語PostGreSQLUnityでソシャゲとかに使えるユーザ認証を作っていきます。
  • ゲームとしての処理はサーバ側に実装、クライアントであるUnity側は与えられたデータを表示するのみとします。
  • 単純な実装では無く、セキュリティやチート対策なども意識して作っていきます。
  • 今回は技術検証がメインのためゲームの面白さは度外視とします。

完成品

サーバー側

クライアント側

制作環境

  • Unity 2021.3.6f1
  • Go言語 go1.19.3 windows/amd64
  • PostgreSQL 15

制作過程

その1 UnityでHTTP接続しよう

まずはHTTP理解の第一歩として、UnityとAPIサーバの接続とレスポンスの取得に挑戦です。

イメージ図

完成品

サーバー側

APIサーバをGo言語で立てて、クライアント(Unity)からのリクエストを待ち受けます。

サーバ側コード(短め)
main.go
package main

import (
	"fmt"      //基本的な入出力処理
	"io"       //入出力に使います
	"log"      //エラーを表示するときに使います
	"net/http" //HTTPを使った通信に必要
)

func HelloWorld(w http.ResponseWriter, r *http.Request) {
	io.WriteString(w, "Hello World!")
}

func main() {
	fmt.Println("Hello, World!")
	http.HandleFunc("/", HelloWorld)       //ルートのアクセスにHelloWorldをハンドリング
	err := http.ListenAndServe(":80", nil) //サーバ起動
	if err != nil {
		panic(err)
	}
	for {
	} //ずっと起動
}

注目したいポイントがいくつかあるので、順を追って説明します。

(一部抜粋)main.go
    http.HandleFunc("/", HelloWorld)       //ルートのアクセスにHelloWorldをハンドリング
	err := http.ListenAndServe(":80", nil) //サーバ起動

Go言語は基本機能でAPIサーバの作成をサポートしています。
http.HandleFuncでURLと関数を紐づけたら、
http.ListenAndServeでサーバーを起動しましょう。
今回は80番ポートを使用するので、第一引数は":80"になります。

(一部抜粋)main.go
func HelloWorld(w http.ResponseWriter, r *http.Request) {
	io.WriteString(w, "Hello World!")
}

リクエストを受ける側の関数では、引数にhttp.ResponseWriter*http.Requestを指定します。
io.WriteStringに書き込んだ文字列がレスポンスの内容になります。

クライアント側

Unityで作ったゲームから、先ほど作ったゲームにリクエストを飛ばしてみましょう。

クライアント側コード(短め)
HTTPTest.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking;
using TMPro;
public class HTTPTest : MonoBehaviour
{
    [SerializeField] TextMeshProUGUI t;
    // Start is called before the first frame update
    public virtual void ConnectStart()//ボタン操作時に呼ばれる
    {
        StartCoroutine(Connect(""));//コルーチン開始
    }

    IEnumerator Connect(string url)//HTTPで文字列をもらってくる
    {
        using (UnityWebRequest www = UnityWebRequest.Get("127.0.0.1/" + url))
        {//サーバー(今回はローカル)に接続
            yield return www.SendWebRequest();//結果が出るまで待機

            if (www.result != UnityWebRequest.Result.Success)//200じゃなかったら
            {
                Debug.Log(www.error);//エラーを表示する
            }
            else//200なら
            {
                t.text = www.downloadHandler.text;//文字列表示
            }
        }
    }
}

こちらも注目したいポイントがいくつかあるので、順を追って説明します。

(一部抜粋)HTTPTest.cs
IEnumerator Connect(string url)//HTTPで文字列をもらってくる
    {
        using (UnityWebRequest www = UnityWebRequest.Get("127.0.0.1/" + url))
        {//サーバー(今回はローカル)に接続
            yield return www.SendWebRequest();//結果が出るまで待機

            if (www.result != UnityWebRequest.Result.Success)//200じゃなかったら
            {
                Debug.Log(www.error);//エラーを表示する
            }
            else//200なら
            {
                t.text = www.downloadHandler.text;//文字列表示
            }
        }
    }

UnityでGETリクエストを行う場合はUnityEngine.Networkingをusingし、
UnityWebRequestUnityWebRequest.GetでURLを指定して生成します。
また、この際usingステートメントを使わないとメモリリークするので気をつけましょう。
UnityWebRequest.SendWebRequestでリクエストを送信し待機した後。
UnityWebRequest.resultで通信が成功したかの結果を、
www.downloadHandler.textで帰ってきたレスポンスの内容を確認できます。

その2 DBと連携してお知らせリストを作ろう

というわけで、DBと連携してみましょう

イメージ図

完成品

サーバー側

PostgreSQLに接続して、お知らせのリストを引っ張りましょう。

サーバ側コード(長め)
getinfo/getinfo.go
package getinfo

import (
	"database/sql"  //SQL操作
	"encoding/json" //json相互変換
	"fmt"           //基本的な入出力処理
	"io"            //入出力
	"net/http"      //http通信

	"github.com/Fuses-Garage/UnityGo/util" //自作パッケージ
	_ "github.com/lib/pq"                  //ポスグレ使用
)

func GetInfo(w http.ResponseWriter, r *http.Request) {
	db := util.LoginToDB()
	//データの検索
	type idata struct { //レコード用の構造体
		ID    int
		TITLE string
		ABOUT string
		DATE  string
	}
	var id int
	var title string
	var about string
	var date string
	var datarows []idata                               //データを格納する配列
	rows, err := db.Query("SELECT * FROM info") //全取得
	util.CheckErr(err)
	for rows.Next() { //1つずつ処理
		switch err := rows.Scan(&id, &title, &about, &date); err { //エラーの有無でスイッチ
		case sql.ErrNoRows:
			fmt.Println("No rows were returned")
		case nil:
			// 一行毎に配列を追加
			datarows = append(datarows, idata{
				ID:    id,
				TITLE: title,
				ABOUT: about,
				DATE:  date,
			})
		default:
			util.CheckErr(err)
		}
	}
	jsoninfo, _ := json.Marshal(datarows) //jsonに変換
	io.WriteString(w, string(jsoninfo))   //string化したものを送信
}
util/checkerr.go
package util

import "fmt"

func CheckErr(err error) {
	if err != nil {
		fmt.Println(err)
		panic(err)
	}
}
util/login_to_db.go
package util

import (
	"database/sql" //SQL操作
	"os"

	"github.com/joho/godotenv" //.envの使用
	_ "github.com/lib/pq"      //ポスグレ使用
)

func LoginToDB() *sql.DB {
	err := godotenv.Load(".vscode/.env")
	CheckErr(err)
	db, err := sql.Open("postgres", "user="+os.Getenv("ENVUSER")+" password="+os.Getenv("ENVPASS")+" dbname=UniGoDB sslmode=disable") //DBに接続
	CheckErr(err)
	return db
}
注目したいポイントがいくつかあるので、順を追って説明します。
(一部抜粋)util/login_to_db.go
package util

import (
	"database/sql" //SQL操作
	"os"

	"github.com/joho/godotenv" //.envの使用
	_ "github.com/lib/pq"      //ポスグレ使用
)

Golangでデータベースを扱うなら"database/sql"だけでなく
DBの種類に応じたDriverをインポートする必要があります。
また、外部に見せたくない情報(DBのパスワードとか)が詰まった.envファイルを読み込むためには
"github.com/joho/godotenv"をインポートする必要があるので注意しましょう。

(一部抜粋)util/login_to_db.go
func LoginToDB() *sql.DB {
	err := godotenv.Load(".vscode/.env")
	CheckErr(err)
	db, err := sql.Open("postgres", "user="+os.Getenv("ENVUSER")+" password="+os.Getenv("ENVPASS")+" dbname=UniGoDB sslmode=disable") //DBに接続
	CheckErr(err)
	return db
}

次にDBへのログイン処理です。
面倒なことに.envファイルを触るためにはgodotenv.Loadを呼ぶ必要があります。
それが済んだら.envファイル内のログイン情報を使ってデータベースにログインし、
ログインできたDBの情報を返しましょう。

(一部抜粋)getinfo/getinfo.go
    type idata struct { //レコード用の構造体
		ID    int
		TITLE string
		ABOUT string
		DATE  string
	}
    var id int
	var title string
	var about string
	var date string
	var datarows []idata                               //データを格納する配列

SQLで行を取得する際は、あらかじめその行に合わせた構造の構造体を定義しておくと後が楽です。

(一部抜粋)getinfo/getinfo.go
    rows, err := db.Query("SELECT * FROM info") //全取得
	util.CheckErr(err)
	for rows.Next() { //1つずつ処理
		switch err := rows.Scan(&id, &title, &about, &date); err { //エラーの有無でスイッチ
		case sql.ErrNoRows:
			fmt.Println("No rows were returned")
		case nil:
			// 一行毎に配列を追加
			datarows = append(datarows, idata{
				ID:    id,
				TITLE: title,
				ABOUT: about,
				DATE:  date,
			})
		default:
			util.CheckErr(err)
		}
	}

SQLですべてのお知らせを取得し、構造体の配列に1つずつ詰めていきます。
rows.Scan()で値を一時変数に受け、構造体にして配列に詰めています。

(一部抜粋)getinfo/getinfo.go
    jsoninfo, _ := json.Marshal(datarows) //jsonに変換
	io.WriteString(w, string(jsoninfo))   //string化したものを送信

最後に、JSONにした配列をStringに変換してクライアントに送り付けたら、
サーバ側のお仕事はこれでおしまいです。

クライアント側

クライアント側コード(激長)
OshiraseData.cs
[System.Serializable]
public class OshiraseData//お知らせデータ
{
    public int ID;
    public string TITLE;
    public string ABOUT;
    public string DATE;
}
[System.Serializable]
class OshiraseWrapper:object//ラッパークラス
{
    public OshiraseData[] osrs;//お知らせデータの配列
}
InfoButton.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking;
public class InfoButton : MonoBehaviour
{
    [SerializeField] GameObject window;//お知らせウィンドウ
    // Start is called before the first frame update
    public virtual void ConnectStart()//ボタン操作時に呼ばれる
    {
        StartCoroutine(Connect());//コルーチン開始
    }

    IEnumerator Connect()//HTTPで文字列をもらってくる
    {
        using (UnityWebRequest www = UnityWebRequest.Get("127.0.0.1/getinfo"))
        {//サーバー(今回はローカル)に接続
            www.timeout = 3;
            yield return www.SendWebRequest();//結果が出るまで待機

            if (www.result != UnityWebRequest.Result.Success)//200じゃなかったら
            {
                Debug.Log(www.error);//エラーを表示する
            }
            else//200なら
            {

                var wrapper = JsonUtility.FromJson<OshiraseWrapper>("{\"osrs\":" + www.downloadHandler.text + "}");//JSONをラッパーに
                var go = Instantiate(window);//ウィンドウ生成
                go.GetComponent<InfoWindow>().InfoAdd(wrapper.osrs);//ウィンドウに渡す
            }
        }
    }
}
InfoWindow.cs
sing System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class InfoWindow : MonoBehaviour
{
    [SerializeField] GameObject contents;//お知らせを出す位置
    [SerializeField] GameObject InfoPanel;//お知らせパネル
    public void InfoAdd(OshiraseData[] ods)
    {
        foreach (var v in ods)//分回す
        {
            GameObject go = Instantiate(InfoPanel,contents.transform);//親を指定して生成
            go.GetComponent<InfoSerializer>().OshiraseSerialize(v);//お知らせの内容を渡す
        }
    }
}
InfoSerializer.cs
sing System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class InfoSerializer : MonoBehaviour//Info初期化用スクリプト
{
    [SerializeField] Text title; //タイトルを表示するText
    [SerializeField] Text about; //内容を表示するText
    [SerializeField] Text date;  //作成日を表示するText
    public void OshiraseSerialize(OshiraseData o)
    {
        //Dataの内容を代入
        title.text = o.TITLE;
        about.text = o.ABOUT;
        date.text = o.DATE.Substring(0,10);//日付だけを抜き出す
    }
}

こちらも注目したいポイントがいくつかあるので、順を追って説明します。

OshiraseData.cs
[System.Serializable]
public class OshiraseData//お知らせデータ
{
    public int ID;
    public string TITLE;
    public string ABOUT;
    public string DATE;
}
[System.Serializable]
class OshiraseWrapper:object//ラッパークラス
{
    public OshiraseData[] osrs;//お知らせデータの配列
}

UnityでJSONファイルを扱う際には、オブジェクトに合わせたクラスと、
その配列をプロパティに持つラッパークラスが必要です。

(一部抜粋)InfoButton.cs
var wrapper = JsonUtility.FromJson<OshiraseWrapper>("{\"osrs\":" + www.downloadHandler.text + "}");//JSONをラッパーに

UnityのJsonUtility.FromJsonではオブジェクトの配列を受け取ることができません。
帰ってきたJSON文字列を{}で囲って強引にラッパークラスの単一オブジェクトとして認識させます。

その3 ユーザー登録・ログイン処理を作ろう

ソーシャルゲームを作るなら、ユーザー情報の管理は欠かせません。

登録処理を作る

というわけで、新たにユーザーを登録する処理を作ってみましょう。
CRUDのCですね。

イメージ図(正常実行時)

完成品

サーバー側

サーバ側コード(激長)
useradd.go
package usermethod

import (
	"crypto/sha256" //ハッシュ関数
	"database/sql"  //SQL操作
	"encoding/hex"  //16進数
	"fmt"           //基本的な入出力処理
	"io"            //入出力
	"net/http"      //http通信
	"os"

	"github.com/joho/godotenv" //.envの使用
	_ "github.com/lib/pq"      //ポスグレ使用
)

func GetInfo(w http.ResponseWriter, r *http.Request) {
	if r.Method != "POST" { //一つでも空欄があれば
		io.WriteString(w, "危険なのでPOSTメソッドでアクセスしてください。") //エラーメッセージを返す
		return
	}
	err := r.ParseForm() //postボディを詠む
	checkErr(err)
	if r.Form.Get("name") == "" || r.Form.Get("pass") == "" || r.Form.Get("loginname") == "" { //一つでも空欄があれば
		io.WriteString(w, "全てのテキストボックスに値を入力してください。") //エラーメッセージを返す
		return
	}
	err = godotenv.Load(".vscode/.env")
	checkErr(err)
	db, err := sql.Open("postgres", "user="+os.Getenv("ENVUSER")+" password="+os.Getenv("ENVPASS")+" dbname=UniGoDB sslmode=disable") //DBに接続
	checkErr(err)
	if err != nil {
		fmt.Printf("読み込み失敗: %v", err)
	}
	row := db.QueryRow("SELECT COUNT(*) AS count FROM userdata_login WHERE loginname=$1 OR name=$2", r.Form.Get("loginname"), r.Form.Get("name")) //同じログインネームのユーザーを探す
	var count int
	err = row.Scan(&count) //ヒット数をintで受ける
	checkErr((err))
	if count > 0 { //誰かいたら
		io.WriteString(w, "そのユーザーネームは既に登録されています") //エラーメッセージを返す
		return
	}
	byt := sha256.Sum256([]byte(r.Form.Get("pass")))                                                                           //ハッシュ化
	bina := byt[:]                                                                                                             //おまじない
	hash := hex.EncodeToString(bina)                                                                                           //16進数の文字列に変化
	_, err = db.Exec("INSERT INTO userdata_login VALUES(DEFAULT,$1,$2,$3)", r.Form.Get("name"), r.Form.Get("loginname"), hash) //新しいレコードを追加
	checkErr(err)
	io.WriteString(w, "success") //string化したものを送信
}
func checkErr(err error) {
	if err != nil {
		panic(err)
	}
}

かなり長くなりましたが、こんなコードが書きあがりました。
リクエストの中に格納されたデータをもとに新たなユーザーを登録します。
ここで注意しておきたいのがパスワードを平文ではなくハッシュ値で格納していること。
万が一に備え、パスワードをそのまま格納せず、ハッシュ化して元に戻せなくしてから格納しています。

(一部抜粋)useradd.go
    byt := sha256.Sum256([]byte(r.Form.Get("pass")))                                                                           //ハッシュ化
	bina := byt[:]                                                                                                             //おまじない
	hash := hex.EncodeToString(bina)   

これでもしもDBサーバの内容が漏洩しても、解析には時間を要するはずです。(レインボー攻撃という手もありますが…)

クライアント側

次はC#からサーバーへPOSTメソッドで入力情報を送り付けてみましょう。

送信処理スクリプト(やや短)
PostSender.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.Events;
using System.Threading;
using System.Threading.Tasks;
public static class PostSender//POSTリクエスト用クラス
{
    [SerializeField] static int limittime=1;
    
    public static IEnumerator Upload(PostParam[] param, string url,UnityAction<string> s)//送信処理
    {

        WWWForm form = new WWWForm();//newで生成
        
        foreach (PostParam p in param)//postparamの中身を1つずつ追加
        {
            form.AddField(p.key,p.val);
            Debug.Log(string.Concat("Key=",p.key,",Value=",p.val));
        }

        using (UnityWebRequest www = UnityWebRequest.Post("127.0.0.1/" + url, form))
        {//リクエスト生成
            Debug.Log("Request Send to " + "localhost/" + url);
            www.timeout = 3;
            yield return www.Send();//送信
            Debug.Log(www.downloadHandler.text);
            Debug.Log("Request Return");
            if (www.result == UnityWebRequest.Result.ProtocolError)
            {
                // レスポンスコードを見て処理
                s.Invoke($"[Error]Response Code : {www.responseCode}");
            }
            else if (www.result == UnityWebRequest.Result.ConnectionError)
            {
                // エラーメッセージを見て処理
                s.Invoke($"[Error]Message : {www.error}");
            }
            else
            {
                s.Invoke(www.downloadHandler.text);
            }
        }
    }
    public static IEnumerator Get(string url,UnityAction<string> s)//送信処理
    {

        WWWForm form = new WWWForm();//newで生成
        using (UnityWebRequest www = UnityWebRequest.Get("127.0.0.1/" + url))
        {//リクエスト生成
            Debug.Log("Request Send to " + "localhost/" + url);
            www.timeout = 3;
            yield return www.Send();//送信
            Debug.Log(www.downloadHandler.text);
            Debug.Log("Request Return");
            if (www.result == UnityWebRequest.Result.ProtocolError)
            {
                // レスポンスコードを見て処理
                s.Invoke($"[Error]Response Code : {www.responseCode}");
            }
            else if (www.result == UnityWebRequest.Result.ConnectionError)
            {
                // エラーメッセージを見て処理
                s.Invoke($"[Error]Message : {www.error}");
            }
            else
            {
                s.Invoke(www.downloadHandler.text);
            }
        }
    }
}

こんなコードが書き上がりました。渡されたキーとバリューの組の配列をPOSTパラメタにして送信します。

平文じゃあちと怖い

localhostでローカルでやってる分にはこれでいいのですが、
このままでは送信した情報が筒抜けです。
パスワードもログインIDも丸見えです。ヤバいですね。
というわけで

TLSを使おう

としたのですが!

TLSの利用に必要なサーバ証明書を

入手できませんでした!

なのでTLSの話は割愛させていただきます。

ログイン処理を作る

アカウントを作ったら、次に作るべきはやはりログイン処理!
ユーザーが入力したログインIDが正しいかを判定するコードを書いてみましょう。

まずは無難にベーシック認証

ベーシック認証は最も簡易的な認証方法で、ログインIDとパスワードが平文で送信されます。
あまりよろしくはないのですが、まずはベーシック認証(もどき)を作ってみましょう。

イメージ図

完成品

サーバー側

サーバ側コード(激長)
loginbasic.go
package usermethod

import (
	"fmt"      //基本的な入出力処理
	"io"       //入出力
	"net/http" //http通信

	"github.com/Fuses-Garage/UnityGo/util"
	_ "github.com/lib/pq" //ポスグレ使用
)

func Login_Basic(w http.ResponseWriter, r *http.Request) {
	if r.Method != "POST" { //一つでも空欄があれば
		io.WriteString(w, "危険なのでPOSTメソッドでアクセスしてください。") //エラーメッセージを返す
		return
	}
	err := r.ParseForm() //postボディを詠む
	util.CheckErr(err)
	if r.FormValue("pass") == "" || r.FormValue("loginname") == "" { //一つでも空欄があれば
		io.WriteString(w, "全てのテキストボックスに値を入力してください。") //エラーメッセージを返す
		return
	}
	db := util.LoginToDB()
	if err != nil {
		fmt.Printf("読み込み失敗: %v", err)
	}
	hash := util.StringtoHex(r.FormValue("pass")) //受け取ったパスワードをハッシュ化
	row := db.QueryRow("SELECT COUNT(*) AS count FROM userdata_login WHERE loginname=$1 AND passhash=$2",
		r.FormValue("loginname"), hash) //ログイン名とパスワード(のハッシュ値)が一致するユーザーを探す
	var count int
	err = row.Scan(&count) //ヒット数をintで受ける
	util.CheckErr((err))
	if count == 1 { //1件のみヒットしたら
		io.WriteString(w, "success") //ログイン成功
		return
	} else { //ヒットしないもしくは複数ヒットしたら
		io.WriteString(w, "ユーザ名かログインIDが間違っています") //エラーメッセージの情報は最低限
		return
	}
}

クライアント側

クライアント側は先ほど作ったPostSender.csを使いまわすので割愛します。

ベーシック認証の問題点

ここまで実装しておいてアレなのですが、ベーシック問題にはいくつか問題点があります。
一つは、通信の内容からパスワードを読み取られてしまうこと。
もう一つは、暗号化の有無にかかわらず、盗聴された通信内容が
悪意ある人物によって再送信されるとあっさり認証が突破されてしまう点です。
他にも問題点はいくつかあるのですが、今回はこの2つに着目します。

問題点を解消するには?

問題点を解消するカギは、ずばりハッシュ関数と乱数にあります。
ハッシュ関数は入力が同じなら出力も等しくなる性質から暗号分野に多く利用されています。
パスワードを読み取られてしまう問題はハッシュ値を元の値には戻せない性質を、
再送攻撃の問題はハッシュ化の際に元の値をランダムな値と演算すれば解決できそうです。

チャレンジアンドレスポンス認証(もどき)を実装しよう

二つの問題を同時に解決するにはワンタイムパスワードの仕組みを作ればよさそうです。
今回は手っ取り早くチャレンジアンドレスポンス認証(もどき)でワンタイムパスワードの仕組みを作ります。

イメージ図

ランダム文字列の生成にはこの記事の方法を使わせていただきました!

サーバー側

サーバ側コード(長め)
make_challenge.go
package usermethod

import (
	"crypto/rand"
	"errors"
	"fmt"
	"io"       //入出力
	"net/http" //http通信

	"github.com/Fuses-Garage/UnityGo/util"
	_ "github.com/lib/pq" //ポスグレ使用
)

func MakeRandomStr(digit uint32) (string, error) {
	const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

	// 乱数を生成
	b := make([]byte, digit)
	if _, err := rand.Read(b); err != nil {
		return "", errors.New("unexpected error\n")
	}

	// letters からランダムに取り出して文字列を生成
	var result string
	for _, v := range b {
		// index が letters の長さに収まるように調整
		result += string(letters[int(v)%len(letters)])
	}
	return result, nil
}
func MakeChallenge(w http.ResponseWriter, r *http.Request) {
	chl, err := MakeRandomStr(64) //チャレンジを生成
	util.CheckErr(err)
	code, err := MakeRandomStr(64) //チャレンジを識別する文字列を生成
	util.CheckErr(err)
	db := util.LoginToDB()                                           //DBにログイン
	db.Exec("INSERT INTO chltable VALUES(DEFAULT,$1,$2)", code, chl) //DBにチャレンジを追加
	json := fmt.Sprintf(`{"code":"%s","chl":"%s"}`, code, chl)       //JSON文字列にする
	io.WriteString(w, json)                                          //生成したJSONを返す
}
login_chap.go
package usermethod

import (
	"fmt"      //基本的な入出力処理
	"io"       //入出力
	"net/http" //http通信

	"github.com/Fuses-Garage/UnityGo/util"
	_ "github.com/lib/pq" //ポスグレ使用
)

func LoginChap(w http.ResponseWriter, r *http.Request) {
	if r.Method != "POST" { //一つでも空欄があれば
		io.WriteString(w, "危険なのでPOSTメソッドでアクセスしてください。") //エラーメッセージを返す
		return
	}
	db := util.LoginToDB() //DBにログイン
	err := r.ParseForm()   //postボディを詠む
	if err != nil {
		fmt.Printf("読み込み失敗: %v", err)
	}
	resp := r.FormValue("pass")
	row := db.QueryRow("SELECT chl FROM chltable WHERE code=$1", r.FormValue("code")) //一時保存していたチャレンジを取得
	db.Exec("DELETE FROM chltable WHERE code=$1", r.FormValue("code"))                //保存していたチャレンジを削除
	var chl string
	err = row.Scan(&chl)
	util.CheckErr(err)
	if r.FormValue("pass") == "" || r.FormValue("loginname") == "" || r.FormValue("code") == "" { //一つでも空欄があれば
		io.WriteString(w, "全てのテキストボックスに値を入力してください。") //エラーメッセージを返す
		return
	}
	var passhash string                                                                                   //受け取ったパスワードをハッシュ化
	row = db.QueryRow("SELECT passhash FROM userdata_login WHERE loginname=$1", r.FormValue("loginname")) //ログイン名が一致するパスワード(のハッシュ値)を探す
	if row.Scan(&passhash) != nil {                                                                       //対応するユーザーがいなかったら
		io.WriteString(w, "ユーザ名かログインIDが間違っています") //エラーメッセージの情報は最低限
		return
	}
	answer := util.StringtoHex(passhash + chl) //正しいレスポンスを計算
	util.CheckErr((err))
	if answer == resp { //2つのレスポンスが合致すれば
		io.WriteString(w, "success") //ログイン成功
		return
	} else { //ヒットしないもしくは複数ヒットしたら
		io.WriteString(w, "ユーザ名かログインIDが間違っています") //エラーメッセージの情報は最低限
		return
	}
}

サーバ側で行うのは、チャレンジの生成とレスポンスの検証です。
まずはチャレンジの生成処理から。

(一部抜粋)make_challenge.go
func MakeChallenge(w http.ResponseWriter, r *http.Request) {
	chl, err := MakeRandomStr(64) //チャレンジを生成
	util.CheckErr(err)
	code, err := MakeRandomStr(64) //チャレンジを識別する文字列を生成
	util.CheckErr(err)
	db := util.LoginToDB()                                           //DBにログイン
	db.Exec("INSERT INTO chltable VALUES(DEFAULT,$1,$2)", code, chl) //DBにチャレンジを追加
	json := fmt.Sprintf(`{"code":"%s","chl":"%s"}`, code, chl)       //JSON文字列にする
	io.WriteString(w, json)                                          //生成したJSONを返す
}

ここで難点となるのは、複数のユーザが同時にログインした際どのようにユーザとチャレンジを対応させるかでした。
考えられる方法はいくつかありましたが、今回はチャレンジを一時的に識別子とともにDBに保存し、
あとで識別子を送り返してもらうために識別子とチャレンジをJSONにして送りつけます。

次にレスポンスの検証。

(一部抜粋)login_chap.go

	resp := r.FormValue("pass")
	row := db.QueryRow("SELECT chl FROM chltable WHERE code=$1", r.FormValue("code")) //一時保存していたチャレンジを取得
	db.Exec("DELETE FROM chltable WHERE code=$1", r.FormValue("code"))                //保存していたチャレンジを削除
	var chl string
	err = row.Scan(&chl)
	util.CheckErr(err)
	if r.FormValue("pass") == "" || r.FormValue("loginname") == "" || r.FormValue("code") == "" { //一つでも空欄があれば
		io.WriteString(w, "全てのテキストボックスに値を入力してください。") //エラーメッセージを返す
		return
	}
	var passhash string                                                                                   //受け取ったパスワードをハッシュ化
	row = db.QueryRow("SELECT passhash FROM userdata_login WHERE loginname=$1", r.FormValue("loginname")) //ログイン名が一致するパスワード(のハッシュ値)を探す
	if row.Scan(&passhash) != nil {                                                                       //対応するユーザーがいなかったら
		io.WriteString(w, "ユーザ名かログインIDが間違っています") //エラーメッセージの情報は最低限
		return
	}
	answer := util.StringtoHex(passhash + chl) //正しいレスポンスを計算
	util.CheckErr((err))
	if answer == resp { //2つのレスポンスが合致すれば
		io.WriteString(w, "success") //ログイン成功
		return
	} else { //ヒットしないもしくは複数ヒットしたら
		io.WriteString(w, "ユーザ名かログインIDが間違っています") //エラーメッセージの情報は最低限
		return
	}

こちら側で保存していたパスワードのハッシュとチャレンジから正しいレスポンスを計算。
送られてきたレスポンスと比較します。
使い終わったチャレンジは邪魔になるだけなので消してしまいましょう。

クライアント側

クライアント側コード(長め)
LoginChap.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using System.Security.Cryptography;
using System.Text;
public class LoginChap : MonoBehaviour
{ 
    [SerializeField] InputField id;//ユーザID入力フォーム
    [SerializeField] InputField pass;//パスワード入力フォーム
    [SerializeField] GameObject Window;//メッセージウィンドウのプレハブ
    public void Submit()//入力確定
    {
        if(id.text==""||pass.text==""){
            EndProcess("全てのテキストボックスに値を入力してください。");
            return;
        }
        PostParam[] param = {null};
        StartCoroutine(PostSender.Get("getchallenge",SendChallenge));//コルーチン開始
    }
    public void SendChallenge(string chl){//チャレンジを受け取ったら
        Debug.Log(chl);
        try{
            var challenge = JsonUtility.FromJson<ChallengeData>(chl);//JSONをパース
            var csp = new SHA256CryptoServiceProvider();//ハッシュ化用クラス
            var targetBytes = Encoding.UTF8.GetBytes(pass.text);//バイトコードに変換
            var hashBytes = csp.ComputeHash(targetBytes);//ハッシュ化
            var hashStr = new StringBuilder();
            foreach (var hashByte in hashBytes) {
                hashStr.Append(hashByte.ToString("x2"));//Stringに変換していく
            }
            targetBytes = Encoding.UTF8.GetBytes(hashStr.ToString()+challenge.chl);//チャレンジとくっ付けてまたバイトコード化
            hashBytes = csp.ComputeHash(targetBytes);//またハッシュ化
            hashStr = new StringBuilder();
            foreach (var hashByte in hashBytes) {
                hashStr.Append(hashByte.ToString("x2"));//またString化
            }
            PostParam[] param = { new PostParam("loginname", id.text), new PostParam("pass", hashStr.ToString()),new PostParam("code",challenge.code) };//チャレンジの識別子とともに送り付ける
            StartCoroutine(PostSender.Upload(param,"login_chap",EndProcess));//コルーチン開始
        }catch{//JSON以外が帰ってきたら
            GameObject go = Instantiate(Window, this.transform.parent.parent);//ウィンドウ生成
            go.GetComponent<MessageSerializer>().SetType(2, chl);//エラーメッセージ出力
        }
    }
    void EndProcess(string mes)//処理が終わったら
    {
        GameObject go = Instantiate(Window, this.transform.parent.parent);//ウィンドウ生成
        if (mes == "success")//正常に終了すれば
        {
            go.GetComponent<MessageSerializer>().SetType(0, "ログインに成功しました!");//成功
        }
        else//エラーなら
        {
            go.GetComponent<MessageSerializer>().SetType(2, mes);//エラーメッセージ出力
        }
    }
}
[System.Serializable]
public class ChallengeData{
    public string code;
    public string chl;
}

クライアント側で行うのはチャレンジの要求とレスポンスの計算です。
まずはチャレンジの要求からやってみます。

(一部抜粋)LoginChap.cs
public void Submit()//入力確定
    {
        if(id.text==""||pass.text==""){
            EndProcess("全てのテキストボックスに値を入力してください。");
            return;
        }
        PostParam[] param = {null};
        StartCoroutine(PostSender.Get("getchallenge",SendChallenge));//コルーチン開始
    }

まずはチャレンジを要求すべく、1回目のリクエストを行います。
チャレンジを受け取ったら、レスポンスの計算に移りましょう。

(一部抜粋)LoginChap.cs
public void SendChallenge(string chl){//チャレンジを受け取ったら
        Debug.Log(chl);
        try{
            var challenge = JsonUtility.FromJson<ChallengeData>(chl);//JSONをパース
            var csp = new SHA256CryptoServiceProvider();//ハッシュ化用クラス
            var targetBytes = Encoding.UTF8.GetBytes(pass.text);//バイトコードに変換
            var hashBytes = csp.ComputeHash(targetBytes);//ハッシュ化
            var hashStr = new StringBuilder();
            foreach (var hashByte in hashBytes) {
                hashStr.Append(hashByte.ToString("x2"));//Stringに変換していく
            }
            targetBytes = Encoding.UTF8.GetBytes(hashStr.ToString()+challenge.chl);//チャレンジとくっ付けてまたバイトコード化
            hashBytes = csp.ComputeHash(targetBytes);//またハッシュ化
            hashStr = new StringBuilder();
            foreach (var hashByte in hashBytes) {
                hashStr.Append(hashByte.ToString("x2"));//またString化
            }
            PostParam[] param = { new PostParam("loginname", id.text), new PostParam("pass", hashStr.ToString()),new PostParam("code",challenge.code) };//チャレンジの識別子とともに送り付ける
            StartCoroutine(PostSender.Upload(param,"login_chap",EndProcess));//コルーチン開始
        }catch{//JSON以外が帰ってきたら
            GameObject go = Instantiate(Window, this.transform.parent.parent);//ウィンドウ生成
            go.GetComponent<MessageSerializer>().SetType(2, chl);//エラーメッセージ出力
        }
    }

ここで注意したいのが、データベースに保存されているのはパスワードではなくパスワードのハッシュ値だということです。
そのためハッシュ化チャレンジと結合もう一度ハッシュ化とハッシュ化を2回行う必要があります。
レスポンスを送った後の処理はベーシック認証(もどき)の時と同じなので割愛します。

セッションのシステムを作ろう

認証を作ってみたはいいものの、このままではログイン状態をサーバーに保持することができません。
そこで、ログイン状態を識別するセッションIDをクライアント、サーバの双方に保存させてみましょう。

イメージ図

サーバー側

ログイン成功時に、新しく生成したセッションIDをクライアントに渡しちゃいましょう。

サーバ側コード(長め)
session_method.go
package usermethod

import (
	//基本的な入出力処理
	"database/sql"
	"fmt"
	"io"       //入出力
	"net/http" //http通信
	"time"

	"github.com/Fuses-Garage/UnityGo/util"
	_ "github.com/lib/pq" //ポスグレ使用
)

func LoginSuccess(loginname string, db *sql.DB) string { //ログイン成功時の処理
	ans := ""
	row := db.QueryRow("SELECT sessioncode FROM userdata_login WHERE loginname=$1", loginname) //ログインしたユーザのセッションコードを検索
	row.Scan(&ans)
	if ans == "" { //まだセッションがないなら
		randstr, err := MakeRandomStr(256)
		util.CheckErr(err)
		ans = util.StringtoHex(loginname + randstr + time.Now().String())                      //セッションIDを生成
		db.Exec("UPDATE userdata_login SET sessioncode=$1 WHERE loginname=$2", ans, loginname) //セッションIDを書き込み
	}
	db.Exec("UPDATE userdata_login SET lastlogin=$1 WHERE loginname=$2", time.Now(), loginname) //最終ログイン日時を更新
	return ans
}
func CheckSession(SID string, db *sql.DB) string {
	row := db.QueryRow("SELECT COUNT(*) AS count FROM userdata_login WHERE sessioncode=$1", SID) //セッションIDが一致するユーザーを探す
	var count int
	err := row.Scan(&count) //ヒット数をintで受ける
	util.CheckErr((err))
	if count == 1 { //1件のみヒットしたら
		namerow := db.QueryRow("SELECT loginname FROM userdata_login WHERE sessioncode=$1", SID) //セッションIDが一致するユーザーを探す
		var loginname string
		err = namerow.Scan(&loginname) //ログイン名を取り出す
		util.CheckErr(err)
		LoginSuccess(loginname, db) //ログイン成功時の処理
		return "success"            //成功!
	} else { //ヒットしないもしくは複数ヒットしたら
		return "login please" //ログインを要求
	}
}
login_chap.go
package usermethod

import (
	"fmt"      //基本的な入出力処理
	"io"       //入出力
	"net/http" //http通信

	"github.com/Fuses-Garage/UnityGo/util"
	_ "github.com/lib/pq" //ポスグレ使用
)

func LoginChap(w http.ResponseWriter, r *http.Request) {
	if r.Method != "POST" { //一つでも空欄があれば
		io.WriteString(w, "危険なのでPOSTメソッドでアクセスしてください。") //エラーメッセージを返す
		return
	}
	db := util.LoginToDB() //DBにログイン
	err := r.ParseForm()   //postボディを詠む
	if err != nil {
		fmt.Printf("読み込み失敗: %v", err)
	}
	resp := r.FormValue("pass")
	row := db.QueryRow("SELECT chl FROM chltable WHERE code=$1", r.FormValue("code")) //一時保存していたチャレンジを取得
	db.Exec("DELETE FROM chltable WHERE code=$1", r.FormValue("code"))                //保存していたチャレンジを削除
	var chl string
	err = row.Scan(&chl)
	util.CheckErr(err)
	if r.FormValue("pass") == "" || r.FormValue("loginname") == "" || r.FormValue("code") == "" { //一つでも空欄があれば
		io.WriteString(w, "全てのテキストボックスに値を入力してください。") //エラーメッセージを返す
		return
	}
	var passhash string                                                                                   //受け取ったパスワードをハッシュ化
	row = db.QueryRow("SELECT passhash FROM userdata_login WHERE loginname=$1", r.FormValue("loginname")) //ログイン名が一致するパスワード(のハッシュ値)を探す
	if row.Scan(&passhash) != nil {                                                                       //対応するユーザーがいなかったら
		io.WriteString(w, "ユーザ名かログインIDが間違っています") //エラーメッセージの情報は最低限
		return
	}
	answer := util.StringtoHex(passhash + chl) //正しいレスポンスを計算
	util.CheckErr((err))
	if answer == resp { //2つのレスポンスが合致すれば
-       io.WriteString(w, "success") //ログイン成功
+       io.WriteString(w, "success "+LoginSuccess(r.FormValue("loginname"), db)) //ログイン成功
		return
	} else { //ヒットしないもしくは複数ヒットしたら
		io.WriteString(w, "ユーザ名かログインIDが間違っています") //エラーメッセージの情報は最低限
		return
	}
}

まずはログイン成功時のセッションID生成処理を作ります。

(一部抜粋)session_method.go
func LoginSuccess(loginname string, db *sql.DB) string { //ログイン成功時の処理
	ans := ""
	row := db.QueryRow("SELECT sessioncode FROM userdata_login WHERE loginname=$1", loginname) //ログインしたユーザのセッションコードを検索
	row.Scan(&ans)
	if ans == "" { //まだセッションがないなら
		randstr, err := MakeRandomStr(256)
		util.CheckErr(err)
		ans = util.StringtoHex(loginname + randstr + time.Now().String())                      //セッションIDを生成
		db.Exec("UPDATE userdata_login SET sessioncode=$1 WHERE loginname=$2", ans, loginname) //セッションIDを書き込み
	}
	db.Exec("UPDATE userdata_login SET lastlogin=$1 WHERE loginname=$2", time.Now(), loginname) //最終ログイン日時を更新
	return ans
}

ここでセッションIDのもとになる文字列は絶対に一意になるようにしましょう。
被ろうものならセッションが乗っ取られてしまいます。気をつけましょう。

ちなみに最終ログイン日時はまた後で使います。

次にユーザーのログイン状態をチェックし、ログインしていないならログインを要求する関数を作ります。

(一部抜粋)session_method.go
func CheckSession(SID string, db *sql.DB) string {
	row := db.QueryRow("SELECT COUNT(*) AS count FROM userdata_login WHERE sessioncode=$1", SID) //セッションIDが一致するユーザーを探す
	var count int
	err := row.Scan(&count) //ヒット数をintで受ける
	util.CheckErr((err))
	if count == 1 { //1件のみヒットしたら
		namerow := db.QueryRow("SELECT loginname FROM userdata_login WHERE sessioncode=$1", SID) //セッションIDが一致するユーザーを探す
		var loginname string
		err = namerow.Scan(&loginname) //ログイン名を取り出す
		util.CheckErr(err)
		LoginSuccess(loginname, db) //ログイン成功時の処理
		return "success"            //成功!
	} else { //ヒットしないもしくは複数ヒットしたら
		return "login please" //ログインを要求
	}
}

認証情報の代わりにセッションIDを使っているだけで、特に特筆すべきところはないですね。

あとはログイン成功時に生成したセッションIDを一緒に送るように改造しておきましょう。

(一部抜粋)login_chap.go
	if answer == resp { //2つのレスポンスが合致すれば
-       io.WriteString(w, "success") //ログイン成功
+       io.WriteString(w, "success "+LoginSuccess(r.FormValue("loginname"), db)) //ログイン成功
		return
	} else { //ヒットしないもしくは複数ヒットしたら
		io.WriteString(w, "ユーザ名かログインIDが間違っています") //エラーメッセージの情報は最低限
		return
	}

クライアント側

認証処理を改造して、セッションIDを受け取って保存しましょう。

クライアント側コード(短め)
(一部抜粋)LoginChap.cs
    void EndProcess(string mes)//処理が終わったら
    {
        var meses=mes.Split(" ");
        GameObject go = Instantiate(Window, this.transform.parent.parent);//ウィンドウ生成
        if (meses[0] == "success")//正常に終了すれば
        {
            PlayerPrefs.SetString("SID",meses[1]);
            PlayerPrefs.SetString("loginname",id.text);
            SceneManager.LoadScene(1);//ゲームシーンに遷移
        }
        else//エラーなら
        {
            go.GetComponent<MessageSerializer>().SetType(2, mes);//エラーメッセージ出力
        }
    }

セッションIDがスペース区切りで帰ってくるので、String.split()で分割してしまいましょう。

(一部抜粋)GetUserData.cs
    void EndProcess(string mes)//処理が終わったら
    {
        var meses=mes.Split(" ");
        GameObject go = Instantiate(Window, this.transform.parent.parent);//ウィンドウ生成
        if (meses[0] == "success")//正常に終了すれば
        {
            PlayerPrefs.SetString("SID",meses[1]);
            PlayerPrefs.SetString("loginname",id.text);
            SceneManager.LoadScene(1);//ゲームシーンに遷移
        }
        else//エラーなら
        {
            go.GetComponent<MessageSerializer>().SetType(2, mes);//エラーメッセージ出力
        }
    }

実際にセッションを使ってみよう

セッションの仕組みを作ったので、さっそくゲーム画面にユーザ情報を表示していきましょう。

イメージ図

完成品

サーバー側

APIサーバをGo言語で立てて、クライアント(Unity)からのリクエストを待ち受けます。

(一部抜粋)session_method.go
func GetUInfo(w http.ResponseWriter, r *http.Request) {
	if r.Method != "POST" { //POSTじゃなければ
		io.WriteString(w, "危険なのでPOSTメソッドでアクセスしてください。") //エラーメッセージを返す
		return
	}
	err := r.ParseForm() //postボディを詠む
	util.CheckErr(err)
	if r.FormValue("SID") == "" { //セッションIDが空白なら
		io.WriteString(w, "login please") //ログインを要求する
		return
	} else {
		db := util.LoginToDB()                         //DBにログイン
		result := CheckSession(r.FormValue("SID"), db) //ログイン状態を確認
		if result == "success" {                       //ログイン中なら
			namerow := db.QueryRow("SELECT name FROM userdata_login WHERE sessioncode=$1", r.FormValue("SID")) //セッションIDが一致するユーザーを探す
			name := ""
			namerow.Scan(&name)                        //ユーザ情報を取得
			json := fmt.Sprintf(`{"name":"%s"}`, name) //JSON文字列にする
			io.WriteString(w, result+" "+json)         //ログインを要求する
		} else {
			io.WriteString(w, result) //ログインを要求する
		}

		return
	}
}

今回取得するのはユーザーの名前のみですが、今後の拡張を考えてJSONで返させましょう。

クライアント側

受け取ったJSONをパースして、内容を画面のテキストに代入していきます。

クライアント側コード(長め) ```GetUserData.cs using System.Collections; using System.Collections.Generic; using UnityEngine.SceneManagement; using UnityEngine; using UnityEngine.UI; public class GetUserData : MonoBehaviour { [SerializeField] GameObject Window;//メッセージウィンドウのプレハブ [SerializeField] Text uname; private void Start() { if(PlayerPrefs.HasKey("SID")){ PostParam[] param = { new PostParam("SID",PlayerPrefs.GetString("SID")) }; StartCoroutine(PostSender.Upload(param,"getuserinfo",EndProcess));//コルーチン開始 } } void EndProcess(string mes)//処理が終わったら { var meses=mes.Split(" "); if (meses[0] == "success")//正常に終了すれば { var uinfo = JsonUtility.FromJson(meses[1]);//JSONをパース uname.text=uinfo.name; } else if(mes.Equals("login please"))//セッションエラーなら { SceneManager.LoadScene(0);//タイトルに戻る }else{//エラーなら GameObject go = Instantiate(Window, this.transform.parent.parent);//ウィンドウ生成 go.GetComponent().SetType(2, mes);//エラーメッセージ出力 } } } [System.Serializable] public class UserInfo{ public string name; } ```
(一部抜粋)GetUserData.cs
    private void Start() 
    {
        if(PlayerPrefs.HasKey("SID")){
            PostParam[] param = { new PostParam("SID",PlayerPrefs.GetString("SID")) };
            StartCoroutine(PostSender.Upload(param,"getuserinfo",EndProcess));//コルーチン開始
        }
    }
    void EndProcess(string mes)//処理が終わったら
    {
        var meses=mes.Split(" ");
        if (meses[0] == "success")//正常に終了すれば
        {
            var uinfo = JsonUtility.FromJson<UserInfo>(meses[1]);//JSONをパース
            uname.text=uinfo.name;
        }
        else if(mes.Equals("login please"))//セッションエラーなら
        {
            SceneManager.LoadScene(1);//タイトルに戻る
        }else{//エラーなら
            GameObject go = Instantiate(Window, this.transform.parent.parent);//ウィンドウ生成
            go.GetComponent<MessageSerializer>().SetType(2, mes);//エラーメッセージ出力
        }
    }

正常に処理出来たら表示更新、
セッションエラーならタイトルに戻ってログイン要求、
それ以外のエラーならタイトルに戻る…と3種類の処理に分岐させましょう。

あとがき

長い記事にもかかわらずここまでお付き合いいただきありがとうございます。
ここまでの知識を応用すれば、様々なブラウザオンラインゲームを作れるはずです。
この記事を通して少しでもサーバサイドに興味を持っていただけたらいいなと思っています。
それでは。

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0