Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option to Logout for server Web UI #659

Merged
merged 1 commit into from
Jan 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions web_ui/authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,14 +180,8 @@ func setLoginCookie(ctx *gin.Context, user string) {
return
}

ctx.SetCookie("login", string(signed), 30*60, "/api/v1.0",
ctx.Request.URL.Host, true, true)
// Explicitly set Cookie for /metrics endpoint as they are in different paths
ctx.SetCookie("login", string(signed), 30*60, "/metrics",
ctx.Request.URL.Host, true, true)
// Explicitly set Cookie for /view endpoint as they are in different paths
ctx.SetCookie("login", string(signed), 30*60, "/view",
ctx.Request.URL.Host, true, true)
// One cookie should be used for all path
ctx.SetCookie("login", string(signed), 30*60, "/", ctx.Request.URL.Host, true, true)
ctx.SetSameSite(http.SameSiteStrictMode)
}

Expand Down Expand Up @@ -324,6 +318,13 @@ func resetLoginHandler(ctx *gin.Context) {
}
}

func logoutHandler(ctx *gin.Context) {
ctx.SetCookie("login", "", -1, "/", ctx.Request.URL.Host, true, true)
ctx.SetSameSite(http.SameSiteStrictMode)
ctx.Set("User", "")
ctx.JSON(http.StatusOK, gin.H{"message": "Success"})
}

// Returns the authentication status of the current user, including user id and role
func whoamiHandler(ctx *gin.Context) {
res := WhoAmIRes{}
Expand Down Expand Up @@ -363,6 +364,7 @@ func configureAuthEndpoints(ctx context.Context, router *gin.Engine, egrp *errgr

group := router.Group("/api/v1.0/auth")
group.POST("/login", loginHandler)
group.POST("/logout", AuthHandler, logoutHandler)
group.POST("/initLogin", initLoginHandler)
group.POST("/resetLogin", AuthHandler, resetLoginHandler)
// Pass csrfhanlder only to the whoami route to generate CSRF token
Expand Down
83 changes: 83 additions & 0 deletions web_ui/authentication_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -524,3 +524,86 @@ func TestAdminAuthHandler(t *testing.T) {
})
}
}

func TestLogoutAPI(t *testing.T) {
ctx, cancel, egrp := test_utils.TestContext(context.Background(), t)
defer func() { require.NoError(t, egrp.Wait()) }()
defer cancel()

dirName := t.TempDir()
viper.Reset()
config.InitConfig()
viper.Set("ConfigDir", dirName)
viper.Set("Server.UIPasswordFile", tempPasswdFile.Name())
err := config.InitServer(ctx, config.OriginType)
require.NoError(t, err)
err = config.GeneratePrivateKey(param.IssuerKey.GetString(), elliptic.P256())
require.NoError(t, err)
viper.Set("Server.UIPasswordFile", tempPasswdFile.Name())

///////////////////////////SETUP///////////////////////////////////
//Add an admin user to file to configure
content := "admin:password\n"
_, err = tempPasswdFile.WriteString(content)
assert.NoError(t, err, "Error writing to temp password file")

//Configure UI
err = configureAuthDB()
assert.NoError(t, err)

//Create a user for testing
err = WritePasswordEntry("user", "password")
assert.NoError(t, err, "error writing a user")
password := "password"
user := "user"
payload := fmt.Sprintf(`{"user": "%s", "password": "%s"}`, user, password)

//Create a request
req, err := http.NewRequest("POST", "/api/v1.0/auth/login", strings.NewReader(payload))
assert.NoError(t, err)

req.Header.Set("Content-Type", "application/json")

recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
//Check ok http reponse
assert.Equal(t, http.StatusOK, recorder.Code)
//Check that success message returned
assert.JSONEq(t, `{"msg":"Success"}`, recorder.Body.String())
//Get the cookie to test 'logout'
loginCookie := recorder.Result().Cookies()
cookieValue := loginCookie[0].Value

///////////////////////////////////////////////////////////////////

//Invoked with valid cookie, should return the username in the cookie
t.Run("With valid cookie", func(t *testing.T) {
req, err = http.NewRequest("POST", "/api/v1.0/auth/logout", nil)
assert.NoError(t, err)

req.AddCookie(&http.Cookie{
Name: "login",
Value: cookieValue,
})

recorder = httptest.NewRecorder()
router.ServeHTTP(recorder, req)

//Check for http reponse code 200
assert.Equal(t, 200, recorder.Code)
assert.Equal(t, 1, len(recorder.Result().Cookies()))
assert.Equal(t, "login", recorder.Result().Cookies()[0].Name)
assert.Greater(t, time.Now(), recorder.Result().Cookies()[0].Expires)
})
//Invoked without valid cookie, should return there is no logged-in user
t.Run("Without a valid cookie", func(t *testing.T) {
req, err = http.NewRequest("POST", "/api/v1.0/auth/logout", nil)
assert.NoError(t, err)

recorder = httptest.NewRecorder()
router.ServeHTTP(recorder, req)

//Check for http reponse code 200
assert.Equal(t, 401, recorder.Code)
})
}
61 changes: 53 additions & 8 deletions web_ui/frontend/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,72 @@
*
***************************************************************/

"use client"

import Image from 'next/image'
import Link from 'next/link'
import {Typography, Box} from "@mui/material";
import IconButton from "@mui/material/IconButton";
import HomeIcon from '@mui/icons-material/Home';
import BuildIcon from '@mui/icons-material/Build';
import { useRouter } from 'next/navigation'
import {Typography, Box, Button, Snackbar, Alert, Tooltip} from "@mui/material";
import LogoutIcon from '@mui/icons-material/Logout';

import styles from "../../app/page.module.css"
import PelicanLogo from "../../public/static/images/PelicanPlatformLogo_Icon.png"
import GithubIcon from "../../public/static/images/github-mark.png"
import {ReactNode} from "react";
import React, {ReactNode, useState} from "react";

export const Sidebar = ({children}: {children: ReactNode}) => {
const router = useRouter()

const [error, setError] = useState("")

const handleLogout = async (e: React.MouseEvent<HTMLElement>) => {
try {
let response = await fetch("/api/v1.0/auth/logout", {
method: "POST",
headers: {
"Content-Type": "application/json"
}
})

if(response.ok){
router.push("/")
} else {
try {
let data = await response.json()
if (data?.error) {
setError(response.status + ": " + data['error'])
} else {
setError("Server error with status code " + response.status)
}
} catch {
setError("Server error with status code " + response.status)
}
}
} catch {
setError("Could not connect to server")
}
}

return (
<Box>
<Snackbar
open={error!=""}
autoHideDuration={6000}
onClose={() => {setError("")}}
anchorOrigin={{vertical: "top", horizontal: "center"}}
>
<Alert onClose={() => {setError("")}} severity="error" sx={{ width: '100%' }}>
{error}
</Alert>
</Snackbar>
<div className={styles.header} style={{display: "flex", flexDirection: "column", justifyContent:"space-between", padding:"1rem", top:0, position:"fixed", zIndex:"1", overflow: "hidden", height: "100vh"}}>
<div style={{display:"flex", flexDirection: "column"}}>
{children}
</div>
<Box display={"flex"} justifyContent={"center"}>
<Box display={"flex"} flexDirection={"column"} justifyContent={"center"} textAlign={"center"}>
<Tooltip title="Logout" placement="right" arrow>
<a aria-label='logout' onClick={handleLogout} style={{marginBottom: 10}}>
<LogoutIcon/>
</a>
</Tooltip>
<a href={"https://github.com/PelicanPlatform"}>
<Image
src={GithubIcon}
Expand Down
Loading