feat(oidc): pre-configured consent (#3118)

Allows users to pre-configure consent if enabled by the client configuration by selecting a checkbox during consent.

Closes #2598
This commit is contained in:
James Elliott 2022-04-08 15:35:21 +10:00 committed by GitHub
parent 4503ac07be
commit 66a450ed38
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 107 additions and 18 deletions

View File

@ -813,6 +813,11 @@ notifier:
## The policy to require for this client; one_factor or two_factor. ## The policy to require for this client; one_factor or two_factor.
# authorization_policy: two_factor # authorization_policy: two_factor
## By default users cannot remember pre-configured consents. Setting this value to a period of time using a
## duration notation will enable users to remember consent for this client. The time configured is the amount
## of time the pre-configured consent is valid for granting new authorizations to the user.
# pre_configured_consent_duration:
## Audience this client is allowed to request. ## Audience this client is allowed to request.
# audience: [] # audience: []

View File

@ -51,6 +51,7 @@ identity_providers:
sector_identifier: '' sector_identifier: ''
public: false public: false
authorization_policy: two_factor authorization_policy: two_factor
pre_configured_consent_duration: ''
audience: [] audience: []
scopes: scopes:
- openid - openid
@ -402,6 +403,21 @@ required: no
The authorization policy for this client: either `one_factor` or `two_factor`. The authorization policy for this client: either `one_factor` or `two_factor`.
#### pre_configured_consent_duration
<div markdown="1">
type: string (duration)
{: .label .label-config .label-purple }
required: no
{: .label .label-config .label-green }
</div>
Configuring this enables users of this client to remember their consent as a pre-configured consent. The value is period
of time is in [duration notation format](../index.md#duration-notation-format). The period of time dictates how long a
users choice to remember the pre-configured consent lasts.
Pre-configured consents are only valid if the subject, client id are exactly the same and the requested scopes/audience
match exactly with the granted scopes/audience.
#### audience #### audience
<div markdown="1"> <div markdown="1">
type: list(string) type: list(string)

View File

@ -813,6 +813,11 @@ notifier:
## The policy to require for this client; one_factor or two_factor. ## The policy to require for this client; one_factor or two_factor.
# authorization_policy: two_factor # authorization_policy: two_factor
## By default users cannot remember pre-configured consents. Setting this value to a period of time using a
## duration notation will enable users to remember consent for this client. The time configured is the amount
## of time the pre-configured consent is valid for granting new authorizations to the user.
# pre_configured_consent_duration:
## Audience this client is allowed to request. ## Audience this client is allowed to request.
# audience: [] # audience: []

View File

@ -47,8 +47,6 @@ type OpenIDConnectClientConfiguration struct {
SectorIdentifier url.URL `koanf:"sector_identifier"` SectorIdentifier url.URL `koanf:"sector_identifier"`
Public bool `koanf:"public"` Public bool `koanf:"public"`
Policy string `koanf:"authorization_policy"`
RedirectURIs []string `koanf:"redirect_uris"` RedirectURIs []string `koanf:"redirect_uris"`
Audience []string `koanf:"audience"` Audience []string `koanf:"audience"`
@ -58,6 +56,10 @@ type OpenIDConnectClientConfiguration struct {
ResponseModes []string `koanf:"response_modes"` ResponseModes []string `koanf:"response_modes"`
UserinfoSigningAlgorithm string `koanf:"userinfo_signing_algorithm"` UserinfoSigningAlgorithm string `koanf:"userinfo_signing_algorithm"`
Policy string `koanf:"authorization_policy"`
PreConfiguredConsentDuration *time.Duration `koanf:"pre_configured_consent_duration"`
} }
// DefaultOpenIDConnectConfiguration contains defaults for OIDC. // DefaultOpenIDConnectConfiguration contains defaults for OIDC.

View File

@ -495,6 +495,7 @@ var ValidKeys = []string{
"identity_providers.oidc.clients[].public", "identity_providers.oidc.clients[].public",
"identity_providers.oidc.clients[].redirect_uris", "identity_providers.oidc.clients[].redirect_uris",
"identity_providers.oidc.clients[].authorization_policy", "identity_providers.oidc.clients[].authorization_policy",
"identity_providers.oidc.clients[].pre_configured_consent_duration",
"identity_providers.oidc.clients[].scopes", "identity_providers.oidc.clients[].scopes",
"identity_providers.oidc.clients[].audience", "identity_providers.oidc.clients[].audience",
"identity_providers.oidc.clients[].grant_types", "identity_providers.oidc.clients[].grant_types",

View File

@ -3,6 +3,7 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"time"
"github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/middlewares"
"github.com/authelia/authelia/v4/internal/model" "github.com/authelia/authelia/v4/internal/model"
@ -78,6 +79,17 @@ func OpenIDConnectConsentPOST(ctx *middlewares.AutheliaCtx) {
return return
} }
if body.PreConfigure {
if client.PreConfiguredConsentDuration == nil {
ctx.Logger.Warnf("Consent session with challenge id '%s' for user '%s': consent pre-configuration was requested and was ignored because it is not permitted on this client", consent.ChallengeID.String(), userSession.Username)
} else {
expiresAt := time.Now().Add(*client.PreConfiguredConsentDuration)
consent.ExpiresAt = &expiresAt
ctx.Logger.Debugf("Consent session with challenge id '%s' for user '%s': pre-configured and set to expire at %v", consent.ChallengeID.String(), userSession.Username, consent.ExpiresAt)
}
}
consent.GrantedScopes = consent.RequestedScopes consent.GrantedScopes = consent.RequestedScopes
consent.GrantedAudience = consent.RequestedAudience consent.GrantedAudience = consent.RequestedAudience
@ -87,7 +99,7 @@ func OpenIDConnectConsentPOST(ctx *middlewares.AutheliaCtx) {
case reject: case reject:
authorized = false authorized = false
default: default:
ctx.Logger.Warnf("User '%s' tried to reply to consent with an unexpected verb", userSession.Username) ctx.Logger.Warnf("User '%s' tried to reply to consent with an unexpected verb '%s'", userSession.Username, body.AcceptOrReject)
ctx.ReplyBadRequest() ctx.ReplyBadRequest()
return return

View File

@ -18,8 +18,6 @@ func NewClient(config schema.OpenIDConnectClientConfiguration) (client *Client)
SectorIdentifier: config.SectorIdentifier.String(), SectorIdentifier: config.SectorIdentifier.String(),
Public: config.Public, Public: config.Public,
Policy: authorization.PolicyToLevel(config.Policy),
Audience: config.Audience, Audience: config.Audience,
Scopes: config.Scopes, Scopes: config.Scopes,
RedirectURIs: config.RedirectURIs, RedirectURIs: config.RedirectURIs,
@ -28,6 +26,10 @@ func NewClient(config schema.OpenIDConnectClientConfiguration) (client *Client)
ResponseModes: []fosite.ResponseModeType{fosite.ResponseModeDefault}, ResponseModes: []fosite.ResponseModeType{fosite.ResponseModeDefault},
UserinfoSigningAlgorithm: config.UserinfoSigningAlgorithm, UserinfoSigningAlgorithm: config.UserinfoSigningAlgorithm,
Policy: authorization.PolicyToLevel(config.Policy),
PreConfiguredConsentDuration: config.PreConfiguredConsentDuration,
} }
for _, mode := range config.ResponseModes { for _, mode := range config.ResponseModes {
@ -57,6 +59,7 @@ func (c Client) GetConsentResponseBody(consent *model.OAuth2ConsentSession) Cons
body := ConsentGetResponseBody{ body := ConsentGetResponseBody{
ClientID: c.ID, ClientID: c.ID,
ClientDescription: c.Description, ClientDescription: c.Description,
PreConfiguration: c.PreConfiguredConsentDuration != nil,
} }
if consent != nil { if consent != nil {

View File

@ -102,8 +102,6 @@ type Client struct {
SectorIdentifier string SectorIdentifier string
Public bool Public bool
Policy authorization.Level
Audience []string Audience []string
Scopes []string Scopes []string
RedirectURIs []string RedirectURIs []string
@ -112,6 +110,10 @@ type Client struct {
ResponseModes []fosite.ResponseModeType ResponseModes []fosite.ResponseModeType
UserinfoSigningAlgorithm string UserinfoSigningAlgorithm string
Policy authorization.Level
PreConfiguredConsentDuration *time.Duration
} }
// KeyManager keeps track of all of the active/inactive rsa keys and provides them to services requiring them. // KeyManager keeps track of all of the active/inactive rsa keys and provides them to services requiring them.
@ -132,12 +134,14 @@ type ConsentGetResponseBody struct {
ClientDescription string `json:"client_description"` ClientDescription string `json:"client_description"`
Scopes []string `json:"scopes"` Scopes []string `json:"scopes"`
Audience []string `json:"audience"` Audience []string `json:"audience"`
PreConfiguration bool `json:"pre_configuration"`
} }
// ConsentPostRequestBody schema of the request body of the consent POST endpoint. // ConsentPostRequestBody schema of the request body of the consent POST endpoint.
type ConsentPostRequestBody struct { type ConsentPostRequestBody struct {
ClientID string `json:"client_id"` ClientID string `json:"client_id"`
AcceptOrReject string `json:"accept_or_reject"` AcceptOrReject string `json:"accept_or_reject"`
PreConfigure bool `json:"pre_configure"`
} }
// ConsentPostResponseBody schema of the response body of the consent POST endpoint. // ConsentPostResponseBody schema of the response body of the consent POST endpoint.

View File

@ -62,6 +62,8 @@
"Must have at least one special character": "Must have at least one special character", "Must have at least one special character": "Must have at least one special character",
"Must be at least {{len}} characters in length": "Must be at least {{len}} characters in length", "Must be at least {{len}} characters in length": "Must be at least {{len}} characters in length",
"Must not be more than {{len}} characters in length": "Must not be more than {{len}} characters in length", "Must not be more than {{len}} characters in length": "Must not be more than {{len}} characters in length",
"This saves this consent as a pre-configured consent for future use": "This saves this consent as a pre-configured consent for future use",
"Remember Consent": "Remember Consent",
"Consent Request": "Consent Request", "Consent Request": "Consent Request",
"Client ID": "Client ID: {{client_id}}" "Client ID": "Client ID: {{client_id}}"
} }

View File

@ -1,6 +1,6 @@
import { useRemoteCall } from "@hooks/RemoteCall"; import { useRemoteCall } from "@hooks/RemoteCall";
import { getRequestedScopes } from "@services/Consent"; import { getConsentResponse } from "@services/Consent";
export function useRequestedScopes() { export function useConsentResponse() {
return useRemoteCall(getRequestedScopes, []); return useRemoteCall(getConsentResponse, []);
} }

View File

@ -4,6 +4,7 @@ import { Post, Get } from "@services/Client";
interface ConsentPostRequestBody { interface ConsentPostRequestBody {
client_id: string; client_id: string;
accept_or_reject: "accept" | "reject"; accept_or_reject: "accept" | "reject";
pre_configure: boolean;
} }
interface ConsentPostResponseBody { interface ConsentPostResponseBody {
@ -15,18 +16,23 @@ interface ConsentGetResponseBody {
client_description: string; client_description: string;
scopes: string[]; scopes: string[];
audience: string[]; audience: string[];
pre_configuration: boolean;
} }
export function getRequestedScopes() { export function getConsentResponse() {
return Get<ConsentGetResponseBody>(ConsentPath); return Get<ConsentGetResponseBody>(ConsentPath);
} }
export function acceptConsent(clientID: string) { export function acceptConsent(clientID: string, preConfigure: boolean) {
const body: ConsentPostRequestBody = { client_id: clientID, accept_or_reject: "accept" }; const body: ConsentPostRequestBody = {
client_id: clientID,
accept_or_reject: "accept",
pre_configure: preConfigure,
};
return Post<ConsentPostResponseBody>(ConsentPath, body); return Post<ConsentPostResponseBody>(ConsentPath, body);
} }
export function rejectConsent(clientID: string) { export function rejectConsent(clientID: string) {
const body: ConsentPostRequestBody = { client_id: clientID, accept_or_reject: "reject" }; const body: ConsentPostRequestBody = { client_id: clientID, accept_or_reject: "reject", pre_configure: false };
return Post<ConsentPostResponseBody>(ConsentPath, body); return Post<ConsentPostResponseBody>(ConsentPath, body);
} }

View File

@ -1,4 +1,4 @@
import React, { useEffect, Fragment, ReactNode } from "react"; import React, { useEffect, Fragment, ReactNode, useState } from "react";
import { import {
Button, Button,
@ -10,13 +10,15 @@ import {
Tooltip, Tooltip,
Typography, Typography,
makeStyles, makeStyles,
Checkbox,
FormControlLabel,
} from "@material-ui/core"; } from "@material-ui/core";
import { AccountBox, CheckBox, Contacts, Drafts, Group } from "@material-ui/icons"; import { AccountBox, CheckBox, Contacts, Drafts, Group } from "@material-ui/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { IndexRoute } from "@constants/Routes"; import { IndexRoute } from "@constants/Routes";
import { useRequestedScopes } from "@hooks/Consent"; import { useConsentResponse } from "@hooks/Consent";
import { useNotifications } from "@hooks/NotificationsContext"; import { useNotifications } from "@hooks/NotificationsContext";
import { useRedirector } from "@hooks/Redirector"; import { useRedirector } from "@hooks/Redirector";
import { useUserInfoGET } from "@hooks/UserInfo"; import { useUserInfoGET } from "@hooks/UserInfo";
@ -46,9 +48,15 @@ const ConsentView = function (props: Props) {
const navigate = useNavigate(); const navigate = useNavigate();
const redirect = useRedirector(); const redirect = useRedirector();
const { createErrorNotification, resetNotification } = useNotifications(); const { createErrorNotification, resetNotification } = useNotifications();
const [resp, fetch, , err] = useRequestedScopes(); const [resp, fetch, , err] = useConsentResponse();
const { t: translate } = useTranslation(); const { t: translate } = useTranslation();
const [preConfigure, setPreConfigure] = useState(false);
const handlePreConfigureChanged = () => {
setPreConfigure((preConfigure) => !preConfigure);
};
const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoGET(); const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoGET();
useEffect(() => { useEffect(() => {
@ -92,7 +100,7 @@ const ConsentView = function (props: Props) {
if (!resp) { if (!resp) {
return; return;
} }
const res = await acceptConsent(resp.client_id); const res = await acceptConsent(resp.client_id, preConfigure);
if (res.redirect_uri) { if (res.redirect_uri) {
redirect(res.redirect_uri); redirect(res.redirect_uri);
} else { } else {
@ -154,6 +162,30 @@ const ConsentView = function (props: Props) {
</List> </List>
</div> </div>
</Grid> </Grid>
{resp?.pre_configuration ? (
<Grid item xs={12}>
<Tooltip
title={
translate("This saves this consent as a pre-configured consent for future use") ||
"This saves this consent as a pre-configured consent for future use"
}
>
<FormControlLabel
control={
<Checkbox
id="pre-configure"
checked={preConfigure}
onChange={handlePreConfigureChanged}
value="preConfigure"
color="primary"
/>
}
className={classes.preConfigure}
label={translate("Remember Consent")}
/>
</Tooltip>
</Grid>
) : null}
<Grid item xs={12}> <Grid item xs={12}>
<Grid container spacing={1}> <Grid container spacing={1}>
<Grid item xs={6}> <Grid item xs={6}>
@ -226,6 +258,7 @@ const useStyles = makeStyles((theme) => ({
textAlign: "center", textAlign: "center",
marginRight: theme.spacing(2), marginRight: theme.spacing(2),
}, },
preConfigure: {},
})); }));
export default ConsentView; export default ConsentView;