vaguely

和歌山に戻りました。ふらふらと色々なものに手を出す毎日。

上海、蘇州に行ってきました

はじめに

8/12〜8/19にかけて上海、蘇州に行ってきました。

大まかなところはTwitterでつぶやいたりInstagramで画像を上げたりしまくったのですが、
それ以外に思ったことだったり、使ったVPNなどについてまとめておくことにします。

シェアサイクルについて

最近の中国絡みのニュースでよく話題になっているものの一つにシェアサイクルがあります。

実際、特に上海では至るところにシェアサイクル用の自転車が置かれていました。

www.instagram.com

使用方法としては、使用前に本体(だいたい鍵のある辺り)につけられたQRコードをアプリで読み取って、
鍵に数字が書かれたプッシュ式のボタンがついているので、それを押して解錠。

使用後はもう一度アプリでQRコードを読み取って終了。

金額は使用時間に対してかけられて、無料のものもある、といった感じのようです。

上海のシェアサイクルはどこでも乗り捨て可能、ということで、道を塞ぐくらい大量に自転車が置かれているところがあったり(´・ω・`)

www.instagram.com

どうしても乗り捨てられる場所には偏りが生じるため、夜間にニーズに合わせて?一部自転車を移動しているようです。

一方蘇州はというと、まだそこまで普及はしていないようで、自転車を見かけることが少なかったように思います。

また、見かけたものはどこでも乗り捨て可能なものではなく、専用の機械が用意されたものでした。

www.instagram.com

蘇州も地下鉄が通ったりどんどん便利になっているので、
今後シェアサイクルも広がっていくのかなぁ、といった感じがしました。

キャッシュレス決済について

もう一つ中国絡みのニュースで話題になっているものといえばャッシュレス決済です(私の中で)。

微信や支付宝を使った支払いにはQRコードを使用するのですが、
これが原因なのか街中至るところでQRコードを見かけました。

支払いができるところも格段に増えていて、
確かに現金無しで生活、というのも可能かもなぁ、と思わせられました。

www.instagram.com

ただキャッシュレス生活がそこまで便利かと言われると、正直まだ賛成しかねるところはあります。

支払いの時に支払い方法(微信なのか支付宝なのかなど)を伝えないといけなかったり、
レストランでの食事のとき、現金だとテーブルでそのまま支払い可能なものが(中国のレストランではレジではなくテーブルで支払うことが多いです)レジまで行かないといけなかったり。

この辺りは次回私が中国へ行く頃には解消されているのかもな〜、といった感じです。

あと現状で言うと、現金決済からキャッシュレス決済に移行している、というよりは現金決済に新しい決済方法が加わっている、といった印象を受けました。 (決済方法が増える方が便利になるので、消費者側としては良いことだと思います)

もう一つ、少し気になっているのが、ネットの記事などを見ていると、ホームレスがキャッシュレス決済での支払いに対応しているみたいな話がありますが、
セキュリティ的な危険はないのかなぁ、というところです。

せいぜい言った金額より多く取られるとかそれくらいで、あとはキャッシュレス決済のサービス提供側が安全にやり取りしてくれる感じなのでしょうか。

微信での支払いは、次回中国へ行くときには自分でも使ってみたいところです。

VPNについて

いつも中国へ行くと、TwitterGoogleのサービスが使えず大変厳しい気持ちになるのですが、
今回はVPNを試してみることにしました。

やっぱりGFWには皆困っているようで、VPNも結構種類がありましたが、
2017年のVPNのランキングで上位3位内に入っていて、Paypalが使えて、Linux版にも対応しているというExpressVPNというものにしました。

www.expressvpn.com

あと一ヶ月単位でライセンスを購入できるのも良いですね。

まぁ結局Linux版はやり方がまずかったのかうまく動作させられませんでしたが。。。
(インストール自体は問題なさそうでしたが、接続に失敗していました)

Androidについて言えば、時々つながりにくくなることはあったものの、特に問題なく接続できました。

多分つながりにくくなったのはGFWパワーのせいかと(; ・`ω・´)

VPNとGFWはいたちごっこ的なところはあるわけですが、次回中国に行くときも使えてそうなら、
またExpressVPNを使おうかな〜、という感じです。

おわりに

上海へ行くたびに思うのは、思うよりも変わっているけど思ったよりも変わってない、ということです。

ネットの記事などを見ていると、中国は日本より遥かに先を行っていてもう追いつけないみたいな気持ちにもなりますが、
そういう側面もあるけれども全てがそうでもない、とも思います。

それぞれ状況が違うわけで、そんな簡単には比較できないのではないでしょうか。

ということで、中国を見下すとか崇拝するとかではなく、良いところをどんどん取り入れて進歩していく、というのが良いのではないかな〜、というのが私の感想でした。

主語がでかくてすみませんm( )m

Angularのセキュリティ対策について調べてみる その4

はじめに

続きです。

特定のURLでCSRF(XSRF)対策を無効にする方法、ドキュメントのCross-site script inclusion (XSSI)について追いかけてみました。

特定のURLでCSRF対策を無効にする

前回は全ページでログインしていないとPostリクエストが送れない状態でしたが、
これでは少し不便ですね。

基本的にCSRFの対策はアカウントのパスワード変更など、ログインした状態で行う処理に対して実行するもの、
ということを考えると、例えばログインしていなくても送信したい情報がある場合、
そのページではCSRFを無効にして未ログイン状態でもPostリクエストを送信できるようにする必要があります。

http.csrf().disable()を使う(失敗)

下記のように「http.csrf().disable()」を使ってCSRF対策を無効にしようとすると、 全ページで無効になったり、「http.csrf().disable()」が無視されてしまいます。

WebSecurityConfig.java

〜省略〜
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    〜省略〜
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
                .httpBasic().and()
                .authorizeRequests()
                .antMatchers("/menulist").authenticated()
                .and().formLogin()
                .and().csrf()
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .and().exceptionHandling()
                .accessDeniedPage("/accessdenied.html");

        http
                .authorizeRequests()
                .antMatchers("/").permitAll()
                .and().csrf()
                .disable();
    }
    
}

http.csrf().ignoringAntMatchers()

じゃあどうするか、というと、「http.csrf().ignoringAntMatchers()」を使用します。

WebSecurityConfig.java

〜省略〜
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    〜省略〜
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
                .httpBasic().and()
                .authorizeRequests()
                .antMatchers("/").permitAll()
                .antMatchers("/menulist").authenticated()
                .and().formLogin()
                .and().csrf()
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .and().exceptionHandling()
                .accessDeniedPage("/accessdenied.html");

        http
                .csrf()
                .ignoringAntMatchers("/postsample");
    }
    
}

ここで注意が必要なのは、「ignoringAntMatchers()」で指定するURLはページのURLではなくPostリクエストの送信先のURLであるということです。

そのため、CSRFを有効にするページとそうでないページとでPostリクエストの送信先が一緒になっていると、
両方でログインを求められたりCSRF対策が無効になってしまったりします。

まぁ適当にコピペしているのでもなければ、通常そんなことは無いだろうとは思いますがorz

X-XSRF-TOKENについて

CSRF対策用のトークンなどは、Spring boot側でCSRF対策が有効・無効であるかにかかわらず、常に含まれるようです。

f:id:mslGt:20170804005758j:plain

f:id:mslGt:20170804005810j:plain

ただし、トークンの値はログインのタイミングで更新されるため、

  1. ログイン前にCSRF対策を無効にしたPostリクエストを送信する
  2. ログインして、CSRF対策を有効にしたPostリクエストを送信する
  3. ログイン状態のままCSRF対策を無効にしたPostリクエストを送信する

とすると、2と3で同じトークンが渡され、1とは異なる値になっています。

IDの固定化攻撃の対策も自動で行ってくれる、ということですね。すごい!

Cross-site script inclusion (XSSI)

JSONから重要なデータを読み取られる脆弱性、ということですが、
JSONとしてサーバー側から受け取った値がそのままJavascriptとして実行できる場合にのみ発生するようで、
XSSなどと比較すると常に気をつけなければいけない、という訳でもないのかしら。。。?
(すみません。イマイチ内容が理解できていません)

とりあえず、このXSSIを防ぐ方法としてサーバー側で冒頭に「)]}」や「\n(改行コード)」をJSONデータのプレフィックスとして付与するというものがあり、
AngularのHttpClientではそれらのプレフィックスをJSONの解析前に削除する、というのが本項目の内容です。

おわりに

一通りAngularのSecurityのページを眺めてみました。

軽く触っただけのところなど、気になる部分もありますが、
次からはSpring Security側の設定なども含めて、実際に何かWebアプリでも作りつつ試していくことにしたいと思います。

ブログはせっかくここに書き溜めているわけですし(なお質は問わないものとする)、
PCやスマホからメモを残したりスケジュールを追加したりできるようなものを作ってみようかな?
(できるとは言っていない)

参照

CSRF

XSSI

Angularのセキュリティ対策について調べてみる その3

はじめに

続きです。

今回はサニタイズせずに値を渡す方法、CSRF (XSRF) などを追いかけてみます。

Trusting safe values

以前Templateを使ってHTMLタグを挿入する場合、自動でサニタイズされることを学びました。

では信頼したHTMLなどの値を、サニタイズせずにページに反映するにはどうすべきか。

DomSanitizerのbypassSecurityTrustHtmlやbypassSecurityTrustUrlを使用します。

  • bypassSecurityTrustHtml
  • bypassSecurityTrustScript
  • bypassSecurityTrustStyle
  • bypassSecurityTrustUrl
  • bypassSecurityTrustResourceUrl

bypass-security.component.ts

import { Component, OnInit } from '@angular/core';
import { DomSanitizer, SafeHtml, SafeUrl } from '@angular/platform-browser';

@Component({
  selector: 'app-bypass-security',
  templateUrl: './bypass-security.component.html',
  styleUrls: ['./bypass-security.component.css']
})
export class BypassSecurityComponent implements OnInit {
  // 元の文字列. そのままTemplateに渡すとサニタイズされる.
  private dangerousHtml: string;
  private dangerousUrl: string; 

  // 信頼された値としてサニタイズされずに表示される.
  private trustedHtml: SafeHtml;
  private trustedUrl: SafeUrl;

  constructor(private sanitizer: DomSanitizer) { }

  ngOnInit() {
    this.dangerousHtml = '< button onclick="alert(0)">Click me!< /button>';
    this.dangerousUrl = 'javascript:alert("Hi there")';
    
    this.trustedHtml = this.sanitizer.bypassSecurityTrustHtml(this.dangerousHtml);
    this.trustedUrl = this.sanitizer.bypassSecurityTrustUrl(this.dangerousUrl);
  }
}

bypass-security.component.html

< h4>An untrusted HTML:< /h4>
< iframe [srcdoc]='dangerousHtml'>< /iframe>
< h4>An trusted HTML:< /h4>
< iframe [srcdoc]='trustedHtml'>< /iframe>
< h4>An untrusted URL:< /h4>
< p>< a [href]="dangerousUrl">Click me< /a>< /p>
< h4>A trusted URL:< /h4>
< p>< a [href]="trustedUrl">Click me< /a>< /p>

これを実行すると以下のようになります。

f:id:mslGt:20170727071523j:plain

trustedHtml、trustedUrlの値がサニタイズされずに表示されているのが確認できます。

{{trustedHtml}}

さて、以前確認していたように、 < p>{{trustedHtml}}< /p> とするとどうなるでしょうか。

bypass-security.component.html

〜省略〜
< p>{{trustedHtml}}< /p>

結果は下記のようなメッセージが表示されます。

SafeValue must use [property]=binding: < button onclick="alert(0)">Click me!< /button> (see http://g.co/ng/security#xss)

つまり、{{trustedHtml}}として表示した場合は値を信頼している・していないに関係なくHTMLが反映されず、
文字列でHTMLのソースを渡して表示したい場合は、iframeなどを使って信頼した値として渡してね、ということのようです。

HTTP-level vulnerabilities

さて、ここからはサーバー側のお話となります。

当然サーバー側の対応はAngularではなく(今回は)Spring bootでの対応となるわけですが、
Cross-site request forgery (CSRF or XSRF) と Cross-site script inclusion (XSSI) についてはサーバー側の処理を補助する仕組みがあるとのことです。

エラーページ

完全に話がずれてしまいますが、引っかかったので書き残しておきます。

前回のCSPの話の中で、「WebSecurityConfig」というクラスを作ってURLごとに閲覧権限を付与していましたが、 例えば必要な権限を持たない状態でページにアクセスすると、Spring Securityがデフォルトで提供する(と思う)エラーページが表示されます。

これを自分で用意する、という場合についてです。

WebSecurityConfig.java

〜省略〜
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
                .inMemoryAuthentication()
                .withUser("user1")
                .password("1234")
                .roles("USER");
        auth
                .inMemoryAuthentication()
                .withUser("user2")
                .password("1234")
                .roles("ADMIN");
    }
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/").permitAll()
                .antMatchers("/menulist").hasAuthority("ADMIN") 
                .and().formLogin()
                .and().exceptionHandling()
                .accessDeniedPage("/accessdenied.html");
    }
}
  • 上記ではLogin画面はデフォルトのものが表示されますが、「formLogin()」のあとに「.loginPage(“/login”)」のようにログインページのパスを指定することで、
    (ルーティングはControllerクラスで実行)自分で用意したログインフォームを表示できます。
  • 上記では未ログイン時はまずログインフォームが表示され、ログイン後にAdmin権限を持っていない場合(つまりuser1でログインした場合)は「accessDeniedPage()」で指定したページが表示されます。

失敗1: ログインフォームを表示していない

WebSecurityConfig.java

〜省略〜
@Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/").permitAll()
                .antMatchers("/menulist").hasAuthority("ADMIN") 

                .and().exceptionHandling()
                .accessDeniedPage("/accessdenied.html");
    }
  }

上記のように、ログインフォームを表示する「.and().formLogin()」を入れずにaccessDeniedPageを指定したところ、
デフォルトのエラーページしか表示されませんでしたorz (未ログイン状態でアクセスして確認)

実際の使用ではまずログインさせるであろう、ということで、
特に問題は無いのですが、ちゃんとドキュメント等を読まずに適当にやると失敗する、という例でした。

失敗2: AccessDeniedPageの表示にAngularのルーティングを使おうとした

WebSecurityConfig.java

〜省略〜
@Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/").permitAll()
                .antMatchers("/menulist").hasAuthority("ADMIN") 
                .and().formLogin()
                .and().exceptionHandling()
                .accessDeniedPage("/accessdenied");
    }
  }

以前トライしたように、アクセス拒否のエラーでもAngularのルーティング機能を使ってページを表示しようとしたところ、
Admin権限でないと見られないはずの「/menulist」のページまで一般ユーザー権限のユーザーが閲覧できるようになってしまいましたorz

Spring boot側で「/accessdenied」に切り替える前に、Angularで「/menulist」を表示してしまうため、
ということなのでしょうか。

まぁそもそもエラーページは通常起こり得ない異常が発生したときに表示するものだと考えれば、
通常通り処理しようとするな、ということでもありますorz

ログインやログインフォームなどについては別途詳しく追いかけてみたいと思います。

Cross-site request forgery (CSRF or XSRF)

さて、元の話題に戻ります。

CSRFの説明は安全なウェブサイトの作り方の1.6などを参照していただけたらと思いますが、
簡単にまとめると

ユーザーが ①のページ を開いてログインを行い、ログインした状態のまま別のタブなどで ②罠の仕掛けられたページ を開いた場合に、
②のページから①のページに対し、ログイン済みの情報を利用して不正なリクエストを送ることができてしまう、という問題です。

この対策として、以下のような方法があります。(防ぎたいリクエストの種類にもよりますが)

  1. サーバー側とクライアント側でセッションID、乱数などのトークンを共有・確認する
  2. ユーザーに再ログインを求める
  3. CAPTCHA機能を利用する
  4. Referer情報から正しいURLから遷移したかを確認する

2、3はパスワードなどの変更や入出金などを行う時に見かけるように思います。

で、今回は1について追いかけてみることにします。

Angular

下記によると、AngularのHttpClient(後述しますが、正確にはHttpClientXsrfModule)にはCSRF(XSRF)の対策としてトークンを発行する機能があります。

クッキーの値は同じドメインのコードからしか読めないことから、そのリクエストが正しくユーザーからのものであり、
攻撃者からではないことが確認できます。

とのことです。

確認してみましょう。

Postリクエストを送信してみる(失敗)

ということで、Postリクエストを送信してみてどのようなHTTPヘッダにデータが渡されているかを見てみることにします。

まずはAngular側から。

post-page.component.ts

import { Component, OnInit } from '@angular/core';
import { Http } from '@angular/http';

@Component({
  selector: 'app-post-page',
  templateUrl: './post-page.component.html',
  styleUrls: ['./post-page.component.css']
})
export class PostPageComponent implements OnInit {

  constructor(private http_: Http) { }

  ngOnInit() {
    this.http_.post("/postsample", {id: 0, createdDate: "2017年6月24日", title: "ニュースだよ", article: "中身です"})
      .subscribe();
  }
}
  • 実際にはせっかく作成したinterface(top-news.ts)を利用したり、ボタン操作によって送信を行いたいところですが、
    今回は確認作業の簡略化のためngOnInitで実行しています。
  • Http.postの戻り値は「Observable」であり、実行には「subscribe」が必要です。

あとは任意のページからpost-page.componentが表示・実行されるようにしておけばOKです。

次にSpring bootです。
RestControllerクラスにPostメソッド用の処理を追加します。

import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;

@RestController
public class CrudSampleController {
    
    〜省略〜

    @PostMapping("/postsample")
    public TopNews postsample(@RequestBody TopNews news){
        System.out.println(news.article);
        return news;
    }
}
  • こちら側も今回は確認のため、特に何も処理を行わずに返しています。

さてこれで準備はOKです。
確認してみましょう!!

404エラーになりましたorz

Postメソッドの送信先が見つからない原因

ズバリSpring SecurityのCSRF対策のための機能です/(^o^)\

そのため、下記のようにCSRF対策を無効にすると正しくアクセスできます。

WebSecurityConfig.java

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

〜省略〜

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/").permitAll()
                .antMatchers("/menulist").hasAuthority("ADMIN")
                .and().formLogin().loginPage("/login")
                .and().exceptionHandling()
                .accessDeniedPage("/accessdenied.html");


       // CSRF対策を無効にする.
       http
                .csrf()
                .disable();

    }
}

これで不正アクセスしようとする攻撃者の気持ちが体験できたわけですね/(^o^)\

…というところでここからが本題です。

Angularでトークンを発行する

HttpClientXsrfModule を使って、CSRF対策のためのトークンを発行できます。

app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { HttpModule } from '@angular/http';
import { HttpClientXsrfModule } from '@angular/common/http'

import { AppComponent } from './app.component';
import { PostPageComponent } from './post-page/post-page.component';

@NgModule({
  declarations: [
    AppComponent,
    PostPageComponent
  ],
  imports: [
    BrowserModule,
    HttpModule,
    
    HttpClientXsrfModule.withOptions({
      cookieName: 'My-Xsrf-Cookie',
      headerName: 'My-Xsrf-Header',
    }),
    
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

この後後述のSpring boot側でAngularのトークンを使う設定を行うと、
Postリクエスト送信時のRequestHeaderで「XSRF-TOKEN」(Cookie)、「X-XSRF-TOKEN」というトークンが付与されます。

ここで一点注意があります。

HttpClient - Angular などでは「HttpClientXsrfModule.withConfig」としてcookieNameなどを設定していますが、
「withOptions」でないとエラーになります。

仕様変更があったのでしょうか。
HttpClientXsrfModule - Angularでは「withOptions」になっていますね。

ドキュメントの修正依頼ってプルリクになるのでしょうか。。。?

Spring bootでAngularのトークンを使うようにする

Spring bootでAngularのトークンを使用し、Postリクエストを有効にします。

WebSecurityConfig

〜省略〜
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
                .inMemoryAuthentication()
                .withUser("user1")
                .password("1234")
                .roles("USER");
        auth
                .inMemoryAuthentication()
                .withUser("user2")
                .password("1234")
                .roles("ADMIN");
    }
    @Override
    public void configure(HttpSecurity http) throws Exception {
        
        http
                .httpBasic().and()
                .authorizeRequests()
                .antMatchers("/").permitAll()
                .anyRequest().authenticated()
                .and().formLogin()
                .and().csrf()
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
                
    }
}
  • トークンの指定は「csrf()」以降です。
  • Angularのトークンを使用するためには、CookieのHttpOnlyはFalseにしておく必要があるようです。
    「.csrfTokenRepository(new CookieCsrfTokenRepository)」のようにすると403エラーが発生しました。
  • 上記ではPostリクエストを送信する前にログインが必要で、「.anyRequest().authenticated()」を、
    「.anyRequest().permitAll()」にすればいけるのでは?と思いましたが、403エラーが発生しましたorz

おわりに

CSRF対策についてはとりあえずPostリクエストを送信できるようになりましたが、
現状だと全てのページでログインを行うか、CSRF対策を無効にする必要が出てきてしまいます。

次回は特定のページでのみCSRF対策を有効にし、ログインしていなくてもPost送信ができるようにする方法を調べてみます。

また、Angularのドキュメントの最後の項目、Cross-site script inclusion (XSSI)についても追いかけてみます。

参照

BypassSecurity

Spring Security

CSRF (XSRF)

Angularのセキュリティ対策について調べてみる その2

はじめに

前回の続きです。

今回はContent security policy(CSP)、Offline template compilerについて追いかけてみました。

Content security policy(CSP)について

CSPは、画像やJavascriptなどのファイルを読み込むことのできるサイト(ドメイン)を指定するためのものです。

もしこれらのファイルを同一ドメインのみから読み込む場合、CSPで同一ドメインのみに制限をかけることで、
万一クロスサイトスクリプティング(XSS)の対応漏れがあったとしても、
(外部のスクリプトファイルが無効なため)危険を回避できる可能性が高まります。

で、このCSPを有効にするには、Webサーバーから Content-Security-Policy HTTP ヘッダを返す必要があります。

今回はSpring boot(Spring Framework)を使用するため、
Spring Securityで設定を行うことになります。

Spring Securityを使用する

※今回はあくまでCSPの確認を行うことができれば良い。という方針で設定しています。
Spring Securityの正しい使い方については公式ドキュメントや本記事の参照リンクなどを確認してください。

今回は以前作成したプロジェクトを流用することにします。

といってもやっているのはコントローラークラスのルーティング設定ぐらいですが。

ではまずSpring Securityを使えるようにしてみます。

build.gradle

buildscript {
    ext {
        springBootVersion = '1.5.4.RELEASE'
    }
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'

version = '0.0.1-SNAPSHOT'
sourceCompatibility = 1.8

repositories {
    mavenCentral()
}
dependencies {
    compile('org.springframework.boot:spring-boot-starter-security')
    compile('org.springframework.boot:spring-boot-starter-web')
    testCompile('org.springframework.boot:spring-boot-starter-test')
}
  • dependenciesにSpring Securityを追加するだけです。
    これを起動すると、Spring Securityが有効になってトップページからログインを求められるようになります。
    (ユーザー名: User、パスワードはSpring bootのプロジェクト起動中にログに出力されます)

Configクラスの作成

このままだとログインが必要ないページでも全てログインしないと表示できなくなるので問題です。
また、(今回は使用しませんが)ユーザーのパスワードが、ビルドごとに変更されるのも不便ですね。

このような設定を行うため、xmlファイルまたはclassを作成します。
(今回はclassを使用することにしました)

WebSecurityConfig.java

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 1. ユーザーの設定.
        auth
                .inMemoryAuthentication()
                .withUser("user")
                .password("1234")
                .roles("USER");
    }
    @Override
    public void configure(HttpSecurity http) throws Exception {
        // 2. ページの閲覧権限の設定.
        http.authorizeRequests()
                .antMatchers("/").permitAll()
                .antMatchers("/menulist").authenticated();
    }
}
  • Spring SecurityのConfigクラスとして認識させるためには、 WebSecurityConfigurerAdapter の継承、
    @Configuration 、 @EnableWebSecurity の付与が必要です。
  • 1でユーザー名、パスワードを設定し、2でページ(トップ、/menulist)の閲覧権限を指定しています。
  • (上記では仮に /menulist をログイン中でしか表示できないようにしていますが)
    今回は全てのユーザーに表示して良いページしかないため、1、2は無くても問題ありません。

X-Frame-Options

実はこの状態で下記のような iframe を含んだHTMLを表示させようとしても、
iframe がブロックされ、表示されません。

main-page.component.html

< img src='https://pbs.twimg.com/media/DC_pK4OUMAAmy_B.jpg' />
< img src='assets/img/sake.jpg' />
< iframe srcdoc="< img src='https://pbs.twimg.com/media/DC_pK4OUMAAmy_B.jpg' />">< /iframe>

それは、Spring Securityではデフォルトで X-Frame-Options がDENYに設定され、iframeが無効になっているためです。

有効にするためにはConfigクラスの configure(HttpSecurity http) で設定を変更します。

WebSecurityConfig.java

〜省略〜
    @Override
    public void configure(HttpSecurity http) throws Exception {
        〜省略〜
        http
                .headers()
                .frameOptions()
                .sameOrigin();
    }
}
  • これで同一ドメインから追加された iframe は有効になり表示されるようになります。
  • ただし iframe が不要なのであればDENYのままの方が良いですね。

CSPの設定

さていよいよCSPです。
こちらはデフォルトでは有効にならないため、 configure(HttpSecurity http) で設定を行います。

WebSecurityConfig.java

〜省略〜
    @Override
    public void configure(HttpSecurity http) throws Exception {
    〜省略〜
        http
            .headers()
            .contentSecurityPolicy("script-src 'self' 'unsafe-eval'; img-src 'self';");
    }
}
  • スクリプト、画像を同一ドメインのみ有効にしています。
  • 各要素は ; (セミコロン)で区切られます。
  • AngularでCSPを使用する場合、'self'以外に'unsafe-eval'(文字列からコードを生成する eval() や類似メソッドの使用許可)を指定しておく必要があります。
    これが抜けていると、 vendor.bundle.js でエラーが発生し、ページが表示されません。
  • スクリプト、画像の他にドメインを制限したい場合は下記などを参照してください。
    Content Security Policy (CSP) - Web セキュリティ - MDN

Offline template compilerについて

【個人的理解に基づく意訳】

Offline template compilerを使うことで、Template Injectionと呼ばれる脆弱性(下記参照)の回避とパフォーマンスの向上が見込まれます。

プロダクトレベル、特にテンプレートがユーザーデータを含む場合、動的にテンプレートを生成するのではなく、
Offline template compilerを使ってください。

Angularではテンプレートを信頼しているため、動的に生成したテンプレートに含まれる問題に対して組み込みのセキュリティ機能が働かないためです

動的にフォームを生成する必要がある場合は、Dynamic Formsのガイドページを参照してください。

ということですので、Dynamic Formsについてもおいおい調べてみたいと思います(そんなのばっかりですね。。。)。

サーバー側のXSS対策について

【個人的理解に基づく意訳】

Angularで構築したWebアプリケーションに、サーバー側からテンプレート言語(Spring Frameworkだと JSP や Thymeleaf ?)を使ってテンプレートのコードを入れてしまうと、
XSSによってWebアプリケーションが自由にコントロールされてしまう危険があります。

これを防ぐため、サーバー側からテンプレート言語を使って渡す値をエスケープ処理し、 サーバー側でAngularのテンプレートを生成するのを避けてください。

フロント側はAngularに任せて、サーバー側からAngularCLIで生成したHTMLなどにあれこれするのはやめてね。ということでしょうか。

参照

Angularのセキュリティ対策について調べてみる その1

はじめに

フロントエンドで行うセキュリティ対策について、Angularではどのようにしているのかなぁ〜、と疑問に思ったので調べてみました。

まずはAngularの公式ドキュメントを元に、あれこれ試してみることにします。

https://angular.io/guide/security

雑な翻訳で進めているため、間違いなどありましたらご指摘のほどよろしくお願いいたしますm( )m

XSSについて

ドキュメントではまずXSSへの対応について書かれています。

Angularではプロパティやスタイルなどの値はTemplateから挿入され、それらの値は全てデフォルトでサニタイズされます。
そのため、仮にユーザーが入力した値をそのままページ上に表示したとしても、Javascriptなどのコードは実行されません。

Angularのセキュリティの文脈で扱われる要素は以下の4つです。

  • HTML
  • Style (CSS)
  • URL (例:< a href>)
  • Resource URL (例:< script src>)

untrustedなコードがサニタイズされた場合、ConsoleにはWarningが出力されます。

ということで、src/app/inner-html-binding.component.htmlを試してみました。

< h3>Binding innerHTML
< p>Bound value:< /p>
< p>{{htmlSnippet}}< /p>
< p>Result of binding to innerHTML:< /p>
< p [innerHTML]="htmlSnippet">< /p>
  • クラスは今回特に関係がないと考えたため、簡略化のため省いています。
  • {{htmlSnippet}}と[innerHTML]に後述のHTMLなどを含めた値を渡し、どのように反映されるかを確認します。
import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-sample-from-docs',
  templateUrl: './sample-from-docs.component.html',
  styleUrls: ['./sample-from-docs.component.css']
})
export class SampleFromDocsComponent implements OnInit {

  
  htmlSnippet = 'Template < script>alert("0wned")< /script>< b>Syntax< /b>';
  

  constructor() { }
  ngOnInit() {
  }
}
  • htmlSnippetにHTMLタグを含めてどう表示されるかを確認しています。
    上記はブログ内でHTMLタグが反映されないように「<」の後にスペースを入れていますが、実際には削除してください。

結果

{{htmlSnippet}}

  • 入力されたHTMLタグは反映されず、記号も含めてそのまま表示される。

[innerHTML]

  • scriptタグは除去され、bタグは反映される。

色々試してみた

[innerHTML]の方はセキュリティ上問題ないとされたHTMLタグは反映されました。

では、他にどのようなものが反映されるか、または反映されないのかを調べてみました。

〜省略〜
export class SampleFromDocsComponent implements OnInit {

  
  htmlSnippet = '< div class="testclass" id="testid" style="color:red;">Test< br>Test2\nTest3< /div>< font color=red>Test4< /font>< ul>< li>t< /li>< li>e< /li>< /ul>< h1>H1< /h1>< img src="#" onerror=alert(0) alt="test" />< video src="#">< /video>< a href="/">Top< /a>< a href="javascript:alert(0)">script< /a>';
  

  constructor() { }
  ngOnInit() {
  }
}

結果

{{htmlSnippet}}

  • 先程と同じくタグは全て反映されず、記号も含めて入力された値がそのまま表示される。

[innerHTML]

反映されたもの
* divタグ
* class
* brタグ
* fontタグ
* ulタグ
* liタグ
* h1タグ
* imgタグ
* videoタグ
* aタグ
反映されなかったもの
* id
* style
* 改行文字(\n)
* onerror
* href="javascript:alert(0)"
* iframeタグ
  • 反映されなかったものの大部分はただ除去されただけですが、「href=“javascript:alert(0)"」は「href="unsafe:javascript:alert(0)"」に置き換えられ、
    リンクをクリックすると「xdg-openを開きますか?」と聞かれ、「xdg-openを開く」としてもJavascriptの実行はされませんでした。
  • classが反映されてidが除去されるのが謎ですね。。。何かidに関連する脆弱性があったのでしょうか。
  • classは反映されるものの、例えばCSSにこのクラスに対して何かを書いても反映はされませんでした。おそらくCSSと上記コードの実行順によるものだと思います。
  • aタグはそのまま反映されるということで、ユーザーの入力した値をそのまま反映する場合は、クッションのページなどを挟んだ方が良いかも知れません。

FormsModuleについて

ドキュメントからは外れますが、Formに入力されたデータも見てみました。

app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';

import { AppComponent } from './app.component';
import { SampleFromDocsComponent } from './sample-from-docs/sample-from-docs.component';
import { SampleForFormComponent } from './sample-for-form/sample-for-form.component';

@NgModule({
  declarations: [
    AppComponent,
    SampleFromDocsComponent,
    SampleForFormComponent
  ],
  imports: [
    BrowserModule,
    FormsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
  • ngModelを使って2 way bindingを行うために、FormsModuleのインポートが必要です。

sample-for-form.component.ts

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-sample-for-form',
  templateUrl: './sample-for-form.component.html',
  styleUrls: ['./sample-for-form.component.css']
})
export class SampleForFormComponent implements OnInit {
  private inputValue: string;
  constructor() { }

  ngOnInit() {
  }
  onSubmitButtonClicked(){
    console.log(this.inputValue);
  }
}
  • 入力された値を保持する変数と、ボタンをクリックした時に入力されたデータをログに出力するようにしました。

sample-for-form.component.html

< textarea [(ngModel)]='inputValue'>< /textarea>
< textarea [(ngModel)]='inputValue'>< /textarea>
< div>{{inputValue}}< /div>
< button (click)='onSubmitButtonClicked()'>Submit< /button>
  • 2つある内どちらかのTextareaに値を入力すると、もう一方にも反映される、という内容です。

確認内容

HTMLタグ、改行、絵文字を入力して、2 way bindingでの動作とボタン押下時のログを見てみます。

結果

HTMLタグ、絵文字はそのまま、改行も反映された状態で出力されました。

ということで、HTTPヘッダ、メールヘッダにユーザーが入力した値をそのまま反映する場合、
改行を取り除く必要があります(HTTPヘッダインジェクション、メールヘッダインジェクション防止のため)。

ま、そもそも値をそのまま反映するな、という話ではありますが。

暗号化

Angularに限ったことではありませんが、例えばログインを行う場合、
入力されたパスワードは暗号化してサーバー側に投げるのかな?と思っていました。

が、実際にはSSLで第三者に読み取られないようにした上で、サーバー側で必要に応じて暗号化を行う、というのが主流のようです。

ま、そりゃそうか。

ちなみにJavasciriptで暗号化とググるとCrypto.jsと、
Web Crypto APIが出てきます。

こちらもおいおい調べていきたいところ。

おわりに

結局ドキュメントからは話が逸れてしまいましたが、XSSについてはデフォルトでも対策がとられていることがわかりました。

自分でサニタイズをするかどうかは、AngularのTemplateだけを使って値を表示する場合は不要ですが、
そうでない場合はサニタイズしておくか、またはユーザーが入力した値がそのまま保存されていることを明示しておいた方が良さそうです。

次はドキュメントの続きを。。。

参照

AngularのAnimationsで画像をスライドさせたい

はじめに

AngularのAnimationsで画像をスライドさせてみたら色々大変でした、というお話です。

※2017.7.6 各コードの解説などを追記しました

今回実現したかったことは、Unity日本語サイトトップのバナーのような、
表示中の画像に、画面外から次の画像が重なってくるアニメーションです。

http://japan.unity3d.com/

結局うまく再現できなかったため、表示中の画像が画面外にスライドすると同時に次の画像がスライドしてくる、という内容にしました。

アニメーション1(失敗)

以前は表示中の画像をフェードアウトのアニメーションで非表示にしたあと、
次の画像を表示する、という内容にしていました。

ということは、次の画像を表示するアニメーションを画面外から移動してくるようにして、
フェードアウトと同時に実行すれば完成じゃね?

と思い、そして失敗しましたorz

main-page.component.html

< section id="top-banner-area" >
  < div *ngFor='let bannerImage of bannerImages' >
    < a href='bannerImage.transitionPath'>
      < img [src]="bannerImage.imagePath" class="top-banner" [@bannerState]="bannerImage.bannerState" (@bannerState.done)="onBannerChanged()" />
    < /a>
  < /div>
< /section>
〜省略〜

main-page.component.ts

import { Component, OnInit, HostListener } from '@angular/core';
import { animate, state, style, transition, trigger } from '@angular/animations';
import { Title } from '@angular/platform-browser';
import { BannerImage } from '../banner-image' 

@Component({
  selector: 'app-main-page',
  templateUrl: './main-page.component.html',
  styleUrls: ['./main-page.component.css'],
  animations: [
    trigger('bannerState',
      [
        state('active', style({
          zindex: 1,
          display: 'block',
          transform: 'translateX(0%)',
          filter: 'brightness(100%)',
          
        })),
        state('inactive', style({
          zindex: 0,
          filter: 'brightness(20%)',
          display: 'none',
        })),
        state('beforeActive', style({
          transform: 'translateX(100%)',
          filter: 'brightness(100%)',
          zindex: 1,
        })),
        transition('active => inactive', animate('400ms ease-in')),
        transition('inactive => beforeActive', animate('1ms ease-in')),
        transition('beforeActive => active', animate('400ms ease-in'))
      ]
    )]
})
export class MainPageComponent implements OnInit {
  private bannerImages: BannerImage[];
  private activeImageNum: number;
  private nextImageNum: number;
  
  constructor(private titleService: Title) {}
  ngOnInit() {
    this.titleService.setTitle('メインページ');

    this.bannerImages = [
      {imagePath: "assets/img/banner01.jpg", transitionPath: '', bannerState: 'inactive'},
      {imagePath: "assets/img/sake.jpg", transitionPath: '', bannerState: 'inactive'}
    ];
    this.activeImageNum = -1;
    this.nextImageNum = 0;
    
    this.onBannerChanged();
  }
  @HostListener('document:keyup', ['$event'])
  onArrowKeyUp(event: any){
    if(event.keyCode === 39){ // RightArrow.
      this.nextImageNum = (this.activeImageNum < this.bannerImages.length - 1)? this.activeImageNum + 1 : 0;
      this.bannerImages[this.activeImageNum].bannerState = 'inactive';
      this.bannerImages[this.nextImageNum].bannerState = 'beforeActive';
    }
    else if(event.keyCode === 37){ // LeftArrow
      this.nextImageNum = (this.activeImageNum > 0)? this.activeImageNum - 1: this.bannerImages.length - 1;
      this.bannerImages[this.activeImageNum].bannerState = 'inactive';
      this.bannerImages[this.nextImageNum].bannerState = 'beforeActive';
    }
  }
  onBannerChanged(){
    if(this.activeImageNum === this.nextImageNum){
      return;
    }
    this.bannerImages[this.nextImageNum].bannerState = 'active';
    this.activeImageNum = this.nextImageNum;
  }
}

main-page.component.css

#top-banner-area{
    background-color: #000;
    width: 81%;
    min-width: 1200px;
    height: 600px;

}
.top-banner{
    height: 600px;
    position:absolute;
    margin-left: auto;
    margin-right: auto;
}

内容

  • active -> inactive -> beforeActive -> activeの繰り返し
  • inactiveで画像の明るさを落としたあと非表示
  • beforeActiveで画像をX軸方向に枠外まで移動 + z-indexを1にしてinactiveより前に表示
  • activeでbeforeActiveの位置から画面中央に移動

うまくいかなかったところ

  • z-indexが効かず、beforeActive -> activeで移動する画像が表示中の画像より前に表示されたり後ろに表示されたりする
  • 上記のコードで再確認したところ再現はしなかったのだが(stateが2つの場合?)アニメーションの実行完了速度が画像(配列の順番?)によって異なる現象が発生した

画像をスライドさせる

ということで、表示中の画像が画面外にスライドすると同時に次の画像がスライドしてくる、という内容のアニメーションを作ってみることにしました。

main-page.component.html

アニメーション1(失敗)と同じです。

main-page.component.ts

import { Component, OnInit, HostListener } from '@angular/core';
import { animate, state, style, transition, trigger } from '@angular/animations';
import { Title } from '@angular/platform-browser';
import { BannerImage } from '../banner-image' 

@Component({
  selector: 'app-main-page',
  templateUrl: './main-page.component.html',
  styleUrls: ['./main-page.component.css'],
  animations: [
    trigger('bannerState',
      [
        state('active', style({
          display: 'block',
          transform: 'translateX(0%)',
          filter: 'brightness(100%)',
        })),
        state('moveLeft', style({
          transform: 'translateX(-100%)',
          filter: 'brightness(20%)',
          display: 'none'
        })),
        state('fromRight', style({
          transform: 'translateX(100%)',
        })),
        state('moveRight', style({
          transform: 'translateX(100%)',
          filter: 'brightness(20%)',
          display: 'none'
        })),
        state('fromLeft', style({
          transform: 'translateX(-100%)',
        })),
        transition('active => *', animate('400ms ease-in')),
        transition('moveLeft => fromRight', animate('1ms ease-in')),
        transition('fromRight => active', animate('400ms ease-in')),
        transition('moveRight => fromLeft', animate('1ms ease-in')),
        transition('fromLeft => active', animate('400ms ease-in'))
      ]
    )]
})
export class MainPageComponent implements OnInit {
  private bannerImages: BannerImage[];
  private activeImageNum: number;
  private nextImageNum: number;
〜省略〜
  ngOnInit() {
〜省略〜
    this.bannerImages = [
      {imagePath: "assets/img/banner01.jpg", transitionPath: '', bannerState: 'inactive'},
      {imagePath: "assets/img/sake.jpg", transitionPath: '', bannerState: 'inactive'}
    ];
    this.activeImageNum = -1;
    this.nextImageNum = 0;
    
    this.onBannerChanged();
  }
  @HostListener('document:keyup', ['$event'])
  onArrowKeyUp(event: any){
    if(event.keyCode === 39){ // RightArrow.
      this.nextImageNum = (this.activeImageNum < this.bannerImages.length - 1)? this.activeImageNum + 1 : 0;
      this.bannerImages[this.activeImageNum].bannerState = 'moveLeft';
      this.bannerImages[this.nextImageNum].bannerState = 'fromRight';
    }
    else if(event.keyCode === 37){ // LeftArrow
      this.nextImageNum = (this.activeImageNum > 0)? this.activeImageNum - 1: this.bannerImages.length - 1;
      this.bannerImages[this.activeImageNum].bannerState = 'moveRight';
      this.bannerImages[this.nextImageNum].bannerState = 'fromLeft';
    }
  }
  onBannerChanged(){
    if(this.activeImageNum === this.nextImageNum){
      return;
    }
    this.bannerImages[this.nextImageNum].bannerState = 'active';
    this.activeImageNum = this.nextImageNum;
  }
}

main-page.component.css

#top-banner-area{
    background-color: #000;
    width: 100%;
    min-width: 1200px;
    height: 600px;
    margin-left: auto;
    margin-right: auto;
    overflow: hidden;
    position: relative;
}
.top-banner{
    position: absolute;
    display: block;
    margin-left: auto;
    margin-right: auto;
    height: 600px;
}
  • class:top-banner-areaで「overflow: hidden;」を指定することで、
    アニメーション中に「translateX(100%)」を実行したときもブラウザの横方向のスライドバーが表示されるのを防ぎます。
  • 最初は横方向のスライドバーの表示を抑えるためclass:top-bannerのpositionを「fixed」にしていたのですが、
    画面に固定されてしまうため、positionは「absolute」に変更し、overflowを指定しました。
  • 「overflow: hidden;」は、一番外側の要素に設定しないと効果が無いようです。
  • スライドする画像を全て同じ位置に表示するためpositionを「absolute」にする必要があり、
    他の要素を並べるためにはheightを固定値で持つ必要があります(%指定だと正しく表示できない)。

背景画像をスライドさせる

画像の高さを固定にすると、画像の比率を保つためには幅も固定にする必要がでてきます。

この場合ここまで作成したようにimgタグを使用していると、
(少なくとも今の私の知識では)画面の幅が狭いデバイスで見たとき画像の左側だけが見える、という状態になります。

これを例えば画像の中央部分を表示するよう指定するためには、
imgタグで画像を表示せず、要素の背景画像として指定する、という方法があります。

main-page.component.html

< section id="top-banner-area" >
  < div *ngFor='let bannerImage of bannerImages' >
    < a href='bannerImage.transitionPath'>
      < div [style.background-image]="'url(' + bannerImage.imagePath + ')'" class="top-banner" [@bannerState]="bannerImage.bannerState" (@bannerState.done)="onBannerChanged()">
      < /div>
    < /a>
  < /div>
< /section>
〜省略〜
  • ngForを使って画像のURLを指定したかったため、HTMLでbackground-imageを設定しています
  • background-imageのURLを「"‘url(bannerImage.imagePath)’“」のようにしてしまうと、
    「bannerImage.imagePath」がngForで得られた内容ではなく、「bannerImage.imagePath」という文字列として扱われ、エラーになります。

main-page.component.ts

import { Component, OnInit, HostListener } from '@angular/core';
import { animate, state, style, transition, trigger } from '@angular/animations';
import { Title } from '@angular/platform-browser';
import { BannerImage } from '../banner-image' 

@Component({
  selector: 'app-main-page',
  templateUrl: './main-page.component.html',
  styleUrls: ['./main-page.component.css'],
  animations: [
    trigger('bannerState',
      [
        state('active', style({
          zIndex: 1,
          display: 'block',
          transform: 'translateX(0%)',
          filter: 'brightness(100%)',
        })),
        state('moveLeft', style({
          zIndex: 0,
          transform: 'translateX(-100%)',
          filter: 'brightness(20%)',
          display: 'none'
        })),
        state('fromRight', style({
          transform: 'translateX(100%)',
        })),
        state('moveRight', style({
          zIndex: 0,
          transform: 'translateX(100%)',
          filter: 'brightness(20%)',
          display: 'none'
        })),
        state('fromLeft', style({
          transform: 'translateX(-100%)',
        })),
        state('start', style({
          transform: 'translateX(0%)',
          display: 'none'
        })),
        transition('active => *', animate('400ms ease-in')),
        transition('moveLeft => fromRight', animate('1ms ease-in')),
        transition('fromRight => active', animate('400ms ease-in')),
        transition('moveRight => fromLeft', animate('1ms ease-in')),
        transition('fromLeft => active', animate('400ms ease-in')),
        transition('* => start', animate('1ms ease-in')),
        transition('start => *', animate('1ms ease-in')),
      ]
    )]
})
export class MainPageComponent implements OnInit {
  private bannerImages: BannerImage[];
  private activeImageNum: number;
  private nextImageNum: number;
  
  constructor(private titleService: Title) {}
  ngOnInit() {
    this.titleService.setTitle('メインページ');

    this.bannerImages = [
      {imagePath: "assets/img/banner01.jpg", transitionPath: '', bannerState: 'start'},
      {imagePath: "assets/img/sake.jpg", transitionPath: '', bannerState: 'start'},
      {imagePath: 'assets/img/pic_1466247624676.jpg', transitionPath: '', bannerState: 'start'}
    ];
    this.activeImageNum = -1;
    this.nextImageNum = 0;
    
    this.onBannerChanged();
  }
  @HostListener('document:keyup', ['$event'])
  onArrowKeyUp(event: any){
    if(event.keyCode === 39){ // RightArrow.
      this.nextImageNum = (this.activeImageNum < this.bannerImages.length - 1)? this.activeImageNum + 1 : 0;
      this.bannerImages[this.activeImageNum].bannerState = 'moveLeft';
      this.bannerImages[this.nextImageNum].bannerState = 'fromRight';
    }
    else if(event.keyCode === 37){ // LeftArrow
      this.nextImageNum = (this.activeImageNum > 0)? this.activeImageNum - 1: this.bannerImages.length - 1;
      this.bannerImages[this.activeImageNum].bannerState = 'moveRight';
      this.bannerImages[this.nextImageNum].bannerState = 'fromLeft';
    }
  }
  onBannerChanged(){
    if(this.activeImageNum === this.nextImageNum){
      return;
    }
    this.bannerImages[this.nextImageNum].bannerState = 'active';
    this.activeImageNum = this.nextImageNum;
  }
}
  • 先程はうまく活躍できなかったz-indexですが、ここではactive状態の場合のみ1にして、他より前に表示されるようにしておくことで、
    移動時の画像などが表示されてしまうことによるチラツキを抑えることができます。

main-page.component.css

#top-banner-area{
    background-color: #000;
    width: 80%;
    height: 600px;
    display: block;
    margin-top: 90px;
    margin-left: auto;
    margin-right: auto;
    overflow: hidden;
    position: relative;
}
.top-banner{
    position: absolute;
    background-size: cover;
    background-position: center;
    display: block;
    width: 100%;
    height: 600px;
}

参照

Angularで引っかかったあれこれ

はじめに

相変わらず上の続きなのですが、Angular周りで細々したことに引っかかりまくったため、ここにまとめることとします。

静的なファイルの読み込み

たとえばヘッダーのロゴ画像など、静的にファイルを読み込みたい場合。

普通にHTMLで書くときと同じように、HTMLファイルと同じ階層に画像ファイルを置いても読み込むことはできません。

angular-cliを使っている場合、ファイルの置き場所は.angular-cli.jsonの中の、app > assets の中で指定されます。

.angular-cli.json

〜省略〜
  "apps": [
    {
〜省略〜
      "assets": [
        "assets",
        "favicon.ico"
      ],
      "index": "index.html",
      "main": "main.ts",
      "polyfills": "polyfills.ts",
      "test": "test.ts",
      "tsconfig": "tsconfig.app.json",
      "testTsconfig": "tsconfig.spec.json",
      "prefix": "app",
      "styles": [
        "styles.css"
      ],
      "scripts": [],
      "environmentSource": "environments/environment.ts",
      "environments": {
        "dev": "environments/environment.ts",
        "prod": "environments/environment.prod.ts"
      }
    }
  ],
  〜省略〜

デフォルトでは favicon.ico と src > assets が指定されており、
例えば src > assets > img > image01.jpg となるようフォルダと画像ファイルを追加した場合、
「< img src=“assets/img/image01.jpg”>」で表示できるようになります。

動的にページタイトルを設定する

ルーティングは以前やりましたが、ページが切り替わったらタイトルも変更したいところ。

ページタイトルを切り替えるには、対象のページのクラスにDIで「Title」クラスを注入し、
「setTitle()」を使って設定します。

main-page.component.ts

import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';

@Component({
  selector: 'app-main-page',
  templateUrl: './main-page.component.html',
  styleUrls: ['./main-page.component.css']
})
export class MainPageComponent implements OnInit {

  constructor(private titleService: Title) {}
  ngOnInit() {
    this.titleService.setTitle('メインページ');
  }

}

IE対応

気になってはいたのです。。。
IEでちゃんと開けるのかということに。

とはいえ、下記を見ればIE9以降であればサポートはされているようなので、
諸事情によりサポート切れのブラウザで表示する必要が。。。みたいな環境でなければ問題ないはずw

ところが、IE11で開くとなにやらエラーが発生してページが正しく動作しませんorz

IEに対応するためには、プロジェクト > src にあるpolyfills.tsの下記の部分をアンコメントする必要があります。

polyfills.ts

/** IE9, IE10 and IE11 requires all of the following polyfills. **/
// import 'core-js/es6/symbol';
// import 'core-js/es6/object';
// import 'core-js/es6/function';
// import 'core-js/es6/parse-int';
// import 'core-js/es6/parse-float';
// import 'core-js/es6/number';
// import 'core-js/es6/math';
// import 'core-js/es6/string';
// import 'core-js/es6/date';
// import 'core-js/es6/array';
// import 'core-js/es6/regexp';
// import 'core-js/es6/map';
// import 'core-js/es6/weak-map';
// import 'core-js/es6/set';

ちなみにここで登場するcore-jsは、ブラウザ間の差異を吸収してくれるもののようです。
(この辺もちゃんと調べたいと思いつつ何もできてないマンですはい)

デフォルトでこれがオンになってない理由は、余計なファイルを含めたくない、ということなのでしょうか。

Componentをどう分けるか

これまで1ページの中の要素でも、ひとかたまりごとにComponentに分けて作っていました。

ただ、この辺りのお話を見ていると、そうではなく複数の箇所から共通で使う部分だけを切り分ける、
とした方が良いように思えてきました。

今から思えば、たしかにハンズオンも、先にまとめて作ったあと切り分けていたなぁ、と。

これもバランスだとは思いますが、今後はComponentをもう少しまとめてみようと思います。

画像切り替え

以前は画像のパスをimgタグ渡して、動的に画像を切り替える、ということをしていましたが、
これには問題がありました。

最初にページを表示した時に、画像がキャッシュされていないので画像切り替え時に一瞬前の画像が見えたりしていたのです。

大量に画像を表示するなどの場合は別ですが、今回のように決まった数を表示する場合は、
画像を全てキャッシュしておいて、それを切り替えた方が良さそうです。

banner-image.ts

export interface BannerImage {
    imagePath: string;
    transitionPath: string;
    bannerState: string;
}

main-page.component.ts

import { Component, OnInit, HostListener } from '@angular/core';
import { animate, state, style, transition, trigger } from '@angular/animations';
import { Title } from '@angular/platform-browser';
import { BannerImage } from '../banner-image' 

@Component({
  selector: 'app-main-page',
  templateUrl: './main-page.component.html',
  styleUrls: ['./main-page.component.css'],
  animations: [
    trigger('bannerState',
      [
        state('active', style({
          transform: 'scale(1.0)',
          filter: 'brightness(100%)',
          display: 'block'
        })),
        state('inactive', style({
          transform: 'scale(0.6)',
          filter: 'brightness(20%)',
          display: 'none'
        })),
        transition('active => inactive', animate('400ms ease-in')),
        transition('inactive => active', animate('10ms ease-in'))
      ]
    )]
})
export class MainPageComponent implements OnInit {
  private bannerImages: BannerImage[];
  private activeImageNum: number;
  private nextImageNum: number;
  
  constructor(private titleService: Title) {}
  ngOnInit() {
    this.titleService.setTitle('メインページ');

    this.bannerImages = [
      {imagePath: "assets/img/banner01.jpg", transitionPath: '', bannerState: 'inactive'},
      {imagePath: "assets/img/banner01.jpg", transitionPath: '', bannerState: 'inactive'},
      {imagePath: "assets/img/banner01.jpg", transitionPath: '', bannerState: 'inactive'},
    ];
    this.activeImageNum = - 1;
    this.nextImageNum = 0;
    this.onBannerChanged();
  }
  @HostListener('document:keyup', ['$event'])
  onArrowKeyUp(event: any){
    if(event.keyCode === 39){ // RightArrow.
      this.nextImageNum = (this.activeImageNum < this.bannerImages.length - 1)? this.activeImageNum + 1 : 0;
      this.bannerImages[this.activeImageNum].bannerState = 'inactive';
    }
    else if(event.keyCode === 37){ // LeftArrow
      this.nextImageNum = (this.activeImageNum > 0)? this.activeImageNum - 1: this.bannerImages.length - 1;
      this.bannerImages[this.activeImageNum].bannerState = 'inactive';
    }
  }
  onBannerChanged(){
    if(this.activeImageNum === this.nextImageNum){
      return;
    }
    this.bannerImages[this.nextImageNum].bannerState = 'active';
    this.activeImageNum = this.nextImageNum;
  }
}

main-page.component.html

< section id="top-banner-area" >
  < div *ngFor='let bannerImage of bannerImages' >
    < a href='bannerImage.transitionPath'>
      < img [src]="bannerImage.imagePath" class="top-banner" [@bannerState]="bannerImage.bannerState" (@bannerState.done)="onBannerChanged()" />
    < /a>
  < /div>
< /section>
< app-top-news>< /app-top-news>

参照

静的ファイルの読み込み

ページタイトル

ブラウザ対応

Component分割