この記事はユニークビジョン株式会社 Advent Calendar 2019の15日目の記事です。
概要
GitLabによるOAuth認証を、ReactとGolangを使って実装する。
認証が完了した際に、そのユーザの情報をブラウザ表示するアプリを実装する。
Document
事前準備
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
レポジトリ
ソースコード
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
レポジトリ
ソースコード
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
ReactDOM.render(
<App />,
document.getElementById('root')
)
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
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
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
にアクセスすると以下のような画面が表示される。
Sign in with GitLab
を押下すると以下のような画面が表示される。
Authorize
を押下すると、
まとめ
認証フロー自体はかなりシンプルで分かりやすいものだが、Documentの例で、/oauth/authorize
に対するリクエストの形式がhttpになっており、この形式が間違っているとエラーすら出ず、サインイン画面にリダイレクトされるのはなかなか分かりづらかった。
社内ではGitLabを中心とした業務フローになりつつあるので、このOAuth認証を使ってツールを構築していければと考えている。