package api import ( "crypto/rand" "encoding/hex" "errors" "fmt" "log/slog" "net/http" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/auth" "github.com/alexei/tinyforge/internal/crypto" "github.com/alexei/tinyforge/internal/store" ) // rateLimitedLogin wraps the login handler with per-IP rate limiting. // Uses clientIP() so X-Forwarded-For is honored only when the request // arrives from a configured trusted-proxy CIDR — preventing remote // attackers from spoofing the header to bypass the per-IP login limiter. func (s *Server) rateLimitedLogin(rl *rateLimiter) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !rl.allow(clientIP(r)) { respondError(w, http.StatusTooManyRequests, "too many login attempts, try again later") return } s.login(w, r) } } // authMode handles GET /api/auth/mode — public endpoint returning the auth mode. func (s *Server) authMode(w http.ResponseWriter, r *http.Request) { as, err := s.store.GetAuthSettings() if err != nil { respondJSON(w, http.StatusOK, map[string]string{"auth_mode": "local"}) return } respondJSON(w, http.StatusOK, map[string]string{"auth_mode": as.AuthMode}) } // 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 } slog.Error("failed to get user", "error", err) respondError(w, http.StatusInternalServerError, "internal server 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 { slog.Error("failed to generate token", "error", err) respondError(w, http.StatusInternalServerError, "internal server 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 { slog.Error("failed to get user", "error", err) respondError(w, http.StatusInternalServerError, "internal server 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, Secure: 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 { slog.Error("failed to create user", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } } else { slog.Error("failed to get user", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } } token, err := s.localAuth.GenerateToken(auth.Claims{ UserID: user.ID, Username: user.Username, Role: user.Role, }) if err != nil { slog.Error("failed to generate token", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } // 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: "/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 { slog.Error("failed to get auth settings", "error", err) respondError(w, http.StatusInternalServerError, "internal server 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 encrypted value. if req.OIDCClientSecret == "********" || req.OIDCClientSecret == "" { existing, err := s.store.GetAuthSettings() if err == nil { req.OIDCClientSecret = existing.OIDCClientSecret } } else { // Encrypt the new client secret before storage. encrypted, err := crypto.Encrypt(s.encKey, req.OIDCClientSecret) if err != nil { respondError(w, http.StatusInternalServerError, "failed to encrypt OIDC client secret") return } // Keep plaintext for OIDC init below, store encrypted. plaintextSecret := req.OIDCClientSecret req.OIDCClientSecret = encrypted defer func() { req.OIDCClientSecret = plaintextSecret }() } if err := s.store.UpdateAuthSettings(req); err != nil { slog.Error("failed to update auth settings", "error", err) respondError(w, http.StatusInternalServerError, "internal server 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 { slog.Error("failed to list users", "error", err) respondError(w, http.StatusInternalServerError, "internal server 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 err := validatePassword(req.Password); err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } if req.Role == "" { req.Role = "viewer" } if req.Role != "admin" && req.Role != "viewer" { respondError(w, http.StatusBadRequest, "role must be 'admin' or 'viewer'") 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 } user, err := s.store.CreateUser(store.User{ Username: req.Username, PasswordHash: hash, Email: req.Email, Role: req.Role, }) if err != nil { slog.Error("failed to create user", "error", err) respondError(w, http.StatusInternalServerError, "internal server 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 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 { 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 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 { 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) }