kaakaa Blog

この世は極楽 空にはとんぼ

Basic認証付きのURLに格納されてるスクリプトを実行する

ネットに転がっているGroovyスクリプトを直接実行する
が面白そうなので、Basic認証付きのページからGroovyスクリプトを取得出来るか考えてみた。
うちで使ってるSVNBasic認証付きなので。

Basic認証エラー

http://localhost:4567/にアクセスすると、以下のテキストが返ってくるとします。

class Hello{ static void main(args){println "Hello world!"}}

このページをBasic認証付きにして、実行しようとすると401エラー。

kaakaa_hoe@[bin]$ groovy http://localhost:4567/
Caught: groovy.lang.GroovyRuntimeException: Unable to get script from URL: 
groovy.lang.GroovyRuntimeException: Unable to get script from URL: 
Caused by: java.io.IOException: Server returned HTTP response code: 401 for URL: http://localhost:4567/

Groovyコードリーディング

とりあえずGroovyのコードを追ってみる。
groovy(.bat) -> startGroovy(.bat) -> org.codehaus.groovy.tools.GroovyStarter
という順で実行されてるらしい。

GroovyStarter.javaの中を見ていくと、リフレクション使ってメソッドを呼び出してるらしい。

...
        // create loader and execute main class
        ClassLoader loader = new RootLoader(lc);  // 92行目
        Method m=null;
        try {
            Class c = loader.loadClass(lc.getMainClass());
            m = c.getMethod("main", new Class[]{String[].class});
        } catch (ClassNotFoundException e1) {
            exit(e1);
        } catch (SecurityException e2) {
            exit(e2);
        } catch (NoSuchMethodException e2) {
            exit(e2);
        }
        try {
            m.invoke(null, new Object[]{newArgs});
        } catch (IllegalArgumentException e3) {
            exit(e3);
        } catch (IllegalAccessException e3) {
            exit(e3);
        } catch (InvocationTargetException e3) {
            exit(e3);
        } 
...

この時、呼び出されてるのはgroovy(.bat)の一番最後の行で指定されてるクラスのmainメソッド
デフォルトだとgroovy.ui.GroovyMain。



というわけで、次はGroovyMain.javaを見ていく。

注目すべきはGroovyMain.javaの最後に宣言されてるメソッドprocessOnce。

...
    /**
     * Process the standard, single script with args.
    */
    private void processOnce() throws CompilationFailedException, IOException {    //581
        GroovyShell groovy = new GroovyShell(conf);

        if (isScriptFile) {
            if (isScriptUrl(script)) {
                groovy.run(getText(script), script.substring(script.lastIndexOf("/") + 1), args);
            } else {
                groovy.run(huntForTheScriptFile(script), args);
            }
        } else {
            groovy.run(script, "script_from_command_line", args);
        }
    }
}

ここのscriptというStringの変数がgroovyコマンドの引数として与えられているスクリプトファイルのパスを表している。(今回はscript='http://localhost:4567/'
変数scriptがURL形式の場合はgetText(script)メソッドでURLの場所にあるデータを取りに行く。


getTextメソッドGroovyMain.javaの409行目あたり。

...
    public String getText(String urlOrFilename) throws IOException {      //409行目
        if (isScriptUrl(urlOrFilename)) {
            try {
                return ResourceGroovyMethods.getText(new URL(urlOrFilename));
            } catch (Exception e) {
                throw new GroovyRuntimeException("Unable to get script from URL: ", e);
            }
        }
        return ResourceGroovyMethods.getText(huntForTheScriptFile(urlOrFilename));
    }
...

URL形式の場合はURLオブジェクトに変換されて、ResourceGroovyMethodsクラスのgetTextメソッドが呼び出される。

return ResourceGroovyMethods.getText(new URL(urlOrFilename));


次はResourceGroovyMethods.java

ResourceGroovyMethods#getText(URL url)から辿っていくと、以下のメソッドにたどり着く。

...
    /**
     * Creates a buffered reader for this URL using the given encoding.
     *
     * @param url a URL
     * @param charset opens the stream with a specified charset
     * @return a BufferedReader for the URL
     * @throws MalformedURLException is thrown if the URL is not well formed
     * @throws IOException if an I/O error occurs while creating the input stream
     * @since 1.5.5
    */
    public static BufferedReader newReader(URL url, String charset) throws MalformedURLException, IOException {      // 1945行目
        return new BufferedReader(new InputStreamReader(configuredInputStream(null, url), charset));
    }
...
    /**
     * Creates an inputstream for this URL, with the possibility to set different connection parameters using the
     * <i>parameters map</i>:
     * <ul>
     * <li>connectTimeout : the connection timeout</li>
     * <li>readTimeout : the read timeout</li>
     * <li>useCaches : set the use cache property for the URL connection</li>
     * <li>allowUserInteraction : set the user interaction flag for the URL connection</li>
     * <li>requestProperties : a map of properties to be passed to the URL connection</li>
     * </ul>
     *
     * @param parameters an optional map specifying part or all of supported connection parameters
     * @param url the url for which to create the inputstream
     * @return an InputStream from the underlying URLConnection
     * @throws IOException if an I/O error occurs while creating the input stream
     * @since 1.8.1
    */
    private static InputStream configuredInputStream(Map parameters, URL url) throws IOException {    // 1854行目
        final URLConnection connection = url.openConnection();
        if (parameters != null) {
            if (parameters.containsKey("connectTimeout")) {
                connection.setConnectTimeout(DefaultGroovyMethods.asType(parameters.get("connectTimeout"), Integer.class));
            }
            if (parameters.containsKey("readTimeout")) {
                connection.setReadTimeout(DefaultGroovyMethods.asType(parameters.get("readTimeout"), Integer.class));
            }
            if (parameters.containsKey("useCaches")) {
                connection.setUseCaches(DefaultGroovyMethods.asType(parameters.get("useCaches"), Boolean.class));
            }
            if (parameters.containsKey("allowUserInteraction")) {
                connection.setAllowUserInteraction(DefaultGroovyMethods.asType(parameters.get("allowUserInteraction"), Boolean.class));
            }
            if (parameters.containsKey("requestProperties")) {
                @SuppressWarnings("unchecked")
                Map<String, String> properties = (Map<String, String>) parameters.get("requestProperties");
                for (Map.Entry<String, String> entry : properties.entrySet()) {
                    connection.setRequestProperty(entry.getKey(), entry.getValue());
                }
            }

        }
        return connection.getInputStream();
    }
...

configuredInputStreamメソッドでURLオブジェクトをInputStreamクラスのオブジェクトに変換しているが、このときconfiguredInputStreamメソッドの第一引数のMapにはNullが指定されているため、認証情報を送信することが出来ず、401エラーが帰ってくるのだと思われる。

対策

最初はconfiguredInputStreamメソッドの第一引数であるMapにrequestPropertiesをキーとする認証情報を送れば良いかと思っていたが、試してみたらうまく動作しなかったため、GroovyMainを呼び出す前にAuthenticator#setDefault(Authenticator a)で認証情報を登録するようにしてみる。
認証情報はコマンドライン上で指定出来るように。


そうして出来たのが以下のAuthGroovy.groovy。

package org.kaakaa.authscript

import org.apache.commons.cli.*

import groovy.ui.GroovyMain

class AuthGroovy {
	public static void main(args){
		Option opt_username = OptionBuilder.withLongOpt('username').withDescription('username for basic auth').hasArg().withArgName('username').create('u')
		Option opt_password = OptionBuilder.withLongOpt('password').withDescription('password for basic auth').hasArg().withArgName('password').create('p')
		Options opts = new Options()
		opts.addOption(opt_username).addOption(opt_password)

		CommandLineParser parser = new PosixParser()
		CommandLine cl = parser.parse(opts,args,false)

		if(cl.hasOption('username') && cl.hasOption('password')){
			def username = cl.getOptionValue('username')
			def password = cl.getOptionValue('password')

			Authenticator.setDefault(new Authenticator() {
						@Override
						protected PasswordAuthentication getPasswordAuthentication() {
							return new PasswordAuthentication(username,password.toCharArray());
						}
					});
		}
		GroovyMain.main(args);
	}
}

コマンドライン上でusername/passwordに関するオプションが指定されている場合のみ、認証情報を登録するように。認証情報に関するオプションが設定されてない場合は、何も設定されずにGroovyMainが呼び出される。
認証情報設定してなくてもコマンドラインをパースする処理が入ってしまうので、処理時間は長くなってしまいます…。


AuthGroovyを含むjarファイルを$GROOVY_HOME/lib/に配置して、$GROOVY_HOME/bin/groovy(.bat)の最後の行を以下のように書き換えれば動作するはずです。

...
. "$DIRNAME/startGroovy"

startGroovy org.kaakaa.authscript.AuthGroovy "$@"
#startGroovy groovy.ui.GroovyMain "$@"

起動スクリプトを書き換えるのはアレな気がしますが…。

実行

認証情報を与えずに、実行すると401エラー。

kaakaa_hoe@[bin]$ groovy http://localhost:4567/
Caught: groovy.lang.GroovyRuntimeException: Unable to get script from URL: 
groovy.lang.GroovyRuntimeException: Unable to get script from URL: 
	at org.kaakaa.authscript.AuthGroovy.main(AuthGroovy.groovy:33)
Caused by: java.io.IOException: Server returned HTTP response code: 401 for URL: http://localhost:4567/
	... 1 more

認証情報を与えてみる。

kaakaa_hoe@[bin]$ groovy http://localhost:4567/ --username=foo --password=bar
Hello world!

省略形もOK。

kaakaa_hoe@[bin]$ groovy http://localhost:4567/ -u foo -p bar
Hello world!


というわけで、Basic認証を超えることが出来ましたが、起動スクリプト書き換えたり、認証情報が指定されてるかを判断するためにパースしたりと色々汚いのが考えどころ。

kaakaa/GroovyBasicAuth · GitHub
(毎回、githubにpushするの、一苦労するんだよなぁ…)


今回、Basic認証付きのページを用意するのにSinatra使ってみた。
Basic認証を行う簡単なサンプル - うなの日記
記述量少なくて楽ですね〜。



参考:
ネットに転がっているGroovyスクリプトを直接実行する - うさぎ組
Basic認証を行う簡単なサンプル - うなの日記
HttpURLConnectionでTwitterにアクセスする(BASIC認証) - Webアプリケーション構築入門2
groovy/groovy-core · GitHub