136 lines
4.5 KiB
Go
136 lines
4.5 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const sessionCookieName = "telesrv_admin_session"
|
|
|
|
// csrfCookieName is the double-submit cookie. It is deliberately NOT HttpOnly:
|
|
// the panel's own JavaScript has to read it back to echo it in the X-CSRF-Token
|
|
// header, which is the whole mechanism.
|
|
const csrfCookieName = "telesrv_admin_csrf"
|
|
|
|
// csrfHeaderName is the header the panel echoes the cookie in.
|
|
const csrfHeaderName = "X-CSRF-Token"
|
|
|
|
type sessionClaims struct {
|
|
Actor string `json:"actor"`
|
|
Exp int64 `json:"exp"`
|
|
Nonce string `json:"nonce"`
|
|
// UserID identifies the admin_console_users row this session belongs to.
|
|
//
|
|
// Zero means the break-glass operator: whoever logged in with
|
|
// TELESRV_ADMIN_UI_PASSWORD / _TOKEN rather than a named account. That
|
|
// login has no database row, so it is deliberately exempt from the
|
|
// per-request revocation check below -- it is the way back in when the
|
|
// database is unreachable or every named account has been locked out.
|
|
UserID int64 `json:"uid,omitempty"`
|
|
// Epoch is the account's token_epoch at the moment this session was minted.
|
|
//
|
|
// Permissions travel inside the signed cookie, which is fast but means a
|
|
// 12-hour session would otherwise keep whatever rights it was issued with
|
|
// long after they were taken away. Every request re-reads the account's
|
|
// current epoch and refuses the session if it has moved, so disabling an
|
|
// operator, editing their rights or changing their password logs them out
|
|
// on their very next request.
|
|
Epoch int32 `json:"epoch,omitempty"`
|
|
// Permissions is the right set granted to this session. For a named account
|
|
// it is a snapshot of that row's permissions; for the break-glass operator
|
|
// it comes from TELESRV_ADMIN_UI_PERMISSIONS. It cannot be edited by the
|
|
// browser: the HMAC covers it. It is still re-read per request for named
|
|
// accounts (see Epoch) so an edit narrows access immediately.
|
|
Permissions []string `json:"permissions,omitempty"`
|
|
// CSRF is the double-submit token bound to this session. Binding it into the
|
|
// signed claims is what makes the cookie/header pair unforgeable by a sibling
|
|
// origin that can only *write* cookies (a subdomain, say): such an attacker
|
|
// can set both the cookie and the header to a value they know, but they cannot
|
|
// produce a session cookie that agrees with it.
|
|
CSRF string `json:"csrf,omitempty"`
|
|
}
|
|
|
|
func signSession(key []byte, claims sessionClaims) (string, error) {
|
|
payload, err := json.Marshal(claims)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
encPayload := base64.RawURLEncoding.EncodeToString(payload)
|
|
mac := hmac.New(sha256.New, key)
|
|
mac.Write([]byte(encPayload))
|
|
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
return encPayload + "." + sig, nil
|
|
}
|
|
|
|
func verifySession(key []byte, value string, now time.Time) (sessionClaims, bool) {
|
|
parts := strings.Split(value, ".")
|
|
if len(parts) != 2 {
|
|
return sessionClaims{}, false
|
|
}
|
|
mac := hmac.New(sha256.New, key)
|
|
mac.Write([]byte(parts[0]))
|
|
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
if subtle.ConstantTimeCompare([]byte(parts[1]), []byte(want)) != 1 {
|
|
return sessionClaims{}, false
|
|
}
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
|
|
if err != nil {
|
|
return sessionClaims{}, false
|
|
}
|
|
var claims sessionClaims
|
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
|
return sessionClaims{}, false
|
|
}
|
|
if claims.Actor == "" || claims.Exp <= now.Unix() {
|
|
return sessionClaims{}, false
|
|
}
|
|
return claims, true
|
|
}
|
|
|
|
// newCSRFToken mints a fresh double-submit token.
|
|
func newCSRFToken() (string, error) {
|
|
var raw [32]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(raw[:]), nil
|
|
}
|
|
|
|
// setCSRFCookie publishes the token to the browser.
|
|
func setCSRFCookie(w http.ResponseWriter, token string, ttl time.Duration) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: csrfCookieName,
|
|
Value: token,
|
|
Path: "/",
|
|
MaxAge: int(ttl.Seconds()),
|
|
// Readable by the panel's script on purpose; see csrfCookieName.
|
|
HttpOnly: false,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
func clearSessionCookie(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: csrfCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: false,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|