// Package auth implements the service's own session login (AGENTS.md ยง9 Auth: // a separate session login, not SSO). Sessions are stateless, signed cookies. // A random CSRF cookie uses the double-submit pattern for forms. package auth import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "errors" "fmt" "net/http" "strconv" "strings" "time" "golang.org/x/crypto/bcrypt" ) const ( sessionCookie = "school_session" csrfCookie = "school_csrf" ) // Manager issues and validates sessions. type Manager struct { // Secret signs every cookie. Required. Secret []byte // TTL is the session lifetime. TTL time.Duration // Secure sets the Secure cookie attribute. Keep true behind HTTPS. Secure bool // Username is the single allowed login name. Username string // passwordHash is a bcrypt hash, or nil when only a plaintext password is set. passwordHash []byte // plaintext is the fallback password, or empty when a hash is set. plaintext string } // New builds a Manager. Exactly one of bcryptHash or plaintext must be set. func New(secret []byte, ttl time.Duration, secure bool, username, bcryptHash, plaintext string) (*Manager, error) { if len(secret) < 16 { return nil, errors.New("session secret too short") } m := &Manager{Secret: secret, TTL: ttl, Secure: secure, Username: username} switch { case bcryptHash != "": if _, err := bcrypt.Cost([]byte(bcryptHash)); err != nil { return nil, fmt.Errorf("SCHOOL_ADMIN_PASSWORD_HASH is not a valid bcrypt hash: %w", err) } m.passwordHash = []byte(bcryptHash) case plaintext != "": m.plaintext = plaintext default: return nil, errors.New("no admin password configured") } return m, nil } // Hashed reports whether the manager verifies against a bcrypt hash. func (m *Manager) Hashed() bool { return m.passwordHash != nil } // CheckCredentials validates a username and password in constant time where // possible. It never reveals which part was wrong. func (m *Manager) CheckCredentials(username, password string) bool { userOK := subtle.ConstantTimeCompare([]byte(username), []byte(m.Username)) == 1 passOK := false if m.passwordHash != nil { passOK = bcrypt.CompareHashAndPassword(m.passwordHash, []byte(password)) == nil } else { passOK = subtle.ConstantTimeCompare([]byte(password), []byte(m.plaintext)) == 1 } return userOK && passOK } // Issue writes a signed session cookie and the matching CSRF cookie. func (m *Manager) Issue(w http.ResponseWriter) { csrf := randomToken() expires := time.Now().Add(m.TTL).Unix() payload := strings.Join([]string{m.Username, csrf, strconv.FormatInt(expires, 10)}, "\n") http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: m.sign(payload), Path: "/", HttpOnly: true, Secure: m.Secure, SameSite: http.SameSiteLaxMode, Expires: time.Unix(expires, 0), MaxAge: int(m.TTL.Seconds()), }) http.SetCookie(w, &http.Cookie{ Name: csrfCookie, Value: csrf, Path: "/", HttpOnly: true, Secure: m.Secure, SameSite: http.SameSiteLaxMode, Expires: time.Unix(expires, 0), MaxAge: int(m.TTL.Seconds()), }) } // Clear removes the session and CSRF cookies. func (m *Manager) Clear(w http.ResponseWriter) { for _, name := range []string{sessionCookie, csrfCookie} { http.SetCookie(w, &http.Cookie{ Name: name, Value: "", Path: "/", HttpOnly: true, Secure: m.Secure, SameSite: http.SameSiteLaxMode, MaxAge: -1, Expires: time.Unix(0, 0), }) } } // Authenticated reports whether the request carries a valid, unexpired session. func (m *Manager) Authenticated(r *http.Request) bool { c, err := r.Cookie(sessionCookie) if err != nil { return false } payload, ok := m.verify(c.Value) if !ok { return false } parts := strings.Split(payload, "\n") if len(parts) != 3 || parts[0] != m.Username { return false } expires, err := strconv.ParseInt(parts[2], 10, 64) if err != nil || time.Now().Unix() > expires { return false } return true } // EnsureCSRF returns the CSRF token for a form, setting the cookie if absent. func (m *Manager) EnsureCSRF(w http.ResponseWriter, r *http.Request) string { if c, err := r.Cookie(csrfCookie); err == nil && c.Value != "" { return c.Value } token := randomToken() http.SetCookie(w, &http.Cookie{ Name: csrfCookie, Value: token, Path: "/", HttpOnly: true, Secure: m.Secure, SameSite: http.SameSiteLaxMode, MaxAge: int(m.TTL.Seconds()), }) return token } // CheckCSRF compares the submitted form token with the CSRF cookie. func (m *Manager) CheckCSRF(r *http.Request) bool { c, err := r.Cookie(csrfCookie) if err != nil || c.Value == "" { return false } form := r.PostFormValue("csrf") return subtle.ConstantTimeCompare([]byte(form), []byte(c.Value)) == 1 } func (m *Manager) sign(payload string) string { mac := hmac.New(sha256.New, m.Secret) mac.Write([]byte(payload)) sig := mac.Sum(nil) return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + base64.RawURLEncoding.EncodeToString(sig) } func (m *Manager) verify(token string) (string, bool) { encoded, sigEncoded, ok := strings.Cut(token, ".") if !ok { return "", false } payload, err := base64.RawURLEncoding.DecodeString(encoded) if err != nil { return "", false } gotSig, err := base64.RawURLEncoding.DecodeString(sigEncoded) if err != nil { return "", false } mac := hmac.New(sha256.New, m.Secret) mac.Write(payload) if !hmac.Equal(gotSig, mac.Sum(nil)) { return "", false } return string(payload), true } func randomToken() string { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { panic("auth: random source failed: " + err.Error()) } return base64.RawURLEncoding.EncodeToString(b) }