DEV Community

loading...
Cover image for GitHub Login ด้วย dex

GitHub Login ด้วย dex

Atthavit Wannasakwong
・5 min read

dex เป็น OpenID Connect provider ที่สามารถต่อกับ Identity provider อื่นๆได้ เช่น GitHub, LDAP หรือ OpenID Connect provider อื่นๆ ทำให้เราสามารถเขียน app เพื่อมาต่อแค่ dex ที่เดียว แต่ user ก็สามารถใช้ account อื่นๆในการ login เว็บเราได้

ในบทความนี้เราเขียน app ด้วย Go แบบง่ายๆเพื่อลองใช้ dex ทำให้เว็บเราสามารถ login ได้ด้วย GitHub account

1. สร้าง OAuth app บน GitHub

  • เปิด GitHub
  • กดที่มุมบนขวาเลือก Settings
  • กดต่อไปที่ Developer settings > OAuth Apps > New OAuth App
  • ตั้งชื่อและใส่ url ต่างๆ
  • Authorization callback URL จะเป็น url ของ dex ที่เราจะเซตในขั้นตอนต่อไป Alt Text
  • เสร็จแล้วจะได้ Client ID, Client Secret มา ให้เปิดหน้านี้ไว้ก่อน เดี๋ยวใช้ในขั้นตอนต่อไป

2. เตรียม dex

  • สร้างไฟล์ dex-config.yml ก่อน สามารถดูตัวอย่างอื่นๆได้ที่นี่
issuer: http://localhost:5556/dex

storage:
  type: sqlite3
  config:
    file: /tmp/dex.db

web:
  http: 0.0.0.0:5556

staticClients:
- id: example
  redirectURIs:
    - 'http://localhost:8000'
  name: example
  secret: secret

connectors:
- type: github
  id: github
  name: GitHub
  config:
    clientID: ***
    clientSecret: ***
    redirectURI: http://localhost:5556/dex/callback
  • issuer จะเป็น url ของ dex
  • storage ในที่นี้จะใช้ sqlite เพราเราแค่ทำเล่นๆ ถ้าจะเอาไปใช้งานจริงควรจะใช้เป็นอย่างอื่นมากกว่า สามารถดูตัวเลือกอื่นๆได้ที่นี่
  • staticClients จะเป็น client ที่ app เราจะใช้เพื่อคุยกับ dex ใส่เป็นอะไรก็ได้ แต่ใน app ก็จะต้องใส่เหมือนกัน
  • connectors ใส่ clientID, clientSecret ของ GitHub ที่เราได้จากขั้นตอนแรกมา

  • สร้างไฟล์ docker-compose.yml เพื่อรัน dex ด้วย docker และ bind port 5556

version: '3'
services:
  dex:
    image: quay.io/dexidp/dex:v2.17.0
    command:
      - serve
      - /dex-config.yml
    volumes:
      - ./dex-config.yml:/dex-config.yml:ro
    ports:
      - '5556:5556'

3. เขียนโค้ด

การทำงาน ของ app

  • GET / - แสดงชื่อ user ถ้า login แล้วจะเก็บ token ไว้ cookie ชื่อว่า token และจะแสดง account ใช้ที่ในการ login
  • GET /login - redirect ไป dex เพื่อ login
  • GET /logout - clear cookie เพื่อ logout

เริ่มจาก main.go

package main

import (
    "context"
    "log"
    "net/http"

    "github.com/coreos/go-oidc"
    "golang.org/x/oauth2"
)

func main() {
    provider, err := oidc.NewProvider(context.TODO(), "http://localhost:5556/dex")
    if err != nil {
        log.Fatal(err)
    }
    a := app{
        oauth2Config: &oauth2.Config{
            ClientID:     "example",
            ClientSecret: "secret",
            Endpoint:     provider.Endpoint(),
            Scopes:       []string{"openid", "profile", "email", "federated:id"},
            RedirectURL:  "http://localhost:8000",
        },
        verifier: provider.Verifier(&oidc.Config{ClientID: "example"}),
    }
    http.HandleFunc("/login", a.handleLogin)
    log.Fatal(http.ListenAndServe(":8000", nil))
}

type app struct {
    oauth2Config *oauth2.Config
    verifier     *oidc.IDTokenVerifier
}

func (a *app) handleLogin(w http.ResponseWriter, r *http.Request) {
    http.Redirect(w, r, a.oauth2Config.AuthCodeURL("example-state"), http.StatusSeeOther)
}
  • สร้าง OIDC provider เป็น dex ที่เราได้เซตไว้แล้วในขั้นตอนที่แล้ว โดย lib มันจะไปดึงข้อมูล endpoints และ keys ต่างๆจาก http://localhost:5556/dex/.well-known/openid-configuration ถ้าเรายังไม่รัน dex จะ error
  • oauth2.Config ใส่ ClientID, ClientSecret ให้ตรงกับที่ตั้งไว้ใน dex ไม่ใช่จาก GitHub
  • scope จะมีที่แปลกๆคือ federated:id ใช้เพื่อให้ dex ส่งข้อมูลมาว่าการ login นี้มาจากไหน เช่น GitHub ดู scope อื่นๆได้ที่นี่
  • สร้าง app struct ไว้ เพื่อใช้เก็บตัวแปรต่างๆ
  • handleLogin เพื่อใช้เป็น handler path /login หน้าที่ของมันมีแค่ redirect ไป dex แต่เราจะใช้ url จาก function AuthCodeURL แต่ในที่นี้เราจะไม่มีการเช็ค state จึงใส่อะไรไปก็ได้ แต่ถ้าใช้งานจริงควรจะเช็ค เพื่อป้องกัน CSRF (อ่านเพิ่มเติมที่นี่)
  • ลองรันด้วย go run main.go และเข้า http://localhost:8000/login เราจะถูก redirect ไป dex แล้วก็ไป github.com
  • ในครั้งแรกจะมีหน้าขึ้นมาให้เรากดยอมให้ app เราเข้าถึงข้อมูล account GitHub ของเราได้
  • หลังจากนั้นเราจะถูก redirect มาหน้า / ซึ่งเรายังไม่ได้ทำ handlerไว้

ต่อไปเราจะเพิ่ม / handler เพื่อเอา authorization code ที่ได้จาก dex ไป exchange เป็น token และใช้แสดงชื่อ user หลังจาก login สำเร็จ

package main

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "time"

    "github.com/coreos/go-oidc"
    "golang.org/x/oauth2"
)

func main() {
    provider, err := oidc.NewProvider(context.TODO(), "http://localhost:5556/dex")
    if err != nil {
        log.Fatal(err)
    }
    a := app{
        oauth2Config: &oauth2.Config{
            ClientID:     "example",
            ClientSecret: "secret",
            Endpoint:     provider.Endpoint(),
            Scopes:       []string{"openid", "profile", "email", "federated:id"},
            RedirectURL:  "http://localhost:8000",
        },
        verifier: provider.Verifier(&oidc.Config{ClientID: "example"}),
    }
    http.HandleFunc("/", a.handleIndex)
    http.HandleFunc("/login", a.handleLogin)
    log.Fatal(http.ListenAndServe(":8000", nil))
}

var cookieName = "token"

type app struct {
    oauth2Config *oauth2.Config
    verifier     *oidc.IDTokenVerifier
}

type Claims struct {
    Email           string `json:"email"`
    FederatedClaims struct {
        ConnectorID string `json:"connector_id"`
    } `json:"federated_claims"`
}

func (a *app) handleLogin(w http.ResponseWriter, r *http.Request) {
    http.Redirect(w, r, a.oauth2Config.AuthCodeURL("example-state"), http.StatusSeeOther)
}

func (a *app) handleIndex(w http.ResponseWriter, r *http.Request) {
    if code := r.FormValue("code"); code != "" {
        ctx := oidc.ClientContext(r.Context(), http.DefaultClient)
        token, err := a.oauth2Config.Exchange(ctx, code)
        if err != nil {
            log.Println(err)
            http.Error(w, "error", http.StatusInternalServerError)
            return
        }
        rawIDToken, ok := token.Extra("id_token").(string)
        if !ok {
            log.Println("no id_token in token response")
            return
        }
        http.SetCookie(w, &http.Cookie{
            Name:    cookieName,
            Value:   rawIDToken,
            Expires: time.Now().Add(time.Minute),
        })
        http.Redirect(w, r, "/", http.StatusSeeOther)
        return
    }
    if token, err := r.Cookie("token"); err == nil {
        a.printUser(w, token.Value)
        return
    }
    w.Header().Set("Content-Type", "text/html")
    fmt.Fprint(w, `not logged in, <a href="/login">log in</a>`)
}

func (a *app) printUser(w http.ResponseWriter, token string) {
    idToken, err := a.verifier.Verify(context.TODO(), token)
    if err != nil {
        log.Printf("Cannot verify token: %v", err)
        http.Error(w, "error", http.StatusInternalServerError)
    }

    var claims Claims
    if err := idToken.Claims(&claims); err != nil {
        http.Error(w, "error", http.StatusInternalServerError)
    }

    w.Header().Set("Content-Type", "text/html")
    fmt.Fprintf(w, `logged in as %s from %s, <a href="/logout">logout</a>`, claims.Email, claims.FederatedClaims.ConnectorID)
}
  • เพิ่ม handlerIndex ไปหน้าที่มันคือ
    • เช็คว่าถ้ามี param code มา จะเอาไป exchange เป็น token และใส่ token ไว้ใน cookie เป็นการ login สำเร็จ
    • param code นี้คือ Authorization Code โดย dex จะเป็นคนส่งมาให้ app เรา หลังจากเรา login สำเร็จแล้ว
    • ถ้ามี cookie token อยู่แล้ว ก็ให้ใช้ token นั้นแสดง email ของ user ออกมาเลย
    • ถ้าไม่มีอะไรเลย ก็จะขึ้นว่ายังไม่ login และมี link ให้กดไปหน้า login
  • printUser จะ verify token ที่มาจาก cookie ถ้าถูกก็จะแสดง user
  • Claims struct - ใช้ในการ unmarshal ข้อมูลจาก dex

ตอนนี้เราจะสามารถ login ได้และมี email เราขึ้นแล้ว ต่อไป เราจะเพิ่ม route ให้ logout ได้

package main

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "time"

    "github.com/coreos/go-oidc"
    "golang.org/x/oauth2"
)

func main() {
    provider, err := oidc.NewProvider(context.TODO(), "http://localhost:5556/dex")
    if err != nil {
        log.Fatal(err)
    }
    a := app{
        oauth2Config: &oauth2.Config{
            ClientID:     "example",
            ClientSecret: "secret",
            Endpoint:     provider.Endpoint(),
            Scopes:       []string{"openid", "profile", "email", "federated:id"},
            RedirectURL:  "http://localhost:8000",
        },
        verifier: provider.Verifier(&oidc.Config{ClientID: "example"}),
    }
    http.HandleFunc("/", a.handleIndex)
    http.HandleFunc("/login", a.handleLogin)
    http.HandleFunc("/logout", a.handleLogout)
    log.Fatal(http.ListenAndServe(":8000", nil))
}

var cookieName = "token"

type app struct {
    oauth2Config *oauth2.Config
    verifier     *oidc.IDTokenVerifier
}

type Claims struct {
    Email           string `json:"email"`
    FederatedClaims struct {
        ConnectorID string `json:"connector_id"`
    } `json:"federated_claims"`
}

func (a *app) handleIndex(w http.ResponseWriter, r *http.Request) {
    if code := r.FormValue("code"); code != "" {
        ctx := oidc.ClientContext(r.Context(), http.DefaultClient)
        token, err := a.oauth2Config.Exchange(ctx, code)
        if err != nil {
            log.Println(err)
            http.Error(w, "error", http.StatusInternalServerError)
            return
        }
        rawIDToken, ok := token.Extra("id_token").(string)
        if !ok {
            log.Println("no id_token in token response")
            return
        }
        http.SetCookie(w, &http.Cookie{
            Name:    cookieName,
            Value:   rawIDToken,
            Expires: time.Now().Add(time.Minute),
        })
        http.Redirect(w, r, "/", http.StatusSeeOther)
        return
    }
    if token, err := r.Cookie("token"); err == nil {
        if err := a.printUser(w, token.Value); err != nil {
            log.Println(err)
            a.handleLogout(w, r)
        }
        return
    }
    w.Header().Set("Content-Type", "text/html")
    fmt.Fprint(w, `not logged in, <a href="/login">log in</a>`)
}

func (a *app) handleLogout(w http.ResponseWriter, r *http.Request) {
    http.SetCookie(w, &http.Cookie{
        Name:   cookieName,
        Value:  "",
        MaxAge: -1,
    })
    http.Redirect(w, r, "/", http.StatusSeeOther)
}

func (a *app) handleLogin(w http.ResponseWriter, r *http.Request) {
    http.Redirect(w, r, a.oauth2Config.AuthCodeURL("example-state"), http.StatusSeeOther)
}

func (a *app) printUser(w http.ResponseWriter, token string) error {
    idToken, err := a.verifier.Verify(context.TODO(), token)
    if err != nil {
        return fmt.Errorf("Cannot verify token: %w", err)
    }

    var claims Claims
    if err := idToken.Claims(&claims); err != nil {
        return err
    }

    w.Header().Set("Content-Type", "text/html")
    fmt.Fprintf(w, `logged in as %s from %s, <a href="/logout">logout</a>`, claims.Email, claims.FederatedClaims.ConnectorID)
    return nil
}

  • handleLogout ให้ลบ cookie ออก โดย set MaxAge เป็นค่าติดลบ และ redirect ไปหน้า index
  • แก้ใน printUser ให้ return error ที่อาจจะเกิดจาก token ผิด โดยถ้า error ก็จะให้ redirect ไป logout เพื่อ clear cookie token ที่ผิดทิ้งไป

เสร็จแล้วหน้าแรกก็จะแสดง email ของเราแบบนี้
Alt Text

ดูโค้ดเต็มๆได้ที่นี่

Discussion (0)