diff --git a/internal/api/auth.go b/internal/api/auth.go index 43e0fd4..725a367 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "encoding/hex" "errors" + "fmt" "log/slog" "net/http" @@ -63,7 +64,8 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { Role: user.Role, }) if err != nil { - respondError(w, http.StatusInternalServerError, "failed to generate token: "+err.Error()) + slog.Error("failed to generate token", "error", err) + respondError(w, http.StatusInternalServerError, "internal server error") return } @@ -80,7 +82,8 @@ func (s *Server) currentUser(w http.ResponseWriter, r *http.Request) { user, err := s.store.GetUserByID(claims.UserID) if err != nil { - respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error()) + slog.Error("failed to get user", "error", err) + respondError(w, http.StatusInternalServerError, "internal server error") return } @@ -175,11 +178,13 @@ func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) { Role: "viewer", // OIDC users default to viewer; admin promotes via settings }) if err != nil { - respondError(w, http.StatusInternalServerError, "failed to create user: "+err.Error()) + slog.Error("failed to create user", "error", err) + respondError(w, http.StatusInternalServerError, "internal server error") return } } else { - respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error()) + slog.Error("failed to get user", "error", err) + respondError(w, http.StatusInternalServerError, "internal server error") return } } @@ -190,28 +195,53 @@ func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) { Role: user.Role, }) if err != nil { - respondError(w, http.StatusInternalServerError, "failed to generate token: "+err.Error()) + slog.Error("failed to generate token", "error", err) + respondError(w, http.StatusInternalServerError, "internal server error") return } - // Set the token in a short-lived cookie the frontend can read once. + // Pass token via short-lived httpOnly cookie. The frontend reads it via + // a dedicated /api/auth/oidc/token endpoint and then the cookie is cleared. http.SetCookie(w, &http.Cookie{ Name: "auth_token", Value: token.Token, - Path: "/", - MaxAge: 60, // 1 minute — frontend reads it immediately - HttpOnly: false, + Path: "/api/auth/oidc", + MaxAge: 60, + HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) http.Redirect(w, r, "/?oidc=success", http.StatusFound) } +// oidcExchangeToken handles POST /api/auth/oidc/token — exchanges the httpOnly cookie for a JSON token. +func (s *Server) oidcExchangeToken(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("auth_token") + if err != nil || cookie.Value == "" { + respondError(w, http.StatusUnauthorized, "no OIDC token available") + return + } + + // Clear the cookie immediately. + http.SetCookie(w, &http.Cookie{ + Name: "auth_token", + Value: "", + Path: "/api/auth/oidc", + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + + respondJSON(w, http.StatusOK, map[string]string{"token": cookie.Value}) +} + // getAuthSettings handles GET /api/auth/settings. func (s *Server) getAuthSettings(w http.ResponseWriter, r *http.Request) { as, err := s.store.GetAuthSettings() if err != nil { - respondError(w, http.StatusInternalServerError, "failed to get auth settings: "+err.Error()) + slog.Error("failed to get auth settings", "error", err) + respondError(w, http.StatusInternalServerError, "internal server error") return } // Mask the client secret for the response. @@ -253,7 +283,8 @@ func (s *Server) updateAuthSettings(w http.ResponseWriter, r *http.Request) { } if err := s.store.UpdateAuthSettings(req); err != nil { - respondError(w, http.StatusInternalServerError, "failed to update auth settings: "+err.Error()) + slog.Error("failed to update auth settings", "error", err) + respondError(w, http.StatusInternalServerError, "internal server error") return } @@ -269,7 +300,8 @@ func (s *Server) updateAuthSettings(w http.ResponseWriter, r *http.Request) { func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) { users, err := s.store.GetAllUsers() if err != nil { - respondError(w, http.StatusInternalServerError, "failed to list users: "+err.Error()) + slog.Error("failed to list users", "error", err) + respondError(w, http.StatusInternalServerError, "internal server error") return } respondJSON(w, http.StatusOK, users) @@ -291,6 +323,10 @@ func (s *Server) createUser(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusBadRequest, "username and password are required") return } + if err := validatePassword(req.Password); err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } if req.Role == "" { req.Role = "viewer" @@ -302,7 +338,8 @@ func (s *Server) createUser(w http.ResponseWriter, r *http.Request) { hash, err := auth.HashPassword(req.Password) if err != nil { - respondError(w, http.StatusInternalServerError, "failed to hash password: "+err.Error()) + slog.Error("failed to hash password", "error", err) + respondError(w, http.StatusInternalServerError, "internal server error") return } @@ -313,7 +350,8 @@ func (s *Server) createUser(w http.ResponseWriter, r *http.Request) { Role: req.Role, }) if err != nil { - respondError(w, http.StatusInternalServerError, "failed to create user: "+err.Error()) + slog.Error("failed to create user", "error", err) + respondError(w, http.StatusInternalServerError, "internal server error") return } @@ -324,6 +362,13 @@ func (s *Server) createUser(w http.ResponseWriter, r *http.Request) { func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "uid") + // Prevent deleting your own account. + claims, ok := auth.ClaimsFromContext(r.Context()) + if ok && claims.UserID == id { + respondError(w, http.StatusBadRequest, "cannot delete your own account") + return + } + // Prevent deleting the last admin. user, err := s.store.GetUserByID(id) if err != nil { @@ -331,7 +376,8 @@ func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) { respondNotFound(w, "user") return } - respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error()) + slog.Error("failed to get user", "error", err) + respondError(w, http.StatusInternalServerError, "internal server error") return } @@ -352,9 +398,128 @@ func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) { } if err := s.store.DeleteUser(id); err != nil { - respondError(w, http.StatusInternalServerError, "failed to delete user: "+err.Error()) + slog.Error("failed to delete user", "error", err) + respondError(w, http.StatusInternalServerError, "internal server error") return } respondJSON(w, http.StatusOK, map[string]string{"deleted": id}) } + +// validatePassword checks that a password meets minimum complexity requirements. +func validatePassword(password string) error { + if len(password) < 8 { + return fmt.Errorf("password must be at least 8 characters long") + } + return nil +} + +// logout handles POST /api/auth/logout — revokes the current token. +func (s *Server) logout(w http.ResponseWriter, r *http.Request) { + tokenStr := auth.ExtractToken(r) + if tokenStr != "" { + s.localAuth.RevokeToken(tokenStr) + } + respondJSON(w, http.StatusOK, map[string]string{"status": "logged out"}) +} + +// changePassword handles PUT /api/auth/users/{uid}/password. +func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { + uid := chi.URLParam(r, "uid") + + var req struct { + Password string `json:"password"` + } + if !decodeJSON(w, r, &req) { + return + } + + if req.Password == "" { + respondError(w, http.StatusBadRequest, "password is required") + return + } + if err := validatePassword(req.Password); err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + + hash, err := auth.HashPassword(req.Password) + if err != nil { + slog.Error("failed to hash password", "error", err) + respondError(w, http.StatusInternalServerError, "internal server error") + return + } + + if err := s.store.UpdateUserPassword(uid, hash); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "user") + return + } + slog.Error("failed to update password", "error", err) + respondError(w, http.StatusInternalServerError, "internal server error") + return + } + + respondJSON(w, http.StatusOK, map[string]string{"status": "password updated"}) +} + +// updateUser handles PUT /api/auth/users/{uid}. +func (s *Server) updateUser(w http.ResponseWriter, r *http.Request) { + uid := chi.URLParam(r, "uid") + + var req struct { + Email string `json:"email"` + Role string `json:"role"` + } + if !decodeJSON(w, r, &req) { + return + } + + if req.Role != "" && req.Role != "admin" && req.Role != "viewer" { + respondError(w, http.StatusBadRequest, "role must be 'admin' or 'viewer'") + return + } + + existing, err := s.store.GetUserByID(uid) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "user") + return + } + slog.Error("failed to get user", "error", err) + respondError(w, http.StatusInternalServerError, "internal server error") + return + } + + // If demoting from admin, check we're not removing the last admin. + if existing.Role == "admin" && req.Role == "viewer" { + users, err := s.store.GetAllUsers() + if err == nil { + adminCount := 0 + for _, u := range users { + if u.Role == "admin" { + adminCount++ + } + } + if adminCount <= 1 { + respondError(w, http.StatusBadRequest, "cannot demote the last admin user") + return + } + } + } + + if req.Email != "" { + existing.Email = req.Email + } + if req.Role != "" { + existing.Role = req.Role + } + + if err := s.store.UpdateUser(existing); err != nil { + slog.Error("failed to update user", "error", err) + respondError(w, http.StatusInternalServerError, "internal server error") + return + } + + respondJSON(w, http.StatusOK, existing) +} diff --git a/internal/api/router.go b/internal/api/router.go index 7b24597..f1a07e3 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -176,6 +176,7 @@ func (s *Server) Router() chi.Router { r.Post("/auth/login", s.rateLimitedLogin(loginLimiter)) r.Get("/auth/oidc/login", s.oidcLogin) r.Get("/auth/oidc/callback", s.oidcCallback) + r.Post("/auth/oidc/token", s.oidcExchangeToken) // Webhook handler (uses its own secret-based auth). r.Mount("/webhook", s.webhook.Route()) @@ -187,6 +188,7 @@ func (s *Server) Router() chi.Router { // Read-only endpoints (any authenticated user). r.Get("/health", s.getHealth) r.Get("/auth/me", s.currentUser) + r.Post("/auth/logout", s.logout) r.Get("/projects", s.listProjects) r.Route("/projects/{id}", func(r chi.Router) { r.Get("/", s.getProject) @@ -281,6 +283,8 @@ func (s *Server) Router() chi.Router { r.Put("/auth/settings", s.updateAuthSettings) r.Get("/auth/users", s.listUsers) r.Post("/auth/users", s.createUser) + r.Put("/auth/users/{uid}", s.updateUser) + r.Put("/auth/users/{uid}/password", s.changePassword) r.Delete("/auth/users/{uid}", s.deleteUser) // Project creation. diff --git a/internal/auth/local.go b/internal/auth/local.go index bd1cd37..747b9a9 100644 --- a/internal/auth/local.go +++ b/internal/auth/local.go @@ -3,8 +3,10 @@ package auth import ( "crypto/hmac" "crypto/sha256" + "encoding/hex" "errors" "fmt" + "sync" "time" "github.com/golang-jwt/jwt/v5" @@ -31,6 +33,8 @@ type jwtClaims struct { // LocalAuth handles password hashing and JWT token management for local auth mode. type LocalAuth struct { jwtSecret []byte + mu sync.RWMutex + blacklist map[string]time.Time // token hash -> expiry time } // NewLocalAuth creates a LocalAuth deriving the JWT signing key from the encryption key @@ -38,8 +42,46 @@ type LocalAuth struct { func NewLocalAuth(encKey [32]byte) *LocalAuth { mac := hmac.New(sha256.New, encKey[:]) mac.Write([]byte("docker-watcher-jwt-secret")) - return &LocalAuth{ + la := &LocalAuth{ jwtSecret: mac.Sum(nil), + blacklist: make(map[string]time.Time), + } + // Periodically clean expired blacklist entries. + go la.cleanBlacklist() + return la +} + +// RevokeToken adds a token to the blacklist. +func (la *LocalAuth) RevokeToken(tokenString string) { + hash := sha256.Sum256([]byte(tokenString)) + key := hex.EncodeToString(hash[:]) + la.mu.Lock() + defer la.mu.Unlock() + la.blacklist[key] = time.Now().Add(TokenExpiry) +} + +// IsRevoked checks if a token has been revoked. +func (la *LocalAuth) IsRevoked(tokenString string) bool { + hash := sha256.Sum256([]byte(tokenString)) + key := hex.EncodeToString(hash[:]) + la.mu.RLock() + defer la.mu.RUnlock() + _, exists := la.blacklist[key] + return exists +} + +// cleanBlacklist removes expired entries from the blacklist every hour. +func (la *LocalAuth) cleanBlacklist() { + ticker := time.NewTicker(1 * time.Hour) + for range ticker.C { + la.mu.Lock() + now := time.Now() + for k, expiry := range la.blacklist { + if now.After(expiry) { + delete(la.blacklist, k) + } + } + la.mu.Unlock() } } diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index c0416f0..fe99547 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -18,7 +18,7 @@ const claimsKey contextKey = "auth_claims" func Middleware(la *LocalAuth) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - tokenStr := extractToken(r) + tokenStr := ExtractToken(r) if tokenStr == "" { http.Error(w, `{"success":false,"error":"authentication required"}`, http.StatusUnauthorized) return @@ -30,6 +30,11 @@ func Middleware(la *LocalAuth) func(http.Handler) http.Handler { return } + if la.IsRevoked(tokenStr) { + http.Error(w, `{"success":false,"error":"token has been revoked"}`, http.StatusUnauthorized) + return + } + ctx := context.WithValue(r.Context(), claimsKey, claims) next.ServeHTTP(w, r.WithContext(ctx)) }) @@ -55,8 +60,8 @@ func ClaimsFromContext(ctx context.Context) (Claims, bool) { return claims, ok } -// extractToken gets the JWT from the Authorization header or "token" query param. -func extractToken(r *http.Request) string { +// ExtractToken gets the JWT from the Authorization header or "token" query param. +func ExtractToken(r *http.Request) string { // Try Authorization: Bearer authHeader := r.Header.Get("Authorization") if strings.HasPrefix(authHeader, "Bearer ") {