Angularでログインページを作成する
以前よりこのページで、AngularのSPAからAJAXでログインするページの構築方法を紹介していましたが、IOSのプライバシー機能(ITP)の強化により、挙動が不安定になっていたのでログインページの構造を変更してリライトしました。
ページ構成
ログインの処理は基本的にバックエンドで行います。Angular(フロントエンド)側はそれに連携するような構成にします。Angular側ですべての認証行ってしまうと、通常外のユーザー操作により認証を回避することが(論理的には)可能なためです。同様の理由で、ログイン後に表示されるコンポーネントに、未ログインユーザーには提供しないデータの記述をしてしまうと、プログラムソースから秘匿したいデータが流出する可能性があります。秘匿したいデータは必ずサーバーの認証を経てサーバーから取得するようにします。
アプリの構成は、SPA(Single Page Aplication)の場合でも、そうでない場合でもログインページを別に設けます。AngularでAJAXを使ってログインを実装することもできますが、プライバシーの観点からCookieに対する制限が強化されつつある昨今ですので、原始的な方法の方が影響を受けづらいのではないかというのがその理由です。
バックエンド
ここではバックエンドにはPHPを利用しているものとして話を進めます。PHPではPOSTされたログインIDとパスワードで認証し、問題なければsession変数にログイン状態を記憶して、Angularのページにリダイレクトします。
データ処理の部分にはここでは触れませんが、認証の必要なデータを要求された場合は常にログイン状態を判定した後に処理をするようにします。認証エラーが発生した場合は処理を中断しエラーを返します。
今回は次のようなコードを用意してみました。
login.php
<?php
// Angularのアプリルートへのパス
define('APP_PATH','http://localhost:4200');
// sessionで使うキー名称
define('LOGIN_KEY','LOGIN');
function main() {
session_start();
$GLOBALS['message']="";
if(isset($_GET['logout'])) {
// GETパラメタにlogoutが存在したらログアウトして自身にリダイレクト
unset($_SESSION[LOGIN_KEY]);
header('Location: /login.php');
exit();
}
if(isset($_GET['islogin'])) {
// GETパラメタにisloginが存在した場合はログイン状態をjson文字列で返す
makeLoginStatus();
exit();
}
if(isLogin()) {
// すでにログイン済み
// リダイレクト
header('Location: '.APP_PATH);
exit();
}
if(isset($_POST['user'],$_POST['password'])) {
// POSTでユーザー名とパスワードを送信された場合
if (checkPassword($_POST['user'],$_POST['password'])) {
// 念のためログイン状態を解除
unset($_SESSION[LOGIN_KEY]);
// ログイン成功でセッションIDを変更
session_regenerate_id();
// ログイン状態に
$_SESSION[LOGIN_KEY]=true;
// リダイレクト
header('Location: '.APP_PATH);
exit();
} else {
unset($_SESSION[LOGIN_KEY]);
$GLOBALS['message']="ログインに失敗しました";
makeLoginPage();
}
} else {
unset($_SESSION[LOGIN_KEY]);
$GLOBALS['message']="ログインしてください";
makeLoginPage();
}
}
function checkPassword($user,$pass) {
// 認証(要実装)
if ($user==="user" && $pass==="pass") {
return true;
} else {
return false;
}
}
function makeLoginStatus() {
// ログインステータスを返すページを生成
if(isLogin()) {
echo json_encode(true);
} else {
echo json_encode(false);
}
}
function isLogin() {
// ログイン状態のチェック
if (isset($_SESSION[LOGIN_KEY]) && $_SESSION[LOGIN_KEY] === true) {
return true;
} else {
return false;
}
}
function makeLoginPage() {
// ログインページの生成
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ログインページ</title>
<style>
* {
box-sizing: border-box;
margin: 3px;
}
div {
width: 300px;
margin: 0 auto;
}
input, button {
display: block;
font-size: 100%;
width: 100%;
}
</style>
</head>
<body>
<div>
<p>ログインページ</p>
<form method="post">
<input type="text" name="user" placeholder="ユーザー名"/>
<input type="password" name="password" placeholder="パスワード"/>
<button type="submit">ログイン</button>
</form>
<p><?php echo $GLOBALS['message']; ?></p>
</div>
</body>
</html>
<?php
}
// エントリー
main();
?>
Angular側
Angular側では、ログインが必要なページ(コンポーネント)を表示する際は、AJAXでログイン状態をバックエンドに確認するようにします。SPA(Single Page Application)構成にしているなどで、ルーターを使っている場合は、ガード(Guard)を設定すると適用や管理が楽になります。
ログイン状態の確認はサービスからHttpClientを利用して行います。
まず、HttpClientを利用できるようにするために、src¥app¥app.module.tsに記述を加えます。
app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
...
@NgModule({
...
imports: [
...
HttpClientModule
],
...
次にサーバーとのデータのやり取りをするサービスを作成します。ここでは「login」という名前にしました。
PS> ng generate service login
実行後src¥appにlogin.service.tsというファイルが生成されます。それを次のようにコーディングしログイン状態の確認を行えるようにします。
login.service.ts
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { HttpClient, HttpHeaders } from '@angular/common/http';
@Injectable({
providedIn: 'root'
})
export class LoginService {
// バックエンドのログイン確認メソッドへのパスを記述します
private loginUrl: string = 'http://localhost/login.php?islogin';
httpOptions = {
headers: new HttpHeaders({ 'Content-Type': 'application/json' }),
withCredentials: true
};
isLogin():Observable<boolean> {
return this.http.get<boolean>(this.loginUrl,this.httpOptions);
}
constructor(private http: HttpClient) { }
}
サービスのisLoginメソッドでは、boolean値のObservableを返しますがこれはAngularでデフォルトでに利用される非同期用ライブラリRxJSを使って認証情報を取得するためのものです。AngularにおけるObservableの詳細はリンク先を参照していただければと思います。
もし認証サーバーとアプリのオリジンが違う場合は、withCredentialsオプションを利用して、認証サーバーとのセッションCookieを送信するようにします(JavaScriptのデフォルトの挙動では表示ページと違うオリジンへのAJAX時には、そのオリジンに所属するCookieでも送信されません)。さらにバックエンドのPHP側でもクロスサイト用のHTTPヘッダを返す必要があります。今回の場合は先のlogin.phpに次のような関数を追加して、makeLoginStatus関数でログイン状態のJSONを出力する前に呼び出します。
本番時は認証サーバーとアプリのオリジンが同じでも、デバッグ用時はオリジンが異なる事が多いと思います。たとえば、開発用のPCにデバッグ用の認証サーバーを立てた場合、そのバックエンドはAngularのデバッグサーバーが使用する4200番ポートでは起動できませんので、必然的にオリジンが異なることになります。
login.php
...
function makeHeader() {
// CROS用の記述
// AJAX元のオリジン(http or https / ドメイン / ポート)
header('Access-Control-Allow-Origin: http://localhost:4200');
// 認証情報の送信の許可
header('Access-Control-Allow-Credentials: true');
// CROSを許可するヘッダ
header('Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept');
// CROSを許可するメソッド
header('Access-Control-Allow-Methods: GET');
}
これらの設定は送信内容によって変わります。たとえばJSONデータをPOSTするような場合はプリフライトリクエストが起きるので、許可するメソッドにPOSTとOPTIONSを追加したりします。
サンプルコンポーネント
テスト用のコンポーネントを作成して、ログイン状態の有無により表示を切り替えてみたいと思います。
まず、対象となるコンポーネントを作成します。ここではrequire-loginとしました。
PS> ng generate component requireLogin
次にコンポーネントの記述をします。ngOnInitライフサイクルフックで、先ほど作成したloginServiceを使ってログイン状態を確認して表示を切り替えるようにします。
require-login.component.ts
import { Component, OnInit } from '@angular/core';
import { LoginService } from '../login.service';
@Component({
selector: 'app-require-login',
templateUrl: './require-login.component.html',
styleUrls: ['./require-login.component.css'],
})
export class RequireLoginComponent implements OnInit {
public login = false;
constructor(private loginService: LoginService) {}
ngOnInit(): void {
this.loginService.isLogin().subscribe((v) => {
if (v === true) {
this.login = true;
}
});
}
}
テンプレート側は、実運用ではログイン状態を確認できた後データの取得をし表示するという流れになると思いますが、ここではシンプルにログイン状態を表示するのみとします。
require-login.component.html
<div *ngIf="login">ログインしています</div>
<div *ngIf="!login">
<p>このページを表示する為にはログインが必要です</p>
<p>ログインは<a href="http://localhost/login.php">こちらから</a></p>
</div>
最後に、アプリのルートでコンポーネントを表示させるようにします。
app.component.html
<app-require-login></app-require-login>
ルーターの使用
先の例ではコンポーネントの中でログイン状態の有無を判定し表示を切り替えましたが、今度はAngularでルーターを設定し、ガードを使うことにより、ログインしている際にのみコンポーネントを表示するように変更します。
プロジェクト生成時にルーターを作成していなかったら、次の作業をしてルーターを利用できるようにします。
まずルーターモジュールをapp.module.tsに設定します。
app.module.ts
...
import { RouterModule } from '@angular/router';
...
@NgModule({
...
imports: [
...
RouterModule
],
...
ルーティングを記述するモジュールを生成します。ルーター用のモジュール名は慣例で「app-routing」とするようになっているそうです。
PS> ng generate module app-routing --flat --module=app
src¥appにapp-routing.module.tsというフォルダが生成されますので、中身を次のようにします。
app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { RequireLoginComponent } from './require-login/require-login.component';
const routes: Routes = [
{ path: 'require-login', component: RequireLoginComponent }
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
routesにアドレスに対して結び付けたいコンポーネントを設定します。ここではhttp://localhost:4200/require-loginとした時に、先に生成したrequire-loginコンポーネントを表示する設定にしています。この設定をimportsで取り込んでいます。
extportsにRouterModuleが設定されています。これについて説明する為に、前段階のapp-routing.module.tsを生成時に指定した--module=appオプションの説明をします。このオプションのappの部分は、app.module.tsファイルを意味し、全体としてそこに生成したモジュールを自動で追加するという意味になります。つまりapp.module.tsに「import { AppRoutingModule } from './app-routing.module';」の記述がされ、importsの配列に「AppRoutingModule」が追加されます。
ルーターをアプリで動かす際はAppRoutingModuleだけでなくRouterModuleも必要なので、app.module.tsでは「RouterModule」をImportする必要もあります。RouterModuleをapp.module.tsに直接記述してもいいのですが、それはAppRoutingModuleで利用しているのでここでexportしておくことにより、app.moduleはAppRoutingModuleと同時にRouterModuleをimportできる仕組みになっています。
Angularのルーティングについては別記事でも紹介していますので、そちらも参考にしてください。
次に先のコンポーネントを修正します。ルーターでログイン状態の制限をかけるので、コンポーネントでは何も処理しません。実際のアプリではサービスを利用してデータ取得してテンプレートに渡す処理を記述します。
require-login.component.ts
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-require-login',
templateUrl: './require-login.component.html',
styleUrls: ['./require-login.component.css'],
})
export class RequireLoginComponent implements OnInit {
constructor() {}
ngOnInit(): void {
}
}
テンプレート側もログイン時の記述のみにします。
require-login.component.html
<div>ログインしています</div>
アプリのルートではコンポーネント表示をルーターに委譲するrouter-outletを記述します。アドレスバーに入力された値によってコンポーネントが選択されタグの内側に出力されます。
app.component.html
<router-outlet></router-outlet>
ルーターの設定ができたら、ガードの設定をしてログインの有無に同期させてコンポーネントの表示有無を指定します。
Angularのガード(Guard)については別記事で紹介していますので、ここでは操作方法だけ紹介します。
まず、CanActivateをimpolemntしたloginGuardを作成します。
PS> ng generate guard login
(*) CanActivate
( ) CanActivateChild
( ) CanDeactivate
( ) CanLoad
...
src¥appにlogin.guard.tsファイルができますので次のように編集します。
login.guard.ts
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';
import { LoginService } from './login.service';
@Injectable({
providedIn: 'root'
})
export class LoginGuard implements CanActivate {
constructor(private loginService: LoginService) {}
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
return this.loginService.isLogin();
}
}
最後にルーターにガードを適用させます。
app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { RequireLoginComponent } from './require-login/require-login.component';
import { LoginGuard } from './login.guard';
const routes: Routes = [
{ path: 'require-login', component: RequireLoginComponent, canActivate: [LoginGuard] }
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
ログインエラー時のルート
この状態でhttp://localhost:4200/require-loginにアクセスすると、ログインしていればコンポーネントが表示され、ログインしていなければ何も出力されないアプリのルートにリダイレクトされます。
何の説明もなしにルートアドレスにリダイレクトされるのはユーザーには不親切なので、ここではエラーページ用のコンポーネントを作成します。
PS> ng generate component loginError
コンポーネントはデフォルトのままで、テンプレートのみ次のように記述します。
require-login.component.html
<div>
<p>指定されたページを表示する為にはログインが必要です</p>
<p>ログインは<a href="http://localhost/login.php">こちらから</a></p>
</div>
CanActivateガードでページの情報であるUrlTreeオブジェクトを返すと、falseを返された時と同じ扱いで、指定したページへリダイレクトします。このページはルーターが管理しているページである必要があります。
もし、今回のログインページのようにルーターの管轄外のページがあり、そこへへリダイレクトしたい場合は、LoginサービスでJavaScriptを使ってリダイレクトします。
login.guard.ts
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
CanActivate,
Router,
RouterStateSnapshot,
UrlTree,
} from '@angular/router';
import { map, Observable } from 'rxjs';
import { LoginService } from './login.service';
@Injectable({
providedIn: 'root',
})
export class LoginGuard implements CanActivate {
constructor(private loginService: LoginService, private router: Router) {}
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
):
| Observable<boolean | UrlTree>
| Promise<boolean | UrlTree>
| boolean
| UrlTree {
return this.loginService.isLogin().pipe(
map((v) => {
if (v === true) {
return true;
} else {
return this.router.parseUrl('login-error');
// 直接リダイレクトするなら
// window.location.href = 'http://localhost/login.php';
// return false;
}
})
);
}
}
ルーターにログインエラーのルートを設定します。
app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { RequireLoginComponent } from './require-login/require-login.component';
import { LoginGuard } from './login.guard';
import { LoginErrorComponent } from './login-error/login-error.component';
const routes: Routes = [
{ path: 'require-login', component: RequireLoginComponent, canActivate: [LoginGuard] },
{ path: 'login-error', component: LoginErrorComponent },
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }