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
12 changed files with 180 additions and 63 deletions

View File

@@ -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"
};
}
}

View File

@@ -27,6 +27,9 @@ type AuthenticatedUser struct {
Username string
Usergroup string
Provider string
SID string
acls []string
}
@@ -80,6 +83,7 @@ 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)
if cfg.LogDebugOptions.AclCheckStarted {
log.WithFields(log.Fields{
"actionTitle": action.Title,
"username": user.Username,
@@ -87,6 +91,7 @@ func aclCheck(requiredPermission PermissionBits, defaultValue bool, cfg *config.
"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)

View File

@@ -178,6 +178,7 @@ type SaveLogsConfig struct {
type LogDebugOptions struct {
SingleFrontendRequests bool
SingleFrontendRequestHeaders bool
AclCheckStarted bool
AclMatched bool
AclNotMatched 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) {
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")

View File

@@ -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
}

View File

@@ -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)
func forwardResponseHandlerLoginLocalUser(md metadata.MD, w http.ResponseWriter) error {
setUser := getMetadataKeyOrEmpty(md, "set-user")
if setUser != "" {
sid := uuid.NewString()
localUserSessions[sid] = setUser
if setUser != "" {
http.SetCookie(
w,
&http.Cookie{
Name: "olivetin_local_user_sid",
Name: "olivetin-sid-local",
Value: sid,
},
)

View File

@@ -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
}

View File

@@ -44,7 +44,12 @@
</ul>
</nav>
<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">

View File

@@ -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'

View File

@@ -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')
}

View File

@@ -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 {