サーバーサイドアプリケーションを起動する。
$ go run server.go
CLIENT_ID
をGoogleで取得したclient_idに置き換える。その後クライアントサイドのdevサーバーを起動する。
$ npm start
http://localhost:3000
にアクセス。
{ | |
"name": "google-sign-sample", | |
"version": "0.1.0", | |
"dependencies": { | |
"react": "^16.3.2", | |
"react-dom": "^16.3.2", | |
"react-router-dom": "^4.2.2", | |
"react-scripts": "1.1.4", | |
"react-google-login": "^3.2.1" | |
}, | |
"scripts": { | |
"start": "react-scripts start", | |
"build": "react-scripts build", | |
"test": "react-scripts test --env=jsdom", | |
"eject": "react-scripts eject" | |
}, | |
} |
package main | |
import ( | |
"crypto/rand" | |
"encoding/base64" | |
"encoding/json" | |
"io" | |
"io/ioutil" | |
"log" | |
"net/http" | |
"net/url" | |
"time" | |
) | |
// LoginParams は `POST /login` のリクエストJSONです | |
type LoginParams struct { | |
IDToken string | |
} | |
// LoginAccount は `POST /login` のレスポンスJSONです。 | |
type LoginAccount struct { | |
UserID string `json:"userId"` | |
Email string `json:"email"` | |
Name string `json:"name"` | |
Picture string `json:"picture"` | |
} | |
// IDTokenPayload はGoogleのトークンエンドポイントのレスポンスJSONです | |
type IDTokenPayload struct { | |
Iss string `json:"iss"` | |
Sub string `json:"sub"` | |
Azp string `json:"azp"` | |
Aud string `json:"aud"` | |
Iat string `json:"iat"` | |
Exp string `json:"exp"` | |
AtHash string `json:"at_hash"` | |
Jti string `json:"jti"` | |
Alg string `json:"alg"` | |
Kid string `json:"kid"` | |
Email string `json:"email"` | |
EmailVerified string `json:"email_verified"` | |
Name string `json:"name"` | |
Picture string `json:"picture"` | |
GivenName string `json:"given_name"` | |
FamilyName string `json:"family_name"` | |
Locale string `json:"locale"` | |
} | |
// User ユーザー情報 | |
type User struct { | |
ID string | |
Name string | |
CreatedAt time.Time | |
} | |
var sessionDB = make(map[string]string) | |
var userDB = make(map[string]User) | |
func findUser(userID string) *User { | |
user, ok := userDB[userID] | |
if !ok { | |
return nil | |
} | |
return &user | |
} | |
func findOrCreateUser(userID string, name string) User { | |
user := findUser(userID) | |
if user == nil { | |
user = &User{ | |
ID: userID, | |
Name: name, | |
CreatedAt: time.Now(), | |
} | |
userDB[userID] = *user | |
} | |
return *user | |
} | |
func createSessionKey() (string, error) { | |
b := make([]byte, 32) | |
if _, err := io.ReadFull(rand.Reader, b); err != nil { | |
return "", err | |
} | |
return base64.URLEncoding.EncodeToString(b), nil | |
} | |
// GetMyProfile `/my` エンドポイントの処理です | |
func GetMyProfile(w http.ResponseWriter, r *http.Request) { | |
cookie, err := r.Cookie("SessionKey") | |
if err != nil { | |
w.WriteHeader(http.StatusUnauthorized) | |
return | |
} | |
session, ok := sessionDB[cookie.Value] | |
if !ok { | |
w.WriteHeader(http.StatusUnauthorized) | |
return | |
} | |
user := findUser(session) | |
if user == nil { | |
w.WriteHeader(http.StatusBadRequest) | |
return | |
} | |
if err := json.NewEncoder(w).Encode(user); err != nil { | |
panic(err) | |
} | |
} | |
// Login `/login` エンドポイントの処理です | |
func Login(w http.ResponseWriter, r *http.Request) { | |
switch r.Method { | |
case "OPTIONS": | |
w.Header().Add("Access-Control-Allow-Methods", "POST") | |
case "POST": | |
body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1048576)) | |
if err != nil { | |
panic(err) | |
} | |
defer r.Body.Close() | |
var loginParams LoginParams | |
if err := json.Unmarshal(body, &loginParams); err != nil { | |
log.Println(body) | |
log.Println(err) | |
w.Header().Set("Content-Type", "application/json; charset=UTF-8") | |
w.WriteHeader(http.StatusInternalServerError) | |
if err := json.NewEncoder(w).Encode(err); err != nil { | |
panic(err) | |
} | |
return | |
} | |
// ID Tokenの検証を実施する | |
values := url.Values{ | |
"id_token": {loginParams.IDToken}, | |
} | |
resp, err := http.Get("https://www.googleapis.com/oauth2/v3/tokeninfo" + "?" + values.Encode()) | |
if err != nil { | |
panic(err) | |
} | |
defer resp.Body.Close() | |
body, err = ioutil.ReadAll(resp.Body) | |
if err != nil { | |
panic(err) | |
} | |
var idTokenPayload IDTokenPayload | |
if err := json.Unmarshal(body, &idTokenPayload); err != nil { | |
w.Header().Set("Content-Type", "application/json; charset=UTF-8") | |
w.WriteHeader(http.StatusInternalServerError) | |
if err := json.NewEncoder(w).Encode(err); err != nil { | |
panic(err) | |
} | |
return | |
} | |
// TODO idTokenPayload.Audが正しいかどうかチェックする | |
log.Println(idTokenPayload) | |
user := findOrCreateUser(idTokenPayload.Sub, idTokenPayload.Name) | |
sessionKey, err := createSessionKey() | |
if err != nil { | |
panic(err) | |
} | |
sessionDB[sessionKey] = user.ID | |
cookie := &http.Cookie{ | |
Name: "SessionKey", | |
Value: sessionKey, | |
Path: "/", | |
Secure: false, // 実際はhttpsにしてtrueする | |
HttpOnly: true, | |
} | |
http.SetCookie(w, cookie) | |
w.WriteHeader(http.StatusOK) | |
default: | |
w.WriteHeader(http.StatusNotFound) | |
} | |
} | |
func withCORS(fn http.HandlerFunc) http.HandlerFunc { | |
return func(w http.ResponseWriter, r *http.Request) { | |
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:3000") // クライアントサイドのdevサーバーはlocalhost:3000で動いてる想定 | |
w.Header().Set("Access-Control-Allow-Headers", "Content-Type") | |
w.Header().Set("Access-Control-Allow-Credentials", "true") | |
fn(w, r) | |
} | |
} | |
type withCORSHandler struct { | |
handler http.Handler | |
} | |
func main() { | |
http.HandleFunc("/my", withCORS(GetMyProfile)) | |
http.HandleFunc("/login", withCORS(Login)) | |
log.Fatal(http.ListenAndServe(":5555", nil)) | |
} |
// src/App.js | |
import React, { Component } from 'react'; | |
import { BrowserRouter as Router, Switch, Route, Link, Redirect } from 'react-router-dom'; | |
import { GoogleLogin } from 'react-google-login'; | |
const PrivateRoute = ({authenticated, render, ...rest}) => ( | |
authenticated ? ( | |
<Route {...rest} render={render} /> | |
) : ( | |
<Route | |
{...rest} | |
render={props => | |
<Redirect | |
to={{ | |
pathname: "/login", | |
state: {from: props.location} | |
}} | |
/> | |
} | |
/> | |
) | |
); | |
class App extends Component { | |
state = { | |
authenticated: false, | |
initialized: null | |
} | |
componentDidMount() { | |
fetch("http://localhost:5555/my", { | |
mode: 'cors', | |
credentials: 'include', | |
}) | |
.then(response => { | |
if (response.status === 200) { | |
response.json().then(data => console.log(data)) | |
this.setState({ authenticated: true, initialized: true }) | |
} else { | |
this.setState({ authenticated: false, initialized: true }) | |
} | |
}) | |
} | |
onSignIn = (googleUser) => { | |
console.log('ID: ' + googleUser.getBasicProfile().getId()); | |
fetch("http://localhost:5555/login", { | |
method: "POST", | |
mode: 'cors', | |
credentials: 'include', | |
headers: new Headers({ 'Content-Type': 'application/json' }), | |
body: JSON.stringify({ "IDToken": googleUser.getAuthResponse().id_token }), | |
}) | |
.then(response => { | |
return fetch("http://localhost:5555/my", { | |
mode: 'cors', | |
credentials: 'include', | |
}) | |
}) | |
.then(response => { | |
response.json().then(data => console.log(data)) | |
this.setState({authenticated: true}) | |
}) | |
} | |
onSignOut = () => { | |
fetch("http://localhost:5555/logout", { | |
method: "POST", | |
mode: 'cors', | |
credentials: 'include', | |
headers: new Headers({ 'Content-Type': 'application/json' }), | |
}) | |
.finally(response => { | |
console.log(response) | |
this.setState({authenticated: false}) | |
}) | |
} | |
render() { | |
if (!this.state.initialized) { | |
return ('Loading...') | |
} | |
return ( | |
<Router> | |
<Switch> | |
<Route exact path="/" render={props => | |
<Top {...props} authenticated={this.state.authenticated} onSignOut={this.onSignOut} /> | |
}/> | |
<Route path="/login" render={props => | |
<Login {...props} onSignIn={this.onSignIn} authenticated={this.state.authenticated} /> | |
}/> | |
<PrivateRoute path="/private" authenticated={this.state.authenticated} render={props => | |
<div>プライベートなページ</div> | |
}/> | |
</Switch> | |
</Router> | |
) | |
} | |
} | |
export default App; | |
class Login extends Component { | |
render() { | |
if (this.props.authenticated) { | |
const { from } = this.props.location.state || { from: { pathname: "/" } }; | |
return (<Redirect to={from} />) | |
} | |
return ( | |
<div> | |
<h1>ようこそ!</h1> | |
<div> | |
<GoogleLogin | |
clientId="<CLIENT_ID>" | |
buttonText="Login" | |
onSuccess={this.props.onSignIn} | |
onFailure={(err) => console.error(err)} | |
/> | |
</div> | |
<Link to='/'>Topへ</Link> | |
</div> | |
) | |
} | |
} | |
class Top extends Component { | |
render() { | |
if (this.props.authenticated) { | |
return ( | |
<div> | |
<h1>ログイン済み:Topページ</h1> | |
<button onClick={this.props.onSignOut}>ログアウト</button> | |
</div> | |
) | |
} else { | |
return ( | |
<div> | |
<h1>ログイン前:Topページ</h1> | |
<Link to='/login'>サインイン</Link> | |
</div> | |
) | |
} | |
} | |
} |