fix(handlers): logout redirection validation (#1908)
parent
42cee0ed6c
commit
f0cb75e1e1
|
@ -187,13 +187,19 @@ paths:
|
||||||
- Authentication
|
- Authentication
|
||||||
summary: Logout
|
summary: Logout
|
||||||
description: The logout endpoint allows a user to logout and destroy a sesssion.
|
description: The logout endpoint allows a user to logout and destroy a sesssion.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/handlers.logoutRequestBody'
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Successful Operation
|
description: Successful Operation
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/middlewares.OkResponse'
|
$ref: '#/components/schemas/handlers.logoutResponseBody'
|
||||||
security:
|
security:
|
||||||
- authelia_auth: []
|
- authelia_auth: []
|
||||||
/api/reset-password/identity/start:
|
/api/reset-password/identity/start:
|
||||||
|
@ -567,6 +573,24 @@ components:
|
||||||
totp_period:
|
totp_period:
|
||||||
type: integer
|
type: integer
|
||||||
example: 30
|
example: 30
|
||||||
|
handlers.logoutRequestBody:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
targetURL:
|
||||||
|
type: string
|
||||||
|
example: https://redirect.example.com
|
||||||
|
handlers.logoutResponseBody:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: OK
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
safeTargetURL:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
handlers.firstFactorRequestBody:
|
handlers.firstFactorRequestBody:
|
||||||
required:
|
required:
|
||||||
- username
|
- username
|
||||||
|
|
|
@ -2,18 +2,50 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"github.com/authelia/authelia/internal/middlewares"
|
"github.com/authelia/authelia/internal/middlewares"
|
||||||
|
"github.com/authelia/authelia/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type logoutBody struct {
|
||||||
|
TargetURL string `json:"targetURL"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type logoutResponseBody struct {
|
||||||
|
SafeTargetURL bool `json:"safeTargetURL"`
|
||||||
|
}
|
||||||
|
|
||||||
// LogoutPost is the handler logging out the user attached to the given cookie.
|
// LogoutPost is the handler logging out the user attached to the given cookie.
|
||||||
func LogoutPost(ctx *middlewares.AutheliaCtx) {
|
func LogoutPost(ctx *middlewares.AutheliaCtx) {
|
||||||
ctx.Logger.Tracef("Destroy session")
|
body := logoutBody{}
|
||||||
err := ctx.Providers.SessionProvider.DestroySession(ctx.RequestCtx)
|
responseBody := logoutResponseBody{SafeTargetURL: false}
|
||||||
|
|
||||||
|
ctx.Logger.Tracef("Attempting to decode body")
|
||||||
|
|
||||||
|
err := ctx.ParseBody(&body)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(fmt.Errorf("Unable to parse body during logout: %s", err), operationFailedMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Logger.Tracef("Attempting to destroy session")
|
||||||
|
|
||||||
|
err = ctx.Providers.SessionProvider.DestroySession(ctx.RequestCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Error(fmt.Errorf("Unable to destroy session during logout: %s", err), operationFailedMessage)
|
ctx.Error(fmt.Errorf("Unable to destroy session during logout: %s", err), operationFailedMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.ReplyOK()
|
redirectionURL, err := url.Parse(body.TargetURL)
|
||||||
|
if err == nil {
|
||||||
|
responseBody.SafeTargetURL = utils.IsRedirectionSafe(*redirectionURL, ctx.Configuration.Session.Domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.TargetURL != "" {
|
||||||
|
ctx.Logger.Debugf("Logout target url is %s, safe %t", body.TargetURL, responseBody.SafeTargetURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ctx.SetJSONBody(responseBody)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(fmt.Errorf("Unable to set body during logout: %s", err), operationFailedMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ package suites
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -10,3 +11,15 @@ func (wds *WebDriverSession) doLogout(ctx context.Context, t *testing.T) {
|
||||||
wds.doVisit(t, fmt.Sprintf("%s%s", GetLoginBaseURL(), "/logout"))
|
wds.doVisit(t, fmt.Sprintf("%s%s", GetLoginBaseURL(), "/logout"))
|
||||||
wds.verifyIsFirstFactorPage(ctx, t)
|
wds.verifyIsFirstFactorPage(ctx, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (wds *WebDriverSession) doLogoutWithRedirect(ctx context.Context, t *testing.T, targetURL string, firstFactor bool) {
|
||||||
|
wds.doVisit(t, fmt.Sprintf("%s%s%s", GetLoginBaseURL(), "/logout?rd=", url.QueryEscape(targetURL)))
|
||||||
|
|
||||||
|
if firstFactor {
|
||||||
|
wds.verifyIsFirstFactorPage(ctx, t)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wds.verifyURLIs(ctx, t, targetURL)
|
||||||
|
}
|
||||||
|
|
|
@ -57,7 +57,7 @@ var redirectionAuthorizations = map[string]bool{
|
||||||
"https://secure.example.com:8080/secret.html": true,
|
"https://secure.example.com:8080/secret.html": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *RedirectionCheckScenario) TestShouldRedirectOnlyWhenDomainIsHandledByAuthelia() {
|
func (s *RedirectionCheckScenario) TestShouldRedirectOnLoginOnlyWhenDomainIsSafe() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
@ -77,6 +77,28 @@ func (s *RedirectionCheckScenario) TestShouldRedirectOnlyWhenDomainIsHandledByAu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var logoutRedirectionURLs = map[string]bool{
|
||||||
|
// external website
|
||||||
|
"https://www.google.fr": false,
|
||||||
|
// Not the right domain
|
||||||
|
"https://public.example-not-right.com:8080/index.html": false,
|
||||||
|
// Not https
|
||||||
|
"http://public.example.com:8080/index.html": false,
|
||||||
|
// Domain handled by Authelia
|
||||||
|
"https://public.example.com:8080/index.html": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RedirectionCheckScenario) TestShouldRedirectOnLogoutOnlyWhenDomainIsSafe() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
for url, success := range logoutRedirectionURLs {
|
||||||
|
s.T().Run(url, func(t *testing.T) {
|
||||||
|
s.doLogoutWithRedirect(ctx, t, url, !success)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRedirectionCheckScenario(t *testing.T) {
|
func TestRedirectionCheckScenario(t *testing.T) {
|
||||||
if testing.Short() {
|
if testing.Short() {
|
||||||
t.Skip("skipping suite test in short mode")
|
t.Skip("skipping suite test in short mode")
|
||||||
|
|
|
@ -212,6 +212,10 @@ func (s *StandaloneSuite) TestRedirectionURLScenario() {
|
||||||
suite.Run(s.T(), NewRedirectionURLScenario())
|
suite.Run(s.T(), NewRedirectionURLScenario())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *StandaloneSuite) TestRedirectionCheckScenario() {
|
||||||
|
suite.Run(s.T(), NewRedirectionCheckScenario())
|
||||||
|
}
|
||||||
|
|
||||||
func TestStandaloneSuite(t *testing.T) {
|
func TestStandaloneSuite(t *testing.T) {
|
||||||
if testing.Short() {
|
if testing.Short() {
|
||||||
t.Skip("skipping suite test in short mode")
|
t.Skip("skipping suite test in short mode")
|
||||||
|
|
|
@ -15,6 +15,7 @@ func (wds *WebDriverSession) verifyURLIs(ctx context.Context, t *testing.T, url
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return currentURL == url, nil
|
return currentURL == url, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -2,13 +2,13 @@ import axios from "axios";
|
||||||
|
|
||||||
import { ServiceResponse, hasServiceError, toData } from "./Api";
|
import { ServiceResponse, hasServiceError, toData } from "./Api";
|
||||||
|
|
||||||
export async function PostWithOptionalResponse<T = undefined>(path: string, body?: any) {
|
export async function PostWithOptionalResponse<T = undefined>(path: string, body?: any): Promise<T | undefined> {
|
||||||
const res = await axios.post<ServiceResponse<T>>(path, body);
|
const res = await axios.post<ServiceResponse<T>>(path, body);
|
||||||
|
|
||||||
if (res.status !== 200 || hasServiceError(res).errored) {
|
if (res.status !== 200 || hasServiceError(res).errored) {
|
||||||
throw new Error(`Failed POST to ${path}. Code: ${res.status}. Message: ${hasServiceError(res).message}`);
|
throw new Error(`Failed POST to ${path}. Code: ${res.status}. Message: ${hasServiceError(res).message}`);
|
||||||
}
|
}
|
||||||
return toData(res);
|
return toData<T>(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function Post<T>(path: string, body?: any) {
|
export async function Post<T>(path: string, body?: any) {
|
||||||
|
|
|
@ -1,6 +1,17 @@
|
||||||
import { LogoutPath } from "./Api";
|
import { LogoutPath } from "./Api";
|
||||||
import { PostWithOptionalResponse } from "./Client";
|
import { PostWithOptionalResponse } from "./Client";
|
||||||
|
|
||||||
export async function signOut() {
|
export type SignOutResponse = { safeTargetURL: boolean } | undefined;
|
||||||
return PostWithOptionalResponse(LogoutPath);
|
|
||||||
|
export type SignOutBody = {
|
||||||
|
targetURL?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function signOut(targetURL: string | undefined): Promise<SignOutResponse> {
|
||||||
|
const body: SignOutBody = {};
|
||||||
|
if (targetURL) {
|
||||||
|
body.targetURL = targetURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return PostWithOptionalResponse<SignOutResponse>(LogoutPath, body);
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,11 +18,14 @@ const SignOut = function (props: Props) {
|
||||||
const { createErrorNotification } = useNotifications();
|
const { createErrorNotification } = useNotifications();
|
||||||
const redirectionURL = useRedirectionURL();
|
const redirectionURL = useRedirectionURL();
|
||||||
const [timedOut, setTimedOut] = useState(false);
|
const [timedOut, setTimedOut] = useState(false);
|
||||||
|
const [safeRedirect, setSafeRedirect] = useState(false);
|
||||||
|
|
||||||
const doSignOut = useCallback(async () => {
|
const doSignOut = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
// TODO(c.michaud): pass redirection URL to backend for validation.
|
const res = await signOut(redirectionURL);
|
||||||
await signOut();
|
if (res !== undefined && res.safeTargetURL) {
|
||||||
|
setSafeRedirect(true);
|
||||||
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
|
@ -33,14 +36,14 @@ const SignOut = function (props: Props) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
createErrorNotification("There was an issue signing out");
|
createErrorNotification("There was an issue signing out");
|
||||||
}
|
}
|
||||||
}, [createErrorNotification, setTimedOut, mounted]);
|
}, [createErrorNotification, redirectionURL, setSafeRedirect, setTimedOut, mounted]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
doSignOut();
|
doSignOut();
|
||||||
}, [doSignOut]);
|
}, [doSignOut]);
|
||||||
|
|
||||||
if (timedOut) {
|
if (timedOut) {
|
||||||
if (redirectionURL) {
|
if (redirectionURL && safeRedirect) {
|
||||||
window.location.href = redirectionURL;
|
window.location.href = redirectionURL;
|
||||||
} else {
|
} else {
|
||||||
return <Redirect to={FirstFactorRoute} />;
|
return <Redirect to={FirstFactorRoute} />;
|
||||||
|
|
Loading…
Reference in New Issue