801 lines
22 KiB
Go
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)
|
|
}
|
|
}
|