Provider auth: require authenticated session for OAuth login/logout (#33115)

Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
Tilo Alexander 2026-08-24 15:47:06 +02:00 • committed by GitHub
parent 226425e22e
commit 4adbf141d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 65 additions and 17 deletions

View file

@ -481,10 +481,11 @@ func runRoot(cmd *cobra.Command, args []string) {
once.Do(func() { close(stopC) }) // signal loop to end once.Do(func() { close(stopC) }) // signal loop to end
}() }()
// allow web access for vehicles
configureAuth(httpd.Router(), valueChan)
authObject := auth.New() authObject := auth.New()
// allow web access for vehicles
configureAuth(httpd.Router(), server.EnsureAuthHandler(authObject), valueChan)
if ok, _ := cmd.Flags().GetBool(flagDisableAuth); ok { if ok, _ := cmd.Flags().GetBool(flagDisableAuth); ok {
log.WARN.Println("❗❗❗ Authentication is disabled. This is dangerous. Your data and credentials are not protected.") log.WARN.Println("❗❗❗ Authentication is disabled. This is dangerous. Your data and credentials are not protected.")
authObject.SetAuthMode(auth.Disabled) authObject.SetAuthMode(auth.Disabled)

View file

@ -1544,7 +1544,7 @@ func configureLoadpoints(conf globalconfig.All) error {
} }
// configureAuth handles routing for devices. For now only api.AuthProvider related routes // configureAuth handles routing for devices. For now only api.AuthProvider related routes
func configureAuth(router *mux.Router, paramC chan<- util.Param) { func configureAuth(router *mux.Router, authMiddleware mux.MiddlewareFunc, paramC chan<- util.Param) {
auth := router.PathPrefix("/providerauth").Subrouter() auth := router.PathPrefix("/providerauth").Subrouter()
auth.Use(handlers.CompressHandler) auth.Use(handlers.CompressHandler)
auth.Use(handlers.CORS( auth.Use(handlers.CORS(
@ -1554,8 +1554,8 @@ func configureAuth(router *mux.Router, paramC chan<- util.Param) {
// backwards-compatible revert of https://github.com/evcc-io/evcc/pull/21266 // backwards-compatible revert of https://github.com/evcc-io/evcc/pull/21266
router.PathPrefix("/oauth").Handler(auth) router.PathPrefix("/oauth").Handler(auth)
// wire the handler // wire the handler; login/logout require an authenticated session
providerauth.Setup(auth, paramC) providerauth.Setup(auth, paramC, authMiddleware)
} }
// isExperimental returns if experimental features are enabled // isExperimental returns if experimental features are enabled

View file

@ -320,14 +320,14 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti
} }
// API key endpoints require an authenticated session. // API key endpoints require an authenticated session.
ensureAuth := ensureAuthHandler(auth) ensureAuth := EnsureAuthHandler(auth)
api.Methods("GET").Path("/apikey").Handler(ensureAuth(apiKeyStatusHandler(auth))) api.Methods("GET").Path("/apikey").Handler(ensureAuth(apiKeyStatusHandler(auth)))
api.Methods("POST").Path("/apikey").Handler(ensureAuth(regenerateApiKeyHandler(auth))) api.Methods("POST").Path("/apikey").Handler(ensureAuth(regenerateApiKeyHandler(auth)))
} }
{ // api/config { // api/config
api := api.PathPrefix("/config").Subrouter() api := api.PathPrefix("/config").Subrouter()
api.Use(ensureAuthHandler(auth)) api.Use(EnsureAuthHandler(auth))
routes := map[string]route{ routes := map[string]route{
"auth": {"POST", "/auth", authHandler}, "auth": {"POST", "/auth", authHandler},
@ -423,7 +423,7 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti
{ // api/system { // api/system
api := api.PathPrefix("/system").Subrouter() api := api.PathPrefix("/system").Subrouter()
api.Use(ensureAuthHandler(auth)) api.Use(EnsureAuthHandler(auth))
routes := map[string]route{ routes := map[string]route{
"log": {"GET", "/log", logHandler}, "log": {"GET", "/log", logHandler},

View file

@ -186,7 +186,8 @@ func logoutHandler(w http.ResponseWriter, r *http.Request) {
}) })
} }
func ensureAuthHandler(authObject auth.Auth) mux.MiddlewareFunc { // EnsureAuthHandler returns middleware that rejects unauthenticated requests
func EnsureAuthHandler(authObject auth.Auth) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if next == nil { if next == nil {

View file

@ -75,3 +75,15 @@ func TestRequireCriticalConfig(t *testing.T) {
}) })
} }
} }
func TestEnsureAuthHandler(t *testing.T) {
get := func(a auth.Auth) int {
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
rec := httptest.NewRecorder()
EnsureAuthHandler(a)(next).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
return rec.Code
}
assert.Equal(t, http.StatusUnauthorized, get(fakeAuth{mode: auth.Enabled}), "enabled must reject without credentials")
assert.Equal(t, http.StatusOK, get(fakeAuth{mode: auth.Disabled}), "disabled must pass through")
}

View file

@ -32,14 +32,14 @@ func init() {
} }
} }
// Setup connects the redirect handler to the router and registers the callback channel // Setup connects the redirect handler to the router and registers the callback channel.
func Setup(router *mux.Router, paramC chan<- util.Param) { // Callback stays open: the cross-site IdP redirect carries no session cookie, the state token gates it.
// callback?code=...&state=... func Setup(router *mux.Router, paramC chan<- util.Param, authMiddleware mux.MiddlewareFunc) {
gate := func(h http.HandlerFunc) http.Handler { return authMiddleware(h) }
router.Methods(http.MethodGet).Path("/callback").HandlerFunc(instance.handleCallback) router.Methods(http.MethodGet).Path("/callback").HandlerFunc(instance.handleCallback)
// login?id=... router.Methods(http.MethodGet).Path("/login").Handler(gate(instance.handleLogin))
router.Methods(http.MethodGet).Path("/login").HandlerFunc(instance.handleLogin) router.Methods(http.MethodGet).Path("/logout").Handler(gate(instance.handleLogout))
// logout?id=...
router.Methods(http.MethodGet).Path("/logout").HandlerFunc(instance.handleLogout)
go instance.run(paramC) go instance.run(paramC)
} }

View file

@ -0,0 +1,34 @@
package providerauth
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/evcc-io/evcc/util"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
)
// stand-in auth middleware rejecting every request
func blockAll(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
})
}
// login/logout are gated, callback stays open (302 to error page, not middleware 401)
func TestSetupGating(t *testing.T) {
router := mux.NewRouter()
Setup(router, make(chan util.Param, 1), blockAll)
get := func(path string) int {
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
return rec.Code
}
assert.Equal(t, http.StatusUnauthorized, get("/login?id=x"), "login must be gated")
assert.Equal(t, http.StatusUnauthorized, get("/logout?id=x"), "logout must be gated")
assert.Equal(t, http.StatusFound, get("/callback"), "callback must stay open (gated by state token, not session)")
}