From 3e90db888d089601ef4819435bb2d8b6881f2a2d Mon Sep 17 00:00:00 2001 From: cialloo Date: Tue, 7 Oct 2025 17:43:22 +0800 Subject: [PATCH] Implement Steam login functionality with OpenID integration --- api/Authenticator.api | 36 +++++ src/etc/authenticator.yaml | 3 + src/internal/config/config.go | 5 + src/internal/handler/routes.go | 12 ++ .../handler/steamlogincallbackhandler.go | 29 ++++ src/internal/handler/steamlogininithandler.go | 29 ++++ src/internal/logic/steamlogincallbacklogic.go | 69 ++++++++ src/internal/logic/steamlogininitlogic.go | 40 +++++ src/internal/types/types.go | 26 +++ src/internal/utils/steamauth/steamopenid.go | 148 ++++++++++++++++++ 10 files changed, 397 insertions(+) create mode 100644 src/internal/handler/steamlogincallbackhandler.go create mode 100644 src/internal/handler/steamlogininithandler.go create mode 100644 src/internal/logic/steamlogincallbacklogic.go create mode 100644 src/internal/logic/steamlogininitlogic.go create mode 100644 src/internal/utils/steamauth/steamopenid.go diff --git a/api/Authenticator.api b/api/Authenticator.api index 9d716a6..5ee473a 100644 --- a/api/Authenticator.api +++ b/api/Authenticator.api @@ -13,6 +13,28 @@ type ( PingResp { ok bool `json:"ok"` } + // Steam Login Types + SteamLoginInitReq {} + SteamLoginInitResp { + RedirectUrl string `json:"redirectUrl"` + } + SteamLoginCallbackReq { + OpenidMode string `form:"openid.mode"` + OpenidNs string `form:"openid.ns"` + OpenidOpEndpoint string `form:"openid.op_endpoint"` + OpenidClaimedId string `form:"openid.claimed_id"` + OpenidIdentity string `form:"openid.identity"` + OpenidReturnTo string `form:"openid.return_to"` + OpenidResponseNonce string `form:"openid.response_nonce"` + OpenidAssocHandle string `form:"openid.assoc_handle"` + OpenidSigned string `form:"openid.signed"` + OpenidSig string `form:"openid.sig"` + } + SteamLoginCallbackResp { + Success bool `json:"success"` + SteamId string `json:"steamId,omitempty"` + Message string `json:"message,omitempty"` + } ) @server ( @@ -25,5 +47,19 @@ service Authenticator { ) @handler pingHandler get /ping (PingReq) returns (PingResp) + + @doc ( + summary: "Initiate Steam login" + description: "Redirects user to Steam OpenID login page" + ) + @handler steamLoginInitHandler + get /steam/login (SteamLoginInitReq) returns (SteamLoginInitResp) + + @doc ( + summary: "Steam login callback" + description: "Handles the callback from Steam after user authentication" + ) + @handler steamLoginCallbackHandler + get /steam/callback (SteamLoginCallbackReq) returns (SteamLoginCallbackResp) } diff --git a/src/etc/authenticator.yaml b/src/etc/authenticator.yaml index fd73eb0..ffa9f16 100644 --- a/src/etc/authenticator.yaml +++ b/src/etc/authenticator.yaml @@ -1,3 +1,6 @@ Name: Authenticator Host: 0.0.0.0 Port: 8888 + +Steam: + CallbackURL: https://www.cialloo.com/api/authenticator/steam/callback diff --git a/src/internal/config/config.go b/src/internal/config/config.go index 8da153d..39d182b 100644 --- a/src/internal/config/config.go +++ b/src/internal/config/config.go @@ -4,4 +4,9 @@ import "github.com/zeromicro/go-zero/rest" type Config struct { rest.RestConf + Steam SteamConfig +} + +type SteamConfig struct { + CallbackURL string } diff --git a/src/internal/handler/routes.go b/src/internal/handler/routes.go index d4b6676..d88bb2b 100644 --- a/src/internal/handler/routes.go +++ b/src/internal/handler/routes.go @@ -20,6 +20,18 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/ping", Handler: pingHandler(serverCtx), }, + { + // Steam login callback + Method: http.MethodGet, + Path: "/steam/callback", + Handler: steamLoginCallbackHandler(serverCtx), + }, + { + // Initiate Steam login + Method: http.MethodGet, + Path: "/steam/login", + Handler: steamLoginInitHandler(serverCtx), + }, }, rest.WithPrefix("/api/authenticator"), ) diff --git a/src/internal/handler/steamlogincallbackhandler.go b/src/internal/handler/steamlogincallbackhandler.go new file mode 100644 index 0000000..8f4cd98 --- /dev/null +++ b/src/internal/handler/steamlogincallbackhandler.go @@ -0,0 +1,29 @@ +package handler + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "src/internal/logic" + "src/internal/svc" + "src/internal/types" +) + +// Steam login callback +func steamLoginCallbackHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.SteamLoginCallbackReq + if err := httpx.Parse(r, &req); err != nil { + httpx.ErrorCtx(r.Context(), w, err) + return + } + + l := logic.NewSteamLoginCallbackLogic(r.Context(), svcCtx) + resp, err := l.SteamLoginCallback(&req) + if err != nil { + httpx.ErrorCtx(r.Context(), w, err) + } else { + httpx.OkJsonCtx(r.Context(), w, resp) + } + } +} diff --git a/src/internal/handler/steamlogininithandler.go b/src/internal/handler/steamlogininithandler.go new file mode 100644 index 0000000..ea44398 --- /dev/null +++ b/src/internal/handler/steamlogininithandler.go @@ -0,0 +1,29 @@ +package handler + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "src/internal/logic" + "src/internal/svc" + "src/internal/types" +) + +// Initiate Steam login +func steamLoginInitHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.SteamLoginInitReq + if err := httpx.Parse(r, &req); err != nil { + httpx.ErrorCtx(r.Context(), w, err) + return + } + + l := logic.NewSteamLoginInitLogic(r.Context(), svcCtx) + resp, err := l.SteamLoginInit(&req) + if err != nil { + httpx.ErrorCtx(r.Context(), w, err) + } else { + httpx.OkJsonCtx(r.Context(), w, resp) + } + } +} diff --git a/src/internal/logic/steamlogincallbacklogic.go b/src/internal/logic/steamlogincallbacklogic.go new file mode 100644 index 0000000..976c4f0 --- /dev/null +++ b/src/internal/logic/steamlogincallbacklogic.go @@ -0,0 +1,69 @@ +package logic + +import ( + "context" + "fmt" + + "src/internal/svc" + "src/internal/types" + "src/internal/utils/steamauth" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SteamLoginCallbackLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Steam login callback +func NewSteamLoginCallbackLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SteamLoginCallbackLogic { + return &SteamLoginCallbackLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *SteamLoginCallbackLogic) SteamLoginCallback(req *types.SteamLoginCallbackReq) (resp *types.SteamLoginCallbackResp, err error) { + // Convert request to map for Steam validation + paramsMap := map[string]string{ + "openid.mode": req.OpenidMode, + "openid.ns": req.OpenidNs, + "openid.op_endpoint": req.OpenidOpEndpoint, + "openid.claimed_id": req.OpenidClaimedId, + "openid.identity": req.OpenidIdentity, + "openid.return_to": req.OpenidReturnTo, + "openid.response_nonce": req.OpenidResponseNonce, + "openid.assoc_handle": req.OpenidAssocHandle, + "openid.signed": req.OpenidSigned, + "openid.sig": req.OpenidSig, + } + + // Validate the response with Steam + steamID, isValid, err := steamauth.ValidateResponse(paramsMap) + if err != nil { + l.Logger.Errorf("Steam validation error: %v", err) + return &types.SteamLoginCallbackResp{ + Success: false, + Message: fmt.Sprintf("Validation error: %v", err), + }, nil + } + + if !isValid { + l.Logger.Info("Steam validation failed: invalid credentials") + return &types.SteamLoginCallbackResp{ + Success: false, + Message: "Invalid Steam credentials", + }, nil + } + + l.Logger.Infof("Steam login successful for Steam ID: %s", steamID) + + return &types.SteamLoginCallbackResp{ + Success: true, + SteamId: steamID, + Message: "Login successful", + }, nil +} diff --git a/src/internal/logic/steamlogininitlogic.go b/src/internal/logic/steamlogininitlogic.go new file mode 100644 index 0000000..bd03880 --- /dev/null +++ b/src/internal/logic/steamlogininitlogic.go @@ -0,0 +1,40 @@ +package logic + +import ( + "context" + + "src/internal/svc" + "src/internal/types" + "src/internal/utils/steamauth" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SteamLoginInitLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Initiate Steam login +func NewSteamLoginInitLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SteamLoginInitLogic { + return &SteamLoginInitLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *SteamLoginInitLogic) SteamLoginInit(req *types.SteamLoginInitReq) (resp *types.SteamLoginInitResp, err error) { + // Get the callback URL from config + callbackURL := l.svcCtx.Config.Steam.CallbackURL + + // Build the Steam OpenID redirect URL + redirectURL := steamauth.GetRedirectURL(callbackURL) + + l.Logger.Infof("Initiating Steam login with callback URL: %s", callbackURL) + + return &types.SteamLoginInitResp{ + RedirectUrl: redirectURL, + }, nil +} diff --git a/src/internal/types/types.go b/src/internal/types/types.go index 51205f1..f8a4519 100644 --- a/src/internal/types/types.go +++ b/src/internal/types/types.go @@ -9,3 +9,29 @@ type PingReq struct { type PingResp struct { Ok bool `json:"ok"` } + +type SteamLoginCallbackReq struct { + OpenidMode string `form:"openid.mode"` + OpenidNs string `form:"openid.ns"` + OpenidOpEndpoint string `form:"openid.op_endpoint"` + OpenidClaimedId string `form:"openid.claimed_id"` + OpenidIdentity string `form:"openid.identity"` + OpenidReturnTo string `form:"openid.return_to"` + OpenidResponseNonce string `form:"openid.response_nonce"` + OpenidAssocHandle string `form:"openid.assoc_handle"` + OpenidSigned string `form:"openid.signed"` + OpenidSig string `form:"openid.sig"` +} + +type SteamLoginCallbackResp struct { + Success bool `json:"success"` + SteamId string `json:"steamId,omitempty"` + Message string `json:"message,omitempty"` +} + +type SteamLoginInitReq struct { +} + +type SteamLoginInitResp struct { + RedirectUrl string `json:"redirectUrl"` +} diff --git a/src/internal/utils/steamauth/steamopenid.go b/src/internal/utils/steamauth/steamopenid.go new file mode 100644 index 0000000..ad5c422 --- /dev/null +++ b/src/internal/utils/steamauth/steamopenid.go @@ -0,0 +1,148 @@ +package steamauth + +import ( + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "regexp" + "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. + + for k, v := range theMap { + + if i == mapLength { + strSeparator = "" + } + + i++ + + fmt.Fprintf(&builder, "%s=%s%s", k, url.QueryEscape(v), strSeparator) + } + + return builder.String() +} + +// 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 { + + if responsePath[0:4] != "http" { + log.Fatal("http was not found in the responsePath!") + } + + if responsePath[4:5] != "s" { + 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", + } + + return StringMapToString(openIdParameters) +} + +// 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. +// 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 +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", + } + + 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] + } + } + + urlObj, err := url.Parse(provider) + if err != nil { + return "", false, err + } + + urlObj.RawQuery = StringMapToString(openIdValidation) + + httpClient := &http.Client{ + Timeout: 10 * time.Second, + } + + validationResp, err := httpClient.Get(urlObj.String()) + if err != nil { + 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) + if err != nil { + return "", false, err + } + + if validCredRx.MatchString(string(returnedBytes)) == true { + return steamRx.FindStringSubmatch(results["openid.claimed_id"])[1], true, nil + } + + return "", false, nil +} + +// GetRedirectURL builds the Steam OpenID redirect URL +func GetRedirectURL(callbackURL string) string { + urlObj, _ := url.Parse(provider) + urlObj.RawQuery = BuildQueryString(callbackURL) + return urlObj.String() +} + +// 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 { + returnMap[k] = v[0] + } + + return returnMap +}