Files
mcr/internal/webserver/auth.go
Kyle Isom 9d7043a594 Block guest accounts from web UI login
The web UI now validates the MCIAS token after login and rejects
accounts with the guest role before setting the session cookie.
This is defense-in-depth alongside the env:restricted MCIAS tag.

The webserver.New() constructor takes a new ValidateFunc parameter
that inspects token roles post-authentication. MCIAS login does not
return roles, so this requires an extra ValidateToken round-trip at
login time (result is cached for 30s).

Security: guest role accounts are denied web UI access

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:02:22 -07:00

211 lines
5.4 KiB
Go

package webserver
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"log"
"net/http"
"slices"
"strings"
)
// sessionKey is the context key for the session token.
type sessionKey struct{}
// tokenFromContext retrieves the bearer token from context.
func tokenFromContext(ctx context.Context) string {
s, _ := ctx.Value(sessionKey{}).(string)
return s
}
// contextWithToken stores a bearer token in the context.
func contextWithToken(ctx context.Context, token string) context.Context {
return context.WithValue(ctx, sessionKey{}, token)
}
// sessionMiddleware checks for a valid mcr_session cookie and adds the
// token to the request context. If no session is present, it redirects
// to the login page.
func (s *Server) sessionMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("mcr_session")
if err != nil || cookie.Value == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ctx := contextWithToken(r.Context(), cookie.Value)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// handleLoginPage renders the login form.
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
csrf := s.generateCSRFToken(w)
s.templates.render(w, "login", map[string]any{
"CSRFToken": csrf,
"Session": false,
})
}
// handleLoginSubmit processes the login form.
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB limit
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !s.validateCSRFToken(r) {
csrf := s.generateCSRFToken(w)
s.templates.render(w, "login", map[string]any{
"Error": "Invalid or expired form submission. Please try again.",
"CSRFToken": csrf,
"Session": false,
})
return
}
username := r.FormValue("username")
password := r.FormValue("password")
if username == "" || password == "" {
csrf := s.generateCSRFToken(w)
s.templates.render(w, "login", map[string]any{
"Error": "Username and password are required.",
"CSRFToken": csrf,
"Session": false,
})
return
}
token, _, err := s.loginFn(username, password)
if err != nil {
log.Printf("login failed for user %q: %v", username, err)
csrf := s.generateCSRFToken(w)
s.templates.render(w, "login", map[string]any{
"Error": "Invalid username or password.",
"CSRFToken": csrf,
"Session": false,
})
return
}
// Validate the token to check roles. Guest accounts are not
// permitted to use the web interface.
roles, err := s.validateFn(token)
if err != nil {
log.Printf("login token validation failed for user %q: %v", username, err)
csrf := s.generateCSRFToken(w)
s.templates.render(w, "login", map[string]any{
"Error": "Login failed. Please try again.",
"CSRFToken": csrf,
"Session": false,
})
return
}
if slices.Contains(roles, "guest") {
log.Printf("login denied for guest user %q", username)
csrf := s.generateCSRFToken(w)
s.templates.render(w, "login", map[string]any{
"Error": "Guest accounts are not permitted to access the web interface.",
"CSRFToken": csrf,
"Session": false,
})
return
}
http.SetCookie(w, &http.Cookie{
Name: "mcr_session",
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// handleLogout clears the session and redirects to login.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "mcr_session",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// generateCSRFToken creates a random token, signs it with HMAC, stores
// the signed value in a cookie, and returns the token for form embedding.
func (s *Server) generateCSRFToken(w http.ResponseWriter) string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// Crypto RNG failure is fatal; this should never happen.
log.Printf("csrf: failed to generate random bytes: %v", err)
return ""
}
token := hex.EncodeToString(b)
sig := s.signCSRF(token)
cookieVal := token + "." + sig
http.SetCookie(w, &http.Cookie{
Name: "csrf_token",
Value: cookieVal,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
return token
}
// validateCSRFToken verifies the form _csrf field matches the cookie and
// the HMAC signature is valid.
func (s *Server) validateCSRFToken(r *http.Request) bool {
formToken := r.FormValue("_csrf")
if formToken == "" {
return false
}
cookie, err := r.Cookie("csrf_token")
if err != nil || cookie.Value == "" {
return false
}
parts := strings.SplitN(cookie.Value, ".", 2)
if len(parts) != 2 {
return false
}
cookieToken := parts[0]
cookieSig := parts[1]
// Verify the form token matches the cookie token.
if !hmac.Equal([]byte(formToken), []byte(cookieToken)) {
return false
}
// Verify the HMAC signature.
expectedSig := s.signCSRF(cookieToken)
return hmac.Equal([]byte(cookieSig), []byte(expectedSig))
}
// signCSRF computes an HMAC-SHA256 signature for a CSRF token.
func (s *Server) signCSRF(token string) string {
mac := hmac.New(sha256.New, s.csrfKey)
mac.Write([]byte(token))
return hex.EncodeToString(mac.Sum(nil))
}