127 lines
3.7 KiB
Go
127 lines
3.7 KiB
Go
// 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))
|
|
})
|
|
}
|