diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..adee0ed --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..4ac50b3 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,35 @@ +name: Go Tests + +on: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: true + matrix: + go-version: + - '1.16' + - '1.17' + - '1.18' + - '1.19' + - '1.20' + - '1.21' + - '1.22' + - '1.23' + - '1.24' + - '1.25' + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go-version }} + + - name: Run tests + run: go test -v ./... diff --git a/README.md b/README.md index e3378df..5224a62 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![Go Report Card](https://goreportcard.com/badge/github.com/TeddiO/GoSteamAuth)](https://goreportcard.com/report/github.com/TeddiO/GoSteamAuth) # GoSteamAuth -A small set of utility functions to quickly process Steam OpenID 2.0 logins. +A small set of utility functions to quickly process OpenID 2.0 logins for Steam. Cousin to the Python library designed to do the same thing: [pySteamSignIn](https://github.com/TeddiO/pySteamSignIn) Similar to why the Python edition was wrote there's no straightforwards Steam authentication flow for Go, so this exists to fulfil the same purpose. Another language, same idea! diff --git a/example/example.go b/example/example.go index de08258..ad250b6 100644 --- a/example/example.go +++ b/example/example.go @@ -29,18 +29,18 @@ func main() { // ExamplePage is just your average default page handler. In this example // We're just using the one liner to redirect the client and at the same time notify // the openid provider (Steam) where to return us. -func ExamplePage(resp http.ResponseWriter, req *http.Request) { - queryString := req.URL.Query() +func ExamplePage(w http.ResponseWriter, r *http.Request) { + queryString := r.URL.Query() if queryString.Get("login") == "true" { - gosteamauth.RedirectClient(resp, req, gosteamauth.BuildQueryString("http://localhost:8080/process")) + gosteamauth.RedirectClient(w, r, gosteamauth.BuildQueryString("http://localhost:8080/process")) return } loadingTemplate := template.New("index.html") loadingTemplate, _ = template.ParseFiles("index.html") - if err := loadingTemplate.Execute(resp, nil); err != nil { + if err := loadingTemplate.Execute(w, nil); err != nil { fmt.Println(err) } @@ -48,8 +48,8 @@ func ExamplePage(resp http.ResponseWriter, req *http.Request) { // ProcessSteamLogin is where the real magic happens in terms of validation. // As long as isValid is true we should always be able to trust the SteamID64 returned. -func ProcessSteamLogin(resp http.ResponseWriter, req *http.Request) { - queryString, _ := url.ParseQuery(req.URL.RawQuery) +func ProcessSteamLogin(w http.ResponseWriter, r *http.Request) { + queryString, _ := url.ParseQuery(r.URL.RawQuery) // Due to ParseQuery() returning a url.Values in form map[string][]string we're going to // convert that data structure to map[string]string so we can validate. @@ -57,7 +57,9 @@ func ProcessSteamLogin(resp http.ResponseWriter, req *http.Request) { steamID64, isValid, err := gosteamauth.ValidateResponse(queryMap) if err != nil { - fmt.Fprintf(resp, "Failed to log in\nError: %s", err) + // You wouldn't typically raise the error to the end user like this, but for + // demonstration purposes we're attaching the error to the response + fmt.Fprintf(w, "Failed to log in\nError: %s", err) return } @@ -65,9 +67,9 @@ func ProcessSteamLogin(resp http.ResponseWriter, req *http.Request) { // client on away from this page, set cookies / sessions and so on. if isValid { - fmt.Fprintf(resp, "Successfully logged in!\nSteamID: %s", steamID64) + fmt.Fprintf(w, "Successfully logged in!\nSteamID: %s", steamID64) } else { - io.WriteString(resp, "Failed to log in.") + io.WriteString(w, "Failed to log in.") } } diff --git a/src/gosteam.go b/src/gosteam.go index 41ef85f..2e078e3 100644 --- a/src/gosteam.go +++ b/src/gosteam.go @@ -1,148 +1,162 @@ package gosteamauth import ( - "fmt" - "io/ioutil" + "errors" + "io" "log" "net/http" "net/url" - "regexp" + "os" + "strconv" "strings" "time" ) -var validCredRx *regexp.Regexp -var steamRx *regexp.Regexp -var provider string = "https://steamcommunity.com/openid/login" - -func init() { - validCredRx = regexp.MustCompile("is_valid:true") - steamRx = regexp.MustCompile(`https://steamcommunity\.com/openid/id/(\d+)`) -} - -// StringMapToString is a utility function that aims to efficiently build a query string -// with a tiny footprint. theMap is expected to be a map of key strings with a value type of a string. -func StringMapToString(theMap map[string]string) string { - - mapLength := len(theMap) - strSeparator := "&" - i := 1 - - var builder strings.Builder - builder.Grow(66) // We already roughly know our base size. +const ( + provider = "https://steamcommunity.com/openid/login" + steamIDPrefix = "https://steamcommunity.com/openid/id/" + openIDNS = "http://specs.openid.net/auth/2.0" + identifierSel = "http://specs.openid.net/auth/2.0/identifier_select" +) - for k, v := range theMap { +var HTTPTimeout = 15 * time.Second - if i == mapLength { - strSeparator = "" +func init() { + if v := os.Getenv("STEAM_AUTH_HTTP_TIMEOUT"); v != "" { + if secs, err := strconv.Atoi(v); err == nil && secs > 0 { + HTTPTimeout = time.Duration(secs) * time.Second } - - i++ - - fmt.Fprintf(&builder, "%s=%s%s", k, url.QueryEscape(v), strSeparator) } +} - return builder.String() +type httpDoer interface { + Do(*http.Request) (*http.Response, error) } -// BuildQueryString is more or less building up a query string to be passed when reaching -// Steam's openid 2.0 provider (or technically any openid 2.0 provider). We only care -// that the Scheme is either http or https. Any other validation should really be done -// before using this function. -func BuildQueryString(responsePath string) string { +var httpClient httpDoer = &http.Client{ + Timeout: HTTPTimeout, +} - if responsePath[0:4] != "http" { +// BuildQueryString uses a url.Values map to correctly structure the paramters to send to Steam +// Strictly speaking we only care that the Scheme is either http or https. +// Any other validation should really be done before using this function. +func BuildQueryString(responsePath string) string { + if !strings.HasPrefix(responsePath, "http") { log.Fatal("http was not found in the responsePath!") } - if responsePath[4:5] != "s" { + if !strings.HasPrefix(responsePath, "https") { log.Println("https isn't being used! Is this intentional?") } - // Even though the below URLs no longer function, the oauth 2.0 process formally calls - // for them and Valve actively checks for their presence. - openIdParameters := map[string]string{ - "openid.mode": "checkid_setup", - "openid.return_to": responsePath, - "openid.realm": responsePath, - "openid.ns": "http://specs.openid.net/auth/2.0", - "openid.identity": "http://specs.openid.net/auth/2.0/identifier_select", - "openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select", - } + values := url.Values{} + values.Set("openid.mode", "checkid_setup") + values.Set("openid.return_to", responsePath) + values.Set("openid.realm", responsePath) + values.Set("openid.ns", openIDNS) + values.Set("openid.identity", identifierSel) + values.Set("openid.claimed_id", identifierSel) - return StringMapToString(openIdParameters) + return values.Encode() } // ValidateResponse is the real chunk of work that goes on. When the client comes back to our site -// we need to take what they give us in the query string and hit up the openid 2.0 provider directly -// to verify what we're being provided with is well, valid. +// we need to take what they give us in the query string and hit up Steam directly +// to verify what we're being provided with is valid. // If we end up with "is_valid:true" response from the Steam then isValid will always return true. -// In any other situation (credential failure, error etc) isValid will always return false. -// Takes a map[string]string to be agnostic among various http clients that exist out there +// In any other situation (credential failure, error etc) isValid will always return false and we aim +// to provide a descriptive error where possible. func ValidateResponse(results map[string]string) (steamID64 string, isValid bool, err error) { - - openIdValidation := map[string]string{ - "openid.assoc_handle": results["openid.assoc_handle"], - "openid.signed": results["openid.signed"], - "openid.sig": results["openid.sig"], - "openid.ns": results["openid.ns"], - "openid.mode": "check_authentication", - } + values := url.Values{} + values.Set("openid.mode", "check_authentication") + values.Set("openid.assoc_handle", results["openid.assoc_handle"]) + values.Set("openid.signed", results["openid.signed"]) + values.Set("openid.sig", results["openid.sig"]) + values.Set("openid.ns", results["openid.ns"]) signedParams := strings.Split(results["openid.signed"], ",") - - for _, value := range signedParams { - item := fmt.Sprintf("openid.%s", value) - if _, exists := openIdValidation[item]; !exists { - openIdValidation[item] = results[item] - } + for _, key := range signedParams { + fullKey := "openid." + key + values.Set(fullKey, results[fullKey]) } - urlObj, err := url.Parse(provider) + reqURL, err := url.Parse(provider) if err != nil { return "", false, err } + reqURL.RawQuery = values.Encode() - urlObj.RawQuery = StringMapToString(openIdValidation) - - httpClient := &http.Client{ - Timeout: 10 * time.Second, + req, err := http.NewRequest("GET", reqURL.String(), nil) + if err != nil { + return "", false, err } - validationResp, err := httpClient.Get(urlObj.String()) + validationResp, err := httpClient.Do(req) if err != nil { - log.Printf("Failed to validate %s. Error: %s ", results["openid.claimed_id"], err) + log.Printf("Failed to validate %s. Error: %s", results["openid.claimed_id"], err) return "", false, err } - defer validationResp.Body.Close() - returnedBytes, err := ioutil.ReadAll(validationResp.Body) + + returnedBytes, err := io.ReadAll(validationResp.Body) if err != nil { return "", false, err } - if validCredRx.MatchString(string(returnedBytes)) == true { - return steamRx.FindStringSubmatch(results["openid.claimed_id"])[1], true, nil + lines := strings.Split(string(returnedBytes), "\n") + + steamSaysValid := false + for _, line := range lines { + if line == "" { + continue + } + + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + + if parts[0] == "is_valid" && parts[1] == "true" { + steamSaysValid = true + break + } + } + + if !steamSaysValid { + return "", false, errors.New("openid validation failed: steam returned is_valid:false") + } + + claimedID := results["openid.claimed_id"] + identity := results["openid.identity"] + + if claimedID == "" || identity == "" { + return "", false, errors.New("openid validation failed: missing claimed_id or identity") + } + + if claimedID != identity { + return "", false, errors.New("openid validation failed: claimed_id and identity mismatch") } - return "", false, nil + if !strings.HasPrefix(claimedID, steamIDPrefix) { + return "", false, errors.New("openid validation failed: identifier is not a steam openid") + } + + steamID := strings.TrimPrefix(claimedID, steamIDPrefix) + return steamID, true, nil } // RedirectClient is a helper function that does the redirection to Steam with the -// correct properties on our behalf. Pass it the appropriate http request / response objects -// alongside the queryString and it'll get the user to the right place -func RedirectClient(response http.ResponseWriter, request *http.Request, queryString string) { - returnUrlObject, _ := url.Parse(provider) - returnUrlObject.RawQuery = queryString - - response.Header().Add("Content-Type", "application/x-www-form-urlencoded") - http.Redirect(response, request, returnUrlObject.String(), 303) - return +// correct properties on our behalf. +func RedirectClient(w http.ResponseWriter, r *http.Request, queryString string) { + u, _ := url.Parse(provider) + u.RawQuery = queryString + + w.Header().Set("Content-Type", "application/x-www-form-urlencoded") + http.Redirect(w, r, u.String(), http.StatusSeeOther) } // ValuesToMap is a boilerplate function designed to convert the results of a url.Values // in to a readable map[string]string for ValidateResponse. -// We don't get duplicate query keys supplied normally - but we'll always take the first one anyways func ValuesToMap(fakeMap url.Values) map[string]string { returnMap := map[string]string{} for k, v := range fakeMap { diff --git a/src/gosteamauth_test.go b/src/gosteamauth_test.go new file mode 100644 index 0000000..100dc47 --- /dev/null +++ b/src/gosteamauth_test.go @@ -0,0 +1,116 @@ +package gosteamauth + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" +) + +type fakeHTTPClient struct { + body string + err error +} + +func (f *fakeHTTPClient) Do(req *http.Request) (*http.Response, error) { + if f.err != nil { + return nil, f.err + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(f.body)), + }, nil +} + +func validResults() map[string]string { + steamID := "12345678901234567" + claimed := steamIDPrefix + steamID + + return map[string]string{ + "openid.assoc_handle": "assoc", + "openid.signed": "claimed_id", + "openid.sig": "sig", + "openid.ns": openIDNS, + "openid.claimed_id": claimed, + "openid.identity": claimed, + } +} + +func TestValidateResponse_Valid(t *testing.T) { + oldClient := httpClient + httpClient = &fakeHTTPClient{body: "is_valid:true\n"} + defer func() { httpClient = oldClient }() + + steamID, ok, err := ValidateResponse(validResults()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatalf("expected valid response") + } + if steamID != "12345678901234567" { + t.Fatalf("unexpected steamID: %s", steamID) + } +} + +func TestValidateResponse_Invalid(t *testing.T) { + oldClient := httpClient + httpClient = &fakeHTTPClient{body: "is_valid:false\n"} + defer func() { httpClient = oldClient }() + + _, ok, _ := ValidateResponse(validResults()) + if ok { + t.Fatalf("expected invalid response") + } +} + +func TestValidateResponse_NetworkErrorFailsClosed(t *testing.T) { + oldClient := httpClient + httpClient = &fakeHTTPClient{err: errors.New("network error")} + defer func() { httpClient = oldClient }() + + _, ok, err := ValidateResponse(validResults()) + if ok { + t.Fatalf("expected network error to fail closed") + } + if err == nil { + t.Fatalf("expected error to be returned on network failure") + } +} + +func TestValidateResponse_TimeoutFailsClosed(t *testing.T) { + oldClient := httpClient + httpClient = &fakeHTTPClient{err: context.DeadlineExceeded} + defer func() { httpClient = oldClient }() + + _, ok, err := ValidateResponse(validResults()) + if ok { + t.Fatalf("expected timeout to fail closed") + } + if err == nil { + t.Fatalf("expected timeout error to be returned") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected context deadline exceeded, got: %v", err) + } +} + +func TestValidateResponse_IdentityMismatchFailsClosed(t *testing.T) { + oldClient := httpClient + httpClient = &fakeHTTPClient{body: "is_valid:true\n"} + defer func() { httpClient = oldClient }() + + results := validResults() + results["openid.identity"] = steamIDPrefix + "00000000000000000" + + _, ok, err := ValidateResponse(results) + if ok { + t.Fatalf("expected identity mismatch to fail closed") + } + if err == nil { + t.Fatalf("expected error on identity mismatch") + } +}