package api import ( "crypto/rand" "encoding/hex" "errors" "log/slog" "net/http" "github.com/go-chi/chi/v5" "github.com/alexei/docker-watcher/internal/auth" "github.com/alexei/docker-watcher/internal/store" ) // login handles POST /api/auth/login. func (s *Server) login(w http.ResponseWriter, r *http.Request) { var req auth.LoginRequest if !decodeJSON(w, r, &req) { return } if req.Username == "" || req.Password == "" { respondError(w, http.StatusBadRequest, "username and password are required") return } user, err := s.store.GetUserByUsername(req.Username) if err != nil { if errors.Is(err, store.ErrNotFound) { respondError(w, http.StatusUnauthorized, "invalid credentials") return } respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error()) return } if err := auth.CheckPassword(user.PasswordHash, req.Password); err != nil { respondError(w, http.StatusUnauthorized, "invalid credentials") return } token, err := s.localAuth.GenerateToken(auth.Claims{ UserID: user.ID, Username: user.Username, Role: user.Role, }) if err != nil { respondError(w, http.StatusInternalServerError, "failed to generate token: "+err.Error()) return } respondJSON(w, http.StatusOK, token) } // currentUser handles GET /api/auth/me — returns the authenticated user. func (s *Server) currentUser(w http.ResponseWriter, r *http.Request) { claims, ok := auth.ClaimsFromContext(r.Context()) if !ok { respondError(w, http.StatusUnauthorized, "not authenticated") return } user, err := s.store.GetUserByID(claims.UserID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error()) return } respondJSON(w, http.StatusOK, user) } // oidcLogin handles GET /api/auth/oidc/login — redirects to OIDC provider. func (s *Server) oidcLogin(w http.ResponseWriter, r *http.Request) { if s.oidcProvider == nil { respondError(w, http.StatusBadRequest, "OIDC is not configured") return } // Generate random state. stateBytes := make([]byte, 16) if _, err := rand.Read(stateBytes); err != nil { respondError(w, http.StatusInternalServerError, "failed to generate state") return } state := hex.EncodeToString(stateBytes) // Store state in a short-lived cookie for validation on callback. http.SetCookie(w, &http.Cookie{ Name: "oidc_state", Value: state, Path: "/api/auth/oidc", MaxAge: 300, // 5 minutes HttpOnly: true, SameSite: http.SameSiteLaxMode, }) http.Redirect(w, r, s.oidcProvider.AuthCodeURL(state), http.StatusFound) } // oidcCallback handles GET /api/auth/oidc/callback — exchanges code for tokens. func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) { if s.oidcProvider == nil { respondError(w, http.StatusBadRequest, "OIDC is not configured") return } // Validate state parameter. stateCookie, err := r.Cookie("oidc_state") if err != nil || stateCookie.Value == "" { respondError(w, http.StatusBadRequest, "missing OIDC state") return } if r.URL.Query().Get("state") != stateCookie.Value { respondError(w, http.StatusBadRequest, "invalid OIDC state") return } // Clear the state cookie. http.SetCookie(w, &http.Cookie{ Name: "oidc_state", Value: "", Path: "/api/auth/oidc", MaxAge: -1, }) code := r.URL.Query().Get("code") if code == "" { respondError(w, http.StatusBadRequest, "missing authorization code") return } userInfo, err := s.oidcProvider.Exchange(r.Context(), code) if err != nil { slog.Error("OIDC exchange failed", "error", err) respondError(w, http.StatusInternalServerError, "OIDC authentication failed") return } // Find or create local user linked to the OIDC identity. username := userInfo.Username if username == "" { username = userInfo.Email } if username == "" { username = userInfo.Subject } user, err := s.store.GetUserByUsername(username) if err != nil { if errors.Is(err, store.ErrNotFound) { // Auto-create user from OIDC. user, err = s.store.CreateUser(store.User{ Username: username, Email: userInfo.Email, Role: "viewer", // OIDC users default to viewer; admin promotes via settings }) if err != nil { respondError(w, http.StatusInternalServerError, "failed to create user: "+err.Error()) return } } else { respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error()) return } } token, err := s.localAuth.GenerateToken(auth.Claims{ UserID: user.ID, Username: user.Username, Role: user.Role, }) if err != nil { respondError(w, http.StatusInternalServerError, "failed to generate token: "+err.Error()) return } // Redirect to frontend with token in query parameter. // The frontend extracts the token and stores it in localStorage. http.Redirect(w, r, "/?token="+token.Token, http.StatusFound) } // 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()) return } // Mask the client secret for the response. if as.OIDCClientSecret != "" { as.OIDCClientSecret = "********" } respondJSON(w, http.StatusOK, as) } // updateAuthSettings handles PUT /api/auth/settings. func (s *Server) updateAuthSettings(w http.ResponseWriter, r *http.Request) { var req store.AuthSettings if !decodeJSON(w, r, &req) { return } if req.AuthMode != "local" && req.AuthMode != "oidc" { respondError(w, http.StatusBadRequest, "auth_mode must be 'local' or 'oidc'") return } // If client secret is masked, preserve the existing value. if req.OIDCClientSecret == "********" || req.OIDCClientSecret == "" { existing, err := s.store.GetAuthSettings() if err == nil { req.OIDCClientSecret = existing.OIDCClientSecret } } if err := s.store.UpdateAuthSettings(req); err != nil { respondError(w, http.StatusInternalServerError, "failed to update auth settings: "+err.Error()) return } // Re-initialize OIDC provider if mode is oidc and config is present. if req.AuthMode == "oidc" && req.OIDCIssuerURL != "" && req.OIDCClientID != "" { s.initOIDCProvider(r.Context(), req) } respondJSON(w, http.StatusOK, req) } // listUsers handles GET /api/auth/users. 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()) return } respondJSON(w, http.StatusOK, users) } // createUser handles POST /api/auth/users. func (s *Server) createUser(w http.ResponseWriter, r *http.Request) { var req struct { Username string `json:"username"` Password string `json:"password"` Email string `json:"email"` Role string `json:"role"` } if !decodeJSON(w, r, &req) { return } if req.Username == "" || req.Password == "" { respondError(w, http.StatusBadRequest, "username and password are required") return } if req.Role == "" { req.Role = "viewer" } hash, err := auth.HashPassword(req.Password) if err != nil { respondError(w, http.StatusInternalServerError, "failed to hash password: "+err.Error()) return } user, err := s.store.CreateUser(store.User{ Username: req.Username, PasswordHash: hash, Email: req.Email, Role: req.Role, }) if err != nil { respondError(w, http.StatusInternalServerError, "failed to create user: "+err.Error()) return } respondJSON(w, http.StatusCreated, user) } // deleteUser handles DELETE /api/auth/users/{uid}. func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "uid") // Prevent deleting the last admin. user, err := s.store.GetUserByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "user") return } respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error()) return } if user.Role == "admin" { 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 delete the last admin user") return } } } if err := s.store.DeleteUser(id); err != nil { respondError(w, http.StatusInternalServerError, "failed to delete user: "+err.Error()) return } respondJSON(w, http.StatusOK, map[string]string{"deleted": id}) }