Files
st-pierre/auth.go
T

735 lines
20 KiB
Go

package main
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"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 (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) createUser(ctx context.Context, username, password string) (*userRow, error) {
h, err := hashPassword(password)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
result, err := s.db.ExecContext(ctx,
`INSERT INTO users (username, password_hash) VALUES (?, ?)`,
username, h,
)
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 authMiddleware(store *sqliteStore, 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
}
token := ""
cookie, err := r.Cookie("st_pierre_session")
if err == nil && cookie.Value != "" {
token = cookie.Value
}
if token == "" {
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
token = strings.TrimPrefix(authHeader, "Bearer ")
}
}
if token == "" {
writeUnauthorized(w)
return
}
session, user, err := store.getSessionByToken(r.Context(), token)
if err != nil {
logger.Error("auth lookup failed", zap.Error(err))
writeUnauthorized(w)
return
}
if session == nil || !session.Active || !user.Active {
writeUnauthorized(w)
return
}
store.updateSessionLastUsed(r.Context(), session.ID)
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")
password := os.Getenv("ADMIN_PASSWORD")
if password == "" {
password = generateRandomPassword()
logger.Warn("ADMIN_PASSWORD not set, generated random password",
zap.String("password", password))
}
admin, err := s.createUser(ctx, username, password)
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, 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
}
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, "UI login")
if err != nil {
logger.Error("create session failed", zap.Error(err))
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
http.SetCookie(w, &http.Cookie{
Name: "st_pierre_session",
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(365 * 24 * time.Hour),
})
writeJSON(w, http.StatusOK, map[string]any{
"session": session,
"user": map[string]any{
"id": user.ID,
"username": user.Username,
},
})
}
}
func logoutHandler(store *sqliteStore) 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
}
cookie, err := r.Cookie("st_pierre_session")
if err == nil && cookie.Value != "" {
session, _, _ := store.getSessionByToken(r.Context(), cookie.Value)
if session != nil {
store.deactivateSession(r.Context(), session.ID, session.UserID)
}
}
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
}
user, err := store.createUser(r.Context(), body.Username, body.Password)
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)
}
}