Files
st-pierre/auth.go
T

801 lines
22 KiB
Go

package main
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
)
var sessionTokenPrefix = "stp_"
type userRow struct {
ID int64 `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
CreatedAt string `json:"created_at"`
Active bool `json:"active"`
}
type sessionRow struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Label string `json:"label"`
TokenHash string `json:"-"`
CreatedAt string `json:"created_at"`
LastUsedAt string `json:"last_used_at,omitempty"`
Active bool `json:"active"`
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type createSessionResponse struct {
ID int64 `json:"id"`
Label string `json:"label"`
Token string `json:"token"`
CreatedAt string `json:"created_at"`
}
func hashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(bytes), nil
}
func checkPassword(hashed, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hashed), []byte(password)) == nil
}
func generateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return sessionTokenPrefix + hex.EncodeToString(b), nil
}
func hashToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}
func loadHMACKey(path string, logger *zap.Logger) ([]byte, error) {
if data, err := os.ReadFile(path); err == nil {
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(data)))
if err == nil && len(key) >= 32 {
return key, nil
}
logger.Warn("invalid HMAC key file, regenerating", zap.Error(err))
}
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("generate HMAC key: %w", err)
}
encoded := base64.StdEncoding.EncodeToString(key)
if err := os.WriteFile(path, []byte(encoded+"\n"), 0600); err != nil {
return nil, fmt.Errorf("write HMAC key: %w", err)
}
logger.Info("generated new HMAC key", zap.String("path", path))
return key, nil
}
func (s *sqliteStore) countUsers(ctx context.Context) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
return n, err
}
func (s *sqliteStore) createUserWithHash(ctx context.Context, username, passwordHash string) (*userRow, error) {
result, err := s.db.ExecContext(ctx,
`INSERT INTO users (username, password_hash) VALUES (?, ?)`,
username, passwordHash,
)
if err != nil {
return nil, fmt.Errorf("insert user: %w", err)
}
id, _ := result.LastInsertId()
return s.getUserByID(ctx, id)
}
func (s *sqliteStore) getUserByUsername(ctx context.Context, username string) (*userRow, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, username, password_hash, created_at, active FROM users WHERE username = ?`, username,
)
var u userRow
var active int
err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt, &active)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query user: %w", err)
}
u.Active = active == 1
return &u, nil
}
func (s *sqliteStore) getUserByID(ctx context.Context, id int64) (*userRow, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, username, password_hash, created_at, active FROM users WHERE id = ?`, id,
)
var u userRow
var active int
err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt, &active)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query user: %w", err)
}
u.Active = active == 1
return &u, nil
}
func (s *sqliteStore) listUsers(ctx context.Context) ([]userRow, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, username, created_at, active FROM users ORDER BY id`,
)
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
defer rows.Close()
var users []userRow
for rows.Next() {
var u userRow
var active int
if err := rows.Scan(&u.ID, &u.Username, &u.CreatedAt, &active); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
u.Active = active == 1
users = append(users, u)
}
return users, rows.Err()
}
func (s *sqliteStore) deactivateUser(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `UPDATE users SET active = 0 WHERE id = ?`, id)
return err
}
func (s *sqliteStore) changePassword(ctx context.Context, userID int64, newHash string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE users SET password_hash = ? WHERE id = ?`, newHash, userID,
)
return err
}
func (s *sqliteStore) createSession(ctx context.Context, userID int64, token, label string) (*sessionRow, error) {
h := hashToken(token)
result, err := s.db.ExecContext(ctx,
`INSERT INTO sessions (user_id, label, token_hash) VALUES (?, ?, ?)`,
userID, label, h,
)
if err != nil {
return nil, fmt.Errorf("insert session: %w", err)
}
id, _ := result.LastInsertId()
row := s.db.QueryRowContext(ctx,
`SELECT id, user_id, label, created_at, active FROM sessions WHERE id = ?`, id,
)
var sr sessionRow
var active int
err = row.Scan(&sr.ID, &sr.UserID, &sr.Label, &sr.CreatedAt, &active)
if err != nil {
return nil, fmt.Errorf("query session: %w", err)
}
sr.Active = active == 1
return &sr, nil
}
func (s *sqliteStore) getSessionByToken(ctx context.Context, token string) (*sessionRow, *userRow, error) {
h := hashToken(token)
row := s.db.QueryRowContext(ctx,
`SELECT s.id, s.user_id, s.label, s.created_at, s.last_used_at, s.active,
u.id, u.username, u.created_at, u.active
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ?`, h,
)
var sr sessionRow
var ur userRow
var sActive, uActive int
var lastUsed sql.NullString
err := row.Scan(&sr.ID, &sr.UserID, &sr.Label, &sr.CreatedAt, &lastUsed, &sActive,
&ur.ID, &ur.Username, &ur.CreatedAt, &uActive)
if err == sql.ErrNoRows {
return nil, nil, nil
}
if err != nil {
return nil, nil, fmt.Errorf("query session: %w", err)
}
sr.Active = sActive == 1
if lastUsed.Valid {
sr.LastUsedAt = lastUsed.String
}
ur.Active = uActive == 1
return &sr, &ur, nil
}
func (s *sqliteStore) listSessions(ctx context.Context, userID int64) ([]sessionRow, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, user_id, label, created_at, last_used_at, active
FROM sessions WHERE user_id = ? ORDER BY id`, userID,
)
if err != nil {
return nil, fmt.Errorf("list sessions: %w", err)
}
defer rows.Close()
var sessions []sessionRow
for rows.Next() {
var sr sessionRow
var active int
var lastUsed sql.NullString
if err := rows.Scan(&sr.ID, &sr.UserID, &sr.Label, &sr.CreatedAt, &lastUsed, &active); err != nil {
return nil, fmt.Errorf("scan session: %w", err)
}
sr.Active = active == 1
if lastUsed.Valid {
sr.LastUsedAt = lastUsed.String
}
sessions = append(sessions, sr)
}
return sessions, rows.Err()
}
func (s *sqliteStore) deactivateSession(ctx context.Context, id, userID int64) error {
_, err := s.db.ExecContext(ctx,
`UPDATE sessions SET active = 0 WHERE id = ? AND user_id = ?`, id, userID,
)
return err
}
func (s *sqliteStore) updateSessionLastUsed(ctx context.Context, id int64) {
s.db.ExecContext(ctx,
`UPDATE sessions SET last_used_at = datetime('now') WHERE id = ?`, id,
)
}
type contextKey string
const userContextKey contextKey = "user"
func getUserFromContext(r *http.Request) *userRow {
if u, ok := r.Context().Value(userContextKey).(*userRow); ok {
return u
}
return nil
}
func signCookie(key []byte, userID int64, username string) string {
expiry := time.Now().Add(30 * 24 * time.Hour).Unix()
payload := fmt.Sprintf("%d|%s|%d", userID, username, expiry)
mac := hmac.New(sha256.New, key)
mac.Write([]byte(payload))
sig := hex.EncodeToString(mac.Sum(nil))
return base64.RawURLEncoding.EncodeToString([]byte(payload + "|" + sig))
}
func verifyCookie(key []byte, encoded string) (int64, string, bool) {
raw, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return 0, "", false
}
parts := strings.SplitN(string(raw), "|", 4)
if len(parts) != 4 {
return 0, "", false
}
userID, username, expiryStr, sig := parts[0], parts[1], parts[2], parts[3]
payload := userID + "|" + username + "|" + expiryStr
mac := hmac.New(sha256.New, key)
mac.Write([]byte(payload))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(sig), []byte(expected)) {
return 0, "", false
}
expiryUnix, err := strconv.ParseInt(expiryStr, 10, 64)
if err != nil || time.Now().Unix() > expiryUnix {
return 0, "", false
}
id, err := strconv.ParseInt(userID, 10, 64)
if err != nil {
return 0, "", false
}
return id, username, true
}
func authMiddleware(store *sqliteStore, hmacKey []byte, logger *zap.Logger, next http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions {
next.ServeHTTP(w, r)
return
}
var user *userRow
cookie, err := r.Cookie("st_pierre_session")
if err == nil && cookie.Value != "" {
if id, username, ok := verifyCookie(hmacKey, cookie.Value); ok {
u, err := store.getUserByID(r.Context(), id)
if err == nil && u != nil && u.Active && u.Username == username {
user = u
}
}
}
if user == nil {
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
token := strings.TrimPrefix(authHeader, "Bearer ")
session, u, err := store.getSessionByToken(r.Context(), token)
if err != nil {
logger.Error("token lookup failed", zap.Error(err))
}
if session != nil && session.Active && u != nil && u.Active {
store.updateSessionLastUsed(r.Context(), session.ID)
user = u
}
}
}
if user == nil {
writeUnauthorized(w)
return
}
ctx := context.WithValue(r.Context(), userContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
}
}
func writeUnauthorized(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"unauthorized"}`))
}
func (s *sqliteStore) bootstrapAdmin(ctx context.Context, logger *zap.Logger) {
n, err := s.countUsers(ctx)
if err != nil {
logger.Fatal("failed to check users table", zap.Error(err))
}
if n > 0 {
return
}
username := getenvDefault("ADMIN_USERNAME", "admin")
var password string
var passwordHash string
if h := os.Getenv("ADMIN_PASSWORD_HASH"); h != "" {
passwordHash = h
} else if pw := os.Getenv("ADMIN_PASSWORD"); pw != "" {
var err error
passwordHash, err = hashPassword(pw)
if err != nil {
logger.Fatal("failed to hash admin password", zap.Error(err))
}
password = pw
}
if passwordHash == "" {
password = generateRandomPassword()
var err error
passwordHash, err = hashPassword(password)
if err != nil {
logger.Fatal("failed to hash admin password", zap.Error(err))
}
logger.Warn("no admin password configured, generated random password",
zap.String("password", password))
}
admin, err := s.createUserWithHash(ctx, username, passwordHash)
if err != nil {
logger.Fatal("failed to create admin user", zap.Error(err))
}
logger.Info("created admin user", zap.String("username", admin.Username))
mcpToken := os.Getenv("MCP_TOKEN")
if mcpToken != "" {
tokenHash := hashToken(mcpToken)
_, err := s.db.ExecContext(ctx,
`INSERT INTO sessions (user_id, label, token_hash) VALUES (?, ?, ?)`,
admin.ID, "MCP_TOKEN bootstrap", tokenHash,
)
if err != nil {
logger.Fatal("failed to bootstrap session from MCP_TOKEN", zap.Error(err))
}
logger.Info("bootstrapped session from MCP_TOKEN")
}
}
func generateRandomPassword() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
func getenvDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func loginHandler(store *sqliteStore, hmacKey []byte, logger *zap.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if req.Username == "" || req.Password == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username and password required"})
return
}
user, err := store.getUserByUsername(r.Context(), req.Username)
if err != nil {
logger.Error("login lookup failed", zap.Error(err))
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
if user == nil || !user.Active || !checkPassword(user.PasswordHash, req.Password) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
return
}
signed := signCookie(hmacKey, user.ID, user.Username)
http.SetCookie(w, &http.Cookie{
Name: "st_pierre_session",
Value: signed,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(30 * 24 * time.Hour),
})
writeJSON(w, http.StatusOK, map[string]any{
"user": map[string]any{
"id": user.ID,
"username": user.Username,
},
})
}
}
func logoutHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
http.SetCookie(w, &http.Cookie{
Name: "st_pierre_session",
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(0, 0),
})
writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"})
}
}
func createSessionHandler(store *sqliteStore, logger *zap.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
user := getUserFromContext(r)
if user == nil {
writeUnauthorized(w)
return
}
var body struct {
Label string `json:"label"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if body.Label == "" {
body.Label = "API session"
}
token, err := generateToken()
if err != nil {
logger.Error("generate token failed", zap.Error(err))
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
session, err := store.createSession(r.Context(), user.ID, token, body.Label)
if err != nil {
logger.Error("create session failed", zap.Error(err))
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
writeJSON(w, http.StatusCreated, createSessionResponse{
ID: session.ID,
Label: session.Label,
Token: token,
CreatedAt: session.CreatedAt,
})
}
}
func listSessionsHandler(store *sqliteStore, logger *zap.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
user := getUserFromContext(r)
if user == nil {
writeUnauthorized(w)
return
}
sessions, err := store.listSessions(r.Context(), user.ID)
if err != nil {
logger.Error("list sessions failed", zap.Error(err))
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"sessions": sessions})
}
}
func deleteSessionHandler(store *sqliteStore, logger *zap.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
user := getUserFromContext(r)
if user == nil {
writeUnauthorized(w)
return
}
id, err := parseIDFromPath(r.URL.Path, "/api/v1/sessions/")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid session id"})
return
}
if err := store.deactivateSession(r.Context(), id, user.ID); err != nil {
logger.Error("deactivate session failed", zap.Error(err))
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "session revoked"})
}
}
func createUserHandler(store *sqliteStore, logger *zap.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
currentUser := getUserFromContext(r)
if currentUser == nil {
writeUnauthorized(w)
return
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if body.Username == "" || body.Password == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username and password required"})
return
}
if len(body.Password) < 8 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "password must be at least 8 characters"})
return
}
h, err := hashPassword(body.Password)
if err != nil {
logger.Error("hash password", zap.Error(err))
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
user, err := store.createUserWithHash(r.Context(), body.Username, h)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint") {
writeJSON(w, http.StatusConflict, map[string]string{"error": "username already exists"})
return
}
logger.Error("create user failed", zap.Error(err))
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
writeJSON(w, http.StatusCreated, user)
}
}
func listUsersHandler(store *sqliteStore, logger *zap.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
currentUser := getUserFromContext(r)
if currentUser == nil {
writeUnauthorized(w)
return
}
users, err := store.listUsers(r.Context())
if err != nil {
logger.Error("list users failed", zap.Error(err))
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"users": users})
}
}
func changePasswordHandler(store *sqliteStore, logger *zap.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
user := getUserFromContext(r)
if user == nil {
writeUnauthorized(w)
return
}
var body struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if body.OldPassword == "" || body.NewPassword == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "old_password and new_password required"})
return
}
if len(body.NewPassword) < 8 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "new password must be at least 8 characters"})
return
}
if body.OldPassword == body.NewPassword {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "new password must differ from old"})
return
}
fullUser, err := store.getUserByID(r.Context(), user.ID)
if err != nil || fullUser == nil {
logger.Error("get user for password change", zap.Error(err))
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
if !checkPassword(fullUser.PasswordHash, body.OldPassword) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "incorrect old password"})
return
}
newHash, err := hashPassword(body.NewPassword)
if err != nil {
logger.Error("hash new password", zap.Error(err))
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
if err := store.changePassword(r.Context(), user.ID, newHash); err != nil {
logger.Error("change password", zap.Error(err))
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "password changed"})
}
}
func parseIDFromPath(path, prefix string) (int64, error) {
rest := strings.TrimPrefix(path, prefix)
rest = strings.TrimSuffix(rest, "/")
if rest == "" {
return 0, fmt.Errorf("empty id")
}
var id int64
_, err := fmt.Sscanf(rest, "%d", &id)
return id, err
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func getUserHandler(store *sqliteStore, logger *zap.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
user := getUserFromContext(r)
if user == nil {
writeUnauthorized(w)
return
}
writeJSON(w, http.StatusOK, user)
}
}