diff --git a/OliveTin.proto b/OliveTin.proto index 3274605..9ae4116 100644 --- a/OliveTin.proto +++ b/OliveTin.proto @@ -46,6 +46,7 @@ message GetDashboardComponentsResponse { repeated DashboardComponent dashboards = 4; string authenticated_user = 5; + string authenticated_user_provider = 6; } message GetDashboardComponentsRequest {} @@ -225,6 +226,8 @@ message PasswordHashRequest { message PasswordHashResponse { } +message LogoutRequest {} + service OliveTinApiService { rpc GetDashboardComponents(GetDashboardComponentsRequest) returns (GetDashboardComponentsResponse) { option (google.api.http) = { @@ -328,4 +331,10 @@ service OliveTinApiService { body: "*" }; } + + rpc Logout(LogoutRequest) returns (google.api.HttpBody) { + option (google.api.http) = { + get: "/api/Logout" + }; + } } diff --git a/internal/acl/acl.go b/internal/acl/acl.go index c720a2a..22891be 100644 --- a/internal/acl/acl.go +++ b/internal/acl/acl.go @@ -27,6 +27,9 @@ type AuthenticatedUser struct { Username string Usergroup string + Provider string + SID string + acls []string } @@ -80,13 +83,15 @@ func permissionsConfigToBits(permissions config.PermissionsList) PermissionBits func aclCheck(requiredPermission PermissionBits, defaultValue bool, cfg *config.Config, aclFunction string, user *AuthenticatedUser, action *config.Action) bool { relevantAcls := getRelevantAcls(cfg, action.Acls, user) - log.WithFields(log.Fields{ - "actionTitle": action.Title, - "username": user.Username, - "usergroup": user.Usergroup, - "relevantAcls": len(relevantAcls), - "requiredPermission": requiredPermission, - }).Debugf("ACL check - %v", aclFunction) + if cfg.LogDebugOptions.AclCheckStarted { + log.WithFields(log.Fields{ + "actionTitle": action.Title, + "username": user.Username, + "usergroup": user.Usergroup, + "relevantAcls": len(relevantAcls), + "requiredPermission": requiredPermission, + }).Debugf("ACL check - %v", aclFunction) + } for _, acl := range relevantAcls { permissionBits := permissionsConfigToBits(acl.Permissions) @@ -134,10 +139,6 @@ func getMetadataKeyOrEmpty(md metadata.MD, key string) string { return "" } -func SetUserFromMetadata(md metadata.MD) string { - return getMetadataKeyOrEmpty(md, "set-user") -} - // UserFromContext tries to find a user from a grpc context func UserFromContext(ctx context.Context, cfg *config.Config) *AuthenticatedUser { var ret *AuthenticatedUser @@ -148,6 +149,7 @@ func UserFromContext(ctx context.Context, cfg *config.Config) *AuthenticatedUser ret = &AuthenticatedUser{} ret.Username = getMetadataKeyOrEmpty(md, "username") ret.Usergroup = getMetadataKeyOrEmpty(md, "usergroup") + ret.Provider = getMetadataKeyOrEmpty(md, "provider") buildUserAcls(cfg, ret) } @@ -159,6 +161,7 @@ func UserFromContext(ctx context.Context, cfg *config.Config) *AuthenticatedUser log.WithFields(log.Fields{ "username": ret.Username, "usergroup": ret.Usergroup, + "provider": ret.Provider, }).Debugf("UserFromContext") return ret @@ -168,6 +171,7 @@ func UserGuest(cfg *config.Config) *AuthenticatedUser { ret := &AuthenticatedUser{} ret.Username = "guest" ret.Usergroup = "guest" + ret.Provider = "system" buildUserAcls(cfg, ret) @@ -178,6 +182,7 @@ func UserFromSystem(cfg *config.Config, username string) *AuthenticatedUser { ret := &AuthenticatedUser{ Username: username, Usergroup: "system", + Provider: "system", } buildUserAcls(cfg, ret) diff --git a/internal/config/config.go b/internal/config/config.go index c68c35d..7655992 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -178,6 +178,7 @@ type SaveLogsConfig struct { type LogDebugOptions struct { SingleFrontendRequests bool SingleFrontendRequestHeaders bool + AclCheckStarted bool AclMatched bool AclNotMatched bool AclNoneMatched bool diff --git a/internal/grpcapi/grpcApi.go b/internal/grpcapi/grpcApi.go index 6580c16..17df0a0 100644 --- a/internal/grpcapi/grpcApi.go +++ b/internal/grpcapi/grpcApi.go @@ -284,6 +284,15 @@ func (api *oliveTinAPI) WatchExecution(req *pb.WatchExecutionRequest, srv pb.Oli } */ +func (api *oliveTinAPI) Logout(ctx ctx.Context, req *pb.LogoutRequest) (*httpbody.HttpBody, error) { + user := acl.UserFromContext(ctx, cfg) + + grpc.SendHeader(ctx, metadata.Pairs("logout-provider", user.Provider)) + grpc.SendHeader(ctx, metadata.Pairs("logout-sid", user.SID)) + + return nil, nil +} + func (api *oliveTinAPI) GetDashboardComponents(ctx ctx.Context, req *pb.GetDashboardComponentsRequest) (*pb.GetDashboardComponentsResponse, error) { user := acl.UserFromContext(ctx, cfg) @@ -298,6 +307,7 @@ func (api *oliveTinAPI) GetDashboardComponents(ctx ctx.Context, req *pb.GetDashb dashboardCfgToPb(res, cfg.Dashboards, cfg) res.AuthenticatedUser = user.Username + res.AuthenticatedUserProvider = user.Provider if res.AuthenticatedUser == "guest" && !cfg.AuthAllowGuest { return nil, status.Errorf(codes.PermissionDenied, "Unauthenticated") diff --git a/internal/httpservers/restapi.go b/internal/httpservers/restapi.go index 9052b7c..4a7bd43 100644 --- a/internal/httpservers/restapi.go +++ b/internal/httpservers/restapi.go @@ -50,26 +50,34 @@ func parseHttpHeaderForAuth(req *http.Request) (string, string) { func parseRequestMetadata(ctx context.Context, req *http.Request) metadata.MD { username := "" usergroup := "" + provider := "unknown" + sid := "" if cfg.AuthJwtCookieName != "" { username, usergroup = parseJwtCookie(req) + provider = "jwt-cookie" } if cfg.AuthHttpHeaderUsername != "" { username, usergroup = parseHttpHeaderForAuth(req) + provider = "http-header" } if len(cfg.AuthOAuth2Providers) > 0 { - username, usergroup = parseOAuth2Cookie(req) + username, usergroup, sid = parseOAuth2Cookie(req) + provider = "oauth2" } - if cfg.AuthLocalUsers.Enabled { - username, usergroup = parseLocalUserCookie(req) + if cfg.AuthLocalUsers.Enabled && username == "" { + username, usergroup, sid = parseLocalUserCookie(req) + provider = "local" } md := metadata.New(map[string]string{ "username": username, "usergroup": usergroup, + "provider": provider, + "sid": sid, }) log.Tracef("api request metadata: %+v", md) @@ -78,11 +86,57 @@ func parseRequestMetadata(ctx context.Context, req *http.Request) metadata.MD { } func forwardResponseHandler(ctx context.Context, w http.ResponseWriter, msg protoreflect.ProtoMessage) error { - forwardResponseHandlerLoginLocalUser(ctx, w, msg) + md, ok := runtime.ServerMetadataFromContext(ctx) + + if !ok { + log.Warn("Could not get ServerMetadata from context") + return nil + } + + forwardResponseHandlerLoginLocalUser(md.HeaderMD, w) + forwardResponseHandlerLogout(md.HeaderMD, w) return nil } +func forwardResponseHandlerLogout(md metadata.MD, w http.ResponseWriter) { + if getMetadataKeyOrEmpty(md, "logout-provider") != "" { + sid := getMetadataKeyOrEmpty(md, "logout-sid") + + delete(registeredStates, sid) + http.SetCookie( + w, + &http.Cookie{ + Name: "olivetin-sid-oauth", + Value: "", + }, + ) + + delete(localUserSessions, sid) + http.SetCookie( + w, + &http.Cookie{ + Name: "olivetin-sid-local", + Value: "", + }, + ) + + w.Header().Set("Content-Type", "text/html") + // We cannot send a HTTP redirect here, because we don't have access to req. + w.Write([]byte("")) + } +} + +func getMetadataKeyOrEmpty(md metadata.MD, key string) string { + mdValues := md.Get(key) + + if len(mdValues) > 0 { + return mdValues[0] + } + + return "" +} + func SetGlobalRestConfig(config *config.Config) { cfg = config } diff --git a/internal/httpservers/restapi_auth_local.go b/internal/httpservers/restapi_auth_local.go index 36fe891..8446687 100644 --- a/internal/httpservers/restapi_auth_local.go +++ b/internal/httpservers/restapi_auth_local.go @@ -1,25 +1,22 @@ package httpservers import ( - "context" + "google.golang.org/grpc/metadata" "net/http" "github.com/google/uuid" - "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" log "github.com/sirupsen/logrus" - - acl "github.com/OliveTin/OliveTin/internal/acl" - "google.golang.org/protobuf/reflect/protoreflect" ) var ( localUserSessions = make(map[string]string) // sid -> username, used for local user sessions ) -func parseLocalUserCookie(req *http.Request) (string, string) { - cookie, err := req.Cookie("olivetin_local_user_sid") +func parseLocalUserCookie(req *http.Request) (string, string, string) { + cookie, err := req.Cookie("olivetin-sid-local") + if err != nil { - return "", "" + return "", "", "" } cookieValue := cookie.Value @@ -28,30 +25,23 @@ func parseLocalUserCookie(req *http.Request) (string, string) { if !ok { log.Warnf("Could not find local user session: %v", cookieValue) - return "", "" + return "", "", "" } - return username, "" + return username, "", cookie.Value } -func forwardResponseHandlerLoginLocalUser(ctx context.Context, w http.ResponseWriter, msg protoreflect.ProtoMessage) error { - md, ok := runtime.ServerMetadataFromContext(ctx) - - if !ok { - log.Warn("Could not get ServerMetadata from context") - return nil - } - - setUser := acl.SetUserFromMetadata(md.HeaderMD) - - sid := uuid.NewString() - localUserSessions[sid] = setUser +func forwardResponseHandlerLoginLocalUser(md metadata.MD, w http.ResponseWriter) error { + setUser := getMetadataKeyOrEmpty(md, "set-user") if setUser != "" { + sid := uuid.NewString() + localUserSessions[sid] = setUser + http.SetCookie( w, &http.Cookie{ - Name: "olivetin_local_user_sid", + Name: "olivetin-sid-local", Value: sid, }, ) diff --git a/internal/httpservers/oauth2.go b/internal/httpservers/restapi_auth_oauth2.go similarity index 80% rename from internal/httpservers/oauth2.go rename to internal/httpservers/restapi_auth_oauth2.go index 1adb664..a572bed 100644 --- a/internal/httpservers/oauth2.go +++ b/internal/httpservers/restapi_auth_oauth2.go @@ -118,6 +118,7 @@ func handleOAuthLogin(w http.ResponseWriter, r *http.Request) { registeredStates[state] = &oauth2State{ provider: provider, + Username: "", } if err != nil { @@ -126,45 +127,46 @@ func handleOAuthLogin(w http.ResponseWriter, r *http.Request) { return } - setOauthCallbackCookie(w, r, "oauth2state", state) + setOauthCallbackCookie(w, r, "olivetin-sid-oauth", state) log.Infof("OAuth2 state: %v mapped to provider %v (found: %v), now redirecting", state, providerName, provider != nil) http.Redirect(w, r, provider.AuthCodeURL(state), http.StatusFound) } -func checkOAuthCallbackCookie(w http.ResponseWriter, r *http.Request) (*oauth2State, bool) { - state, err := r.Cookie("oauth2state") +func checkOAuthCallbackCookie(w http.ResponseWriter, r *http.Request) (*oauth2State, string, bool) { + cookie, err := r.Cookie("olivetin-sid-oauth") + state := cookie.Value if err != nil { log.Errorf("Failed to get state cookie: %v", err) http.Error(w, "State not found", http.StatusBadRequest) - return nil, false + return nil, state, false } - if r.URL.Query().Get("state") != state.Value { - log.Errorf("State mismatch: %v != %v", r.URL.Query().Get("state"), state.Value) + if r.URL.Query().Get("state") != state { + log.Errorf("State mismatch: %v != %v", r.URL.Query().Get("state"), state) http.Error(w, "State mismatch", http.StatusBadRequest) - return nil, false + return nil, state, false } - registeredState, ok := registeredStates[state.Value] + registeredState, ok := registeredStates[state] if !ok { - log.Errorf("State not found in server: %v", state.Value) + log.Errorf("State not found in server: %v", state) http.Error(w, "State not found in server", http.StatusBadRequest) } - return registeredState, true + return registeredState, state, true } func handleOAuthCallback(w http.ResponseWriter, r *http.Request) { log.Infof("OAuth2 Callback received") - registeredState, ok := checkOAuthCallbackCookie(w, r) + registeredState, state, ok := checkOAuthCallbackCookie(w, r) if !ok { return @@ -172,7 +174,10 @@ func handleOAuthCallback(w http.ResponseWriter, r *http.Request) { code := r.FormValue("code") - log.Debugf("OAuth2 Token Code: %v", code) + log.WithFields(log.Fields{ + "state": state, + "token-code": code, + }).Debug("OAuth2 Token Code") httpClient := &http.Client{Timeout: 2 * time.Second} ctx := context.Background() @@ -188,12 +193,21 @@ func handleOAuthCallback(w http.ResponseWriter, r *http.Request) { client := registeredState.provider.Client(ctx, tok) - registeredState.Username = getUsername(client) + username := getUsername(client) - loginMessage := fmt.Sprintf("Logged in as %v", registeredState.Username) + registeredStates[state].Username = username - log.Infof(loginMessage) + for k, v := range registeredStates { + log.Debugf("states: %+v %+v", k, v) + } + loginMessage := fmt.Sprintf("OAuth2 login complete for %v", registeredStates[state].Username) + + log.WithFields(log.Fields{ + "state": state, + }).Infof(loginMessage) + + http.Redirect(w, r, "/", http.StatusFound) w.Write([]byte(loginMessage)) } @@ -232,20 +246,22 @@ func getUsername(client *http.Client) string { return username.(string) } -func parseOAuth2Cookie(r *http.Request) (string, string) { - cookie, err := r.Cookie("oauth2state") +func parseOAuth2Cookie(r *http.Request) (string, string, string) { + cookie, err := r.Cookie("olivetin-sid-oauth") if err != nil { log.Warnf("Failed to read OAuth2 cookie: %v", err) - return "", "" + return "", "", "" } serverState, found := registeredStates[cookie.Value] if !found { log.Warnf("Failed to find OAuth2 state: %v", cookie.Value) - return "", "" + return "", "", cookie.Value } - return serverState.Username, serverState.Usergroup + log.Debugf("Found OAuth2 state: %+v", serverState) + + return serverState.Username, serverState.Usergroup, cookie.Value } diff --git a/internal/httpservers/oauth2_providers.go b/internal/httpservers/restapi_auth_oauth2_providers.go similarity index 100% rename from internal/httpservers/oauth2_providers.go rename to internal/httpservers/restapi_auth_oauth2_providers.go diff --git a/webui.dev/index.html b/webui.dev/index.html index fc04143..8d708ff 100644 --- a/webui.dev/index.html +++ b/webui.dev/index.html @@ -44,7 +44,12 @@ -   +
+ + +   + +
diff --git a/webui.dev/js/LoginForm.js b/webui.dev/js/LoginForm.js index 81d2abe..d908c0e 100644 --- a/webui.dev/js/LoginForm.js +++ b/webui.dev/js/LoginForm.js @@ -40,7 +40,7 @@ export class LoginForm extends window.HTMLElement { } }).then((res) => { if (res.success) { - window.location.reload() + window.location.href = '/' } else { document.querySelector('.error').innerHTML = 'Login failed.' } @@ -59,7 +59,14 @@ export class LoginForm extends window.HTMLElement { for (const provider of providers) { const providerForm = document.createElement('form') providerForm.method = 'GET' - providerForm.action = '/oauth2?provider=' + provider.Name + providerForm.action = '/oauth/login' + + const hiddenField = document.createElement('input') + hiddenField.type = 'hidden' + hiddenField.name = 'provider' + hiddenField.value = provider.Name + + providerForm.appendChild(hiddenField) const providerButton = document.createElement('button') providerButton.type = 'submit' diff --git a/webui.dev/js/marshaller.js b/webui.dev/js/marshaller.js index 4cd5636..8c82631 100644 --- a/webui.dev/js/marshaller.js +++ b/webui.dev/js/marshaller.js @@ -70,6 +70,21 @@ export function marshalDashboardComponentsJsonToHtml (json) { document.getElementById('username').innerText = json.authenticatedUser + if (window.settings.AuthLocalLogin || window.settings.AuthLocalRegister != null) { + if (json.authenticatedUser === 'guest') { + document.getElementById('link-login').hidden = false + document.getElementById('link-logout').hidden = true + } else { + document.getElementById('link-login').hidden = true + + if (json.authenticatedUserProvider === 'local' || json.authenticatedUserProvider === 'oauth2') { + document.getElementById('link-logout').hidden = false + } + } + + document.getElementById('username').setAttribute('title', json.authenticatedUserProvider) + } + document.body.setAttribute('initial-marshal-complete', 'true') } diff --git a/webui.dev/style.css b/webui.dev/style.css index d400490..8280def 100644 --- a/webui.dev/style.css +++ b/webui.dev/style.css @@ -98,11 +98,15 @@ nav ul li a.selected { padding: 0; } -#username { - margin-right: 1em; +.userinfo { + padding-right: 1em; font-size: small; } +.userinfo svg, .userinfo span { + vertical-align: middle; +} + nav { left: -250px; } @@ -150,6 +154,7 @@ h1 { font-size: small; padding-left: .5em; flex-grow: 1; + margin: 0; } dialog h1 {