feature: Login/Logout links (#443)

* feature: Login/Logout links

* cicd: codestyle
This commit is contained in:
James Read 2024-10-19 08:49:41 +01:00 committed by GitHub
parent 1af2e92132
commit 0283b51eca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 180 additions and 63 deletions

View File

@ -46,6 +46,7 @@ message GetDashboardComponentsResponse {
repeated DashboardComponent dashboards = 4; repeated DashboardComponent dashboards = 4;
string authenticated_user = 5; string authenticated_user = 5;
string authenticated_user_provider = 6;
} }
message GetDashboardComponentsRequest {} message GetDashboardComponentsRequest {}
@ -225,6 +226,8 @@ message PasswordHashRequest {
message PasswordHashResponse { message PasswordHashResponse {
} }
message LogoutRequest {}
service OliveTinApiService { service OliveTinApiService {
rpc GetDashboardComponents(GetDashboardComponentsRequest) returns (GetDashboardComponentsResponse) { rpc GetDashboardComponents(GetDashboardComponentsRequest) returns (GetDashboardComponentsResponse) {
option (google.api.http) = { option (google.api.http) = {
@ -328,4 +331,10 @@ service OliveTinApiService {
body: "*" body: "*"
}; };
} }
rpc Logout(LogoutRequest) returns (google.api.HttpBody) {
option (google.api.http) = {
get: "/api/Logout"
};
}
} }

View File

@ -27,6 +27,9 @@ type AuthenticatedUser struct {
Username string Username string
Usergroup string Usergroup string
Provider string
SID string
acls []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 { func aclCheck(requiredPermission PermissionBits, defaultValue bool, cfg *config.Config, aclFunction string, user *AuthenticatedUser, action *config.Action) bool {
relevantAcls := getRelevantAcls(cfg, action.Acls, user) relevantAcls := getRelevantAcls(cfg, action.Acls, user)
log.WithFields(log.Fields{ if cfg.LogDebugOptions.AclCheckStarted {
"actionTitle": action.Title, log.WithFields(log.Fields{
"username": user.Username, "actionTitle": action.Title,
"usergroup": user.Usergroup, "username": user.Username,
"relevantAcls": len(relevantAcls), "usergroup": user.Usergroup,
"requiredPermission": requiredPermission, "relevantAcls": len(relevantAcls),
}).Debugf("ACL check - %v", aclFunction) "requiredPermission": requiredPermission,
}).Debugf("ACL check - %v", aclFunction)
}
for _, acl := range relevantAcls { for _, acl := range relevantAcls {
permissionBits := permissionsConfigToBits(acl.Permissions) permissionBits := permissionsConfigToBits(acl.Permissions)
@ -134,10 +139,6 @@ func getMetadataKeyOrEmpty(md metadata.MD, key string) string {
return "" return ""
} }
func SetUserFromMetadata(md metadata.MD) string {
return getMetadataKeyOrEmpty(md, "set-user")
}
// UserFromContext tries to find a user from a grpc context // UserFromContext tries to find a user from a grpc context
func UserFromContext(ctx context.Context, cfg *config.Config) *AuthenticatedUser { func UserFromContext(ctx context.Context, cfg *config.Config) *AuthenticatedUser {
var ret *AuthenticatedUser var ret *AuthenticatedUser
@ -148,6 +149,7 @@ func UserFromContext(ctx context.Context, cfg *config.Config) *AuthenticatedUser
ret = &AuthenticatedUser{} ret = &AuthenticatedUser{}
ret.Username = getMetadataKeyOrEmpty(md, "username") ret.Username = getMetadataKeyOrEmpty(md, "username")
ret.Usergroup = getMetadataKeyOrEmpty(md, "usergroup") ret.Usergroup = getMetadataKeyOrEmpty(md, "usergroup")
ret.Provider = getMetadataKeyOrEmpty(md, "provider")
buildUserAcls(cfg, ret) buildUserAcls(cfg, ret)
} }
@ -159,6 +161,7 @@ func UserFromContext(ctx context.Context, cfg *config.Config) *AuthenticatedUser
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"username": ret.Username, "username": ret.Username,
"usergroup": ret.Usergroup, "usergroup": ret.Usergroup,
"provider": ret.Provider,
}).Debugf("UserFromContext") }).Debugf("UserFromContext")
return ret return ret
@ -168,6 +171,7 @@ func UserGuest(cfg *config.Config) *AuthenticatedUser {
ret := &AuthenticatedUser{} ret := &AuthenticatedUser{}
ret.Username = "guest" ret.Username = "guest"
ret.Usergroup = "guest" ret.Usergroup = "guest"
ret.Provider = "system"
buildUserAcls(cfg, ret) buildUserAcls(cfg, ret)
@ -178,6 +182,7 @@ func UserFromSystem(cfg *config.Config, username string) *AuthenticatedUser {
ret := &AuthenticatedUser{ ret := &AuthenticatedUser{
Username: username, Username: username,
Usergroup: "system", Usergroup: "system",
Provider: "system",
} }
buildUserAcls(cfg, ret) buildUserAcls(cfg, ret)

View File

@ -178,6 +178,7 @@ type SaveLogsConfig struct {
type LogDebugOptions struct { type LogDebugOptions struct {
SingleFrontendRequests bool SingleFrontendRequests bool
SingleFrontendRequestHeaders bool SingleFrontendRequestHeaders bool
AclCheckStarted bool
AclMatched bool AclMatched bool
AclNotMatched bool AclNotMatched bool
AclNoneMatched bool AclNoneMatched bool

View File

@ -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) { func (api *oliveTinAPI) GetDashboardComponents(ctx ctx.Context, req *pb.GetDashboardComponentsRequest) (*pb.GetDashboardComponentsResponse, error) {
user := acl.UserFromContext(ctx, cfg) user := acl.UserFromContext(ctx, cfg)
@ -298,6 +307,7 @@ func (api *oliveTinAPI) GetDashboardComponents(ctx ctx.Context, req *pb.GetDashb
dashboardCfgToPb(res, cfg.Dashboards, cfg) dashboardCfgToPb(res, cfg.Dashboards, cfg)
res.AuthenticatedUser = user.Username res.AuthenticatedUser = user.Username
res.AuthenticatedUserProvider = user.Provider
if res.AuthenticatedUser == "guest" && !cfg.AuthAllowGuest { if res.AuthenticatedUser == "guest" && !cfg.AuthAllowGuest {
return nil, status.Errorf(codes.PermissionDenied, "Unauthenticated") return nil, status.Errorf(codes.PermissionDenied, "Unauthenticated")

View File

@ -50,26 +50,34 @@ func parseHttpHeaderForAuth(req *http.Request) (string, string) {
func parseRequestMetadata(ctx context.Context, req *http.Request) metadata.MD { func parseRequestMetadata(ctx context.Context, req *http.Request) metadata.MD {
username := "" username := ""
usergroup := "" usergroup := ""
provider := "unknown"
sid := ""
if cfg.AuthJwtCookieName != "" { if cfg.AuthJwtCookieName != "" {
username, usergroup = parseJwtCookie(req) username, usergroup = parseJwtCookie(req)
provider = "jwt-cookie"
} }
if cfg.AuthHttpHeaderUsername != "" { if cfg.AuthHttpHeaderUsername != "" {
username, usergroup = parseHttpHeaderForAuth(req) username, usergroup = parseHttpHeaderForAuth(req)
provider = "http-header"
} }
if len(cfg.AuthOAuth2Providers) > 0 { if len(cfg.AuthOAuth2Providers) > 0 {
username, usergroup = parseOAuth2Cookie(req) username, usergroup, sid = parseOAuth2Cookie(req)
provider = "oauth2"
} }
if cfg.AuthLocalUsers.Enabled { if cfg.AuthLocalUsers.Enabled && username == "" {
username, usergroup = parseLocalUserCookie(req) username, usergroup, sid = parseLocalUserCookie(req)
provider = "local"
} }
md := metadata.New(map[string]string{ md := metadata.New(map[string]string{
"username": username, "username": username,
"usergroup": usergroup, "usergroup": usergroup,
"provider": provider,
"sid": sid,
}) })
log.Tracef("api request metadata: %+v", md) 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 { 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 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) { func SetGlobalRestConfig(config *config.Config) {
cfg = config cfg = config
} }

View File

@ -1,25 +1,22 @@
package httpservers package httpservers
import ( import (
"context" "google.golang.org/grpc/metadata"
"net/http" "net/http"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
acl "github.com/OliveTin/OliveTin/internal/acl"
"google.golang.org/protobuf/reflect/protoreflect"
) )
var ( var (
localUserSessions = make(map[string]string) // sid -> username, used for local user sessions localUserSessions = make(map[string]string) // sid -> username, used for local user sessions
) )
func parseLocalUserCookie(req *http.Request) (string, string) { func parseLocalUserCookie(req *http.Request) (string, string, string) {
cookie, err := req.Cookie("olivetin_local_user_sid") cookie, err := req.Cookie("olivetin-sid-local")
if err != nil { if err != nil {
return "", "" return "", "", ""
} }
cookieValue := cookie.Value cookieValue := cookie.Value
@ -28,30 +25,23 @@ func parseLocalUserCookie(req *http.Request) (string, string) {
if !ok { if !ok {
log.Warnf("Could not find local user session: %v", cookieValue) 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 { func forwardResponseHandlerLoginLocalUser(md metadata.MD, w http.ResponseWriter) error {
md, ok := runtime.ServerMetadataFromContext(ctx) setUser := getMetadataKeyOrEmpty(md, "set-user")
if !ok {
log.Warn("Could not get ServerMetadata from context")
return nil
}
setUser := acl.SetUserFromMetadata(md.HeaderMD)
sid := uuid.NewString()
localUserSessions[sid] = setUser
if setUser != "" { if setUser != "" {
sid := uuid.NewString()
localUserSessions[sid] = setUser
http.SetCookie( http.SetCookie(
w, w,
&http.Cookie{ &http.Cookie{
Name: "olivetin_local_user_sid", Name: "olivetin-sid-local",
Value: sid, Value: sid,
}, },
) )

View File

@ -118,6 +118,7 @@ func handleOAuthLogin(w http.ResponseWriter, r *http.Request) {
registeredStates[state] = &oauth2State{ registeredStates[state] = &oauth2State{
provider: provider, provider: provider,
Username: "",
} }
if err != nil { if err != nil {
@ -126,45 +127,46 @@ func handleOAuthLogin(w http.ResponseWriter, r *http.Request) {
return 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) 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) http.Redirect(w, r, provider.AuthCodeURL(state), http.StatusFound)
} }
func checkOAuthCallbackCookie(w http.ResponseWriter, r *http.Request) (*oauth2State, bool) { func checkOAuthCallbackCookie(w http.ResponseWriter, r *http.Request) (*oauth2State, string, bool) {
state, err := r.Cookie("oauth2state") cookie, err := r.Cookie("olivetin-sid-oauth")
state := cookie.Value
if err != nil { if err != nil {
log.Errorf("Failed to get state cookie: %v", err) log.Errorf("Failed to get state cookie: %v", err)
http.Error(w, "State not found", http.StatusBadRequest) http.Error(w, "State not found", http.StatusBadRequest)
return nil, false return nil, state, false
} }
if 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.Value) log.Errorf("State mismatch: %v != %v", r.URL.Query().Get("state"), state)
http.Error(w, "State mismatch", http.StatusBadRequest) 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 { 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) 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) { func handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
log.Infof("OAuth2 Callback received") log.Infof("OAuth2 Callback received")
registeredState, ok := checkOAuthCallbackCookie(w, r) registeredState, state, ok := checkOAuthCallbackCookie(w, r)
if !ok { if !ok {
return return
@ -172,7 +174,10 @@ func handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
code := r.FormValue("code") 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} httpClient := &http.Client{Timeout: 2 * time.Second}
ctx := context.Background() ctx := context.Background()
@ -188,12 +193,21 @@ func handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
client := registeredState.provider.Client(ctx, tok) 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)) w.Write([]byte(loginMessage))
} }
@ -232,20 +246,22 @@ func getUsername(client *http.Client) string {
return username.(string) return username.(string)
} }
func parseOAuth2Cookie(r *http.Request) (string, string) { func parseOAuth2Cookie(r *http.Request) (string, string, string) {
cookie, err := r.Cookie("oauth2state") cookie, err := r.Cookie("olivetin-sid-oauth")
if err != nil { if err != nil {
log.Warnf("Failed to read OAuth2 cookie: %v", err) log.Warnf("Failed to read OAuth2 cookie: %v", err)
return "", "" return "", "", ""
} }
serverState, found := registeredStates[cookie.Value] serverState, found := registeredStates[cookie.Value]
if !found { if !found {
log.Warnf("Failed to find OAuth2 state: %v", cookie.Value) 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
} }

View File

@ -44,7 +44,12 @@
</ul> </ul>
</nav> </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> </header>
<main title = "main content"> <main title = "main content">

View File

@ -40,7 +40,7 @@ export class LoginForm extends window.HTMLElement {
} }
}).then((res) => { }).then((res) => {
if (res.success) { if (res.success) {
window.location.reload() window.location.href = '/'
} else { } else {
document.querySelector('.error').innerHTML = 'Login failed.' document.querySelector('.error').innerHTML = 'Login failed.'
} }
@ -59,7 +59,14 @@ export class LoginForm extends window.HTMLElement {
for (const provider of providers) { for (const provider of providers) {
const providerForm = document.createElement('form') const providerForm = document.createElement('form')
providerForm.method = 'GET' 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') const providerButton = document.createElement('button')
providerButton.type = 'submit' providerButton.type = 'submit'

View File

@ -70,6 +70,21 @@ export function marshalDashboardComponentsJsonToHtml (json) {
document.getElementById('username').innerText = json.authenticatedUser 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') document.body.setAttribute('initial-marshal-complete', 'true')
} }

View File

@ -98,11 +98,15 @@ nav ul li a.selected {
padding: 0; padding: 0;
} }
#username { .userinfo {
margin-right: 1em; padding-right: 1em;
font-size: small; font-size: small;
} }
.userinfo svg, .userinfo span {
vertical-align: middle;
}
nav { nav {
left: -250px; left: -250px;
} }
@ -150,6 +154,7 @@ h1 {
font-size: small; font-size: small;
padding-left: .5em; padding-left: .5em;
flex-grow: 1; flex-grow: 1;
margin: 0;
} }
dialog h1 { dialog h1 {