package web import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "io/fs" "log/slog" "net/http" "net/url" "strconv" "strings" "time" "git.zio.sh/cs2/simpleadmin-web/internal/steam" ) const ( sessionCookie = "saw_session" sessionTTL = 7 * 24 * time.Hour // Browsers can't send this header cross-site without a CORS preflight, which is never granted. csrfHeader = "X-Requested-With" csrfValue = "simpleadmin-web" ) func (s *Server) sign(payload string) string { m := hmac.New(sha256.New, s.cfg.SessionSecret) m.Write([]byte(payload)) return base64.RawURLEncoding.EncodeToString(m.Sum(nil)) } func (s *Server) setSession(w http.ResponseWriter, steamid string) { payload := steamid + "|" + strconv.FormatInt(time.Now().Add(sessionTTL).Unix(), 10) enc := base64.RawURLEncoding.EncodeToString([]byte(payload)) http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: enc + "." + s.sign(payload), Path: "/", MaxAge: int(sessionTTL.Seconds()), HttpOnly: true, Secure: s.secure, // Lax, not Strict: the sign-in redirect chain starts on steamcommunity.com. SameSite: http.SameSiteLaxMode, }) } // sessionSteamID returns the signed-in SteamID64, or "". func (s *Server) sessionSteamID(r *http.Request) string { c, err := r.Cookie(sessionCookie) if err != nil { return "" } enc, sig, ok := strings.Cut(c.Value, ".") if !ok { return "" } raw, err := base64.RawURLEncoding.DecodeString(enc) if err != nil { return "" } payload := string(raw) if !hmac.Equal([]byte(sig), []byte(s.sign(payload))) { return "" } steamid, exp, ok := strings.Cut(payload, "|") if !ok { return "" } unix, err := strconv.ParseInt(exp, 10, 64) if err != nil || time.Now().Unix() > unix || !steam.ValidID(steamid) { return "" } return steamid } func (s *Server) callbackURL() string { return s.cfg.BaseURL + "/auth/callback" } func (s *Server) login(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, steam.LoginURL(s.cfg.BaseURL+"/", s.callbackURL()), http.StatusFound) } func (s *Server) callback(w http.ResponseWriter, r *http.Request) { id, err := steam.Verify(r.Context(), s.http, s.callbackURL(), r.URL.Query()) if err != nil { slog.Info("steam sign-in failed", "err", err) http.Redirect(w, r, "/admin?signin="+url.QueryEscape(err.Error()), http.StatusFound) return } s.setSession(w, id) http.Redirect(w, r, "/admin", http.StatusFound) } func (s *Server) logout(w http.ResponseWriter, r *http.Request) { if !s.sameOrigin(r) { fail(w, http.StatusForbidden, "Sign out from the panel itself.") return } http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteLaxMode}) writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } // sameOrigin rejects cross-site writes: the custom header must be present, and so must a matching // Origin when the browser sends one. func (s *Server) sameOrigin(r *http.Request) bool { if r.Header.Get(csrfHeader) != csrfValue { return false } if o := r.Header.Get("Origin"); o != "" && o != strings.TrimRight(s.cfg.BaseURL, "/") { return false } return true } // identity returns the signed-in staff member, or nil for anyone else. Staff means an unexpired // SimpleAdmin admin on this server with @css/generic (or @css/root), read fresh every 15 seconds, // so removing someone in SimpleAdmin signs them out of the panel too. func (s *Server) identity(r *http.Request) (*Identity, *staffIndex, error) { steamid := s.sessionSteamID(r) if steamid == "" { return nil, nil, nil } x, err := s.staffIdx(r.Context()) if err != nil { return nil, nil, err } a, ok := x.bySteam[steamid] if !ok { return nil, x, nil } id := &Identity{SteamID: steamid, Name: a.Name, Flags: x.flags(a), Immunity: x.immunity(steamid)} if len(a.RowIDs) > 0 { id.RowID = a.RowIDs[0] } if !id.Has("@css/generic") { return nil, x, nil } return id, x, nil } type adminHandler func(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) func (s *Server) requireStaff(h adminHandler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && !s.sameOrigin(r) { fail(w, http.StatusForbidden, "This request didn't come from the panel.") return } id, x, err := s.identity(r) if err != nil { internal(w, r, err) return } if id == nil { fail(w, http.StatusUnauthorized, "Sign in with a staff account to do this.") return } h(w, r, id, x) } } // adminPage serves the staff panel to staff, and sends everyone else to sign in. func (s *Server) adminPage(static fs.FS) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id, _, err := s.identity(r) if err != nil { slog.Error("admin page", "err", err) } if id == nil { http.ServeFileFS(w, r, static, "signin.html") return } http.ServeFileFS(w, r, static, "admin.html") } } type meView struct { SignedIn bool `json:"signedIn"` Staff bool `json:"staff"` SteamID string `json:"steamid,omitempty"` Name string `json:"name,omitempty"` Rank *rankView `json:"rank,omitempty"` Perms []string `json:"perms,omitempty"` Immunity int `json:"immunity,omitempty"` Site string `json:"site"` } func (s *Server) me(w http.ResponseWriter, r *http.Request) { v := meView{Site: s.site.Title} id, x, err := s.identity(r) if err != nil { internal(w, r, err) return } if steamid := s.sessionSteamID(r); steamid != "" { v.SignedIn, v.SteamID = true, steamid } if id != nil { v.Staff, v.Name, v.Rank, v.Immunity = true, id.Name, s.rankOf(x, id.SteamID), id.Immunity v.Perms = id.Flags } writeJSON(w, http.StatusOK, v) }