8
7

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.

ユニークビジョン株式会社Advent Calendar 2019

Day 15

GitLab as an OAuth2 provider: Web application flowを実装する

Last updated at Posted at 2019-12-15

この記事はユニークビジョン株式会社 Advent Calendar 2019の15日目の記事です。

概要

GitLabによるOAuth認証を、ReactとGolangを使って実装する。
認証が完了した際に、そのユーザの情報をブラウザ表示するアプリを実装する。

Document

GitLab as an OAuth2 provider

事前準備

User settings -> Applications内でOAuth認証に使うアプリを作成しておく

認証の流れ

1. https://gitlab.example.com/oauth/authorize へリクエストを送る

この際に幾つかパラメータを付けてリクエストを行う

  • client_id
    • GitLabアプリのApplication ID
  • redirect_uri
    • アプリの利用を承認した後にリダイレクトするURI
    • 事前にアプリの設定でcallback uriを追加しておくこと
  • response_type
    • あまり理解していないが、OAuth認証の方式によって異なる模様
    • 今回はcodeを指定する
  • state
    • CSRF攻撃を防ぐために指定する
    • 認証リクエスト毎にユニークであることが望ましい
    • この記事では面倒だったため一意にhogeで設定している
  • scope
    • この認証で許可する操作を指定する
    • アプリで設定した権限以上は設定できないと思われる

2. GitLabに遷移後、許可、Redirect uriへリダイレクト

http://myapp.com/oauth/redirect?code=1234567890&state=YOUR_UNIQUE_STATE_HASH

以上のような形でリダイレクトされる。
この記事では、React側にリダイレクトさせて、クエリをパースし、こちらで用意したサーバに送る。

3. https://gitlab.example.com/oauth/token へcodeとstateを送る

この記事ではサーバでcode, stateを受け取り、リクエストを行う。
その後、発行されたAccess tokenを使ってUser情報を取得し、React側へ返している。

サーバーサイド

Go

レポジトリ

gitlab

ソースコード

main.go
package main

import (
	"encoding/json"
	"errors"
	"github.com/gin-contrib/cors"
	"github.com/gin-gonic/gin"
	"io/ioutil"
	"log"
	"net/http"
	"net/url"
	"strings"
	"time"
)

type Config struct {
	Port        string
	DbHost      string
	ClientID    string
	AppSecret   string
	RedirectURI string
}

func main() {
	config := get_config()
	r := gin.Default()
	// Cors
	r.Use(cors.New(cors.Config{
		AllowOrigins:     []string{"*"},
		AllowMethods:     []string{"GET", "POST", "PUT", "PATCH", "OPTIONS"},
		AllowHeaders:     []string{"Origin"},
		ExposeHeaders:    []string{"Content-Length"},
		AllowCredentials: true,
		AllowOriginFunc: func(origin string) bool {
			return true
		},
		MaxAge: 12 * time.Hour,
	}))
	// Routing
	r.POST("/oauth", oauth)
	r.Run(":" + config.Port)
}

func get_config() Config {
	return Config{
		Port:        "8000",
		ClientID:    "xxxxx",
		AppSecret:   "xxxxx",
		RedirectURI: "http://127.0.0.1:3000/auth",
	}
}

// クライアント側で認証を許可すると呼ばれるエンドポイント
func oauth(c *gin.Context) {
	c.Request.ParseForm()
	// callback urlに併せて付いてくるcodeとstateを使ってパラメータを組み立てる
	code := strings.Join(c.Request.Form["code"], "")
	state := strings.Join(c.Request.Form["state"], "")
	oauth, err := oauth_token(code, state)
	if err != nil {
		c.JSON(400, gin.H{
			"error": err.Error(),
		})
		return
	}
	// 認証したユーザでAPIをコールする
	user, err := get_user(oauth.AccessToken)
	if err != nil {
		c.JSON(400, gin.H{
			"error": err.Error(),
		})
		return
	}
	c.JSON(200, user)
}

func oauth_token(code string, state string) (OAuth, error) {
	config := get_config()
	endpoint := "https://gitlab.com/oauth/token"

	// HTTPクライアントを作成
	client := &http.Client{}
	client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
		return http.ErrUseLastResponse
	}
	values := url.Values{}
	// callback urlに併せて付いてくるcodeとstateを使ってパラメータを組み立てる
	values.Add("client_id", config.ClientID)
	values.Add("client_secret", config.AppSecret)
	values.Add("code", code)
	values.Add("state", state)
	values.Add("grant_type", "authorization_code")
	values.Add("redirect_uri", config.RedirectURI)

	// HTTPリクエストを作成
	req, err := http.NewRequest("POST", endpoint, strings.NewReader(values.Encode()))
	if err != nil {
		return OAuth{}, errors.New(err.Error())
	}

	// リクエストを実行
	resp, err := client.Do(req)
	if err != nil {
		return OAuth{}, errors.New(err.Error())
	}
	defer resp.Body.Close()

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return OAuth{}, errors.New(err.Error())
	}
	// JSONをパース
	var oauth OAuth
	if err := json.Unmarshal(body, &oauth); err != nil {
		return OAuth{}, errors.New(err.Error())
	}
	return oauth, nil
}

type OAuth struct {
	AccessToken string `json:"access_token"`
}

type User struct {
	Id        int    `json:"id"`
	Username  string `json:"username"`
	Name      string `json:"name"`
	AvatarUrl string `json:"avatar_url"`
}

// 取得したトークンでユーザ情報を取得
func get_user(token string) (User, error) {
	endpoint := "https://gitlab.com/api/v4/user"

	// HTTPクライアントを作成
	client := &http.Client{}
	client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
		return http.ErrUseLastResponse
	}

	// HTTPリクエストを作成
	req, err := http.NewRequest("GET", endpoint, nil)

	// Bearerトークンを指定
	req.Header.Add("Authorization", "Bearer "+token)
	if err != nil {
		return User{}, errors.New(err.Error())
	}

	// リクエスト実行
	resp, err := client.Do(req)
	if err != nil {
		return User{}, errors.New(err.Error())
	}
	defer resp.Body.Close()

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return User{}, errors.New(err.Error())
	}

	// JSONをパース
	var user User
	if err := json.Unmarshal(body, &user); err != nil {
		log.Fatal(err)
	}
	return user, nil
}

クライアント

React

レポジトリ

gitlab

ソースコード

index.js
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'

ReactDOM.render(
  <App />,
  document.getElementById('root')
)
App.js
import React, { Component } from 'react'
import { BrowserRouter, Route, Link } from 'react-router-dom';
import queryString from 'query-string';
import Auth from './auth';
import Root from './root';

const App = () => (
  <BrowserRouter>
    <div>
      <Route exact path='/' component={Root} />
      <Route path='/auth' render={ (props) => <Auth qs={queryString.parse(props.location.search)} />} />
    </div>
  </BrowserRouter>
)

export default App
root.jsx
import React, { useState, useEffect } from 'react';

const appId = "xxxxx";
const scope = "api+profile+read_user";
const redirect_uri = encodeURI("http://127.0.0.1:3000/auth");
const oauth_url = "https://gitlab.com/oauth/authorize?client_id=" + appId + "&redirect_uri=" + redirect_uri + "&response_type=code&state=hoge&scope=" + scope;

// GitLabへのリンクを作成する
// 認証が完了するとredirect_uriに指定したエンドポイントにリダイレクトする
// このサンプルでは同Reactアプリの/authに遷移する
let Root = (props) => {
  return (
    <div>
      <h2>Home</h2>
      <p>Welcome to test oauth</p>
      <a href={oauth_url}><button>Sign in with GitLab</button></a>
    </div>
  );
}

export default Root
auth.jsx
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'
import axios from 'axios';

// こちらで用意したサーバのエンドポイント
const url = "http://127.0.0.1:8000" + "/oauth";

// OAuth認証承認後、このページに遷移する
let Auth = (props) => {
  const [user, setUser] = useState({
    id: null,
    username: null,
    avatar_url: null,
    name: null
  });

  const qs = props.qs;

// このコンポーネントがマウントされたときにサーバへGitLabが発行したcode, stateを送る
let Auth = (props) => {
  useEffect(() => {
    getAuth()
  }, []);

  // このコンポーネントがマウントされたときにサーバへGitLabが発行したcode, stateを送る
  let getAuth = () => {
    let params = new URLSearchParams();

    // redirect uriにクエリパラメータとして付いているcodeとstateをサーバへ送る
    params.append('code', qs.code);
    params.append('state', qs.state);
    axios.post(url, params)
    .then(response => {
      setUser(response.data);
    }).catch(error => {
      console.log(error);
    });
  }

  // 発行されたトークンを使って取得したユーザ情報を表示する
  return (
    <div>
      <img border="0" src={user.avatar_url} width="128" height="128" alt="avatar"></img>
      <h2>ID</h2>
      <p>{user.id}</p>
      <h2>Username</h2>
      <p>{user.username}</p>
      <h2>name</h2>
      <p>{user.name}</p>
      <Link to="/">Go back Home</Link>
    </div>
  );
}

export default Auth

動作確認

サーバ起動

goプロジェクトのルートで以下のコマンドを実行localhost:8000でサーバが起動する

go run main.go

クライアント準備

Reactプロジェクトのルートで以下のコマンドを実行localhost:3000でブラウザが起動する

npm start

実際に認証してみる

localhost:3000にアクセスすると以下のような画面が表示される。

Screenshot from 2019-12-16 00-26-38.png

Sign in with GitLabを押下すると以下のような画面が表示される。

image.png

Authorize を押下すると、

image.png

まとめ

認証フロー自体はかなりシンプルで分かりやすいものだが、Documentの例で、/oauth/authorizeに対するリクエストの形式がhttpになっており、この形式が間違っているとエラーすら出ず、サインイン画面にリダイレクトされるのはなかなか分かりづらかった。

社内ではGitLabを中心とした業務フローになりつつあるので、このOAuth認証を使ってツールを構築していければと考えている。

8
7
0

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
8
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?