OAuth 2.0を使用して Office365のSMTPでメール送信したい

注意

Microsoft Exchangeのアカウント(例:@の後ろが企業名とか独自のドメインになっているもの)でないとSMTPのOAuth認証を有効に出来ないような。アカウント持っていないので詳細はわからない。

個人アカウントでも、Microsoft Entraでアプリの登録が出来るので、トークン取得部分だけ動作確認した。Microsoft Exchangeのアカウントがあれば、通しでも多分動くはず。

build.gradle
plugins {
	id 'java'
}

group = 'oauth2hotmail'
version = '0.0.1-SNAPSHOT'

java {
	sourceCompatibility = '17'
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
    implementation 'com.microsoft.azure:msal4j:1.14.2'
    implementation 'com.sun.mail:jakarta.mail:2.0.1'
}

tasks.named('test') {
	useJUnitPlatform()
}
HotmailOAuth.java
public class HotmailOAuth {

    private static final String SCOPE = "https://graph.microsoft.com/.default";
    // アプリケーション(クライアント) ID
    private static final String CLIENT_ID = "";
    // クライアント シークレット
    private static final String CLIENT_SECRET = "";
    // OAuth 2.0 トークン エンドポイント (v2)
    private static final String AUTHORITY = "https://login.microsoftonline.com/ディレクトリ(テナント) ID/oauth2/v2.0/token";
    private static final String MAIL_FROM = "sender@foo.bar";
    private static final String MAIL_TO = "receiver@foo.bar";

    public static void main(String[] args) throws MalformedURLException, AddressException, MessagingException {
        // アクセス トークンを取得
        var token = getAccessTokenFromSecret();

        // SMTP セッションを作成
        var props = new Properties();
        props.put("mail.smtp.host", "それぞれのSMTPホスト名");
        props.put("mail.smtp.port", "587");
        props.put("mail.smtp.auth", "true");
        props.put("mail.smtp.auth.mechanisms", "XOAUTH2");
        props.put("mail.smtp.starttls.enable", "true");
        var session = Session.getDefaultInstance(props);

        // メールを作成
        var message = new MimeMessage(session);
        message.setFrom(new InternetAddress(MAIL_FROM));
        message.setRecipient(Message.RecipientType.TO, new InternetAddress(MAIL_TO));
        message.setSubject("OAuth 2.0認証を使って送信したメール");
        message.setText("このメールは、OAuth 2.0 認証を使って送信しました。");

        // メールを送信
        var transport = session.getTransport("smtp");
        transport.connect(MAIL_FROM, token);
        transport.sendMessage(message, message.getAllRecipients());
        transport.close();

        System.out.println("メールを送信しました。");
    }

    public static String getAccessTokenFromSecret() throws MalformedURLException {
      var credential = ClientCredentialFactory.createFromSecret(CLIENT_SECRET);

      var cca = ConfidentialClientApplication
              .builder(CLIENT_ID, credential)
              .authority(AUTHORITY)
              .build();

      var parameters = ClientCredentialParameters
              .builder(Set.of(SCOPE))
              .build();

      var result = cca.acquireToken(parameters).join();

      return result.accessToken();
    }
}

Hibernate Search 7.0 Migration

Hibernate Search 7.0がリリースされていたので、アップグレードした。今回プログラムの修正は必要なかった。

pom.xmlの変更点

  • lucene-analyzers-kuromojiをlucene-analysis-kuromojiに変更
  • hibernate-search-mapper-orm-orm6をhibernate-search-mapper-ormに変更
<!-- Hibernate Search & Lucene -->
<dependency>
    <groupId>org.apache.lucene</groupId>
    <artifactId>lucene-analysis-kuromoji</artifactId>
    <version>9.8.0</version>
</dependency>
<dependency>
    <groupId>org.hibernate.search</groupId>
    <artifactId>hibernate-search-mapper-orm</artifactId>
    <version>7.0.0.Final</version>
</dependency>
<dependency>
   <groupId>org.hibernate.search</groupId>
   <artifactId>hibernate-search-backend-lucene</artifactId>
   <version>7.0.0.Final</version>
</dependency>

バージョンを変えるだけでなく、artifactIdを変更する必要がある。luceneを使用している場合はそちらも変更する必要がある。

OAuth 2.0を使用して GmailのSMTPでメール送信したい

前提条件

Gmailのアカウントを持っていること。

Google Cloudの設定

https://console.cloud.google.com/にアクセスして、プロジェクトを作成する。

わかりやすい名前を付ける。

User Typeは「外部」を選択する。

テスト用なのでアプリ情報は適当に入力する。

プロジェクトを作成したら、「認証情報」タブの「認証情報を作成」をクリックする。

「OAuthクライアントID」を選択する。

アプリケーションの種類には「デスクトップアプリ」を選択する。メールを送信するだけなので、ライブラリからGmail APIを追加する必要はない。

作成が完了したら、クライアント ID、クライアント シークレットが発行されるので、メモしておく。

プログラムの例

build.gradle
plugins {
	id 'java'
}

group = 'oauth2gmail'
version = '0.0.1-SNAPSHOT'

java {
	sourceCompatibility = '17'
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
    implementation 'com.google.api-client:google-api-client:2.2.0'
    implementation 'com.sun.mail:jakarta.mail:2.0.1'
}

tasks.named('test') {
	useJUnitPlatform()
}
GmailOAuth.java

メモしておいたクライアント ID、クライアント シークレットを、CLIENT_ID、CLIENT_SECRET変数にそれぞれ設定する。

今回リダイレクト処理は必要ないので、リダイレクトURLにはurn:ietf:wg:oauth:2.0:oobを設定する。これを設定するとリダイレクトせずに直接画面に認証コードが表示される。

MAIL_FROMにGmailのアドレスを設定する。MAIL_TOにはテストメールを送っても大丈夫なメールアドレスを設定する。

public class GmailOAuth {

    private static final String SCOPE = "https://mail.google.com/";
    private static final String CLIENT_ID = "CLIENT_ID";
    private static final String CLIENT_SECRET = "CLIENT_SECRET";
    private static final String REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob";
    private static final String MAIL_FROM = "sender@foo.bar";
    private static final String MAIL_TO = "receiver@foo.bar";

    public static void main(String[] args) throws IOException, MessagingException {
        // 取得した認証コードを保存するフォルダを指定
        var fileDataStoreFactory = new FileDataStoreFactory(new File(System.getProperty("java.io.tmpdir")));

        // OAuth 2.0 の認証フローを作成
        var flow = new GoogleAuthorizationCodeFlow.Builder(new NetHttpTransport(),
                GsonFactory.getDefaultInstance(), CLIENT_ID, CLIENT_SECRET, Set.of(SCOPE))
                        .setCredentialDataStore(StoredCredential.getDefaultDataStore(fileDataStoreFactory)).build();

        // アクセス トークンを取得
        var credential = flow.loadCredential("user");

        // 取得済みの認証情報があるか、またそれは有効か確認
        if (credential == null || (credential.getExpiresInSeconds() < 100 && !credential.refreshToken())) {
            // 有効なコードがなかった場合、新たに認可コードを取得
            var url = flow.newAuthorizationUrl().setRedirectUri(REDIRECT_URI).build();
            System.out.println("Please open the following URL in your browser then type the authorization code:");
            System.out.println("  " + url);
            System.out.println("Please enter your authentication code:");

            try (var s = new Scanner(System.in)) {
                var code = s.nextLine();
                var tokenResponse = flow.newTokenRequest(code).setRedirectUri(REDIRECT_URI).execute();
                credential = flow.createAndStoreCredential(tokenResponse, "user");
            }
        }

        // SMTP セッションを作成
        var props = new Properties();
        props.put("mail.smtp.host", "smtp.gmail.com");
        props.put("mail.smtp.port", "587");
        props.put("mail.smtp.auth", "true");
        props.put("mail.smtp.auth.mechanisms", "XOAUTH2");
        props.put("mail.smtp.starttls.enable", "true");
        var session = Session.getDefaultInstance(props);

        // メールを作成
        var message = new MimeMessage(session);
        message.setFrom(new InternetAddress(MAIL_FROM));
        message.setRecipient(Message.RecipientType.TO, new InternetAddress(MAIL_TO));
        message.setSubject("Gmail から送信したメール");
        message.setText("このメールは、Gmail の SMTP で OAuth 2.0 認証を使って送信しました。");

        // メールを送信
        var transport = session.getTransport("smtp");
        transport.connect(MAIL_FROM, credential.getAccessToken());
        transport.sendMessage(message, message.getAllRecipients());
        transport.close();

        System.out.println("メールを送信しました。");
    }
}

実行

初回実行時、下記のメッセージが表示されるようになっている。表示されているURLにブラウザでアクセスし、アクセス権を付与する。

Please open the following URL in your browser then type the authorization code:
  ここに表示されるURLにブラウザでアクセスする
Please enter your authentication code:

ChromeブラウザでGmailにログイン済みの状態であれば、下記の通りアカウントの選択に表示される。もちろん別のアカウントを選択しても良い。

テスト用のプロジェクトのため、注意事項が表示されるがそのまま続行する。

アクセス権の付与が間違いないことを確認して、続行する。

表示された認証コードをコピーして、「Please enter your authentication code:」の下に貼り付けEnterを押下する。

「メールを送信しました。」と表示されたら、送信先のメールアドレスにメールが送信されたことを確認する。

2回目以降は、認証コードの有効期限内であれば、上記の手順無しでメール送信できる。逆にもう一度最初から実行したい場合は、「System.getProperty(“java.io.tmpdir”)」フォルダ内の「StoredCredential」というファイルを削除する。

ChromeでYouTube視聴中マウスを動かすとノイズが表示される

ChromeでYouTube視聴中マウスを動かすと、黒と透過の市松模様のノイズが画面に表示されることがある。

Chromeの設定変更で直ると記載があったので、下記の通り変更してみた。

1.Chromeのアドレスバーに「chrome://flags/」と入力してEnter

2.検索ボックスに「Choose ANGLE graphics backend」と入力

3.プルダウンを選択し、DefaultからOpenGLに変更

4.Chromeを再起動

Spring IOのport out of range -1エラー

Spring Tools Suite、またはそのプラグインを入れたEclipseで「Failed to fetch Generation from Spring IO: port out of range:-1」というエラーが表示される場合、以下の設定を変更することでエラー解消することが出来る。

Eclipseの設定を開き、一般>ネットワーク接続のアクティブ・プロバイダーを「直接」に変更する。

Tomcat 10をJava 21で動かす

UbuntuにTomcat 10をインストールしJava 21で動かしたいが、まだパッケージでは提供されていないため、手動でインストールする。

前提

Java 21がインストール済みであること。
インストールされていない場合はこちらの記事の「Java 21のインストール」を参照のこと。

$ java -version
openjdk version "21" 2023-09-19 LTS
OpenJDK Runtime Environment Temurin-21+35 (build 21+35-LTS)
OpenJDK 64-Bit Server VM Temurin-21+35 (build 21+35-LTS, mixed mode, sharing)

Tomcat 10のインストール

Apache Tomcatのページがら最新版をダウンロードして解凍する。今後のため、シンボリックリンクも作成しておく。

$ wget https://dlcdn.apache.org/tomcat/tomcat-10/v10.1.15/bin/apache-tomcat-10.1.15.tar.gz
$ tar -xvf apache-tomcat-10.1.15.tar.gz
$ sudo ln -s /opt/apache-tomcat-10.1.15 /opt/tomcat-10

Tomcat 10用の実行ユーザーを作成する。

$ sudo useradd -m -U -d /opt/tomcat-10 -s /bin/false tomcat1$

ファイルのオーナーを先ほど作成したユーザーに変更する。

$ sudo chown -R tomcat10: /opt/apache-tomcat-10.1.15

シェルを実行可能にする。

$ sudo sh -c 'chmod +x /opt/tomcat-10/bin/*.sh'

SystemD用のファイルを作成する。

$ sudo vi /usr/lib/systemd/system/tomcat10.service
#
# Systemd unit file for Apache Tomcat
#
[Unit]
Description=Apache Tomcat 10 Web Application Server
After=network.target
[Service]
# Configuration
Environment="JAVA_HOME=/opt/jdk-21"
Environment="JAVA_OPTS=-Djava.awt.headless=true"
Environment="CATALINA_BASE=/opt/tomcat-10"
Environment="CATALINA_HOME=/opt/tomcat-10"
Environment="CATALINA_OPTS=-Xms128m -Xmx1024m -server"
Environment="CATALINA_PID=/opt/tomcat-10/temp/tomcat.pid"
# Lifecycle
Type=forking
ExecStart=/opt/tomcat-10/bin/startup.sh
ExecStop=/opt/tomcat-10/bin/shutdown.sh
Restart=always
PIDFile=/opt/tomcat-10/temp/tomcat.pid
# Logging
SyslogIdentifier=tomcat10
# Security
User=tomcat10
Group=tomcat10
[Install]
WantedBy=multi-user.target

SystemDの設定ファイルを再読み込みする。

$ sudo systemctl daemon-reload

Tomcat 10を有効にし、起動する。

$ sudo systemctl enable --now tomcat10
Created symlink /etc/systemd/system/multi-user.target.wants/tomcat10.service  /lib/systemd/system/tomcat10.service.

正常起動を確認する。

$ sudo systemctl status tomcat10
 tomcat10.service - Apache Tomcat 10 Web Application Server
     Loaded: loaded (/lib/systemd/system/tomcat10.service; enabled; vendor pres>
     Active: active (running) since Wed 2023-10-25 05:58:29 UTC; 6s ago
    Process: 12815 ExecStart=/opt/tomcat-10/bin/startup.sh (code=exited, status>
   Main PID: 12822 (java)
      Tasks: 35 (limit: 18910)
     Memory: 101.2M
        CPU: 6.485s
     CGroup: /system.slice/tomcat10.service
             mq12822 /opt/jdk-21/bin/java -Djava.util.logging.config.file=/opt/>
10月 25 05:58:29 hostname systemd[1]: Starting Apache Tomcat 10 Web Applicati>
10月 25 05:58:29 hostname tomcat10[12815]: Tomcat started.
10月 25 05:58:29 hostname systemd[1]: Started Apache Tomcat 10 Web Applicatio>

JenkinsをJava 21で動かす

JenkinsでビルドしていたプロジェクトをJava 21に変更したため、JenkinsもJava 21で起動する必要がある。

前提

Ubuntu上で既にJenkinsがJava 17で稼働しているものとする。
Jenkinsは最新版を使用している。

Java 21のインストール

下記からLinux用JDKをダウンロードして、任意のフォルダに解凍する。
https://adoptium.net/temurin/releases/

$ cd /opt
$ sudo wget https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21%2B35/OpenJDK21U-jdk_x64_linux_hotspot_21_35.tar.gz
$ sudo tar -xvf OpenJDK21U-jdk_x64_linux_hotspot_21_35.tar.gz
$ sudo ln -s /opt/jdk-21+35 /opt/jdk-21

Javaの設定

$ sudo update-alternatives --install /usr/bin/java java /opt/jdk-21/bin/java 1
$ sudo update-alternatives --install /usr/bin/javac javac /opt/jdk-21/bin/javac 1
$ sudo update-alternatives --install /usr/bin/javadoc javadoc /opt/jdk-21/bin/javadoc 1

インストールしたJDKをデフォルトに設定する。

$ sudo update-alternatives --config java
$ sudo update-alternatives --config javac
$ sudo update-alternatives --config javadoc

Java 21に切り替わっていることを確認する。

$ java -version
openjdk version "21" 2023-09-19 LTS
OpenJDK Runtime Environment Temurin-21+35 (build 21+35-LTS)
OpenJDK 64-Bit Server VM Temurin-21+35 (build 21+35-LTS, mixed mode, sharing)

Jenkinsの起動と動作確認

Jenkinsを起動する。

$ sudo systemctl start jenkins
$ sudo systemctl status jenkins
 jenkins.service - Jenkins Continuous Integration Server
・・・
10月 19 04:37:40 systemd[1]: Started Jenkins Continuous Integration >

Java 21のプロジェクトをビルドし、正常終了することを確認する。

Java 17から21にアップグレードする

Java 21がリリースされたので、自前のプログラムをJava 21に変更してみる。

コードの変更部分

Java 21に変更したら下記のコードが非推奨になっていた。

new Locale(locale)
new URL(referer)

それぞれ下記の通り修正した。

Locale.of(locale)
URI.create(referer).toURL()

newするようなコードは今後もなくなっていくのだろうか。個人的には変更後のコードの方が好きだ。

2023/10/17 追記

pom.xmlに下記設定を追記した。

-proc:full

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.11.0</version>
    <configuration>
        <compilerArgs>
            <arg>-parameters</arg>
            <arg>-proc:full</arg>
        </compilerArgs>
    </configuration>
</plugin>

-XX:+EnableDynamicAgentLoading

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.1.2</version>
    <configuration>
        <reuseForks>false</reuseForks>
        <argLine>${argLine} -XX:+EnableDynamicAgentLoading -Dcatalina.base=${project.build.directory}</argLine>
    </configuration>
</plugin>
2023/10/18 追記

Java 21にコンパイルの設定を変更すると、なぜか下記11行目ががコンパイルエラーになる。(一部抜粋)

import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class NovelProcessTest {

    @Mock
    private MessageSourceAccessor messages;

    @Test
    void testExecute() {
        when(messages.getMessage(anyString())).thenReturn(

下記のように修正するとコンパイルエラーは解消する。理由はわからない。

import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class NovelProcessTest {

    @Mock
    private MessageSourceAccessor messages;

    @Test
    void testExecute() {
        String msg = messages.getMessage(anyString());
        when(msg).thenReturn(

下記でもコンパイルエラーは解消するので、whenがメソッドの最初にあるのが駄目なのだろうか。

    @Test
    void testExecute() {
        String msg = "";
        when(messages.getMessage(anyString())).thenReturn(

Tempus dominus 4から6にアップグレードする

デフォルトの設定は下記ページのドキュメント参照のこと。
Tempus Dominus

以下は、自プロジェクト用に少し修正したもの。

インポートするJS、CSSファイル

Tempus dominus 4

<script src="https://cdnjs.cloudflare.com/ajax/libs/tempusdominus-bootstrap-4/5.39.0/js/tempusdominus-bootstrap-4.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tempusdominus-bootstrap-4/5.39.0/css/tempusdominus-bootstrap-4.min.css" />

Tempus dominus 6

<script src="https://cdnjs.cloudflare.com/ajax/libs/tempus-dominus/6.7.13/js/tempus-dominus.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tempus-dominus/6.7.13/css/tempus-dominus.min.css" />

HTML

Tempus dominus 4と6で特に変更なし。

<label for="accountExpiredDate" th:text="#{user.accountExpiredDate}">user.accountExpiredDate</label>
<div class="input-group date" id="accountExpiredDatePicker" data-target-input="nearest">
  <input type="text" class="form-control datetimepicker-input" th:classappend="${#fields.hasErrors('accountExpiredDate')} ? is-invalid" th:field="*{accountExpiredDate}" th:placeholder="#{datetimepicker.format}" data-target="#accountExpiredDatePicker" />
  <div class="input-group-text" data-target="#accountExpiredDatePicker" data-toggle="datetimepicker">
    <em class="fas fa-calendar"></em>
  </div>
</div>

JavaScript

Tempus dominus 4

$('#accountExpiredDatePicker').datetimepicker({
  locale: 'ja',
  format: 'yyyy/MM/dd HH:mm',
})

Tempus dominus 6
※jQueryの使用は非推奨になった。

new tempusDominus.TempusDominus(document.getElementById('accountExpiredDatePicker'), {
  localization: {
    locale: 'ja',
    format: 'yyyy/MM/dd HH:mm',
  }
})

jjwt 0.11.5から0.12.1にアップグレードする

以下のメソッドを変更した。

- SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS512);
+ SecretKey key = Jwts.SIG.HS512.key().build();

- Claims claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
+ Claims claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload();

Jwts.builder()
-        .setClaims(claims)
-        .setSubject(username)
-        .setIssuedAt(createdDate)
-        .setExpiration(expirationDate)
+        .claims(claims)
+        .subject(username)
+        .issuedAt(createdDate)
+        .expiration(expirationDate)
        .signWith(key)
        .compact();

0.12.0はバグがあるので注意!
https://github.com/jwtk/jjwt/issues/854

class io.jsonwebtoken.impl.lang.OptionalMethodInvoker cannot access class sun.security.util.KeyUtil (in module java.base) because module java.base does not export sun.security.util to unnamed module