auth.go

  1package appview
  2
  3import (
  4	"bufio"
  5	"crypto/hmac"
  6	"crypto/rand"
  7	"crypto/sha256"
  8	"crypto/subtle"
  9	"encoding/base64"
 10	"encoding/hex"
 11	"fmt"
 12	"net/http"
 13	"os"
 14	"strconv"
 15	"strings"
 16	"time"
 17
 18	"golang.org/x/crypto/bcrypt"
 19)
 20
 21// Who is making a request.
 22type Who struct {
 23	Owner bool   // logged in with the owner's password
 24	Agent string // name of the agent token used, if any
 25}
 26
 27// CanRead: the owner and any agent.
 28func (w Who) CanRead() bool { return w.Owner || w.Agent != "" }
 29
 30// CanApprove: only the owner's session approves gates, runs danger actions
 31// and cancels (DESIGN.md ยง8 rule 7).
 32func (w Who) CanApprove() bool { return w.Owner }
 33
 34// Actor is how the decision is recorded.
 35func (w Who) Actor(owner string) string {
 36	if w.Owner {
 37		return owner
 38	}
 39	return "agent:" + w.Agent
 40}
 41
 42// Auth holds the owner's password hash, the session key and agent tokens.
 43type Auth struct {
 44	PasswordHash []byte            // bcrypt
 45	Key          []byte            // signs session cookies and CSRF tokens
 46	Agents       map[string]string // token -> name
 47	Secure       bool              // Secure cookies (behind https)
 48	SessionTTL   time.Duration
 49}
 50
 51const cookieName = "ci_session"
 52
 53// LoadAgents reads "<name> <token>" lines (# comments).
 54func LoadAgents(path string) (map[string]string, error) {
 55	out := map[string]string{}
 56	if path == "" {
 57		return out, nil
 58	}
 59	f, err := os.Open(path)
 60	if err != nil {
 61		return nil, err
 62	}
 63	defer f.Close()
 64	sc := bufio.NewScanner(f)
 65	for sc.Scan() {
 66		line := strings.TrimSpace(sc.Text())
 67		if line == "" || strings.HasPrefix(line, "#") {
 68			continue
 69		}
 70		name, tok, ok := strings.Cut(line, " ")
 71		tok = strings.TrimSpace(tok)
 72		if !ok || len(tok) < 16 {
 73			return nil, fmt.Errorf("%s: want \"<name> <token>\" with a token of 16+ characters", path)
 74		}
 75		out[tok] = name
 76	}
 77	return out, sc.Err()
 78}
 79
 80// NewKey returns a random session key (sessions end when the appview restarts
 81// unless a key file is configured).
 82func NewKey() []byte {
 83	k := make([]byte, 32)
 84	_, _ = rand.Read(k)
 85	return k
 86}
 87
 88func (a *Auth) ttl() time.Duration {
 89	if a.SessionTTL > 0 {
 90		return a.SessionTTL
 91	}
 92	return 30 * 24 * time.Hour
 93}
 94
 95func (a *Auth) sign(s string) string {
 96	m := hmac.New(sha256.New, a.Key)
 97	m.Write([]byte(s))
 98	return base64.RawURLEncoding.EncodeToString(m.Sum(nil))
 99}
100
101// CheckPassword reports whether password is the owner's.
102func (a *Auth) CheckPassword(password string) bool {
103	return len(a.PasswordHash) > 0 && bcrypt.CompareHashAndPassword(a.PasswordHash, []byte(password)) == nil
104}
105
106// Login sets the session cookie.
107func (a *Auth) Login(w http.ResponseWriter) {
108	exp := strconv.FormatInt(time.Now().Add(a.ttl()).Unix(), 10)
109	nonce := make([]byte, 12)
110	_, _ = rand.Read(nonce)
111	v := exp + "." + hex.EncodeToString(nonce)
112	http.SetCookie(w, &http.Cookie{
113		Name: cookieName, Value: v + "." + a.sign(v), Path: "/", HttpOnly: true, Secure: a.Secure,
114		SameSite: http.SameSiteStrictMode, MaxAge: int(a.ttl().Seconds()),
115	})
116}
117
118// Logout clears it.
119func (a *Auth) Logout(w http.ResponseWriter) {
120	http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: a.Secure, SameSite: http.SameSiteStrictMode})
121}
122
123// session returns the session id if the cookie is valid.
124func (a *Auth) session(r *http.Request) (string, bool) {
125	c, err := r.Cookie(cookieName)
126	if err != nil {
127		return "", false
128	}
129	i := strings.LastIndexByte(c.Value, '.')
130	if i < 0 {
131		return "", false
132	}
133	v, sig := c.Value[:i], c.Value[i+1:]
134	if subtle.ConstantTimeCompare([]byte(sig), []byte(a.sign(v))) != 1 {
135		return "", false
136	}
137	exp, _, _ := strings.Cut(v, ".")
138	t, err := strconv.ParseInt(exp, 10, 64)
139	if err != nil || time.Now().Unix() > t {
140		return "", false
141	}
142	return v, true
143}
144
145// Who identifies a request: a valid session cookie, or an agent's bearer
146// token. Agent tokens never make a request the owner's.
147func (a *Auth) Who(r *http.Request) Who {
148	if _, ok := a.session(r); ok {
149		return Who{Owner: true}
150	}
151	if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
152		tok := strings.TrimPrefix(h, "Bearer ")
153		for t, name := range a.Agents {
154			if subtle.ConstantTimeCompare([]byte(t), []byte(tok)) == 1 {
155				return Who{Agent: name}
156			}
157		}
158	}
159	return Who{}
160}
161
162// CSRF returns the form token for the request's session.
163func (a *Auth) CSRF(r *http.Request) string {
164	s, ok := a.session(r)
165	if !ok {
166		return ""
167	}
168	return a.sign("csrf:" + s)
169}
170
171// CheckCSRF verifies a form's token (session-bound; cookies are also
172// SameSite=Strict).
173func (a *Auth) CheckCSRF(r *http.Request) bool {
174	want := a.CSRF(r)
175	return want != "" && subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(want)) == 1
176}