MicronautでBASIC認証を使う方法（とHTTPエラーレスポンス）

基本的にはドキュメント通りにするんだけど、一部はまったとこのメモ。

# そもそもの話
## 公式ドキュメントはどこ？

[公式ドキュメント](https://docs.micronaut.io/latest/guide/)の16章に「こっちをみてね」くらいしか書いてないので非常にわかりにくい。:cry:
https://micronaut-projects.github.io/micronaut-security/latest/guide/

あと、こちらのガイドも参考になる。
https://guides.micronaut.io/latest/micronaut-security-basicauth-maven-java.html

## どういう動きになればいいの？

ざっくりとした流れは[Micronaut SecurityにあるBASIC認証のシーケンス図](https://micronaut-projects.github.io/micronaut-security/latest/guide/#basicAuth)を参照。要は401レスポンスを返せばいいらしいが、それでだけではダメで[WWW-Authenticateヘッダを一緒に返す](https://www.rfc-editor.org/rfc/rfc7617#section-2)必要があるらしい。

# 導入

## 1. まずはMicronaut Security の導入

pom.xml に "micronaut-security" を指定すればよい。
ただし、導入時点で「（static-resourceを含む）すべてのリクエストを拒否する」動きになるのでちょっとびっくりする。ブラウザでHTTPレスポンスを確認してればわかるのだけど、初見だとちょっとつらい。

## 2. 導入方針を決める

制御方法がいくつかあり、どうやら優先順はあるものの「誰かが許可したら"許可"、誰かが拒否したら"拒否"」という動きになっている模様。
どういう制限をしたいか、によってベストな方法が変わってくると思われる。
「基本的にホワイトリスト／ブラックリスト運用する」以外は、混ぜないほうがよさそう。

<dl>
<dt>Controllerをだけ使う</dt><dd>Securedアノテーションを用いる方法がいいのかも。ただし、static-resourceは前述の「明示的に許可されていないので、暫定拒否」扱いになるので、その場合後者。<br>https://micronaut-projects.github.io/micronaut-security/latest/guide/#secured</dd>
<dt>static-resourceも制御する</dt><dd>Intercept URL Mapで実現するのがよさそう。<br>https://micronaut-projects.github.io/micronaut-security/latest/guide/#interceptUrlMap</dd>
<dt>いろいろ条件を組み合わせたい</dt><dd>独自のSecurityRuleを実装する（後述）</dd>
</dl>

## 3. SecurityRuleベースでを実装する

自前で条件を決めるのならばほぼ一択かと。
次の3つのファイルがあればよい。

### 3a. ユーザー認証

公式に基本コードあり。AuthenticationProvider を継承したクラスを用意すればよさそう。今回は単純に Properties.load() で読み込んだユーザー情報で判定する形に改造した。
参考： https://guides.micronaut.io/latest/micronaut-security-basicauth-maven-java.html#authentication-provider

```AuthenticationProviderUserPassword.java
import io.micronaut.core.annotation.Nullable;
import io.micronaut.http.HttpRequest;
import io.micronaut.security.authentication.AuthenticationProvider;
import io.micronaut.security.authentication.AuthenticationRequest;
import io.micronaut.security.authentication.AuthenticationResponse;
import jakarta.inject.Singleton;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import reactor.core.publisher.FluxSink;

import java.io.IOException;
import java.util.Properties;

@Singleton
public class AuthenticationProviderUserPassword implements AuthenticationProvider  {

    private Properties users;

    public AuthenticationProviderUserPassword() {
        users = new Properties();
        try {
            users.load(AuthenticationProviderUserPassword.class.getResourceAsStream("/users.properties"));
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public Publisher<AuthenticationResponse> authenticate(@Nullable HttpRequest<?> httpRequest,
                                                          AuthenticationRequest<?, ?> authenticationRequest) {
        return Flux.create(emitter -> {
            String password = (String)users.get(authenticationRequest.getIdentity());
            if( password != null && authenticationRequest.getSecret().equals(password) ) {
                emitter.next(AuthenticationResponse.success((String) authenticationRequest.getIdentity()));
                emitter.complete();
            } else {
                emitter.error(AuthenticationResponse.exception());
            }
        }, FluxSink.OverflowStrategy.ERROR);
    }
}
```

### 3b. ルール適用

ルールの判定処理。
ユーザー認証していない場合は authentication に null が渡されるので、AuthorizationException を投げて中断させる。
ユーザー認証している場合は「誰なのか？」の情報が authentication でわたってくるので、必要ならそれで判断すればよさそう。

前述のとおり、全アクセスについて判定しようとされることに注意。
また、ファイルが存在するかどうか（404エラー）は考慮しなくてよい。

参考： https://blog.wick.technology/micronaut-security-rule/

```CustomSecurityRule.java
import io.micronaut.http.HttpRequest;
import io.micronaut.security.authentication.Authentication;
import io.micronaut.security.authentication.AuthorizationException;
import io.micronaut.security.rules.AbstractSecurityRule;
import io.micronaut.security.rules.SecurityRuleResult;
import io.micronaut.security.token.RolesFinder;
import io.micronaut.web.router.RouteMatch;
import jakarta.inject.Singleton;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Mono;

@Singleton
public class CustomSecurityRule extends AbstractSecurityRule {

    public CustomSecurityRule(RolesFinder rolesFinder) {
        super(rolesFinder);
    }

    @Override
    public Publisher<SecurityRuleResult> check(HttpRequest<?> request, RouteMatch<?> routeMatch, Authentication authentication) {
        String path = request.getPath();

        if( path.startsWith("/admin/")  && authentication == null ) {
            throw new AuthorizationException(null);
        }

        return Mono.just(SecurityRuleResult.ALLOWED);
    }
}
```

### 3c. HTTPレスポンス処理

公式にコードあり。AuthorizationException が発行されると実行されるハンドラ。
真ん中のif文は「お前が誰だかわかったけど、お前には使わせない」ケースと思われる。

参考： https://guides.micronaut.io/latest/micronaut-security-basicauth-maven-java.html#www-authenticate

```AuthorizationExceptionHandler.java
import io.micronaut.context.annotation.Replaces;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MutableHttpResponse;
import io.micronaut.security.authentication.AuthorizationException;
import io.micronaut.security.authentication.DefaultAuthorizationExceptionHandler;
import jakarta.inject.Singleton;

import static io.micronaut.http.HttpHeaders.WWW_AUTHENTICATE;
import static io.micronaut.http.HttpStatus.FORBIDDEN;
import static io.micronaut.http.HttpStatus.UNAUTHORIZED;

@Singleton
@Replaces(DefaultAuthorizationExceptionHandler.class)
public class AuthorizationExceptionHandler extends DefaultAuthorizationExceptionHandler {
    @Override
    protected MutableHttpResponse<?> httpResponseWithStatus(HttpRequest request,
                                                            AuthorizationException e) {
        if (e.isForbidden()) {
            return HttpResponse.status(FORBIDDEN);
        }
        return HttpResponse.status(UNAUTHORIZED)
                .header(WWW_AUTHENTICATE, "Basic realm=\"hoge\"");
    }
}
```

# おまけ：HTTPエラーレスポンスの実装方法

## 400 Bad Request

HttpStatusException(HttpStatus.BAD_REQUEST) を投げて、このハンドラを実装すればOK

```BadRequestController.java
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Error;
import io.micronaut.http.annotation.Produces;

@Controller
@Produces(MediaType.TEXT_PLAIN)
public class BadRequestController {
    @Error(status = HttpStatus.BAD_REQUEST, global = true)
    public HttpResponse<?> badRequest(HttpRequest<?> request) {
        return HttpResponse.badRequest("Bad Request");
    }
}
```

## 404 Page Not Found

static-resourceが存在しないのもここに飛んでくる。

```NotFoundController.java
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Error;
import io.micronaut.http.annotation.Produces;

@Controller
@Produces(MediaType.TEXT_PLAIN)
public class NotFoundController {

    @Error(status = HttpStatus.NOT_FOUND, global = true)
    public HttpResponse<?> notFound(HttpRequest<?> request) {
        return HttpResponse.notFound("Page Not Found");
    }
}
```

## 例外が発生したら 500 Internal Server Error

Exceptionクラスに対してハンドラを実装すればOK。
上記の各種例外は飛んでこないので安心。

```DefaultExceptionHandler.java
import io.micronaut.context.annotation.Requires;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Produces;
import io.micronaut.http.server.exceptions.ExceptionHandler;
import jakarta.inject.Singleton;

@Produces(MediaType.TEXT_PLAIN)
@Singleton
@Requires(classes = {Exception.class, ExceptionHandler.class})
public class DefaultExceptionHandler implements ExceptionHandler<Exception, HttpResponse> {

    @Override
    public HttpResponse handle(HttpRequest request, Exception exception) {
        return HttpResponse.serverError("Internal Server Error");
    }
}
```