もなかアイスの試食品

「とりあえずやってみたい」そんな気持ちが先走りすぎて挫折が多い私のメモ書きみたいなものです.

SptingBoot(SpringSecurity)で簡単にログインパラメータを増やす方法

はじめに

SpringBootを使って新しいサービスを作ることになった

そのサービスでは、ログイン画面にログインIDとパスワード以外に、もう一つパラメータが必要

SpringBoot(SpringSecurity)ではログインIDとパスワードを使ったログイン認証が簡単に実装できるようになっているけど、他のパラメータを増やす方法を調べてみた。

パラメータ追加についての参考サイト

terasolunaorg.github.io

先にまず結論

参考にしたサイトではUsernamePasswordAuthenticationTokenやらUsernamePasswordAuthenticationFilterなどのクラスを継承するやり方が書いてあるけど・・・

結構面倒臭い(詳しくは参考サイトを見てほしい)

実装が終わった後、「パラメータ(パスワード以外)を区切り文字で全部くっつけた文字列」をログインIDとするほうが簡単で、認証処理がそのまま使えることに気がついた・・・

具体例

例えばログイン画面に以下のパラメータが必要だったとする

  • 会社コード
  • 従業員コード
  • パスワード

このとき、ユーザのDBテーブルは最低限の実装は以下の感じになると思う(会社コードと従業員コードが主キー)

会社コード 従業員コード ユーザ名 パスワード
100 1 しげる (ハッシュ値)
100 2 もりお (ハッシュ値)
100 3 やさお (ハッシュ値)
200 1 やばお (ハッシュ値)
200 2 まさよ (ハッシュ値)

このテーブルに「認証用に余計なカラム」を追加する

会社コード 従業員コード ログインID ユーザ名 パスワード
100 1 100__d(OvO)__1 しげる (ハッシュ値)
100 2 100__d(OvO)__2 もりお (ハッシュ値)
100 3 100__d(OvO)__3 やさお (ハッシュ値)
200 1 200__d(OvO)__1 やばお (ハッシュ値)
200 2 200__d(OvO)__2 まさよ (ハッシュ値)

ログインIDというカラムを追加し、会社コードと従業員コードを「__d(OvO)__」という文字列でくっつけたデータを入れている

ログインIDのカラムに「会社コード + __d(OvO)__ + 従業員コード」以外の文字列が入らないようにCHECK制約入れておくと安心

なぜ顔文字?というと

  1. 区切り文字が「@」だとメールアドレスを使用することになったら困るかも。他の記号も意外と危ないかな・・・?
  2. 記号を複数回・複数種類で組み合わせれば大丈夫じゃね?
  3. じゃぁ顔文字使えるじゃん!

という訳で特に理由はない

この追加したカラムをSpringBootで言うところのusernameに割り当てる

usernameパラメータはhtmlでhiddenにしておき、JavascriptなりjQueryなりでsubmitするときに、usernameパラメータを設定してあげる。

<form id="login-form" action="/login" method="post">
  <input type="hidden" id="user-name" name="username"/>
  <div>
    <label for="company-code">会社コード</label>
    <input id="company-code"/>
  </div>
  <div>
    <label for="employee-code">従業員コード</label>
    <input id="employee-code"/>
  </div>
  <div>
    <label for="password">パスワード</label>
    <input type="password" id="password" name="password"/>
  </div>
  <div>
    <input type="submit" value="ログイン"/>
  </div>
</form>
$('#login-form').submit(function() {
  var companyCode = $('#company-code').val();
  var employeeCode = $('#employee-code').val();
  $('#user-name').val(companyCode + '__d(OvO)__' + employeeCode);
  return true;
});

後の具体的な実装については、いろんなサイトに載っているSpringSecurityのログイン機能の実装の仕方と同じになる

実装例↓

qiita.com

認証処理のカスタムクラスを作ったときの面倒臭いところ

そもそもカスタムクラスを作るのが面倒臭い

ネットで調べたり、ソースコードを覗いたりすると、SpringSecurityのRememberMeの機能にも手を入れる必要があると気がついた。(RememberMe機能を利用する場合)

qiita.com

上記の記事を参考にすると、CookieにログインIDを保存している。

SpringSecurityのソースコードでも、Cookieからユーザを検索する処理が確認できた

public class TokenBasedRememberMeServices extends AbstractRememberMeServices {

    @Override
    protected UserDetails processAutoLoginCookie(String[] cookieTokens,
            HttpServletRequest request, HttpServletResponse response) {

        // 中略

        // Cookieからユーザを検索してるっぽい。引数は一個だけ
        UserDetails userDetails = getUserDetailsService().loadUserByUsername(
                cookieTokens[0]);

        // 中略

        return userDetails;
    }
}

ユーザの主キーは会社コードと従業員コードで設計したので、cookieTokens[0]に会社コードと従業員コードを入れてユーザを特定できるようにしないといけない

RememberMe用のCookie生成処理も手を入れないといけない

カスタムクラスの作成に意外と時間が掛かり、RememberMeも同じぐらい色々やらないといけなさそうと思ったとき、もう諦めの境地

おわりに

そもそも主キーの会社コードと従業員コードを文字列結合すれば、ユニークになるということに早く気がつけば・・・

調べ方が悪いのか、パラメータデータをくっつけてログインIDにするという方法は見つからない

邪道なんだろうか・・・

Go言語でWake On Lan出来るようにする(マジックパケットを送信する)

はじめに

社内の不要になったマシンにCentOSを入れて、GitLabを運用することにした。

会社にいる間しか使用しないので、20時にサスペンドするcronを設定した。

朝イチにサーバの電源ボタンを押すのが面倒くさいので、自分のマシンからWake On LanでGitLabサーバを起動するようにした。

最初はWindowsバッチ処理を他のサイトから拾ってきて起動していたけど、今会社でメインに使用しているのがmac

なので、mac/Windows両方からWake On Lan出来るように、Go言語でマジックパケットを送信するプログラムを作ってみた。

参考サイト

e-words.jp

qiita.com

poga.jp

ソースコード

宛先のポート番号は適当に9999にした。

package main

import (
    "fmt"
    "net"
)

func main() {
    // マジックパケットについて
    // See: http://e-words.jp/w/%E3%83%9E%E3%82%B8%E3%83%83%E3%82%AF%E3%83%91%E3%82%B1%E3%83%83%E3%83%88.html

    // マジックパケット送信先のMACアドレスを記述する
    targetMacAddrs := [...] string{
        "xx:xx:xx:xx:xx:xx",
    }

    remoteUdpAddr, _ := net.ResolveUDPAddr("udp", "255.255.255.255:9999")
    localUdpAddr, _ := net.ResolveUDPAddr("udp", ":0")
    conn, err := net.DialUDP("udp", localUdpAddr, remoteUdpAddr)
    if err != nil {
        fmt.Println(err.Error())
        return
    }
    defer func() {
        _ = conn.Close()
    }()

    packetPrefix := make([]byte, 6)
    for i := range packetPrefix {
        packetPrefix[i] = 0xFF
    }

    for _, macAddress := range targetMacAddrs {
        hw, err := net.ParseMAC(macAddress)
        if err != nil {
            fmt.Println(err.Error())
            return
        }

        sendPacket := make([]byte, 0)
        sendPacket = append(sendPacket, packetPrefix...)
        for i := 0; i < 16; i++ {
            sendPacket = append(sendPacket, hw...)
        }

        _, err = conn.Write(sendPacket)
        if err != nil {
            fmt.Println(err.Error())
            return
        }
    }

}

おわりに

とりあえず今回のコードで、自分の環境はmac/Windows共にCentOSマシンをWake On Lanで起動できるようになった。

0xFFを6回書くのは微妙かなって思ってループ使ってるけど、配列(スライス)を0xFFを6個分で初期化するみたいなことをGo言語はもっと簡単に書けないっすかね?

SpringBootで暗号化・復号処理の実装

はじめに

DBにデータを保存する際に、セキュリティ向上のため暗号化してDBに保存することにした。

目次

参考サイト

qiita.com

blog.hide92795.org

実装

設定値の追加

application.ymlに暗号化に使用するキーの設定値を追加した

my_constants:
  security:
    key: (英数大文字小文字・数字・記号を使った16文字)

キーは16文字で、正直決めるのが面倒くさかったので、以下のサイトでランダム生成した

www.luft.co.jp

Beanの追加

暗号化・復号処理で使うインスタンスをBeanで用意する

@Configuration
public class CommonConfig {

    @Bean
    public SecretKeySpec secretKeySpec(@Value("${my-constants.security.key}") String key) {
        return new SecretKeySpec(key.getBytes(), "AES");
    }

    @Bean
    public Cipher cipher()
        throws NoSuchPaddingException, NoSuchAlgorithmException {
        return Cipher.getInstance("AES/CBC/PKCS5Padding");
    }
}

ログ出力

ログ出力にデフォルト実装を使った

/**
 * ログを出力する機能を実装する
 *
 * ログを出力したいクラスで、このインターフェースを実装する<br/>
 *
 * <pre>
 *     class Foo implements Loggable {
 *         public void bar() {
 *             logger().debug(&quot;Hello world!!!&quot;);
 *         }
 *     }
 * </pre>
 */
public interface Loggable {

    /**
     * デフォルト実装
     *
     * @return ロガー
     */
    default Logger logger() {
        return LoggerFactory.getLogger(this.getClass());
    }

}

暗号化・復号処理クラスの作成

実際の暗号化・復号化処理は以下の通り

@Component
public class Crypt implements Loggable {

    private final SecretKeySpec secretKeySpec;

    private final Cipher cipher;

    // 暗号化・復号処理が同時に呼び出されたときに、Cipher#initを実行しないようにするためのロック
    // 実際に同時にCipher#initした時にどうなるかは未確認。良くないことが起きそう気がするのでロックしている
    // cipherを暗号化用/復号用でインスタンスを分けるのもアリかも
    private final Object lockObj = new Object();

    @Autowired
    public Crypt(SecretKeySpec secretKeySpec, Cipher cipher) {
        this.secretKeySpec = secretKeySpec;
        this.cipher = cipher;
    }

    /**
     * 暗号化する
     *
     * @param str 暗号化する文字列
     * @return 暗号化した文字列
     */
    public String encrypt(String str) {
        synchronized (this.lockObj) {
            this.setEncryptMode();
            byte[] encodedBytes;
            try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
                // 最初に初期ベクトルを出力
                outputStream.write(this.cipher.getIV());
                // ByteArrayOutputStream#toByteArray実行前に、CipherOutputStreamを先にcloseしないと復号化に失敗する
                try (CipherOutputStream cipherOutputStream = new CipherOutputStream(outputStream, this.cipher)) {
                    cipherOutputStream.write(str.getBytes());
                }
                encodedBytes = outputStream.toByteArray();
            } catch (IOException e) {
                this.logger().error("failed cipher.");
                throw new CryptException(e);
            }

            byte[] base64Bytes = Base64.getEncoder().encode(encodedBytes);
            return new String(base64Bytes);
        }
    }

    /**
     * 復号する
     *
     * @param str 復号する文字列
     * @return 復号した文字列
     */
    public String decrypt(String str) {
        synchronized (this.lockObj) {
            byte[] base64Bytes = Base64.getDecoder().decode(str);
            try (ByteArrayInputStream inputStream = new ByteArrayInputStream(base64Bytes)) {
                byte[] iv = getIVForDecode(inputStream);
                this.setDecryptMode(iv);
                return this.decryptCipherStream(inputStream);
            } catch (IOException e) {
                this.logger().error("failed decode.");
                throw new CryptException(e);
            }
        }
    }

    /**
     * 暗号化モードに設定する
     */
    private void setEncryptMode() {
        try {
            this.cipher.init(Cipher.ENCRYPT_MODE, this.secretKeySpec);
        } catch (InvalidKeyException e) {
            this.logger().error("failed initialize cipher.");
            throw new CryptException(e);
        }
    }

    /**
     * 復号モードに設定する
     */
    private void setDecryptMode(byte[] iv) {
        IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
        try {
            this.cipher.init(Cipher.DECRYPT_MODE, this.secretKeySpec, ivParameterSpec);
        } catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
            this.logger().error("failed initialize cipher.");
            throw new CryptException(e);
        }
    }

    /**
     * 暗号化されたストリームを復号する
     *
     * @param inputStream ストリーム
     * @return 復号されたデータ
     */
    private String decryptCipherStream(ByteArrayInputStream inputStream) {
        try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
            try (CipherInputStream cipherInputStream = new CipherInputStream(inputStream, this.cipher)) {
                byte[] buffer = new byte[1024];
                int length;
                while ((length = cipherInputStream.read(buffer)) != -1) {
                    outputStream.write(buffer, 0, length);
                }
            }
            return outputStream.toString();
        } catch (IOException e) {
            this.logger().error("failed decode.");
            throw new CryptException(e);
        }
    }

    /**
     * 暗号化されたデータから初期ベクトルを取得する。
     *
     * @param inputStream 暗号化データのストリーム
     * @return 初期ベクトル
     */
    private static byte[] getIVForDecode(ByteArrayInputStream inputStream) {
        byte[] iv = new byte[16];
        for (int i = 0; i < iv.length; ++i) {
            iv[i] = (byte)inputStream.read();
        }
        return iv;
    }

}

例外クラス

暗号化・復号失敗時の例外クラス

@ResponseStatus(code = HttpStatus.INTERNAL_SERVER_ERROR)
public class CryptException extends RuntimeException {

    public CryptException() {
    }

    public CryptException(String message) {
        super(message);
    }

    public CryptException(String message, Throwable cause) {
        super(message, cause);
    }

    public CryptException(Throwable cause) {
        super(cause);
    }

    public CryptException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}

テスト

適当にテストコードを書いてみた。

10万回ほどランダムな文字列を作成して、暗号化→復号して文字列が一致するかテスト

何回か実行して大丈夫だった。

@RunWith(SpringRunner.class)
@SpringBootTest
public class CryptTests implements Loggable {

    @Autowired
    private Crypt testTarget;

    @Test
    public void cryptTest() {
        IntStream.rangeClosed(1, 100_000)
            .forEach(i -> {
                int randomNumber = Integer.parseInt(RandomStringUtils.randomNumeric(1, 5));
                String rawText = RandomStringUtils.randomAlphanumeric(randomNumber);
                this.logger().info(String.format("rawText text:%s", rawText));

                String encodedText = this.testTarget.encrypt(rawText);
                this.logger().info(String.format("encoded text:%s", encodedText));

                String decodedText = this.testTarget.decrypt(encodedText);
                assertEquals(rawText, decodedText);
            });
    }

}

おわりに

取り敢えず暗号化する処理はできた。

暗号化するとデータが増加するので、暗号化前のデータを圧縮した方が良いと何処かで見かけたので、ZipOutputStreamとかGZIPOutputStreamを使って見たけど、上手く行かなかった・・・