はじめに
今回の記事ではcoesiteレポジトリのコードについて解説をしたいと思います。これまで、データを複数のMBTilesファイルに分割してホスティングするところまで出来ましたが、ここではMicrosoft Azure認証の機能もつけます。
こちらの記事によると、coesiteレポジトリは2021年4月に1.8版に更新される前のマイクロソフトのチュートリアルを参考にして作成されたとのことです。
こちらのコードはこちらの記事に重ねる部分もあるため、ある程度は説明を省略します。
レポジトリはこちらです。
app.js
require('dotenv').config()
//for Azure
const session = require('express-session')
const flash = require('connect-flash')
const msal = require('@azure/msal-node')
var createError = require('http-errors')
var cookieParser = require('cookie-parser')
//var logger = require('morgan')
//for onyx
const config = require('config')
const fs = require('fs')
const express = require('express')
const path = require('path')
const spdy = require('spdy') //for https
const cors = require('cors')
const morgan = require('morgan')
const MBTiles = require('@mapbox/mbtiles')
const TimeFormat = require('hh-mm-ss')
const winston = require('winston')
const DailyRotateFile = require('winston-daily-rotate-file')
// config constants
const morganFormat = config.get('morganFormat')
const htdocsPath = config.get('htdocsPath')
const privkeyPath = config.get('privkeyPath')
const fullchainPath = config.get('fullchainPath')
const port = config.get('port')
const defaultZ = config.get('defaultZ')
const mbtilesDir = config.get('mbtilesDir')
const logDirPath = config.get('logDirPath')
// logger configuration
const logger = winston.createLogger({
transports: [
new winston.transports.Console(),
new DailyRotateFile({
filename: `${logDirPath}/coesite-%DATE%.log`,
datePattern: 'YYYY-MM-DD'
})
]
})
logger.stream = {
write: (message) => { logger.info(message.trim()) }
}
var authRouter = require('./routes/auth') //before app
const app = express()
//(before indexRouter) from here
// In-memory storage of logged-in users
// For demo purposes only, production apps should store
// this in a reliable storage
app.locals.users = {};
// MSAL config
const msalConfig = {
auth: {
clientId: process.env.OAUTH_APP_ID,
authority: process.env.OAUTH_AUTHORITY,
clientSecret: process.env.OAUTH_APP_SECRET
},
system: {
loggerOptions: {
loggerCallback(loglevel, message, containsPii) {
console.log(message);
},
piiLoggingEnabled: false,
logLevel: msal.LogLevel.Verbose,
}
}
};
// Create msal application object
app.locals.msalClient = new msal.ConfidentialClientApplication(msalConfig);
//(before indexRouter) until here
var indexRouter = require('./routes/index')
var usersRouter = require('./routes/users')
var mapRouter = require('./routes/map') //test 0104
//var plowRouter = require('./routes/plow')// for future extension
//var plowORouter = require('./routes/plow-open') // for future extension
//var vtileSRouter = require('./routes/vtile-s') // for single module
var vtileMRouter = require('./routes/vtile-m') //test 0308
var vtileORouter = require('./routes/vtile-open') //test 0322
var vtilePRouter = require('./routes/vtile-pass') //test 0713
// Session middleware
// NOTE: Uses default in-memory session store, which is not
// suitable for production
app.use(session({
secret: process.env.OAUTH_APP_SECRET,
resave: false,
saveUninitialized: false,
unset: 'destroy'
}))
// Flash middleware
app.use(flash())
// Set up local vars for template layout
app.use(function (req, res, next) {
// Read any flashed errors and save
// in the response locals
res.locals.error = req.flash('error_msg')
// Check for simple error string and
// convert to layout's expected format
var errs = req.flash('error')
for (var i in errs) {
res.locals.error.push({ message: 'An error occurred', debug: errs[i] })
}
// Check for an authenticated user and load
// into response locals
if (req.session.userId) {
res.locals.user = app.locals.users[req.session.userId]
}
next()
})
// view engine setup
app.set('views', path.join(__dirname, 'views'))
app.set('view engine', 'hbs')
//app.use(logger('dev'))
app.use(morgan(morganFormat, {
stream: logger.stream
}))
app.use(express.json())
app.use(express.urlencoded({ extended: false }))
app.use(cookieParser())
app.use(cors())
app.use(express.static('public'))
//app.use(express.static(path.join(__dirname, htdocsPath)))
app.use('/', indexRouter)
app.use('/auth', authRouter) //after app.use('/', indexRouter)
app.use('/users', usersRouter)
app.use('/map', mapRouter)
//app.use('/plow', plowRouter)
//app.use('/plow-open', plowORouter)
//app.use('/vtile-s', vtileSRouter)
app.use('/vtile-m', vtileMRouter)
app.use('/vtile-open', vtileORouter)
app.use('/vtile-pass', vtilePRouter) //0713
// error handler
//app.use((req, res) => {
// res.sendStatus(404)
//})
app.use(function (err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message
res.locals.error = req.app.get('env') === 'development' ? err : {}
// render the error page
res.status(err.status || 500)
res.render('error')
})
//for https
spdy.createServer({
key: fs.readFileSync(privkeyPath),
cert: fs.readFileSync(fullchainPath)
}, app).listen(port)
//for http
//app.listen(port, () => {
// console.log(`Running at Port ${port} ...`)
//app.listen(3000, () => {
//console.log("running at port 3000 ...")
//})
モジュール読み込み
require('dotenv').config()
dotenvパッケージは、プロジェクトのルートディレクトリにある.envファイルから環境変数を読み込むための設定です。このコードを使うと、.envファイルに記述されたキーと値がプロジェクト内で環境変数として使用できるようになります。
つまり、dotenv.config()を実行すると、.envファイルに記述された変数がprocess.envオブジェクトに追加されます。
.envファイルは以下のように記載されています。
OAUTH_APP_ID=(Your APP ID here)
OAUTH_APP_SECRET=(Your secret here)
OAUTH_REDIRECT_URI=http://localhost:3000/auth/callback(Your callback here)
OAUTH_SCOPES='user.read'
OAUTH_AUTHORITY=https://login.microsoftonline.com/(Your tenant here)/
const path = require('path')
ファイルやディレクトリのパス(経路)を扱うための便利な機能を提供しており、OSのパス表記の違いを意識せずに扱えるため、クロスプラットフォームでの開発が容易になります。
・path.join()
複数のパスを結合して1つのパスを作成します。OSのパス区切り(/または\)に合わせて自動的に調整してくれるため、WindowsやmacOS/Linuxで同じコードが使えます。
const filePath = path.join(__dirname, 'views', 'index.html');
// 結果: `/path/to/project/views/index.html` または `\path\to\project\views\index.html`
app.localsとは?
app.locals は Express.js のローカル変数を格納するオブジェクトです。
アプリケーション全体で共有される変数を保存するために使用されます。
スコープ: アプリケーションのライフサイクル全体で有効。
例: テンプレートエンジンに渡すデータや、状態管理が必要なデータを保存するのに便利です。
MSAL
// MSAL config
const msalConfig = {
auth: {
clientId: process.env.OAUTH_APP_ID,
authority: process.env.OAUTH_AUTHORITY,
clientSecret: process.env.OAUTH_APP_SECRET
},
system: {
loggerOptions: {
loggerCallback(loglevel, message, containsPii) {
console.log(message);
},
piiLoggingEnabled: false,
logLevel: msal.LogLevel.Verbose,
}
}
};
msalConfigは、@azure/msal-nodeライブラリを使ってアプリケーションの認証設定を行うためのオブジェクトです。このオブジェクトをMSALに渡すことで、Microsoftの認証システムと連携し、ユーザーの認証やトークンの取得などの操作ができるようになります。
authオブジェクト
authは、MicrosoftのOAuth認証に必要な情報を指定するプロパティで、以下の3つのキーがあります:
• clientId: クライアントID。Azureポータルでアプリケーションを登録した際に取得できるアプリのIDです。.envファイルに設定されているOAUTH_APP_IDの環境変数から読み込みます。
• authority: 認証エンドポイント。このURLは認証に使用され、Azure Active Directoryのテナント情報を含みます。.envファイルのOAUTH_AUTHORITYに指定されています。
エンドポイント(endpoint)とは、WebアプリケーションやAPIにおいて、クライアント(ブラウザや他のアプリケーション)からアクセス可能な特定のリソースや機能の入口となるURLを指します。
テナントとは、組織が Azure AD を利用する際に作成される仮想的なディレクトリです。
テナント情報とは、テナントID、テナントドメイン名(例:example.onmicrosoft.com )、ディレクトリ名などです。
• clientSecret: クライアントシークレット。このシークレットはアプリケーションのパスワードのようなもので、Azure ADアプリ登録で生成したシークレットです。.envファイルのOAUTH_APP_SECRETから読み込まれます。
systemオブジェクト
systemは、MSALの動作に関する詳細設定を行うプロパティで、ログ設定に関するオプションを指定します。
• loggerOptions:ログ設定オプション
• loggerCallback: ログのコールバック関数。この関数は、ログが生成されたときに呼び出され、loglevel、message、containsPii(個人識別情報が含まれるか)をパラメータとして受け取ります。この例では、ログメッセージのみをコンソールに出力しています。
• piiLoggingEnabled: 個人識別情報(PII)をログに含めるかどうかを指定するオプション。ここではfalseに設定されており、PIIをログに出力しないようにしています。
• logLevel: ログのレベルを指定します。ここではmsal.LogLevel.Verboseに設定され、詳細なログが出力されます。
app.locals.msalClient = new msal.ConfidentialClientApplication(msalConfig);
MSALを使用してAzure ADに認証リクエストを送るためのオブジェクトを生成し、それを変数app.locals.msalClientに格納しています。
msalConfigを引数として渡すことで、このクラスが認証やトークン取得のための設定を受け取り、Azure ADにアクセスするための準備が整います。
app.locals.msalClientが定義された以降は使用されていないのではないかと思っていましたが、app.locals.msalClientはアプリケーション全体に渡って利用出来るので、routes/auth.jsで利用されています。
セッション管理
app.use(session({
secret: process.env.OAUTH_APP_SECRET,
resave: false,
saveUninitialized: false,
unset: 'destroy'
}))
session()は、express-sessionパッケージから提供されるミドルウェアで、アプリケーションにセッション管理機能を追加します。これを利用することで、ユーザーがアプリ内で複数のページにまたがってアクセスする際に、セッションデータ(例:認証情報や設定情報など)を一貫して管理できます。
各プロパティの設定
• secret:
セッションIDを暗号化するためのキーです。ここでは、環境変数OAUTH_APP_SECRETをsecretとして使用しています。ここでOAUTH_APP_SECRETをsecretとして使用することが適切かどうかは不明です。このキーがないと、セッションの内容が保護されず、悪意のあるユーザーにセッションデータが盗まれる可能性があるため、secretは必須です。
• resave: falseに設定すると、セッションデータが変更されない限り、既存のセッションを再保存しないようになります。これにより、データベースへの不要なアクセスが抑えられ、パフォーマンスが向上します。
• saveUninitialized: falseに設定することで、セッションが初期化される(最初に設定される)まで、セッションを保存しません。これにより、特に何もセッションに保存しない訪問者のデータが無駄に記録されるのを防ぎます。
• unset: 'destroy'により、セッションの破棄時にセッションデータが完全に削除されます。この設定を行うことで、セッションが必要なくなった際のメモリ使用を抑え、セキュリティが強化されます。
※このコードがあることにより、req.session オブジェクトをリクエストごとに生成しています。
app.use(flash())
app.use(flash())を使うことで、ユーザーに一時的なメッセージを表示する機能を簡単に追加することができます。主にログイン成功・失敗メッセージ、フォームの送信完了メッセージなど、ユーザーに対するフィードバックを表示するのに便利です。あとで出てきます。
app.use(function (req, res, next) {
res.locals.error = req.flash('error_msg')
var errs = req.flash('error')
for (var i in errs) {
res.locals.error.push({ message: 'An error occurred', debug: errs[i] })
}
if (req.session.userId) {
res.locals.user = app.locals.users[req.session.userId]
}
next()
})
・res.locals.error = req.flash('error_msg')
error_msg というタイプで保存されたフラッシュメッセージをerrorというローカル変数に代入しています。これにより、ビューで error を参照するだけでエラーメッセージが表示可能です。
・var errs = req.flash('error')
errorというタイプのフラッシュメッセージを取得し、errsに格納します。
・for ループ
取得したerrorメッセージを個別に処理し、res.locals.errorに詳細なオブジェクト形式(messageとdebug)で追加します。
・if (req.session.userId) は、セッションにuserIdが存在する場合に実行され、これはユーザーがログインしていることを示します。req.session.userId はユーザーが認証に成功した後に値を持ちます。具体的には、routes/auth.jsの中の
req.session.userId = response.account.homeAccountId;
です。
・res.locals.user = app.locals.users[req.session.userId]
app.locals.users に格納されたユーザー情報を取得し、res.locals.userに設定します。これにより、ビューでuserオブジェクトを参照し、ユーザー名やその他の情報を表示できるようになります。
Viewエンジンセットアップ
app.set('views', path.join(__dirname, 'views'))
ビュー(テンプレート)ファイルが配置されているディレクトリを指定しています。
views ディレクトリには、テンプレートファイルが置かれており、ルートやミドルウェアからレンダリング時に自動的にこのディレクトリ内を参照するように設定されます。
例としては、res.render('error') があり、viewsディレクトリのerror.hbsというテンプレートが必要になります。
app.set('view engine', 'hbs')
hbs(Handlebars)をテンプレートエンジンとして設定しています。
hbsはExpressでよく使われるテンプレートエンジンの一つで、HTMLテンプレートにロジックを組み込み、動的なコンテンツを生成するのに使われます。
hbs テンプレートエンジンを設定することで、.hbs 拡張子のファイルをレンダリングする際に、テンプレートエンジンとしてハンドルバーズが使用されます。
テンプレートファイルを使用すれば、JavaScriptのコードの状態に応じて、動的にHTMLを生成できます。
app.use(express.json())
express.json() は、リクエストボディにJSONデータが含まれている場合、そのデータを自動的に解析し、JavaScriptオブジェクトとして req.body に格納します。これにより、JSON形式で送信されたリクエストデータにアクセスしやすくなります。
app.use(express.urlencoded({ extended: false }))
express.urlencoded() は、URLエンコードされたデータ(application/x-www-form-urlencoded)を解析し、JavaScriptオブジェクトとして req.body に格納します。
extended: false の設定は、クエリストリングの解析にNode.jsの標準ライブラリ querystring を使用することを意味し、シンプルなデータ構造のみ(例えば、{key: 'value'} のような単純なオブジェクト)を扱う場合に適しています。
app.use(cookieParser())
クライアントから送信されたクッキーをサーバー側で簡単に読み取れるようになります。
cookie-parser はクッキーの値を解析し、JavaScriptオブジェクトとして req.cookies に格納します。
クッキーは、ブラウザがサーバーに対して送信するデータで、セッション管理やユーザー設定の保存などに利用されます。
app.use(function (err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message
res.locals.error = req.app.get('env') === 'development' ? err : {}
// render the error page
res.status(err.status || 500)
res.render('error')
})
他のミドルウェアやルート処理中にエラーが発生し、next(err) が呼び出された場合に実行されます。そのため、正常にルートが処理された場合や、エラーが発生しなかった場合には実行されません。
・res.locals.error = req.app.get('env') === 'development' ? err : {}
アプリケーションの環境(env)が development の場合、エラーオブジェクト全体を res.locals.error に保存します。
production 環境では、空のオブジェクト {} を設定し、エラーメッセージの詳細がユーザーに表示されないようにします。
これは、開発環境ではデバッグのため詳細なエラー情報を表示し、本番環境ではユーザーに不要な情報が表示されないようにするためです。
・res.status(err.status || 500)
err.status が設定されていればそのステータスコードを使用し、設定されていない場合は 500(内部サーバーエラー)をデフォルトで設定します。
・res.render('error')
error テンプレートをレンダリングして、ユーザーにエラーページを表示します。このテンプレートは views フォルダ内の error.hbsファイルであることが多いです。
routes/index.js
var express = require('express');
var router = express.Router();
/* GET home page. */
router.get('/',
async function(req, res, next) {
let params = {
active: { home: true }
};
// Get the user
const user = req.app.locals.users[req.session.userId];
// Get the access token
var accessToken;
try {
accessToken = await getAccessToken(req.session.userId, req.app.locals.msalClient);
} catch (err) {
res.send(JSON.stringify(err, Object.getOwnPropertyNames(err)));
return;
}
res.render('index', params);
});
async function getAccessToken(userId, msalClient) {
// Look up the user's account in the cache
try {
const accounts = await msalClient
.getTokenCache()
.getAllAccounts();
const userAccount = accounts.find(a => a.homeAccountId === userId);
// Get the token silently
const response = await msalClient.acquireTokenSilent({
scopes: process.env.OAUTH_SCOPES.split(','),
redirectUri: process.env.OAUTH_REDIRECT_URI,
account: userAccount
});
return response.accessToken;
} catch (err) {
console.log(JSON.stringify(err, Object.getOwnPropertyNames(err)));
}
}
module.exports = router;
こちらの記事のコードと比較したところ、多くのコードは不必要なんじゃないかと思われます。以下のようにコメントアウトしても動いたので、以下のコードで問題ないものと思われます。
var express = require("express");
var router = express.Router();
/* GET home page. */
router.get(
"/",
// async function(req, res, next) {
async function (req, res) {
let params = {
active: { home: true },
};
// // Get the user
// const user = req.app.locals.users[req.session.userId];
// // Get the access token
// var accessToken;
// try {
// accessToken = await getAccessToken(req.session.userId, req.app.locals.msalClient);
// } catch (err) {
// res.send(JSON.stringify(err, Object.getOwnPropertyNames(err)));
// return;
// }
res.render("index", params);
}
);
// async function getAccessToken(userId, msalClient) {
// // Look up the user's account in the cache
// try {
// const accounts = await msalClient
// .getTokenCache()
// .getAllAccounts();
// const userAccount = accounts.find(a => a.homeAccountId === userId);
// // Get the token silently
// const response = await msalClient.acquireTokenSilent({
// scopes: process.env.OAUTH_SCOPES.split(','),
// redirectUri: process.env.OAUTH_REDIRECT_URI,
// account: userAccount
// });
// return response.accessToken;
// } catch (err) {
// console.log(JSON.stringify(err, Object.getOwnPropertyNames(err)));
// }
// }
module.exports = router;
views/index.hbs
<div class="jumbotron">
<h1>Unite Map powered by UN Vector Tile Toolkit.</h1>
<p class="lead">This sample map app is based on the Microsoft Graph API (Node.js Graph Tutorial)</p>
<p class="lead">The United Nations Vector Tile Toolkit (UNVT) is a collection of Open Source Software (OSS) to produce, host, style and optimize vector tiles for web mapping.</p>
{{#if user}}
<h4>Welcome {{ user.displayName }}!</h4>
<p><h5>What's new?</h5>
2021-03-26: Launch of new server repository<br>
<p>
<h4>Map APP</h4>
<h5><a href="./map">map </a></h5>
with Mapbox-gl.js version 1<br>
Tile URL:<br>
<ol>
<li>un-small https://XXX/un-s/{z}/{x}/{y}.pbf </li>
<li>osm-small https://XXX/osm-s/{z}/{x}/{y}.pbf </li>
<li>un-large https://XXX/vtile-m/zxy/un-z5/{z}/{x}/{y}.pbf </li>
<li>osm-large https://XXX/vtile-m/zxy/osm-z456/{z}/{x}/{y}.pbf </li>
</ol>
</p>
{{else}}
<a href="/auth/signin" class="btn btn-primary btn-large">Click here to sign in</a>
{{/if}}
</div>
私のコードでは、上記のコードから修正して、地図に飛べるようにリンクをつけています。
Sign in前は以下のように表示されます。
Sign in後は以下のような表示となります。
routes/auth.js
var graph = require('../graph');
var router = require('express-promise-router')();
/* GET auth callback. */
router.get('/signin',
async function (req, res) {
const urlParameters = {
scopes: process.env.OAUTH_SCOPES.split(','), //check
redirectUri: process.env.OAUTH_REDIRECT_URI
};
try {
const authUrl = await req.app.locals
.msalClient.getAuthCodeUrl(urlParameters);
res.redirect(authUrl);
}
catch (error) {
console.log(`Error: ${error}`);
req.flash('error_msg', {
message: 'Error getting auth URL',
debug: JSON.stringify(error, Object.getOwnPropertyNames(error))
});
res.redirect('/');
}
}
);
router.get('/callback',
async function(req, res) {
const tokenRequest = {
code: req.query.code,
scopes: process.env.OAUTH_SCOPES.split(','),
redirectUri: process.env.OAUTH_REDIRECT_URI
};
try {
const response = await req.app.locals
.msalClient.acquireTokenByCode(tokenRequest);
// Save the user's homeAccountId in their session
req.session.userId = response.account.homeAccountId;
const user = await graph.getUserDetails(response.accessToken);
// Add the user to user storage
req.app.locals.users[req.session.userId] = {
displayName: user.displayName
// email: user.mail || user.userPrincipalName,
// timeZone: user.mailboxSettings.timeZone
};
} catch(error) {
req.flash('error_msg', {
message: 'Error completing authentication',
debug: JSON.stringify(error, Object.getOwnPropertyNames(error))
});
}
res.redirect('/');
}
);
router.get('/signout',
async function(req, res) {
// Sign out
if (req.session.userId) {
// Look up the user's account in the cache
const accounts = await req.app.locals.msalClient
.getTokenCache()
.getAllAccounts();
const userAccount = accounts.find(a => a.homeAccountId === req.session.userId);
// Remove the account
if (userAccount) {
req.app.locals.msalClient
.getTokenCache()
.removeAccount(userAccount);
}
}
// Destroy the user's session
req.session.destroy(function (err) {
res.redirect('/');
});
}
);
module.exports = router;
Microsoft の認証ライブラリ (MSAL) を使用して OAuth 2.0 認証フローを実装した Node.js アプリケーションの Express ルーターのコードです。
全体の流れ
- /signin ルートで認証 URL を生成し、ユーザーをリダイレクトします。
- 認証後、Azure AD から送信された code を使用してアクセストークンを取得し、ユーザー情報を取得します (/callback ルート)。
- /signout ルートでログアウト処理を行います。
var router = require('express-promise-router')();
express-promise-routerは、通常のExpressのルーターと異なり、非同期(async/await)関数を直接扱えるルーターを提供します。通常、Expressでは非同期処理を行う場合、nextを呼び出したり、catchでエラーハンドリングをする必要がありますが、このモジュールを使用することで、async/awaitを使ってよりシンプルにルート処理が記述できます。
router.get('/signin',
async function (req, res) {
const urlParameters = {
scopes: process.env.OAUTH_SCOPES.split(','), //check
redirectUri: process.env.OAUTH_REDIRECT_URI
};
try {
const authUrl = await req.app.locals
.msalClient.getAuthCodeUrl(urlParameters);
res.redirect(authUrl);
}
catch (error) {
console.log(`Error: ${error}`);
req.flash('error_msg', {
message: 'Error getting auth URL',
debug: JSON.stringify(error, Object.getOwnPropertyNames(error))
});
res.redirect('/');
}
}
);
このルート (/signin) の主な役割は、Azure AD へのサインインを始めるための認証用 URL を生成し、ユーザーをその URL にリダイレクトすることです。
ユーザーが /signin にアクセスしたときに、この非同期関数が実行されます。
scopes:Azure AD に対して、アプリケーションがリクエストするアクセス権限の範囲を指定しています。
redirectUri:ユーザーがサインイン後にリダイレクトされる URLです。
req.app.locals.msalClient:
アプリケーション全体で共有される MSAL クライアントオブジェクトです。Express のリクエストオブジェクト (req) には、自動的に app プロパティが含まれています。app.locals は、Express アプリケーションのローカル変数を格納するオブジェクトで、アプリケーションの全体的なスコープで共有されます。
msalClient は、Microsoft 認証ライブラリ (MSAL) を使用して OAuth フローを管理します。
getAuthCodeUrl:
• Azure AD に認証用 URL をリクエストするメソッド。
• urlParameters を渡すことで、スコープやリダイレクト先などの情報を含む URL を生成。
• 戻り値は、ユーザーを認証にリダイレクトするための完全な URL。
req.flash メソッドが存在する理由は、connect-flash ミドルウェアをアプリケーションにインストールして使用しているためです。connect-flash を使用するには、express-session と一緒に設定します。
-
メッセージを保存:
• req.flash(key, message) を使うことで、指定した key にメッセージを保存します。
• 保存されたメッセージは、express-session に保存されるため、次のリクエストまで保持されます。 -
メッセージを取得:
• 次のリクエストで req.flash(key) を呼び出すと、保存されていたメッセージが取得されます。
res.redirect('/'):
• エラー発生時、ホームページ (/) にリダイレクトします。
router.get('/callback',
async function(req, res) {
const tokenRequest = {
code: req.query.code,
scopes: process.env.OAUTH_SCOPES.split(','),
redirectUri: process.env.OAUTH_REDIRECT_URI
};
try {
const response = await req.app.locals
.msalClient.acquireTokenByCode(tokenRequest);
// Save the user's homeAccountId in their session
req.session.userId = response.account.homeAccountId;
const user = await graph.getUserDetails(response.accessToken);
// Add the user to user storage
req.app.locals.users[req.session.userId] = {
displayName: user.displayName
// email: user.mail || user.userPrincipalName,
// timeZone: user.mailboxSettings.timeZone
};
} catch(error) {
req.flash('error_msg', {
message: 'Error completing authentication',
debug: JSON.stringify(error, Object.getOwnPropertyNames(error))
});
}
res.redirect('/');
}
);
code: 認証プロセス中にAzure ADから返される一時的な認可コード。このコードを使用してトークンを取得します。
なぜ、req.query.codeのようにリクエストにコードが入っているのか?
→
認可コードは認証サーバーからクライアントへのリダイレクト時にクエリパラメータとして渡されます。
クライアントが認可コードを使ってアクセストークンをリクエストするため、リクエスト (req.query.code) に含まれるのが適切です。
• acquireTokenByCode: Azure AD に認可コード (code) を送信してアクセストークンを取得するメソッド。
• このトークンを使ってユーザーの情報やリソースにアクセスします。
graph.getUserDetails: Microsoft Graph API を使ってユーザーの詳細情報 (例: 名前やメールアドレス) を取得します。
ここのuserは以下の、user.displayNameに使用されています。
req.app.locals.users[req.session.userId] = {
displayName: user.displayName
};
そして、
req.app.locals.users[req.session.userId] は、app.jsの以下のコードで利用されています。
res.locals.user = app.locals.users[req.session.userId];
そして、上記のres.locals.userが、index.hbsの {{#if user}}で使用されています。
router.get('/signout',
async function(req, res) {
// Sign out
if (req.session.userId) {
// Look up the user's account in the cache
const accounts = await req.app.locals.msalClient
.getTokenCache()
.getAllAccounts();
const userAccount = accounts.find(a => a.homeAccountId === req.session.userId);
// Remove the account
if (userAccount) {
req.app.locals.msalClient
.getTokenCache()
.removeAccount(userAccount);
}
}
// Destroy the user's session
req.session.destroy(function (err) {
res.redirect('/');
});
}
);
このルート (/signout) は、ユーザーのセッションを破棄し、Azure AD のキャッシュからそのユーザーのアカウント情報を削除することで、ログアウトプロセスを完了します。
• msalClient.getTokenCache(): MSAL (Microsoft Authentication Library) のトークンキャッシュを取得。
• getAllAccounts(): キャッシュに保存されているすべてのアカウント情報を取得します。
・const userAccount = accounts.find(a => a.homeAccountId === req.session.userId);
ユーザーの homeAccountId (セッション内のユーザーID) と一致するアカウントをキャッシュから検索します。
removeAccount(userAccount): 見つかったユーザーアカウントをキャッシュから削除します。
キャッシュを削除することで、このユーザーのトークンを無効化します。これにより、再度認証を要求されるようになります。
req.session.destroy(function (err) {
res.redirect('/');
});
req.session.destroy(): サーバー上のセッションデータを完全に削除します。
セッションが削除された後、/ (ホームページ) にリダイレクトします。
graph.js
var graph = require('@microsoft/microsoft-graph-client');
require('isomorphic-fetch');
module.exports = {
getUserDetails: async function(accessToken) {
const client = getAuthenticatedClient(accessToken);
const user = await client
.api('/me')
// .select('displayName,mail,mailboxSettings,userPrincipalName')
.select('displayName')
.get();
return user;
},
};
function getAuthenticatedClient(accessToken) {
// Initialize Graph client
const client = graph.Client.init({
// Use the provided access token to authenticate
// requests
authProvider: (done) => {
done(null, accessToken);
}
});
return client;
}
Microsoft Graph APIを使用して、認証済みのユーザーの情報を取得するためのモジュールです。
処理内容:
- getAuthenticatedClient関数を使用して、アクセストークンを持つ認証済みのクライアントを作成。
- client.api('/me') を使い、Microsoft Graph APIの /me エンドポイントにリクエストを送信。
• /me エンドポイントは、現在のログインユーザーの情報を取得します。 - .select('displayName') により、必要なプロパティだけ(displayName)を取得します。
- get() を呼び出して、APIからデータを取得。
routes/map.js
var express = require('express');
var router = express.Router();
/* GET home page. */
router.get('/',
async function(req, res, next) {
if (!req.session.userId) {
// Redirect unauthenticated requests to home page
res.redirect('/')
} else {
let params = {
active: { home: true }
};
// Get the user
const user = req.app.locals.users[req.session.userId];
// Get the access token
var accessToken;
try {
accessToken = await getAccessToken(req.session.userId, req.app.locals.msalClient);
} catch (err) {
res.send(JSON.stringify(err, Object.getOwnPropertyNames(err)));
return;
}
if (accessToken && accessToken.length > 0) {
try {
// render
res.render('map',{ layout: false } );
// res.render('map', params);
} catch (err) {
res.send(JSON.stringify(err, Object.getOwnPropertyNames(err)));
}
}
else {
req.flash('error_msg', 'Could not get an access token');
}
}
}
);
async function getAccessToken(userId, msalClient) {
// Look up the user's account in the cache
try {
const accounts = await msalClient
.getTokenCache()
.getAllAccounts();
const userAccount = accounts.find(a => a.homeAccountId === userId);
// Get the token silently
const response = await msalClient.acquireTokenSilent({
scopes: process.env.OAUTH_SCOPES.split(','),
redirectUri: process.env.OAUTH_REDIRECT_URI,
account: userAccount
});
return response.accessToken;
} catch (err) {
console.log(JSON.stringify(err, Object.getOwnPropertyNames(err)));
}
}
module.exports = router;
このコードは、特に使用されていないように見えるので、下に概要だけ書いています。
• 認証チェック: req.session.userId が存在するかを確認し、未認証ユーザーをリダイレクト。res.redirect('/')はアプリケーションのルート、つまりindex.jsにリダイレクトされます。
• アクセストークン取得: getAccessToken でMSALを使用し、トークンをサイレントに取得。
• ビューのレンダリング: map テンプレートを条件付きでレンダリング。
{ layout: false } はレイアウトなしでレンダリング。
• エラーハンドリング: トークン取得やレンダリング時に発生したエラーを適切に処理。
map.hbs
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8' />
<title>Map test</title>
<meta name='viewport' content='initial-scale=1,maximum-scale=1,user-scalable=no' />
<script src='./mapbox-gl.js'></script>
<link href='./mapbox-gl_r.css' rel='stylesheet' />
<style>
body { margin:0; padding:0; }
#map {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
}
</style>
</head>
<body>
<div id='map'"></div>
<script>
var map = new mapboxgl.Map({
container: 'map',
attributionControl: false,
hash: true,
style: './styles/test.json', // style file path
center: [0,0], // center [lng, lat]
zoom: 1, // zoom level at loading
maxZoom: 0, // min zoom
maxZoom: 17 // max zoom
});
//UI
map.addControl(new mapboxgl.AttributionControl({customAttribution: "<a href='https://geoportal.dfs.un.org/arcgis/apps/sites/#/unitemaps'><img src='https://geoportal.dfs.un.org/webapps/resources/LOGOS/PoweredbyUniteMaps.png' alt='Powered by Unite Maps'></a> <a href='http://unopengis.org/unopengis/main/main.php'><img src='https://unopengis.github.io/watermark/watermark.png' alt='UN OpenGIS' style='width:40px;height:40px;'></a>" }));
map.addControl(new mapboxgl.NavigationControl(), 'bottom-right');
map.addControl(new mapboxgl.ScaleControl() );
//debug
map.showTileBoundaries = false;
map.showCollisionBoxes = false;
</script>
</body>
</html>
Mapbox GL JS を使ってカスタマイズされたインタラクティブな地図をウェブページに表示する基本的な構成を提供しています。coesite3でカスタマイズされているので、そちらで詳しく解説することにします。
routes/vtile-open.js(認証なし)
var express = require('express')
var router = express.Router()
const config = require('config')
const fs = require('fs')
const cors = require('cors')
const MBTiles = require('@mapbox/mbtiles')
const TimeFormat = require('hh-mm-ss')
// config constants
const defaultZ = config.get('defaultZ')
const mbtilesDir = config.get('mbtilesDir')
// global variables
let mbtilesPool = {}
let tz = config.get('tz')
let sTileName = config.get('sTileName')
let busy = false
var app = express()
app.use(cors())
//Get tile functions
const getMBTiles = async (t, z, x, y) => {
let mbtilesPath = ''
let mbtilesPath2 = ''
let mbtilesPath3 = ''
if (!tz[t]) tz[t] = defaultZ
let tz2 = tz[t] - 1
let tz3 = tz[t] - 2
if (z < tz[t]) {
if (sTileName[t]) {
let stname = sTileName[t]
mbtilesPath = `${mbtilesDir}/${t}/${stname}.mbtiles`
mbtilesPath2 = `${mbtilesDir}/${t}/${stname}.mbtiles`
mbtilesPath3 = `${mbtilesDir}/${t}/${stname}.mbtiles`
} else {
mbtilesPath = `${mbtilesDir}/${t}/0-0-0.mbtiles`
mbtilesPath2 = `${mbtilesDir}/${t}/0-0-0.mbtiles`
mbtilesPath3 = `${mbtilesDir}/${t}/0-0-0.mbtiles`
}
} else {
mbtilesPath =
`${mbtilesDir}/${t}/${tz[t]}-${x >> (z - tz[t])}-${y >> (z - tz[t])}.mbtiles`
mbtilesPath2 =
`${mbtilesDir}/${t}/${tz2}-${x >> (z - tz2)}-${y >> (z - tz2)}.mbtiles`
mbtilesPath3 =
`${mbtilesDir}/${t}/${tz3}-${x >> (z - tz3)}-${y >> (z - tz3)}.mbtiles`
}
return new Promise((resolve, reject) => {
if (mbtilesPool[mbtilesPath]) {
resolve(mbtilesPool[mbtilesPath].mbtiles)
} else if (mbtilesPool[mbtilesPath2]) {
resolve(mbtilesPool[mbtilesPath2].mbtiles)
} else if (mbtilesPool[mbtilesPath3]) {
resolve(mbtilesPool[mbtilesPath3].mbtiles)
} else {
if (fs.existsSync(mbtilesPath)) {
new MBTiles(`${mbtilesPath}?mode=ro`, (err, mbtiles) => {
if (err) {
reject(new Error(`${mbtilesPath} could not open.`))
} else {
mbtilesPool[mbtilesPath] = {
mbtiles: mbtiles, openTime: new Date()
}
resolve(mbtilesPool[mbtilesPath].mbtiles)
}
})
} else if (fs.existsSync(mbtilesPath2)) {
new MBTiles(`${mbtilesPath2}?mode=ro`, (err, mbtiles) => {
if (err) {
reject(new Error(`${mbtilesPath2} could not open.`))
} else {
mbtilesPool[mbtilesPath2] = {
mbtiles: mbtiles, openTime: new Date()
}
resolve(mbtilesPool[mbtilesPath2].mbtiles)
}
})
} else if (fs.existsSync(mbtilesPath3)) {
new MBTiles(`${mbtilesPath3}?mode=ro`, (err, mbtiles) => {
if (err) {
reject(new Error(`${mbtilesPath3} could not open.`))
} else {
mbtilesPool[mbtilesPath3] = {
mbtiles: mbtiles, openTime: new Date()
}
resolve(mbtilesPool[mbtilesPath3].mbtiles)
}
})
//edit until here
} else {
reject(new Error(`${mbtilesPath} was not found.`))
}
}
})
}
const getTile = async (mbtiles, z, x, y) => {
return new Promise((resolve, reject) => {
mbtiles.getTile(z, x, y, (err, tile, headers) => {
if (err) {
reject()
} else {
resolve({tile: tile, headers: headers})
}
})
})
}
/* GET Tile. */
router.get(`/zxy/:t/:z/:x/:y.pbf`,
async function(req, res) {
busy = true
const t = req.params.t
const z = parseInt(req.params.z)
const x = parseInt(req.params.x)
const y = parseInt(req.params.y)
getMBTiles(t, z, x, y).then(mbtiles => {
getTile(mbtiles, z, x, y).then(r => {
if (r.tile) {
res.set('content-type', 'application/vnd.mapbox-vector-tile')
res.set('content-encoding', 'gzip')
res.set('last-modified', r.headers['Last-Modified'])
res.set('etag', r.headers['ETag'])
res.send(r.tile)
busy = false
} else {
res.status(404).send(`tile not found: /zxy/${t}/${z}/${x}/${y}.pbf`)
busy = false
}
}).catch(e => {
res.status(404).send(`tile not found (getTile error): /zxy/${t}/${z}/${x}/${y}.pbf`)
busy = false
})
}).catch(e => {
res.status(404).send(`mbtiles not found for /zxy/${t}/${z}/${x}/${y}.pbf`)
})
}
);
module.exports = router;
上記コードは、例えばズームレベル6の全てのmbtilesが存在している前提ではなく、地物数が少ない地域においてはズームレベル5や4のmbtilesが存在すると仮定した場合のコードだと思われます。このようなコードとすることで、mbtilesの数を抑えることが出来ます。
変数が色々と出てきますが、default.hjsonに記載されている変数をまとめると以下の通りです。
defaultZ = 6
t = un-z5 ※変数tはURLの一部であり、地図を見るクライアントが指定します。
tz[t] = tz[un-z5] = 5
tz2 = 4
tz3 = 3
sTileNmae[t] = sTileName[un-z5] = small-scale
※sTileNameは、スタートタイル名の略だと思われます。
zが5未満、つまり小縮尺の場合
・sTileName[t]が存在する場合
mbtilesPath、mbtilesPath2、mbtilesPath3の3つの変数は共通して、
${mbtilesDir}/${t}/${stname}.mbtiles
つまり、
mbtiles/un-z5/small-scale.mbtiles
となります。
・sTileName[t]が存在しない場合
mbtilesPath、mbtilesPath2、mbtilesPath3の3つの変数は共通して、
${mbtilesDir}/${t}/0-0-0.mbtiles
つまり、
mbtiles/un-z5/0-0-0.mbtiles
となります。
上記の意味するところは、default.hjsonにおいて使用が想定されるmbtilesファイルが記載されており、それが想定通り使用される場合はそれを使用する。想定以外の場合は、0-0-0.mbtilesを使用するということではないかと思います。
zが5以上、つまり大縮尺の場合
変数mbtilesPathは、
${mbtilesDir}/${t}/${tz[t]}-${x >> (z - tz[t])}-${y >> (z - tz[t])}.mbtiles
つまり、
mbtiles/un-z5/5-(ZL5の場合のx座標)-(ZL5の場合のy座標).mbtiles
となります。
変数mbtilesPath2は、
${mbtilesDir}/${t}/${tz2}-${x >> (z - tz2)}-${y >> (z - tz2)}.mbtiles
つまり、
mbtiles/un-z5/4-(ZL4の場合のx座標)-(ZL4の場合のy座標).mbtiles
となります。
変数mbtilesPath3は、
${mbtilesDir}/${t}/${tz3}-${x >> (z - tz3)}-${y >> (z - tz3)}.mbtiles
つまり、
mbtiles/un-z5/3-(ZL3の場合のx座標)-(ZL3の場合のy座標).mbtiles
となります。
その他
その後のコードは、ZL5でmbtilesファイルが見つかればそれを使用し、見つからなければZL4を探す。ZL4も見つからなければ、ZL3を探すというコードです。
小縮尺地図を表示する
こちらの記事を参考にして、publicフォルダ内にmap/naturalEarth.htmlとmap/naturalEarth.jsonを追記しました。
naturalEarth.jsonについては、参照するタイルをプログラムに合わせて以下のように修正しました。
"tiles": ["http://localhost:3000/vtile-open/zxy/naturalEarth/{z}/{x}/{y}.pbf"]
また、naturalEarth.htmlはmaplibreを使用しているので、publicフォルダ以下に、maplibre-gl.css、maplibre-gl.js、maplibre-gl.js.mapを追加しました。
index.hbsファイルにおいて、以下を追記して地図にすぐに飛べるようにしています。
<a href="map/naturalEarth.html">map/naturalEarth.html</a><br>
mbtilesファイルの準備
こちらの記事で作成したNatural Earthのmbtilesファイルを使用することにして、mbtilesフォルダの下にnaturalEarth/0-0-0naturalEarth.mbtilesを追加しました。
また、default.hjsonのtzオブジェクトに「naturalEarth: 6」を、sTileNameオブジェクトに 「naturalEarth: 0-0-0naturalEarth」を追記しました。
以下のように地図が表示されました。
default.hjsonのtzオブジェクトに「naturalEarth: 6」がない場合には、エラーが発生し地図がうまく表示されません。
chatGPTに聞いたところ、ハイフンを含むキーは必ずダブルクオートまたはシングルクオートで囲む必要があり、それが原因かもしれません。アンダースコアにすると問題ありません。
tz:{
un-z5: 5
osm-z456: 6
naturalEarth: 6
}
を
tz:{
unZ5: 5
osmZ456: 6
}
として試してみましたが、うまくいきませんでした。
config モジュールはデフォルト設定では読み取り専用のため、hjsonからconfig.getで読み込んでくる場合は、オブジェクトのキーと値を新しく追加出来ないのかもしれません。
UNの小縮尺地図を表示
同様にUNの小縮尺地図を表示してみます。
publicフォルダ内にmap/un-z5.htmlとmap/un-z5.jsonを追記しました。
un-z5.jsonについては、参照するタイルをプログラムに合わせて以下のように修正しました。
"tiles": ["http://localhost:3000/vtile-open/zxy/un-z5/{z}/{x}/{y}.pbf"]
こちらの記事で作成したUN小縮尺データのmbtilesファイルを使用することにして、mbtilesフォルダの下にun-z5/small-scale.mbtilesを追加しました。「small-scale.mbtiles」というファイル名にしているのは、default.hjsonの記述に合わせるためです。
以下の通り地図が表示されました。
大縮尺地図の表示
publicフォルダ内にmap/produce-gsc-un.htmlとmap/produce-gsc-un.jsonを追記しました。
produce-gsc-un.jsonについては、参照するタイルをプログラムに合わせて以下のように修正しています。
"tiles": ["http://localhost:3000/vtile-open/zxy/produce-gsc-un/{z}/{x}/{y}.pbf"]
mbtilesファイルの準備
こちらの記事で作成したUNデータの6-35-31(z-x-y)の部分のmbtilesファイルを使用することにして、mbtilesフォルダの下にproduce-gsc-un/6-35-31.mbtilesを追加しました。
また、default.hjsonのtzオブジェクトに「produce-gsc-un: 6」を、sTileNameオブジェクトに 「produce-gsc-un: 6-35-31」を追記しました。
以下のように地図が表示されました。
ズームレベルを大きくした場合でも、以下のように対象ズームレベルのタイルが表示されています。以下の例ではズームレベル13のタイルが表示されています。
上記地図では、簡単のためlandmass, bndlなどのみを表示しています。
routes/vtile-m.js(認証あり)
routes/vtile-open.js(認証なし)との違いは以下の認証に関するコードが追加された部分のみです。つまり、ログインしていないユーザがタイルを取得しようとしても、出来ない設定になっています。
if (!req.session.userId) {
res.status(401).send(`Please log in to get: /zxy/${t}/${z}/${x}/${y}.pbf`);
} else {
まずは、認証なしのvtile-open.jsに関して、サーバを立ち上げた後に以下のタイルを取得してみます。
http://localhost:3000/vtile-open/zxy/un-z5/1/1/1.pbf
そうすると以下のような変な名前のファイルですが無事に取得できました。
1 - 2024-12-05T163007.505.pbf
次に認証ありのvtile-m.jsに関して、認証前に以下のタイルを取得してみます。
http://localhost:3000/vtile-m/zxy/un-z5/1/1/1.pbf
次に同じURLで認証後にタイルを取得してみます。
そうすると無事に取得できました。
このことから、認証されたユーザのみタイルを取得出来ることが分かりました。
まとめ
本記事ではcoesiteレポジトリについて解説を行いました。認証に関するコードが複雑で、最初は意味不明でしたが、chatGPTに聞いたり、自身でサンプルコードを書いたりして徐々に理解を深める事が出来ました。次は、coesiteレポジトリのコードをレンタルサーバで実行し、https化やPM2を使用したサーバの永続化を試してみます。
Reference