mirror of
https://github.com/OliveTin/OliveTin
synced 2025-12-15 10:35:45 +00:00
feature: Login/Logout links (#443)
* feature: Login/Logout links * cicd: codestyle
This commit is contained in:
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -178,6 +178,7 @@ type SaveLogsConfig struct {
|
||||
type LogDebugOptions struct {
|
||||
SingleFrontendRequests bool
|
||||
SingleFrontendRequestHeaders bool
|
||||
AclCheckStarted bool
|
||||
AclMatched bool
|
||||
AclNotMatched bool
|
||||
AclNoneMatched bool
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"> </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">
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user