From 355d07433fa4bc32b169bcee630feb3c7da0cacd Mon Sep 17 00:00:00 2001 From: genshen Date: Thu, 13 Jun 2024 21:53:12 +0800 Subject: [PATCH] feat(vpn-qr-code): add support of qr-code vpn login and qr-code login 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 --- client-ui/main.go | 1 + client-ui/qr_login_ui.go | 77 ++++++++++++++++--- plugins/vpn/qr-code-docs.md | 19 +++++ plugins/vpn/qr_code.go | 148 ++++++++++++++++++++++++------------ plugins/vpn/vpn.go | 85 ++++++++++++++++----- 5 files changed, 255 insertions(+), 75 deletions(-) create mode 100644 plugins/vpn/qr-code-docs.md diff --git a/client-ui/main.go b/client-ui/main.go index 3f0f647..014682d 100644 --- a/client-ui/main.go +++ b/client-ui/main.go @@ -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() { diff --git a/client-ui/qr_login_ui.go b/client-ui/qr_login_ui.go index fb22eaf..8b7ac2e 100644 --- a/client-ui/qr_login_ui.go +++ b/client-ui/qr_login_ui.go @@ -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 } diff --git a/plugins/vpn/qr-code-docs.md b/plugins/vpn/qr-code-docs.md new file mode 100644 index 0000000..c7e4bf7 --- /dev/null +++ b/plugins/vpn/qr-code-docs.md @@ -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. diff --git a/plugins/vpn/qr_code.go b/plugins/vpn/qr_code.go index 40aba98..b2f87f2 100644 --- a/plugins/vpn/qr_code.go +++ b/plugins/vpn/qr_code.go @@ -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" ) @@ -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. 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", @@ -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 @@ -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 } diff --git a/plugins/vpn/vpn.go b/plugins/vpn/vpn.go index b62e810..06fb15a 100644 --- a/plugins/vpn/vpn.go +++ b/plugins/vpn/vpn.go @@ -29,10 +29,15 @@ type UstbVpnPasswdAuth struct { Password string } +type QrCodeAuth interface { + ShowQrCodeAndWait(client *http.Client, cookies []*http.Cookie, qrCode QrImg) ([]*http.Cookie, error) +} + type UstbVpn struct { Enable bool AuthMethod int // value of VpnAuthMethodPasswd or VpnAuthMethodQRCode PasswdAuth UstbVpnPasswdAuth + QrCodeAuth QrCodeAuth TargetVpn string HostEncrypt bool ForceLogout bool @@ -57,11 +62,25 @@ func NewUstbVpnCli() *UstbVpn { return &vpn } -// implementation of interface RequestPlugin +// BeforeRequest is implementation of interface RequestPlugin +// In the UstbVpn plugin, we use it for vpn auth (password auth and QR code auth). func (v *UstbVpn) BeforeRequest(hc *http.Client, transport *http.Transport, url *url.URL, header *http.Header) error { if !v.Enable { return nil } + + if v.AuthMethod == VpnAuthMethodPasswd { + return v.PasswordAuthForCookie(hc, transport, url) + } else if v.AuthMethod == VpnAuthMethodQRCode { + return v.QrCodeAuthForCookie(hc, transport, url) + } + return fmt.Errorf("unknown auth method") +} + +// PasswordAuthForCookie send password to vpn server for auth, +// and keep cookie for websocket request. +// It can support cli and gui client. +func (v *UstbVpn) PasswordAuthForCookie(hc *http.Client, transport *http.Transport, url *url.URL) error { // read username and password if they are empty. if v.PasswdAuth.Username == "" { reader := bufio.NewReader(os.Stdin) @@ -86,26 +105,54 @@ func (v *UstbVpn) BeforeRequest(hc *http.Client, transport *http.Transport, url if cookies, err := al.vpnLogin(v.PasswdAuth.Username, v.PasswdAuth.Password); err != nil { return fmt.Errorf("error vpn login: %w", err) } else { - // In vpnLogin, we can test https support. - // If the vpn support https, we can set transport.SkipTLSVerify if necessary. - if al.SSLEnabled && v.ConnOptions.SkipTLSVerify { - transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} - } + return v.SetWebSocketCookies(al.SSLEnabled, hc, transport, url, cookies) + } +} - // change target url. - vpnUrl(v.HostEncrypt, v.TargetVpn, al.SSLEnabled, url) - log.Infof("real url: %s, ssl enabled:%t", url.String(), al.SSLEnabled) +func (v *UstbVpn) SetWebSocketCookies(SSLEnabled bool, hc *http.Client, transport *http.Transport, url *url.URL, cookies []*http.Cookie) error { + // In vpnLogin, we can test https support. + // If the vpn support https, we can set transport.SkipTLSVerify if necessary. + if SSLEnabled && v.ConnOptions.SkipTLSVerify { + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } - if jar, err := cookiejar.New(nil); err != nil { - return err - } else { - cookieUrl := *url - // replace url scheme "wss" to "https" and "ws"to "http" - cookieUrl.Scheme = strings.Replace(cookieUrl.Scheme, "ws", "http", 1) - jar.SetCookies(&cookieUrl, cookies) - hc.Jar = jar - return nil - } + // change target url. + vpnUrl(v.HostEncrypt, v.TargetVpn, SSLEnabled, url) + log.Infof("real url: %s, ssl enabled:%t", url.String(), SSLEnabled) + + if jar, err := cookiejar.New(nil); err != nil { + return err + } else { + cookieUrl := *url + // replace url scheme "wss" to "https" and "ws"to "http" + cookieUrl.Scheme = strings.Replace(cookieUrl.Scheme, "ws", "http", 1) + jar.SetCookies(&cookieUrl, cookies) + hc.Jar = jar + return nil + } +} + +func (v *UstbVpn) QrCodeAuthForCookie(hc *http.Client, transport *http.Transport, url *url.URL) error { + if v.QrCodeAuth == nil { + return fmt.Errorf("QrCodeAuth is not configed") + } + // Note: todo: check https enabled for the vpn host + // currently, it only support https schema. + authHttpClient := http.Client{} + var cookies []*http.Cookie + + // step1: send request to get a frame and SID in the frame. + var qr QrImg + if err := qr.ParseQRCodeImgUrl(&authHttpClient, &cookies); err != nil { + return err + } + + // step2: pass qr code content to show qr code in ui and wait for scan status. + if _, err := v.QrCodeAuth.ShowQrCodeAndWait(&authHttpClient, cookies, qr); err != nil { + return err + } else { + // pass cookie to websocket + return v.SetWebSocketCookies(true, hc, transport, url, cookies) } }