Răsfoiți Sursa

feature: Login/Logout links (#443)

* feature: Login/Logout links

* cicd: codestyle
James Read 1 an în urmă
părinte
comite
0283b51eca

+ 9 - 0
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"
+        };
+    }
 }

+ 16 - 11
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)

+ 1 - 0
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

+ 10 - 0
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")

+ 58 - 4
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("<script>window.location.href = '/';</script>"))
+	}
+}
+
+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
 }

+ 13 - 23
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,
 			},
 		)

+ 36 - 20
internal/httpservers/oauth2.go → 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
 }

+ 0 - 0
internal/httpservers/oauth2_providers.go → internal/httpservers/restapi_auth_oauth2_providers.go


+ 6 - 1
webui.dev/index.html

@@ -44,7 +44,12 @@
 				</ul>
 			</nav>
 
-			<span id = "username">&nbsp;</span>
+			<div class = "userinfo">
+				<span id = "link-login" hidden><a href = "/login">Login</a> |</span>
+				<span id = "link-logout" hidden><a href = "/api/Logout">Logout</a> |</span>
+				<span id = "username">&nbsp;</span>
+				<svg xmlns="http://www.w3.org/2000/svg" width="1.5em" height="1.5em" viewBox="0 0 24 24"><g fill="none" fill-rule="evenodd"><path d="m12.594 23.258l-.012.002l-.071.035l-.02.004l-.014-.004l-.071-.036q-.016-.004-.024.006l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.016-.018m.264-.113l-.014.002l-.184.093l-.01.01l-.003.011l.018.43l.005.012l.008.008l.201.092q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.003-.011l.018-.43l-.003-.012l-.01-.01z"/><path fill="currentColor" d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10s10-4.477 10-10S17.523 2 12 2M8.5 9.5a3.5 3.5 0 1 1 7 0a3.5 3.5 0 0 1-7 0m9.758 7.484A7.99 7.99 0 0 1 12 20a7.99 7.99 0 0 1-6.258-3.016C7.363 15.821 9.575 15 12 15s4.637.821 6.258 1.984"/></g></svg>
+			</div>
 		</header>
 
 		<main title = "main content">

+ 9 - 2
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'

+ 15 - 0
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')
 }
 

+ 7 - 2
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 {