simpleadmin-web 0.1.0: web panel for CS2-SimpleAdmin
This commit is contained in:
commit
4f38daf0e6
31 changed files with 6870 additions and 0 deletions
202
internal/web/auth.go
Normal file
202
internal/web/auth.go
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue