feat(web): privacy policy url (#4625)

This allows users to customize a privacy policy URL at the bottom of the login view.

Closes #2639
pull/4816/head
James Elliott 2023-01-22 19:58:07 +11:00 committed by GitHub
parent df52b1b4c4
commit a566c16d08
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 389 additions and 23 deletions

View File

@ -505,7 +505,6 @@ authentication_backend:
# variant: standard
# cost: 12
##
## Password Policy Configuration.
##
@ -540,6 +539,23 @@ password_policy:
## Configures the minimum score allowed.
min_score: 3
##
## Privacy Policy Configuration
##
## Parameters used for displaying the privacy policy link and drawer.
privacy_policy:
## Enables the display of the privacy policy using the policy_url.
enabled: false
## Enables the display of the privacy policy drawer which requires users accept the privacy policy
## on a per-browser basis.
require_user_acceptance: false
## The URL of the privacy policy document. Must be an absolute URL and must have the 'https://' scheme.
## If the privacy policy enabled option is true, this MUST be provided.
policy_url: ''
##
## Access Control Configuration
##

View File

@ -0,0 +1,72 @@
---
title: "Privacy Policy"
description: "Privacy Policy Configuration."
lead: "This describes a section of the configuration for enabling a Privacy Policy link display."
date: 2020-02-29T01:43:59+01:00
draft: false
images: []
menu:
configuration:
parent: "miscellaneous"
weight: 199100
toc: true
---
## Configuration
```yaml
privacy_policy:
enabled: false
require_user_acceptance: false
policy_url: ''
```
## Options
### enabled
{{< confkey type="boolean" default="false" required="no" >}}
Enables the display of the Privacy Policy link.
### require_user_acceptance
{{< confkey type="boolean" default="false" required="no" >}}
Requires users accept per-browser the Privacy Policy via a Dialog Drawer at the bottom of the page. The fact they have
accepted is recorded and checked in the browser
[localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage).
If the user has not accepted the policy they should not be able to interact with the Authelia UI via normal means.
Administrators who are required to abide by the [GDPR] or other privacy laws should be advised that
[OpenID Connect 1.0](../identity-providers/open-id-connect.md) clients configured with the `implicit` consent mode are
unlikely to trigger the display of the Authelia UI if the user is already authenticated.
We wont be adding checks like this to the `implicit` consent mode when that mode in particular is unlikely to be
compliant with those laws, and that mode is not strictly compliant with the OpenID Connect 1.0 specifications. It is
therefore recommended if `require_user_acceptance` is enabled then administrators should avoid using the `implicit`
consent mode or do so at their own risk.
### policy_url
{{< confkey type="string" required="situational" >}}
The privacy policy URL is a URL which optionally is displayed in the frontend linking users to the administrators
privacy policy. This is useful for users who wish to abide by laws such as the [GDPR].
Administrators can view the particulars of what _Authelia_ collects out of the box with our
[Privacy Policy](https://www.authelia.com/privacy/#application).
This value must be an absolute URL, and must have the `https://` scheme.
This option is required if the [enabled](#enabled) option is true.
[GDPR]: https://gdpr-info.eu/
_**Example:**_
```yaml
privacy_policy:
enabled: true
policy_url: 'https://www.example.com/privacy-policy'
```

File diff suppressed because one or more lines are too long

View File

@ -505,7 +505,6 @@ authentication_backend:
# variant: standard
# cost: 12
##
## Password Policy Configuration.
##
@ -540,6 +539,23 @@ password_policy:
## Configures the minimum score allowed.
min_score: 3
##
## Privacy Policy Configuration
##
## Parameters used for displaying the privacy policy link and drawer.
privacy_policy:
## Enables the display of the privacy policy using the policy_url.
enabled: false
## Enables the display of the privacy policy drawer which requires users accept the privacy policy
## on a per-browser basis.
require_user_acceptance: false
## The URL of the privacy policy document. Must be an absolute URL and must have the 'https://' scheme.
## If the privacy policy enabled option is true, this MUST be provided.
policy_url: ''
##
## Access Control Configuration
##

View File

@ -23,4 +23,5 @@ type Configuration struct {
Telemetry TelemetryConfig `koanf:"telemetry"`
Webauthn WebauthnConfiguration `koanf:"webauthn"`
PasswordPolicy PasswordPolicyConfiguration `koanf:"password_policy"`
PrivacyPolicy PrivacyPolicy `koanf:"privacy_policy"`
}

View File

@ -268,4 +268,7 @@ var Keys = []string{
"password_policy.standard.require_special",
"password_policy.zxcvbn.enabled",
"password_policy.zxcvbn.min_score",
"privacy_policy.enabled",
"privacy_policy.require_user_acceptance",
"privacy_policy.policy_url",
}

View File

@ -0,0 +1,12 @@
package schema
import (
"net/url"
)
// PrivacyPolicy is the privacy policy configuration.
type PrivacyPolicy struct {
Enabled bool `koanf:"enabled"`
RequireUserAcceptance bool `koanf:"require_user_acceptance"`
PolicyURL *url.URL `koanf:"policy_url"`
}

View File

@ -68,6 +68,8 @@ func ValidateConfiguration(config *schema.Configuration, validator *schema.Struc
ValidateNTP(config, validator)
ValidatePasswordPolicy(&config.PasswordPolicy, validator)
ValidatePrivacyPolicy(&config.PrivacyPolicy, validator)
}
func validateDefault2FAMethod(config *schema.Configuration, validator *schema.StructValidator) {

View File

@ -294,22 +294,17 @@ const (
errFmtPasswordPolicyZXCVBNMinScoreInvalid = "password_policy: zxcvbn: option 'min_score' is invalid: must be between 1 and 4 but it's configured as %d"
)
const (
errPrivacyPolicyEnabledWithoutURL = "privacy_policy: option 'policy_url' must be provided when the option 'enabled' is true"
errFmtPrivacyPolicyURLNotHTTPS = "privacy_policy: option 'policy_url' must have the 'https' scheme but it's configured as '%s'"
)
const (
errFmtDuoMissingOption = "duo_api: option '%s' is required when duo is enabled but it is missing"
)
// Error constants.
const (
/*
errFmtDeprecatedConfigurationKey = "the %s configuration option is deprecated and will be " +
"removed in %s, please use %s instead"
Uncomment for use when deprecating keys.
TODO: Create a method from within Koanf to automatically remap deprecated keys and produce warnings.
TODO (cont): The main consideration is making sure we do not overwrite the destination key name if it already exists.
*/
errFmtInvalidDefault2FAMethod = "option 'default_2fa_method' is configured as '%s' but must be one of " +
"the following values: '%s'"
errFmtInvalidDefault2FAMethodDisabled = "option 'default_2fa_method' is configured as '%s' " +

View File

@ -7,7 +7,7 @@ import (
"github.com/authelia/authelia/v4/internal/utils"
)
// ValidatePasswordPolicy validates and update Password Policy configuration.
// ValidatePasswordPolicy validates and updates the Password Policy configuration.
func ValidatePasswordPolicy(config *schema.PasswordPolicyConfiguration, validator *schema.StructValidator) {
if !utils.IsBoolCountLessThanN(1, true, config.Standard.Enabled, config.ZXCVBN.Enabled) {
validator.Push(fmt.Errorf(errPasswordPolicyMultipleDefined))

View File

@ -0,0 +1,23 @@
package validator
import (
"fmt"
"github.com/authelia/authelia/v4/internal/configuration/schema"
)
// ValidatePrivacyPolicy validates and updates the Privacy Policy configuration.
func ValidatePrivacyPolicy(config *schema.PrivacyPolicy, validator *schema.StructValidator) {
if !config.Enabled {
return
}
switch config.PolicyURL {
case nil:
validator.Push(fmt.Errorf(errPrivacyPolicyEnabledWithoutURL))
default:
if config.PolicyURL.Scheme != schemeHTTPS {
validator.Push(fmt.Errorf(errFmtPrivacyPolicyURLNotHTTPS, config.PolicyURL.Scheme))
}
}
}

View File

@ -0,0 +1,41 @@
package validator
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/authelia/authelia/v4/internal/configuration/schema"
)
func TestValidatePrivacyPolicy(t *testing.T) {
testCases := []struct {
name string
have schema.PrivacyPolicy
expected string
}{
{"ShouldValidateDefaultConfig", schema.PrivacyPolicy{}, ""},
{"ShouldValidateValidEnabledPolicy", schema.PrivacyPolicy{Enabled: true, PolicyURL: MustParseURL("https://example.com/privacy")}, ""},
{"ShouldValidateValidEnabledPolicyWithUserAcceptance", schema.PrivacyPolicy{Enabled: true, RequireUserAcceptance: true, PolicyURL: MustParseURL("https://example.com/privacy")}, ""},
{"ShouldNotValidateOnInvalidScheme", schema.PrivacyPolicy{Enabled: true, PolicyURL: MustParseURL("http://example.com/privacy")}, "privacy_policy: option 'policy_url' must have the 'https' scheme but it's configured as 'http'"},
{"ShouldNotValidateOnMissingURL", schema.PrivacyPolicy{Enabled: true}, "privacy_policy: option 'policy_url' must be provided when the option 'enabled' is true"},
}
validator := schema.NewStructValidator()
for _, tc := range testCases {
validator.Clear()
t.Run(tc.name, func(t *testing.T) {
ValidatePrivacyPolicy(&tc.have, validator)
assert.Len(t, validator.Warnings(), 0)
if tc.expected == "" {
assert.Len(t, validator.Errors(), 0)
} else {
assert.EqualError(t, validator.Errors()[0], tc.expected)
}
})
}
}

View File

@ -39,6 +39,7 @@
"Password": "Password",
"Passwords do not match": "Passwords do not match.",
"Powered by": "Powered by",
"Privacy Policy": "Privacy Policy",
"Push Notification": "Push Notification",
"Register device": "Register device",
"Register your first device by clicking on the link below": "Register your first device by clicking on the link below.",
@ -67,6 +68,7 @@
"Use OpenID to verify your identity": "Use OpenID to verify your identity",
"Username": "Username",
"You must open the link from the same device and browser that initiated the registration process": "You must open the link from the same device and browser that initiated the registration process",
"You must view and accept the Privacy Policy before using": "You must view and accept the <0>Privacy Policy</0> before using",
"You're being signed out and redirected": "You're being signed out and redirected",
"Your supplied password does not meet the password policy requirements": "Your supplied password does not meet the password policy requirements."
}

View File

@ -212,6 +212,11 @@ func NewTemplatedFileOptions(config *schema.Configuration) (opts *TemplatedFileO
EndpointsOpenIDConnect: !(config.IdentityProviders.OIDC == nil),
}
if config.PrivacyPolicy.Enabled {
opts.PrivacyPolicyURL = config.PrivacyPolicy.PolicyURL.String()
opts.PrivacyPolicyAccept = strconv.FormatBool(config.PrivacyPolicy.RequireUserAcceptance)
}
if !config.DuoAPI.Disable {
opts.DuoSelfEnrollment = strconv.FormatBool(config.DuoAPI.EnableSelfEnrollment)
}
@ -226,6 +231,8 @@ type TemplatedFileOptions struct {
RememberMe string
ResetPassword string
ResetPasswordCustomURL string
PrivacyPolicyURL string
PrivacyPolicyAccept string
Session string
Theme string
@ -251,6 +258,8 @@ func (options *TemplatedFileOptions) CommonData(base, baseURL, nonce, logoOverri
RememberMe: options.RememberMe,
ResetPassword: options.ResetPassword,
ResetPasswordCustomURL: options.ResetPasswordCustomURL,
PrivacyPolicyURL: options.PrivacyPolicyURL,
PrivacyPolicyAccept: options.PrivacyPolicyAccept,
Session: options.Session,
Theme: options.Theme,
}
@ -298,6 +307,8 @@ type TemplatedFileCommonData struct {
RememberMe string
ResetPassword string
ResetPasswordCustomURL string
PrivacyPolicyURL string
PrivacyPolicyAccept string
Session string
Theme string
}

View File

@ -4,4 +4,6 @@ VITE_DUO_SELF_ENROLLMENT={{ .DuoSelfEnrollment }}
VITE_REMEMBER_ME={{ .RememberMe }}
VITE_RESET_PASSWORD={{ .ResetPassword }}
VITE_RESET_PASSWORD_CUSTOM_URL={{ .ResetPasswordCustomURL }}
VITE_PRIVACY_POLICY_URL={{ .PrivacyPolicyURL }}
VITE_PRIVACY_POLICY_ACCEPT={{ .PrivacyPolicyAccept }}
VITE_THEME={{ .Theme }}

View File

@ -19,6 +19,8 @@
data-rememberme="%VITE_REMEMBER_ME%"
data-resetpassword="%VITE_RESET_PASSWORD%"
data-resetpasswordcustomurl="%VITE_RESET_PASSWORD_CUSTOM_URL%"
data-privacypolicyurl="%VITE_PRIVACY_POLICY_URL%"
data-privacypolicyaccept="%VITE_PRIVACY_POLICY_ACCEPT%"
data-theme="%VITE_THEME%"
>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@ -0,0 +1,54 @@
import { Button, Drawer, DrawerProps, Grid, Typography } from "@mui/material";
import { Trans, useTranslation } from "react-i18next";
import PrivacyPolicyLink from "@components/PrivacyPolicyLink";
import { usePersistentStorageValue } from "@hooks/PersistentStorage";
import { getPrivacyPolicyEnabled, getPrivacyPolicyRequireAccept } from "@utils/Configuration";
const PrivacyPolicyDrawer = function (props: DrawerProps) {
const privacyEnabled = getPrivacyPolicyEnabled();
const privacyRequireAccept = getPrivacyPolicyRequireAccept();
const [accepted, setAccepted] = usePersistentStorageValue<boolean>("privacy-policy-accepted", false);
const { t: translate } = useTranslation();
return privacyEnabled && privacyRequireAccept && !accepted ? (
<Drawer {...props} anchor="bottom" open={!accepted}>
<Grid
container
alignItems="center"
justifyContent="center"
textAlign="center"
aria-labelledby="privacy-policy-drawer-title"
aria-describedby="privacy-policy-drawer-description"
>
<Grid container item xs={12} paddingY={2}>
<Grid item xs={12}>
<Typography id="privacy-policy-drawer-title" variant="h6" component="h2">
{translate("Privacy Policy")}
</Typography>
</Grid>
</Grid>
<Grid item xs={12}>
<Typography id="privacy-policy-drawer-description">
<Trans
i18nKey="You must view and accept the Privacy Policy before using"
components={[<PrivacyPolicyLink />]}
/>{" "}
Authelia.
</Typography>
</Grid>
<Grid item xs={12} paddingY={2}>
<Button
onClick={() => {
setAccepted(true);
}}
>
{translate("Accept")}
</Button>
</Grid>
</Grid>
</Drawer>
) : null;
};
export default PrivacyPolicyDrawer;

View File

@ -0,0 +1,22 @@
import React, { Fragment } from "react";
import { Link, LinkProps } from "@mui/material";
import { useTranslation } from "react-i18next";
import { getPrivacyPolicyURL } from "@utils/Configuration";
const PrivacyPolicyLink = function (props: LinkProps) {
const hrefPrivacyPolicy = getPrivacyPolicyURL();
const { t: translate } = useTranslation();
return (
<Fragment>
<Link {...props} href={hrefPrivacyPolicy} target="_blank" rel="noopener" underline="hover">
{translate("Privacy Policy")}
</Link>
</Fragment>
);
};
export default PrivacyPolicyLink;

View File

@ -0,0 +1,60 @@
import { useEffect, useState } from "react";
interface PersistentStorage {
getItem(key: string): string | null;
setItem(key: string, value: any): void;
}
class LocalStorage implements PersistentStorage {
getItem(key: string) {
const item = localStorage.getItem(key);
if (item === null) return undefined;
if (item === "null") return null;
if (item === "undefined") return undefined;
try {
return JSON.parse(item);
} catch {}
return item;
}
setItem(key: string, value: any) {
if (value === undefined) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, JSON.stringify(value));
}
}
}
class MockStorage implements PersistentStorage {
getItem() {
return null;
}
setItem() {}
}
const persistentStorage = window?.localStorage ? new LocalStorage() : new MockStorage();
export function usePersistentStorageValue<T>(key: string, initialValue?: T) {
const [value, setValue] = useState<T>(() => {
const valueFromStorage = persistentStorage.getItem(key);
if (typeof initialValue === "object" && !Array.isArray(initialValue) && initialValue !== null) {
return {
...initialValue,
...valueFromStorage,
};
}
return valueFromStorage || initialValue;
});
useEffect(() => {
persistentStorage.setItem(key, value);
}, [key, value]);
return [value, setValue] as const;
}

View File

@ -1,13 +1,15 @@
import React, { ReactNode, useEffect } from "react";
import React, { Fragment, ReactNode, useEffect } from "react";
import { Container, Grid, Link, Theme } from "@mui/material";
import { Container, Divider, Grid, Link, Theme } from "@mui/material";
import { grey } from "@mui/material/colors";
import makeStyles from "@mui/styles/makeStyles";
import { useTranslation } from "react-i18next";
import { ReactComponent as UserSvg } from "@assets/images/user.svg";
import PrivacyPolicyDrawer from "@components/PrivacyPolicyDrawer";
import PrivacyPolicyLink from "@components/PrivacyPolicyLink";
import TypographyWithTooltip from "@components/TypographyWithTootip";
import { getLogoOverride } from "@utils/Configuration";
import { getLogoOverride, getPrivacyPolicyEnabled } from "@utils/Configuration";
export interface Props {
id?: string;
@ -23,15 +25,20 @@ const url = "https://www.authelia.com";
const LoginLayout = function (props: Props) {
const styles = useStyles();
const { t: translate } = useTranslation();
const logo = getLogoOverride() ? (
<img src="./static/media/logo.png" alt="Logo" className={styles.icon} />
) : (
<UserSvg className={styles.icon} />
);
const { t: translate } = useTranslation();
const privacyEnabled = getPrivacyPolicyEnabled();
useEffect(() => {
document.title = `${translate("Login")} - Authelia`;
}, [translate]);
return (
<Grid id={props.id} className={styles.root} container spacing={0} alignItems="center" justifyContent="center">
<Container maxWidth="xs" className={styles.rootContainer}>
@ -57,14 +64,25 @@ const LoginLayout = function (props: Props) {
{props.children}
</Grid>
{props.showBrand ? (
<Grid item xs={12}>
<Link href={url} target="_blank" underline="hover" className={styles.poweredBy}>
<Grid item container xs={12} alignItems="center" justifyContent="center">
<Grid item xs={4}>
<Link href={url} target="_blank" underline="hover" className={styles.footerLinks}>
{translate("Powered by")} Authelia
</Link>
</Grid>
{privacyEnabled ? (
<Fragment>
<Divider orientation="vertical" flexItem variant="middle" />
<Grid item xs={4}>
<PrivacyPolicyLink className={styles.footerLinks} />
</Grid>
</Fragment>
) : null}
</Grid>
) : null}
</Grid>
</Container>
<PrivacyPolicyDrawer />
</Grid>
);
};
@ -92,7 +110,7 @@ const useStyles = makeStyles((theme: Theme) => ({
paddingTop: theme.spacing(),
paddingBottom: theme.spacing(),
},
poweredBy: {
footerLinks: {
fontSize: "0.7em",
color: grey[500],
},

View File

@ -5,4 +5,6 @@ document.body.setAttribute("data-duoselfenrollment", "true");
document.body.setAttribute("data-rememberme", "true");
document.body.setAttribute("data-resetpassword", "true");
document.body.setAttribute("data-resetpasswordcustomurl", "");
document.body.setAttribute("data-privacypolicyurl", "");
document.body.setAttribute("data-privacypolicyaccept", "false");
document.body.setAttribute("data-theme", "light");

View File

@ -27,6 +27,18 @@ export function getResetPasswordCustomURL() {
return getEmbeddedVariable("resetpasswordcustomurl");
}
export function getPrivacyPolicyEnabled() {
return getEmbeddedVariable("privacypolicyurl") !== "";
}
export function getPrivacyPolicyURL() {
return getEmbeddedVariable("privacypolicyurl");
}
export function getPrivacyPolicyRequireAccept() {
return getEmbeddedVariable("privacypolicyaccept") === "true";
}
export function getTheme() {
return getEmbeddedVariable("theme");
}