// Package auth provides minimal single-account authentication for the admin // console: a bcrypt-verified login and a stdlib HMAC-SHA256 signed token // (JWT-compatible) plus a chi middleware that guards write routes. package auth import ( "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "net/http" "strings" "time" "golang.org/x/crypto/bcrypt" ) // Authenticator holds the single admin credential and token signing secret. type Authenticator struct { username string passwordHash []byte secret []byte ttl time.Duration } // New builds an Authenticator. passwordHash must be a bcrypt hash. func New(username string, passwordHash, secret []byte, ttl time.Duration) *Authenticator { return &Authenticator{username: username, passwordHash: passwordHash, secret: secret, ttl: ttl} } // ErrInvalidCredentials is returned when login fails. var ErrInvalidCredentials = errors.New("invalid credentials") // Login verifies the username/password and returns a signed token on success. func (a *Authenticator) Login(username, password string) (string, error) { if username != a.username { // Still run bcrypt to keep timing roughly constant. _ = bcrypt.CompareHashAndPassword(a.passwordHash, []byte(password)) return "", ErrInvalidCredentials } if err := bcrypt.CompareHashAndPassword(a.passwordHash, []byte(password)); err != nil { return "", ErrInvalidCredentials } return a.issue(username) } type claims struct { Sub string `json:"sub"` Exp int64 `json:"exp"` } func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } func (a *Authenticator) sign(signingInput string) string { mac := hmac.New(sha256.New, a.secret) mac.Write([]byte(signingInput)) return b64(mac.Sum(nil)) } func (a *Authenticator) issue(sub string) (string, error) { header := b64([]byte(`{"alg":"HS256","typ":"JWT"}`)) payloadJSON, err := json.Marshal(claims{Sub: sub, Exp: time.Now().Add(a.ttl).Unix()}) if err != nil { return "", err } payload := b64(payloadJSON) signingInput := header + "." + payload return signingInput + "." + a.sign(signingInput), nil } // Verify checks a token's signature and expiry, returning the subject. func (a *Authenticator) Verify(token string) (string, error) { parts := strings.Split(token, ".") if len(parts) != 3 { return "", errors.New("malformed token") } signingInput := parts[0] + "." + parts[1] if !hmac.Equal([]byte(a.sign(signingInput)), []byte(parts[2])) { return "", errors.New("bad signature") } payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return "", err } var c claims if err := json.Unmarshal(payload, &c); err != nil { return "", err } if time.Now().Unix() >= c.Exp { return "", errors.New("token expired") } return c.Sub, nil } type ctxKey int const userKey ctxKey = 0 // UserFrom returns the authenticated subject from the request context. func UserFrom(ctx context.Context) string { if v, ok := ctx.Value(userKey).(string); ok { return v } return "" } // Middleware rejects requests without a valid Bearer token. func (a *Authenticator) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := r.Header.Get("Authorization") token := strings.TrimPrefix(h, "Bearer ") if token == h || token == "" { http.Error(w, `{"error":{"code":"unauthorized","message":"missing token"}}`, http.StatusUnauthorized) return } sub, err := a.Verify(token) if err != nil { http.Error(w, `{"error":{"code":"unauthorized","message":"invalid token"}}`, http.StatusUnauthorized) return } ctx := context.WithValue(r.Context(), userKey, sub) next.ServeHTTP(w, r.WithContext(ctx)) }) }