6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

「雰囲気で使わずきちんと理解する!整理してOAuth2.0を使うためのチュートリアルガイド」のチュートリアルをAngularで実装してみる

Last updated at Posted at 2020-05-14

注意:Public ClientであるSPAで認可コードグラントを実装していますが、実際には認可コードグラント + PKCEが推奨となります。
2020/5/15 stateの検証を追加しました。


「雰囲気で使わずきちんと理解する!整理してOAuth2.0を使うためのチュートリアルガイド」 の「第6章 チュートリアル」の認可コードグラントでの認可をAngularで実装してみます

OAuthの理解は以下記事もとても参考になります。
「OAuth 2.0 全フローの図解と動画」

本書チュートリアルではCLIでGoogleのPhotos Library APIでOAuthを利用しています
Google Photos APIの仕様はこちらです
https://developers.google.com/photos/library/reference/rest

Not OAuth認証

今回は***「OAuth認証(認証プロトコル)」でなく「OAuth(認可プロトコル)」***としての利用です
「OAuth認証」と「OAuth」の認識が間違われることが多いみたいなのでご注意を

OAuth認証についてはこちらを参考にしてください
https://auth0.com/docs/quickstart/spa/angular2
https://dev.classmethod.jp/articles/auth0-google-login-angular-spa/

準備

Angular CLIでプロジェクト作成

プロジェクト名angular-oauth-sampleで作成

APIのクライアントID、クライアントシークレットを取得

Google Cloud PlatformでAPIの設定をします
クライアントID、クライアントシークレットの取得方法の詳細は割愛しますが、以下を実施してください

  • Photos Library APIの有効化
  • コールバックURIをhttp://localhost:4200/callbackに設定

Googleフォトに画像のアップロード

https://www.google.com/intl/ja/photos/about/
Googleフォトの画像を表示する実装のため、画像をアップロードします

最近お気に入りのtacoさんを利用します
taco.png

実装

ディレクトリ構造は以下です

src/
 ├── app/
 │     ├── component/
 │     │   ├── callback/           // 認可コードを受け取るコールバック先、トークンエンドポイントへのリクエスト
 │     │   ├── display-photo/      // リソースアクセスポイントへのリクエスト
 │     │   └── home/               // 認可エンドポイントへのリクエスト
 │     ├── service/
 │     │   └── token.service.ts    // アクセストークンの管理
 │     ├── app.component.html
 │     ├── app.component.ts
 │     ├── app.module.ts
 │     └── app-routing.module.ts
 └── environments/environment.ts   // OAuth情報の管理

処理の手順としては以下です

  1. 認可エンドポイントにリクエストを送る(home
  2. (Googleの)認可画面にリダイレクトされ、Googleの認証情報を入力する
  3. 認可コードがコールバックされる(callback
  4. 認可コードを利用してトークエンドポイントにリクエストを送り、アクセストークンを取得する(callback
  5. アクセストークンを利用してGoogle Photo APIで写真取得する

OAuth情報

準備したクライアントID,クライアントシークレットをenvironment.tsに設定します

environment.ts
export const environment = {
  auth: {
    authUri: 'https://accounts.google.com/o/oauth2/auth',
    tokenUri: 'https://oauth2.googleapis.com/token',
    clientId: 'xxxxxxxx-xxxxxxxxxxxxxxxxx.apps.googleusercontent.com',
    clientSecret: 'xxxxxxxxxx',

    // 認可コードを受け取るコールバック先
    redirectUri: 'http://localhost:4200/callback',

    // Google Phots API画像取得のスコープ
    scope: 'https://www.googleapis.com/auth/photoslibrary.readonly',

    // Google Phots API画像取得のURI
    photosAlbumUri: 'https://photoslibrary.googleapis.com/v1/albums'
  }
};

ルーティング設定

app-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HomeComponent } from './component/home/home.component';
import { CallbackComponent } from './component/callback/callback.component';
import { DisplayPhotoComponent } from './component/display-photo/display-photo.component';

const routes: Routes = [
  {path: 'home', component: HomeComponent},
  {path: 'callback', component: CallbackComponent},
  {path: 'photo', component: DisplayPhotoComponent},
  {path: '**', redirectTo: 'home', pathMatch: 'full'}
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule {
}

認可エンドポイントへのリクエスト

  • 認可エンドポイントへのリクエスト + 認可画面へのリダイレクトにwindow.location.hrefを利用しました
    • 何か他にいい方法あればコメントよろしくお願いいたします
  • CSRF対策としてstateを設定し、認可コードがコールバックした際に検証すえうためにsessionStorageに保持しています(@ritouさん コメントでのご指摘ありがとうございます)
home.component.ts
import { Component } from '@angular/core';
import { environment } from '../../../environments/environment';

@Component({
  selector: 'app-home',
  templateUrl: './home.component.html',
  styleUrls: ['./home.component.css']
})
export class HomeComponent {

  constructor() { }

  public connectApi() {

    // stateの発行
    const state = Math.random().toString(32).substring(2);
    sessionStorage.setItem('state', state);

    // リクエストパラメータ
    const params = new Map();
    params.set('response_type', 'code');
    params.set('client_id', environment.auth.clientId);
    params.set('state', state);
    params.set('scope', environment.auth.scope);
    params.set('redirect_uri', environment.auth.redirectUri);

    let authUrl = `${environment.auth.authUri}?`;
    params.forEach((value: string, key: string) => {
      authUrl += `${key}=${value}&`;
    });

    // 認可エンドポイントにリクエスト
    window.location.href = authUrl;
  }
}

認可コード + トークエンドポイントへのリクエスト

Googleの認可画面で情報入力しリクエストを送ると、認可コードhttp://localhost:4200/callback に返ってきます
認可コードをもとにトークエンドポイントにリクエストを送り、アクセストークンを取得します

callback.component.ts
/**
 * トークンエンドポイントのリクエストパラメータ
 */
interface TokenEndPointRequest {
  client_id: string;
  client_secret: string;
  grant_type: string;
  code: string;
  redirect_uri: string;
}

/**
 * トークンエンドポイントのレスポンスパラメータ
 */
interface TokenEndPointResponse {
  access_token: string;
  expires_in: string;
  scope: string;
  token_type: string;
}

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

  // 認可コード
  private code: string;

  constructor(private route: ActivatedRoute,
              private router: Router,
              private http: HttpClient,
              private tokenService: TokenService) { }

  async ngOnInit() {
    // 認可コード,state取得
    let callbackState;
    await this.route.queryParamMap.subscribe( param => {
      this.code = param.get('code');
      callbackState = param.get('state');
    });

    // state検証
    const issuedState = sessionStorage.getItem('state');
    sessionStorage.removeItem('state');
    if (callbackState !== issuedState) {
      alert('state検証に失敗しました');
      await this.router.navigate(['/home']);
      return;
    }

    // アクセストークン取得
    await this.getAccessToken();

    // 画像表示コンポーネントに遷移
    await this.router.navigate(['/photo']);
  }

  /**
   * アクセストークン
   */
  getAccessToken() {
    const  headers = new HttpHeaders();
    headers.append('Content-Type', 'application/x-www-form-urlencoded');
    const options = { headers };

    const body: TokenEndPointRequest = {
      client_id: environment.auth.clientId,
      client_secret: environment.auth.clientSecret,
      grant_type: 'authorization_code',
      code: this.code,
      redirect_uri: environment.auth.redirectUri,
    };

    return new Promise( (resolve, reject) => {
      this.http.post<TokenEndPointResponse>(environment.auth.tokenUri, body, options).subscribe( async data => {
        // アクセストークン保持
        await this.tokenService.setAccessToken(data.access_token);
        resolve();
      });
    });
  }
}

別コンポーネントでアクセストークンを利用してGoogle Photo APIを利用するため、
アクセストークンの管理はサービスクラスで管理させます

token.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class TokenService {

  private accessTokenSubject: BehaviorSubject<string> = new BehaviorSubject('');
  public accessToken = this.accessTokenSubject.asObservable();

  constructor() { }

  /**
   * アクセストークンの更新
   * @param accsessToken アクセストークン
   */
  setAccessToken(accsessToken: string) {
    this.accessTokenSubject.next(accsessToken);
  }
}

Google Photo APIで写真取得

TokenServiceからアクセストークンを取得し、Google Photo APIで画像取得します
今回はサンプルとして一枚の画像のみを表示させるようにします

display-photo.component.ts
import {Component, OnInit} from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { TokenService } from '../../service/token.service';
import { environment } from '../../../environments/environment';

/**
 * Google Photos APIのI/F
 */
interface AlbumResponse {
  albums: Album[];
}
interface Album {
  mediaItemsCount: string;
  coverPhotoMediaItemId: string;
  coverPhotoBaseUrl: string;
  id: string;
  title: string;
  productUrl: string;
}

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

  private accessToken: string;
  public imgUrl: string;

  constructor(private http: HttpClient,
              private tokenService: TokenService) {
    // アクセストークンの取得
    this.tokenService.accessToken.subscribe( token => {
      this.accessToken = token;
    });
  }

  ngOnInit() {
    // Google Photos API
    this.getGooglePhotos(this.accessToken);
  }

  /**
   * Google Photos APIで画像取得
   */
  getGooglePhotos(accessToken: string) {
    console.log(accessToken);
    const headers = new HttpHeaders(
      { Authorization: 'Bearer ' + accessToken }
    );
    const options = { headers };
    this.http.get<AlbumResponse>(environment.auth.photosAlbumUri, options).subscribe( data => {
      this.imgUrl = data.albums[0].coverPhotoBaseUrl;
    });
  }
}

確認

  1. http://localhost:4200/homeにアクセスし、ボタンをクリックします

    スクリーンショット 2020-05-14 23.12.04.png
  2. 認可画面にリダイレクトされ、Googleアカウントで認証(認可?)します

    google.png
  3. アプリケーションにリダイレクトされ、画像表示されました

    スクリーンショット 2020-05-14 23.18.10.png

補足

今回はリフレッシュトークンアクセストークンの有効期限は考慮していません
チュートリアルの流れを実装して、理解を深めるためのメモになります

コードは以下にあります
https://github.com/okome-me/angular-oauth-sample

6
4
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?