202 lines
5.6 KiB
Go
202 lines
5.6 KiB
Go
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)
|
|
}
|