fix: user is now redirected when authenticated (#2082)

* fix(handlers,web): user is now redirected when authenticated

Fix: #1788

* remove dead code and fix ci issues

* fix infinite loop in frontend

* fix issue with integration tests

* handle bot recommendation

* fix integration test & add dot to comment

* fix last integration test

* Update api/openapi.yml

Co-authored-by: James Elliott <james-d-elliott@users.noreply.github.com>

* Update web/src/services/SafeRedirection.ts

Co-authored-by: Amir Zarrinkafsh <nightah@me.com>

* Update web/src/services/SafeRedirection.ts

Co-authored-by: Amir Zarrinkafsh <nightah@me.com>

* Update api/openapi.yml

* Update openapi.yml

* refactor: valid -> safe

* refactor: adjust merge conflicts

* Apply suggestions from code review

Co-authored-by: James Elliott <james-d-elliott@users.noreply.github.com>

* fix: adjust test return messaging

Co-authored-by: James Elliott <james-d-elliott@users.noreply.github.com>
Co-authored-by: Amir Zarrinkafsh <nightah@me.com>
This commit is contained in:
Clément Michaud 2021-08-02 08:15:38 +02:00 committed by GitHub
parent 03274c171e
commit bc983ce9f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 323 additions and 31 deletions

View File

@ -166,13 +166,6 @@ paths:
responses:
"200":
description: Successful Operation
headers:
Set-Cookie:
style: simple
explode: false
schema:
type: string
example: authelia_session=kTTCSLupEUirZVfLeZTijezewFQnNOgs; Path=/
content:
application/json:
schema:
@ -181,6 +174,30 @@ paths:
description: Unauthorized
security:
- authelia_auth: []
/api/checks/safe-redirection:
post:
tags:
- Authentication
summary: Check whether URI is safe to redirect to.
description: >
End users usually needs to be redirected to a target website after authentication. This endpoint aims to check
if target URL is safe to redirect to. This prevents open redirect attacks.
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/handlers.checkURIWithinDomainRequestBody'
responses:
"200":
description: Successful Operation
content:
application/json:
schema:
$ref: '#/components/schemas/handlers.checkURIWithinDomainResponseBody'
"401":
description: Unauthorized
security:
- authelia_auth: []
/api/logout:
post:
tags:

View File

@ -0,0 +1,41 @@
package handlers
import (
"fmt"
"github.com/authelia/authelia/internal/authentication"
"github.com/authelia/authelia/internal/middlewares"
"github.com/authelia/authelia/internal/utils"
)
// CheckSafeRedirection handler checking whether the redirection to a given URL provided in body is safe.
func CheckSafeRedirection(ctx *middlewares.AutheliaCtx) {
userSession := ctx.GetSession()
if userSession.AuthenticationLevel == authentication.NotAuthenticated {
ctx.ReplyUnauthorized()
return
}
var reqBody checkURIWithinDomainRequestBody
err := ctx.ParseBody(&reqBody)
if err != nil {
ctx.Error(fmt.Errorf("Unable to parse request body: %w", err), messageOperationFailed)
return
}
safe, err := utils.IsRedirectionURISafe(reqBody.URI, ctx.Configuration.Session.Domain)
if err != nil {
ctx.Error(fmt.Errorf("Unable to determine if uri %s is safe to redirect to: %w", reqBody.URI, err), messageOperationFailed)
return
}
err = ctx.SetJSONBody(checkURIWithinDomainResponseBody{
OK: safe,
})
if err != nil {
ctx.Error(fmt.Errorf("Unable to create response body: %w", err), messageOperationFailed)
return
}
}

View File

@ -0,0 +1,65 @@
package handlers
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/authelia/authelia/internal/authentication"
"github.com/authelia/authelia/internal/mocks"
"github.com/authelia/authelia/internal/session"
)
var exampleDotComDomain = "example.com"
func TestCheckSafeRedirection_ForbiddenCall(t *testing.T) {
mock := mocks.NewMockAutheliaCtxWithUserSession(t, session.UserSession{
Username: "john",
AuthenticationLevel: authentication.NotAuthenticated,
})
defer mock.Close()
mock.Ctx.Configuration.Session.Domain = exampleDotComDomain
mock.SetRequestBody(t, checkURIWithinDomainRequestBody{
URI: "http://myapp.example.com",
})
CheckSafeRedirection(mock.Ctx)
assert.Equal(t, 401, mock.Ctx.Response.StatusCode())
}
func TestCheckSafeRedirection_UnsafeRedirection(t *testing.T) {
mock := mocks.NewMockAutheliaCtxWithUserSession(t, session.UserSession{
Username: "john",
AuthenticationLevel: authentication.OneFactor,
})
defer mock.Close()
mock.Ctx.Configuration.Session.Domain = exampleDotComDomain
mock.SetRequestBody(t, checkURIWithinDomainRequestBody{
URI: "http://myapp.com",
})
CheckSafeRedirection(mock.Ctx)
mock.Assert200OK(t, checkURIWithinDomainResponseBody{
OK: false,
})
}
func TestCheckSafeRedirection_SafeRedirection(t *testing.T) {
mock := mocks.NewMockAutheliaCtxWithUserSession(t, session.UserSession{
Username: "john",
AuthenticationLevel: authentication.OneFactor,
})
defer mock.Close()
mock.Ctx.Configuration.Session.Domain = exampleDotComDomain
mock.SetRequestBody(t, checkURIWithinDomainRequestBody{
URI: "https://myapp.example.com",
})
CheckSafeRedirection(mock.Ctx)
mock.Assert200OK(t, checkURIWithinDomainResponseBody{
OK: true,
})
}

View File

@ -101,10 +101,8 @@ func Handle1FAResponse(ctx *middlewares.AutheliaCtx, targetURI, requestMethod st
}
ctx.Logger.Debugf("Redirection URL %s is safe", targetURI)
err = ctx.SetJSONBody(redirectResponse{Redirect: targetURI})
response := redirectResponse{Redirect: targetURI}
err = ctx.SetJSONBody(response)
if err != nil {
ctx.Logger.Errorf("Unable to set redirection URL in body: %s", err)
}
@ -125,15 +123,17 @@ func Handle2FAResponse(ctx *middlewares.AutheliaCtx, targetURI string) {
return
}
targetURL, err := url.ParseRequestURI(targetURI)
safe, err := utils.IsRedirectionURISafe(targetURI, ctx.Configuration.Session.Domain)
if err != nil {
ctx.Error(fmt.Errorf("Unable to parse target URL: %s", err), messageMFAValidationFailed)
ctx.Error(fmt.Errorf("Unable to check target URL: %s", err), messageMFAValidationFailed)
return
}
if targetURL != nil && utils.IsRedirectionSafe(*targetURL, ctx.Configuration.Session.Domain) {
if safe {
ctx.Logger.Debugf("Redirection URL %s is safe", targetURI)
err := ctx.SetJSONBody(redirectResponse{Redirect: targetURI})
if err != nil {
ctx.Logger.Errorf("Unable to set redirection URL in body: %s", err)
}

View File

@ -53,6 +53,16 @@ type firstFactorRequestBody struct {
// TODO(c.michaud): add required validation once the above PR is merged.
}
// checkURIWithinDomainRequestBody represents the JSON body received by the endpoint checking if an URI is within
// the configured domain.
type checkURIWithinDomainRequestBody struct {
URI string `json:"uri"`
}
type checkURIWithinDomainResponseBody struct {
OK bool `json:"ok"`
}
// redirectResponse represent the response sent by the first factor endpoint
// when a redirection URL has been provided.
type redirectResponse struct {

View File

@ -127,12 +127,28 @@ func NewMockAutheliaCtx(t *testing.T) *MockAutheliaCtx {
return mockAuthelia
}
// NewMockAutheliaCtxWithUserSession create an instance of AutheliaCtx mock with predefined user session.
func NewMockAutheliaCtxWithUserSession(t *testing.T, userSession session.UserSession) *MockAutheliaCtx {
mock := NewMockAutheliaCtx(t)
err := mock.Ctx.SaveSession(userSession)
require.NoError(t, err)
return mock
}
// Close close the mock.
func (m *MockAutheliaCtx) Close() {
m.Hook.Reset()
m.Ctrl.Finish()
}
// SetRequestBody set the request body from a struct with json tags.
func (m *MockAutheliaCtx) SetRequestBody(t *testing.T, body interface{}) {
bodyBytes, err := json.Marshal(body)
require.NoError(t, err)
m.Ctx.Request.SetBody(bodyBytes)
}
// Assert401KO assert an error response from the service.
func (m *MockAutheliaCtx) Assert401KO(t *testing.T, message string) {
assert.Equal(t, 401, m.Ctx.Response.StatusCode())

View File

@ -64,6 +64,8 @@ func registerRoutes(configuration schema.Configuration, providers middlewares.Pr
r.GET("/api/verify", autheliaMiddleware(handlers.VerifyGet(configuration.AuthenticationBackend)))
r.HEAD("/api/verify", autheliaMiddleware(handlers.VerifyGet(configuration.AuthenticationBackend)))
r.POST("/api/checks/safe-redirection", autheliaMiddleware(handlers.CheckSafeRedirection))
r.POST("/api/firstfactor", autheliaMiddleware(handlers.FirstFactorPost(1000, true)))
r.POST("/api/logout", autheliaMiddleware(handlers.LogoutPost))

View File

@ -2,6 +2,7 @@ package suites
import (
"context"
"fmt"
"log"
"testing"
"time"
@ -69,12 +70,35 @@ func (s *OneFactorOnlyWebSuite) TestShouldDisplayAuthenticatedView() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
s.doLoginOneFactor(ctx, s.T(), "john", "password", false, "http://unsafe.local")
s.doLoginOneFactor(ctx, s.T(), "john", "password", false, "")
s.verifyURLIs(ctx, s.T(), HomeBaseURL+"/")
s.doVisit(s.T(), GetLoginBaseURL())
s.verifyIsAuthenticatedPage(ctx, s.T())
}
func (s *OneFactorOnlyWebSuite) TestShouldRedirectAlreadyAuthenticatedUser() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
s.doLoginOneFactor(ctx, s.T(), "john", "password", false, "")
s.verifyURLIs(ctx, s.T(), HomeBaseURL+"/")
s.doVisit(s.T(), fmt.Sprintf("%s?rd=https://singlefactor.example.com:8080/secret.html", GetLoginBaseURL()))
s.verifyURLIs(ctx, s.T(), "https://singlefactor.example.com:8080/secret.html")
}
func (s *OneFactorOnlyWebSuite) TestShouldNotRedirectAlreadyAuthenticatedUserToUnsafeURL() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
s.doLoginOneFactor(ctx, s.T(), "john", "password", false, "")
s.verifyURLIs(ctx, s.T(), HomeBaseURL+"/")
// Visit the login page and wait for redirection to 2FA page with success icon displayed.
s.doVisit(s.T(), fmt.Sprintf("%s?rd=https://secure.example.local:8080", GetLoginBaseURL()))
s.verifyNotificationDisplayed(ctx, s.T(), "Redirection was determined to be unsafe and aborted. Ensure the redirection URL is correct.")
}
func (s *OneFactorOnlySuite) TestWeb() {
suite.Run(s.T(), NewOneFactorOnlyWebSuite())
}

View File

@ -67,6 +67,36 @@ func (s *StandaloneWebDriverSuite) TestShouldLetUserKnowHeIsAlreadyAuthenticated
s.verifyIsAuthenticatedPage(ctx, s.T())
}
func (s *StandaloneWebDriverSuite) TestShouldRedirectAlreadyAuthenticatedUser() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
_ = s.doRegisterAndLogin2FA(ctx, s.T(), "john", "password", false, "")
// Visit home page to change context.
s.doVisit(s.T(), HomeBaseURL)
s.verifyIsHome(ctx, s.T())
// Visit the login page and wait for redirection to 2FA page with success icon displayed.
s.doVisit(s.T(), fmt.Sprintf("%s?rd=https://secure.example.com:8080", GetLoginBaseURL()))
s.verifyURLIs(ctx, s.T(), "https://secure.example.com:8080/")
}
func (s *StandaloneWebDriverSuite) TestShouldNotRedirectAlreadyAuthenticatedUserToUnsafeURL() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
_ = s.doRegisterAndLogin2FA(ctx, s.T(), "john", "password", false, "")
// Visit home page to change context.
s.doVisit(s.T(), HomeBaseURL)
s.verifyIsHome(ctx, s.T())
// Visit the login page and wait for redirection to 2FA page with success icon displayed.
s.doVisit(s.T(), fmt.Sprintf("%s?rd=https://secure.example.local:8080", GetLoginBaseURL()))
s.verifyNotificationDisplayed(ctx, s.T(), "Redirection was determined to be unsafe and aborted. Ensure the redirection URL is correct.")
}
func (s *StandaloneWebDriverSuite) TestShouldCheckUserIsAskedToRegisterDevice() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()

View File

@ -1,11 +1,12 @@
package utils
import (
"fmt"
"net/url"
"strings"
)
// IsRedirectionSafe determines if a redirection URL is secured.
// IsRedirectionSafe determines whether the URL is safe to be redirected to.
func IsRedirectionSafe(url url.URL, protectedDomain string) bool {
if url.Scheme != "https" {
return false
@ -17,3 +18,14 @@ func IsRedirectionSafe(url url.URL, protectedDomain string) bool {
return true
}
// IsRedirectionURISafe determines whether the URI is safe to be redirected to.
func IsRedirectionURISafe(uri, protectedDomain string) (bool, error) {
targetURL, err := url.ParseRequestURI(uri)
if err != nil {
return false, fmt.Errorf("Unable to parse redirection URI %s: %w", uri, err)
}
return targetURL != nil && IsRedirectionSafe(*targetURL, protectedDomain), nil
}

View File

@ -12,14 +12,31 @@ func isURLSafe(requestURI string, domain string) bool { //nolint:unparam
return IsRedirectionSafe(*url, domain)
}
func TestShouldReturnFalseOnBadScheme(t *testing.T) {
func TestIsRedirectionSafe_ShouldReturnFalseOnBadScheme(t *testing.T) {
assert.False(t, isURLSafe("http://secure.example.com", "example.com"))
assert.False(t, isURLSafe("ftp://secure.example.com", "example.com"))
assert.True(t, isURLSafe("https://secure.example.com", "example.com"))
}
func TestShouldReturnFalseOnBadDomain(t *testing.T) {
func TestIsRedirectionSafe_ShouldReturnFalseOnBadDomain(t *testing.T) {
assert.False(t, isURLSafe("https://secure.example.com.c", "example.com"))
assert.False(t, isURLSafe("https://secure.example.comc", "example.com"))
assert.False(t, isURLSafe("https://secure.example.co", "example.com"))
}
func TestIsRedirectionURISafe_CannotParseURI(t *testing.T) {
_, err := IsRedirectionURISafe("http//invalid", "example.com")
assert.EqualError(t, err, "Unable to parse redirection URI http//invalid: parse \"http//invalid\": invalid URI for request")
}
func TestIsRedirectionURISafe_InvalidRedirectionURI(t *testing.T) {
valid, err := IsRedirectionURISafe("http://myurl.com/myresource", "example.com")
assert.NoError(t, err)
assert.False(t, valid)
}
func TestIsRedirectionURISafe_ValidRedirectionURI(t *testing.T) {
valid, err := IsRedirectionURISafe("http://myurl.example.com/myresource", "example.com")
assert.NoError(t, err)
assert.False(t, valid)
}

View File

@ -1,5 +1,7 @@
import { useCallback } from "react";
export function useRedirector() {
return (url: string) => {
return useCallback((url: string) => {
window.location.href = url;
};
}, []);
}

View File

@ -1,9 +1,8 @@
import "@utils/AssetPath";
import React from "react";
import ReactDOM from "react-dom";
import "@utils/AssetPath";
import "@root/index.css";
import App from "@root/App";
import * as serviceWorker from "@root/serviceWorker";

View File

@ -25,6 +25,7 @@ export const InitiateResetPasswordPath = basePath + "/api/reset-password/identit
export const CompleteResetPasswordPath = basePath + "/api/reset-password/identity/finish";
// Do the password reset during completion.
export const ResetPasswordPath = basePath + "/api/reset-password";
export const ChecksSafeRedirectionPath = basePath + "/api/checks/safe-redirection";
export const LogoutPath = basePath + "/api/logout";
export const StatePath = basePath + "/api/state";

View File

@ -0,0 +1,10 @@
import { ChecksSafeRedirectionPath } from "@services/Api";
import { PostWithOptionalResponse } from "@services/Client";
interface SafeRedirectionResponse {
ok: boolean;
}
export async function checkSafeRedirection(uri: string) {
return PostWithOptionalResponse<SafeRedirectionResponse>(ChecksSafeRedirectionPath, { uri });
}

View File

@ -56,6 +56,7 @@ const RegisterOneTimePassword = function () {
useEffect(() => {
completeRegistrationProcess();
}, [completeRegistrationProcess]);
function SecretButton(text: string | undefined, action: string, icon: IconDefinition) {
return (
<IconButton

View File

@ -1,14 +1,14 @@
import React, { useEffect, Fragment, ReactNode, useState, useCallback } from "react";
import React, { Fragment, ReactNode, useCallback, useEffect, useState } from "react";
import { Switch, Route, Redirect, useHistory, useLocation } from "react-router";
import { Redirect, Route, Switch, useHistory, useLocation } from "react-router";
import {
AuthenticatedRoute,
FirstFactorRoute,
SecondFactorPushRoute,
SecondFactorRoute,
SecondFactorTOTPRoute,
SecondFactorPushRoute,
SecondFactorU2FRoute,
AuthenticatedRoute,
} from "@constants/Routes";
import { useConfiguration } from "@hooks/Configuration";
import { useNotifications } from "@hooks/NotificationsContext";
@ -18,6 +18,7 @@ import { useRequestMethod } from "@hooks/RequestMethod";
import { useAutheliaState } from "@hooks/State";
import { useUserPreferences as userUserInfo } from "@hooks/UserInfo";
import { SecondFactorMethod } from "@models/Methods";
import { checkSafeRedirection } from "@services/SafeRedirection";
import { AuthenticationLevel } from "@services/State";
import LoadingPage from "@views/LoadingPage/LoadingPage";
import AuthenticatedView from "@views/LoginPortal/AuthenticatedView/AuthenticatedView";
@ -29,6 +30,9 @@ export interface Props {
resetPassword: boolean;
}
const RedirectionErrorMessage =
"Redirection was determined to be unsafe and aborted. Ensure the redirection URL is correct.";
const LoginPortal = function (props: Props) {
const history = useHistory();
const location = useLocation();
@ -87,7 +91,31 @@ const LoginPortal = function (props: Props) {
// Redirect to the correct stage if not enough authenticated
useEffect(() => {
if (state) {
(async function () {
if (!state) {
return;
}
if (
redirectionURL &&
((configuration &&
!configuration.second_factor_enabled &&
state.authentication_level >= AuthenticationLevel.OneFactor) ||
state.authentication_level === AuthenticationLevel.TwoFactor)
) {
try {
const res = await checkSafeRedirection(redirectionURL);
if (res && res.ok) {
redirector(redirectionURL);
} else {
createErrorNotification(RedirectionErrorMessage);
}
} catch (err) {
createErrorNotification(RedirectionErrorMessage);
}
return;
}
const redirectionSuffix = redirectionURL
? `?rd=${encodeURIComponent(redirectionURL)}${requestMethod ? `&rm=${requestMethod}` : ""}`
: "";
@ -108,8 +136,18 @@ const LoginPortal = function (props: Props) {
}
}
}
}
}, [state, redirectionURL, requestMethod, redirect, userInfo, setFirstFactorDisabled, configuration]);
})();
}, [
state,
redirectionURL,
requestMethod,
redirect,
userInfo,
setFirstFactorDisabled,
configuration,
createErrorNotification,
redirector,
]);
const handleAuthSuccess = async (redirectionURL: string | undefined) => {
if (redirectionURL) {

View File

@ -38,7 +38,7 @@ const OneTimePasswordMethod = function (props: Props) {
/* eslint-enable react-hooks/exhaustive-deps */
const signInFunc = useCallback(async () => {
if (props.authenticationLevel === AuthenticationLevel.TwoFactor) {
if (!props.registered || props.authenticationLevel === AuthenticationLevel.TwoFactor) {
return;
}
@ -59,7 +59,14 @@ const OneTimePasswordMethod = function (props: Props) {
setState(State.Failure);
}
setPasscode("");
}, [passcode, onSignInErrorCallback, onSignInSuccessCallback, redirectionURL, props.authenticationLevel]);
}, [
passcode,
onSignInErrorCallback,
onSignInSuccessCallback,
redirectionURL,
props.authenticationLevel,
props.registered,
]);
// Set successful state if user is already authenticated.
useEffect(() => {

View File

@ -47,7 +47,7 @@ const SecurityKeyMethod = function (props: Props) {
const doInitiateSignIn = useCallback(async () => {
// If user is already authenticated, we don't initiate sign in process.
if (!props.registered || props.authenticationLevel >= AuthenticationLevel.TwoFactor) {
if (!props.registered || props.authenticationLevel === AuthenticationLevel.TwoFactor) {
return;
}