Skip to content

Commit

Permalink
feat(vpn-qr-code): add support of qr-code vpn login and qr-code login…
Browse files Browse the repository at this point in the history
… based wssocks connection

In this commit, we add full implementation of qr-code vpn login. In the fyne based client, we can
click "start" button -> show qr code window -> scan code -> connect to wssocks server. In the go
code level, we add the full implementation of qr-code based vpn login (see document in
`plugins/vpn/qr-code-docs.md`). However, qr code vpn login is only implemented on fyne based client.
The cli client and swiftui client do not support qr code vpn login currently.

re #25 #24
  • Loading branch information
genshen committed Jun 13, 2024
1 parent 956c15b commit 5714fe9
Show file tree
Hide file tree
Showing 5 changed files with 255 additions and 75 deletions.
1 change: 1 addition & 0 deletions client-ui/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ func loadVpnUI(wssApp *fyne.App) (*fyne.Container, func() vpn.UstbVpn, func()) {
Username: uiVpnUsername.Text,
Password: uiVpnPassword.Text,
},
QrCodeAuth: newQrCodeAuth(wssApp),
}
}
onVpnClose := func() {
Expand Down
77 changes: 68 additions & 9 deletions client-ui/qr_login_ui.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,78 @@
package main

import (
"bytes"
"context"
"errors"
"fmt"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
"github.com/genshen/wssocks-plugin-ustb/plugins/vpn"
log "github.com/sirupsen/logrus"
"github.com/skip2/go-qrcode"
"net/http"
"time"
)

// LoadQRImage shows QR image for login
func LoadQRImage() *canvas.Image {
if qrImgReader, err := vpn.LoadQrAuthImage(); err != nil {
log.Println(err) // todo
return nil
type FyneQrCodeAuth struct {
appRef *fyne.App
}

func newQrCodeAuth(app *fyne.App) vpn.QrCodeAuth {
return &FyneQrCodeAuth{
appRef: app,
}
}

func (q *FyneQrCodeAuth) ShowQrCodeAndWait(client *http.Client, cookies []*http.Cookie, qr vpn.QrImg) ([]*http.Cookie, error) {
// generate qr code from image
qrPng, err := qrcode.Encode(qr.GenQrCodeContent(), qrcode.Medium, 256)
if err != nil {
return nil, err
}
buf := bytes.NewReader(qrPng)
QrImage := canvas.NewImageFromReader(buf, "qr.png")
QrImage.FillMode = canvas.ImageFillOriginal

scanned := make(chan bool, 1) // signal of qr code scan finished
// show qr code window
qrAuthWindow := (*q.appRef).NewWindow("QR Code vpn auth")
qrAuthWindow.SetContent(container.NewVBox(
QrImage,
widget.NewLabel("scan QR code, and then click button `Finish` "),
widget.NewButton("Finish", func() {
scanned <- true
}),
))
qrAuthWindow.Show()

// wait qr code scanned or time out
// scan the qr code in 30 seconds. Otherwise, an error of Timeout will return.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

select {
case <-scanned:
qrAuthWindow.Close()
return WaitStatus(client, cookies, qr)
case <-ctx.Done():
qrAuthWindow.Close()
return nil, errors.New("scan QR code canceled due to timeout")
}
}

func WaitStatus(client *http.Client, cookies []*http.Cookie, qr vpn.QrImg) ([]*http.Cookie, error) {
// todo: set http cancel, after timeout
if state, err := vpn.WaitQrState(qr.Sid); err != nil {
fmt.Println(err)
return nil, err
} else {
image := canvas.NewImageFromReader(qrImgReader, "qr.png")
image.FillMode = canvas.ImageFillOriginal
return image
if err = vpn.RedirectToLogin(client, cookies, qr.Config.AppID, state, qr.Config.RandToken); err != nil {
fmt.Println(err)
return nil, err
}
}

return nil, nil
}
19 changes: 19 additions & 0 deletions plugins/vpn/qr-code-docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
## Documentation of implementation of USTB vpn qr-code login

Starting from 2024/05, the ustb-vpn adds QR-code login.
The authentication process can be described as following:
1. In vpn login page's html code, (e.g., https://n.ustb.edu.cn/login), it contains an iframe of url:
"https://sis.ustb.edu.cn/connect/qrpage?appid=****&return_url=https%3A%2F%2Fn.ustb.edu.cn%2Flogin%3Fustb_sis%3Dtrue&rand_token=***&embed_flag=1",
In the iframe, it contains a QR-code (qr code image url: https://sis.ustb.edu.cn/connect/qrimg?sid=${SID}, and QR content is:
https://sis.ustb.edu.cn/auth?sid=${SID).
2. While waiting for WeChat QR scanning, it sends a requests of `https://sis.ustb.edu.cn/connect/state?sid=${SID}` (state request) and waits for its response.
3. If WeChat QR scanning is finished, the request in step 2. will response json data `{"state":200,"data":"39ff2a42e4474e70228b6337e159da8d"}`.
4. The `data` field in above json data is used as **auth_code**.
5. A callback redirect of url `https://n.ustb.edu.cn/login?ustb_sis=true&appid=***&auth_code=***&rand_token=***` will be generated and requested.
6. QR-code login finished.

In our go side implementation, we need:
1) Parse _appid_, _rand\_token_, and _SID_ in the iframe url or its html content.
2) Generate QR code images: use _SID_ to generate the QR code by using package github.com/skip2/go-qrcode.
3) Send state request and waits for its response. After its response arrives, we can obtain the _auth_code_.
4) Send callback redirect request to finish QR-code login.
148 changes: 101 additions & 47 deletions plugins/vpn/qr_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package vpn
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"github.com/skip2/go-qrcode"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
"io"
"io/ioutil"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
)
Expand Down Expand Up @@ -47,64 +49,64 @@ func (q *QRCodeImgLoaderConfig) genIframeUrl() (string, error) {
}

type QrImg struct {
imgUrl string
QrImg []byte
Config QRCodeImgLoaderConfig
Sid string // sis in ustb auth, can be parsed from image url.
}

// ParseQRCodeImgUrl uses ParseQRCodeHtmlUrl to get the iframe html,
// and then parse the html file to get final image url (contains SID).
// And set QrImg's imgUrl and sid.
func (i *QrImg) ParseQRCodeImgUrl() (string, error) {
iframeUrl, err := ParseQRCodeHtmlUrl()
// And set QrImg's fields of config and sid.
func (i *QrImg) ParseQRCodeImgUrl(client *http.Client, cookies *([]*http.Cookie)) error {
qrImgUrlConfig, err := ParseQRCodeHtmlUrl(client, cookies)
if err != nil {
return "", err
return err
}
htmlUri, err := url.Parse(iframeUrl)
i.Config = qrImgUrlConfig
// generate iframe url.
iframeUrl, err := qrImgUrlConfig.genIframeUrl()
if err != nil {
return "", err
return err
}

// make a http request of the iframe
response, err := http.Get(iframeUrl)
req, err := http.Get(iframeUrl)
if err != nil {
return "", err
return err
}

defer response.Body.Close()
defer req.Body.Close()

scanner := bufio.NewScanner(response.Body)
scanner := bufio.NewScanner(req.Body)
var imgUrl string
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, FindQrcodeImgTagRegex) { // first line to match start
// line e.g. <img id="qrimg" src="/connect/qrimg?sid=3894c5568dd1ef0f6434f426297a678d" height="90%" border="0">
subStr := strings.SplitN(line, "\"", 5)
if len(subStr) != 5 {
return "", errors.New("invalid format in qr image url parsing")
return errors.New("invalid format in qr image url parsing")
} else {
i.imgUrl = subStr[3]
imgUrl = subStr[3]
break
}
}
}

if err := scanner.Err(); err != nil {
return "", err
return err
}

// parse sid in qr image url.
sidStr := strings.SplitN(i.imgUrl, "=", 2)
sidStr := strings.SplitN(imgUrl, "=", 2)
if len(sidStr) != 2 {
return "", errors.New("invalid format in qr image url (sis) parsing")
return errors.New("invalid format in qr image url (sis) parsing")
} else {
i.Sid = sidStr[1]
}

// use htmlUri's host, schema
return fmt.Sprintf("%s://%s%s", htmlUri.Scheme, htmlUri.Host, i.imgUrl), nil
return nil
}

func ParseQRCodeHtmlUrl() (string, error) {
func ParseQRCodeHtmlUrl(client *http.Client, cookies *([]*http.Cookie)) (QRCodeImgLoaderConfig, error) {
// parse the html of `LOAD_IMG_URL` to get following object text:
//{
// id: "ustb-qrcode",
Expand All @@ -115,12 +117,24 @@ func ParseQRCodeHtmlUrl() (string, error) {
// width: "",
// height: ""
//}
response, err := http.Get(LoadImgUrl)
// make a http request of the iframe
req, err := http.NewRequest("GET", LoadImgUrl, nil)
if err != nil {
return QRCodeImgLoaderConfig{}, err
}

response, err := client.Do(req)
if err != nil {
return QRCodeImgLoaderConfig{}, err
}
defer response.Body.Close()

*cookies = response.Cookies() // save cookies
if len(*cookies) == 0 {
return QRCodeImgLoaderConfig{}, fmt.Errorf("no cookie found while getting iframe")
}
log.Println("COOKIE:", *cookies)

scanner := bufio.NewScanner(response.Body)
var findQrMatchStart = false
var qrConfigBuffer bytes.Buffer
Expand All @@ -140,47 +154,87 @@ func ParseQRCodeHtmlUrl() (string, error) {
}

if err := scanner.Err(); err != nil {
return "", err
return QRCodeImgLoaderConfig{}, err
}

fmt.Println("parsed qr code config:", qrConfigBuffer.String())
var qrImgUrlConfig QRCodeImgLoaderConfig
if err := yaml.Unmarshal(qrConfigBuffer.Bytes(), &qrImgUrlConfig); err != nil {
return QRCodeImgLoaderConfig{}, err
}

return qrImgUrlConfig, nil
}

func (i *QrImg) GenQrCodeContent() string {
return fmt.Sprintf(SisAuthPath+"/auth?sid=%s", i.Sid)
}

// GenQrImgUrl generate the url of qr code image
func (i *QrImg) GenQrImgUrl(imgUrl string) (string, error) {
iframeUrl, err := i.Config.genIframeUrl()
if err != nil {
return "", err
}

// generate iframe url.
if qrUrl, err := qrImgUrlConfig.genIframeUrl(); err != nil {
htmlUri, err := url.Parse(iframeUrl)
if err != nil {
return "", err
} else {
return qrUrl, nil
}
// use htmlUri's host, schema
return fmt.Sprintf("%s://%s%s", htmlUri.Scheme, htmlUri.Host, imgUrl), nil
}

type StateResponseAuthData struct {
State int `json:"state"`
Data string `json:"data"`
}

func (i *QrImg) GenQrImg() error {
imgContent := fmt.Sprintf(SisAuthPath+"/auth?sid=%s", i.Sid)
qrPng, err := qrcode.Encode(imgContent, qrcode.Medium, 256)
// WaitQrState waits qr state and get auth code (as return value)
func WaitQrState(sid string) (string, error) {
stateUrl := fmt.Sprintf(SisAuthPath+"/connect/state?sid=%s", sid)
response, err := http.Get(stateUrl)
if err != nil {
return err
return "", err
}
i.QrImg = qrPng
return nil
}

func LoadQrAuthImage() (io.Reader, error) {
var qr QrImg
if _, err := qr.ParseQRCodeImgUrl(); err != nil {
return nil, err
defer response.Body.Close()
body, err := ioutil.ReadAll(response.Body)
if err != nil {
return "", err
}

if err := qr.GenQrImg(); err != nil {
return nil, err
authData := StateResponseAuthData{}
if err := json.Unmarshal(body, &authData); err != nil {
return "", err
}
if authData.State != 200 {
return "", fmt.Errorf("auth status is not 200, but %d", authData.State)
}
buf := bytes.NewReader(qr.QrImg)
return buf, nil
return authData.Data, nil
}

// WaitQrState waits qr state and get auth code
func WaitQrState(imgUrl *url.URL) {
// get https://sis.ustb.edu.cn/connect/state?sid=bf1a027b75d6e21b351f81cdc1b739a2
func RedirectToLogin(client *http.Client, cookies []*http.Cookie, appid, authCode, randToken string) error {
loginUrl := fmt.Sprintf(LoadImgUrl+"?ustb_sis=true&appid=%s&auth_code=%s&rand_token=%s", appid, authCode, randToken)
// todo: generate login url based on return url.
log.Println("redirect url:", loginUrl)

req, err := http.NewRequest("GET", loginUrl, nil)
if err != nil {
return err
}

jar, _ := cookiejar.New(nil)
jar.SetCookies(req.URL, cookies)
client.Jar = jar

response, err := client.Do(req)
if err != nil {
return err
}
//, err := ioutil.ReadAll(response.Body)
//fmt.Println(string(b))

defer response.Body.Close()
return nil
}
Loading

0 comments on commit 5714fe9

Please sign in to comment.