Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
264 lines
7.9 KiB
Go
264 lines
7.9 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/v10networks/nmon/internal/clock"
|
|
"github.com/v10networks/nmon/internal/store"
|
|
)
|
|
|
|
// Session lifetimes and cookie settings.
|
|
const (
|
|
DefaultIdleTimeout = time.Hour
|
|
DefaultAbsoluteTimeout = 12 * time.Hour
|
|
// DefaultCookieName is the session cookie.
|
|
DefaultCookieName = "nmon_session"
|
|
touchEvery = time.Minute
|
|
sessionIDBytes = 32 // 256 bits
|
|
csrfBytes = 32
|
|
purposeSession = "session"
|
|
)
|
|
|
|
// SessionConfig sets session lifetimes and the cookie's shape.
|
|
type SessionConfig struct {
|
|
// Idle ends a session that has not been used for this long. Default one hour.
|
|
Idle time.Duration
|
|
// Absolute ends a session this long after login however active it is. Default 12 hours.
|
|
Absolute time.Duration
|
|
// CookieName defaults to DefaultCookieName.
|
|
CookieName string
|
|
// InsecureCookies drops the Secure attribute. Only for --insecure-http development.
|
|
InsecureCookies bool
|
|
}
|
|
|
|
func (c SessionConfig) withDefaults() SessionConfig {
|
|
if c.Idle <= 0 {
|
|
c.Idle = DefaultIdleTimeout
|
|
}
|
|
if c.Absolute <= 0 {
|
|
c.Absolute = DefaultAbsoluteTimeout
|
|
}
|
|
if c.CookieName == "" {
|
|
c.CookieName = DefaultCookieName
|
|
}
|
|
return c
|
|
}
|
|
|
|
// Identity is who a session is for.
|
|
type Identity struct {
|
|
Subject string
|
|
Name string
|
|
Role Role
|
|
}
|
|
|
|
// IDPTokens are the provider tokens a session keeps so it can refresh and log out at the IDP.
|
|
type IDPTokens struct {
|
|
RefreshToken string `json:"refresh_token,omitempty"`
|
|
IDToken string `json:"id_token,omitempty"`
|
|
Expiry time.Time `json:"expiry,omitzero"`
|
|
}
|
|
|
|
// Refresher renews a session through the IDP. It returns the new tokens and the role the IDP's claims
|
|
// now give, or an empty Role when the response carried no claims. An error ends the session.
|
|
type Refresher interface {
|
|
Refresh(ctx context.Context, t IDPTokens) (IDPTokens, Role, error)
|
|
}
|
|
|
|
// Sessions are the server-side logins behind the session cookie. The cookie holds a random 256-bit id
|
|
// signed with the rotating server key; the database holds only the id's hash.
|
|
type Sessions struct {
|
|
st Store
|
|
clk clock.Clock
|
|
sg *signer
|
|
cfg SessionConfig
|
|
|
|
mu sync.Mutex // serialises refreshes so a rotating refresh token is spent once
|
|
refresher Refresher
|
|
}
|
|
|
|
// NewSessions returns the session manager. refresher may be nil (break-glass and tests).
|
|
func NewSessions(st Store, sg *signer, clk clock.Clock, cfg SessionConfig, refresher Refresher) *Sessions {
|
|
return &Sessions{st: st, clk: clk, sg: sg, cfg: cfg.withDefaults(), refresher: refresher}
|
|
}
|
|
|
|
func hashID(raw []byte) string {
|
|
h := sha256.Sum256(raw)
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
// Create stores a new session for id and returns the cookie to set.
|
|
func (s *Sessions) Create(ctx context.Context, id Identity, idp *IDPTokens) (*http.Cookie, error) {
|
|
raw := make([]byte, sessionIDBytes)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
return nil, fmt.Errorf("session id: %w", err)
|
|
}
|
|
csrf, err := randomToken(csrfBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
now := s.clk.Now()
|
|
row := &store.Session{
|
|
IDHash: hashID(raw), Subject: id.Subject, Name: id.Name, Role: string(id.Role), CSRF: csrf,
|
|
CreatedAt: now, LastSeen: now, ExpiresAt: now.Add(s.cfg.Absolute),
|
|
}
|
|
if idp != nil {
|
|
if row.IDP, err = json.Marshal(idp); err != nil {
|
|
return nil, fmt.Errorf("session tokens: %w", err)
|
|
}
|
|
if !idp.Expiry.IsZero() {
|
|
row.IDPExpiresAt = &idp.Expiry
|
|
}
|
|
}
|
|
if err := s.st.CreateSession(ctx, row); err != nil {
|
|
return nil, fmt.Errorf("create session: %w", err)
|
|
}
|
|
return s.cookie(s.sg.sign(purposeSession, raw), s.cfg.Absolute), nil
|
|
}
|
|
|
|
func (s *Sessions) cookie(value string, ttl time.Duration) *http.Cookie {
|
|
return &http.Cookie{
|
|
Name: s.cfg.CookieName, Value: value, Path: "/", MaxAge: int(ttl / time.Second),
|
|
HttpOnly: true, Secure: !s.cfg.InsecureCookies, SameSite: http.SameSiteLaxMode,
|
|
}
|
|
}
|
|
|
|
// ClearCookie is the cookie that deletes the session cookie in the browser.
|
|
func (s *Sessions) ClearCookie() *http.Cookie {
|
|
c := s.cookie("", 0)
|
|
c.MaxAge = -1
|
|
return c
|
|
}
|
|
|
|
// sessionHash returns the stored key named by the request's cookie.
|
|
func (s *Sessions) sessionHash(r *http.Request) (string, error) {
|
|
c, err := r.Cookie(s.cfg.CookieName)
|
|
if err != nil || c.Value == "" {
|
|
return "", ErrUnauthenticated
|
|
}
|
|
raw, err := s.sg.verify(purposeSession, c.Value)
|
|
if err != nil || len(raw) != sessionIDBytes {
|
|
return "", ErrUnauthenticated
|
|
}
|
|
return hashID(raw), nil
|
|
}
|
|
|
|
// Load authenticates the request's cookie. Any problem is ErrUnauthenticated: a forged, unknown, revoked,
|
|
// idle, expired or unrefreshable session all look the same to the caller.
|
|
func (s *Sessions) Load(ctx context.Context, r *http.Request) (*Principal, error) {
|
|
hash, err := s.sessionHash(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sess, err := s.st.GetSession(ctx, hash)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return nil, ErrUnauthenticated
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load session: %w", err)
|
|
}
|
|
now := s.clk.Now()
|
|
if sess.RevokedAt != nil || !now.Before(sess.ExpiresAt) || !now.Before(sess.LastSeen.Add(s.cfg.Idle)) {
|
|
return nil, ErrUnauthenticated
|
|
}
|
|
if sess.IDPExpiresAt != nil && !now.Before(*sess.IDPExpiresAt) {
|
|
if sess, err = s.refresh(ctx, hash); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if now.Sub(sess.LastSeen) >= touchEvery {
|
|
if err := s.st.TouchSession(ctx, hash, now); err != nil {
|
|
return nil, fmt.Errorf("touch session: %w", err)
|
|
}
|
|
}
|
|
return &Principal{
|
|
User: sess.Subject, Name: sess.Name, Role: Role(sess.Role), Via: sessionVia(sess.Subject),
|
|
SessionID: hash, CSRF: sess.CSRF,
|
|
}, nil
|
|
}
|
|
|
|
// refresh renews the session through the IDP, or revokes it when the IDP says no.
|
|
func (s *Sessions) refresh(ctx context.Context, hash string) (*store.Session, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
sess, err := s.st.GetSession(ctx, hash) // another request may have refreshed while we waited
|
|
if err != nil {
|
|
return nil, ErrUnauthenticated
|
|
}
|
|
now := s.clk.Now()
|
|
if sess.IDPExpiresAt == nil || now.Before(*sess.IDPExpiresAt) {
|
|
return sess, nil
|
|
}
|
|
var old IDPTokens
|
|
if s.refresher == nil || json.Unmarshal(sess.IDP, &old) != nil || old.RefreshToken == "" {
|
|
return nil, s.endSession(ctx, hash)
|
|
}
|
|
fresh, role, err := s.refresher.Refresh(ctx, old)
|
|
if err != nil {
|
|
return nil, s.endSession(ctx, hash)
|
|
}
|
|
b, err := json.Marshal(fresh)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("session tokens: %w", err)
|
|
}
|
|
var exp *time.Time
|
|
if !fresh.Expiry.IsZero() {
|
|
exp = &fresh.Expiry
|
|
}
|
|
if err := s.st.UpdateSessionIDP(ctx, hash, b, exp); err != nil {
|
|
return nil, fmt.Errorf("store refreshed tokens: %w", err)
|
|
}
|
|
sess.IDP, sess.IDPExpiresAt = b, exp
|
|
if role != "" {
|
|
if err := s.st.SetSessionRole(ctx, hash, string(role)); err != nil {
|
|
return nil, fmt.Errorf("store refreshed role: %w", err)
|
|
}
|
|
sess.Role = string(role)
|
|
}
|
|
return sess, nil
|
|
}
|
|
|
|
// endSession revokes a session whose IDP link is dead and reports it as unauthenticated.
|
|
func (s *Sessions) endSession(ctx context.Context, hash string) error {
|
|
if err := s.st.RevokeSession(ctx, hash); err != nil && !errors.Is(err, store.ErrNotFound) {
|
|
return fmt.Errorf("revoke session: %w", err)
|
|
}
|
|
return ErrUnauthenticated
|
|
}
|
|
|
|
// IDP returns the stored provider tokens of a session (for logout).
|
|
func (s *Sessions) IDP(ctx context.Context, hash string) (IDPTokens, error) {
|
|
var t IDPTokens
|
|
sess, err := s.st.GetSession(ctx, hash)
|
|
if err != nil {
|
|
return t, err
|
|
}
|
|
if len(sess.IDP) > 0 {
|
|
err = json.Unmarshal(sess.IDP, &t)
|
|
}
|
|
return t, err
|
|
}
|
|
|
|
// Revoke ends a session at once. Revoking an unknown session is not an error.
|
|
func (s *Sessions) Revoke(ctx context.Context, hash string) error {
|
|
if err := s.st.RevokeSession(ctx, hash); err != nil && !errors.Is(err, store.ErrNotFound) {
|
|
return fmt.Errorf("revoke session: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sessionVia(subject string) Via {
|
|
if subject == breakGlassSubject {
|
|
return ViaBreakGlass
|
|
}
|
|
return ViaSession
|
|
}
|